mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-10-28 10:48:49 -04:00
added rest settings and resting display to ui
This commit is contained in:
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',
|
||||
};
|
||||
+1
@@ -425,6 +425,7 @@ describe('SearchesTabComponent', () => {
|
||||
eligibleUnits: 37,
|
||||
queuedUnits: 3,
|
||||
recentlyGrabbedUnits: 1,
|
||||
restingUnits: 0,
|
||||
searchedUnits: 20,
|
||||
remainingUnits: 13,
|
||||
countedAt: '2026-07-30T10:00:00Z',
|
||||
|
||||
+3
@@ -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>
|
||||
|
||||
+7
@@ -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;
|
||||
|
||||
+23
@@ -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();
|
||||
|
||||
|
||||
+14
-3
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user