diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index 053a9e3..2cba436 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -28,6 +28,7 @@ import { migration as migration023 } from './migrations/023_create_pattern_match import { migration as migration024 } from './migrations/024_create_arr_rename_settings.ts'; import { migration as migration025 } from './migrations/025_add_rename_notification_mode.ts'; import { migration as migration026 } from './migrations/026_create_upgrade_runs.ts'; +import { migration as migration027 } from './migrations/027_create_rename_runs.ts'; export interface Migration { version: number; @@ -268,7 +269,8 @@ export function loadMigrations(): Migration[] { migration023, migration024, migration025, - migration026 + migration026, + migration027 ]; // Sort by version number diff --git a/src/lib/server/db/migrations/027_create_rename_runs.ts b/src/lib/server/db/migrations/027_create_rename_runs.ts new file mode 100644 index 0000000..b4f4e47 --- /dev/null +++ b/src/lib/server/db/migrations/027_create_rename_runs.ts @@ -0,0 +1,91 @@ +import type { Migration } from '../migrations.ts'; + +/** + * Migration 027: Create rename_runs table + * + * Creates the table for storing rename run history. + * Similar to upgrade_runs but with rename-specific fields. + * + * Fields: + * - id: UUID primary key + * - instance_id: Foreign key to arr_instances + * - started_at, completed_at: Timestamps for the run + * - status: success, partial, failed, skipped + * - dry_run: Whether this was a dry run + * - manual: Whether this was manually triggered + * + * Config snapshot: + * - rename_folders, ignore_tag + * + * Stats (flat for easy querying): + * - library_total, after_ignore_tag, skipped_by_tag + * - files_needing_rename, files_renamed, folders_renamed + * - commands_triggered, commands_completed, commands_failed + * + * Complex data as JSON: + * - items: Array of renamed items with file paths + * - errors: Array of error strings + */ + +export const migration: Migration = { + version: 27, + name: 'Create rename_runs table', + + up: ` + CREATE TABLE rename_runs ( + id TEXT PRIMARY KEY, + + -- Relationship + instance_id INTEGER NOT NULL, + + -- Timing + started_at TEXT NOT NULL, + completed_at TEXT NOT NULL, + + -- Status + status TEXT NOT NULL CHECK (status IN ('success', 'partial', 'failed', 'skipped')), + dry_run INTEGER NOT NULL DEFAULT 1, + manual INTEGER NOT NULL DEFAULT 0, + + -- Config snapshot + rename_folders INTEGER NOT NULL DEFAULT 0, + ignore_tag TEXT, + + -- Library stats + library_total INTEGER NOT NULL, + library_fetch_ms INTEGER NOT NULL, + + -- Filtering stats + after_ignore_tag INTEGER NOT NULL, + skipped_by_tag INTEGER NOT NULL, + + -- Results stats + files_needing_rename INTEGER NOT NULL, + files_renamed INTEGER NOT NULL, + folders_renamed INTEGER NOT NULL, + commands_triggered INTEGER NOT NULL, + commands_completed INTEGER NOT NULL, + commands_failed INTEGER NOT NULL, + + -- Complex data as JSON + items TEXT NOT NULL DEFAULT '[]', + errors TEXT NOT NULL DEFAULT '[]', + + -- Metadata + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE + ); + + CREATE INDEX idx_rename_runs_instance ON rename_runs(instance_id); + CREATE INDEX idx_rename_runs_started_at ON rename_runs(started_at DESC); + CREATE INDEX idx_rename_runs_status ON rename_runs(status); + `, + + down: ` + DROP INDEX IF EXISTS idx_rename_runs_status; + DROP INDEX IF EXISTS idx_rename_runs_started_at; + DROP INDEX IF EXISTS idx_rename_runs_instance; + DROP TABLE IF EXISTS rename_runs; + ` +}; diff --git a/src/lib/server/db/queries/renameRuns.ts b/src/lib/server/db/queries/renameRuns.ts new file mode 100644 index 0000000..87d8e68 --- /dev/null +++ b/src/lib/server/db/queries/renameRuns.ts @@ -0,0 +1,251 @@ +import { db } from '../db.ts'; +import type { RenameJobLog } from '$lib/server/rename/types.ts'; + +/** + * Database row type for rename_runs table + */ +interface RenameRunRow { + id: string; + instance_id: number; + started_at: string; + completed_at: string; + status: string; + dry_run: number; + manual: number; + rename_folders: number; + ignore_tag: string | null; + library_total: number; + library_fetch_ms: number; + after_ignore_tag: number; + skipped_by_tag: number; + files_needing_rename: number; + files_renamed: number; + folders_renamed: number; + commands_triggered: number; + commands_completed: number; + commands_failed: number; + items: string; + errors: string; + created_at: string; +} + +/** + * Renamed item stored in the database + */ +interface RenamedItemRow { + id: number; + title: string; + files: { existingPath: string; newPath: string }[]; +} + +/** + * Convert database row to RenameJobLog format + */ +function rowToLog(row: RenameRunRow): RenameJobLog { + return { + id: row.id, + instanceId: row.instance_id, + instanceName: '', // Not stored, can be joined if needed + instanceType: 'radarr', // Not stored, default + startedAt: row.started_at, + completedAt: row.completed_at, + status: row.status as 'success' | 'partial' | 'failed' | 'skipped', + + config: { + dryRun: row.dry_run === 1, + renameFolders: row.rename_folders === 1, + ignoreTag: row.ignore_tag, + manual: row.manual === 1 + }, + + library: { + totalItems: row.library_total, + fetchDurationMs: row.library_fetch_ms + }, + + filtering: { + afterIgnoreTag: row.after_ignore_tag, + skippedByTag: row.skipped_by_tag + }, + + results: { + filesNeedingRename: row.files_needing_rename, + filesRenamed: row.files_renamed, + foldersRenamed: row.folders_renamed, + commandsTriggered: row.commands_triggered, + commandsCompleted: row.commands_completed, + commandsFailed: row.commands_failed, + errors: JSON.parse(row.errors) as string[] + }, + + renamedItems: JSON.parse(row.items) as RenamedItemRow[] + }; +} + +/** + * All queries for rename_runs table + */ +export const renameRunsQueries = { + /** + * Insert a new rename run + */ + insert(log: RenameJobLog): void { + db.execute( + `INSERT INTO rename_runs ( + id, instance_id, started_at, completed_at, status, dry_run, manual, + rename_folders, ignore_tag, + library_total, library_fetch_ms, + after_ignore_tag, skipped_by_tag, + files_needing_rename, files_renamed, folders_renamed, + commands_triggered, commands_completed, commands_failed, + items, errors + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + log.id, + log.instanceId, + log.startedAt, + log.completedAt, + log.status, + log.config.dryRun ? 1 : 0, + log.config.manual ? 1 : 0, + log.config.renameFolders ? 1 : 0, + log.config.ignoreTag, + log.library.totalItems, + log.library.fetchDurationMs, + log.filtering.afterIgnoreTag, + log.filtering.skippedByTag, + log.results.filesNeedingRename, + log.results.filesRenamed, + log.results.foldersRenamed, + log.results.commandsTriggered, + log.results.commandsCompleted, + log.results.commandsFailed, + JSON.stringify(log.renamedItems), + JSON.stringify(log.results.errors) + ); + }, + + /** + * Get all rename runs for an instance, newest first + */ + getByInstanceId(instanceId: number, limit = 100): RenameJobLog[] { + const rows = db.query( + `SELECT * FROM rename_runs + WHERE instance_id = ? + ORDER BY started_at DESC + LIMIT ?`, + instanceId, + limit + ); + return rows.map(rowToLog); + }, + + /** + * Get a single rename run by ID + */ + getById(id: string): RenameJobLog | undefined { + const row = db.queryFirst( + 'SELECT * FROM rename_runs WHERE id = ?', + id + ); + return row ? rowToLog(row) : undefined; + }, + + /** + * Get recent runs across all instances + */ + getRecent(limit = 50): RenameJobLog[] { + const rows = db.query( + `SELECT * FROM rename_runs + ORDER BY started_at DESC + LIMIT ?`, + limit + ); + return rows.map(rowToLog); + }, + + /** + * Get runs by status for an instance + */ + getByStatus(instanceId: number, status: string, limit = 50): RenameJobLog[] { + const rows = db.query( + `SELECT * FROM rename_runs + WHERE instance_id = ? AND status = ? + ORDER BY started_at DESC + LIMIT ?`, + instanceId, + status, + limit + ); + return rows.map(rowToLog); + }, + + /** + * Delete old runs (for retention policy) + * Returns number of rows deleted + */ + deleteOlderThan(days: number): number { + return db.execute( + `DELETE FROM rename_runs + WHERE datetime(started_at) < datetime('now', '-' || ? || ' days')`, + days + ); + }, + + /** + * Delete all runs for an instance + */ + deleteByInstanceId(instanceId: number): number { + return db.execute( + 'DELETE FROM rename_runs WHERE instance_id = ?', + instanceId + ); + }, + + /** + * Get count of runs for an instance + */ + getCount(instanceId: number): number { + const result = db.queryFirst<{ count: number }>( + 'SELECT COUNT(*) as count FROM rename_runs WHERE instance_id = ?', + instanceId + ); + return result?.count ?? 0; + }, + + /** + * Get stats summary for an instance + */ + getStats(instanceId: number): { + totalRuns: number; + successfulRuns: number; + failedRuns: number; + totalFilesRenamed: number; + totalFoldersRenamed: number; + } { + const result = db.queryFirst<{ + total_runs: number; + successful_runs: number; + failed_runs: number; + total_files_renamed: number; + total_folders_renamed: number; + }>( + `SELECT + COUNT(*) as total_runs, + SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as successful_runs, + SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_runs, + SUM(files_renamed) as total_files_renamed, + SUM(folders_renamed) as total_folders_renamed + FROM rename_runs + WHERE instance_id = ?`, + instanceId + ); + + return { + totalRuns: result?.total_runs ?? 0, + successfulRuns: result?.successful_runs ?? 0, + failedRuns: result?.failed_runs ?? 0, + totalFilesRenamed: result?.total_files_renamed ?? 0, + totalFoldersRenamed: result?.total_folders_renamed ?? 0 + }; + } +}; diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index 81cd0bc..ca0142e 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -555,3 +555,59 @@ CREATE TABLE upgrade_runs ( CREATE INDEX idx_upgrade_runs_instance ON upgrade_runs(instance_id); CREATE INDEX idx_upgrade_runs_started_at ON upgrade_runs(started_at DESC); CREATE INDEX idx_upgrade_runs_status ON upgrade_runs(status); + +-- ============================================================================== +-- TABLE: rename_runs +-- Purpose: Store rename run history for each arr instance +-- Migration: 027_create_rename_runs.ts +-- ============================================================================== + +CREATE TABLE rename_runs ( + id TEXT PRIMARY KEY, -- UUID + + -- Relationship + instance_id INTEGER NOT NULL, -- Foreign key to arr_instances + + -- Timing + started_at TEXT NOT NULL, -- ISO timestamp when run started + completed_at TEXT NOT NULL, -- ISO timestamp when run completed + + -- Status + status TEXT NOT NULL CHECK (status IN ('success', 'partial', 'failed', 'skipped')), + dry_run INTEGER NOT NULL DEFAULT 1, -- 1=dry run, 0=live + manual INTEGER NOT NULL DEFAULT 0, -- 1=manually triggered, 0=scheduled + + -- Config snapshot + rename_folders INTEGER NOT NULL DEFAULT 0, -- 1=rename folders too + ignore_tag TEXT, -- Tag name to skip + + -- Library stats + library_total INTEGER NOT NULL, -- Total items in library + library_fetch_ms INTEGER NOT NULL, -- Time to fetch library in ms + + -- Filtering stats + after_ignore_tag INTEGER NOT NULL, -- Items after ignore tag filter + skipped_by_tag INTEGER NOT NULL, -- Items skipped due to tag + + -- Results stats + files_needing_rename INTEGER NOT NULL, -- Files that need renaming + files_renamed INTEGER NOT NULL, -- Files actually renamed + folders_renamed INTEGER NOT NULL, -- Folders renamed + commands_triggered INTEGER NOT NULL, -- Rename commands triggered + commands_completed INTEGER NOT NULL, -- Commands completed successfully + commands_failed INTEGER NOT NULL, -- Commands that failed + + -- Complex data as JSON + items TEXT NOT NULL DEFAULT '[]', -- JSON array of renamed items + errors TEXT NOT NULL DEFAULT '[]', -- JSON array of error strings + + -- Metadata + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE +); + +-- Rename runs indexes (Migration: 027_create_rename_runs.ts) +CREATE INDEX idx_rename_runs_instance ON rename_runs(instance_id); +CREATE INDEX idx_rename_runs_started_at ON rename_runs(started_at DESC); +CREATE INDEX idx_rename_runs_status ON rename_runs(status); diff --git a/src/lib/server/rename/logger.ts b/src/lib/server/rename/logger.ts index 9ff9a66..95ae6f0 100644 --- a/src/lib/server/rename/logger.ts +++ b/src/lib/server/rename/logger.ts @@ -1,9 +1,11 @@ /** * Structured logging for rename jobs * Uses the shared logger with source 'RenameJob' + * Stores run history in the database */ import { logger } from '$logger/logger.ts'; +import { renameRunsQueries } from '$db/queries/renameRuns.ts'; import type { RenameJobLog } from './types.ts'; const SOURCE = 'RenameJob'; @@ -41,6 +43,16 @@ export async function logRenameRun(log: RenameJobLog): Promise { items } }); + + // Save full structured data to database + try { + renameRunsQueries.insert(log); + } catch (err) { + await logger.error(`Failed to save rename run to database: ${err}`, { + source: SOURCE, + meta: { runId: log.id, error: err } + }); + } } /** diff --git a/src/routes/arr/[id]/rename/+page.server.ts b/src/routes/arr/[id]/rename/+page.server.ts index 3a46541..2ff625d 100644 --- a/src/routes/arr/[id]/rename/+page.server.ts +++ b/src/routes/arr/[id]/rename/+page.server.ts @@ -2,6 +2,7 @@ import { error, fail } from '@sveltejs/kit'; import type { Actions, ServerLoad } from '@sveltejs/kit'; import { arrInstancesQueries } from '$db/queries/arrInstances.ts'; import { arrRenameSettingsQueries } from '$db/queries/arrRenameSettings.ts'; +import { renameRunsQueries } from '$db/queries/renameRuns.ts'; import { logger } from '$logger/logger.ts'; import { processRenameConfig } from '$lib/server/rename/processor.ts'; @@ -19,10 +20,12 @@ export const load: ServerLoad = ({ params }) => { } const settings = arrRenameSettingsQueries.getByInstanceId(id); + const renameRuns = renameRunsQueries.getByInstanceId(id); return { instance, - settings: settings ?? null + settings: settings ?? null, + renameRuns }; }; diff --git a/src/routes/arr/[id]/rename/+page.svelte b/src/routes/arr/[id]/rename/+page.svelte index 71517fe..22b4030 100644 --- a/src/routes/arr/[id]/rename/+page.svelte +++ b/src/routes/arr/[id]/rename/+page.svelte @@ -4,8 +4,9 @@ import { onMount } from 'svelte'; import { alertStore } from '$lib/client/alerts/store'; import { isDirty, initEdit, initCreate, update, clear } from '$lib/client/stores/dirty'; - import { Info, Save, Play, Settings } from 'lucide-svelte'; + import { Info, Save, Play, Settings, History } from 'lucide-svelte'; import RenameSettings from './components/RenameSettings.svelte'; + import RenameRunHistory from './components/RenameRunHistory.svelte'; import RenameInfoModal from './components/RenameInfoModal.svelte'; import DirtyModal from '$lib/client/ui/modal/DirtyModal.svelte'; import StickyCard from '$ui/card/StickyCard.svelte'; @@ -136,6 +137,14 @@ +
+

+ + Run History +

+ +
+
+ import { AlertTriangle, X, FileText, Calendar, CircleDot, Check } from 'lucide-svelte'; + import type { RenameJobLog } from '$lib/server/rename/types.ts'; + import { createSearchStore } from '$lib/client/stores/search'; + import ActionsBar from '$ui/actions/ActionsBar.svelte'; + import SearchAction from '$ui/actions/SearchAction.svelte'; + import ActionButton from '$ui/actions/ActionButton.svelte'; + import Dropdown from '$ui/dropdown/Dropdown.svelte'; + import DropdownItem from '$ui/dropdown/DropdownItem.svelte'; + import ExpandableTable from '$ui/table/ExpandableTable.svelte'; + import Badge from '$ui/badge/Badge.svelte'; + import type { Column } from '$ui/table/types'; + + const searchStore = createSearchStore(); + const debouncedQuery = searchStore.debouncedQuery; + + export let runs: RenameJobLog[] = []; + + // Filter state + let dateFilter: 'all' | 'today' | 'yesterday' | 'week' | 'month' = 'all'; + let statusFilter: 'all' | 'success' | 'partial' | 'failed' | 'skipped' = 'all'; + + // Filter runs based on all criteria + $: filteredRuns = filterRuns(runs, $debouncedQuery, dateFilter, statusFilter); + + function filterRuns( + items: RenameJobLog[], + query: string, + date: typeof dateFilter, + status: typeof statusFilter + ): RenameJobLog[] { + let result = items; + + // Text search (search in renamed item titles) + if (query) { + const queryLower = query.toLowerCase(); + result = result.filter( + (item) => + item.renamedItems.some((r) => r.title.toLowerCase().includes(queryLower)) || + item.status.toLowerCase().includes(queryLower) + ); + } + + // Date filter + if (date !== 'all') { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + const weekAgo = new Date(today); + weekAgo.setDate(weekAgo.getDate() - 7); + const monthAgo = new Date(today); + monthAgo.setDate(monthAgo.getDate() - 30); + + result = result.filter((item) => { + const itemDate = new Date(item.startedAt); + switch (date) { + case 'today': + return itemDate >= today; + case 'yesterday': + return itemDate >= yesterday && itemDate < today; + case 'week': + return itemDate >= weekAgo; + case 'month': + return itemDate >= monthAgo; + default: + return true; + } + }); + } + + // Status filter + if (status !== 'all') { + result = result.filter((item) => item.status === status); + } + + return result; + } + + // Check if any filters are active + $: hasActiveFilters = dateFilter !== 'all' || statusFilter !== 'all'; + + let expandedIds: Set = new Set(); + + const columns: Column[] = [ + { key: 'runNumber', header: '#', sortable: false }, + { key: 'date', header: 'Date', sortable: true }, + { key: 'duration', header: 'Duration', sortable: false }, + { key: 'status', header: 'Status', sortable: true }, + { key: 'summary', header: 'Summary', sortable: false } + ]; + + // Status badge config + const statusConfig = { + success: { variant: 'success' as const, icon: Check }, + partial: { variant: 'warning' as const, icon: AlertTriangle }, + failed: { variant: 'danger' as const, icon: X }, + skipped: { variant: 'neutral' as const, icon: X } + }; + + function getRunNumber(row: RenameJobLog): number { + const originalIndex = runs.findIndex((r) => r.id === row.id); + return runs.length - originalIndex; + } + + function formatDate(isoString: string): string { + const date = new Date(isoString); + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + let dateStr: string; + if (date.toDateString() === today.toDateString()) { + dateStr = 'Today'; + } else if (date.toDateString() === yesterday.toDateString()) { + dateStr = 'Yesterday'; + } else { + dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + } + + const timeStr = date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + + return `${dateStr}, ${timeStr}`; + } + + function formatDuration(startedAt: string, completedAt: string): string { + const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime(); + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; + return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`; + } + + function getFileName(path: string): string { + return path.split('/').pop() || path; + } + + +
+
+ + + + + + + {#each [ + { value: 'all', label: 'All time' }, + { value: 'today', label: 'Today' }, + { value: 'yesterday', label: 'Yesterday' }, + { value: 'week', label: 'Last 7 days' }, + { value: 'month', label: 'Last 30 days' } + ] as option} + (dateFilter = option.value)} + /> + {/each} + + + + + + + {#each [ + { value: 'all', label: 'All' }, + { value: 'success', label: 'Success' }, + { value: 'partial', label: 'Partial' }, + { value: 'failed', label: 'Failed' }, + { value: 'skipped', label: 'Skipped' } + ] as option} + (statusFilter = option.value)} + /> + {/each} + + + +
+ + row.id} + bind:expandedRows={expandedIds} + chevronPosition="right" + flushExpanded={true} + emptyMessage="No rename runs yet. Configure and enable rename to start." + > + + {#if column.key === 'runNumber'} + + #{getRunNumber(row)} + + {:else if column.key === 'date'} +
+ + {formatDate(row.startedAt)} + + {#if row.config.dryRun} + + DRY + + {/if} + {#if row.config.manual} + + MANUAL + + {/if} +
+ {:else if column.key === 'duration'} + + {formatDuration(row.startedAt, row.completedAt)} + + {:else if column.key === 'status'} + {@const config = statusConfig[row.status] || statusConfig.failed} + + {row.status.charAt(0).toUpperCase() + row.status.slice(1)} + + {:else if column.key === 'summary'} + + {row.library.totalItems.toLocaleString()} scanned + + {row.results.filesNeedingRename} need rename + + {row.results.filesRenamed} + {row.config.dryRun ? 'would rename' : 'renamed'} + + {/if} +
+ + +
+ +
+ Config + + {#if row.config.renameFolders}Folders enabled{:else}Files only{/if} + {#if row.config.ignoreTag} + | Ignore tag: "{row.config.ignoreTag}" + {/if} + +
+ + +
+ Library + + {row.library.totalItems.toLocaleString()} items + + ({row.library.fetchDurationMs}ms) + + +
+ + + {#if row.filtering.skippedByTag > 0} +
+ Filtered + + {row.filtering.skippedByTag} skipped by tag + + {row.filtering.afterIgnoreTag} remaining + +
+ {/if} + + +
+ Results + + {#if row.config.dryRun} + {row.results.filesNeedingRename} files would be renamed + {:else} + {row.results.filesRenamed}/{row.results.filesNeedingRename} files renamed + {#if row.results.foldersRenamed > 0} + , {row.results.foldersRenamed} folders + {/if} + {/if} + {#if row.results.commandsFailed > 0} + , {row.results.commandsFailed} failed + {/if} + +
+ + + {#if row.renamedItems.length > 0} + {@const itemsColumns = [ + { key: 'title', header: 'Title', sortable: false }, + { key: 'files', header: 'Files', sortable: false, align: 'center' as const } + ]} +
+
+ + Items {row.config.dryRun ? 'Needing Rename' : 'Renamed'} +
+ item.id} + compact={true} + emptyMessage="No items" + > + + {#if column.key === 'title'} + {item.title} + {:else if column.key === 'files'} + {item.files.length} + {/if} + + + +
+ {#each item.files as file} +
+
+ From: + + {getFileName(file.existingPath)} + +
+
+ To: + + {getFileName(file.newPath)} + +
+
+ {/each} +
+
+
+
+ {/if} +
+
+
+