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 c572d006..0c43d3bf 100644 --- a/code/frontend/src/app/core/feature-badges/feature-registry.ts +++ b/code/frontend/src/app/core/feature-badges/feature-registry.ts @@ -64,4 +64,5 @@ export const NEW_FEATURES: NewFeature[] = [ { id: 'seeker-preview' }, { id: 'seeker-sonarr-search-mode' }, { id: 'seeker-cf-gap-strategy' }, + { id: 'seeker-unit-rest' }, ]; diff --git a/code/frontend/src/app/core/models/search-stats.models.ts b/code/frontend/src/app/core/models/search-stats.models.ts index da4f75b9..3a7d35f6 100644 --- a/code/frontend/src/app/core/models/search-stats.models.ts +++ b/code/frontend/src/app/core/models/search-stats.models.ts @@ -25,6 +25,7 @@ export interface InstanceFunnel { eligibleUnits: number; queuedUnits: number; recentlyGrabbedUnits: number; + restingUnits: number; searchedUnits: number; remainingUnits: number; countedAt: string; @@ -93,6 +94,7 @@ export enum SeekerExclusionReason { UpgradesDisabled = 'UpgradesDisabled', InQueue = 'InQueue', RecentlyGrabbed = 'RecentlyGrabbed', + Resting = 'Resting', SearchedThisCycle = 'SearchedThisCycle', } @@ -105,6 +107,9 @@ export interface SeekerPreviewUnit { export interface SeekerPreviewExclusion { name: string; reason: SeekerExclusionReason; + consecutiveMisses?: number; + restCyclesLeft?: number; + restUntil?: string; } export interface SeekerPreview { diff --git a/code/frontend/src/app/core/services/documentation.service.ts b/code/frontend/src/app/core/services/documentation.service.ts index e458b988..7101cfe2 100644 --- a/code/frontend/src/app/core/services/documentation.service.ts +++ b/code/frontend/src/app/core/services/documentation.service.ts @@ -245,6 +245,8 @@ export class DocumentationService { 'activeDownloadLimit': 'instance-active-download-limit', 'ignoreStruckDownloads': 'instance-ignore-struck-downloads', 'recentGrabWindowHours': 'instance-recent-grab-window', + 'restUnitsEnabled': 'instance-rest-items-that-find-nothing', + 'restMaxDays': 'instance-max-rest-days', 'minCycleTimeDays': 'instance-min-cycle-time-days', 'searchSpecials': 'instance-search-for-specials', 'searchAnime': 'instance-search-for-anime', diff --git a/code/frontend/src/app/features/seeker-stats/searches-tab/search-display.util.spec.ts b/code/frontend/src/app/features/seeker-stats/searches-tab/search-display.util.spec.ts index a50305c2..0c0ad3b9 100644 --- a/code/frontend/src/app/features/seeker-stats/searches-tab/search-display.util.spec.ts +++ b/code/frontend/src/app/features/seeker-stats/searches-tab/search-display.util.spec.ts @@ -152,6 +152,7 @@ describe('funnelStages', () => { eligibleUnits: 6, queuedUnits: 1, recentlyGrabbedUnits: 2, + restingUnits: 0, searchedUnits: 1, remainingUnits: 2, countedAt: '2026-07-30T10:00:00Z', @@ -165,4 +166,13 @@ describe('funnelStages', () => { it('splits the units needing a search by state', () => { expect(funnelStages(funnel, 'Sonarr').split.map((s) => s.value)).toEqual([1, 2, 1, 2]); }); + + it('adds the resting stage only when units are resting', () => { + expect(funnelStages(funnel, 'Sonarr').split.map((s) => s.label)).not.toContain('resting'); + + const split = funnelStages({ ...funnel, restingUnits: 4 }, 'Sonarr').split; + + expect(split.map((s) => s.label)).toEqual(['in queue', 'grabbed', 'resting', 'searched', 'left']); + expect(split[2].value).toBe(4); + }); }); diff --git a/code/frontend/src/app/features/seeker-stats/searches-tab/search-display.util.ts b/code/frontend/src/app/features/seeker-stats/searches-tab/search-display.util.ts index bcb2c783..981ec34b 100644 --- a/code/frontend/src/app/features/seeker-stats/searches-tab/search-display.util.ts +++ b/code/frontend/src/app/features/seeker-stats/searches-tab/search-display.util.ts @@ -84,6 +84,7 @@ export interface FunnelStage { * and the split of those units by state. */ export function funnelStages(funnel: InstanceFunnel, instanceType: string): { narrowing: FunnelStage[]; split: FunnelStage[] } { + const resting: FunnelStage[] = funnel.restingUnits > 0 ? [{ label: 'resting', value: funnel.restingUnits }] : []; return { narrowing: [ { label: instanceType === 'Radarr' ? 'movies' : 'series', value: funnel.libraryItems }, @@ -95,6 +96,7 @@ export function funnelStages(funnel: InstanceFunnel, instanceType: string): { na split: [ { label: 'in queue', value: funnel.queuedUnits }, { label: 'grabbed', value: funnel.recentlyGrabbedUnits }, + ...resting, { label: 'searched', value: funnel.searchedUnits }, { label: 'left', value: funnel.remainingUnits }, ], @@ -111,5 +113,6 @@ export const EXCLUSION_REASON_LABELS: Record = { [SeekerExclusionReason.UpgradesDisabled]: 'Upgrades disabled', [SeekerExclusionReason.InQueue]: 'In queue', [SeekerExclusionReason.RecentlyGrabbed]: 'Recently grabbed', + [SeekerExclusionReason.Resting]: 'Resting after repeated misses', [SeekerExclusionReason.SearchedThisCycle]: 'Searched this cycle', }; diff --git a/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.spec.ts b/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.spec.ts index b2fc07bd..54ec49c1 100644 --- a/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.spec.ts +++ b/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.spec.ts @@ -425,6 +425,7 @@ describe('SearchesTabComponent', () => { eligibleUnits: 37, queuedUnits: 3, recentlyGrabbedUnits: 1, + restingUnits: 0, searchedUnits: 20, remainingUnits: 13, countedAt: '2026-07-30T10:00:00Z', diff --git a/code/frontend/src/app/features/seeker-stats/searches-tab/seeker-preview-drawer/seeker-preview-drawer.component.html b/code/frontend/src/app/features/seeker-stats/searches-tab/seeker-preview-drawer/seeker-preview-drawer.component.html index 415c556b..91e6986b 100644 --- a/code/frontend/src/app/features/seeker-stats/searches-tab/seeker-preview-drawer/seeker-preview-drawer.component.html +++ b/code/frontend/src/app/features/seeker-stats/searches-tab/seeker-preview-drawer/seeker-preview-drawer.component.html @@ -49,6 +49,9 @@
  • {{ item.name }} {{ reasonLabels[item.reason] }} + @if (restDetail(item); as detail) { + {{ detail }} + }
  • } @empty {
  • No excluded items match.
  • diff --git a/code/frontend/src/app/features/seeker-stats/searches-tab/seeker-preview-drawer/seeker-preview-drawer.component.scss b/code/frontend/src/app/features/seeker-stats/searches-tab/seeker-preview-drawer/seeker-preview-drawer.component.scss index 84bd8cb2..60ed030f 100644 --- a/code/frontend/src/app/features/seeker-stats/searches-tab/seeker-preview-drawer/seeker-preview-drawer.component.scss +++ b/code/frontend/src/app/features/seeker-stats/searches-tab/seeker-preview-drawer/seeker-preview-drawer.component.scss @@ -66,12 +66,19 @@ &__row { display: flex; align-items: center; + flex-wrap: wrap; gap: var(--space-2); padding: var(--space-1-5) 0; border-bottom: 1px solid var(--divider); font-size: var(--font-size-sm); } + &__detail { + flex-basis: 100%; + font-size: var(--font-size-xs); + color: var(--text-tertiary); + } + &__name { flex: 1; min-width: 0; diff --git a/code/frontend/src/app/features/seeker-stats/searches-tab/seeker-preview-drawer/seeker-preview-drawer.component.spec.ts b/code/frontend/src/app/features/seeker-stats/searches-tab/seeker-preview-drawer/seeker-preview-drawer.component.spec.ts index a2954b54..ce02f37f 100644 --- a/code/frontend/src/app/features/seeker-stats/searches-tab/seeker-preview-drawer/seeker-preview-drawer.component.spec.ts +++ b/code/frontend/src/app/features/seeker-stats/searches-tab/seeker-preview-drawer/seeker-preview-drawer.component.spec.ts @@ -14,6 +14,7 @@ const FUNNEL: InstanceFunnel = { eligibleUnits: 60, queuedUnits: 5, recentlyGrabbedUnits: 4, + restingUnits: 0, searchedUnits: 30, remainingUnits: 21, countedAt: '2026-07-30T10:00:00Z', @@ -115,6 +116,28 @@ describe('SeekerPreviewDrawerComponent', () => { expect(getPreview).not.toHaveBeenCalled(); }); + it('renders the label and detail line for a resting exclusion', () => { + const { fixture, host } = setup(of(preview({ + excluded: [ + { name: 'Zeta', reason: SeekerExclusionReason.Resting, consecutiveMisses: 5, restCyclesLeft: 2, restUntil: '2026-08-15T12:00:00Z' }, + { name: 'Eta', reason: SeekerExclusionReason.Resting, consecutiveMisses: 3, restCyclesLeft: 1 }, + { name: 'Theta', reason: SeekerExclusionReason.NotMonitored }, + ], + }))); + + open(fixture, host); + + expect(texts('.preview__row--excluded app-badge')).toEqual([ + 'Resting after repeated misses', + 'Resting after repeated misses', + 'Not monitored', + ]); + expect(texts('.preview__detail')).toEqual([ + '5 misses in a row · back after 2 cycles end or on 08/15', + '3 misses in a row · back after 1 cycle end', + ]); + }); + it('requests the preview for the instance when opened', () => { const { fixture, host, getPreview } = setup(); diff --git a/code/frontend/src/app/features/seeker-stats/searches-tab/seeker-preview-drawer/seeker-preview-drawer.component.ts b/code/frontend/src/app/features/seeker-stats/searches-tab/seeker-preview-drawer/seeker-preview-drawer.component.ts index b98a93b5..650bd1be 100644 --- a/code/frontend/src/app/features/seeker-stats/searches-tab/seeker-preview-drawer/seeker-preview-drawer.component.ts +++ b/code/frontend/src/app/features/seeker-stats/searches-tab/seeker-preview-drawer/seeker-preview-drawer.component.ts @@ -1,13 +1,14 @@ -import { Component, ChangeDetectionStrategy, inject, input, model, computed, linkedSignal } from '@angular/core'; +import { Component, ChangeDetectionStrategy, LOCALE_ID, inject, input, model, computed, linkedSignal } from '@angular/core'; import { rxResource } from '@angular/core/rxjs-interop'; -import { DatePipe } from '@angular/common'; +import { DatePipe, formatDate } from '@angular/common'; import { BadgeComponent, ButtonComponent, DrawerComponent, EmptyStateComponent, InputComponent, PaginatorComponent, SelectComponent, SpinnerComponent, } from '@ui'; import type { SelectOption } from '@ui'; import { SearchStatsApi } from '@core/api/search-stats.api'; -import type { InstanceSearchStat, SeekerExclusionReason, SeekerPreview } from '@core/models/search-stats.models'; +import { SeekerExclusionReason } from '@core/models/search-stats.models'; +import type { InstanceSearchStat, SeekerPreview, SeekerPreviewExclusion } from '@core/models/search-stats.models'; import { EXCLUSION_REASON_LABELS, formatSearchReason, funnelStages, searchReasonSeverity } from '../search-display.util'; const DEFAULT_PAGE_SIZE = 25; @@ -32,6 +33,7 @@ const DEFAULT_PAGE_SIZE = 25; }) export class SeekerPreviewDrawerComponent { private readonly api = inject(SearchStatsApi); + private readonly locale = inject(LOCALE_ID); readonly instance = input(null); readonly visible = model(false); @@ -110,6 +112,15 @@ export class SeekerPreviewDrawerComponent { this.reasonFilter.set(value as SeekerExclusionReason | ''); } + restDetail(item: SeekerPreviewExclusion): string { + if (item.reason !== SeekerExclusionReason.Resting) { + return ''; + } + const cycles = item.restCyclesLeft ?? 0; + const until = item.restUntil ? ` or on ${formatDate(item.restUntil, 'MM/dd', this.locale)}` : ''; + return `${item.consecutiveMisses ?? 0} misses in a row · back after ${cycles} ${cycles === 1 ? 'cycle' : 'cycles'} end${until}`; + } + refresh(): void { this.previewResource.reload(); } 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 5d9e41a9..ef069298 100644 --- a/code/frontend/src/app/features/settings/seeker/seeker.component.html +++ b/code/frontend/src/app/features/settings/seeker/seeker.component.html @@ -161,6 +161,23 @@ helpKey="seeker:recentGrabWindowHours" featureId="seeker-recent-grab-window" /> + + @if (instance.restUnitsEnabled) { + + } { expect(component.hasPendingChanges()).toBe(false); }); + it('loads the rest toggle and saves it when flipped', () => { + const { fixture, component, updateConfig } = setup(); + const toggle = toggleByLabel(fixture, 'Rest Items That Find Nothing'); + + expect(toggle.getAttribute('aria-checked')).toBe('false'); + expect(component.instances()[1].restUnitsEnabled).toBe(true); + + toggle.click(); + fixture.detectChanges(); + saveButton(fixture).click(); + fixture.detectChanges(); + + expect(updateConfig.mock.calls[0][0].instances[0].restUnitsEnabled).toBe(true); + }); + + it('shows the max rest days input only while the rest toggle is on and saves it', () => { + const { fixture, component, updateConfig } = setup(); + + expect(text(fixture)).not.toContain('Max Rest (Days)'); + + toggleByLabel(fixture, 'Rest Items That Find Nothing').click(); + fixture.detectChanges(); + + expect(numberField(fixture, 'Max Rest').value).toBe('30'); + + setNumberField(fixture, 'Max Rest', '45'); + expect(component.instances()[0].restMaxDays).toBe(45); + + saveButton(fixture).click(); + fixture.detectChanges(); + + expect(updateConfig.mock.calls[0][0].instances[0].restMaxDays).toBe(45); + + setNumberField(fixture, 'Max Rest', ''); + expect(component.instances()[0].restMaxDays).toBe(30); + + toggleByLabel(fixture, 'Rest Items That Find Nothing').click(); + fixture.detectChanges(); + + expect(text(fixture)).not.toContain('Max Rest (Days)'); + }); + it('patches the remaining instance toggles from the DOM', () => { const { fixture, component } = setup(); @@ -607,6 +653,8 @@ describe('SeekerComponent', () => { activeDownloadLimit: 3, ignoreStruckDownloads: true, recentGrabWindowHours: 48, + restUnitsEnabled: false, + restMaxDays: 30, minCycleTimeDays: 7, monitoredOnly: true, useCutoff: false, @@ -621,6 +669,8 @@ describe('SeekerComponent', () => { activeDownloadLimit: 5, ignoreStruckDownloads: true, recentGrabWindowHours: 48, + restUnitsEnabled: true, + restMaxDays: 60, minCycleTimeDays: 14, monitoredOnly: false, useCutoff: true, 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 24a90095..f4c09d05 100644 --- a/code/frontend/src/app/features/settings/seeker/seeker.component.ts +++ b/code/frontend/src/app/features/settings/seeker/seeker.component.ts @@ -84,6 +84,8 @@ interface InstanceState { activeDownloadLimit: number; ignoreStruckDownloads: boolean; recentGrabWindowHours: number; + restUnitsEnabled: boolean; + restMaxDays: number; minCycleTimeDays: number; monitoredOnly: boolean; useCutoff: boolean; @@ -185,6 +187,8 @@ export class SeekerComponent implements HasPendingChanges { activeDownloadLimit: i.activeDownloadLimit, ignoreStruckDownloads: i.ignoreStruckDownloads, recentGrabWindowHours: i.recentGrabWindowHours, + restUnitsEnabled: i.restUnitsEnabled, + restMaxDays: i.restMaxDays, minCycleTimeDays: i.minCycleTimeDays, monitoredOnly: i.monitoredOnly, useCutoff: i.useCutoff, @@ -236,6 +240,14 @@ export class SeekerComponent implements HasPendingChanges { this.patchInstance(index, { recentGrabWindowHours: hours ?? 24 }); } + updateInstanceRestUnitsEnabled(index: number, enabled: boolean): void { + this.patchInstance(index, { restUnitsEnabled: enabled }); + } + + updateInstanceRestMaxDays(index: number, days: number | null): void { + this.patchInstance(index, { restMaxDays: days ?? 30 }); + } + updateInstanceSonarrSearchMode(index: number, mode: unknown): void { this.patchInstance(index, { sonarrSearchMode: mode as SonarrSearchMode }); } @@ -263,6 +275,8 @@ export class SeekerComponent implements HasPendingChanges { activeDownloadLimit: i.activeDownloadLimit, ignoreStruckDownloads: i.ignoreStruckDownloads, recentGrabWindowHours: i.recentGrabWindowHours, + restUnitsEnabled: i.restUnitsEnabled, + restMaxDays: i.restMaxDays, minCycleTimeDays: i.minCycleTimeDays, monitoredOnly: i.monitoredOnly, useCutoff: i.useCutoff, 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 628c4e29..97b4c6db 100644 --- a/code/frontend/src/app/shared/models/seeker-config.model.ts +++ b/code/frontend/src/app/shared/models/seeker-config.model.ts @@ -22,6 +22,8 @@ export interface SeekerInstanceConfig { activeDownloadLimit: number; ignoreStruckDownloads: boolean; recentGrabWindowHours: number; + restUnitsEnabled: boolean; + restMaxDays: number; minCycleTimeDays: number; monitoredOnly: boolean; useCutoff: boolean; @@ -47,6 +49,8 @@ export interface UpdateSeekerInstanceConfig { activeDownloadLimit: number; ignoreStruckDownloads: boolean; recentGrabWindowHours: number; + restUnitsEnabled: boolean; + restMaxDays: number; minCycleTimeDays: number; monitoredOnly: boolean; useCutoff: boolean;