diff --git a/deno.json b/deno.json index 691bcfb..8f04c16 100644 --- a/deno.json +++ b/deno.json @@ -1,5 +1,6 @@ { "imports": { + "$lib/": "./src/lib/", "$config": "./src/lib/server/utils/config/config.ts", "$logger/": "./src/lib/server/utils/logger/", "$shared/": "./src/lib/shared/", @@ -15,6 +16,7 @@ "$http/": "./src/lib/server/utils/http/", "$utils/": "./src/lib/server/utils/", "$notifications/": "./src/lib/server/notifications/", + "$cache/": "./src/lib/server/utils/cache/", "@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.2.0", "@std/assert": "jsr:@std/assert@^1.0.0", "marked": "npm:marked@^15.0.6", diff --git a/deno.lock b/deno.lock index 8208517..97375f8 100644 --- a/deno.lock +++ b/deno.lock @@ -1,11 +1,22 @@ { "version": "5", "specifiers": { + "jsr:@db/sqlite@0.12": "0.12.0", + "jsr:@denosaurs/plug@1": "1.1.0", "jsr:@soapbox/kysely-deno-sqlite@*": "2.2.0", "jsr:@soapbox/kysely-deno-sqlite@^2.2.0": "2.2.0", "jsr:@std/assert@*": "1.0.15", + "jsr:@std/assert@0.217": "0.217.0", "jsr:@std/assert@1": "1.0.15", + "jsr:@std/encoding@1": "1.0.10", + "jsr:@std/fmt@1": "1.0.8", + "jsr:@std/fs@1": "1.0.19", + "jsr:@std/internal@^1.0.10": "1.0.12", "jsr:@std/internal@^1.0.12": "1.0.12", + "jsr:@std/internal@^1.0.9": "1.0.12", + "jsr:@std/path@0.217": "0.217.0", + "jsr:@std/path@1": "1.1.2", + "jsr:@std/path@^1.1.1": "1.1.2", "npm:@deno/vite-plugin@^1.0.5": "1.0.5_vite@7.1.12__@types+node@22.19.0__picomatch@4.0.3_@types+node@22.19.0", "npm:@eslint/compat@^1.4.0": "1.4.1_eslint@9.39.1", "npm:@eslint/js@^9.36.0": "9.39.1", @@ -36,20 +47,64 @@ "npm:vite@^7.1.7": "7.1.12_@types+node@22.19.0_picomatch@4.0.3" }, "jsr": { + "@db/sqlite@0.12.0": { + "integrity": "dd1ef7f621ad50fc1e073a1c3609c4470bd51edc0994139c5bf9851de7a6d85f", + "dependencies": [ + "jsr:@denosaurs/plug", + "jsr:@std/path@0.217" + ] + }, + "@denosaurs/plug@1.1.0": { + "integrity": "eb2f0b7546c7bca2000d8b0282c54d50d91cf6d75cb26a80df25a6de8c4bc044", + "dependencies": [ + "jsr:@std/encoding", + "jsr:@std/fmt", + "jsr:@std/fs", + "jsr:@std/path@1" + ] + }, "@soapbox/kysely-deno-sqlite@2.2.0": { "integrity": "668ec94600bc4b4d7bd618dd7ca65d4ef30ee61c46ffcb379b6f45203c08517a", "dependencies": [ "npm:kysely@~0.27.2" ] }, + "@std/assert@0.217.0": { + "integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642" + }, "@std/assert@1.0.15": { "integrity": "d64018e951dbdfab9777335ecdb000c0b4e3df036984083be219ce5941e4703b", "dependencies": [ - "jsr:@std/internal" + "jsr:@std/internal@^1.0.12" + ] + }, + "@std/encoding@1.0.10": { + "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" + }, + "@std/fmt@1.0.8": { + "integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7" + }, + "@std/fs@1.0.19": { + "integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06", + "dependencies": [ + "jsr:@std/internal@^1.0.9", + "jsr:@std/path@^1.1.1" ] }, "@std/internal@1.0.12": { "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" + }, + "@std/path@0.217.0": { + "integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11", + "dependencies": [ + "jsr:@std/assert@0.217" + ] + }, + "@std/path@1.1.2": { + "integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038", + "dependencies": [ + "jsr:@std/internal@^1.0.10" + ] } }, "npm": { diff --git a/src/lib/client/ui/table/ExpandableTable.svelte b/src/lib/client/ui/table/ExpandableTable.svelte index 927f27a..64e55cc 100644 --- a/src/lib/client/ui/table/ExpandableTable.svelte +++ b/src/lib/client/ui/table/ExpandableTable.svelte @@ -1,14 +1,16 @@ @@ -45,13 +109,32 @@ - {column.header} + {#if column.sortable} + + {:else} + {column.header} + {/if} {/each} - {#if data.length === 0} + {#if sortedData.length === 0} {:else} - {#each data as row} + {#each sortedData as row} {@const rowId = getRowId(row)} diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index 6fad909..8551914 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -14,6 +14,7 @@ import { migration as migration009 } from './migrations/009_add_personal_access_ import { migration as migration010 } from './migrations/010_add_is_private.ts'; import { migration as migration011 } from './migrations/011_create_upgrade_configs.ts'; import { migration as migration012 } from './migrations/012_add_upgrade_last_run.ts'; +import { migration as migration013 } from './migrations/013_add_upgrade_dry_run.ts'; export interface Migration { version: number; @@ -243,7 +244,8 @@ export function loadMigrations(): Migration[] { migration009, migration010, migration011, - migration012 + migration012, + migration013 ]; // Sort by version number diff --git a/src/lib/server/db/migrations/013_add_upgrade_dry_run.ts b/src/lib/server/db/migrations/013_add_upgrade_dry_run.ts new file mode 100644 index 0000000..720606e --- /dev/null +++ b/src/lib/server/db/migrations/013_add_upgrade_dry_run.ts @@ -0,0 +1,47 @@ +import type { Migration } from '../migrations.ts'; + +/** + * Migration 013: Add dry_run to upgrade_configs + * + * Adds a dry_run flag that allows running upgrade jobs in test mode. + * When enabled, the job will log what it would do without actually + * triggering searches in the arr instance. + */ + +export const migration: Migration = { + version: 13, + name: 'Add dry_run to upgrade_configs', + + up: ` + ALTER TABLE upgrade_configs + ADD COLUMN dry_run INTEGER NOT NULL DEFAULT 0; + `, + + down: ` + -- SQLite doesn't support DROP COLUMN easily, so we recreate the table + CREATE TABLE upgrade_configs_backup ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + arr_instance_id INTEGER NOT NULL UNIQUE, + enabled INTEGER NOT NULL DEFAULT 0, + schedule INTEGER NOT NULL DEFAULT 360, + filter_mode TEXT NOT NULL DEFAULT 'round_robin', + filters TEXT NOT NULL DEFAULT '[]', + current_filter_index INTEGER NOT NULL DEFAULT 0, + last_run_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (arr_instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE + ); + + INSERT INTO upgrade_configs_backup + SELECT id, arr_instance_id, enabled, schedule, filter_mode, filters, + current_filter_index, last_run_at, created_at, updated_at + FROM upgrade_configs; + + DROP TABLE upgrade_configs; + + ALTER TABLE upgrade_configs_backup RENAME TO upgrade_configs; + + CREATE INDEX idx_upgrade_configs_arr_instance ON upgrade_configs(arr_instance_id); + ` +}; diff --git a/src/lib/server/db/queries/databaseInstances.ts b/src/lib/server/db/queries/databaseInstances.ts index 40104f5..51bdbda 100644 --- a/src/lib/server/db/queries/databaseInstances.ts +++ b/src/lib/server/db/queries/databaseInstances.ts @@ -108,6 +108,7 @@ export const databaseInstancesQueries = { /** * Get databases that need auto-sync check + * Note: last_synced_at may be ISO format (with T and Z), normalize for datetime() */ getDueForSync(): DatabaseInstance[] { return db.query( @@ -116,7 +117,7 @@ export const databaseInstancesQueries = { AND sync_strategy > 0 AND ( last_synced_at IS NULL - OR datetime(last_synced_at, '+' || sync_strategy || ' minutes') <= datetime('now') + OR datetime(replace(replace(last_synced_at, 'T', ' '), 'Z', ''), '+' || sync_strategy || ' minutes') <= datetime('now') ) ORDER BY last_synced_at ASC NULLS FIRST` ); diff --git a/src/lib/server/db/queries/jobs.ts b/src/lib/server/db/queries/jobs.ts index 459e750..3fb4893 100644 --- a/src/lib/server/db/queries/jobs.ts +++ b/src/lib/server/db/queries/jobs.ts @@ -54,12 +54,15 @@ export const jobsQueries = { /** * Get jobs that need to run (next_run_at <= now) + * Note: next_run_at is stored as ISO string (2025-12-27T08:35:00.000Z) + * datetime('now') returns space-separated format (2025-12-27 08:35:00) + * We normalize by replacing T with space and comparing first 19 chars */ getDueJobs(): Job[] { return db.query( `SELECT * FROM jobs WHERE enabled = 1 - AND (next_run_at IS NULL OR next_run_at <= datetime('now')) + AND (next_run_at IS NULL OR substr(replace(next_run_at, 'T', ' '), 1, 19) <= datetime('now')) ORDER BY next_run_at` ); }, diff --git a/src/lib/server/db/queries/upgradeConfigs.ts b/src/lib/server/db/queries/upgradeConfigs.ts index 2cda551..31b31ac 100644 --- a/src/lib/server/db/queries/upgradeConfigs.ts +++ b/src/lib/server/db/queries/upgradeConfigs.ts @@ -8,6 +8,7 @@ interface UpgradeConfigRow { id: number; arr_instance_id: number; enabled: number; + dry_run: number; schedule: number; filter_mode: string; filters: string; @@ -22,6 +23,7 @@ interface UpgradeConfigRow { */ export interface UpgradeConfigInput { enabled?: boolean; + dryRun?: boolean; schedule?: number; filterMode?: FilterMode; filters?: FilterConfig[]; @@ -36,6 +38,7 @@ function rowToConfig(row: UpgradeConfigRow): UpgradeConfig { id: row.id, arrInstanceId: row.arr_instance_id, enabled: row.enabled === 1, + dryRun: row.dry_run === 1, schedule: row.schedule, filterMode: row.filter_mode as FilterMode, filters: JSON.parse(row.filters) as FilterConfig[], @@ -94,6 +97,7 @@ export const upgradeConfigsQueries = { // Create new const enabled = input.enabled !== undefined ? (input.enabled ? 1 : 0) : 0; + const dryRun = input.dryRun !== undefined ? (input.dryRun ? 1 : 0) : 0; const schedule = input.schedule ?? 360; const filterMode = input.filterMode ?? 'round_robin'; const filters = JSON.stringify(input.filters ?? []); @@ -101,10 +105,11 @@ export const upgradeConfigsQueries = { db.execute( `INSERT INTO upgrade_configs - (arr_instance_id, enabled, schedule, filter_mode, filters, current_filter_index) - VALUES (?, ?, ?, ?, ?, ?)`, + (arr_instance_id, enabled, dry_run, schedule, filter_mode, filters, current_filter_index) + VALUES (?, ?, ?, ?, ?, ?, ?)`, arrInstanceId, enabled, + dryRun, schedule, filterMode, filters, @@ -125,6 +130,10 @@ export const upgradeConfigsQueries = { updates.push('enabled = ?'); params.push(input.enabled ? 1 : 0); } + if (input.dryRun !== undefined) { + updates.push('dry_run = ?'); + params.push(input.dryRun ? 1 : 0); + } if (input.schedule !== undefined) { updates.push('schedule = ?'); params.push(input.schedule); @@ -213,6 +222,7 @@ export const upgradeConfigsQueries = { /** * Get all enabled configs that are due to run * A config is due if: last_run_at is null OR (now - last_run_at) >= schedule minutes + * Note: last_run_at may be stored as ISO string with T and Z, normalize for julianday */ getDueConfigs(): UpgradeConfig[] { const rows = db.query(` @@ -220,7 +230,7 @@ export const upgradeConfigsQueries = { WHERE enabled = 1 AND ( last_run_at IS NULL - OR (julianday('now') - julianday(last_run_at)) * 24 * 60 >= schedule + OR (julianday('now') - julianday(replace(replace(last_run_at, 'T', ' '), 'Z', ''))) * 24 * 60 >= schedule ) `); return rows.map(rowToConfig); diff --git a/src/lib/server/jobs/logic/upgradeManager.ts b/src/lib/server/jobs/logic/upgradeManager.ts index b987331..9a79464 100644 --- a/src/lib/server/jobs/logic/upgradeManager.ts +++ b/src/lib/server/jobs/logic/upgradeManager.ts @@ -6,7 +6,8 @@ import { upgradeConfigsQueries } from '$db/queries/upgradeConfigs.ts'; import { arrInstancesQueries } from '$db/queries/arrInstances.ts'; import { logger } from '$logger/logger.ts'; -import type { FilterConfig, UpgradeConfig } from '$lib/shared/filters'; +import { processUpgradeConfig } from '$lib/server/upgrades/processor.ts'; +import type { UpgradeConfig } from '$lib/shared/filters.ts'; export interface UpgradeInstanceStatus { instanceId: number; @@ -26,30 +27,9 @@ export interface UpgradeManagerResult { } /** - * Get the next filter to run based on the config's mode + * Process a single upgrade config and convert result to status */ -function getNextFilter(config: UpgradeConfig): FilterConfig | null { - const enabledFilters = config.filters.filter((f) => f.enabled); - - if (enabledFilters.length === 0) { - return null; - } - - if (config.filterMode === 'random') { - // Random: pick a random filter - const randomIndex = Math.floor(Math.random() * enabledFilters.length); - return enabledFilters[randomIndex]; - } - - // Round robin: use currentFilterIndex - const index = config.currentFilterIndex % enabledFilters.length; - return enabledFilters[index]; -} - -/** - * Process a single upgrade config - */ -async function processUpgradeConfig(config: UpgradeConfig): Promise { +async function processConfig(config: UpgradeConfig): Promise { const instance = arrInstancesQueries.getById(config.arrInstanceId); if (!instance) { @@ -70,68 +50,36 @@ async function processUpgradeConfig(config: UpgradeConfig): Promise { }); for (const config of dueConfigs) { - const status = await processUpgradeConfig(config); + const status = await processConfig(config); statuses.push(status); if (status.success) { successCount++; - } else if (status.error?.includes('disabled') || status.error?.includes('No enabled')) { + } else if (status.error?.includes('disabled') || status.error?.includes('not yet supported') || status.error?.includes('No enabled')) { skippedCount++; } else { failureCount++; diff --git a/src/lib/server/upgrades/cooldown.ts b/src/lib/server/upgrades/cooldown.ts new file mode 100644 index 0000000..0a47775 --- /dev/null +++ b/src/lib/server/upgrades/cooldown.ts @@ -0,0 +1,145 @@ +/** + * 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 + */ + +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-'; + +/** + * Get today's cooldown tag label + */ +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}`; +} + +/** + * Parse a date from a profilarr search tag label + * Returns null if not a valid profilarr tag + */ +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; +} + +/** + * 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 + */ +export function isOnCooldown( + itemTagIds: number[], + allTags: RadarrTag[], + cooldownHours: number +): boolean { + const now = new Date(); + const cooldownMs = cooldownHours * 60 * 60 * 1000; + + // Create a lookup for tag IDs to labels + 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) { + return true; + } + } + + return false; +} + +/** + * 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 + */ +export function filterByCooldown( + items: T[], + allTags: RadarrTag[], + cooldownHours: number +): T[] { + if (cooldownHours <= 0) { + // No cooldown, return all items + return items; + } + + return items.filter((item) => !isOnCooldown(item._tags, allTags, cooldownHours)); +} + +/** + * Apply today's search tag to a movie + * Adds the tag to the movie's existing tags and updates via API + */ +export async function applySearchTag( + client: RadarrClient, + movie: RadarrMovie, + tagId: number +): Promise { + // Get current tags, add new one if not present + const currentTags = movie.tags ?? []; + if (currentTags.includes(tagId)) { + return movie; // Already has the tag + } + + const updatedMovie = { + ...movie, + tags: [...currentTags, tagId] + }; + + return await client.updateMovie(updatedMovie); +} + +/** + * Apply search tag to multiple movies + */ +export async function applySearchTagToMovies( + client: RadarrClient, + movies: RadarrMovie[], + tagId: number +): Promise<{ success: number; failed: number; errors: string[] }> { + let success = 0; + let failed = 0; + const errors: string[] = []; + + for (const movie of movies) { + try { + await applySearchTag(client, movie, tagId); + success++; + } catch (error) { + failed++; + errors.push(`Failed to tag "${movie.title}": ${error instanceof Error ? error.message : String(error)}`); + } + } + + return { success, failed, errors }; +} diff --git a/src/lib/server/upgrades/logger.ts b/src/lib/server/upgrades/logger.ts new file mode 100644 index 0000000..86b3d94 --- /dev/null +++ b/src/lib/server/upgrades/logger.ts @@ -0,0 +1,83 @@ +/** + * Structured logging for upgrade jobs + * Uses the shared logger with source 'UpgradeJob' + */ + +import { logger } from '$logger/logger.ts'; +import type { UpgradeJobLog } from './types.ts'; + +const SOURCE = 'UpgradeJob'; + +/** + * Log an upgrade run with structured data + * Logs info summary and debug with full details + */ +export async function logUpgradeRun(log: UpgradeJobLog): Promise { + // Build summary message + const statusEmoji = log.status === 'success' ? '✓' : log.status === 'partial' ? '~' : '✗'; + const duration = new Date(log.completedAt).getTime() - new Date(log.startedAt).getTime(); + + const summary = `[${statusEmoji}] ${log.instanceName}: ${log.filter.name} - ${log.selection.actualCount}/${log.selection.requestedCount} items searched (${duration}ms)`; + + // Log info with key metrics + await logger.info(summary, { + source: SOURCE, + meta: { + instanceId: log.instanceId, + configId: log.configId, + status: log.status, + matchedCount: log.filter.matchedCount, + afterCooldown: log.filter.afterCooldown, + searchedCount: log.selection.actualCount, + durationMs: duration + } + }); + + // Log debug with full structured data + await logger.debug('Upgrade job details', { + source: SOURCE, + meta: log + }); +} + +/** + * Log when an upgrade config is skipped + */ +export async function logUpgradeSkipped( + instanceId: number, + instanceName: string, + reason: string +): Promise { + await logger.info(`Skipped ${instanceName}: ${reason}`, { + source: SOURCE, + meta: { instanceId, reason } + }); +} + +/** + * Log when upgrade processing starts + */ +export async function logUpgradeStart( + instanceId: number, + instanceName: string, + filterName: string +): Promise { + await logger.debug(`Starting upgrade for ${instanceName} with filter "${filterName}"`, { + source: SOURCE, + meta: { instanceId, filterName } + }); +} + +/** + * Log errors during upgrade processing + */ +export async function logUpgradeError( + instanceId: number, + instanceName: string, + error: string +): Promise { + await logger.error(`Upgrade failed for ${instanceName}: ${error}`, { + source: SOURCE, + meta: { instanceId, error } + }); +} diff --git a/src/lib/server/upgrades/normalize.ts b/src/lib/server/upgrades/normalize.ts new file mode 100644 index 0000000..99eb3c5 --- /dev/null +++ b/src/lib/server/upgrades/normalize.ts @@ -0,0 +1,93 @@ +/** + * Normalization logic for converting arr library items to UpgradeItem + * Maps raw API responses to the normalized interface used by filter evaluation + */ + +import type { RadarrMovie, RadarrMovieFile, RadarrQualityProfile } from '$lib/server/utils/arr/types.ts'; +import type { UpgradeItem } from './types.ts'; + +/** + * Normalize a Radarr movie to an UpgradeItem for filter evaluation + * + * @param movie - The raw movie from Radarr API + * @param movieFile - The movie file (if exists) + * @param profile - The quality profile + * @param cutoffPercent - The cutoff percentage from filter config (0-100) + */ +export function normalizeRadarrItem( + movie: RadarrMovie, + movieFile: RadarrMovieFile | undefined, + profile: RadarrQualityProfile | undefined, + cutoffPercent: number +): UpgradeItem { + // Calculate current score + const currentScore = movieFile?.customFormatScore ?? 0; + + // Calculate cutoff threshold based on profile and filter's cutoff percent + const profileCutoff = profile?.cutoffFormatScore ?? 0; + const cutoffThreshold = (profileCutoff * cutoffPercent) / 100; + + // Determine if cutoff is met + const cutoffMet = currentScore >= cutoffThreshold; + + // Convert size to GB + const sizeOnDiskGB = (movie.sizeOnDisk ?? 0) / (1024 * 1024 * 1024); + + // Extract ratings with fallbacks + const tmdbRating = movie.ratings?.tmdb?.value ?? 0; + const imdbRating = movie.ratings?.imdb?.value ?? 0; + const tomatoRating = movie.ratings?.rottenTomatoes?.value ?? 0; + const traktRating = movie.ratings?.trakt?.value ?? 0; + + // Date added - use movie's added date + const dateAdded = movie.added ?? new Date().toISOString(); + + return { + // Core fields (snake_case for filter matching) + id: movie.id, + title: movie.title, + year: movie.year ?? 0, + monitored: movie.monitored ?? false, + cutoff_met: cutoffMet, + minimum_availability: movie.minimumAvailability ?? 'released', + quality_profile: profile?.name ?? 'Unknown', + collection: movie.collection?.title ?? movie.collection?.name ?? '', + studio: movie.studio ?? '', + original_language: movie.originalLanguage?.name ?? '', + genres: movie.genres?.join(', ') ?? '', + keywords: '', // Radarr doesn't expose keywords in basic movie endpoint + release_group: movieFile?.releaseGroup ?? '', + popularity: movie.popularity ?? 0, + runtime: movie.runtime ?? 0, + size_on_disk: sizeOnDiskGB, + tmdb_rating: tmdbRating, + imdb_rating: imdbRating, + tomato_rating: tomatoRating, + trakt_rating: traktRating, + date_added: dateAdded, + + // For selectors (camelCase) + dateAdded: dateAdded, + score: currentScore, + + // Original data + _raw: movie, + _tags: movie.tags ?? [] + }; +} + +/** + * Normalize a batch of Radarr movies + */ +export function normalizeRadarrItems( + movies: RadarrMovie[], + movieFileMap: Map, + profileMap: Map, + cutoffPercent: number +): UpgradeItem[] { + return movies.map((movie) => { + const movieFile = movieFileMap.get(movie.id); + const profile = profileMap.get(movie.qualityProfileId); + return normalizeRadarrItem(movie, movieFile, profile, cutoffPercent); + }); +} diff --git a/src/lib/server/upgrades/processor.ts b/src/lib/server/upgrades/processor.ts new file mode 100644 index 0000000..6a46e1f --- /dev/null +++ b/src/lib/server/upgrades/processor.ts @@ -0,0 +1,252 @@ +/** + * Main orchestrator for processing upgrade configs + * Coordinates fetching, filtering, selection, and searching + */ + +import { RadarrClient } from '$lib/server/utils/arr/clients/radarr.ts'; +import type { ArrInstance } from '$lib/server/db/queries/arrInstances.ts'; +import type { UpgradeConfig, FilterConfig } from '$lib/shared/filters.ts'; +import { evaluateGroup } from '$lib/shared/filters.ts'; +import { getSelector } from '$lib/shared/selectors.ts'; +import type { UpgradeItem, UpgradeJobLog } from './types.ts'; +import { normalizeRadarrItems } from './normalize.ts'; +import { filterByCooldown, getTodayTagLabel, applySearchTagToMovies } from './cooldown.ts'; +import { logUpgradeRun, logUpgradeStart, logUpgradeError, logUpgradeSkipped } from './logger.ts'; + +/** + * Get the next filter to run based on the config's mode + */ +function getNextFilter(config: UpgradeConfig): FilterConfig | null { + const enabledFilters = config.filters.filter((f) => f.enabled); + + if (enabledFilters.length === 0) { + return null; + } + + if (config.filterMode === 'random') { + const randomIndex = Math.floor(Math.random() * enabledFilters.length); + return enabledFilters[randomIndex]; + } + + // Round robin + const index = config.currentFilterIndex % enabledFilters.length; + return enabledFilters[index]; +} + +/** + * Create an empty/skipped job log + */ +function createSkippedLog( + config: UpgradeConfig, + instance: ArrInstance, + reason: string +): UpgradeJobLog { + const now = new Date().toISOString(); + return { + id: crypto.randomUUID(), + configId: config.id ?? 0, + instanceId: instance.id, + instanceName: instance.name, + startedAt: now, + completedAt: now, + status: 'skipped', + config: { + schedule: config.schedule, + filterMode: config.filterMode, + selectedFilter: '', + dryRun: config.dryRun + }, + library: { + totalItems: 0, + fetchedFromCache: false, + fetchDurationMs: 0 + }, + filter: { + name: '', + rules: { type: 'group', match: 'all', children: [] }, + matchedCount: 0, + afterCooldown: 0 + }, + selection: { + method: '', + requestedCount: 0, + actualCount: 0, + items: [] + }, + results: { + searchesTriggered: 0, + successful: 0, + failed: 0, + errors: [reason] + } + }; +} + +/** + * Process a single upgrade config for an arr instance + */ +export async function processUpgradeConfig( + config: UpgradeConfig, + instance: ArrInstance +): Promise { + const startedAt = new Date(); + const logId = crypto.randomUUID(); + + // Get the filter to run + const filter = getNextFilter(config); + + if (!filter) { + const log = createSkippedLog(config, instance, 'No enabled filters'); + await logUpgradeSkipped(instance.id, instance.name, 'No enabled filters'); + return log; + } + + await logUpgradeStart(instance.id, instance.name, filter.name); + + // Create client + const client = new RadarrClient(instance.url, instance.api_key); + + try { + // Step 1: Fetch library data + const fetchStart = Date.now(); + const [movies, profiles] = await Promise.all([ + client.getMovies(), + client.getQualityProfiles() + ]); + + // Get movie files for movies with files + const movieIdsWithFiles = movies.filter((m) => m.hasFile).map((m) => m.id); + const movieFiles = await client.getMovieFiles(movieIdsWithFiles); + + const fetchDurationMs = Date.now() - fetchStart; + + // Create lookup maps + const movieFileMap = new Map(movieFiles.map((mf) => [mf.movieId, mf])); + const profileMap = new Map(profiles.map((p) => [p.id, p])); + + // Step 2: Normalize items + const normalizedItems = normalizeRadarrItems( + movies, + movieFileMap, + profileMap, + filter.cutoff + ); + + // Step 3: Apply filter rules + const matchedItems = normalizedItems.filter((item) => + evaluateGroup(item as unknown as Record, filter.group) + ); + + // Step 4: Filter by cooldown + const tags = await client.getTags(); + const afterCooldown = filterByCooldown(matchedItems, tags, filter.searchCooldown); + + // Step 5: Apply selector + const selector = getSelector(filter.selector); + const selectedItems: UpgradeItem[] = selector + ? selector.select(afterCooldown, filter.count) + : afterCooldown.slice(0, filter.count); + + // Build selection info + const selectionItems = selectedItems.map((item) => ({ + id: item.id, + title: item.title + })); + + // Step 6: Trigger search if we have items (skip if dry run) + let searchesTriggered = 0; + let successful = 0; + let failed = 0; + const errors: string[] = []; + const isDryRun = config.dryRun; + + if (selectedItems.length > 0) { + if (isDryRun) { + // Dry run - log what would happen without actually doing it + searchesTriggered = selectedItems.length; + successful = selectedItems.length; + errors.push('[DRY RUN] Search and tagging skipped'); + } else { + try { + const movieIds = selectedItems.map((item) => item.id); + await client.searchMovies(movieIds); + searchesTriggered = movieIds.length; + + // Step 7: Apply search tag + const tagLabel = getTodayTagLabel(); + const searchTag = await client.getOrCreateTag(tagLabel); + const tagResult = await applySearchTagToMovies( + client, + selectedItems.map((item) => item._raw), + searchTag.id + ); + + successful = tagResult.success; + failed = tagResult.failed; + errors.push(...tagResult.errors); + } catch (error) { + failed = selectedItems.length; + errors.push(`Search failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + } + + // Build the log + const completedAt = new Date(); + const log: UpgradeJobLog = { + id: logId, + configId: config.id ?? 0, + instanceId: instance.id, + instanceName: instance.name, + startedAt: startedAt.toISOString(), + completedAt: completedAt.toISOString(), + status: failed > 0 && successful === 0 ? 'failed' : failed > 0 ? 'partial' : 'success', + config: { + schedule: config.schedule, + filterMode: config.filterMode, + selectedFilter: filter.name, + dryRun: isDryRun + }, + library: { + totalItems: movies.length, + fetchedFromCache: false, // TODO: implement caching + fetchDurationMs + }, + filter: { + name: filter.name, + rules: filter.group, + matchedCount: matchedItems.length, + afterCooldown: afterCooldown.length + }, + selection: { + method: filter.selector, + requestedCount: filter.count, + actualCount: selectedItems.length, + items: selectionItems + }, + results: { + searchesTriggered, + successful, + failed, + errors + } + }; + + await logUpgradeRun(log); + return log; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + await logUpgradeError(instance.id, instance.name, errorMessage); + + const log = createSkippedLog(config, instance, errorMessage); + log.id = logId; + log.startedAt = startedAt.toISOString(); + log.completedAt = new Date().toISOString(); + log.status = 'failed'; + log.config.selectedFilter = filter?.name ?? ''; + + return log; + } finally { + client.close(); + } +} diff --git a/src/lib/server/upgrades/types.ts b/src/lib/server/upgrades/types.ts new file mode 100644 index 0000000..4c079ee --- /dev/null +++ b/src/lib/server/upgrades/types.ts @@ -0,0 +1,100 @@ +/** + * Types for the upgrade processing system + */ + +import type { RadarrMovie } from '$lib/server/utils/arr/types.ts'; +import type { FilterGroup } from '$lib/shared/filters.ts'; + +/** + * Normalized item interface that matches filter field names + * Used for evaluating filter rules against library items + */ +export interface UpgradeItem { + // Core fields + id: number; + title: string; + year: number; + monitored: boolean; + cutoff_met: boolean; + minimum_availability: string; + quality_profile: string; + collection: string; + studio: string; + original_language: string; + genres: string; + keywords: string; + release_group: string; + popularity: number; + runtime: number; + size_on_disk: number; + tmdb_rating: number; + imdb_rating: number; + tomato_rating: number; + trakt_rating: number; + date_added: string; + + // For selectors (camelCase versions) + dateAdded: string; + score: number; + + // Original data for API calls + _raw: RadarrMovie; + _tags: number[]; +} + +/** + * Structured log for each upgrade run + * Contains all metrics and details about what happened + */ +export interface UpgradeJobLog { + id: string; // UUID + configId: number; + instanceId: number; + instanceName: string; + startedAt: string; + completedAt: string; + status: 'success' | 'partial' | 'failed' | 'skipped'; + + config: { + schedule: number; + filterMode: string; + selectedFilter: string; + dryRun: boolean; + }; + + library: { + totalItems: number; + fetchedFromCache: boolean; + fetchDurationMs: number; + }; + + filter: { + name: string; + rules: FilterGroup; + matchedCount: number; + afterCooldown: number; + }; + + selection: { + method: string; + requestedCount: number; + actualCount: number; + items: { id: number; title: string }[]; + }; + + results: { + searchesTriggered: number; + successful: number; + failed: number; + errors: string[]; + }; +} + +/** + * Result from processing a single upgrade config + */ +export interface UpgradeProcessResult { + success: boolean; + log: UpgradeJobLog; + error?: string; +} diff --git a/src/lib/server/utils/arr/clients/radarr.ts b/src/lib/server/utils/arr/clients/radarr.ts index f123f25..35084bf 100644 --- a/src/lib/server/utils/arr/clients/radarr.ts +++ b/src/lib/server/utils/arr/clients/radarr.ts @@ -6,7 +6,9 @@ import type { RadarrLibraryItem, ScoreBreakdownItem, CustomFormatRef, - QualityProfileFormatItem + QualityProfileFormatItem, + RadarrTag, + RadarrCommand } from '../types.ts'; /** @@ -107,6 +109,8 @@ export class RadarrClient extends BaseArrClient { qualityProfileId: movie.qualityProfileId, qualityProfileName: profileName, hasFile: movie.hasFile, + dateAdded: movie.added, + popularity: movie.popularity, customFormats, customFormatScore, qualityName: movieFile?.quality?.quality?.name, @@ -122,4 +126,62 @@ export class RadarrClient extends BaseArrClient { return libraryItems; } + + // ========================================================================= + // Search Methods + // ========================================================================= + + /** + * Trigger a search for specific movies + * Uses the MoviesSearch command endpoint + */ + searchMovies(movieIds: number[]): Promise { + return this.post(`/api/${this.apiVersion}/command`, { + name: 'MoviesSearch', + movieIds + }); + } + + // ========================================================================= + // Tag Methods + // ========================================================================= + + /** + * Get all tags + */ + getTags(): Promise { + return this.get(`/api/${this.apiVersion}/tag`); + } + + /** + * Create a new tag + */ + createTag(label: string): Promise { + return this.post(`/api/${this.apiVersion}/tag`, { label }); + } + + /** + * Get a tag by label, or create it if it doesn't exist + */ + async getOrCreateTag(label: string): Promise { + const tags = await this.getTags(); + const existing = tags.find((t) => t.label.toLowerCase() === label.toLowerCase()); + + if (existing) { + return existing; + } + + return this.createTag(label); + } + + // ========================================================================= + // Movie Update Methods + // ========================================================================= + + /** + * Update a movie (used for adding/removing tags) + */ + updateMovie(movie: RadarrMovie): Promise { + return this.put(`/api/${this.apiVersion}/movie/${movie.id}`, movie); + } } diff --git a/src/lib/server/utils/arr/types.ts b/src/lib/server/utils/arr/types.ts index 702e415..79d634d 100644 --- a/src/lib/server/utils/arr/types.ts +++ b/src/lib/server/utils/arr/types.ts @@ -29,6 +29,8 @@ export interface RadarrMovie { ratings?: { imdb?: { votes: number; value: number }; tmdb?: { votes: number; value: number }; + rottenTomatoes?: { votes: number; value: number }; + trakt?: { votes: number; value: number }; }; genres?: string[]; overview?: string; @@ -38,6 +40,17 @@ export interface RadarrMovie { rootFolderPath?: string; sizeOnDisk?: number; status?: string; + tags?: number[]; + collection?: { + title?: string; + name?: string; // Deprecated, use title + tmdbId?: number; + }; + popularity?: number; + originalLanguage?: { + id: number; + name: string; + }; } /** @@ -117,6 +130,32 @@ export interface RadarrQualityProfile { }[]; } +/** + * Tag from /api/v3/tag + */ +export interface RadarrTag { + id: number; + label: string; +} + +/** + * Command response from /api/v3/command + */ +export interface RadarrCommand { + id: number; + name: string; + commandName: string; + status: 'queued' | 'started' | 'completed' | 'failed' | string; + queued?: string; + started?: string; + ended?: string; + message?: string; + body?: { + movieIds?: number[]; + sendUpdatesToClient?: boolean; + }; +} + // ============================================================================= // Library View Types (computed/joined data) // ============================================================================= @@ -141,6 +180,8 @@ export interface RadarrLibraryItem { qualityProfileId: number; qualityProfileName: string; hasFile: boolean; + dateAdded?: string; + popularity?: number; // From /moviefile (only if hasFile) customFormats: CustomFormatRef[]; diff --git a/src/lib/server/cache/cache.ts b/src/lib/server/utils/cache/cache.ts similarity index 100% rename from src/lib/server/cache/cache.ts rename to src/lib/server/utils/cache/cache.ts diff --git a/src/lib/server/utils/logger/reader.ts b/src/lib/server/utils/logger/reader.ts index 3725041..7f51f3f 100644 --- a/src/lib/server/utils/logger/reader.ts +++ b/src/lib/server/utils/logger/reader.ts @@ -183,3 +183,72 @@ export function parseLogLine(line: string): LogEntry | null { return null; } } + +/** + * Filter options for reading logs + */ +export interface LogFilterOptions { + source?: string; + instanceId?: number; + count?: number; +} + +/** + * Read logs filtered by source and/or instanceId + * @param options Filter options + * @returns Array of filtered log entries (newest first) + */ +export async function readFilteredLogs( + options: LogFilterOptions = {} +): Promise { + const { source, instanceId, count = 500 } = options; + + try { + const logFiles = await getLogFiles(); + const logs: LogEntry[] = []; + + // Read from newest files first + for (const filePath of logFiles) { + try { + const content = await Deno.readTextFile(filePath); + const lines = content.split("\n").filter((line) => line.trim()); + + // Parse each line as JSON + for (const line of lines) { + try { + const entry = JSON.parse(line) as LogEntry; + + // Apply filters + if (source && entry.source !== source) { + continue; + } + + if (instanceId !== undefined && entry.meta) { + const meta = entry.meta as Record; + if (meta.instanceId !== instanceId) { + continue; + } + } + + logs.push(entry); + } catch { + // Skip invalid JSON lines + } + } + } catch { + // Skip files we can't read + } + } + + // Sort by timestamp (newest first) + logs.sort((a, b) => + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + ); + + // Return first N entries + return logs.slice(0, count); + } catch (_error) { + // If anything fails, return empty array + return []; + } +} diff --git a/src/lib/shared/filters.ts b/src/lib/shared/filters.ts index f4c9e11..540700a 100644 --- a/src/lib/shared/filters.ts +++ b/src/lib/shared/filters.ts @@ -8,8 +8,10 @@ export interface FilterOperator { label: string; } +export type FilterValueType = string | number | boolean | null; + export interface FilterValue { - value: any; + value: FilterValueType; label: string; } @@ -25,7 +27,7 @@ export interface FilterRule { type: 'rule'; field: string; operator: string; - value: any; + value: FilterValueType; } export interface FilterGroup { @@ -51,6 +53,7 @@ export interface UpgradeConfig { id?: number; arrInstanceId: number; enabled: boolean; + dryRun: boolean; schedule: number; // minutes filterMode: FilterMode; filters: FilterConfig[]; @@ -309,6 +312,7 @@ export function createEmptyUpgradeConfig(arrInstanceId: number): UpgradeConfig { return { arrInstanceId, enabled: false, + dryRun: false, schedule: 360, // 6 hours filterMode: 'round_robin', filters: [], @@ -342,3 +346,131 @@ export function isRule(child: FilterRule | FilterGroup): child is FilterRule { export function isGroup(child: FilterRule | FilterGroup): child is FilterGroup { return child.type === 'group'; } + +/** + * Evaluate a single filter rule against an item + */ +export function evaluateRule(item: Record, rule: FilterRule): boolean { + const fieldValue = item[rule.field]; + const ruleValue = rule.value; + + // Handle null/undefined field values + if (fieldValue === null || fieldValue === undefined) { + // For 'is_not' or negation operators, null means "not equal" so return true + if (['is_not', 'neq', 'not_contains'].includes(rule.operator)) { + return true; + } + return false; + } + + switch (rule.operator) { + // Boolean operators + case 'is': + return fieldValue === ruleValue; + case 'is_not': + return fieldValue !== ruleValue; + + // Number operators + case 'eq': + if (typeof fieldValue === 'string' && typeof ruleValue === 'string') { + return fieldValue.toLowerCase() === ruleValue.toLowerCase(); + } + return fieldValue === ruleValue; + case 'neq': + if (typeof fieldValue === 'string' && typeof ruleValue === 'string') { + return fieldValue.toLowerCase() !== ruleValue.toLowerCase(); + } + return fieldValue !== ruleValue; + case 'gt': + return typeof fieldValue === 'number' && typeof ruleValue === 'number' && fieldValue > ruleValue; + case 'gte': + return typeof fieldValue === 'number' && typeof ruleValue === 'number' && fieldValue >= ruleValue; + case 'lt': + return typeof fieldValue === 'number' && typeof ruleValue === 'number' && fieldValue < ruleValue; + case 'lte': + return typeof fieldValue === 'number' && typeof ruleValue === 'number' && fieldValue <= ruleValue; + + // Text operators (case-insensitive) + case 'contains': { + const strField = String(fieldValue).toLowerCase(); + const strRule = String(ruleValue).toLowerCase(); + return strField.includes(strRule); + } + case 'not_contains': { + const strField = String(fieldValue).toLowerCase(); + const strRule = String(ruleValue).toLowerCase(); + return !strField.includes(strRule); + } + case 'starts_with': { + const strField = String(fieldValue).toLowerCase(); + const strRule = String(ruleValue).toLowerCase(); + return strField.startsWith(strRule); + } + case 'ends_with': { + const strField = String(fieldValue).toLowerCase(); + const strRule = String(ruleValue).toLowerCase(); + return strField.endsWith(strRule); + } + + // Date operators + case 'before': { + const fieldDate = new Date(fieldValue as string); + const ruleDate = new Date(ruleValue as string); + return fieldDate < ruleDate; + } + case 'after': { + const fieldDate = new Date(fieldValue as string); + const ruleDate = new Date(ruleValue as string); + return fieldDate > ruleDate; + } + case 'in_last': { + // ruleValue is number of days/hours depending on context + const fieldDate = new Date(fieldValue as string); + const now = new Date(); + const diffMs = now.getTime() - fieldDate.getTime(); + const diffDays = diffMs / (1000 * 60 * 60 * 24); + return diffDays <= (ruleValue as number); + } + case 'not_in_last': { + const fieldDate = new Date(fieldValue as string); + const now = new Date(); + const diffMs = now.getTime() - fieldDate.getTime(); + const diffDays = diffMs / (1000 * 60 * 60 * 24); + return diffDays > (ruleValue as number); + } + + default: + return false; + } +} + +/** + * Evaluate a filter group against an item + * Supports nested groups with AND/OR logic + */ +export function evaluateGroup(item: Record, group: FilterGroup): boolean { + if (group.children.length === 0) { + // Empty group matches everything + return true; + } + + if (group.match === 'all') { + // AND logic: all children must match + return group.children.every((child) => { + if (isRule(child)) { + return evaluateRule(item, child); + } else { + return evaluateGroup(item, child); + } + }); + } else { + // OR logic: any child must match + return group.children.some((child) => { + if (isRule(child)) { + return evaluateRule(item, child); + } else { + return evaluateGroup(item, child); + } + }); + } +} diff --git a/src/routes/arr/[id]/library/+page.server.ts b/src/routes/arr/[id]/library/+page.server.ts index 75ceae6..e9e6566 100644 --- a/src/routes/arr/[id]/library/+page.server.ts +++ b/src/routes/arr/[id]/library/+page.server.ts @@ -111,14 +111,14 @@ export const load: ServerLoad = async ({ params }) => { }; export const actions: Actions = { - refresh: async ({ params }) => { + refresh: ({ params }) => { const id = parseInt(params.id || '', 10); if (!isNaN(id)) { cache.delete(`library:${id}`); } return { success: true }; }, - delete: async ({ params }) => { + delete: ({ params }) => { const id = parseInt(params.id || '', 10); if (!isNaN(id)) { arrInstancesQueries.delete(id); diff --git a/src/routes/arr/[id]/library/+page.svelte b/src/routes/arr/[id]/library/+page.svelte index ba2f45d..84eb070 100644 --- a/src/routes/arr/[id]/library/+page.svelte +++ b/src/routes/arr/[id]/library/+page.svelte @@ -1,64 +1,162 @@ {data.instance.name} - Library - Profilarr -
{#if data.libraryError}
@@ -82,7 +180,7 @@
- {:else if moviesWithFiles.length === 0} + {:else if allMoviesWithFiles.length === 0}
@@ -95,232 +193,41 @@
{:else} - -
-
-
-

{data.instance.name}

- - {data.instance.type} - - -
- {data.instance.url} -
-
-
{ - refreshing = true; - return async ({ update }) => { - await update(); - refreshing = false; - }; - }} - > - -
- - Open Radarr - - - - - -
{ - if (!confirm('Are you sure you want to delete this instance?')) { - return ({ cancel }) => cancel(); - } - return async ({ update }) => { - await update(); - }; - }} - > - -
-
-
+ - - - - - Change Profile - ({moviesWithFiles.length}) - - -
- {#if data.profilesByDatabase.length === 0} -
- No databases configured -
- {:else} - {#each data.profilesByDatabase as db} -
-
- - - {db.databaseName} - -
- {#each db.profiles as profile} - - {/each} -
- {/each} - {/if} -
-
-
-
-
+ - - row.id} compact={true}> + row.id} + compact={true} + {defaultSort} + emptyMessage={activeFilters.length > 0 || debouncedQuery ? 'No movies match the current filters' : 'No movies with files'} + > - {#if column.key === 'title'} -
-
{row.title}
- {#if row.year} -
{row.year}
- {/if} -
- {:else if column.key === 'qualityProfileName'} -
- - {#if !row.isProfilarrProfile} - - {/if} - {row.qualityProfileName} - - {#if !row.isProfilarrProfile} -
- Not managed by Profilarr -
- {/if} -
- {:else if column.key === 'qualityName'} - - {row.qualityName ?? 'N/A'} - - {:else if column.key === 'customFormatScore'} -
- - {row.customFormatScore.toLocaleString()} - - - / {row.cutoffScore.toLocaleString()} - -
- {:else if column.key === 'progress'} -
-
-
-
- {#if row.cutoffMet} - - {:else} - - {Math.round(row.progress * 100)}% - - {/if} -
- {:else if column.key === 'actions'} -
- {#if row.tmdbId} - - - - {/if} -
- {/if} +
-
- - {#if row.fileName} - {row.fileName} - {/if} - - - {#if row.scoreBreakdown.length > 0} -
- {#each row.scoreBreakdown.toSorted((a, b) => b.score - a.score) as item} -
- {item.name} - {item.score >= 0 ? '+' : ''}{item.score.toLocaleString()} -
- {/each} - - = {row.customFormatScore.toLocaleString()} - -
- {:else} -
No custom formats matched
- {/if} -
+
{/if} diff --git a/src/routes/arr/[id]/library/components/LibraryActionBar.svelte b/src/routes/arr/[id]/library/components/LibraryActionBar.svelte new file mode 100644 index 0000000..fbce0d3 --- /dev/null +++ b/src/routes/arr/[id]/library/components/LibraryActionBar.svelte @@ -0,0 +1,152 @@ + + + + + + + +
+ {#each toggleableColumns as colKey} + + {/each} +
+
+
+
+ + + +
+ +
+
+ Quality +
+ {#each uniqueQualities as quality} + + {/each} +
+ + +
+
+ Profile +
+ {#each uniqueProfiles as profile} + + {/each} +
+
+
+
+
+ + Change Profile + ({filteredCount}) + + +
+ {#if profilesByDatabase.length === 0} +
+ No databases configured +
+ {:else} + {#each profilesByDatabase as db} +
+
+ + + {db.databaseName} + +
+ {#each db.profiles as profile} + + {/each} +
+ {/each} + {/if} +
+
+
+
+
diff --git a/src/routes/arr/[id]/library/components/LibraryHeader.svelte b/src/routes/arr/[id]/library/components/LibraryHeader.svelte new file mode 100644 index 0000000..cd5a49f --- /dev/null +++ b/src/routes/arr/[id]/library/components/LibraryHeader.svelte @@ -0,0 +1,100 @@ + + +
+
+
+

{instance.name}

+ + {instance.type} + + +
+ {instance.url} +
+
+
{ + refreshing = true; + return async ({ update }) => { + await update(); + refreshing = false; + }; + }} + > + +
+ + Open Radarr + + + + + +
{ + if (!confirm('Are you sure you want to delete this instance?')) { + cancel(); + return; + } + return ({ update }) => update(); + }} + > + +
+
+
diff --git a/src/routes/arr/[id]/library/components/MovieRow.svelte b/src/routes/arr/[id]/library/components/MovieRow.svelte new file mode 100644 index 0000000..a63a1dd --- /dev/null +++ b/src/routes/arr/[id]/library/components/MovieRow.svelte @@ -0,0 +1,127 @@ + + +{#if mode === 'cell'} + {#if column.key === 'title'} +
+
{row.title}
+ {#if row.year} +
{row.year}
+ {/if} +
+ {:else if column.key === 'qualityProfileName'} +
+ + {#if !row.isProfilarrProfile} + + {/if} + {row.qualityProfileName} + + {#if !row.isProfilarrProfile} +
+ Not managed by Profilarr +
+ {/if} +
+ {:else if column.key === 'qualityName'} + + {row.qualityName ?? 'N/A'} + + {:else if column.key === 'customFormatScore'} +
+ + {row.customFormatScore.toLocaleString()} + + + / {row.cutoffScore.toLocaleString()} + +
+ {:else if column.key === 'progress'} +
+
+
+
+ {#if row.cutoffMet} + + {:else} + + {Math.round(row.progress * 100)}% + + {/if} +
+ {:else if column.key === 'popularity'} + + {row.popularity?.toFixed(1) ?? '-'} + + {:else if column.key === 'dateAdded'} + + {formatDate(row.dateAdded)} + + {:else if column.key === 'actions'} +
+ {#if row.tmdbId} + + + + {/if} +
+ {/if} +{:else} + +
+ + {#if row.fileName} + {row.fileName} + {/if} + + + {#if row.scoreBreakdown.length > 0} +
+ {#each [...row.scoreBreakdown].sort((a, b) => b.score - a.score) as item} +
+ {item.name} + {item.score >= 0 ? '+' : ''}{item.score.toLocaleString()} +
+ {/each} + + = {row.customFormatScore.toLocaleString()} + +
+ {:else} +
No custom formats matched
+ {/if} +
+{/if} diff --git a/src/routes/arr/[id]/logs/+page.server.ts b/src/routes/arr/[id]/logs/+page.server.ts index 8c8ae41..f4714d7 100644 --- a/src/routes/arr/[id]/logs/+page.server.ts +++ b/src/routes/arr/[id]/logs/+page.server.ts @@ -1,8 +1,36 @@ import { error } from '@sveltejs/kit'; import type { ServerLoad } from '@sveltejs/kit'; import { arrInstancesQueries } from '$db/queries/arrInstances.ts'; +import { readFilteredLogs } from '$logger/reader.ts'; +import type { UpgradeJobLog } from '$lib/server/upgrades/types.ts'; -export const load: ServerLoad = ({ params }) => { +/** + * Extract UpgradeJobLog from a DEBUG log entry + * DEBUG logs contain the full structured log in the meta field + */ +function extractUpgradeJobLog(meta: unknown): UpgradeJobLog | null { + if (!meta || typeof meta !== 'object') return null; + + const log = meta as Record; + + // Check for required UpgradeJobLog fields + if ( + typeof log.id === 'string' && + typeof log.instanceId === 'number' && + typeof log.status === 'string' && + log.config && + log.library && + log.filter && + log.selection && + log.results + ) { + return log as unknown as UpgradeJobLog; + } + + return null; +} + +export const load: ServerLoad = async ({ params }) => { const id = parseInt(params.id || '', 10); if (isNaN(id)) { @@ -15,7 +43,35 @@ export const load: ServerLoad = ({ params }) => { error(404, `Instance not found: ${id}`); } + // Load upgrade job logs for this instance + const logs = await readFilteredLogs({ + source: 'UpgradeJob', + instanceId: id, + count: 500 + }); + + // Extract full UpgradeJobLog objects from DEBUG entries + const upgradeRuns: UpgradeJobLog[] = []; + const seenIds = new Set(); + + for (const log of logs) { + if (log.level === 'DEBUG' && log.meta) { + const upgradeLog = extractUpgradeJobLog(log.meta); + if (upgradeLog && !seenIds.has(upgradeLog.id)) { + seenIds.add(upgradeLog.id); + upgradeRuns.push(upgradeLog); + } + } + } + + // Sort by startedAt (newest first) + upgradeRuns.sort( + (a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime() + ); + return { - instance + instance, + logs, + upgradeRuns }; }; diff --git a/src/routes/arr/[id]/logs/+page.svelte b/src/routes/arr/[id]/logs/+page.svelte index 5fe599c..92095da 100644 --- a/src/routes/arr/[id]/logs/+page.svelte +++ b/src/routes/arr/[id]/logs/+page.svelte @@ -1,21 +1,122 @@ {data.instance.name} - Logs - Profilarr -
-
-

Logs

-

- View sync and activity logs for this {data.instance.type} instance. -

-
- Logs viewer coming soon... +
+ +
+
+

Upgrade Logs

+

+ View upgrade job history for this {data.instance.type} instance. +

+
+ +
+ + +
+ +
+
+ {stats.total} total runs +
+ {#if stats.success > 0} +
+ {stats.success} successful +
+ {/if} + {#if stats.partial > 0} +
+ {stats.partial} partial +
+ {/if} + {#if stats.failed > 0} +
+ {stats.failed} failed +
+ {/if} +
+ + +
+ {#each statusFilters as filter} + + {/each}
+ + + {#if filteredRuns.length === 0} +
+

+ {#if data.upgradeRuns.length === 0} + No upgrade runs yet. Configure upgrades and run a test to see logs here. + {:else} + No runs match the selected filter. + {/if} +

+
+ {:else} +
+ {#each filteredRuns as run, index (run.id)} + + {/each} +
+ {/if}
diff --git a/src/routes/arr/[id]/logs/components/UpgradeRunCard.svelte b/src/routes/arr/[id]/logs/components/UpgradeRunCard.svelte new file mode 100644 index 0000000..0ac4cc1 --- /dev/null +++ b/src/routes/arr/[id]/logs/components/UpgradeRunCard.svelte @@ -0,0 +1,216 @@ + + + + +
+ +
(expanded = !expanded)} class="flex cursor-pointer items-start justify-between gap-4 p-4"> +
+ +
+ #{runNumber} + + {run.config.selectedFilter || 'Unknown Filter'} + + {#if run.config.dryRun} + + DRY RUN + + {/if} +
+ + +
+ {formatDate(run.startedAt)} @ {formatTime(run.startedAt)} + | + {formatDuration(run.startedAt, run.completedAt)} +
+
+ + +
+ + + {run.status.charAt(0).toUpperCase() + run.status.slice(1)} + + +
+
+ + + {#if expanded} +
+
+ +
+ Config + + Schedule: {formatSchedule(run.config.schedule)} | Mode: {formatFilterMode(run.config.filterMode)} + +
+ + +
+ Library + + {run.library.totalItems.toLocaleString()} items + {#if run.library.fetchedFromCache} + (cached) + {/if} + + ({run.library.fetchDurationMs}ms) + + +
+ + +
+ Filter + + "{run.filter.name}" + + {run.filter.matchedCount} matched + + {run.filter.afterCooldown} after cooldown + +
+ + +
+ Selection + + {formatMethod(run.selection.method)} + {run.selection.actualCount} of {run.selection.requestedCount} + +
+ + +
+ Results + + {run.results.searchesTriggered} searches triggered, + {run.results.successful} successful + {#if run.results.failed > 0} + , {run.results.failed} failed + {/if} + +
+ + + {#if run.results.errors.length > 0} +
+ Notes +
+ {#each run.results.errors as error} +
{error}
+ {/each} +
+
+ {/if} + + + {#if run.selection.items.length > 0} +
+
+ + Items Searched +
+
    + {#each run.selection.items as item} +
  • {item.title}
  • + {/each} +
+
+ {/if} +
+
+ {/if} +
diff --git a/src/routes/arr/[id]/upgrades/+page.server.ts b/src/routes/arr/[id]/upgrades/+page.server.ts index 58a3942..f0f5e8d 100644 --- a/src/routes/arr/[id]/upgrades/+page.server.ts +++ b/src/routes/arr/[id]/upgrades/+page.server.ts @@ -3,7 +3,8 @@ import type { Actions, ServerLoad } from '@sveltejs/kit'; import { arrInstancesQueries } from '$db/queries/arrInstances.ts'; import { upgradeConfigsQueries } from '$db/queries/upgradeConfigs.ts'; import { logger } from '$logger/logger.ts'; -import type { FilterConfig, FilterMode } from '$lib/shared/filters'; +import type { FilterConfig, FilterMode } from '$lib/shared/filters.ts'; +import { processUpgradeConfig } from '$lib/server/upgrades/processor.ts'; export const load: ServerLoad = ({ params }) => { const id = parseInt(params.id || '', 10); @@ -43,6 +44,7 @@ export const actions: Actions = { try { const enabled = formData.get('enabled') === 'true'; + const dryRun = formData.get('dryRun') === 'true'; const schedule = parseInt(formData.get('schedule') as string, 10) || 360; const filterMode = (formData.get('filterMode') as FilterMode) || 'round_robin'; const filtersJson = formData.get('filters') as string; @@ -50,6 +52,7 @@ export const actions: Actions = { const configData = { enabled, + dryRun, schedule, filterMode, filters @@ -67,10 +70,11 @@ export const actions: Actions = { meta: { instanceId: id, enabled, + dryRun, schedule, filterMode, filterCount: filters.length, - filters: filters.map((f) => ({ + filters: filters.map((f: FilterConfig) => ({ id: f.id, name: f.name, enabled: f.enabled, @@ -113,6 +117,7 @@ export const actions: Actions = { try { const enabled = formData.get('enabled') === 'true'; + const dryRun = formData.get('dryRun') === 'true'; const schedule = parseInt(formData.get('schedule') as string, 10) || 360; const filterMode = (formData.get('filterMode') as FilterMode) || 'round_robin'; const filtersJson = formData.get('filters') as string; @@ -120,6 +125,7 @@ export const actions: Actions = { const configData = { enabled, + dryRun, schedule, filterMode, filters @@ -137,10 +143,11 @@ export const actions: Actions = { meta: { instanceId: id, enabled, + dryRun, schedule, filterMode, filterCount: filters.length, - filters: filters.map((f) => ({ + filters: filters.map((f: FilterConfig) => ({ id: f.id, name: f.name, enabled: f.enabled, @@ -160,5 +167,78 @@ export const actions: Actions = { }); return fail(500, { error: 'Failed to update configuration' }); } + }, + + run: async ({ params }) => { + const id = parseInt(params.id || '', 10); + + if (isNaN(id)) { + return fail(400, { error: 'Invalid instance ID' }); + } + + const instance = arrInstancesQueries.getById(id); + if (!instance) { + return fail(404, { error: 'Instance not found' }); + } + + const config = upgradeConfigsQueries.getByArrInstanceId(id); + if (!config) { + return fail(404, { error: 'No upgrade configuration found. Save a configuration first.' }); + } + + if (config.filters.length === 0) { + return fail(400, { error: 'No filters configured. Add at least one filter.' }); + } + + const enabledFilters = config.filters.filter((f: FilterConfig) => f.enabled); + if (enabledFilters.length === 0) { + return fail(400, { error: 'No enabled filters. Enable at least one filter.' }); + } + + // Only support Radarr for now + if (instance.type !== 'radarr') { + return fail(400, { error: `Upgrade not yet supported for ${instance.type}` }); + } + + // Only allow manual runs in dry run mode + if (!config.dryRun) { + return fail(400, { error: 'Manual runs only allowed in Dry Run mode. Enable Dry Run first.' }); + } + + try { + await logger.info(`Manual upgrade run triggered for "${instance.name}"`, { + source: 'upgrades', + meta: { instanceId: id, dryRun: config.dryRun } + }); + + const result = await processUpgradeConfig(config, instance); + + // Update last run timestamp + upgradeConfigsQueries.updateLastRun(id); + + // Update filter index for round-robin mode + if (result.status !== 'failed' && config.filterMode === 'round_robin') { + upgradeConfigsQueries.incrementFilterIndex(id); + } + + return { + success: true, + runResult: { + status: result.status, + filterName: result.config.selectedFilter, + dryRun: result.config.dryRun, + matched: result.filter.matchedCount, + afterCooldown: result.filter.afterCooldown, + searched: result.selection.actualCount, + items: result.selection.items + } + }; + } catch (err) { + await logger.error('Manual upgrade run failed', { + source: 'upgrades', + meta: { instanceId: id, error: err } + }); + return fail(500, { error: 'Upgrade run failed. Check logs for details.' }); + } } }; diff --git a/src/routes/arr/[id]/upgrades/+page.svelte b/src/routes/arr/[id]/upgrades/+page.svelte index 287f507..907ea11 100644 --- a/src/routes/arr/[id]/upgrades/+page.svelte +++ b/src/routes/arr/[id]/upgrades/+page.svelte @@ -3,16 +3,18 @@ import type { FilterConfig, FilterMode } from '$lib/shared/filters'; import { enhance } from '$app/forms'; import { alertStore } from '$lib/client/alerts/store'; - import { Info, Save, Pencil } from 'lucide-svelte'; + import { Info, Save, Pencil, Play } from 'lucide-svelte'; import CoreSettings from './components/CoreSettings.svelte'; import FilterSettings from './components/FilterSettings.svelte'; import UpgradesInfoModal from './components/UpgradesInfoModal.svelte'; + import CooldownTracker from './components/CooldownTracker.svelte'; export let data: PageData; export let form: ActionData; // Initialize from existing config or defaults let enabled = data.config?.enabled ?? false; + let dryRun = data.config?.dryRun ?? false; let schedule = String(data.config?.schedule ?? 360); let filterMode: FilterMode = data.config?.filterMode ?? 'round_robin'; let filters: FilterConfig[] = data.config?.filters ?? []; @@ -22,11 +24,20 @@ let showInfoModal = false; let saving = false; + let running = false; // Handle form response - $: if (form?.success) { + $: if (form?.success && !form?.runResult) { alertStore.add('success', `Configuration ${isNewConfig ? 'saved' : 'updated'} successfully`); } + $: if (form?.success && form?.runResult) { + const r = form.runResult; + const dryLabel = r.dryRun ? '[DRY RUN] ' : ''; + alertStore.add( + r.status === 'success' ? 'success' : r.status === 'partial' ? 'warning' : 'error', + `${dryLabel}${r.filterName}: ${r.searched}/${r.matched} items searched (${r.afterCooldown} after cooldown)` + ); + } $: if (form?.error) { alertStore.add('error', form.error); } @@ -48,6 +59,7 @@ }} > + @@ -73,14 +85,22 @@
- + {#if !isNewConfig && data.config?.lastRunAt} + + {/if} + + - -
+ +
+ +{/if} + diff --git a/src/routes/arr/[id]/upgrades/components/CooldownTracker.svelte b/src/routes/arr/[id]/upgrades/components/CooldownTracker.svelte new file mode 100644 index 0000000..8336359 --- /dev/null +++ b/src/routes/arr/[id]/upgrades/components/CooldownTracker.svelte @@ -0,0 +1,155 @@ + + +{#if lastRunAt} +
+
+ +
+ {#if !enabled} +
+ +
+
+
Paused
+
+ Enable to resume scheduled runs +
+
+ {:else if isDue} +
+ +
+
+
Ready to run
+
+ Will run on next job cycle +
+
+ {:else} +
+ +
+
+
+ Next run in {formatTimeRemaining(timeUntilNext ?? 0)} +
+
+ Every {formatSchedule(schedule)} +
+
+ {/if} +
+ + +
+
+ + Last run +
+
+ {formatDate(lastRunAt)} @ {formatTime(lastRunAt)} +
+
+
+ + + {#if enabled && !isDue} +
+
+
+
+
+ {/if} +
+{/if} diff --git a/src/routes/arr/[id]/upgrades/components/CoreSettings.svelte b/src/routes/arr/[id]/upgrades/components/CoreSettings.svelte index c327ba0..bc46c7d 100644 --- a/src/routes/arr/[id]/upgrades/components/CoreSettings.svelte +++ b/src/routes/arr/[id]/upgrades/components/CoreSettings.svelte @@ -2,6 +2,7 @@ import { filterModes, type FilterMode } from '$lib/shared/filters'; export let enabled: boolean = true; + export let dryRun: boolean = false; export let schedule: string = '360'; export let filterMode: FilterMode = 'round_robin'; @@ -64,35 +65,69 @@
- -
-
-
diff --git a/src/routes/arr/[id]/upgrades/components/FilterGroup.svelte b/src/routes/arr/[id]/upgrades/components/FilterGroup.svelte index 20f769c..9550b0d 100644 --- a/src/routes/arr/[id]/upgrades/components/FilterGroup.svelte +++ b/src/routes/arr/[id]/upgrades/components/FilterGroup.svelte @@ -138,13 +138,13 @@ /> {:else if field?.valueType === 'number'}
- + child.value = e.detail} font="mono" />
{:else if field?.valueType === 'date'} {#if child.operator === 'in_last' || child.operator === 'not_in_last'}
- + child.value = e.detail} min={1} font="mono" />
days
diff --git a/src/routes/arr/[id]/upgrades/components/FilterSettings.svelte b/src/routes/arr/[id]/upgrades/components/FilterSettings.svelte index 472338b..a41cfc2 100644 --- a/src/routes/arr/[id]/upgrades/components/FilterSettings.svelte +++ b/src/routes/arr/[id]/upgrades/components/FilterSettings.svelte @@ -1,11 +1,12 @@
Fields + +
{#if editingId === filter.id} +