diff --git a/code/frontend/src/app/core/feature-badges/feature-registry.ts b/code/frontend/src/app/core/feature-badges/feature-registry.ts
index debca42b..c8015252 100644
--- a/code/frontend/src/app/core/feature-badges/feature-registry.ts
+++ b/code/frontend/src/app/core/feature-badges/feature-registry.ts
@@ -53,4 +53,5 @@ export const NEW_FEATURES: NewFeature[] = [
{ id: 'qbit-api-key' },
{ id: 'theme-follow-system' },
{ id: 'errored-downloads' },
+ { id: 'seeker-include-tags' },
];
diff --git a/code/frontend/src/app/core/services/documentation.service.ts b/code/frontend/src/app/core/services/documentation.service.ts
index 25cb015f..53d3445d 100644
--- a/code/frontend/src/app/core/services/documentation.service.ts
+++ b/code/frontend/src/app/core/services/documentation.service.ts
@@ -239,6 +239,7 @@ export class DocumentationService {
'postReleaseGraceHours': 'post-release-grace-period',
'missingSearchEnabled': 'enable-missing-search',
'enabled': 'instance-enabled',
+ 'includeTags': 'instance-include-tags',
'skipTags': 'instance-skip-tags',
'activeDownloadLimit': 'instance-active-download-limit',
'ignoreStruckDownloads': 'instance-ignore-struck-downloads',
diff --git a/code/frontend/src/app/features/settings/seeker/seeker.component.html b/code/frontend/src/app/features/settings/seeker/seeker.component.html
index 0d829191..0075a5c9 100644
--- a/code/frontend/src/app/features/settings/seeker/seeker.component.html
+++ b/code/frontend/src/app/features/settings/seeker/seeker.component.html
@@ -102,6 +102,15 @@
hint="Search for upgrades when a file's custom format score is below the quality profile's cutoff format score"
(checkedChange)="patchInstance($index, { useCustomFormatScore: $event })"
helpKey="seeker:useCustomFormatScore" />
+
{
return host.querySelector('.number-field') as HTMLInputElement;
}
+ function chipField(fixture: ComponentFixture, label: string): HTMLElement {
+ return Array.from(fixture.nativeElement.querySelectorAll('app-chip-input')).find((input) =>
+ (input as HTMLElement).querySelector('.chip-label')?.textContent?.includes(label),
+ ) as HTMLElement;
+ }
+
+ function chipTexts(field: HTMLElement): string[] {
+ return Array.from(field.querySelectorAll('.chip')).map((chip) =>
+ (chip as HTMLElement).textContent!.replace('×', '').trim(),
+ );
+ }
+
function setNumberField(fixture: ComponentFixture, label: string, value: string): void {
const field = numberField(fixture, label);
field.value = value;
@@ -151,8 +165,8 @@ describe('SeekerComponent', () => {
expect(component.seekerForm.postReleaseGraceHours().value()).toBe(6);
expect(component.instances()).toEqual([
- { ...SONARR, skipTags: ['alpha', 'beta'] },
- { ...RADARR, skipTags: [] },
+ { ...SONARR, skipTags: ['alpha', 'beta'], includeTags: ['wanted'] },
+ { ...RADARR, skipTags: [], includeTags: [] },
]);
expect(component.dirty()).toBe(false);
expect(component.hasPendingChanges()).toBe(false);
@@ -160,23 +174,22 @@ describe('SeekerComponent', () => {
expect(text(fixture)).toContain('Search for missing items and quality upgrades');
});
- it('copies the loaded skip tags instead of sharing the response array', () => {
+ it('copies the loaded tags instead of sharing the response arrays', () => {
const { component } = setup();
- component.patchInstance(0, { skipTags: ['gamma'] });
+ component.patchInstance(0, { skipTags: ['gamma'], includeTags: ['delta'] });
expect(SONARR.skipTags).toEqual(['alpha', 'beta']);
+ expect(SONARR.includeTags).toEqual(['wanted']);
expect(component.instances()[0].skipTags).toEqual(['gamma']);
+ expect(component.instances()[0].includeTags).toEqual(['delta']);
});
- it('renders the skip tag chips and the per-instance detail controls', () => {
+ it('renders the include and skip tag chips and the per-instance detail controls', () => {
const { fixture } = setup();
- const chips = Array.from(fixture.nativeElement.querySelectorAll('.chip')).map((chip) =>
- (chip as HTMLElement).textContent!.replace('×', '').trim(),
- );
-
- expect(chips).toEqual(['alpha', 'beta']);
+ expect(chipTexts(chipField(fixture, 'Include Tags'))).toEqual(['wanted']);
+ expect(chipTexts(chipField(fixture, 'Skip Tags'))).toEqual(['alpha', 'beta']);
expect(toggleByLabel(fixture, 'Monitored Only')).not.toBeNull();
expect(toggleByLabel(fixture, 'Use Cutoff')).not.toBeNull();
expect(toggleByLabel(fixture, 'Use Custom Format Score')).not.toBeNull();
@@ -382,7 +395,8 @@ describe('SeekerComponent', () => {
it('patches the skip tags edited in the chip input', () => {
const { fixture, component } = setup();
- const input = fixture.nativeElement.querySelector('.chip-input') as HTMLInputElement;
+ const field = chipField(fixture, 'Skip Tags');
+ const input = field.querySelector('.chip-input') as HTMLInputElement;
input.value = 'gamma';
input.dispatchEvent(new Event('input'));
@@ -392,12 +406,28 @@ describe('SeekerComponent', () => {
expect(component.instances()[0].skipTags).toEqual(['alpha', 'beta', 'gamma']);
- (fixture.nativeElement.querySelector('.chip__remove') as HTMLButtonElement).click();
+ (field.querySelector('.chip__remove') as HTMLButtonElement).click();
fixture.detectChanges();
expect(component.instances()[0].skipTags).toEqual(['beta', 'gamma']);
});
+ it('patches the include tags edited in the chip input', () => {
+ const { fixture, component } = setup();
+ const field = chipField(fixture, 'Include Tags');
+ const input = field.querySelector('.chip-input') as HTMLInputElement;
+
+ input.value = 'anime';
+ input.dispatchEvent(new Event('input'));
+ fixture.detectChanges();
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
+ fixture.detectChanges();
+
+ expect(component.instances()[0].includeTags).toEqual(['wanted', 'anime']);
+ expect(component.instances()[0].skipTags).toEqual(['alpha', 'beta']);
+ expect(component.dirty()).toBe(true);
+ });
+
it('blocks saving while proactive search has no enabled instance', () => {
const { fixture, component } = setup();
@@ -457,7 +487,7 @@ describe('SeekerComponent', () => {
component.seekerForm.searchInterval().value.set(10);
component.seekerForm.selectionStrategy().value.set(SelectionStrategy.NewestFirst);
component.seekerForm.postReleaseGraceHours().value.set(24);
- component.patchInstance(0, { skipTags: ['gamma'], ignoreStruckDownloads: true });
+ component.patchInstance(0, { skipTags: ['gamma'], includeTags: ['anime'], ignoreStruckDownloads: true });
fixture.detectChanges();
expect(component.dirty()).toBe(true);
@@ -477,6 +507,7 @@ describe('SeekerComponent', () => {
arrInstanceId: SONARR.arrInstanceId,
enabled: true,
skipTags: ['gamma'],
+ includeTags: ['anime'],
activeDownloadLimit: 3,
ignoreStruckDownloads: true,
minCycleTimeDays: 7,
@@ -488,6 +519,7 @@ describe('SeekerComponent', () => {
arrInstanceId: RADARR.arrInstanceId,
enabled: false,
skipTags: [],
+ includeTags: [],
activeDownloadLimit: 5,
ignoreStruckDownloads: true,
minCycleTimeDays: 14,
diff --git a/code/frontend/src/app/features/settings/seeker/seeker.component.ts b/code/frontend/src/app/features/settings/seeker/seeker.component.ts
index 6d5ece5d..8ef9e233 100644
--- a/code/frontend/src/app/features/settings/seeker/seeker.component.ts
+++ b/code/frontend/src/app/features/settings/seeker/seeker.component.ts
@@ -70,6 +70,7 @@ interface InstanceState {
instanceType: string;
enabled: boolean;
skipTags: string[];
+ includeTags: string[];
lastProcessedAt?: string;
arrInstanceEnabled: boolean;
activeDownloadLimit: number;
@@ -167,6 +168,7 @@ export class SeekerComponent implements HasPendingChanges {
instanceType: i.instanceType,
enabled: i.enabled,
skipTags: [...i.skipTags],
+ includeTags: [...i.includeTags],
lastProcessedAt: i.lastProcessedAt,
arrInstanceEnabled: i.arrInstanceEnabled,
activeDownloadLimit: i.activeDownloadLimit,
@@ -236,6 +238,7 @@ export class SeekerComponent implements HasPendingChanges {
arrInstanceId: i.arrInstanceId,
enabled: i.enabled,
skipTags: i.skipTags,
+ includeTags: i.includeTags,
activeDownloadLimit: i.activeDownloadLimit,
ignoreStruckDownloads: i.ignoreStruckDownloads,
minCycleTimeDays: i.minCycleTimeDays,
diff --git a/code/frontend/src/app/shared/models/seeker-config.model.ts b/code/frontend/src/app/shared/models/seeker-config.model.ts
index 3c84f993..fc74eebf 100644
--- a/code/frontend/src/app/shared/models/seeker-config.model.ts
+++ b/code/frontend/src/app/shared/models/seeker-config.model.ts
@@ -16,6 +16,7 @@ export interface SeekerInstanceConfig {
instanceType: string;
enabled: boolean;
skipTags: string[];
+ includeTags: string[];
lastProcessedAt?: string;
arrInstanceEnabled: boolean;
activeDownloadLimit: number;
@@ -40,6 +41,7 @@ export interface UpdateSeekerInstanceConfig {
arrInstanceId: string;
enabled: boolean;
skipTags: string[];
+ includeTags: string[];
activeDownloadLimit: number;
ignoreStruckDownloads: boolean;
minCycleTimeDays: number;
diff --git a/docs/docs/configuration/seeker/index.mdx b/docs/docs/configuration/seeker/index.mdx
index b11f245f..daf83e4a 100644
--- a/docs/docs/configuration/seeker/index.mdx
+++ b/docs/docs/configuration/seeker/index.mdx
@@ -182,11 +182,27 @@ Enables this instance for proactive search. When disabled, the instance will not
+
+
+Arr tags that limit proactive search. When this list has tags, only a movie or series carrying at least one of them gets searched. Leave it empty to search every item.
+
+Skip Tags take precedence: an item carrying both an include tag and a skip tag is skipped.
+
+**Examples:**
+```
+anime
+4k
+```
+
+
+
-Arr tags to exclude from search. Any movie or series in the corresponding *arr application that has one of these tags assigned will be skipped entirely during proactive search.
+Arr tags to exclude from search. Any movie or series in the corresponding *arr application that has one of these tags assigned will be skipped entirely during proactive search, even when it also carries an include tag.
**Examples:**
```