mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
feat: implement basic cooldown, remove old time based one
This commit is contained in:
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
76
src/lib/server/db/migrations/031_remove_search_cooldown.ts
Normal file
76
src/lib/server/db/migrations/031_remove_search_cooldown.ts
Normal 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
|
||||
};
|
||||
@@ -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;
|
||||
`
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -101,11 +101,11 @@ export interface UpgradeJobLog {
|
||||
};
|
||||
|
||||
filter: {
|
||||
id: string;
|
||||
name: string;
|
||||
rules: FilterGroup;
|
||||
matchedCount: number;
|
||||
afterCooldown: number;
|
||||
cooldownHours: number;
|
||||
dryRunExcluded: number;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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}"
|
||||
|
||||
566
src/tests/upgrades/cooldown.test.ts
Normal file
566
src/tests/upgrades/cooldown.test.ts
Normal 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();
|
||||
Reference in New Issue
Block a user