added rest settings and resting display to ui

This commit is contained in:
Flaminel committed 2026-10-23 03:02:46 +03:00
1 parent aedbf92603
commit 7262270969
14 files changed
+154 -3

No files matched your search

@@ -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' },
];
@@ -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 {
@@ -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',
@@ -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);
});
});
@@ -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, string> = {
[SeekerExclusionReason.UpgradesDisabled]: 'Upgrades disabled',
[SeekerExclusionReason.InQueue]: 'In queue',
[SeekerExclusionReason.RecentlyGrabbed]: 'Recently grabbed',
[SeekerExclusionReason.Resting]: 'Resting after repeated misses',
[SeekerExclusionReason.SearchedThisCycle]: 'Searched this cycle',
};
@@ -425,6 +425,7 @@ describe('SearchesTabComponent', () => {
eligibleUnits: 37,
queuedUnits: 3,
recentlyGrabbedUnits: 1,
restingUnits: 0,
searchedUnits: 20,
remainingUnits: 13,
countedAt: '2026-07-30T10:00:00Z',
@@ -49,6 +49,9 @@
<li class="preview__row preview__row--excluded">
<span class="preview__name">{{ item.name }}</span>
<app-badge size="sm">{{ reasonLabels[item.reason] }}</app-badge>
@if (restDetail(item); as detail) {
<span class="preview__detail">{{ detail }}</span>
}
</li>
} @empty {
<li class="preview__note">No excluded items match.</li>
@@ -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;
@@ -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();
@@ -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<InstanceSearchStat | null>(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();
}
@@ -161,6 +161,23 @@
helpKey="seeker:recentGrabWindowHours"
featureId="seeker-recent-grab-window"
/>
<app-toggle label="Rest Items That Find Nothing" [checked]="instance.restUnitsEnabled"
hint="After 3 searches in a row find nothing, skip the item for 1 cycle, doubling with each further miss up to 8 cycles or the max rest days, whichever ends first. A grab clears it."
(checkedChange)="updateInstanceRestUnitsEnabled($index, $event)"
helpKey="seeker:restUnitsEnabled"
featureId="seeker-unit-rest" />
@if (instance.restUnitsEnabled) {
<app-number-input
label="Max Rest (Days)"
placeholder="30"
hint="A rest always ends after this many days, even if cycles are slow"
[value]="instance.restMaxDays"
[min]="1"
[max]="365"
(valueChange)="updateInstanceRestMaxDays($index, $event)"
helpKey="seeker:restMaxDays"
/>
}
<app-number-input
label="Min Cycle Time (Days)"
placeholder="7"
@@ -19,6 +19,8 @@ const SONARR: SeekerInstanceConfig = {
activeDownloadLimit: 3,
ignoreStruckDownloads: false,
recentGrabWindowHours: 24,
restUnitsEnabled: false,
restMaxDays: 30,
minCycleTimeDays: 7,
monitoredOnly: true,
useCutoff: false,
@@ -38,6 +40,8 @@ const RADARR: SeekerInstanceConfig = {
activeDownloadLimit: 5,
ignoreStruckDownloads: true,
recentGrabWindowHours: 48,
restUnitsEnabled: true,
restMaxDays: 60,
minCycleTimeDays: 14,
monitoredOnly: false,
useCutoff: true,
@@ -387,6 +391,48 @@ describe('SeekerComponent', () => {
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,
@@ -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,
@@ -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;