feat: implement basic cooldown, remove old time based one

This commit is contained in:
Sam Chau
2026-01-22 11:37:05 +10:30
parent ac9dea7186
commit 6577174a22
13 changed files with 875 additions and 126 deletions

View File

@@ -2,12 +2,6 @@
# Feedback from Serpahys
- add tags to filters for upgrades
- upradinatorr filter should be default - both when adding new config and when
an arr is added (run a transformation on the database when an arr is added to
add the default config, but keep upgrades disabled)
- cutoff % should be 100 or removed entirely
- cooldown maybe too complicated
- cache fetches from github on things that dont change often (images, stats for
example)
- rethink job polling architecture
@@ -165,10 +159,3 @@ Run 50: matched=50, afterCooldown=0
Run N: sleeping, dry run → matched=70 (was 50)
WAKE UP!
```
# Upgrades Info
Move info to upgrades/info to use full page. Modal is too small
- using a table maybe?
- examples?

View File

@@ -32,12 +32,15 @@ import { migration as migration027 } from './migrations/027_create_rename_runs.t
import { migration as migration028 } from './migrations/028_simplify_delay_profile_sync.ts';
import { migration as migration029 } from './migrations/029_add_database_id_foreign_keys.ts';
import { migration as migration030 } from './migrations/030_create_general_settings.ts';
import { migration as migration031 } from './migrations/031_remove_search_cooldown.ts';
import { migration as migration032 } from './migrations/032_add_filter_id_to_upgrade_runs.ts';
export interface Migration {
version: number;
name: string;
up: string;
down?: string;
afterUp?: () => void; // Optional callback for data migrations
}
/**
@@ -97,6 +100,11 @@ class MigrationRunner {
migration.name
);
});
// Run data migration callback if present (outside transaction)
if (migration.afterUp) {
migration.afterUp();
}
} catch (error) {
await logger.error(`Failed to apply migration ${migration.version}: ${migration.name}`, {
source: 'DatabaseMigrations',
@@ -276,7 +284,9 @@ export function loadMigrations(): Migration[] {
migration027,
migration028,
migration029,
migration030
migration030,
migration031,
migration032
];
// Sort by version number

View File

@@ -0,0 +1,76 @@
import type { Migration } from '../migrations.ts';
import { db } from '../db.ts';
/**
* Migration 031: Remove searchCooldown from filter configs
*
* The cooldown system is being simplified to use filter-level tags
* instead of time-based cooldowns. This migration removes the
* searchCooldown field from all existing filter configurations.
*/
interface LegacyFilterConfig {
id: string;
name: string;
enabled: boolean;
group: unknown;
selector: string;
count: number;
cutoff: number;
searchCooldown?: number; // Field being removed
}
interface UpgradeConfigRow {
id: number;
filters: string;
}
/**
* Remove searchCooldown from filter configs
*/
function migrateFilterConfigs(): void {
const rows = db.query<UpgradeConfigRow>('SELECT id, filters FROM upgrade_configs');
for (const row of rows) {
try {
const filters = JSON.parse(row.filters) as LegacyFilterConfig[];
let modified = false;
const updatedFilters = filters.map((filter) => {
if ('searchCooldown' in filter) {
modified = true;
const { searchCooldown: _, ...rest } = filter;
return rest;
}
return filter;
});
if (modified) {
db.execute(
'UPDATE upgrade_configs SET filters = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
JSON.stringify(updatedFilters),
row.id
);
}
} catch {
// Skip rows with invalid JSON - shouldn't happen but be safe
}
}
}
export const migration: Migration = {
version: 31,
name: 'Remove searchCooldown from filter configs',
up: `
-- Data migration handled by afterUp callback
SELECT 1;
`,
down: `
-- Cannot restore removed searchCooldown values
SELECT 1;
`,
afterUp: migrateFilterConfigs
};

View File

@@ -0,0 +1,26 @@
import type { Migration } from '../migrations.ts';
/**
* Migration 032: Add filter_id to upgrade_runs table
*
* Replaces the cooldown_hours column with filter_id.
* The cooldown system now uses filter-level tags instead of time-based cooldown.
*
* - Adds filter_id TEXT column to track which filter was used
* - cooldown_hours is deprecated but kept for backwards compatibility
*/
export const migration: Migration = {
version: 32,
name: 'Add filter_id to upgrade_runs',
up: `
ALTER TABLE upgrade_runs ADD COLUMN filter_id TEXT NOT NULL DEFAULT '';
`,
down: `
-- SQLite doesn't support dropping columns easily
-- The column will remain but won't be used
SELECT 1;
`
};

View File

@@ -14,12 +14,12 @@ interface UpgradeRunRow {
schedule: number;
filter_mode: string;
filter_name: string;
filter_id: string;
library_total: number;
library_cached: number;
library_fetch_ms: number;
matched_count: number;
after_cooldown: number;
cooldown_hours: number;
dry_run_excluded: number;
selection_method: string;
selection_requested: number;
@@ -60,11 +60,11 @@ function rowToLog(row: UpgradeRunRow): UpgradeJobLog {
},
filter: {
id: row.filter_id,
name: row.filter_name,
rules: { type: 'group', match: 'all', children: [] }, // Not stored, too complex
matchedCount: row.matched_count,
afterCooldown: row.after_cooldown,
cooldownHours: row.cooldown_hours,
dryRunExcluded: row.dry_run_excluded
},
@@ -95,13 +95,13 @@ export const upgradeRunsQueries = {
db.execute(
`INSERT INTO upgrade_runs (
id, instance_id, started_at, completed_at, status, dry_run,
schedule, filter_mode, filter_name,
schedule, filter_mode, filter_name, filter_id,
library_total, library_cached, library_fetch_ms,
matched_count, after_cooldown, cooldown_hours, dry_run_excluded,
selection_method, selection_requested, selected_count,
searches_triggered, successful, failed,
items, errors
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
log.id,
log.instanceId,
log.startedAt,
@@ -111,12 +111,13 @@ export const upgradeRunsQueries = {
log.config.schedule,
log.config.filterMode,
log.config.selectedFilter,
log.filter.id,
log.library.totalItems,
log.library.fetchedFromCache ? 1 : 0,
log.library.fetchDurationMs,
log.filter.matchedCount,
log.filter.afterCooldown,
log.filter.cooldownHours,
0, // cooldown_hours deprecated, kept for backwards compatibility
log.filter.dryRunExcluded,
log.selection.method,
log.selection.requestedCount,

View File

@@ -1,74 +1,61 @@
/**
* Tag-based cooldown tracking for upgrade searches
*
* Uses tags in the format: profilarr-searched-YYYY-MM-DD
* This allows checking if an item was searched within the cooldown window
* Basic mode (current implementation):
* - Uses filter-level tags: profilarr-filter-{filterId}
* - Items are tagged when searched
* - Tagged items are skipped on subsequent runs
* - When filter is exhausted (no untagged items), all tags are cleared to reset
*
* Future: Advanced mode with adaptive backoff per scratchpad.md
*/
import type { RadarrTag, RadarrMovie } from '$lib/server/utils/arr/types.ts';
import type { RadarrClient } from '$lib/server/utils/arr/clients/radarr.ts';
const TAG_PREFIX = 'profilarr-searched-';
const FILTER_TAG_PREFIX = 'profilarr-';
/**
* Get today's cooldown tag label
* Slugify a filter name for use in tags
* Converts "Things I Don't Want to Upgrade" -> "things-i-dont-want-to-upgrade"
*/
export function getTodayTagLabel(): string {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
return `${TAG_PREFIX}${year}-${month}-${day}`;
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/['']/g, '') // Remove apostrophes
.replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with dashes
.replace(/^-+|-+$/g, '') // Trim leading/trailing dashes
.slice(0, 50); // Limit length
}
/**
* Parse a date from a profilarr search tag label
* Returns null if not a valid profilarr tag
* Get the tag label for a specific filter
*/
function parseDateFromTagLabel(label: string): Date | null {
if (!label.startsWith(TAG_PREFIX)) {
return null;
}
const dateStr = label.slice(TAG_PREFIX.length);
const parsed = new Date(dateStr);
// Check if valid date
if (isNaN(parsed.getTime())) {
return null;
}
return parsed;
export function getFilterTagLabel(filterName: string): string {
return `${FILTER_TAG_PREFIX}${slugify(filterName)}`;
}
/**
* Check if an item is on cooldown based on its tags
*
* @param itemTagIds - The tag IDs on the item
* @param allTags - All tags from the arr instance
* @param cooldownHours - The cooldown period in hours
* Check if a tag label is a profilarr filter tag
*/
export function isOnCooldown(
export function isFilterTag(label: string): boolean {
return label.startsWith(FILTER_TAG_PREFIX);
}
/**
* Check if an item has a specific filter's tag
*/
export function hasFilterTag(
itemTagIds: number[],
allTags: RadarrTag[],
cooldownHours: number
filterName: string
): boolean {
const now = new Date();
const cooldownMs = cooldownHours * 60 * 60 * 1000;
// Create a lookup for tag IDs to labels
const targetLabel = getFilterTagLabel(filterName);
const tagMap = new Map(allTags.map((t) => [t.id, t.label]));
for (const tagId of itemTagIds) {
const label = tagMap.get(tagId);
if (!label) continue;
const tagDate = parseDateFromTagLabel(label);
if (!tagDate) continue;
// Check if the tag date is within the cooldown window
const diffMs = now.getTime() - tagDate.getTime();
if (diffMs <= cooldownMs) {
if (label === targetLabel) {
return true;
}
}
@@ -77,35 +64,26 @@ export function isOnCooldown(
}
/**
* Filter items that are NOT on cooldown
*
* @param items - Items with _tags property
* @param allTags - All tags from the arr instance
* @param cooldownHours - The cooldown period in hours
* Filter items that do NOT have the filter's tag
* Returns only items eligible for searching (not yet searched by this filter)
*/
export function filterByCooldown<T extends { _tags: number[] }>(
export function filterByFilterTag<T extends { _tags: number[] }>(
items: T[],
allTags: RadarrTag[],
cooldownHours: number
filterName: string
): T[] {
if (cooldownHours <= 0) {
// No cooldown, return all items
return items;
}
return items.filter((item) => !isOnCooldown(item._tags, allTags, cooldownHours));
return items.filter((item) => !hasFilterTag(item._tags, allTags, filterName));
}
/**
* Apply today's search tag to a movie
* Apply a filter's tag to a movie
* Adds the tag to the movie's existing tags and updates via API
*/
export async function applySearchTag(
export async function applyFilterTag(
client: RadarrClient,
movie: RadarrMovie,
tagId: number
): Promise<RadarrMovie> {
// Get current tags, add new one if not present
const currentTags = movie.tags ?? [];
if (currentTags.includes(tagId)) {
return movie; // Already has the tag
@@ -120,9 +98,9 @@ export async function applySearchTag(
}
/**
* Apply search tag to multiple movies
* Apply filter tag to multiple movies
*/
export async function applySearchTagToMovies(
export async function applyFilterTagToMovies(
client: RadarrClient,
movies: RadarrMovie[],
tagId: number
@@ -133,7 +111,7 @@ export async function applySearchTagToMovies(
for (const movie of movies) {
try {
await applySearchTag(client, movie, tagId);
await applyFilterTag(client, movie, tagId);
success++;
} catch (error) {
failed++;
@@ -145,3 +123,85 @@ export async function applySearchTagToMovies(
return { success, failed, errors };
}
/**
* Remove a filter's tag from a single movie
*/
export async function removeFilterTag(
client: RadarrClient,
movie: RadarrMovie,
tagId: number
): Promise<RadarrMovie> {
const currentTags = movie.tags ?? [];
if (!currentTags.includes(tagId)) {
return movie; // Doesn't have the tag
}
const updatedMovie = {
...movie,
tags: currentTags.filter((id) => id !== tagId)
};
return await client.updateMovie(updatedMovie);
}
/**
* Reset filter cooldown by removing the filter's tag from all movies that have it
* Called when a filter is "exhausted" (no more untagged items to search)
*
* @returns Count of movies that had their tags removed
*/
export async function resetFilterCooldown(
client: RadarrClient,
filterName: string
): Promise<{ reset: number; failed: number; errors: string[] }> {
const tagLabel = getFilterTagLabel(filterName);
// Find the tag ID
const tags = await client.getTags();
const filterTag = tags.find((t) => t.label === tagLabel);
if (!filterTag) {
// No tag exists, nothing to reset
return { reset: 0, failed: 0, errors: [] };
}
// Get all movies that have this tag
const movies = await client.getMovies();
const taggedMovies = movies.filter((m) => m.tags?.includes(filterTag.id));
if (taggedMovies.length === 0) {
return { reset: 0, failed: 0, errors: [] };
}
let reset = 0;
let failed = 0;
const errors: string[] = [];
for (const movie of taggedMovies) {
try {
await removeFilterTag(client, movie, filterTag.id);
reset++;
} catch (error) {
failed++;
errors.push(
`Failed to untag "${movie.title}": ${error instanceof Error ? error.message : String(error)}`
);
}
}
return { reset, failed, errors };
}
/**
* Check if a filter is exhausted (all matched items are tagged)
* When true, the filter should be reset before the next run
*/
export function isFilterExhausted<T extends { _tags: number[] }>(
matchedItems: T[],
allTags: RadarrTag[],
filterName: string
): boolean {
const untaggedItems = filterByFilterTag(matchedItems, allTags, filterName);
return untaggedItems.length === 0 && matchedItems.length > 0;
}

View File

@@ -69,8 +69,8 @@ export async function logUpgradeRun(log: UpgradeJobLog): Promise<void> {
const meta = {
dryRun: log.config.dryRun,
filter: log.filter.name,
filterId: log.filter.id,
selector: log.selection.method,
cooldownHours: log.filter.cooldownHours,
funnel,
items: formattedItems
};

View File

@@ -10,7 +10,13 @@ import { evaluateGroup } from '$lib/shared/filters.ts';
import { getSelector } from '$lib/shared/selectors.ts';
import type { UpgradeItem, UpgradeJobLog, UpgradeSelectionItem } from './types.ts';
import { normalizeRadarrItems } from './normalize.ts';
import { filterByCooldown, getTodayTagLabel, applySearchTagToMovies } from './cooldown.ts';
import {
filterByFilterTag,
getFilterTagLabel,
applyFilterTagToMovies,
isFilterExhausted,
resetFilterCooldown
} from './cooldown.ts';
import { logUpgradeRun, logUpgradeError, logUpgradeSkipped } from './logger.ts';
import { notifications } from '$lib/server/notifications/definitions/index.ts';
import { notificationServicesQueries } from '$lib/server/db/queries/notificationServices.ts';
@@ -145,11 +151,11 @@ function createSkippedLog(
fetchDurationMs: 0
},
filter: {
id: '',
name: '',
rules: { type: 'group', match: 'all', children: [] },
matchedCount: 0,
afterCooldown: 0,
cooldownHours: 0,
dryRunExcluded: 0
},
selection: {
@@ -222,8 +228,19 @@ export async function processUpgradeConfig(
evaluateGroup(item as unknown as Record<string, unknown>, filter.group)
);
// Step 4: Filter by cooldown (tags already fetched above)
const afterCooldownItems = filterByCooldown(matchedItems, tags, filter.searchCooldown);
// Step 4: Filter by filter-level tag (items already searched by this filter)
// First check if filter is exhausted - if so, reset the cooldown
if (isFilterExhausted(matchedItems, tags, filter.name)) {
const resetResult = await resetFilterCooldown(client, filter.name);
if (resetResult.reset > 0) {
// Re-fetch tags after reset
const updatedTags = await client.getTags();
tags.length = 0;
tags.push(...updatedTags);
}
}
const afterCooldownItems = filterByFilterTag(matchedItems, tags, filter.name);
const afterCooldownCount = afterCooldownItems.length;
// Step 4b: If dry run, also exclude items from previous dry runs
@@ -363,13 +380,13 @@ export async function processUpgradeConfig(
}
}
// Apply search tag
const tagLabel = getTodayTagLabel();
const searchTag = await client.getOrCreateTag(tagLabel);
const tagResult = await applySearchTagToMovies(
// Apply filter tag to mark items as searched by this filter
const tagLabel = getFilterTagLabel(filter.name);
const filterTag = await client.getOrCreateTag(tagLabel);
const tagResult = await applyFilterTagToMovies(
client,
selectedItems.map((item) => item._raw),
searchTag.id
filterTag.id
);
failed = tagResult.failed;
@@ -414,11 +431,11 @@ export async function processUpgradeConfig(
fetchDurationMs
},
filter: {
id: filter.id,
name: filter.name,
rules: filter.group,
matchedCount: matchedItems.length,
afterCooldown: afterCooldownCount,
cooldownHours: filter.searchCooldown,
dryRunExcluded: dryRunExcludedCount
},
selection: {
@@ -451,6 +468,8 @@ export async function processUpgradeConfig(
log.completedAt = new Date().toISOString();
log.status = 'failed';
log.config.selectedFilter = filter?.name ?? '';
log.filter.id = filter?.id ?? '';
log.filter.name = filter?.name ?? '';
return log;
} finally {

View File

@@ -101,11 +101,11 @@ export interface UpgradeJobLog {
};
filter: {
id: string;
name: string;
rules: FilterGroup;
matchedCount: number;
afterCooldown: number;
cooldownHours: number;
dryRunExcluded: number;
};

View File

@@ -48,7 +48,8 @@ export interface FilterConfig {
selector: string;
count: number;
cutoff: number;
searchCooldown: number; // hours - skip items searched within this time
// Cooldown is handled via filter-level tags (profilarr-{filterId})
// Future: cooldownMode?: 'basic' | 'advanced' for adaptive backoff
}
export type FilterMode = 'round_robin' | 'random';
@@ -392,8 +393,7 @@ export function createEmptyFilterConfig(name: string = 'New Filter'): FilterConf
group: createDefaultGroup(),
selector: 'random',
count: 2,
cutoff: 100,
searchCooldown: 672 // 4 weeks in hours
cutoff: 100
};
}

View File

@@ -86,8 +86,7 @@ export const actions: Actions = {
enabled: f.enabled,
selector: f.selector,
count: f.count,
cutoff: f.cutoff,
searchCooldown: f.searchCooldown
cutoff: f.cutoff
}))
}
});

View File

@@ -77,7 +77,16 @@
}
function addFilter() {
const newFilter = createEmptyFilterConfig(`Filter ${filters.length + 1}`);
// Generate unique filter name
let baseName = 'Filter';
let counter = filters.length + 1;
let name = `${baseName} ${counter}`;
while (filters.some((f) => f.name.toLowerCase() === name.toLowerCase())) {
counter++;
name = `${baseName} ${counter}`;
}
const newFilter = createEmptyFilterConfig(name);
filters = [...filters, newFilter];
expandedIds.add(newFilter.id);
expandedIds = expandedIds;
@@ -93,7 +102,16 @@
if (editingId) {
const filter = filters.find((f) => f.id === editingId);
if (filter) {
filter.name = editingName;
// Check for duplicate names
const trimmedName = editingName.trim();
const isDuplicate = filters.some(
(f) => f.id !== editingId && f.name.toLowerCase() === trimmedName.toLowerCase()
);
if (isDuplicate) {
alertStore.add('error', 'A filter with this name already exists');
return;
}
filter.name = trimmedName || filter.name;
filters = filters;
notifyChange();
}
@@ -119,10 +137,19 @@
function duplicateFilter(id: string) {
const filter = filters.find((f) => f.id === id);
if (filter) {
// Generate unique name for duplicate
let baseName = `${filter.name} (Copy)`;
let name = baseName;
let counter = 1;
while (filters.some((f) => f.name.toLowerCase() === name.toLowerCase())) {
counter++;
name = `${baseName} ${counter}`;
}
const duplicate: FilterConfig = {
...structuredClone(filter),
id: uuid(),
name: `${filter.name} (Copy)`
name
};
filters = [...filters, duplicate];
expandedIds.add(duplicate.id);
@@ -166,7 +193,6 @@
filter.selector = imported.selector ?? filter.selector;
filter.count = imported.count ?? filter.count;
filter.cutoff = imported.cutoff ?? filter.cutoff;
filter.searchCooldown = imported.searchCooldown ?? filter.searchCooldown;
filter.enabled = imported.enabled ?? filter.enabled;
filters = filters;
@@ -288,7 +314,7 @@
class="rounded-lg border border-neutral-200 bg-neutral-50 p-4 dark:border-neutral-800 dark:bg-neutral-800/50"
>
<h3 class="mb-3 text-sm font-medium text-neutral-700 dark:text-neutral-300">Settings</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<label
for="cutoff-{row.id}"
@@ -311,27 +337,6 @@
Score threshold for "cutoff met"
</p>
</div>
<div>
<label
for="cooldown-{row.id}"
class="block text-sm font-medium text-neutral-600 dark:text-neutral-400"
>
Cooldown (hours)
</label>
<div class="mt-1">
<NumberInput
name="cooldown-{row.id}"
bind:value={row.searchCooldown}
min={24}
font="mono"
responsive
on:change={handleChange}
/>
</div>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Skip if searched recently
</p>
</div>
<div>
<label
for="selector-{row.id}"

View File

@@ -0,0 +1,566 @@
/**
* Tests for cooldown logic
* Tests the pure functions in upgrades/cooldown.ts
* Also tests API-dependent functions with mocked RadarrClient
*/
import { BaseTest } from '../base/BaseTest.ts';
import { assertEquals } from '@std/assert';
import {
getFilterTagLabel,
isFilterTag,
hasFilterTag,
filterByFilterTag,
isFilterExhausted,
applyFilterTagToMovies,
resetFilterCooldown
} from '../../lib/server/upgrades/cooldown.ts';
import type { RadarrTag, RadarrMovie } from '../../lib/server/utils/arr/types.ts';
import type { RadarrClient } from '../../lib/server/utils/arr/clients/radarr.ts';
/**
* Mock RadarrClient for testing
* Stores state in-memory to simulate Radarr behavior
*/
class MockRadarrClient {
tags: RadarrTag[] = [];
movies: RadarrMovie[] = [];
private nextTagId = 1;
constructor(initialMovies: Partial<RadarrMovie>[] = []) {
this.movies = initialMovies.map((m, i) => ({
id: m.id ?? i + 1,
title: m.title ?? `Movie ${i + 1}`,
tags: m.tags ?? [],
// Required fields with defaults
tmdbId: m.tmdbId ?? 0,
year: m.year ?? 2024,
qualityProfileId: m.qualityProfileId ?? 1,
monitored: m.monitored ?? true,
hasFile: m.hasFile ?? true,
added: m.added ?? new Date().toISOString(),
minimumAvailability: m.minimumAvailability ?? 'released',
status: m.status ?? 'released',
sizeOnDisk: m.sizeOnDisk ?? 0,
runtime: m.runtime ?? 120,
popularity: m.popularity ?? 0,
movieFileId: m.movieFileId ?? 0,
path: m.path ?? '/movies',
rootFolderPath: m.rootFolderPath ?? '/movies'
} as RadarrMovie));
}
async getTags(): Promise<RadarrTag[]> {
return this.tags;
}
async getMovies(): Promise<RadarrMovie[]> {
return this.movies;
}
async getOrCreateTag(label: string): Promise<RadarrTag> {
const existing = this.tags.find((t) => t.label.toLowerCase() === label.toLowerCase());
if (existing) return existing;
const newTag: RadarrTag = { id: this.nextTagId++, label };
this.tags.push(newTag);
return newTag;
}
async updateMovie(movie: RadarrMovie): Promise<RadarrMovie> {
const index = this.movies.findIndex((m) => m.id === movie.id);
if (index >= 0) {
this.movies[index] = movie;
}
return movie;
}
}
class CooldownTest extends BaseTest {
runTests(): void {
// =====================
// getFilterTagLabel (slugify)
// =====================
this.test('getFilterTagLabel: simple name', () => {
const label = getFilterTagLabel('My Filter');
assertEquals(label, 'profilarr-my-filter');
});
this.test('getFilterTagLabel: name with apostrophe', () => {
const label = getFilterTagLabel("Things I Don't Want");
assertEquals(label, 'profilarr-things-i-dont-want');
});
this.test('getFilterTagLabel: name with special characters', () => {
const label = getFilterTagLabel('4K HDR (Dolby Vision)');
assertEquals(label, 'profilarr-4k-hdr-dolby-vision');
});
this.test('getFilterTagLabel: name with multiple spaces', () => {
const label = getFilterTagLabel('Filter With Spaces');
assertEquals(label, 'profilarr-filter-with-spaces');
});
this.test('getFilterTagLabel: already lowercase', () => {
const label = getFilterTagLabel('already-lowercase');
assertEquals(label, 'profilarr-already-lowercase');
});
this.test('getFilterTagLabel: numbers preserved', () => {
const label = getFilterTagLabel('Filter 123');
assertEquals(label, 'profilarr-filter-123');
});
this.test('getFilterTagLabel: long name truncated to 50 chars', () => {
const longName = 'This is a very long filter name that should be truncated to fifty characters';
const label = getFilterTagLabel(longName);
// profilarr- prefix (10 chars) + 50 char max slug = 60 max
assertEquals(label.length <= 60, true);
assertEquals(label.startsWith('profilarr-'), true);
assertEquals(label, 'profilarr-this-is-a-very-long-filter-name-that-should-be-tru');
});
// =====================
// isFilterTag
// =====================
this.test('isFilterTag: matches profilarr tag', () => {
assertEquals(isFilterTag('profilarr-my-filter'), true);
});
this.test('isFilterTag: rejects non-profilarr tag', () => {
assertEquals(isFilterTag('some-other-tag'), false);
});
this.test('isFilterTag: rejects empty string', () => {
assertEquals(isFilterTag(''), false);
});
// =====================
// hasFilterTag
// =====================
this.test('hasFilterTag: finds matching tag', () => {
const allTags: RadarrTag[] = [
{ id: 1, label: 'profilarr-my-filter' },
{ id: 2, label: 'other-tag' }
];
const itemTagIds = [1, 2];
assertEquals(hasFilterTag(itemTagIds, allTags, 'My Filter'), true);
});
this.test('hasFilterTag: returns false when tag not present', () => {
const allTags: RadarrTag[] = [
{ id: 1, label: 'profilarr-other-filter' },
{ id: 2, label: 'other-tag' }
];
const itemTagIds = [1, 2];
assertEquals(hasFilterTag(itemTagIds, allTags, 'My Filter'), false);
});
this.test('hasFilterTag: returns false when item has no tags', () => {
const allTags: RadarrTag[] = [{ id: 1, label: 'profilarr-my-filter' }];
const itemTagIds: number[] = [];
assertEquals(hasFilterTag(itemTagIds, allTags, 'My Filter'), false);
});
this.test('hasFilterTag: handles case-insensitive filter name', () => {
const allTags: RadarrTag[] = [{ id: 1, label: 'profilarr-my-filter' }];
const itemTagIds = [1];
// getFilterTagLabel lowercases, so "MY FILTER" -> "profilarr-my-filter"
assertEquals(hasFilterTag(itemTagIds, allTags, 'MY FILTER'), true);
});
// =====================
// filterByFilterTag
// =====================
this.test('filterByFilterTag: filters out tagged items', () => {
const allTags: RadarrTag[] = [{ id: 1, label: 'profilarr-my-filter' }];
const items = [
{ id: 1, title: 'Tagged Movie', _tags: [1] },
{ id: 2, title: 'Untagged Movie', _tags: [] },
{ id: 3, title: 'Other Tagged', _tags: [2] }
];
const result = filterByFilterTag(items, allTags, 'My Filter');
assertEquals(result.length, 2);
assertEquals(result[0].title, 'Untagged Movie');
assertEquals(result[1].title, 'Other Tagged');
});
this.test('filterByFilterTag: returns all items when none tagged', () => {
const allTags: RadarrTag[] = [{ id: 1, label: 'profilarr-other-filter' }];
const items = [
{ id: 1, title: 'Movie 1', _tags: [] },
{ id: 2, title: 'Movie 2', _tags: [1] }
];
const result = filterByFilterTag(items, allTags, 'My Filter');
assertEquals(result.length, 2);
});
this.test('filterByFilterTag: returns empty when all tagged', () => {
const allTags: RadarrTag[] = [{ id: 1, label: 'profilarr-my-filter' }];
const items = [
{ id: 1, title: 'Movie 1', _tags: [1] },
{ id: 2, title: 'Movie 2', _tags: [1] }
];
const result = filterByFilterTag(items, allTags, 'My Filter');
assertEquals(result.length, 0);
});
// =====================
// isFilterExhausted
// =====================
this.test('isFilterExhausted: true when all items tagged', () => {
const allTags: RadarrTag[] = [{ id: 1, label: 'profilarr-my-filter' }];
const items = [
{ id: 1, _tags: [1] },
{ id: 2, _tags: [1] }
];
assertEquals(isFilterExhausted(items, allTags, 'My Filter'), true);
});
this.test('isFilterExhausted: false when some items untagged', () => {
const allTags: RadarrTag[] = [{ id: 1, label: 'profilarr-my-filter' }];
const items = [
{ id: 1, _tags: [1] },
{ id: 2, _tags: [] }
];
assertEquals(isFilterExhausted(items, allTags, 'My Filter'), false);
});
this.test('isFilterExhausted: false when no items matched', () => {
const allTags: RadarrTag[] = [{ id: 1, label: 'profilarr-my-filter' }];
const items: { id: number; _tags: number[] }[] = [];
assertEquals(isFilterExhausted(items, allTags, 'My Filter'), false);
});
this.test('isFilterExhausted: true triggers reset cycle', () => {
// Simulates: matched 3 items, all 3 tagged = exhausted = reset
const allTags: RadarrTag[] = [{ id: 1, label: 'profilarr-upgrade-filter' }];
const matchedItems = [
{ id: 100, _tags: [1] },
{ id: 200, _tags: [1] },
{ id: 300, _tags: [1] }
];
const exhausted = isFilterExhausted(matchedItems, allTags, 'Upgrade Filter');
assertEquals(exhausted, true);
// When exhausted, processor should call resetFilterCooldown()
});
// =====================
// Full Cycle Scenarios
// =====================
this.test('scenario: complete tag-exhaust-reset cycle', () => {
const filterName = 'My Upgrade Filter';
const tagLabel = getFilterTagLabel(filterName);
const tagId = 42;
// Step 1: Initial state - no tags exist, 3 matched items
let allTags: RadarrTag[] = [];
let items = [
{ id: 1, title: 'Movie A', _tags: [] as number[] },
{ id: 2, title: 'Movie B', _tags: [] as number[] },
{ id: 3, title: 'Movie C', _tags: [] as number[] }
];
// All items available (none tagged)
let available = filterByFilterTag(items, allTags, filterName);
assertEquals(available.length, 3, 'All 3 items should be available initially');
assertEquals(isFilterExhausted(items, allTags, filterName), false);
// Step 2: First run - tag is created, 1 item searched and tagged
allTags = [{ id: tagId, label: tagLabel }];
items[0]._tags = [tagId]; // Movie A tagged
available = filterByFilterTag(items, allTags, filterName);
assertEquals(available.length, 2, '2 items should be available after first search');
assertEquals(isFilterExhausted(items, allTags, filterName), false);
// Step 3: Second run - another item tagged
items[1]._tags = [tagId]; // Movie B tagged
available = filterByFilterTag(items, allTags, filterName);
assertEquals(available.length, 1, '1 item should be available');
assertEquals(isFilterExhausted(items, allTags, filterName), false);
// Step 4: Third run - last item tagged, filter exhausted
items[2]._tags = [tagId]; // Movie C tagged
available = filterByFilterTag(items, allTags, filterName);
assertEquals(available.length, 0, 'No items should be available');
assertEquals(isFilterExhausted(items, allTags, filterName), true, 'Filter should be exhausted');
// Step 5: Reset - tags removed (simulates resetFilterCooldown)
items[0]._tags = [];
items[1]._tags = [];
items[2]._tags = [];
available = filterByFilterTag(items, allTags, filterName);
assertEquals(available.length, 3, 'All 3 items should be available after reset');
assertEquals(isFilterExhausted(items, allTags, filterName), false, 'Filter should not be exhausted after reset');
});
this.test('scenario: multiple filters operate independently', () => {
const filter1Name = 'Filter One';
const filter2Name = 'Filter Two';
const tag1Id = 1;
const tag2Id = 2;
const allTags: RadarrTag[] = [
{ id: tag1Id, label: getFilterTagLabel(filter1Name) },
{ id: tag2Id, label: getFilterTagLabel(filter2Name) }
];
const items = [
{ id: 1, title: 'Movie A', _tags: [tag1Id] }, // Tagged by Filter One only
{ id: 2, title: 'Movie B', _tags: [tag2Id] }, // Tagged by Filter Two only
{ id: 3, title: 'Movie C', _tags: [tag1Id, tag2Id] }, // Tagged by both
{ id: 4, title: 'Movie D', _tags: [] } // Not tagged
];
// Filter One sees: Movie B (not tagged by it), Movie D (not tagged)
const availableForFilter1 = filterByFilterTag(items, allTags, filter1Name);
assertEquals(availableForFilter1.length, 2);
assertEquals(availableForFilter1[0].title, 'Movie B');
assertEquals(availableForFilter1[1].title, 'Movie D');
// Filter Two sees: Movie A (not tagged by it), Movie D (not tagged)
const availableForFilter2 = filterByFilterTag(items, allTags, filter2Name);
assertEquals(availableForFilter2.length, 2);
assertEquals(availableForFilter2[0].title, 'Movie A');
assertEquals(availableForFilter2[1].title, 'Movie D');
// Neither filter is exhausted
assertEquals(isFilterExhausted(items, allTags, filter1Name), false);
assertEquals(isFilterExhausted(items, allTags, filter2Name), false);
});
this.test('scenario: new item added mid-cycle is picked up', () => {
const filterName = 'Ongoing Filter';
const tagId = 1;
const allTags: RadarrTag[] = [{ id: tagId, label: getFilterTagLabel(filterName) }];
// Start with 2 items, both tagged (exhausted)
let items = [
{ id: 1, title: 'Old Movie 1', _tags: [tagId] },
{ id: 2, title: 'Old Movie 2', _tags: [tagId] }
];
assertEquals(isFilterExhausted(items, allTags, filterName), true, 'Should be exhausted');
assertEquals(filterByFilterTag(items, allTags, filterName).length, 0);
// User adds a new movie (no tag)
items = [
...items,
{ id: 3, title: 'New Movie', _tags: [] }
];
// Now filter is NOT exhausted - new item available
assertEquals(isFilterExhausted(items, allTags, filterName), false, 'Should not be exhausted after new item');
const available = filterByFilterTag(items, allTags, filterName);
assertEquals(available.length, 1);
assertEquals(available[0].title, 'New Movie');
});
// =====================
// API-Dependent Functions (with Mock)
// =====================
this.test('applyFilterTagToMovies: tags multiple movies', async () => {
const client = new MockRadarrClient([
{ id: 1, title: 'Movie A', tags: [] },
{ id: 2, title: 'Movie B', tags: [] },
{ id: 3, title: 'Movie C', tags: [99] } // Already has another tag
]);
const filterTag = await client.getOrCreateTag('profilarr-test-filter');
const movies = await client.getMovies();
const result = await applyFilterTagToMovies(
client as unknown as RadarrClient,
movies,
filterTag.id
);
assertEquals(result.success, 3);
assertEquals(result.failed, 0);
assertEquals(result.errors.length, 0);
// Verify tags were applied
const updatedMovies = await client.getMovies();
assertEquals(updatedMovies[0].tags?.includes(filterTag.id), true);
assertEquals(updatedMovies[1].tags?.includes(filterTag.id), true);
assertEquals(updatedMovies[2].tags?.includes(filterTag.id), true);
assertEquals(updatedMovies[2].tags?.includes(99), true); // Original tag preserved
});
this.test('applyFilterTagToMovies: skips already-tagged movies', async () => {
const client = new MockRadarrClient([
{ id: 1, title: 'Movie A', tags: [1] }, // Already has the filter tag
{ id: 2, title: 'Movie B', tags: [] }
]);
client.tags = [{ id: 1, label: 'profilarr-test-filter' }];
const movies = await client.getMovies();
const result = await applyFilterTagToMovies(
client as unknown as RadarrClient,
movies,
1 // tag id
);
// Both succeed (one skipped, one added)
assertEquals(result.success, 2);
assertEquals(result.failed, 0);
});
this.test('resetFilterCooldown: removes tags from all tagged movies', async () => {
const filterName = 'Reset Test Filter';
const tagLabel = getFilterTagLabel(filterName);
const client = new MockRadarrClient([
{ id: 1, title: 'Tagged 1', tags: [1] },
{ id: 2, title: 'Tagged 2', tags: [1] },
{ id: 3, title: 'Not Tagged', tags: [] },
{ id: 4, title: 'Other Tag', tags: [2] }
]);
client.tags = [
{ id: 1, label: tagLabel },
{ id: 2, label: 'other-tag' }
];
const result = await resetFilterCooldown(
client as unknown as RadarrClient,
filterName
);
assertEquals(result.reset, 2, 'Should reset 2 movies');
assertEquals(result.failed, 0);
assertEquals(result.errors.length, 0);
// Verify tags were removed
const movies = await client.getMovies();
assertEquals(movies[0].tags?.includes(1), false, 'Tag should be removed from movie 1');
assertEquals(movies[1].tags?.includes(1), false, 'Tag should be removed from movie 2');
assertEquals((movies[2].tags ?? []).length, 0, 'Movie 3 should still have no tags');
assertEquals(movies[3].tags?.includes(2), true, 'Movie 4 should keep other tag');
});
this.test('resetFilterCooldown: handles no tag existing', async () => {
const client = new MockRadarrClient([
{ id: 1, title: 'Movie A', tags: [] }
]);
// No tags exist
const result = await resetFilterCooldown(
client as unknown as RadarrClient,
'Non Existent Filter'
);
assertEquals(result.reset, 0);
assertEquals(result.failed, 0);
assertEquals(result.errors.length, 0);
});
this.test('resetFilterCooldown: handles no movies tagged', async () => {
const filterName = 'Empty Filter';
const tagLabel = getFilterTagLabel(filterName);
const client = new MockRadarrClient([
{ id: 1, title: 'Movie A', tags: [] },
{ id: 2, title: 'Movie B', tags: [2] } // Different tag
]);
client.tags = [
{ id: 1, label: tagLabel },
{ id: 2, label: 'other-tag' }
];
const result = await resetFilterCooldown(
client as unknown as RadarrClient,
filterName
);
assertEquals(result.reset, 0);
assertEquals(result.failed, 0);
});
this.test('integration: full cycle with mock client', async () => {
const filterName = 'Integration Test';
const tagLabel = getFilterTagLabel(filterName);
// Setup: 3 movies, no tags
const client = new MockRadarrClient([
{ id: 1, title: 'Movie A', tags: [] },
{ id: 2, title: 'Movie B', tags: [] },
{ id: 3, title: 'Movie C', tags: [] }
]);
// Step 1: Create filter tag
const filterTag = await client.getOrCreateTag(tagLabel);
assertEquals(filterTag.label, tagLabel);
// Step 2: Tag first 2 movies (simulating search)
const movies = await client.getMovies();
await applyFilterTagToMovies(
client as unknown as RadarrClient,
[movies[0], movies[1]],
filterTag.id
);
// Step 3: Check state - 1 movie available
let tags = await client.getTags();
let currentMovies = await client.getMovies();
let items = currentMovies.map((m) => ({ ...m, _tags: m.tags ?? [] }));
let available = filterByFilterTag(items, tags, filterName);
assertEquals(available.length, 1, 'Should have 1 available');
assertEquals(available[0].title, 'Movie C');
assertEquals(isFilterExhausted(items, tags, filterName), false);
// Step 4: Tag last movie
await applyFilterTagToMovies(
client as unknown as RadarrClient,
[movies[2]],
filterTag.id
);
// Step 5: Now exhausted
currentMovies = await client.getMovies();
items = currentMovies.map((m) => ({ ...m, _tags: m.tags ?? [] }));
assertEquals(isFilterExhausted(items, tags, filterName), true, 'Should be exhausted');
assertEquals(filterByFilterTag(items, tags, filterName).length, 0);
// Step 6: Reset
const resetResult = await resetFilterCooldown(
client as unknown as RadarrClient,
filterName
);
assertEquals(resetResult.reset, 3, 'Should reset 3 movies');
// Step 7: All available again
currentMovies = await client.getMovies();
items = currentMovies.map((m) => ({ ...m, _tags: m.tags ?? [] }));
tags = await client.getTags();
available = filterByFilterTag(items, tags, filterName);
assertEquals(available.length, 3, 'All 3 should be available after reset');
assertEquals(isFilterExhausted(items, tags, filterName), false);
});
}
}
// Create instance and run tests
const cooldownTest = new CooldownTest();
cooldownTest.runTests();