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:** ```