feat: run history for rename jobs

This commit is contained in:
Sam Chau
2026-01-19 01:52:26 +10:30
parent 1b3a5828c4
commit f6d99bc267
8 changed files with 801 additions and 3 deletions

View File

@@ -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

View File

@@ -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;
`
};

View File

@@ -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<RenameRunRow>(
`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<RenameRunRow>(
'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<RenameRunRow>(
`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<RenameRunRow>(
`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
};
}
};

View File

@@ -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);

View File

@@ -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<void> {
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 }
});
}
}
/**