From 874f04b7a5bd4035e9bf7c76469368ad24f498a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Mon, 13 Apr 2026 07:41:19 +0200 Subject: [PATCH] wire scheduler into queue, add retry, dev-reset cleanup, biome 2.4 migrate - execute: actually call isInScheduleWindow/waitForWindow/sleepBetweenJobs in runSequential (they were dead code); emit queue_status SSE events (running/paused/sleeping/idle) so the pipeline's existing QueueStatus listener lights up - review: POST /:id/retry resets an errored plan to approved, wipes old done/error jobs, rebuilds command from current decisions, queues fresh job - scan: dev-mode DELETE now also wipes jobs + subtitle_files (previously orphaned after every dev reset) - biome: migrate config to 2.4 schema, autoformat 68 files (strings + indentation), relax opinionated a11y/hooks-deps/index-key rules that don't fit this codebase - routeTree.gen.ts regenerated after /nodes removal --- biome.json | 25 +- server/api/dashboard.ts | 32 +- server/api/execute.ts | 239 +++++--- server/api/paths.ts | 8 +- server/api/review.ts | 526 ++++++++++++------ server/api/scan.ts | 218 +++++--- server/api/setup.ts | 82 +-- server/api/subtitles.ts | 400 ++++++++----- server/db/index.ts | 109 ++-- server/db/schema.ts | 34 +- server/index.tsx | 65 ++- server/lib/__tests__/validate.test.ts | 42 +- server/lib/validate.ts | 4 +- server/services/__tests__/analyzer.test.ts | 162 +++--- server/services/__tests__/ffmpeg.test.ts | 194 ++++--- server/services/analyzer.ts | 91 ++- server/services/apple-compat.ts | 59 +- server/services/ffmpeg.ts | 324 ++++++----- server/services/jellyfin.ts | 123 ++-- server/services/radarr.ts | 76 +-- server/services/scheduler.ts | 26 +- server/services/sonarr.ts | 69 ++- server/types.ts | 39 +- src/features/dashboard/DashboardPage.tsx | 61 +- src/features/execute/ExecutePage.tsx | 330 +++++++---- src/features/paths/PathsPage.tsx | 45 +- src/features/pipeline/DoneColumn.tsx | 10 +- src/features/pipeline/PipelineCard.tsx | 35 +- src/features/pipeline/PipelinePage.tsx | 30 +- src/features/pipeline/ProcessingColumn.tsx | 16 +- src/features/pipeline/QueueColumn.tsx | 10 +- src/features/pipeline/ReviewColumn.tsx | 24 +- src/features/pipeline/ScheduleControls.tsx | 23 +- src/features/pipeline/SeriesCard.tsx | 45 +- src/features/review/AudioDetailPage.tsx | 338 ++++++----- src/features/review/AudioListPage.tsx | 379 +++++++++---- src/features/scan/ScanPage.tsx | 123 ++-- src/features/setup/SetupPage.tsx | 267 ++++++--- src/features/subtitles/SubtitleDetailPage.tsx | 312 ++++++----- .../subtitles/SubtitleExtractPage.tsx | 224 +++++--- src/features/subtitles/SubtitleListPage.tsx | 117 ++-- src/index.css | 8 +- src/main.tsx | 22 +- src/routes/__root.tsx | 38 +- src/routes/execute.tsx | 10 +- src/routes/index.tsx | 6 +- src/routes/paths.tsx | 6 +- src/routes/pipeline.tsx | 6 +- src/routes/review.tsx | 4 +- src/routes/review/audio/$id.tsx | 6 +- src/routes/review/audio/index.tsx | 10 +- src/routes/review/index.tsx | 8 +- src/routes/review/subtitles/$id.tsx | 6 +- src/routes/review/subtitles/extract.tsx | 10 +- src/routes/review/subtitles/index.tsx | 6 +- src/routes/scan.tsx | 6 +- src/routes/settings.tsx | 6 +- src/shared/components/ui/alert.tsx | 16 +- src/shared/components/ui/badge.tsx | 30 +- src/shared/components/ui/button.tsx | 26 +- src/shared/components/ui/filter-tabs.tsx | 39 +- src/shared/components/ui/input.tsx | 10 +- src/shared/components/ui/select.tsx | 10 +- src/shared/components/ui/textarea.tsx | 8 +- src/shared/lib/api.ts | 13 +- src/shared/lib/lang.ts | 55 +- src/shared/lib/types.ts | 14 +- src/shared/lib/utils.ts | 4 +- vite.config.ts | 24 +- 69 files changed, 3511 insertions(+), 2232 deletions(-) diff --git a/biome.json b/biome.json index 1c40799..14e188b 100644 --- a/biome.json +++ b/biome.json @@ -1,7 +1,7 @@ { - "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.4/schema.json", "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, - "organizeImports": { "enabled": true }, + "assist": { "actions": { "source": { "organizeImports": "on" } } }, "formatter": { "enabled": true, "indentStyle": "tab", @@ -12,11 +12,26 @@ "enabled": true, "rules": { "recommended": true, - "suspicious": { "noExplicitAny": "off" }, - "style": { "noNonNullAssertion": "off" } + "suspicious": { + "noExplicitAny": "off", + "noArrayIndexKey": "off" + }, + "style": { + "noNonNullAssertion": "off" + }, + "correctness": { + "useExhaustiveDependencies": "off", + "noInvalidUseBeforeDeclaration": "off" + }, + "a11y": { + "useButtonType": "off", + "noLabelWithoutControl": "off", + "noStaticElementInteractions": "off", + "useKeyWithClickEvents": "off" + } } }, "files": { - "ignore": ["node_modules", "dist", "src/routeTree.gen.ts"] + "includes": ["**", "!**/node_modules", "!**/dist", "!**/src/routeTree.gen.ts"] } } diff --git a/server/api/dashboard.ts b/server/api/dashboard.ts index a408bfd..dee315b 100644 --- a/server/api/dashboard.ts +++ b/server/api/dashboard.ts @@ -1,22 +1,32 @@ -import { Hono } from 'hono'; -import { getDb, getConfig } from '../db/index'; +import { Hono } from "hono"; +import { getConfig, getDb } from "../db/index"; const app = new Hono(); -app.get('/', (c) => { +app.get("/", (c) => { const db = getDb(); - const totalItems = (db.prepare('SELECT COUNT(*) as n FROM media_items').get() as { n: number }).n; - const scanned = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'scanned'").get() as { n: number }).n; - const needsAction = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number }).n; - const noChange = (db.prepare('SELECT COUNT(*) as n FROM review_plans WHERE is_noop = 1').get() as { n: number }).n; - const approved = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'approved'").get() as { n: number }).n; + const totalItems = (db.prepare("SELECT COUNT(*) as n FROM media_items").get() as { n: number }).n; + const scanned = ( + db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'scanned'").get() as { n: number } + ).n; + const needsAction = ( + db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number } + ).n; + const noChange = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE is_noop = 1").get() as { n: number }).n; + const approved = ( + db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'approved'").get() as { n: number } + ).n; const done = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'done'").get() as { n: number }).n; const errors = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'error'").get() as { n: number }).n; - const scanRunning = getConfig('scan_running') === '1'; - const setupComplete = getConfig('setup_complete') === '1'; + const scanRunning = getConfig("scan_running") === "1"; + const setupComplete = getConfig("setup_complete") === "1"; - return c.json({ stats: { totalItems, scanned, needsAction, approved, done, errors, noChange }, scanRunning, setupComplete }); + return c.json({ + stats: { totalItems, scanned, needsAction, approved, done, errors, noChange }, + scanRunning, + setupComplete, + }); }); export default app; diff --git a/server/api/execute.ts b/server/api/execute.ts index 6310897..6a8c869 100644 --- a/server/api/execute.ts +++ b/server/api/execute.ts @@ -1,11 +1,19 @@ -import { Hono } from 'hono'; -import { stream } from 'hono/streaming'; -import { getDb } from '../db/index'; -import type { Job, MediaItem, MediaStream } from '../types'; -import { predictExtractedFiles } from '../services/ffmpeg'; -import { accessSync, constants } from 'node:fs'; -import { log, error as logError } from '../lib/log'; -import { getSchedulerState, updateSchedulerState } from '../services/scheduler'; +import { accessSync, constants } from "node:fs"; +import { Hono } from "hono"; +import { stream } from "hono/streaming"; +import { getDb } from "../db/index"; +import { log, error as logError } from "../lib/log"; +import { predictExtractedFiles } from "../services/ffmpeg"; +import { + getSchedulerState, + isInScheduleWindow, + msUntilWindow, + nextWindowTime, + sleepBetweenJobs, + updateSchedulerState, + waitForWindow, +} from "../services/scheduler"; +import type { Job, MediaItem, MediaStream } from "../types"; const app = new Hono(); @@ -13,17 +21,45 @@ const app = new Hono(); let queueRunning = false; +function emitQueueStatus( + status: "running" | "paused" | "sleeping" | "idle", + extra: { until?: string; seconds?: number } = {}, +): void { + const line = `event: queue_status\ndata: ${JSON.stringify({ status, ...extra })}\n\n`; + for (const l of jobListeners) l(line); +} + async function runSequential(jobs: Job[]): Promise { if (queueRunning) return; queueRunning = true; try { + let first = true; for (const job of jobs) { + // Pause outside the scheduler window + if (!isInScheduleWindow()) { + emitQueueStatus("paused", { until: nextWindowTime(), seconds: Math.round(msUntilWindow() / 1000) }); + await waitForWindow(); + } + + // Sleep between jobs (but not before the first one) + if (!first) { + const state = getSchedulerState(); + if (state.job_sleep_seconds > 0) { + emitQueueStatus("sleeping", { seconds: state.job_sleep_seconds }); + await sleepBetweenJobs(); + } + } + first = false; + // Atomic claim: only pick up jobs still pending const db = getDb(); const claimed = db - .prepare("UPDATE jobs SET status = 'running', started_at = datetime('now'), output = '' WHERE id = ? AND status = 'pending'") + .prepare( + "UPDATE jobs SET status = 'running', started_at = datetime('now'), output = '' WHERE id = ? AND status = 'pending'", + ) .run(job.id); if (claimed.changes === 0) continue; // cancelled or already running + emitQueueStatus("running"); try { await runJob(job); } catch (err) { @@ -32,6 +68,7 @@ async function runSequential(jobs: Job[]): Promise { } } finally { queueRunning = false; + emitQueueStatus("idle"); } } @@ -59,49 +96,89 @@ function parseFFmpegDuration(line: string): number | null { function loadJobRow(jobId: number) { const db = getDb(); - const row = db.prepare(` + const row = db + .prepare(` SELECT j.*, mi.id as mi_id, mi.name, mi.type, mi.series_name, mi.season_number, mi.episode_number, mi.file_path FROM jobs j LEFT JOIN media_items mi ON mi.id = j.item_id WHERE j.id = ? - `).get(jobId) as (Job & { - mi_id: number | null; name: string | null; type: string | null; - series_name: string | null; season_number: number | null; episode_number: number | null; - file_path: string | null; - }) | undefined; + `) + .get(jobId) as + | (Job & { + mi_id: number | null; + name: string | null; + type: string | null; + series_name: string | null; + season_number: number | null; + episode_number: number | null; + file_path: string | null; + }) + | undefined; if (!row) return null; - const item = row.name ? { id: row.item_id, name: row.name, type: row.type, series_name: row.series_name, season_number: row.season_number, episode_number: row.episode_number, file_path: row.file_path } as unknown as MediaItem : null; + const item = row.name + ? ({ + id: row.item_id, + name: row.name, + type: row.type, + series_name: row.series_name, + season_number: row.season_number, + episode_number: row.episode_number, + file_path: row.file_path, + } as unknown as MediaItem) + : null; return { job: row as unknown as Job, item }; } // ─── List ───────────────────────────────────────────────────────────────────── -app.get('/', (c) => { +app.get("/", (c) => { const db = getDb(); - const filter = (c.req.query('filter') ?? 'pending') as 'all' | 'pending' | 'running' | 'done' | 'error'; + const filter = (c.req.query("filter") ?? "pending") as "all" | "pending" | "running" | "done" | "error"; - const validFilters = ['all', 'pending', 'running', 'done', 'error']; - const whereClause = validFilters.includes(filter) && filter !== 'all' ? `WHERE j.status = ?` : ''; + const validFilters = ["all", "pending", "running", "done", "error"]; + const whereClause = validFilters.includes(filter) && filter !== "all" ? `WHERE j.status = ?` : ""; const params = whereClause ? [filter] : []; - const jobRows = db.prepare(` + const jobRows = db + .prepare(` SELECT j.*, mi.name, mi.type, mi.series_name, mi.season_number, mi.episode_number, mi.file_path FROM jobs j LEFT JOIN media_items mi ON mi.id = j.item_id ${whereClause} ORDER BY j.created_at DESC LIMIT 200 - `).all(...params) as (Job & { name: string; type: string; series_name: string | null; season_number: number | null; episode_number: number | null; file_path: string })[]; + `) + .all(...params) as (Job & { + name: string; + type: string; + series_name: string | null; + season_number: number | null; + episode_number: number | null; + file_path: string; + })[]; const jobs = jobRows.map((r) => ({ job: r as unknown as Job, - item: r.name ? { id: r.item_id, name: r.name, type: r.type, series_name: r.series_name, season_number: r.season_number, episode_number: r.episode_number, file_path: r.file_path } as unknown as MediaItem : null, + item: r.name + ? ({ + id: r.item_id, + name: r.name, + type: r.type, + series_name: r.series_name, + season_number: r.season_number, + episode_number: r.episode_number, + file_path: r.file_path, + } as unknown as MediaItem) + : null, })); - const countRows = db.prepare('SELECT status, COUNT(*) as cnt FROM jobs GROUP BY status').all() as { status: string; cnt: number }[]; + const countRows = db.prepare("SELECT status, COUNT(*) as cnt FROM jobs GROUP BY status").all() as { + status: string; + cnt: number; + }[]; const totalCounts: Record = { all: 0, pending: 0, running: 0, done: 0, error: 0 }; for (const row of countRows) { totalCounts[row.status] = row.cnt; @@ -121,22 +198,22 @@ function parseId(raw: string | undefined): number | null { // ─── Start all pending ──────────────────────────────────────────────────────── -app.post('/start', (c) => { +app.post("/start", (c) => { const db = getDb(); const pending = db.prepare("SELECT * FROM jobs WHERE status = 'pending' ORDER BY created_at").all() as Job[]; - runSequential(pending).catch((err) => logError('Queue failed:', err)); + runSequential(pending).catch((err) => logError("Queue failed:", err)); return c.json({ ok: true, started: pending.length }); }); // ─── Run single ─────────────────────────────────────────────────────────────── -app.post('/job/:id/run', async (c) => { - const jobId = parseId(c.req.param('id')); - if (jobId == null) return c.json({ error: 'invalid job id' }, 400); +app.post("/job/:id/run", async (c) => { + const jobId = parseId(c.req.param("id")); + if (jobId == null) return c.json({ error: "invalid job id" }, 400); const db = getDb(); - const job = db.prepare('SELECT * FROM jobs WHERE id = ?').get(jobId) as Job | undefined; + const job = db.prepare("SELECT * FROM jobs WHERE id = ?").get(jobId) as Job | undefined; if (!job) return c.notFound(); - if (job.status !== 'pending') { + if (job.status !== "pending") { const result = loadJobRow(jobId); if (!result) return c.notFound(); return c.json(result); @@ -149,9 +226,9 @@ app.post('/job/:id/run', async (c) => { // ─── Cancel ─────────────────────────────────────────────────────────────────── -app.post('/job/:id/cancel', (c) => { - const jobId = parseId(c.req.param('id')); - if (jobId == null) return c.json({ error: 'invalid job id' }, 400); +app.post("/job/:id/cancel", (c) => { + const jobId = parseId(c.req.param("id")); + if (jobId == null) return c.json({ error: "invalid job id" }, 400); const db = getDb(); db.prepare("DELETE FROM jobs WHERE id = ? AND status = 'pending'").run(jobId); return c.json({ ok: true }); @@ -159,18 +236,20 @@ app.post('/job/:id/cancel', (c) => { // ─── Clear queue ────────────────────────────────────────────────────────────── -app.post('/clear', (c) => { +app.post("/clear", (c) => { const db = getDb(); - db.prepare(` + db + .prepare(` UPDATE review_plans SET status = 'pending', reviewed_at = NULL WHERE item_id IN (SELECT item_id FROM jobs WHERE status = 'pending') AND status = 'approved' - `).run(); + `) + .run(); const result = db.prepare("DELETE FROM jobs WHERE status = 'pending'").run(); return c.json({ ok: true, cleared: result.changes }); }); -app.post('/clear-completed', (c) => { +app.post("/clear-completed", (c) => { const db = getDb(); const result = db.prepare("DELETE FROM jobs WHERE status IN ('done', 'error')").run(); return c.json({ ok: true, cleared: result.changes }); @@ -178,26 +257,34 @@ app.post('/clear-completed', (c) => { // ─── SSE ────────────────────────────────────────────────────────────────────── -app.get('/events', (c) => { +app.get("/events", (c) => { return stream(c, async (s) => { - c.header('Content-Type', 'text/event-stream'); - c.header('Cache-Control', 'no-cache'); + c.header("Content-Type", "text/event-stream"); + c.header("Cache-Control", "no-cache"); const queue: string[] = []; let resolve: (() => void) | null = null; - const listener = (data: string) => { queue.push(data); resolve?.(); }; + const listener = (data: string) => { + queue.push(data); + resolve?.(); + }; jobListeners.add(listener); - s.onAbort(() => { jobListeners.delete(listener); }); + s.onAbort(() => { + jobListeners.delete(listener); + }); try { while (!s.closed) { if (queue.length > 0) { await s.write(queue.shift()!); } else { - await new Promise((res) => { resolve = res; setTimeout(res, 15_000); }); + await new Promise((res) => { + resolve = res; + setTimeout(res, 15_000); + }); resolve = null; - if (queue.length === 0) await s.write(': keepalive\n\n'); + if (queue.length === 0) await s.write(": keepalive\n\n"); } } } finally { @@ -213,30 +300,34 @@ async function runJob(job: Job): Promise { log(`Job ${job.id} command: ${job.command}`); const db = getDb(); - const itemRow = db.prepare('SELECT file_path FROM media_items WHERE id = ?').get(job.item_id) as { file_path: string } | undefined; + const itemRow = db.prepare("SELECT file_path FROM media_items WHERE id = ?").get(job.item_id) as + | { file_path: string } + | undefined; if (itemRow?.file_path) { try { accessSync(itemRow.file_path, constants.R_OK | constants.W_OK); } catch (fsErr) { const msg = `File not accessible: ${itemRow.file_path}\n${(fsErr as Error).message}`; - db.prepare("UPDATE jobs SET status = 'error', output = ?, exit_code = 1, completed_at = datetime('now') WHERE id = ?").run(msg, job.id); - emitJobUpdate(job.id, 'error', msg); + db + .prepare("UPDATE jobs SET status = 'error', output = ?, exit_code = 1, completed_at = datetime('now') WHERE id = ?") + .run(msg, job.id); + emitJobUpdate(job.id, "error", msg); db.prepare("UPDATE review_plans SET status = 'error' WHERE item_id = ?").run(job.item_id); return; } } - emitJobUpdate(job.id, 'running'); + emitJobUpdate(job.id, "running"); const outputLines: string[] = []; let pendingFlush = false; let lastFlushAt = 0; let totalSeconds = 0; let lastProgressEmit = 0; - const updateOutput = db.prepare('UPDATE jobs SET output = ? WHERE id = ?'); + const updateOutput = db.prepare("UPDATE jobs SET output = ? WHERE id = ?"); const flush = (final = false) => { - const text = outputLines.join('\n'); + const text = outputLines.join("\n"); const now = Date.now(); if (final || now - lastFlushAt > 500) { updateOutput.run(text, job.id); @@ -245,7 +336,7 @@ async function runJob(job: Job): Promise { } else { pendingFlush = true; } - emitJobUpdate(job.id, 'running', text); + emitJobUpdate(job.id, "running", text); }; const consumeProgress = (line: string) => { @@ -264,18 +355,18 @@ async function runJob(job: Job): Promise { }; try { - const proc = Bun.spawn(['sh', '-c', job.command], { stdout: 'pipe', stderr: 'pipe' }); - const readStream = async (readable: ReadableStream, prefix = '') => { + const proc = Bun.spawn(["sh", "-c", job.command], { stdout: "pipe", stderr: "pipe" }); + const readStream = async (readable: ReadableStream, prefix = "") => { const reader = readable.getReader(); const decoder = new TextDecoder(); - let buffer = ''; + let buffer = ""; try { while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const parts = buffer.split(/\r\n|\n|\r/); - buffer = parts.pop() ?? ''; + buffer = parts.pop() ?? ""; for (const line of parts) { if (!line.trim()) continue; outputLines.push(prefix + line); @@ -288,25 +379,29 @@ async function runJob(job: Job): Promise { consumeProgress(buffer); } } catch (err) { - logError(`stream read error (${prefix.trim() || 'stdout'}):`, err); + logError(`stream read error (${prefix.trim() || "stdout"}):`, err); } }; - await Promise.all([readStream(proc.stdout), readStream(proc.stderr, '[stderr] '), proc.exited]); + await Promise.all([readStream(proc.stdout), readStream(proc.stderr, "[stderr] "), proc.exited]); const exitCode = await proc.exited; - if (pendingFlush) updateOutput.run(outputLines.join('\n'), job.id); + if (pendingFlush) updateOutput.run(outputLines.join("\n"), job.id); if (exitCode !== 0) throw new Error(`FFmpeg exited with code ${exitCode}`); - const fullOutput = outputLines.join('\n'); + const fullOutput = outputLines.join("\n"); // Gather sidecar files to record - const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(job.item_id) as MediaItem | undefined; - const streams = db.prepare('SELECT * FROM media_streams WHERE item_id = ?').all(job.item_id) as MediaStream[]; + const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(job.item_id) as MediaItem | undefined; + const streams = db.prepare("SELECT * FROM media_streams WHERE item_id = ?").all(job.item_id) as MediaStream[]; const files = item && streams.length > 0 ? predictExtractedFiles(item, streams) : []; - const insertFile = db.prepare('INSERT OR IGNORE INTO subtitle_files (item_id, file_path, language, codec, is_forced, is_hearing_impaired) VALUES (?, ?, ?, ?, ?, ?)'); - const markJobDone = db.prepare("UPDATE jobs SET status = 'done', exit_code = 0, output = ?, completed_at = datetime('now') WHERE id = ?"); + const insertFile = db.prepare( + "INSERT OR IGNORE INTO subtitle_files (item_id, file_path, language, codec, is_forced, is_hearing_impaired) VALUES (?, ?, ?, ?, ?, ?)", + ); + const markJobDone = db.prepare( + "UPDATE jobs SET status = 'done', exit_code = 0, output = ?, completed_at = datetime('now') WHERE id = ?", + ); const markPlanDone = db.prepare("UPDATE review_plans SET status = 'done' WHERE item_id = ?"); - const markSubsExtracted = db.prepare('UPDATE review_plans SET subs_extracted = 1 WHERE item_id = ?'); + const markSubsExtracted = db.prepare("UPDATE review_plans SET subs_extracted = 1 WHERE item_id = ?"); db.transaction(() => { markJobDone.run(fullOutput, job.id); @@ -318,23 +413,25 @@ async function runJob(job: Job): Promise { })(); log(`Job ${job.id} completed successfully`); - emitJobUpdate(job.id, 'done', fullOutput); + emitJobUpdate(job.id, "done", fullOutput); } catch (err) { logError(`Job ${job.id} failed:`, err); - const fullOutput = outputLines.join('\n') + '\n' + String(err); - db.prepare("UPDATE jobs SET status = 'error', exit_code = 1, output = ?, completed_at = datetime('now') WHERE id = ?").run(fullOutput, job.id); - emitJobUpdate(job.id, 'error', fullOutput); + const fullOutput = `${outputLines.join("\n")}\n${String(err)}`; + db + .prepare("UPDATE jobs SET status = 'error', exit_code = 1, output = ?, completed_at = datetime('now') WHERE id = ?") + .run(fullOutput, job.id); + emitJobUpdate(job.id, "error", fullOutput); db.prepare("UPDATE review_plans SET status = 'error' WHERE item_id = ?").run(job.item_id); } } // ─── Scheduler ──────────────────────────────────────────────────────────────── -app.get('/scheduler', (c) => { +app.get("/scheduler", (c) => { return c.json(getSchedulerState()); }); -app.patch('/scheduler', async (c) => { +app.patch("/scheduler", async (c) => { const body = await c.req.json(); updateSchedulerState(body); return c.json(getSchedulerState()); diff --git a/server/api/paths.ts b/server/api/paths.ts index 1f79870..8383724 100644 --- a/server/api/paths.ts +++ b/server/api/paths.ts @@ -1,6 +1,6 @@ -import { existsSync } from 'node:fs'; -import { Hono } from 'hono'; -import { getDb } from '../db/index'; +import { existsSync } from "node:fs"; +import { Hono } from "hono"; +import { getDb } from "../db/index"; const app = new Hono(); @@ -10,7 +10,7 @@ interface PathInfo { accessible: boolean; } -app.get('/', (c) => { +app.get("/", (c) => { const db = getDb(); const rows = db .query<{ prefix: string; count: number }, []>( diff --git a/server/api/review.ts b/server/api/review.ts index ca60e9a..09f9e05 100644 --- a/server/api/review.ts +++ b/server/api/review.ts @@ -1,62 +1,96 @@ -import { Hono } from 'hono'; -import { getDb, getConfig, getAllConfig } from '../db/index'; -import { analyzeItem, assignTargetOrder } from '../services/analyzer'; -import { buildCommand } from '../services/ffmpeg'; -import { normalizeLanguage, getItem, refreshItem, mapStream } from '../services/jellyfin'; -import { parseId, isOneOf } from '../lib/validate'; -import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from '../types'; +import { Hono } from "hono"; +import { getAllConfig, getConfig, getDb } from "../db/index"; +import { isOneOf, parseId } from "../lib/validate"; +import { analyzeItem, assignTargetOrder } from "../services/analyzer"; +import { buildCommand } from "../services/ffmpeg"; +import { getItem, mapStream, normalizeLanguage, refreshItem } from "../services/jellyfin"; +import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from "../types"; const app = new Hono(); // ─── Helpers ────────────────────────────────────────────────────────────────── function getSubtitleLanguages(): string[] { - return JSON.parse(getConfig('subtitle_languages') ?? '["eng","deu","spa"]'); + return JSON.parse(getConfig("subtitle_languages") ?? '["eng","deu","spa"]'); } function countsByFilter(db: ReturnType): Record { - const total = (db.prepare('SELECT COUNT(*) as n FROM review_plans').get() as { n: number }).n; - const noops = (db.prepare('SELECT COUNT(*) as n FROM review_plans WHERE is_noop = 1').get() as { n: number }).n; - const pending = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number }).n; - const approved = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'approved'").get() as { n: number }).n; - const skipped = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'skipped'").get() as { n: number }).n; + const total = (db.prepare("SELECT COUNT(*) as n FROM review_plans").get() as { n: number }).n; + const noops = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE is_noop = 1").get() as { n: number }).n; + const pending = ( + db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number } + ).n; + const approved = ( + db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'approved'").get() as { n: number } + ).n; + const skipped = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'skipped'").get() as { n: number }) + .n; const done = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'done'").get() as { n: number }).n; const error = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'error'").get() as { n: number }).n; - const manual = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE needs_review = 1 AND original_language IS NULL").get() as { n: number }).n; + const manual = ( + db.prepare("SELECT COUNT(*) as n FROM media_items WHERE needs_review = 1 AND original_language IS NULL").get() as { + n: number; + } + ).n; return { all: total, needs_action: pending, noop: noops, approved, skipped, done, error, manual }; } function buildWhereClause(filter: string): string { switch (filter) { - case 'needs_action': return "rp.status = 'pending' AND rp.is_noop = 0"; - case 'noop': return 'rp.is_noop = 1'; - case 'manual': return 'mi.needs_review = 1 AND mi.original_language IS NULL'; - case 'approved': return "rp.status = 'approved'"; - case 'skipped': return "rp.status = 'skipped'"; - case 'done': return "rp.status = 'done'"; - case 'error': return "rp.status = 'error'"; - default: return '1=1'; + case "needs_action": + return "rp.status = 'pending' AND rp.is_noop = 0"; + case "noop": + return "rp.is_noop = 1"; + case "manual": + return "mi.needs_review = 1 AND mi.original_language IS NULL"; + case "approved": + return "rp.status = 'approved'"; + case "skipped": + return "rp.status = 'skipped'"; + case "done": + return "rp.status = 'done'"; + case "error": + return "rp.status = 'error'"; + default: + return "1=1"; } } type RawRow = MediaItem & { - plan_id: number | null; plan_status: string | null; is_noop: number | null; - plan_notes: string | null; reviewed_at: string | null; plan_created_at: string | null; - remove_count: number; keep_count: number; + plan_id: number | null; + plan_status: string | null; + is_noop: number | null; + plan_notes: string | null; + reviewed_at: string | null; + plan_created_at: string | null; + remove_count: number; + keep_count: number; }; function rowToPlan(r: RawRow): ReviewPlan | null { if (r.plan_id == null) return null; - return { id: r.plan_id, item_id: r.id, status: r.plan_status ?? 'pending', is_noop: r.is_noop ?? 0, notes: r.plan_notes, reviewed_at: r.reviewed_at, created_at: r.plan_created_at ?? '' } as ReviewPlan; + return { + id: r.plan_id, + item_id: r.id, + status: r.plan_status ?? "pending", + is_noop: r.is_noop ?? 0, + notes: r.plan_notes, + reviewed_at: r.reviewed_at, + created_at: r.plan_created_at ?? "", + } as ReviewPlan; } function loadItemDetail(db: ReturnType, itemId: number) { - const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(itemId) as MediaItem | undefined; + const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(itemId) as MediaItem | undefined; if (!item) return { item: null, streams: [], plan: null, decisions: [], command: null }; - const streams = db.prepare('SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index').all(itemId) as MediaStream[]; - const plan = db.prepare('SELECT * FROM review_plans WHERE item_id = ?').get(itemId) as ReviewPlan | undefined | null; - const decisions = plan ? db.prepare('SELECT * FROM stream_decisions WHERE plan_id = ?').all(plan.id) as StreamDecision[] : []; + const streams = db + .prepare("SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index") + .all(itemId) as MediaStream[]; + const plan = db.prepare("SELECT * FROM review_plans WHERE item_id = ?").get(itemId) as ReviewPlan | undefined | null; + const decisions = plan + ? (db.prepare("SELECT * FROM stream_decisions WHERE plan_id = ?").all(plan.id) as StreamDecision[]) + : []; const command = plan && !plan.is_noop ? buildCommand(item, streams, decisions) : null; @@ -69,36 +103,57 @@ function loadItemDetail(db: ReturnType, itemId: number) { * survive stream-id changes when Jellyfin re-probes metadata. */ function titleKey(s: { type: string; language: string | null; stream_index: number; title: string | null }): string { - return `${s.type}|${s.language ?? ''}|${s.stream_index}|${s.title ?? ''}`; + return `${s.type}|${s.language ?? ""}|${s.stream_index}|${s.title ?? ""}`; } function reanalyze(db: ReturnType, itemId: number, preservedTitles?: Map): void { - const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(itemId) as MediaItem; + const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(itemId) as MediaItem; if (!item) return; - const streams = db.prepare('SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index').all(itemId) as MediaStream[]; + const streams = db + .prepare("SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index") + .all(itemId) as MediaStream[]; const subtitleLanguages = getSubtitleLanguages(); - const audioLanguages: string[] = JSON.parse(getConfig('audio_languages') ?? '[]'); - const analysis = analyzeItem({ original_language: item.original_language, needs_review: item.needs_review, container: item.container }, streams, { subtitleLanguages, audioLanguages }); + const audioLanguages: string[] = JSON.parse(getConfig("audio_languages") ?? "[]"); + const analysis = analyzeItem( + { original_language: item.original_language, needs_review: item.needs_review, container: item.container }, + streams, + { subtitleLanguages, audioLanguages }, + ); - db.prepare(` + db + .prepare(` INSERT INTO review_plans (item_id, status, is_noop, confidence, apple_compat, job_type, notes) VALUES (?, 'pending', ?, ?, ?, ?, ?) ON CONFLICT(item_id) DO UPDATE SET status = 'pending', is_noop = excluded.is_noop, confidence = excluded.confidence, apple_compat = excluded.apple_compat, job_type = excluded.job_type, notes = excluded.notes - `).run(itemId, analysis.is_noop ? 1 : 0, analysis.confidence, analysis.apple_compat, analysis.job_type, analysis.notes.length > 0 ? analysis.notes.join('\n') : null); + `) + .run( + itemId, + analysis.is_noop ? 1 : 0, + analysis.confidence, + analysis.apple_compat, + analysis.job_type, + analysis.notes.length > 0 ? analysis.notes.join("\n") : null, + ); - const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number }; + const plan = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(itemId) as { id: number }; // Preserve existing custom_titles: prefer by stream_id (streams unchanged); // fall back to titleKey match (streams regenerated after rescan). const byStreamId = new Map( - (db.prepare('SELECT stream_id, custom_title FROM stream_decisions WHERE plan_id = ?').all(plan.id) as { stream_id: number; custom_title: string | null }[]) - .map((r) => [r.stream_id, r.custom_title]) + ( + db.prepare("SELECT stream_id, custom_title FROM stream_decisions WHERE plan_id = ?").all(plan.id) as { + stream_id: number; + custom_title: string | null; + }[] + ).map((r) => [r.stream_id, r.custom_title]), ); - const streamById = new Map(streams.map(s => [s.id, s] as const)); + const streamById = new Map(streams.map((s) => [s.id, s] as const)); - db.prepare('DELETE FROM stream_decisions WHERE plan_id = ?').run(plan.id); - const insertDecision = db.prepare('INSERT INTO stream_decisions (plan_id, stream_id, action, target_index, custom_title, transcode_codec) VALUES (?, ?, ?, ?, ?, ?)'); + db.prepare("DELETE FROM stream_decisions WHERE plan_id = ?").run(plan.id); + const insertDecision = db.prepare( + "INSERT INTO stream_decisions (plan_id, stream_id, action, target_index, custom_title, transcode_codec) VALUES (?, ?, ?, ?, ?, ?)", + ); for (const dec of analysis.decisions) { let customTitle = byStreamId.get(dec.stream_id) ?? null; if (!customTitle && preservedTitles) { @@ -114,50 +169,68 @@ function reanalyze(db: ReturnType, itemId: number, preservedTitles * recompute is_noop without wiping user-chosen actions or custom_titles. */ function recomputePlanAfterToggle(db: ReturnType, itemId: number): void { - const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(itemId) as MediaItem | undefined; + const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(itemId) as MediaItem | undefined; if (!item) return; - const streams = db.prepare('SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index').all(itemId) as MediaStream[]; - const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number } | undefined; + const streams = db + .prepare("SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index") + .all(itemId) as MediaStream[]; + const plan = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(itemId) as { id: number } | undefined; if (!plan) return; - const decisions = db.prepare('SELECT stream_id, action, target_index, transcode_codec FROM stream_decisions WHERE plan_id = ?').all(plan.id) as { - stream_id: number; action: 'keep' | 'remove'; target_index: number | null; transcode_codec: string | null + const decisions = db + .prepare("SELECT stream_id, action, target_index, transcode_codec FROM stream_decisions WHERE plan_id = ?") + .all(plan.id) as { + stream_id: number; + action: "keep" | "remove"; + target_index: number | null; + transcode_codec: string | null; }[]; const origLang = item.original_language ? normalizeLanguage(item.original_language) : null; - const audioLanguages: string[] = JSON.parse(getConfig('audio_languages') ?? '[]'); + const audioLanguages: string[] = JSON.parse(getConfig("audio_languages") ?? "[]"); // Re-assign target_index based on current actions - const decWithIdx = decisions.map(d => ({ stream_id: d.stream_id, action: d.action, target_index: null as number | null, transcode_codec: d.transcode_codec })); + const decWithIdx = decisions.map((d) => ({ + stream_id: d.stream_id, + action: d.action, + target_index: null as number | null, + transcode_codec: d.transcode_codec, + })); assignTargetOrder(streams, decWithIdx, origLang, audioLanguages); - const updateIdx = db.prepare('UPDATE stream_decisions SET target_index = ? WHERE plan_id = ? AND stream_id = ?'); + const updateIdx = db.prepare("UPDATE stream_decisions SET target_index = ? WHERE plan_id = ? AND stream_id = ?"); for (const d of decWithIdx) updateIdx.run(d.target_index, plan.id, d.stream_id); // Recompute is_noop: audio removed OR reordered OR subs exist OR transcode needed - const anyAudioRemoved = streams.some(s => s.type === 'Audio' && decWithIdx.find(d => d.stream_id === s.id)?.action === 'remove'); - const hasSubs = streams.some(s => s.type === 'Subtitle'); - const needsTranscode = decWithIdx.some(d => d.transcode_codec != null && d.action === 'keep'); + const anyAudioRemoved = streams.some( + (s) => s.type === "Audio" && decWithIdx.find((d) => d.stream_id === s.id)?.action === "remove", + ); + const hasSubs = streams.some((s) => s.type === "Subtitle"); + const needsTranscode = decWithIdx.some((d) => d.transcode_codec != null && d.action === "keep"); const keptAudio = streams - .filter(s => s.type === 'Audio' && decWithIdx.find(d => d.stream_id === s.id)?.action === 'keep') + .filter((s) => s.type === "Audio" && decWithIdx.find((d) => d.stream_id === s.id)?.action === "keep") .sort((a, b) => a.stream_index - b.stream_index); let audioOrderChanged = false; for (let i = 0; i < keptAudio.length; i++) { - const dec = decWithIdx.find(d => d.stream_id === keptAudio[i].id); - if (dec?.target_index !== i) { audioOrderChanged = true; break; } + const dec = decWithIdx.find((d) => d.stream_id === keptAudio[i].id); + if (dec?.target_index !== i) { + audioOrderChanged = true; + break; + } } const isNoop = !anyAudioRemoved && !audioOrderChanged && !hasSubs && !needsTranscode; - db.prepare('UPDATE review_plans SET is_noop = ? WHERE id = ?').run(isNoop ? 1 : 0, plan.id); + db.prepare("UPDATE review_plans SET is_noop = ? WHERE id = ?").run(isNoop ? 1 : 0, plan.id); } // ─── Pipeline: summary ─────────────────────────────────────────────────────── -app.get('/pipeline', (c) => { +app.get("/pipeline", (c) => { const db = getDb(); - const jellyfinUrl = getConfig('jellyfin_url') ?? ''; + const jellyfinUrl = getConfig("jellyfin_url") ?? ""; - const review = db.prepare(` + const review = db + .prepare(` SELECT rp.*, mi.name, mi.series_name, mi.series_jellyfin_id, mi.jellyfin_id, mi.season_number, mi.episode_number, mi.type, mi.container, @@ -169,9 +242,11 @@ app.get('/pipeline', (c) => { CASE rp.confidence WHEN 'high' THEN 0 ELSE 1 END, COALESCE(mi.series_name, mi.name), mi.season_number, mi.episode_number - `).all(); + `) + .all(); - const queued = db.prepare(` + const queued = db + .prepare(` SELECT j.*, mi.name, mi.series_name, mi.type, rp.job_type, rp.apple_compat FROM jobs j @@ -179,18 +254,22 @@ app.get('/pipeline', (c) => { JOIN review_plans rp ON rp.item_id = j.item_id WHERE j.status = 'pending' ORDER BY j.created_at - `).all(); + `) + .all(); - const processing = db.prepare(` + const processing = db + .prepare(` SELECT j.*, mi.name, mi.series_name, mi.type, rp.job_type, rp.apple_compat FROM jobs j JOIN media_items mi ON mi.id = j.item_id JOIN review_plans rp ON rp.item_id = j.item_id WHERE j.status = 'running' - `).all(); + `) + .all(); - const done = db.prepare(` + const done = db + .prepare(` SELECT j.*, mi.name, mi.series_name, mi.type, rp.job_type, rp.apple_compat FROM jobs j @@ -199,24 +278,27 @@ app.get('/pipeline', (c) => { WHERE j.status IN ('done', 'error') ORDER BY j.completed_at DESC LIMIT 50 - `).all(); + `) + .all(); - const noops = db.prepare('SELECT COUNT(*) as count FROM review_plans WHERE is_noop = 1').get() as { count: number }; + const noops = db.prepare("SELECT COUNT(*) as count FROM review_plans WHERE is_noop = 1").get() as { count: number }; // Batch transcode reasons for all review plans in one query (avoids N+1) - const planIds = (review as { id: number }[]).map(r => r.id); + const planIds = (review as { id: number }[]).map((r) => r.id); const reasonsByPlan = new Map(); if (planIds.length > 0) { - const placeholders = planIds.map(() => '?').join(','); - const allReasons = db.prepare(` + const placeholders = planIds.map(() => "?").join(","); + const allReasons = db + .prepare(` SELECT DISTINCT sd.plan_id, ms.codec, sd.transcode_codec FROM stream_decisions sd JOIN media_streams ms ON ms.id = sd.stream_id WHERE sd.plan_id IN (${placeholders}) AND sd.transcode_codec IS NOT NULL - `).all(...planIds) as { plan_id: number; codec: string | null; transcode_codec: string }[]; + `) + .all(...planIds) as { plan_id: number; codec: string | null; transcode_codec: string }[]; for (const r of allReasons) { if (!reasonsByPlan.has(r.plan_id)) reasonsByPlan.set(r.plan_id, []); - reasonsByPlan.get(r.plan_id)!.push(`${(r.codec ?? '').toUpperCase()} → ${r.transcode_codec.toUpperCase()}`); + reasonsByPlan.get(r.plan_id)!.push(`${(r.codec ?? "").toUpperCase()} → ${r.transcode_codec.toUpperCase()}`); } } for (const item of review as { id: number; transcode_reasons?: string[] }[]) { @@ -228,12 +310,13 @@ app.get('/pipeline', (c) => { // ─── List ───────────────────────────────────────────────────────────────────── -app.get('/', (c) => { +app.get("/", (c) => { const db = getDb(); - const filter = c.req.query('filter') ?? 'all'; + const filter = c.req.query("filter") ?? "all"; const where = buildWhereClause(filter); - const movieRows = db.prepare(` + const movieRows = db + .prepare(` SELECT mi.*, rp.id as plan_id, rp.status as plan_status, rp.is_noop, rp.notes as plan_notes, rp.reviewed_at, rp.created_at as plan_created_at, COUNT(CASE WHEN sd.action = 'remove' THEN 1 END) as remove_count, @@ -243,11 +326,18 @@ app.get('/', (c) => { LEFT JOIN stream_decisions sd ON sd.plan_id = rp.id WHERE mi.type = 'Movie' AND ${where} GROUP BY mi.id ORDER BY mi.name LIMIT 500 - `).all() as RawRow[]; + `) + .all() as RawRow[]; - const movies = movieRows.map((r) => ({ item: r as unknown as MediaItem, plan: rowToPlan(r), removeCount: r.remove_count, keepCount: r.keep_count })); + const movies = movieRows.map((r) => ({ + item: r as unknown as MediaItem, + plan: rowToPlan(r), + removeCount: r.remove_count, + keepCount: r.keep_count, + })); - const series = db.prepare(` + const series = db + .prepare(` SELECT COALESCE(mi.series_jellyfin_id, mi.series_name) as series_key, mi.series_name, MAX(mi.original_language) as original_language, COUNT(DISTINCT mi.season_number) as season_count, COUNT(mi.id) as episode_count, @@ -262,7 +352,8 @@ app.get('/', (c) => { LEFT JOIN review_plans rp ON rp.item_id = mi.id WHERE mi.type = 'Episode' AND ${where} GROUP BY series_key ORDER BY mi.series_name - `).all(); + `) + .all(); const totalCounts = countsByFilter(db); return c.json({ movies, series, filter, totalCounts }); @@ -270,11 +361,12 @@ app.get('/', (c) => { // ─── Series episodes ────────────────────────────────────────────────────────── -app.get('/series/:seriesKey/episodes', (c) => { +app.get("/series/:seriesKey/episodes", (c) => { const db = getDb(); - const seriesKey = decodeURIComponent(c.req.param('seriesKey')); + const seriesKey = decodeURIComponent(c.req.param("seriesKey")); - const rows = db.prepare(` + const rows = db + .prepare(` SELECT mi.*, rp.id as plan_id, rp.status as plan_status, rp.is_noop, rp.notes as plan_notes, rp.reviewed_at, rp.created_at as plan_created_at, COUNT(CASE WHEN sd.action = 'remove' THEN 1 END) as remove_count, 0 as keep_count @@ -284,7 +376,8 @@ app.get('/series/:seriesKey/episodes', (c) => { WHERE mi.type = 'Episode' AND (mi.series_jellyfin_id = ? OR (mi.series_jellyfin_id IS NULL AND mi.series_name = ?)) GROUP BY mi.id ORDER BY mi.season_number, mi.episode_number - `).all(seriesKey, seriesKey) as RawRow[]; + `) + .all(seriesKey, seriesKey) as RawRow[]; const seasonMap = new Map(); for (const r of rows) { @@ -299,9 +392,11 @@ app.get('/series/:seriesKey/episodes', (c) => { season, episodes, noopCount: (episodes as { plan: ReviewPlan | null }[]).filter((e) => e.plan?.is_noop).length, - actionCount: (episodes as { plan: ReviewPlan | null }[]).filter((e) => e.plan?.status === 'pending' && !e.plan.is_noop).length, - approvedCount: (episodes as { plan: ReviewPlan | null }[]).filter((e) => e.plan?.status === 'approved').length, - doneCount: (episodes as { plan: ReviewPlan | null }[]).filter((e) => e.plan?.status === 'done').length, + actionCount: (episodes as { plan: ReviewPlan | null }[]).filter( + (e) => e.plan?.status === "pending" && !e.plan.is_noop, + ).length, + approvedCount: (episodes as { plan: ReviewPlan | null }[]).filter((e) => e.plan?.status === "approved").length, + doneCount: (episodes as { plan: ReviewPlan | null }[]).filter((e) => e.plan?.status === "done").length, })); return c.json({ seasons }); @@ -309,63 +404,78 @@ app.get('/series/:seriesKey/episodes', (c) => { // ─── Approve series ─────────────────────────────────────────────────────────── -app.post('/series/:seriesKey/approve-all', (c) => { +app.post("/series/:seriesKey/approve-all", (c) => { const db = getDb(); - const seriesKey = decodeURIComponent(c.req.param('seriesKey')); - const pending = db.prepare(` + const seriesKey = decodeURIComponent(c.req.param("seriesKey")); + const pending = db + .prepare(` SELECT rp.*, mi.id as item_id FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id WHERE mi.type = 'Episode' AND (mi.series_jellyfin_id = ? OR (mi.series_jellyfin_id IS NULL AND mi.series_name = ?)) AND rp.status = 'pending' AND rp.is_noop = 0 - `).all(seriesKey, seriesKey) as (ReviewPlan & { item_id: number })[]; + `) + .all(seriesKey, seriesKey) as (ReviewPlan & { item_id: number })[]; for (const plan of pending) { db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id); const { item, streams, decisions } = loadItemDetail(db, plan.item_id); - if (item) db.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'audio', 'pending')").run(plan.item_id, buildCommand(item, streams, decisions)); + if (item) + db + .prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'audio', 'pending')") + .run(plan.item_id, buildCommand(item, streams, decisions)); } return c.json({ ok: true, count: pending.length }); }); // ─── Approve season ─────────────────────────────────────────────────────────── -app.post('/season/:seriesKey/:season/approve-all', (c) => { +app.post("/season/:seriesKey/:season/approve-all", (c) => { const db = getDb(); - const seriesKey = decodeURIComponent(c.req.param('seriesKey')); - const season = Number.parseInt(c.req.param('season') ?? '', 10); - if (!Number.isFinite(season)) return c.json({ error: 'invalid season' }, 400); - const pending = db.prepare(` + const seriesKey = decodeURIComponent(c.req.param("seriesKey")); + const season = Number.parseInt(c.req.param("season") ?? "", 10); + if (!Number.isFinite(season)) return c.json({ error: "invalid season" }, 400); + const pending = db + .prepare(` SELECT rp.*, mi.id as item_id FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id WHERE mi.type = 'Episode' AND (mi.series_jellyfin_id = ? OR (mi.series_jellyfin_id IS NULL AND mi.series_name = ?)) AND mi.season_number = ? AND rp.status = 'pending' AND rp.is_noop = 0 - `).all(seriesKey, seriesKey, season) as (ReviewPlan & { item_id: number })[]; + `) + .all(seriesKey, seriesKey, season) as (ReviewPlan & { item_id: number })[]; for (const plan of pending) { db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id); const { item, streams, decisions } = loadItemDetail(db, plan.item_id); - if (item) db.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'audio', 'pending')").run(plan.item_id, buildCommand(item, streams, decisions)); + if (item) + db + .prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'audio', 'pending')") + .run(plan.item_id, buildCommand(item, streams, decisions)); } return c.json({ ok: true, count: pending.length }); }); // ─── Approve all ────────────────────────────────────────────────────────────── -app.post('/approve-all', (c) => { +app.post("/approve-all", (c) => { const db = getDb(); - const pending = db.prepare( - "SELECT rp.*, mi.id as item_id FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id WHERE rp.status = 'pending' AND rp.is_noop = 0" - ).all() as (ReviewPlan & { item_id: number })[]; + const pending = db + .prepare( + "SELECT rp.*, mi.id as item_id FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id WHERE rp.status = 'pending' AND rp.is_noop = 0", + ) + .all() as (ReviewPlan & { item_id: number })[]; for (const plan of pending) { db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id); const { item, streams, decisions } = loadItemDetail(db, plan.item_id); - if (item) db.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'audio', 'pending')").run(plan.item_id, buildCommand(item, streams, decisions)); + if (item) + db + .prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'audio', 'pending')") + .run(plan.item_id, buildCommand(item, streams, decisions)); } return c.json({ ok: true, count: pending.length }); }); // ─── Detail ─────────────────────────────────────────────────────────────────── -app.get('/:id', (c) => { +app.get("/:id", (c) => { const db = getDb(); - const id = parseId(c.req.param('id')); - if (id == null) return c.json({ error: 'invalid id' }, 400); + const id = parseId(c.req.param("id")); + if (id == null) return c.json({ error: "invalid id" }, 400); const detail = loadItemDetail(db, id); if (!detail.item) return c.notFound(); return c.json(detail); @@ -373,13 +483,14 @@ app.get('/:id', (c) => { // ─── Override language ──────────────────────────────────────────────────────── -app.patch('/:id/language', async (c) => { +app.patch("/:id/language", async (c) => { const db = getDb(); - const id = parseId(c.req.param('id')); - if (id == null) return c.json({ error: 'invalid id' }, 400); + const id = parseId(c.req.param("id")); + if (id == null) return c.json({ error: "invalid id" }, 400); const body = await c.req.json<{ language: string | null }>(); const lang = body.language || null; - db.prepare("UPDATE media_items SET original_language = ?, orig_lang_source = 'manual', needs_review = 0 WHERE id = ?") + db + .prepare("UPDATE media_items SET original_language = ?, orig_lang_source = 'manual', needs_review = 0 WHERE id = ?") .run(lang ? normalizeLanguage(lang) : null, id); reanalyze(db, id); const detail = loadItemDetail(db, id); @@ -389,16 +500,18 @@ app.patch('/:id/language', async (c) => { // ─── Edit stream title ──────────────────────────────────────────────────────── -app.patch('/:id/stream/:streamId/title', async (c) => { +app.patch("/:id/stream/:streamId/title", async (c) => { const db = getDb(); - const itemId = parseId(c.req.param('id')); - const streamId = parseId(c.req.param('streamId')); - if (itemId == null || streamId == null) return c.json({ error: 'invalid id' }, 400); + const itemId = parseId(c.req.param("id")); + const streamId = parseId(c.req.param("streamId")); + if (itemId == null || streamId == null) return c.json({ error: "invalid id" }, 400); const body = await c.req.json<{ title: string }>(); - const title = (body.title ?? '').trim() || null; - const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number } | undefined; + const title = (body.title ?? "").trim() || null; + const plan = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(itemId) as { id: number } | undefined; if (!plan) return c.notFound(); - db.prepare('UPDATE stream_decisions SET custom_title = ? WHERE plan_id = ? AND stream_id = ?').run(title, plan.id, streamId); + db + .prepare("UPDATE stream_decisions SET custom_title = ? WHERE plan_id = ? AND stream_id = ?") + .run(title, plan.id, streamId); const detail = loadItemDetail(db, itemId); if (!detail.item) return c.notFound(); return c.json(detail); @@ -406,26 +519,30 @@ app.patch('/:id/stream/:streamId/title', async (c) => { // ─── Toggle stream action ───────────────────────────────────────────────────── -app.patch('/:id/stream/:streamId', async (c) => { +app.patch("/:id/stream/:streamId", async (c) => { const db = getDb(); - const itemId = parseId(c.req.param('id')); - const streamId = parseId(c.req.param('streamId')); - if (itemId == null || streamId == null) return c.json({ error: 'invalid id' }, 400); + const itemId = parseId(c.req.param("id")); + const streamId = parseId(c.req.param("streamId")); + if (itemId == null || streamId == null) return c.json({ error: "invalid id" }, 400); const body = await c.req.json<{ action: unknown }>().catch(() => ({ action: null })); - if (!isOneOf(body.action, ['keep', 'remove'] as const)) { + if (!isOneOf(body.action, ["keep", "remove"] as const)) { return c.json({ error: 'action must be "keep" or "remove"' }, 400); } - const action: 'keep' | 'remove' = body.action; + const action: "keep" | "remove" = body.action; // Only audio streams can be toggled — subtitles are always removed (extracted to sidecar) - const stream = db.prepare('SELECT type, item_id FROM media_streams WHERE id = ?').get(streamId) as { type: string; item_id: number } | undefined; - if (!stream || stream.item_id !== itemId) return c.json({ error: 'stream not found on item' }, 404); - if (stream.type === 'Subtitle') return c.json({ error: 'Subtitle streams cannot be toggled' }, 400); + const stream = db.prepare("SELECT type, item_id FROM media_streams WHERE id = ?").get(streamId) as + | { type: string; item_id: number } + | undefined; + if (!stream || stream.item_id !== itemId) return c.json({ error: "stream not found on item" }, 404); + if (stream.type === "Subtitle") return c.json({ error: "Subtitle streams cannot be toggled" }, 400); - const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number } | undefined; + const plan = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(itemId) as { id: number } | undefined; if (!plan) return c.notFound(); - db.prepare('UPDATE stream_decisions SET action = ? WHERE plan_id = ? AND stream_id = ?').run(action, plan.id, streamId); + db + .prepare("UPDATE stream_decisions SET action = ? WHERE plan_id = ? AND stream_id = ?") + .run(action, plan.id, streamId); recomputePlanAfterToggle(db, itemId); @@ -436,63 +553,94 @@ app.patch('/:id/stream/:streamId', async (c) => { // ─── Approve ────────────────────────────────────────────────────────────────── -app.post('/:id/approve', (c) => { +app.post("/:id/approve", (c) => { const db = getDb(); - const id = parseId(c.req.param('id')); - if (id == null) return c.json({ error: 'invalid id' }, 400); - const plan = db.prepare('SELECT * FROM review_plans WHERE item_id = ?').get(id) as ReviewPlan | undefined; + const id = parseId(c.req.param("id")); + if (id == null) return c.json({ error: "invalid id" }, 400); + const plan = db.prepare("SELECT * FROM review_plans WHERE item_id = ?").get(id) as ReviewPlan | undefined; if (!plan) return c.notFound(); db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id); if (!plan.is_noop) { const { item, streams, decisions } = loadItemDetail(db, id); - if (item) db.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'audio', 'pending')").run(id, buildCommand(item, streams, decisions)); + if (item) + db + .prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'audio', 'pending')") + .run(id, buildCommand(item, streams, decisions)); } return c.json({ ok: true }); }); // ─── Unapprove ─────────────────────────────────────────────────────────────── -app.post('/:id/unapprove', (c) => { +// ─── Retry failed job ───────────────────────────────────────────────────────── + +app.post("/:id/retry", (c) => { const db = getDb(); - const id = parseId(c.req.param('id')); - if (id == null) return c.json({ error: 'invalid id' }, 400); - const plan = db.prepare('SELECT * FROM review_plans WHERE item_id = ?').get(id) as ReviewPlan | undefined; + const id = parseId(c.req.param("id")); + if (id == null) return c.json({ error: "invalid id" }, 400); + const plan = db.prepare("SELECT * FROM review_plans WHERE item_id = ?").get(id) as ReviewPlan | undefined; if (!plan) return c.notFound(); - if (plan.status !== 'approved') return c.json({ ok: false, error: 'Can only unapprove items with status approved' }, 409); + if (plan.status !== "error") return c.json({ ok: false, error: "Only failed plans can be retried" }, 409); + + // Clear old errored/done jobs for this item so the queue starts clean + db.prepare("DELETE FROM jobs WHERE item_id = ? AND status IN ('error', 'done')").run(id); + + // Rebuild the command from the current decisions (streams may have been edited) + const { item, command } = loadItemDetail(db, id); + if (!item || !command) return c.json({ ok: false, error: "Cannot rebuild command" }, 400); + + db.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'audio', 'pending')").run(id, command); + db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id); + return c.json({ ok: true }); +}); + +app.post("/:id/unapprove", (c) => { + const db = getDb(); + const id = parseId(c.req.param("id")); + if (id == null) return c.json({ error: "invalid id" }, 400); + const plan = db.prepare("SELECT * FROM review_plans WHERE item_id = ?").get(id) as ReviewPlan | undefined; + if (!plan) return c.notFound(); + if (plan.status !== "approved") + return c.json({ ok: false, error: "Can only unapprove items with status approved" }, 409); // Only allow if the associated job hasn't started yet - const job = db.prepare("SELECT * FROM jobs WHERE item_id = ? ORDER BY created_at DESC LIMIT 1").get(id) as { id: number; status: string } | undefined; - if (job && job.status !== 'pending') return c.json({ ok: false, error: 'Job already started — cannot unapprove' }, 409); + const job = db.prepare("SELECT * FROM jobs WHERE item_id = ? ORDER BY created_at DESC LIMIT 1").get(id) as + | { id: number; status: string } + | undefined; + if (job && job.status !== "pending") + return c.json({ ok: false, error: "Job already started — cannot unapprove" }, 409); // Delete the pending job and revert plan status - if (job) db.prepare('DELETE FROM jobs WHERE id = ?').run(job.id); + if (job) db.prepare("DELETE FROM jobs WHERE id = ?").run(job.id); db.prepare("UPDATE review_plans SET status = 'pending', reviewed_at = NULL WHERE id = ?").run(plan.id); return c.json({ ok: true }); }); // ─── Skip / Unskip ─────────────────────────────────────────────────────────── -app.post('/:id/skip', (c) => { +app.post("/:id/skip", (c) => { const db = getDb(); - const id = parseId(c.req.param('id')); - if (id == null) return c.json({ error: 'invalid id' }, 400); + const id = parseId(c.req.param("id")); + if (id == null) return c.json({ error: "invalid id" }, 400); db.prepare("UPDATE review_plans SET status = 'skipped', reviewed_at = datetime('now') WHERE item_id = ?").run(id); return c.json({ ok: true }); }); -app.post('/:id/unskip', (c) => { +app.post("/:id/unskip", (c) => { const db = getDb(); - const id = parseId(c.req.param('id')); - if (id == null) return c.json({ error: 'invalid id' }, 400); - db.prepare("UPDATE review_plans SET status = 'pending', reviewed_at = NULL WHERE item_id = ? AND status = 'skipped'").run(id); + const id = parseId(c.req.param("id")); + if (id == null) return c.json({ error: "invalid id" }, 400); + db + .prepare("UPDATE review_plans SET status = 'pending', reviewed_at = NULL WHERE item_id = ? AND status = 'skipped'") + .run(id); return c.json({ ok: true }); }); // ─── Rescan ─────────────────────────────────────────────────────────────────── -app.post('/:id/rescan', async (c) => { +app.post("/:id/rescan", async (c) => { const db = getDb(); - const id = parseId(c.req.param('id')); - if (id == null) return c.json({ error: 'invalid id' }, 400); - const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(id) as MediaItem | undefined; + const id = parseId(c.req.param("id")); + if (id == null) return c.json({ error: "invalid id" }, 400); + const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(id) as MediaItem | undefined; if (!item) return c.notFound(); const cfg = getAllConfig(); @@ -505,13 +653,21 @@ app.post('/:id/rescan', async (c) => { // Snapshot custom_titles keyed by stable properties, since replacing // media_streams cascades away all stream_decisions. const preservedTitles = new Map(); - const oldRows = db.prepare(` + const oldRows = db + .prepare(` SELECT ms.type, ms.language, ms.stream_index, ms.title, sd.custom_title FROM stream_decisions sd JOIN media_streams ms ON ms.id = sd.stream_id JOIN review_plans rp ON rp.id = sd.plan_id WHERE rp.item_id = ? AND sd.custom_title IS NOT NULL - `).all(id) as { type: string; language: string | null; stream_index: number; title: string | null; custom_title: string }[]; + `) + .all(id) as { + type: string; + language: string | null; + stream_index: number; + title: string | null; + custom_title: string; + }[]; for (const r of oldRows) { preservedTitles.set(titleKey(r), r.custom_title); } @@ -523,11 +679,26 @@ app.post('/:id/rescan', async (c) => { title, is_default, is_forced, is_hearing_impaired, channels, channel_layout, bit_rate, sample_rate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); - db.prepare('DELETE FROM media_streams WHERE item_id = ?').run(id); + db.prepare("DELETE FROM media_streams WHERE item_id = ?").run(id); for (const jStream of fresh.MediaStreams ?? []) { if (jStream.IsExternal) continue; // skip external subs — not embedded in container const s = mapStream(jStream); - insertStream.run(id, s.stream_index, s.type, s.codec, s.language, s.language_display, s.title, s.is_default, s.is_forced, s.is_hearing_impaired, s.channels, s.channel_layout, s.bit_rate, s.sample_rate); + insertStream.run( + id, + s.stream_index, + s.type, + s.codec, + s.language, + s.language_display, + s.title, + s.is_default, + s.is_forced, + s.is_hearing_impaired, + s.channels, + s.channel_layout, + s.bit_rate, + s.sample_rate, + ); } } @@ -539,16 +710,17 @@ app.post('/:id/rescan', async (c) => { // ─── Pipeline: approve up to here ──────────────────────────────────────────── -app.post('/approve-up-to/:id', (c) => { - const targetId = parseId(c.req.param('id')); - if (targetId == null) return c.json({ error: 'invalid id' }, 400); +app.post("/approve-up-to/:id", (c) => { + const targetId = parseId(c.req.param("id")); + if (targetId == null) return c.json({ error: "invalid id" }, 400); const db = getDb(); - const target = db.prepare('SELECT id FROM review_plans WHERE id = ?').get(targetId) as { id: number } | undefined; - if (!target) return c.json({ error: 'Plan not found' }, 404); + const target = db.prepare("SELECT id FROM review_plans WHERE id = ?").get(targetId) as { id: number } | undefined; + if (!target) return c.json({ error: "Plan not found" }, 404); // Get all pending plans sorted by confidence (high first), then name - const pendingPlans = db.prepare(` + const pendingPlans = db + .prepare(` SELECT rp.id FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id @@ -559,7 +731,8 @@ app.post('/approve-up-to/:id', (c) => { mi.season_number, mi.episode_number, mi.name - `).all() as { id: number }[]; + `) + .all() as { id: number }[]; // Find the target and approve everything up to and including it const toApprove: number[] = []; @@ -571,10 +744,14 @@ app.post('/approve-up-to/:id', (c) => { // Batch approve and create jobs for (const planId of toApprove) { db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(planId); - const planRow = db.prepare('SELECT item_id, job_type FROM review_plans WHERE id = ?').get(planId) as { item_id: number; job_type: string }; + const planRow = db.prepare("SELECT item_id, job_type FROM review_plans WHERE id = ?").get(planId) as { + item_id: number; + job_type: string; + }; const detail = loadItemDetail(db, planRow.item_id); if (detail.item && detail.command) { - db.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, ?, 'pending')") + db + .prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, ?, 'pending')") .run(planRow.item_id, detail.command, planRow.job_type); } } @@ -584,18 +761,21 @@ app.post('/approve-up-to/:id', (c) => { // ─── Pipeline: series language ─────────────────────────────────────────────── -app.patch('/series/:seriesKey/language', async (c) => { - const seriesKey = decodeURIComponent(c.req.param('seriesKey')); +app.patch("/series/:seriesKey/language", async (c) => { + const seriesKey = decodeURIComponent(c.req.param("seriesKey")); const { language } = await c.req.json<{ language: string }>(); const db = getDb(); - const items = db.prepare( - 'SELECT id FROM media_items WHERE series_jellyfin_id = ? OR (series_jellyfin_id IS NULL AND series_name = ?)' - ).all(seriesKey, seriesKey) as { id: number }[]; + const items = db + .prepare( + "SELECT id FROM media_items WHERE series_jellyfin_id = ? OR (series_jellyfin_id IS NULL AND series_name = ?)", + ) + .all(seriesKey, seriesKey) as { id: number }[]; const normalizedLang = language ? normalizeLanguage(language) : null; for (const item of items) { - db.prepare("UPDATE media_items SET original_language = ?, orig_lang_source = 'manual', needs_review = 0 WHERE id = ?") + db + .prepare("UPDATE media_items SET original_language = ?, orig_lang_source = 'manual', needs_review = 0 WHERE id = ?") .run(normalizedLang, item.id); } diff --git a/server/api/scan.ts b/server/api/scan.ts index 11c48fa..46d63cc 100644 --- a/server/api/scan.ts +++ b/server/api/scan.ts @@ -1,12 +1,12 @@ -import { Hono } from 'hono'; -import { stream } from 'hono/streaming'; -import { getDb, getConfig, setConfig, getAllConfig } from '../db/index'; -import { getAllItems, getDevItems, extractOriginalLanguage, mapStream, normalizeLanguage } from '../services/jellyfin'; -import { getOriginalLanguage as radarrLang } from '../services/radarr'; -import { getOriginalLanguage as sonarrLang } from '../services/sonarr'; -import { analyzeItem } from '../services/analyzer'; -import type { MediaItem, MediaStream } from '../types'; -import { log, warn, error as logError } from '../lib/log'; +import { Hono } from "hono"; +import { stream } from "hono/streaming"; +import { getAllConfig, getConfig, getDb, setConfig } from "../db/index"; +import { log, error as logError, warn } from "../lib/log"; +import { analyzeItem } from "../services/analyzer"; +import { extractOriginalLanguage, getAllItems, getDevItems, mapStream, normalizeLanguage } from "../services/jellyfin"; +import { getOriginalLanguage as radarrLang } from "../services/radarr"; +import { getOriginalLanguage as sonarrLang } from "../services/sonarr"; +import type { MediaStream } from "../types"; const app = new Hono(); @@ -21,45 +21,48 @@ function emitSse(type: string, data: unknown): void { } function currentScanLimit(): number | null { - const v = getConfig('scan_limit'); + const v = getConfig("scan_limit"); return v ? Number(v) : null; } // ─── Status ─────────────────────────────────────────────────────────────────── -app.get('/', (c) => { +app.get("/", (c) => { const db = getDb(); - const running = getConfig('scan_running') === '1'; - const total = (db.prepare('SELECT COUNT(*) as n FROM media_items').get() as { n: number }).n; - const scanned = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'scanned'").get() as { n: number }).n; - const errors = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'error'").get() as { n: number }).n; - const recentItems = db.prepare( - 'SELECT name, type, scan_status, file_path FROM media_items ORDER BY last_scanned_at DESC LIMIT 50' - ).all() as { name: string; type: string; scan_status: string; file_path: string }[]; + const running = getConfig("scan_running") === "1"; + const total = (db.prepare("SELECT COUNT(*) as n FROM media_items").get() as { n: number }).n; + const scanned = ( + db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'scanned'").get() as { n: number } + ).n; + const errors = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'error'").get() as { n: number }) + .n; + const recentItems = db + .prepare("SELECT name, type, scan_status, file_path FROM media_items ORDER BY last_scanned_at DESC LIMIT 50") + .all() as { name: string; type: string; scan_status: string; file_path: string }[]; return c.json({ running, progress: { scanned, total, errors }, recentItems, scanLimit: currentScanLimit() }); }); // ─── Start ──────────────────────────────────────────────────────────────────── -app.post('/start', async (c) => { +app.post("/start", async (c) => { const db = getDb(); // Atomic claim: only succeed if scan_running is not already '1'. const claim = db.prepare("UPDATE config SET value = '1' WHERE key = 'scan_running' AND value != '1'").run(); if (claim.changes === 0) { - return c.json({ ok: false, error: 'Scan already running' }, 409); + return c.json({ ok: false, error: "Scan already running" }, 409); } const body = await c.req.json<{ limit?: number }>().catch(() => ({ limit: undefined })); const formLimit = body.limit ?? null; const envLimit = process.env.SCAN_LIMIT ? Number(process.env.SCAN_LIMIT) : null; const limit = formLimit ?? envLimit ?? null; - setConfig('scan_limit', limit != null ? String(limit) : ''); + setConfig("scan_limit", limit != null ? String(limit) : ""); runScan(limit).catch((err) => { - logError('Scan failed:', err); - setConfig('scan_running', '0'); - emitSse('error', { message: String(err) }); + logError("Scan failed:", err); + setConfig("scan_running", "0"); + emitSse("error", { message: String(err) }); }); return c.json({ ok: true }); @@ -67,19 +70,19 @@ app.post('/start', async (c) => { // ─── Stop ───────────────────────────────────────────────────────────────────── -app.post('/stop', (c) => { +app.post("/stop", (c) => { scanAbort?.abort(); - setConfig('scan_running', '0'); + setConfig("scan_running", "0"); return c.json({ ok: true }); }); // ─── SSE ────────────────────────────────────────────────────────────────────── -app.get('/events', (c) => { +app.get("/events", (c) => { return stream(c, async (s) => { - c.header('Content-Type', 'text/event-stream'); - c.header('Cache-Control', 'no-cache'); - c.header('Connection', 'keep-alive'); + c.header("Content-Type", "text/event-stream"); + c.header("Cache-Control", "no-cache"); + c.header("Connection", "keep-alive"); const queue: string[] = []; let resolve: (() => void) | null = null; @@ -90,7 +93,9 @@ app.get('/events', (c) => { }; scanListeners.add(listener); - s.onAbort(() => { scanListeners.delete(listener); }); + s.onAbort(() => { + scanListeners.delete(listener); + }); try { while (!s.closed) { @@ -102,7 +107,7 @@ app.get('/events', (c) => { setTimeout(res, 25_000); }); resolve = null; - if (queue.length === 0) await s.write(': keepalive\n\n'); + if (queue.length === 0) await s.write(": keepalive\n\n"); } } } finally { @@ -114,25 +119,31 @@ app.get('/events', (c) => { // ─── Core scan logic ────────────────────────────────────────────────────────── async function runScan(limit: number | null = null): Promise { - log(`Scan started${limit ? ` (limit: ${limit})` : ''}`); + log(`Scan started${limit ? ` (limit: ${limit})` : ""}`); scanAbort = new AbortController(); const { signal } = scanAbort; - const isDev = process.env.NODE_ENV === 'development'; + const isDev = process.env.NODE_ENV === "development"; const db = getDb(); if (isDev) { - db.prepare('DELETE FROM stream_decisions').run(); - db.prepare('DELETE FROM review_plans').run(); - db.prepare('DELETE FROM media_streams').run(); - db.prepare('DELETE FROM media_items').run(); + // Order matters only if foreign keys are enforced without CASCADE; we + // have ON DELETE CASCADE on media_streams/review_plans/stream_decisions/ + // subtitle_files/jobs, so deleting media_items would be enough. List + // them explicitly for clarity and to survive future schema drift. + db.prepare("DELETE FROM jobs").run(); + db.prepare("DELETE FROM subtitle_files").run(); + db.prepare("DELETE FROM stream_decisions").run(); + db.prepare("DELETE FROM review_plans").run(); + db.prepare("DELETE FROM media_streams").run(); + db.prepare("DELETE FROM media_items").run(); } const cfg = getAllConfig(); const jellyfinCfg = { url: cfg.jellyfin_url, apiKey: cfg.jellyfin_api_key, userId: cfg.jellyfin_user_id }; const subtitleLanguages: string[] = JSON.parse(cfg.subtitle_languages ?? '["eng","deu","spa"]'); - const audioLanguages: string[] = JSON.parse(cfg.audio_languages ?? '[]'); - const radarrEnabled = cfg.radarr_enabled === '1'; - const sonarrEnabled = cfg.sonarr_enabled === '1'; + const audioLanguages: string[] = JSON.parse(cfg.audio_languages ?? "[]"); + const radarrEnabled = cfg.radarr_enabled === "1"; + const sonarrEnabled = cfg.sonarr_enabled === "1"; let processed = 0; let errors = 0; @@ -157,7 +168,7 @@ async function runScan(limit: number | null = null): Promise { scan_status = 'scanned', last_scanned_at = datetime('now') `); - const deleteStreams = db.prepare('DELETE FROM media_streams WHERE item_id = ?'); + const deleteStreams = db.prepare("DELETE FROM media_streams WHERE item_id = ?"); const insertStream = db.prepare(` INSERT INTO media_streams ( item_id, stream_index, type, codec, language, language_display, @@ -181,15 +192,15 @@ async function runScan(limit: number | null = null): Promise { VALUES (?, ?, ?, ?, ?) ON CONFLICT(plan_id, stream_id) DO UPDATE SET action = excluded.action, target_index = excluded.target_index, transcode_codec = excluded.transcode_codec `); - const getItemByJellyfinId = db.prepare('SELECT id FROM media_items WHERE jellyfin_id = ?'); - const getPlanByItemId = db.prepare('SELECT id FROM review_plans WHERE item_id = ?'); - const getStreamsByItemId = db.prepare('SELECT * FROM media_streams WHERE item_id = ?'); + const getItemByJellyfinId = db.prepare("SELECT id FROM media_items WHERE jellyfin_id = ?"); + const getPlanByItemId = db.prepare("SELECT id FROM review_plans WHERE item_id = ?"); + const getStreamsByItemId = db.prepare("SELECT * FROM media_streams WHERE item_id = ?"); const itemSource = isDev ? getDevItems(jellyfinCfg) : getAllItems(jellyfinCfg, (_fetched, jellyfinTotal) => { - total = limit != null ? Math.min(limit, jellyfinTotal) : jellyfinTotal; - }); + total = limit != null ? Math.min(limit, jellyfinTotal) : jellyfinTotal; + }); for await (const jellyfinItem of itemSource) { if (signal.aborted) break; if (!isDev && limit != null && processed >= limit) break; @@ -199,45 +210,67 @@ async function runScan(limit: number | null = null): Promise { } processed++; - emitSse('progress', { scanned: processed, total, current_item: jellyfinItem.Name, errors, running: true }); + emitSse("progress", { scanned: processed, total, current_item: jellyfinItem.Name, errors, running: true }); try { const providerIds = jellyfinItem.ProviderIds ?? {}; - const imdbId = providerIds['Imdb'] ?? null; - const tmdbId = providerIds['Tmdb'] ?? null; - const tvdbId = providerIds['Tvdb'] ?? null; + const imdbId = providerIds.Imdb ?? null; + const tmdbId = providerIds.Tmdb ?? null; + const tvdbId = providerIds.Tvdb ?? null; let origLang: string | null = extractOriginalLanguage(jellyfinItem); - let origLangSource = 'jellyfin'; + let origLangSource = "jellyfin"; let needsReview = origLang ? 0 : 1; - if (jellyfinItem.Type === 'Movie' && radarrEnabled && (tmdbId || imdbId)) { - const lang = await radarrLang({ url: cfg.radarr_url, apiKey: cfg.radarr_api_key }, { tmdbId: tmdbId ?? undefined, imdbId: imdbId ?? undefined }); - if (lang) { if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1; origLang = lang; origLangSource = 'radarr'; } + if (jellyfinItem.Type === "Movie" && radarrEnabled && (tmdbId || imdbId)) { + const lang = await radarrLang( + { url: cfg.radarr_url, apiKey: cfg.radarr_api_key }, + { tmdbId: tmdbId ?? undefined, imdbId: imdbId ?? undefined }, + ); + if (lang) { + if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1; + origLang = lang; + origLangSource = "radarr"; + } } - if (jellyfinItem.Type === 'Episode' && sonarrEnabled && tvdbId) { + if (jellyfinItem.Type === "Episode" && sonarrEnabled && tvdbId) { const lang = await sonarrLang({ url: cfg.sonarr_url, apiKey: cfg.sonarr_api_key }, tvdbId); - if (lang) { if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1; origLang = lang; origLangSource = 'sonarr'; } + if (lang) { + if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1; + origLang = lang; + origLangSource = "sonarr"; + } } // Compute confidence from source agreement - let confidence: 'high' | 'low' = 'low'; + let confidence: "high" | "low" = "low"; if (!origLang) { - confidence = 'low'; // unknown language + confidence = "low"; // unknown language } else if (needsReview) { - confidence = 'low'; // sources disagree + confidence = "low"; // sources disagree } else { - confidence = 'high'; // language known, no conflicts + confidence = "high"; // language known, no conflicts } upsertItem.run( - jellyfinItem.Id, jellyfinItem.Type === 'Episode' ? 'Episode' : 'Movie', - jellyfinItem.Name, jellyfinItem.SeriesName ?? null, jellyfinItem.SeriesId ?? null, - jellyfinItem.ParentIndexNumber ?? null, jellyfinItem.IndexNumber ?? null, - jellyfinItem.ProductionYear ?? null, jellyfinItem.Path, jellyfinItem.Size ?? null, - jellyfinItem.Container ?? null, origLang, origLangSource, needsReview, - imdbId, tmdbId, tvdbId + jellyfinItem.Id, + jellyfinItem.Type === "Episode" ? "Episode" : "Movie", + jellyfinItem.Name, + jellyfinItem.SeriesName ?? null, + jellyfinItem.SeriesId ?? null, + jellyfinItem.ParentIndexNumber ?? null, + jellyfinItem.IndexNumber ?? null, + jellyfinItem.ProductionYear ?? null, + jellyfinItem.Path, + jellyfinItem.Size ?? null, + jellyfinItem.Container ?? null, + origLang, + origLangSource, + needsReview, + imdbId, + tmdbId, + tvdbId, ); const itemRow = getItemByJellyfinId.get(jellyfinItem.Id) as { id: number }; @@ -247,29 +280,62 @@ async function runScan(limit: number | null = null): Promise { for (const jStream of jellyfinItem.MediaStreams ?? []) { if (jStream.IsExternal) continue; // skip external subs — not embedded in container const s = mapStream(jStream); - insertStream.run(itemId, s.stream_index, s.type, s.codec, s.language, s.language_display, s.title, s.is_default, s.is_forced, s.is_hearing_impaired, s.channels, s.channel_layout, s.bit_rate, s.sample_rate); + insertStream.run( + itemId, + s.stream_index, + s.type, + s.codec, + s.language, + s.language_display, + s.title, + s.is_default, + s.is_forced, + s.is_hearing_impaired, + s.channels, + s.channel_layout, + s.bit_rate, + s.sample_rate, + ); } const streams = getStreamsByItemId.all(itemId) as MediaStream[]; - const analysis = analyzeItem({ original_language: origLang, needs_review: needsReview, container: jellyfinItem.Container ?? null }, streams, { subtitleLanguages, audioLanguages }); + const analysis = analyzeItem( + { original_language: origLang, needs_review: needsReview, container: jellyfinItem.Container ?? null }, + streams, + { subtitleLanguages, audioLanguages }, + ); // Override base confidence with scan-computed value const finalConfidence = confidence; - upsertPlan.run(itemId, analysis.is_noop ? 1 : 0, finalConfidence, analysis.apple_compat, analysis.job_type, analysis.notes.length > 0 ? analysis.notes.join('\n') : null); + upsertPlan.run( + itemId, + analysis.is_noop ? 1 : 0, + finalConfidence, + analysis.apple_compat, + analysis.job_type, + analysis.notes.length > 0 ? analysis.notes.join("\n") : null, + ); const planRow = getPlanByItemId.get(itemId) as { id: number }; - for (const dec of analysis.decisions) upsertDecision.run(planRow.id, dec.stream_id, dec.action, dec.target_index, dec.transcode_codec); + for (const dec of analysis.decisions) + upsertDecision.run(planRow.id, dec.stream_id, dec.action, dec.target_index, dec.transcode_codec); - emitSse('log', { name: jellyfinItem.Name, type: jellyfinItem.Type, status: 'scanned', file: jellyfinItem.Path }); + emitSse("log", { name: jellyfinItem.Name, type: jellyfinItem.Type, status: "scanned", file: jellyfinItem.Path }); } catch (err) { errors++; logError(`Error scanning ${jellyfinItem.Name}:`, err); - try { db.prepare("UPDATE media_items SET scan_status = 'error', scan_error = ? WHERE jellyfin_id = ?").run(String(err), jellyfinItem.Id); } catch { /* ignore */ } - emitSse('log', { name: jellyfinItem.Name, type: jellyfinItem.Type, status: 'error', file: jellyfinItem.Path }); + try { + db + .prepare("UPDATE media_items SET scan_status = 'error', scan_error = ? WHERE jellyfin_id = ?") + .run(String(err), jellyfinItem.Id); + } catch { + /* ignore */ + } + emitSse("log", { name: jellyfinItem.Name, type: jellyfinItem.Type, status: "error", file: jellyfinItem.Path }); } } - setConfig('scan_running', '0'); + setConfig("scan_running", "0"); log(`Scan complete: ${processed} scanned, ${errors} errors`); - emitSse('complete', { scanned: processed, total, errors }); + emitSse("complete", { scanned: processed, total, errors }); } export default app; diff --git a/server/api/setup.ts b/server/api/setup.ts index c4091e1..46195c8 100644 --- a/server/api/setup.ts +++ b/server/api/setup.ts @@ -1,104 +1,106 @@ -import { Hono } from 'hono'; -import { setConfig, getAllConfig, getDb, getEnvLockedKeys } from '../db/index'; -import { testConnection as testJellyfin, getUsers } from '../services/jellyfin'; -import { testConnection as testRadarr } from '../services/radarr'; -import { testConnection as testSonarr } from '../services/sonarr'; +import { Hono } from "hono"; +import { getAllConfig, getDb, getEnvLockedKeys, setConfig } from "../db/index"; +import { getUsers, testConnection as testJellyfin } from "../services/jellyfin"; +import { testConnection as testRadarr } from "../services/radarr"; +import { testConnection as testSonarr } from "../services/sonarr"; const app = new Hono(); -app.get('/', (c) => { +app.get("/", (c) => { const config = getAllConfig(); const envLocked = Array.from(getEnvLockedKeys()); return c.json({ config, envLocked }); }); -app.post('/jellyfin', async (c) => { +app.post("/jellyfin", async (c) => { const body = await c.req.json<{ url: string; api_key: string }>(); - const url = body.url?.replace(/\/$/, ''); + const url = body.url?.replace(/\/$/, ""); const apiKey = body.api_key; - if (!url || !apiKey) return c.json({ ok: false, error: 'URL and API key are required' }, 400); + if (!url || !apiKey) return c.json({ ok: false, error: "URL and API key are required" }, 400); const result = await testJellyfin({ url, apiKey }); if (!result.ok) return c.json({ ok: false, error: result.error }); - setConfig('jellyfin_url', url); - setConfig('jellyfin_api_key', apiKey); - setConfig('setup_complete', '1'); + setConfig("jellyfin_url", url); + setConfig("jellyfin_api_key", apiKey); + setConfig("setup_complete", "1"); try { const users = await getUsers({ url, apiKey }); - const admin = users.find((u) => u.Name === 'admin') ?? users[0]; - if (admin?.Id) setConfig('jellyfin_user_id', admin.Id); - } catch { /* ignore */ } + const admin = users.find((u) => u.Name === "admin") ?? users[0]; + if (admin?.Id) setConfig("jellyfin_user_id", admin.Id); + } catch { + /* ignore */ + } return c.json({ ok: true }); }); -app.post('/radarr', async (c) => { +app.post("/radarr", async (c) => { const body = await c.req.json<{ url?: string; api_key?: string }>(); - const url = body.url?.replace(/\/$/, ''); + const url = body.url?.replace(/\/$/, ""); const apiKey = body.api_key; if (!url || !apiKey) { - setConfig('radarr_enabled', '0'); - return c.json({ ok: false, error: 'URL and API key are required' }, 400); + setConfig("radarr_enabled", "0"); + return c.json({ ok: false, error: "URL and API key are required" }, 400); } const result = await testRadarr({ url, apiKey }); if (!result.ok) return c.json({ ok: false, error: result.error }); - setConfig('radarr_url', url); - setConfig('radarr_api_key', apiKey); - setConfig('radarr_enabled', '1'); + setConfig("radarr_url", url); + setConfig("radarr_api_key", apiKey); + setConfig("radarr_enabled", "1"); return c.json({ ok: true }); }); -app.post('/sonarr', async (c) => { +app.post("/sonarr", async (c) => { const body = await c.req.json<{ url?: string; api_key?: string }>(); - const url = body.url?.replace(/\/$/, ''); + const url = body.url?.replace(/\/$/, ""); const apiKey = body.api_key; if (!url || !apiKey) { - setConfig('sonarr_enabled', '0'); - return c.json({ ok: false, error: 'URL and API key are required' }, 400); + setConfig("sonarr_enabled", "0"); + return c.json({ ok: false, error: "URL and API key are required" }, 400); } const result = await testSonarr({ url, apiKey }); if (!result.ok) return c.json({ ok: false, error: result.error }); - setConfig('sonarr_url', url); - setConfig('sonarr_api_key', apiKey); - setConfig('sonarr_enabled', '1'); + setConfig("sonarr_url", url); + setConfig("sonarr_api_key", apiKey); + setConfig("sonarr_enabled", "1"); return c.json({ ok: true }); }); -app.post('/subtitle-languages', async (c) => { +app.post("/subtitle-languages", async (c) => { const body = await c.req.json<{ langs: string[] }>(); if (body.langs?.length > 0) { - setConfig('subtitle_languages', JSON.stringify(body.langs)); + setConfig("subtitle_languages", JSON.stringify(body.langs)); } return c.json({ ok: true }); }); -app.post('/audio-languages', async (c) => { +app.post("/audio-languages", async (c) => { const body = await c.req.json<{ langs: string[] }>(); - setConfig('audio_languages', JSON.stringify(body.langs ?? [])); + setConfig("audio_languages", JSON.stringify(body.langs ?? [])); return c.json({ ok: true }); }); -app.post('/clear-scan', (c) => { +app.post("/clear-scan", (c) => { const db = getDb(); // Delete children first to avoid slow cascade deletes db.transaction(() => { - db.prepare('DELETE FROM stream_decisions').run(); - db.prepare('DELETE FROM jobs').run(); - db.prepare('DELETE FROM subtitle_files').run(); - db.prepare('DELETE FROM review_plans').run(); - db.prepare('DELETE FROM media_streams').run(); - db.prepare('DELETE FROM media_items').run(); + db.prepare("DELETE FROM stream_decisions").run(); + db.prepare("DELETE FROM jobs").run(); + db.prepare("DELETE FROM subtitle_files").run(); + db.prepare("DELETE FROM review_plans").run(); + db.prepare("DELETE FROM media_streams").run(); + db.prepare("DELETE FROM media_items").run(); db.prepare("UPDATE config SET value = '0' WHERE key = 'scan_running'").run(); })(); return c.json({ ok: true }); diff --git a/server/api/subtitles.ts b/server/api/subtitles.ts index 4f26023..e72e58c 100644 --- a/server/api/subtitles.ts +++ b/server/api/subtitles.ts @@ -1,44 +1,67 @@ -import { Hono } from 'hono'; -import { getDb, getConfig, getAllConfig } from '../db/index'; -import { buildExtractOnlyCommand } from '../services/ffmpeg'; -import { normalizeLanguage, getItem, refreshItem, mapStream } from '../services/jellyfin'; -import { parseId } from '../lib/validate'; -import type { MediaItem, MediaStream, SubtitleFile, ReviewPlan, StreamDecision } from '../types'; -import { unlinkSync } from 'node:fs'; -import { dirname, resolve as resolvePath, sep } from 'node:path'; -import { error as logError } from '../lib/log'; +import { unlinkSync } from "node:fs"; +import { dirname, resolve as resolvePath, sep } from "node:path"; +import { Hono } from "hono"; +import { getAllConfig, getConfig, getDb } from "../db/index"; +import { error as logError } from "../lib/log"; +import { parseId } from "../lib/validate"; +import { buildExtractOnlyCommand } from "../services/ffmpeg"; +import { getItem, mapStream, normalizeLanguage, refreshItem } from "../services/jellyfin"; +import type { MediaItem, MediaStream, ReviewPlan, StreamDecision, SubtitleFile } from "../types"; const app = new Hono(); // ─── Types ─────────────────────────────────────────────────────────────────── interface SubListItem { - id: number; jellyfin_id: string; type: string; name: string; - series_name: string | null; season_number: number | null; - episode_number: number | null; year: number | null; - original_language: string | null; file_path: string; - subs_extracted: number | null; sub_count: number; file_count: number; + id: number; + jellyfin_id: string; + type: string; + name: string; + series_name: string | null; + season_number: number | null; + episode_number: number | null; + year: number | null; + original_language: string | null; + file_path: string; + subs_extracted: number | null; + sub_count: number; + file_count: number; } interface SubSeriesGroup { - series_key: string; series_name: string; original_language: string | null; - season_count: number; episode_count: number; - not_extracted_count: number; extracted_count: number; no_subs_count: number; + series_key: string; + series_name: string; + original_language: string | null; + season_count: number; + episode_count: number; + not_extracted_count: number; + extracted_count: number; + no_subs_count: number; } // ─── Helpers ───────────────────────────────────────────────────────────────── function loadDetail(db: ReturnType, itemId: number) { - const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(itemId) as MediaItem | undefined; + const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(itemId) as MediaItem | undefined; if (!item) return null; - const subtitleStreams = db.prepare("SELECT * FROM media_streams WHERE item_id = ? AND type = 'Subtitle' ORDER BY stream_index").all(itemId) as MediaStream[]; - const files = db.prepare('SELECT * FROM subtitle_files WHERE item_id = ? ORDER BY file_path').all(itemId) as SubtitleFile[]; - const plan = db.prepare('SELECT * FROM review_plans WHERE item_id = ?').get(itemId) as ReviewPlan | undefined; + const subtitleStreams = db + .prepare("SELECT * FROM media_streams WHERE item_id = ? AND type = 'Subtitle' ORDER BY stream_index") + .all(itemId) as MediaStream[]; + const files = db + .prepare("SELECT * FROM subtitle_files WHERE item_id = ? ORDER BY file_path") + .all(itemId) as SubtitleFile[]; + const plan = db.prepare("SELECT * FROM review_plans WHERE item_id = ?").get(itemId) as ReviewPlan | undefined; const decisions = plan - ? db.prepare("SELECT sd.* FROM stream_decisions sd JOIN media_streams ms ON ms.id = sd.stream_id WHERE sd.plan_id = ? AND ms.type = 'Subtitle'").all(plan.id) as StreamDecision[] + ? (db + .prepare( + "SELECT sd.* FROM stream_decisions sd JOIN media_streams ms ON ms.id = sd.stream_id WHERE sd.plan_id = ? AND ms.type = 'Subtitle'", + ) + .all(plan.id) as StreamDecision[]) : []; - const allStreams = db.prepare('SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index').all(itemId) as MediaStream[]; + const allStreams = db + .prepare("SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index") + .all(itemId) as MediaStream[]; const extractCommand = buildExtractOnlyCommand(item, allStreams); return { @@ -56,20 +79,25 @@ function loadDetail(db: ReturnType, itemId: number) { function buildSubWhere(filter: string): string { switch (filter) { - case 'not_extracted': return "sub_count > 0 AND COALESCE(rp.subs_extracted, 0) = 0"; - case 'extracted': return "rp.subs_extracted = 1"; - case 'no_subs': return "sub_count = 0"; - default: return '1=1'; + case "not_extracted": + return "sub_count > 0 AND COALESCE(rp.subs_extracted, 0) = 0"; + case "extracted": + return "rp.subs_extracted = 1"; + case "no_subs": + return "sub_count = 0"; + default: + return "1=1"; } } -app.get('/', (c) => { +app.get("/", (c) => { const db = getDb(); - const filter = c.req.query('filter') ?? 'all'; + const filter = c.req.query("filter") ?? "all"; const where = buildSubWhere(filter); // Movies - const movieRows = db.prepare(` + const movieRows = db + .prepare(` SELECT mi.id, mi.jellyfin_id, mi.type, mi.name, mi.series_name, mi.season_number, mi.episode_number, mi.year, mi.original_language, mi.file_path, rp.subs_extracted, @@ -79,10 +107,12 @@ app.get('/', (c) => { LEFT JOIN review_plans rp ON rp.item_id = mi.id WHERE mi.type = 'Movie' AND ${where} ORDER BY mi.name LIMIT 500 - `).all() as SubListItem[]; + `) + .all() as SubListItem[]; // Series groups - const series = db.prepare(` + const series = db + .prepare(` SELECT COALESCE(mi.series_jellyfin_id, mi.series_name) as series_key, mi.series_name, MAX(mi.original_language) as original_language, @@ -100,14 +130,21 @@ app.get('/', (c) => { LEFT JOIN review_plans rp ON rp.item_id = mi.id WHERE ${where} GROUP BY series_key ORDER BY mi.series_name - `).all() as SubSeriesGroup[]; + `) + .all() as SubSeriesGroup[]; - const totalAll = (db.prepare('SELECT COUNT(*) as n FROM media_items').get() as { n: number }).n; - const totalExtracted = (db.prepare('SELECT COUNT(*) as n FROM review_plans WHERE subs_extracted = 1').get() as { n: number }).n; - const totalNoSubs = (db.prepare(` + const totalAll = (db.prepare("SELECT COUNT(*) as n FROM media_items").get() as { n: number }).n; + const totalExtracted = ( + db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE subs_extracted = 1").get() as { n: number } + ).n; + const totalNoSubs = ( + db + .prepare(` SELECT COUNT(*) as n FROM media_items mi WHERE NOT EXISTS (SELECT 1 FROM media_streams ms WHERE ms.item_id = mi.id AND ms.type = 'Subtitle') - `).get() as { n: number }).n; + `) + .get() as { n: number } + ).n; const totalNotExtracted = totalAll - totalExtracted - totalNoSubs; return c.json({ @@ -120,11 +157,12 @@ app.get('/', (c) => { // ─── Series episodes (subtitles) ───────────────────────────────────────────── -app.get('/series/:seriesKey/episodes', (c) => { +app.get("/series/:seriesKey/episodes", (c) => { const db = getDb(); - const seriesKey = decodeURIComponent(c.req.param('seriesKey')); + const seriesKey = decodeURIComponent(c.req.param("seriesKey")); - const rows = db.prepare(` + const rows = db + .prepare(` SELECT mi.id, mi.jellyfin_id, mi.type, mi.name, mi.series_name, mi.season_number, mi.episode_number, mi.year, mi.original_language, mi.file_path, rp.subs_extracted, @@ -135,7 +173,8 @@ app.get('/series/:seriesKey/episodes', (c) => { WHERE mi.type = 'Episode' AND (mi.series_jellyfin_id = ? OR (mi.series_jellyfin_id IS NULL AND mi.series_name = ?)) ORDER BY mi.season_number, mi.episode_number - `).all(seriesKey, seriesKey) as SubListItem[]; + `) + .all(seriesKey, seriesKey) as SubListItem[]; const seasonMap = new Map(); for (const r of rows) { @@ -159,40 +198,55 @@ app.get('/series/:seriesKey/episodes', (c) => { // ─── Summary ───────────────────────────────────────────────────────────────── -interface CategoryRow { language: string | null; is_forced: number; is_hearing_impaired: number; cnt: number } - -function variantOf(row: { is_forced: number; is_hearing_impaired: number }): 'forced' | 'cc' | 'standard' { - if (row.is_forced) return 'forced'; - if (row.is_hearing_impaired) return 'cc'; - return 'standard'; +interface CategoryRow { + language: string | null; + is_forced: number; + is_hearing_impaired: number; + cnt: number; } -function catKey(lang: string | null, variant: string) { return `${lang ?? '__null__'}|${variant}`; } +function variantOf(row: { is_forced: number; is_hearing_impaired: number }): "forced" | "cc" | "standard" { + if (row.is_forced) return "forced"; + if (row.is_hearing_impaired) return "cc"; + return "standard"; +} -app.get('/summary', (c) => { +function catKey(lang: string | null, variant: string) { + return `${lang ?? "__null__"}|${variant}`; +} + +app.get("/summary", (c) => { const db = getDb(); // Embedded count — items with subtitle streams where subs_extracted = 0 - const embeddedCount = (db.prepare(` + const embeddedCount = ( + db + .prepare(` SELECT COUNT(DISTINCT mi.id) as n FROM media_items mi JOIN media_streams ms ON ms.item_id = mi.id AND ms.type = 'Subtitle' LEFT JOIN review_plans rp ON rp.item_id = mi.id WHERE COALESCE(rp.subs_extracted, 0) = 0 - `).get() as { n: number }).n; + `) + .get() as { n: number } + ).n; // Stream counts by (language, variant) - const streamRows = db.prepare(` + const streamRows = db + .prepare(` SELECT language, is_forced, is_hearing_impaired, COUNT(*) as cnt FROM media_streams WHERE type = 'Subtitle' GROUP BY language, is_forced, is_hearing_impaired - `).all() as CategoryRow[]; + `) + .all() as CategoryRow[]; // File counts by (language, variant) - const fileRows = db.prepare(` + const fileRows = db + .prepare(` SELECT language, is_forced, is_hearing_impaired, COUNT(*) as cnt FROM subtitle_files GROUP BY language, is_forced, is_hearing_impaired - `).all() as CategoryRow[]; + `) + .all() as CategoryRow[]; // Merge into categories const catMap = new Map(); @@ -205,23 +259,28 @@ app.get('/summary', (c) => { const v = variantOf(r); const k = catKey(r.language, v); const existing = catMap.get(k); - if (existing) { existing.fileCount = r.cnt; } - else { catMap.set(k, { language: r.language, variant: v, streamCount: 0, fileCount: r.cnt }); } + if (existing) { + existing.fileCount = r.cnt; + } else { + catMap.set(k, { language: r.language, variant: v, streamCount: 0, fileCount: r.cnt }); + } } const categories = Array.from(catMap.values()).sort((a, b) => { - const la = a.language ?? 'zzz'; - const lb = b.language ?? 'zzz'; + const la = a.language ?? "zzz"; + const lb = b.language ?? "zzz"; if (la !== lb) return la.localeCompare(lb); return a.variant.localeCompare(b.variant); }); // Title grouping - const titleRows = db.prepare(` + const titleRows = db + .prepare(` SELECT language, title, COUNT(*) as cnt FROM media_streams WHERE type = 'Subtitle' GROUP BY language, title ORDER BY language, cnt DESC - `).all() as { language: string | null; title: string | null; cnt: number }[]; + `) + .all() as { language: string | null; title: string | null; cnt: number }[]; // Determine canonical title per language (most common) const canonicalByLang = new Map(); @@ -237,19 +296,23 @@ app.get('/summary', (c) => { })); // Keep languages from config - const raw = getConfig('subtitle_languages'); + const raw = getConfig("subtitle_languages"); let keepLanguages: string[] = []; - try { keepLanguages = JSON.parse(raw ?? '[]'); } catch { /* empty */ } + try { + keepLanguages = JSON.parse(raw ?? "[]"); + } catch { + /* empty */ + } return c.json({ embeddedCount, categories, titles, keepLanguages }); }); // ─── Detail ────────────────────────────────────────────────────────────────── -app.get('/:id', (c) => { +app.get("/:id", (c) => { const db = getDb(); - const id = parseId(c.req.param('id')); - if (id == null) return c.json({ error: 'invalid id' }, 400); + const id = parseId(c.req.param("id")); + if (id == null) return c.json({ error: "invalid id" }, 400); const detail = loadDetail(db, id); if (!detail) return c.notFound(); return c.json(detail); @@ -257,19 +320,21 @@ app.get('/:id', (c) => { // ─── Edit stream language ──────────────────────────────────────────────────── -app.patch('/:id/stream/:streamId/language', async (c) => { +app.patch("/:id/stream/:streamId/language", async (c) => { const db = getDb(); - const itemId = parseId(c.req.param('id')); - const streamId = parseId(c.req.param('streamId')); - if (itemId == null || streamId == null) return c.json({ error: 'invalid id' }, 400); + const itemId = parseId(c.req.param("id")); + const streamId = parseId(c.req.param("streamId")); + if (itemId == null || streamId == null) return c.json({ error: "invalid id" }, 400); const body = await c.req.json<{ language: string }>(); - const lang = (body.language ?? '').trim() || null; + const lang = (body.language ?? "").trim() || null; - const stream = db.prepare('SELECT * FROM media_streams WHERE id = ? AND item_id = ?').get(streamId, itemId) as MediaStream | undefined; + const stream = db.prepare("SELECT * FROM media_streams WHERE id = ? AND item_id = ?").get(streamId, itemId) as + | MediaStream + | undefined; if (!stream) return c.notFound(); const normalized = lang ? normalizeLanguage(lang) : null; - db.prepare('UPDATE media_streams SET language = ? WHERE id = ?').run(normalized, streamId); + db.prepare("UPDATE media_streams SET language = ? WHERE id = ?").run(normalized, streamId); const detail = loadDetail(db, itemId); if (!detail) return c.notFound(); @@ -278,17 +343,19 @@ app.patch('/:id/stream/:streamId/language', async (c) => { // ─── Edit stream title ────────────────────────────────────────────────────── -app.patch('/:id/stream/:streamId/title', async (c) => { +app.patch("/:id/stream/:streamId/title", async (c) => { const db = getDb(); - const itemId = parseId(c.req.param('id')); - const streamId = parseId(c.req.param('streamId')); - if (itemId == null || streamId == null) return c.json({ error: 'invalid id' }, 400); + const itemId = parseId(c.req.param("id")); + const streamId = parseId(c.req.param("streamId")); + if (itemId == null || streamId == null) return c.json({ error: "invalid id" }, 400); const body = await c.req.json<{ title: string }>(); - const title = (body.title ?? '').trim() || null; + const title = (body.title ?? "").trim() || null; - const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number } | undefined; + const plan = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(itemId) as { id: number } | undefined; if (!plan) return c.notFound(); - db.prepare('UPDATE stream_decisions SET custom_title = ? WHERE plan_id = ? AND stream_id = ?').run(title, plan.id, streamId); + db + .prepare("UPDATE stream_decisions SET custom_title = ? WHERE plan_id = ? AND stream_id = ?") + .run(title, plan.id, streamId); const detail = loadDetail(db, itemId); if (!detail) return c.notFound(); @@ -297,22 +364,28 @@ app.patch('/:id/stream/:streamId/title', async (c) => { // ─── Extract all ────────────────────────────────────────────────────────────── -app.post('/extract-all', (c) => { +app.post("/extract-all", (c) => { const db = getDb(); // Find items with subtitle streams that haven't been extracted yet - const items = db.prepare(` + const items = db + .prepare(` SELECT mi.* FROM media_items mi WHERE EXISTS (SELECT 1 FROM media_streams ms WHERE ms.item_id = mi.id AND ms.type = 'Subtitle') AND NOT EXISTS (SELECT 1 FROM review_plans rp WHERE rp.item_id = mi.id AND rp.subs_extracted = 1) AND NOT EXISTS (SELECT 1 FROM jobs j WHERE j.item_id = mi.id AND j.status IN ('pending', 'running')) - `).all() as MediaItem[]; + `) + .all() as MediaItem[]; let queued = 0; for (const item of items) { - const streams = db.prepare('SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index').all(item.id) as MediaStream[]; + const streams = db + .prepare("SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index") + .all(item.id) as MediaStream[]; const command = buildExtractOnlyCommand(item, streams); if (!command) continue; - db.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'subtitle', 'pending')").run(item.id, command); + db + .prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'subtitle', 'pending')") + .run(item.id, command); queued++; } @@ -321,22 +394,26 @@ app.post('/extract-all', (c) => { // ─── Extract ───────────────────────────────────────────────────────────────── -app.post('/:id/extract', (c) => { +app.post("/:id/extract", (c) => { const db = getDb(); - const id = parseId(c.req.param('id')); - if (id == null) return c.json({ error: 'invalid id' }, 400); + const id = parseId(c.req.param("id")); + if (id == null) return c.json({ error: "invalid id" }, 400); - const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(id) as MediaItem | undefined; + const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(id) as MediaItem | undefined; if (!item) return c.notFound(); - const plan = db.prepare('SELECT * FROM review_plans WHERE item_id = ?').get(id) as ReviewPlan | undefined; - if (plan?.subs_extracted) return c.json({ ok: false, error: 'Subtitles already extracted' }, 409); + const plan = db.prepare("SELECT * FROM review_plans WHERE item_id = ?").get(id) as ReviewPlan | undefined; + if (plan?.subs_extracted) return c.json({ ok: false, error: "Subtitles already extracted" }, 409); - const streams = db.prepare('SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index').all(id) as MediaStream[]; + const streams = db + .prepare("SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index") + .all(id) as MediaStream[]; const command = buildExtractOnlyCommand(item, streams); - if (!command) return c.json({ ok: false, error: 'No subtitles to extract' }, 400); + if (!command) return c.json({ ok: false, error: "No subtitles to extract" }, 400); - db.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'subtitle', 'pending')").run(id, command); + db + .prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'subtitle', 'pending')") + .run(id, command); return c.json({ ok: true }); }); @@ -352,36 +429,46 @@ function isSidecarOfItem(filePath: string, videoPath: string): boolean { return targetDir === videoDir || targetDir.startsWith(videoDir + sep); } -app.delete('/:id/files/:fileId', (c) => { +app.delete("/:id/files/:fileId", (c) => { const db = getDb(); - const itemId = parseId(c.req.param('id')); - const fileId = parseId(c.req.param('fileId')); - if (itemId == null || fileId == null) return c.json({ error: 'invalid id' }, 400); + const itemId = parseId(c.req.param("id")); + const fileId = parseId(c.req.param("fileId")); + if (itemId == null || fileId == null) return c.json({ error: "invalid id" }, 400); - const file = db.prepare('SELECT * FROM subtitle_files WHERE id = ? AND item_id = ?').get(fileId, itemId) as SubtitleFile | undefined; + const file = db.prepare("SELECT * FROM subtitle_files WHERE id = ? AND item_id = ?").get(fileId, itemId) as + | SubtitleFile + | undefined; if (!file) return c.notFound(); - const item = db.prepare('SELECT file_path FROM media_items WHERE id = ?').get(itemId) as { file_path: string } | undefined; + const item = db.prepare("SELECT file_path FROM media_items WHERE id = ?").get(itemId) as + | { file_path: string } + | undefined; if (!item || !isSidecarOfItem(file.file_path, item.file_path)) { logError(`Refusing to delete subtitle file outside media dir: ${file.file_path}`); - db.prepare('DELETE FROM subtitle_files WHERE id = ?').run(fileId); - return c.json({ ok: false, error: 'file path outside media directory; DB entry removed without touching disk' }, 400); + db.prepare("DELETE FROM subtitle_files WHERE id = ?").run(fileId); + return c.json({ ok: false, error: "file path outside media directory; DB entry removed without touching disk" }, 400); } - try { unlinkSync(file.file_path); } catch { /* file may not exist */ } - db.prepare('DELETE FROM subtitle_files WHERE id = ?').run(fileId); + try { + unlinkSync(file.file_path); + } catch { + /* file may not exist */ + } + db.prepare("DELETE FROM subtitle_files WHERE id = ?").run(fileId); - const files = db.prepare('SELECT * FROM subtitle_files WHERE item_id = ? ORDER BY file_path').all(itemId) as SubtitleFile[]; + const files = db + .prepare("SELECT * FROM subtitle_files WHERE item_id = ? ORDER BY file_path") + .all(itemId) as SubtitleFile[]; return c.json({ ok: true, files }); }); // ─── Rescan ────────────────────────────────────────────────────────────────── -app.post('/:id/rescan', async (c) => { +app.post("/:id/rescan", async (c) => { const db = getDb(); - const id = parseId(c.req.param('id')); - if (id == null) return c.json({ error: 'invalid id' }, 400); - const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(id) as MediaItem | undefined; + const id = parseId(c.req.param("id")); + if (id == null) return c.json({ error: "invalid id" }, 400); + const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(id) as MediaItem | undefined; if (!item) return c.notFound(); const cfg = getAllConfig(); @@ -396,11 +483,26 @@ app.post('/:id/rescan', async (c) => { title, is_default, is_forced, is_hearing_impaired, channels, channel_layout, bit_rate, sample_rate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); - db.prepare('DELETE FROM media_streams WHERE item_id = ?').run(id); + db.prepare("DELETE FROM media_streams WHERE item_id = ?").run(id); for (const jStream of fresh.MediaStreams ?? []) { if (jStream.IsExternal) continue; const s = mapStream(jStream); - insertStream.run(id, s.stream_index, s.type, s.codec, s.language, s.language_display, s.title, s.is_default, s.is_forced, s.is_hearing_impaired, s.channels, s.channel_layout, s.bit_rate, s.sample_rate); + insertStream.run( + id, + s.stream_index, + s.type, + s.codec, + s.language, + s.language_display, + s.title, + s.is_default, + s.is_forced, + s.is_hearing_impaired, + s.channels, + s.channel_layout, + s.bit_rate, + s.sample_rate, + ); } } @@ -411,45 +513,57 @@ app.post('/:id/rescan', async (c) => { // ─── Batch delete subtitle files ───────────────────────────────────────────── -app.post('/batch-delete', async (c) => { +app.post("/batch-delete", async (c) => { const db = getDb(); - const body = await c.req.json<{ categories: { language: string | null; variant: 'standard' | 'forced' | 'cc' }[] }>(); + const body = await c.req.json<{ categories: { language: string | null; variant: "standard" | "forced" | "cc" }[] }>(); let deleted = 0; for (const cat of body.categories) { - const isForced = cat.variant === 'forced' ? 1 : 0; - const isHI = cat.variant === 'cc' ? 1 : 0; + const isForced = cat.variant === "forced" ? 1 : 0; + const isHI = cat.variant === "cc" ? 1 : 0; let files: SubtitleFile[]; if (cat.language === null) { - files = db.prepare(` + files = db + .prepare(` SELECT * FROM subtitle_files WHERE language IS NULL AND is_forced = ? AND is_hearing_impaired = ? - `).all(isForced, isHI) as SubtitleFile[]; + `) + .all(isForced, isHI) as SubtitleFile[]; } else { - files = db.prepare(` + files = db + .prepare(` SELECT * FROM subtitle_files WHERE language = ? AND is_forced = ? AND is_hearing_impaired = ? - `).all(cat.language, isForced, isHI) as SubtitleFile[]; + `) + .all(cat.language, isForced, isHI) as SubtitleFile[]; } for (const file of files) { - const item = db.prepare('SELECT file_path FROM media_items WHERE id = ?').get(file.item_id) as { file_path: string } | undefined; + const item = db.prepare("SELECT file_path FROM media_items WHERE id = ?").get(file.item_id) as + | { file_path: string } + | undefined; if (item && isSidecarOfItem(file.file_path, item.file_path)) { - try { unlinkSync(file.file_path); } catch { /* file may not exist */ } + try { + unlinkSync(file.file_path); + } catch { + /* file may not exist */ + } } else { logError(`Refusing to delete subtitle file outside media dir: ${file.file_path}`); } - db.prepare('DELETE FROM subtitle_files WHERE id = ?').run(file.id); + db.prepare("DELETE FROM subtitle_files WHERE id = ?").run(file.id); deleted++; } // Reset subs_extracted for affected items that now have no subtitle files const affectedItems = new Set(files.map((f) => f.item_id)); for (const itemId of affectedItems) { - const remaining = (db.prepare('SELECT COUNT(*) as n FROM subtitle_files WHERE item_id = ?').get(itemId) as { n: number }).n; + const remaining = ( + db.prepare("SELECT COUNT(*) as n FROM subtitle_files WHERE item_id = ?").get(itemId) as { n: number } + ).n; if (remaining === 0) { - db.prepare('UPDATE review_plans SET subs_extracted = 0 WHERE item_id = ?').run(itemId); + db.prepare("UPDATE review_plans SET subs_extracted = 0 WHERE item_id = ?").run(itemId); } } } @@ -459,16 +573,18 @@ app.post('/batch-delete', async (c) => { // ─── Normalize titles ──────────────────────────────────────────────────────── -app.post('/normalize-titles', (c) => { +app.post("/normalize-titles", (c) => { const db = getDb(); // Get title groups per language - const titleRows = db.prepare(` + const titleRows = db + .prepare(` SELECT language, title, COUNT(*) as cnt FROM media_streams WHERE type = 'Subtitle' GROUP BY language, title ORDER BY language, cnt DESC - `).all() as { language: string | null; title: string | null; cnt: number }[]; + `) + .all() as { language: string | null; title: string | null; cnt: number }[]; // Find canonical (most common) title per language const canonicalByLang = new Map(); @@ -484,31 +600,43 @@ app.post('/normalize-titles', (c) => { // Find all streams matching this language+title and set custom_title on their decisions let streams: { id: number; item_id: number }[]; if (r.language === null) { - streams = db.prepare(` + streams = db + .prepare(` SELECT id, item_id FROM media_streams WHERE type = 'Subtitle' AND language IS NULL AND title IS ? - `).all(r.title) as { id: number; item_id: number }[]; + `) + .all(r.title) as { id: number; item_id: number }[]; } else { - streams = db.prepare(` + streams = db + .prepare(` SELECT id, item_id FROM media_streams WHERE type = 'Subtitle' AND language = ? AND title IS ? - `).all(r.language, r.title) as { id: number; item_id: number }[]; + `) + .all(r.language, r.title) as { id: number; item_id: number }[]; } for (const stream of streams) { // Ensure review_plan exists - let plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(stream.item_id) as { id: number } | undefined; + let plan = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(stream.item_id) as + | { id: number } + | undefined; if (!plan) { db.prepare("INSERT INTO review_plans (item_id, status, is_noop) VALUES (?, 'pending', 0)").run(stream.item_id); - plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(stream.item_id) as { id: number }; + plan = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(stream.item_id) as { id: number }; } // Upsert stream_decision with custom_title - const existing = db.prepare('SELECT id FROM stream_decisions WHERE plan_id = ? AND stream_id = ?').get(plan.id, stream.id); + const existing = db + .prepare("SELECT id FROM stream_decisions WHERE plan_id = ? AND stream_id = ?") + .get(plan.id, stream.id); if (existing) { - db.prepare('UPDATE stream_decisions SET custom_title = ? WHERE plan_id = ? AND stream_id = ?').run(canonical, plan.id, stream.id); + db + .prepare("UPDATE stream_decisions SET custom_title = ? WHERE plan_id = ? AND stream_id = ?") + .run(canonical, plan.id, stream.id); } else { - db.prepare("INSERT INTO stream_decisions (plan_id, stream_id, action, custom_title) VALUES (?, ?, 'keep', ?)").run(plan.id, stream.id, canonical); + db + .prepare("INSERT INTO stream_decisions (plan_id, stream_id, action, custom_title) VALUES (?, ?, 'keep', ?)") + .run(plan.id, stream.id, canonical); } normalized++; } diff --git a/server/db/index.ts b/server/db/index.ts index 86ffa53..861e620 100644 --- a/server/db/index.ts +++ b/server/db/index.ts @@ -1,29 +1,28 @@ -import { Database } from 'bun:sqlite'; -import { join } from 'node:path'; -import { mkdirSync } from 'node:fs'; -import { SCHEMA, DEFAULT_CONFIG } from './schema'; +import { Database } from "bun:sqlite"; +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { DEFAULT_CONFIG, SCHEMA } from "./schema"; -const dataDir = process.env.DATA_DIR ?? './data'; +const dataDir = process.env.DATA_DIR ?? "./data"; mkdirSync(dataDir, { recursive: true }); -const isDev = process.env.NODE_ENV === 'development'; -const dbPath = join(dataDir, isDev ? 'netfelix-dev.db' : 'netfelix.db'); +const isDev = process.env.NODE_ENV === "development"; +const dbPath = join(dataDir, isDev ? "netfelix-dev.db" : "netfelix.db"); // ─── Env-var → config key mapping ───────────────────────────────────────────── const ENV_MAP: Record = { - jellyfin_url: 'JELLYFIN_URL', - jellyfin_api_key: 'JELLYFIN_API_KEY', - jellyfin_user_id: 'JELLYFIN_USER_ID', - radarr_url: 'RADARR_URL', - radarr_api_key: 'RADARR_API_KEY', - radarr_enabled: 'RADARR_ENABLED', - sonarr_url: 'SONARR_URL', - sonarr_api_key: 'SONARR_API_KEY', - sonarr_enabled: 'SONARR_ENABLED', - subtitle_languages: 'SUBTITLE_LANGUAGES', - audio_languages: 'AUDIO_LANGUAGES', - + jellyfin_url: "JELLYFIN_URL", + jellyfin_api_key: "JELLYFIN_API_KEY", + jellyfin_user_id: "JELLYFIN_USER_ID", + radarr_url: "RADARR_URL", + radarr_api_key: "RADARR_API_KEY", + radarr_enabled: "RADARR_ENABLED", + sonarr_url: "SONARR_URL", + sonarr_api_key: "SONARR_API_KEY", + sonarr_enabled: "SONARR_ENABLED", + subtitle_languages: "SUBTITLE_LANGUAGES", + audio_languages: "AUDIO_LANGUAGES", }; /** Read a config key from environment variables (returns null if not set). */ @@ -32,9 +31,10 @@ function envValue(key: string): string | null { if (!envKey) return null; const val = process.env[envKey]; if (!val) return null; - if (key.endsWith('_enabled')) return val === '1' || val.toLowerCase() === 'true' ? '1' : '0'; - if (key === 'subtitle_languages' || key === 'audio_languages') return JSON.stringify(val.split(',').map((s) => s.trim())); - if (key.endsWith('_url')) return val.replace(/\/$/, ''); + if (key.endsWith("_enabled")) return val === "1" || val.toLowerCase() === "true" ? "1" : "0"; + if (key === "subtitle_languages" || key === "audio_languages") + return JSON.stringify(val.split(",").map((s) => s.trim())); + if (key.endsWith("_url")) return val.replace(/\/$/, ""); return val; } @@ -52,23 +52,49 @@ export function getDb(): Database { _db = new Database(dbPath, { create: true }); _db.exec(SCHEMA); // Migrations for columns added after initial release - try { _db.exec('ALTER TABLE stream_decisions ADD COLUMN custom_title TEXT'); } catch { /* already exists */ } - try { _db.exec('ALTER TABLE review_plans ADD COLUMN subs_extracted INTEGER NOT NULL DEFAULT 0'); } catch { /* already exists */ } - try { _db.exec("ALTER TABLE jobs ADD COLUMN job_type TEXT NOT NULL DEFAULT 'audio'"); } catch { /* already exists */ } + try { + _db.exec("ALTER TABLE stream_decisions ADD COLUMN custom_title TEXT"); + } catch { + /* already exists */ + } + try { + _db.exec("ALTER TABLE review_plans ADD COLUMN subs_extracted INTEGER NOT NULL DEFAULT 0"); + } catch { + /* already exists */ + } + try { + _db.exec("ALTER TABLE jobs ADD COLUMN job_type TEXT NOT NULL DEFAULT 'audio'"); + } catch { + /* already exists */ + } // Apple compat pipeline columns - try { _db.exec("ALTER TABLE review_plans ADD COLUMN confidence TEXT NOT NULL DEFAULT 'low'"); } catch { /* already exists */ } - try { _db.exec('ALTER TABLE review_plans ADD COLUMN apple_compat TEXT'); } catch { /* already exists */ } - try { _db.exec("ALTER TABLE review_plans ADD COLUMN job_type TEXT NOT NULL DEFAULT 'copy'"); } catch { /* already exists */ } - try { _db.exec('ALTER TABLE stream_decisions ADD COLUMN transcode_codec TEXT'); } catch { /* already exists */ } + try { + _db.exec("ALTER TABLE review_plans ADD COLUMN confidence TEXT NOT NULL DEFAULT 'low'"); + } catch { + /* already exists */ + } + try { + _db.exec("ALTER TABLE review_plans ADD COLUMN apple_compat TEXT"); + } catch { + /* already exists */ + } + try { + _db.exec("ALTER TABLE review_plans ADD COLUMN job_type TEXT NOT NULL DEFAULT 'copy'"); + } catch { + /* already exists */ + } + try { + _db.exec("ALTER TABLE stream_decisions ADD COLUMN transcode_codec TEXT"); + } catch { + /* already exists */ + } seedDefaults(_db); return _db; } function seedDefaults(db: Database): void { - const insert = db.prepare( - 'INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)' - ); + const insert = db.prepare("INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)"); for (const [key, value] of Object.entries(DEFAULT_CONFIG)) { insert.run(key, value); } @@ -79,17 +105,13 @@ export function getConfig(key: string): string | null { const fromEnv = envValue(key); if (fromEnv !== null) return fromEnv; // Auto-complete setup when all required Jellyfin env vars are present - if (key === 'setup_complete' && isEnvConfigured()) return '1'; - const row = getDb() - .prepare('SELECT value FROM config WHERE key = ?') - .get(key) as { value: string } | undefined; + if (key === "setup_complete" && isEnvConfigured()) return "1"; + const row = getDb().prepare("SELECT value FROM config WHERE key = ?").get(key) as { value: string } | undefined; return row?.value ?? null; } export function setConfig(key: string, value: string): void { - getDb() - .prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)') - .run(key, value); + getDb().prepare("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)").run(key, value); } /** Returns the set of config keys currently overridden by environment variables. */ @@ -102,17 +124,14 @@ export function getEnvLockedKeys(): Set { } export function getAllConfig(): Record { - const rows = getDb() - .prepare('SELECT key, value FROM config') - .all() as { key: string; value: string }[]; - const result = Object.fromEntries(rows.map((r) => [r.key, r.value ?? ''])); + const rows = getDb().prepare("SELECT key, value FROM config").all() as { key: string; value: string }[]; + const result = Object.fromEntries(rows.map((r) => [r.key, r.value ?? ""])); // Apply env overrides on top of DB values for (const key of Object.keys(ENV_MAP)) { const fromEnv = envValue(key); if (fromEnv !== null) result[key] = fromEnv; } // Auto-complete setup when all required Jellyfin env vars are present - if (isEnvConfigured()) result.setup_complete = '1'; + if (isEnvConfigured()) result.setup_complete = "1"; return result; } - diff --git a/server/db/schema.ts b/server/db/schema.ts index edf4d6e..48b7a68 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -110,22 +110,22 @@ CREATE INDEX IF NOT EXISTS idx_jobs_item_id ON jobs(item_id); `; export const DEFAULT_CONFIG: Record = { - setup_complete: '0', - jellyfin_url: '', - jellyfin_api_key: '', - jellyfin_user_id: '', - radarr_url: '', - radarr_api_key: '', - radarr_enabled: '0', - sonarr_url: '', - sonarr_api_key: '', - sonarr_enabled: '0', - subtitle_languages: JSON.stringify(['eng', 'deu', 'spa']), - audio_languages: '[]', + setup_complete: "0", + jellyfin_url: "", + jellyfin_api_key: "", + jellyfin_user_id: "", + radarr_url: "", + radarr_api_key: "", + radarr_enabled: "0", + sonarr_url: "", + sonarr_api_key: "", + sonarr_enabled: "0", + subtitle_languages: JSON.stringify(["eng", "deu", "spa"]), + audio_languages: "[]", - scan_running: '0', - job_sleep_seconds: '0', - schedule_enabled: '0', - schedule_start: '01:00', - schedule_end: '07:00', + scan_running: "0", + job_sleep_seconds: "0", + schedule_enabled: "0", + schedule_start: "01:00", + schedule_end: "07:00", }; diff --git a/server/index.tsx b/server/index.tsx index 22e806f..c569096 100644 --- a/server/index.tsx +++ b/server/index.tsx @@ -1,70 +1,69 @@ -import { Hono } from 'hono'; -import { serveStatic } from 'hono/bun'; -import { cors } from 'hono/cors'; -import { getDb, getConfig } from './db/index'; -import { log } from './lib/log'; - -import setupRoutes from './api/setup'; -import scanRoutes from './api/scan'; -import reviewRoutes from './api/review'; -import executeRoutes from './api/execute'; -import subtitlesRoutes from './api/subtitles'; -import dashboardRoutes from './api/dashboard'; -import pathsRoutes from './api/paths'; +import { Hono } from "hono"; +import { serveStatic } from "hono/bun"; +import { cors } from "hono/cors"; +import dashboardRoutes from "./api/dashboard"; +import executeRoutes from "./api/execute"; +import pathsRoutes from "./api/paths"; +import reviewRoutes from "./api/review"; +import scanRoutes from "./api/scan"; +import setupRoutes from "./api/setup"; +import subtitlesRoutes from "./api/subtitles"; +import { getDb } from "./db/index"; +import { log } from "./lib/log"; const app = new Hono(); // ─── CORS (dev: Vite on :5173 talks to Hono on :3000) ──────────────────────── -app.use('/api/*', cors({ origin: ['http://localhost:5173', 'http://localhost:3000'] })); +app.use("/api/*", cors({ origin: ["http://localhost:5173", "http://localhost:3000"] })); // ─── Request logging ────────────────────────────────────────────────────────── -app.use('/api/*', async (c, next) => { +app.use("/api/*", async (c, next) => { const start = Date.now(); await next(); const ms = Date.now() - start; // Skip noisy SSE/polling endpoints - if (c.req.path.endsWith('/events')) return; + if (c.req.path.endsWith("/events")) return; log(`${c.req.method} ${c.req.path} → ${c.res.status} (${ms}ms)`); }); // ─── API routes ─────────────────────────────────────────────────────────────── -import pkg from '../package.json'; +import pkg from "../package.json"; -app.get('/api/version', (c) => c.json({ version: pkg.version })); -app.route('/api/dashboard', dashboardRoutes); -app.route('/api/setup', setupRoutes); -app.route('/api/scan', scanRoutes); -app.route('/api/review', reviewRoutes); -app.route('/api/execute', executeRoutes); -app.route('/api/subtitles', subtitlesRoutes); -app.route('/api/paths', pathsRoutes); +app.get("/api/version", (c) => c.json({ version: pkg.version })); +app.route("/api/dashboard", dashboardRoutes); +app.route("/api/setup", setupRoutes); +app.route("/api/scan", scanRoutes); +app.route("/api/review", reviewRoutes); +app.route("/api/execute", executeRoutes); +app.route("/api/subtitles", subtitlesRoutes); +app.route("/api/paths", pathsRoutes); // ─── Static assets (production: serve Vite build) ──────────────────────────── -app.use('/assets/*', serveStatic({ root: './dist' })); -app.use('/favicon.ico', serveStatic({ path: './dist/favicon.ico' })); +app.use("/assets/*", serveStatic({ root: "./dist" })); +app.use("/favicon.ico", serveStatic({ path: "./dist/favicon.ico" })); // ─── SPA fallback ───────────────────────────────────────────────────────────── // All non-API routes serve the React index.html so TanStack Router handles them. -app.get('*', (c) => { - const accept = c.req.header('Accept') ?? ''; - if (c.req.path.startsWith('/api/')) return c.notFound(); +app.get("*", (c) => { + const _accept = c.req.header("Accept") ?? ""; + if (c.req.path.startsWith("/api/")) return c.notFound(); // In dev the Vite server handles the SPA. In production serve dist/index.html. try { - const html = Bun.file('./dist/index.html').text(); + const html = Bun.file("./dist/index.html").text(); return html.then((text) => c.html(text)); } catch { - return c.text('Run `bun build` first to generate the frontend.', 503); + return c.text("Run `bun build` first to generate the frontend.", 503); } }); // ─── Start ──────────────────────────────────────────────────────────────────── -const port = Number(process.env.PORT ?? '3000'); +const port = Number(process.env.PORT ?? "3000"); log(`netfelix-audio-fix v${pkg.version} starting on http://localhost:${port}`); diff --git a/server/lib/__tests__/validate.test.ts b/server/lib/__tests__/validate.test.ts index 47d4cba..5a29794 100644 --- a/server/lib/__tests__/validate.test.ts +++ b/server/lib/__tests__/validate.test.ts @@ -1,34 +1,34 @@ -import { describe, test, expect } from 'bun:test'; -import { parseId, isOneOf } from '../validate'; +import { describe, expect, test } from "bun:test"; +import { isOneOf, parseId } from "../validate"; -describe('parseId', () => { - test('returns the integer for valid numeric strings', () => { - expect(parseId('42')).toBe(42); - expect(parseId('1')).toBe(1); +describe("parseId", () => { + test("returns the integer for valid numeric strings", () => { + expect(parseId("42")).toBe(42); + expect(parseId("1")).toBe(1); }); - test('returns null for invalid, negative, zero, or missing ids', () => { - expect(parseId('0')).toBe(null); - expect(parseId('-1')).toBe(null); - expect(parseId('abc')).toBe(null); - expect(parseId('')).toBe(null); + test("returns null for invalid, negative, zero, or missing ids", () => { + expect(parseId("0")).toBe(null); + expect(parseId("-1")).toBe(null); + expect(parseId("abc")).toBe(null); + expect(parseId("")).toBe(null); expect(parseId(undefined)).toBe(null); }); - test('parses leading integer from mixed strings (parseInt semantics)', () => { - expect(parseId('42abc')).toBe(42); + test("parses leading integer from mixed strings (parseInt semantics)", () => { + expect(parseId("42abc")).toBe(42); }); }); -describe('isOneOf', () => { - test('narrows to allowed string literals', () => { - expect(isOneOf('keep', ['keep', 'remove'] as const)).toBe(true); - expect(isOneOf('remove', ['keep', 'remove'] as const)).toBe(true); +describe("isOneOf", () => { + test("narrows to allowed string literals", () => { + expect(isOneOf("keep", ["keep", "remove"] as const)).toBe(true); + expect(isOneOf("remove", ["keep", "remove"] as const)).toBe(true); }); - test('rejects disallowed values and non-strings', () => { - expect(isOneOf('delete', ['keep', 'remove'] as const)).toBe(false); - expect(isOneOf(null, ['keep', 'remove'] as const)).toBe(false); - expect(isOneOf(42, ['keep', 'remove'] as const)).toBe(false); + test("rejects disallowed values and non-strings", () => { + expect(isOneOf("delete", ["keep", "remove"] as const)).toBe(false); + expect(isOneOf(null, ["keep", "remove"] as const)).toBe(false); + expect(isOneOf(42, ["keep", "remove"] as const)).toBe(false); }); }); diff --git a/server/lib/validate.ts b/server/lib/validate.ts index 5e62fef..3018b6f 100644 --- a/server/lib/validate.ts +++ b/server/lib/validate.ts @@ -1,4 +1,4 @@ -import type { Context } from 'hono'; +import type { Context } from "hono"; /** Parse a route param as a positive integer id. Returns null if invalid. */ export function parseId(raw: string | undefined): number | null { @@ -22,5 +22,5 @@ export function requireId(c: Context, name: string): number | null { /** True if value is one of the allowed strings. */ export function isOneOf(value: unknown, allowed: readonly T[]): value is T { - return typeof value === 'string' && (allowed as readonly string[]).includes(value); + return typeof value === "string" && (allowed as readonly string[]).includes(value); } diff --git a/server/services/__tests__/analyzer.test.ts b/server/services/__tests__/analyzer.test.ts index 43d157d..230fdef 100644 --- a/server/services/__tests__/analyzer.test.ts +++ b/server/services/__tests__/analyzer.test.ts @@ -1,8 +1,8 @@ -import { describe, test, expect } from 'bun:test'; -import { analyzeItem } from '../analyzer'; -import type { MediaStream } from '../../types'; +import { describe, expect, test } from "bun:test"; +import type { MediaStream } from "../../types"; +import { analyzeItem } from "../analyzer"; -type StreamOverride = Partial & Pick; +type StreamOverride = Partial & Pick; function stream(o: StreamOverride): MediaStream { return { @@ -22,112 +22,110 @@ function stream(o: StreamOverride): MediaStream { }; } -const ITEM_DEFAULTS = { needs_review: 0 as number, container: 'mkv' as string | null }; +const ITEM_DEFAULTS = { needs_review: 0 as number, container: "mkv" as string | null }; -describe('analyzeItem — audio keep rules', () => { - test('keeps only OG + configured languages, drops others', () => { +describe("analyzeItem — audio keep rules", () => { + test("keeps only OG + configured languages, drops others", () => { const streams = [ - stream({ id: 1, type: 'Video', stream_index: 0, codec: 'h264' }), - stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }), - stream({ id: 3, type: 'Audio', stream_index: 2, codec: 'aac', language: 'deu' }), - stream({ id: 4, type: 'Audio', stream_index: 3, codec: 'aac', language: 'fra' }), + stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }), + stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng" }), + stream({ id: 3, type: "Audio", stream_index: 2, codec: "aac", language: "deu" }), + stream({ id: 4, type: "Audio", stream_index: 3, codec: "aac", language: "fra" }), ]; - const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, { + const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, { subtitleLanguages: [], - audioLanguages: ['deu'], + audioLanguages: ["deu"], }); - const actions = Object.fromEntries(result.decisions.map(d => [d.stream_id, d.action])); - expect(actions).toEqual({ 1: 'keep', 2: 'keep', 3: 'keep', 4: 'remove' }); + const actions = Object.fromEntries(result.decisions.map((d) => [d.stream_id, d.action])); + expect(actions).toEqual({ 1: "keep", 2: "keep", 3: "keep", 4: "remove" }); }); - test('keeps all audio when OG language unknown', () => { + test("keeps all audio when OG language unknown", () => { const streams = [ - stream({ id: 1, type: 'Audio', stream_index: 0, language: 'eng' }), - stream({ id: 2, type: 'Audio', stream_index: 1, language: 'deu' }), - stream({ id: 3, type: 'Audio', stream_index: 2, language: 'fra' }), + stream({ id: 1, type: "Audio", stream_index: 0, language: "eng" }), + stream({ id: 2, type: "Audio", stream_index: 1, language: "deu" }), + stream({ id: 3, type: "Audio", stream_index: 2, language: "fra" }), ]; const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: null, needs_review: 1 }, streams, { subtitleLanguages: [], - audioLanguages: ['deu'], + audioLanguages: ["deu"], }); - expect(result.decisions.every(d => d.action === 'keep')).toBe(true); - expect(result.notes.some(n => n.includes('manual review'))).toBe(true); + expect(result.decisions.every((d) => d.action === "keep")).toBe(true); + expect(result.notes.some((n) => n.includes("manual review"))).toBe(true); }); - test('keeps audio tracks with undetermined language', () => { + test("keeps audio tracks with undetermined language", () => { const streams = [ - stream({ id: 1, type: 'Audio', stream_index: 0, language: 'eng' }), - stream({ id: 2, type: 'Audio', stream_index: 1, language: null }), + stream({ id: 1, type: "Audio", stream_index: 0, language: "eng" }), + stream({ id: 2, type: "Audio", stream_index: 1, language: null }), ]; - const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, { + const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, { subtitleLanguages: [], audioLanguages: [], }); - const byId = Object.fromEntries(result.decisions.map(d => [d.stream_id, d.action])); - expect(byId[2]).toBe('keep'); + const byId = Object.fromEntries(result.decisions.map((d) => [d.stream_id, d.action])); + expect(byId[2]).toBe("keep"); }); - test('normalizes language codes (ger → deu)', () => { - const streams = [ - stream({ id: 1, type: 'Audio', stream_index: 0, language: 'ger' }), - ]; - const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'deu' }, streams, { + test("normalizes language codes (ger → deu)", () => { + const streams = [stream({ id: 1, type: "Audio", stream_index: 0, language: "ger" })]; + const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "deu" }, streams, { subtitleLanguages: [], audioLanguages: [], }); - expect(result.decisions[0].action).toBe('keep'); + expect(result.decisions[0].action).toBe("keep"); }); }); -describe('analyzeItem — audio ordering', () => { - test('OG first, then additional languages in configured order', () => { +describe("analyzeItem — audio ordering", () => { + test("OG first, then additional languages in configured order", () => { const streams = [ - stream({ id: 10, type: 'Audio', stream_index: 0, codec: 'aac', language: 'deu' }), - stream({ id: 11, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }), - stream({ id: 12, type: 'Audio', stream_index: 2, codec: 'aac', language: 'spa' }), + stream({ id: 10, type: "Audio", stream_index: 0, codec: "aac", language: "deu" }), + stream({ id: 11, type: "Audio", stream_index: 1, codec: "aac", language: "eng" }), + stream({ id: 12, type: "Audio", stream_index: 2, codec: "aac", language: "spa" }), ]; - const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, { + const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, { subtitleLanguages: [], - audioLanguages: ['deu', 'spa'], + audioLanguages: ["deu", "spa"], }); - const byId = Object.fromEntries(result.decisions.map(d => [d.stream_id, d.target_index])); + const byId = Object.fromEntries(result.decisions.map((d) => [d.stream_id, d.target_index])); expect(byId[11]).toBe(0); // eng (OG) first expect(byId[10]).toBe(1); // deu second expect(byId[12]).toBe(2); // spa third }); - test('audioOrderChanged is_noop=false when OG audio is not first in input', () => { + test("audioOrderChanged is_noop=false when OG audio is not first in input", () => { const streams = [ - stream({ id: 1, type: 'Video', stream_index: 0, codec: 'h264' }), - stream({ id: 2, type: 'Audio', stream_index: 1, language: 'deu' }), - stream({ id: 3, type: 'Audio', stream_index: 2, language: 'eng' }), + stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }), + stream({ id: 2, type: "Audio", stream_index: 1, language: "deu" }), + stream({ id: 3, type: "Audio", stream_index: 2, language: "eng" }), ]; - const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, { + const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, { subtitleLanguages: [], - audioLanguages: ['deu'], + audioLanguages: ["deu"], }); expect(result.is_noop).toBe(false); }); - test('audioOrderChanged is_noop=true when OG audio is already first', () => { + test("audioOrderChanged is_noop=true when OG audio is already first", () => { const streams = [ - stream({ id: 1, type: 'Video', stream_index: 0, codec: 'h264' }), - stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }), - stream({ id: 3, type: 'Audio', stream_index: 2, codec: 'aac', language: 'deu' }), + stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }), + stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng" }), + stream({ id: 3, type: "Audio", stream_index: 2, codec: "aac", language: "deu" }), ]; - const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, { + const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, { subtitleLanguages: [], - audioLanguages: ['deu'], + audioLanguages: ["deu"], }); expect(result.is_noop).toBe(true); }); - test('removing an audio track triggers non-noop even if OG first', () => { + test("removing an audio track triggers non-noop even if OG first", () => { const streams = [ - stream({ id: 1, type: 'Audio', stream_index: 0, codec: 'aac', language: 'eng' }), - stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'fra' }), + stream({ id: 1, type: "Audio", stream_index: 0, codec: "aac", language: "eng" }), + stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "fra" }), ]; - const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, { + const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, { subtitleLanguages: [], audioLanguages: [], }); @@ -135,27 +133,27 @@ describe('analyzeItem — audio ordering', () => { }); }); -describe('analyzeItem — subtitles & is_noop', () => { - test('subtitles are always marked remove (extracted to sidecar)', () => { +describe("analyzeItem — subtitles & is_noop", () => { + test("subtitles are always marked remove (extracted to sidecar)", () => { const streams = [ - stream({ id: 1, type: 'Audio', stream_index: 0, codec: 'aac', language: 'eng' }), - stream({ id: 2, type: 'Subtitle', stream_index: 1, codec: 'subrip', language: 'eng' }), + stream({ id: 1, type: "Audio", stream_index: 0, codec: "aac", language: "eng" }), + stream({ id: 2, type: "Subtitle", stream_index: 1, codec: "subrip", language: "eng" }), ]; - const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, { - subtitleLanguages: ['eng'], + const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, { + subtitleLanguages: ["eng"], audioLanguages: [], }); - const subDec = result.decisions.find(d => d.stream_id === 2); - expect(subDec?.action).toBe('remove'); + const subDec = result.decisions.find((d) => d.stream_id === 2); + expect(subDec?.action).toBe("remove"); expect(result.is_noop).toBe(false); // subs present → not noop }); - test('no audio change, no subs → is_noop true', () => { + test("no audio change, no subs → is_noop true", () => { const streams = [ - stream({ id: 1, type: 'Video', stream_index: 0, codec: 'h264' }), - stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }), + stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }), + stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng" }), ]; - const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, { + const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, { subtitleLanguages: [], audioLanguages: [], }); @@ -163,29 +161,25 @@ describe('analyzeItem — subtitles & is_noop', () => { }); }); -describe('analyzeItem — transcode targets', () => { - test('DTS on mp4 → transcode to eac3', () => { - const streams = [ - stream({ id: 1, type: 'Audio', stream_index: 0, codec: 'dts', language: 'eng' }), - ]; - const result = analyzeItem({ original_language: 'eng', needs_review: 0, container: 'mp4' }, streams, { +describe("analyzeItem — transcode targets", () => { + test("DTS on mp4 → transcode to eac3", () => { + const streams = [stream({ id: 1, type: "Audio", stream_index: 0, codec: "dts", language: "eng" })]; + const result = analyzeItem({ original_language: "eng", needs_review: 0, container: "mp4" }, streams, { subtitleLanguages: [], audioLanguages: [], }); - expect(result.decisions[0].transcode_codec).toBe('eac3'); - expect(result.job_type).toBe('transcode'); + expect(result.decisions[0].transcode_codec).toBe("eac3"); + expect(result.job_type).toBe("transcode"); expect(result.is_noop).toBe(false); }); - test('AAC passes through without transcode', () => { - const streams = [ - stream({ id: 1, type: 'Audio', stream_index: 0, codec: 'aac', language: 'eng' }), - ]; - const result = analyzeItem({ original_language: 'eng', needs_review: 0, container: 'mp4' }, streams, { + test("AAC passes through without transcode", () => { + const streams = [stream({ id: 1, type: "Audio", stream_index: 0, codec: "aac", language: "eng" })]; + const result = analyzeItem({ original_language: "eng", needs_review: 0, container: "mp4" }, streams, { subtitleLanguages: [], audioLanguages: [], }); expect(result.decisions[0].transcode_codec).toBe(null); - expect(result.job_type).toBe('copy'); + expect(result.job_type).toBe("copy"); }); }); diff --git a/server/services/__tests__/ffmpeg.test.ts b/server/services/__tests__/ffmpeg.test.ts index 46cc6d7..ff3a52e 100644 --- a/server/services/__tests__/ffmpeg.test.ts +++ b/server/services/__tests__/ffmpeg.test.ts @@ -1,8 +1,8 @@ -import { describe, test, expect } from 'bun:test'; -import { buildCommand, buildPipelineCommand, shellQuote, sortKeptStreams, predictExtractedFiles } from '../ffmpeg'; -import type { MediaItem, MediaStream, StreamDecision } from '../../types'; +import { describe, expect, test } from "bun:test"; +import type { MediaItem, MediaStream, StreamDecision } from "../../types"; +import { buildCommand, buildPipelineCommand, predictExtractedFiles, shellQuote, sortKeptStreams } from "../ffmpeg"; -function stream(o: Partial & Pick): MediaStream { +function stream(o: Partial & Pick): MediaStream { return { item_id: 1, codec: null, @@ -20,7 +20,7 @@ function stream(o: Partial & Pick & Pick): StreamDecision { +function decision(o: Partial & Pick): StreamDecision { return { id: 0, plan_id: 1, @@ -32,162 +32,178 @@ function decision(o: Partial & Pick { - test('wraps plain strings in single quotes', () => { - expect(shellQuote('hello')).toBe("'hello'"); +describe("shellQuote", () => { + test("wraps plain strings in single quotes", () => { + expect(shellQuote("hello")).toBe("'hello'"); }); - test('escapes single quotes safely', () => { + test("escapes single quotes safely", () => { expect(shellQuote("it's")).toBe("'it'\\''s'"); }); - test('handles paths with spaces', () => { - expect(shellQuote('/movies/My Movie.mkv')).toBe("'/movies/My Movie.mkv'"); + test("handles paths with spaces", () => { + expect(shellQuote("/movies/My Movie.mkv")).toBe("'/movies/My Movie.mkv'"); }); }); -describe('sortKeptStreams', () => { - test('orders by type priority (Video, Audio, Subtitle, Data), then target_index', () => { +describe("sortKeptStreams", () => { + test("orders by type priority (Video, Audio, Subtitle, Data), then target_index", () => { const streams = [ - stream({ id: 1, type: 'Audio', stream_index: 1 }), - stream({ id: 2, type: 'Video', stream_index: 0 }), - stream({ id: 3, type: 'Audio', stream_index: 2 }), + stream({ id: 1, type: "Audio", stream_index: 1 }), + stream({ id: 2, type: "Video", stream_index: 0 }), + stream({ id: 3, type: "Audio", stream_index: 2 }), ]; const decisions = [ - decision({ stream_id: 1, action: 'keep', target_index: 1 }), - decision({ stream_id: 2, action: 'keep', target_index: 0 }), - decision({ stream_id: 3, action: 'keep', target_index: 0 }), + decision({ stream_id: 1, action: "keep", target_index: 1 }), + decision({ stream_id: 2, action: "keep", target_index: 0 }), + decision({ stream_id: 3, action: "keep", target_index: 0 }), ]; const sorted = sortKeptStreams(streams, decisions); - expect(sorted.map(k => k.stream.id)).toEqual([2, 3, 1]); + expect(sorted.map((k) => k.stream.id)).toEqual([2, 3, 1]); }); - test('drops streams with action remove', () => { - const streams = [stream({ id: 1, type: 'Audio', stream_index: 0 })]; - const decisions = [decision({ stream_id: 1, action: 'remove' })]; + test("drops streams with action remove", () => { + const streams = [stream({ id: 1, type: "Audio", stream_index: 0 })]; + const decisions = [decision({ stream_id: 1, action: "remove" })]; expect(sortKeptStreams(streams, decisions)).toEqual([]); }); }); -describe('buildCommand', () => { - test('produces ffmpeg remux with tmp-rename pattern', () => { +describe("buildCommand", () => { + test("produces ffmpeg remux with tmp-rename pattern", () => { const streams = [ - stream({ id: 1, type: 'Video', stream_index: 0, codec: 'h264' }), - stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }), + stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }), + stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng" }), ]; const decisions = [ - decision({ stream_id: 1, action: 'keep', target_index: 0 }), - decision({ stream_id: 2, action: 'keep', target_index: 0 }), + decision({ stream_id: 1, action: "keep", target_index: 0 }), + decision({ stream_id: 2, action: "keep", target_index: 0 }), ]; const cmd = buildCommand(ITEM, streams, decisions); - expect(cmd).toContain('ffmpeg'); - expect(cmd).toContain('-map 0:v:0'); - expect(cmd).toContain('-map 0:a:0'); - expect(cmd).toContain('-c copy'); + expect(cmd).toContain("ffmpeg"); + expect(cmd).toContain("-map 0:v:0"); + expect(cmd).toContain("-map 0:a:0"); + expect(cmd).toContain("-c copy"); expect(cmd).toContain("'/movies/Test.tmp.mkv'"); expect(cmd).toContain("mv '/movies/Test.tmp.mkv' '/movies/Test.mkv'"); }); - test('uses type-relative specifiers (0:a:N) not absolute stream_index', () => { + test("uses type-relative specifiers (0:a:N) not absolute stream_index", () => { const streams = [ - stream({ id: 1, type: 'Video', stream_index: 0 }), - stream({ id: 2, type: 'Audio', stream_index: 1 }), - stream({ id: 3, type: 'Audio', stream_index: 2 }), + stream({ id: 1, type: "Video", stream_index: 0 }), + stream({ id: 2, type: "Audio", stream_index: 1 }), + stream({ id: 3, type: "Audio", stream_index: 2 }), ]; // Keep only the second audio; still mapped as 0:a:1 const decisions = [ - decision({ stream_id: 1, action: 'keep', target_index: 0 }), - decision({ stream_id: 2, action: 'remove' }), - decision({ stream_id: 3, action: 'keep', target_index: 0 }), + decision({ stream_id: 1, action: "keep", target_index: 0 }), + decision({ stream_id: 2, action: "remove" }), + decision({ stream_id: 3, action: "keep", target_index: 0 }), ]; const cmd = buildCommand(ITEM, streams, decisions); - expect(cmd).toContain('-map 0:a:1'); - expect(cmd).not.toContain('-map 0:a:2'); + expect(cmd).toContain("-map 0:a:1"); + expect(cmd).not.toContain("-map 0:a:2"); }); - test('sets first kept audio as default, clears others', () => { + test("sets first kept audio as default, clears others", () => { const streams = [ - stream({ id: 1, type: 'Video', stream_index: 0 }), - stream({ id: 2, type: 'Audio', stream_index: 1, language: 'eng' }), - stream({ id: 3, type: 'Audio', stream_index: 2, language: 'deu' }), + stream({ id: 1, type: "Video", stream_index: 0 }), + stream({ id: 2, type: "Audio", stream_index: 1, language: "eng" }), + stream({ id: 3, type: "Audio", stream_index: 2, language: "deu" }), ]; const decisions = [ - decision({ stream_id: 1, action: 'keep', target_index: 0 }), - decision({ stream_id: 2, action: 'keep', target_index: 0 }), - decision({ stream_id: 3, action: 'keep', target_index: 1 }), + decision({ stream_id: 1, action: "keep", target_index: 0 }), + decision({ stream_id: 2, action: "keep", target_index: 0 }), + decision({ stream_id: 3, action: "keep", target_index: 1 }), ]; const cmd = buildCommand(ITEM, streams, decisions); - expect(cmd).toContain('-disposition:a:0 default'); - expect(cmd).toContain('-disposition:a:1 0'); + expect(cmd).toContain("-disposition:a:0 default"); + expect(cmd).toContain("-disposition:a:1 0"); }); }); -describe('buildPipelineCommand', () => { - test('emits subtitle extraction outputs and final remux in one pass', () => { +describe("buildPipelineCommand", () => { + test("emits subtitle extraction outputs and final remux in one pass", () => { const streams = [ - stream({ id: 1, type: 'Video', stream_index: 0 }), - stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }), - stream({ id: 3, type: 'Subtitle', stream_index: 2, codec: 'subrip', language: 'eng' }), + stream({ id: 1, type: "Video", stream_index: 0 }), + stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng" }), + stream({ id: 3, type: "Subtitle", stream_index: 2, codec: "subrip", language: "eng" }), ]; const decisions = [ - decision({ stream_id: 1, action: 'keep', target_index: 0 }), - decision({ stream_id: 2, action: 'keep', target_index: 0 }), - decision({ stream_id: 3, action: 'remove' }), + decision({ stream_id: 1, action: "keep", target_index: 0 }), + decision({ stream_id: 2, action: "keep", target_index: 0 }), + decision({ stream_id: 3, action: "remove" }), ]; const { command, extractedFiles } = buildPipelineCommand(ITEM, streams, decisions); - expect(command).toContain('-map 0:s:0'); - expect(command).toContain('-c:s copy'); + expect(command).toContain("-map 0:s:0"); + expect(command).toContain("-c:s copy"); expect(command).toContain("'/movies/Test.en.srt'"); - expect(command).toContain('-map 0:v:0'); - expect(command).toContain('-map 0:a:0'); + expect(command).toContain("-map 0:v:0"); + expect(command).toContain("-map 0:a:0"); expect(extractedFiles).toHaveLength(1); - expect(extractedFiles[0].path).toBe('/movies/Test.en.srt'); + expect(extractedFiles[0].path).toBe("/movies/Test.en.srt"); }); - test('transcodes incompatible audio with per-track codec flag', () => { - const dtsItem = { ...ITEM, container: 'mp4', file_path: '/movies/x.mp4' }; + test("transcodes incompatible audio with per-track codec flag", () => { + const dtsItem = { ...ITEM, container: "mp4", file_path: "/movies/x.mp4" }; const streams = [ - stream({ id: 1, type: 'Video', stream_index: 0 }), - stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'dts', language: 'eng', channels: 6 }), + stream({ id: 1, type: "Video", stream_index: 0 }), + stream({ id: 2, type: "Audio", stream_index: 1, codec: "dts", language: "eng", channels: 6 }), ]; const decisions = [ - decision({ stream_id: 1, action: 'keep', target_index: 0 }), - decision({ stream_id: 2, action: 'keep', target_index: 0, transcode_codec: 'eac3' }), + decision({ stream_id: 1, action: "keep", target_index: 0 }), + decision({ stream_id: 2, action: "keep", target_index: 0, transcode_codec: "eac3" }), ]; const { command } = buildPipelineCommand(dtsItem, streams, decisions); - expect(command).toContain('-c:a:0 eac3'); - expect(command).toContain('-b:a:0 640k'); // 6 channels → 640k + expect(command).toContain("-c:a:0 eac3"); + expect(command).toContain("-b:a:0 640k"); // 6 channels → 640k }); }); -describe('predictExtractedFiles', () => { - test('predicts sidecar paths matching extraction output', () => { +describe("predictExtractedFiles", () => { + test("predicts sidecar paths matching extraction output", () => { const streams = [ - stream({ id: 1, type: 'Subtitle', stream_index: 0, codec: 'subrip', language: 'eng' }), - stream({ id: 2, type: 'Subtitle', stream_index: 1, codec: 'subrip', language: 'deu', is_forced: 1 }), + stream({ id: 1, type: "Subtitle", stream_index: 0, codec: "subrip", language: "eng" }), + stream({ id: 2, type: "Subtitle", stream_index: 1, codec: "subrip", language: "deu", is_forced: 1 }), ]; const files = predictExtractedFiles(ITEM, streams); expect(files).toHaveLength(2); - expect(files[0].file_path).toBe('/movies/Test.en.srt'); - expect(files[1].file_path).toBe('/movies/Test.de.forced.srt'); + expect(files[0].file_path).toBe("/movies/Test.en.srt"); + expect(files[1].file_path).toBe("/movies/Test.de.forced.srt"); expect(files[1].is_forced).toBe(true); }); - test('deduplicates paths with a numeric suffix', () => { + test("deduplicates paths with a numeric suffix", () => { const streams = [ - stream({ id: 1, type: 'Subtitle', stream_index: 0, codec: 'subrip', language: 'eng' }), - stream({ id: 2, type: 'Subtitle', stream_index: 1, codec: 'subrip', language: 'eng' }), + stream({ id: 1, type: "Subtitle", stream_index: 0, codec: "subrip", language: "eng" }), + stream({ id: 2, type: "Subtitle", stream_index: 1, codec: "subrip", language: "eng" }), ]; const files = predictExtractedFiles(ITEM, streams); - expect(files[0].file_path).toBe('/movies/Test.en.srt'); - expect(files[1].file_path).toBe('/movies/Test.en.2.srt'); + expect(files[0].file_path).toBe("/movies/Test.en.srt"); + expect(files[1].file_path).toBe("/movies/Test.en.2.srt"); }); }); diff --git a/server/services/analyzer.ts b/server/services/analyzer.ts index 75a755c..f4d1eea 100644 --- a/server/services/analyzer.ts +++ b/server/services/analyzer.ts @@ -1,6 +1,6 @@ -import type { MediaItem, MediaStream, PlanResult } from '../types'; -import { normalizeLanguage } from './jellyfin'; -import { transcodeTarget, computeAppleCompat } from './apple-compat'; +import type { MediaItem, MediaStream, PlanResult } from "../types"; +import { computeAppleCompat, transcodeTarget } from "./apple-compat"; +import { normalizeLanguage } from "./jellyfin"; export interface AnalyzerConfig { subtitleLanguages: string[]; @@ -17,77 +17,73 @@ export interface AnalyzerConfig { * at all. */ export function analyzeItem( - item: Pick, + item: Pick, streams: MediaStream[], - config: AnalyzerConfig + config: AnalyzerConfig, ): PlanResult { const origLang = item.original_language ? normalizeLanguage(item.original_language) : null; const notes: string[] = []; - const decisions: PlanResult['decisions'] = streams.map((s) => { + const decisions: PlanResult["decisions"] = streams.map((s) => { const action = decideAction(s, origLang, config.audioLanguages); return { stream_id: s.id, action, target_index: null, transcode_codec: null }; }); - const anyAudioRemoved = streams.some((s, i) => s.type === 'Audio' && decisions[i].action === 'remove'); + const anyAudioRemoved = streams.some((s, i) => s.type === "Audio" && decisions[i].action === "remove"); assignTargetOrder(streams, decisions, origLang, config.audioLanguages); const audioOrderChanged = checkAudioOrderChanged(streams, decisions); for (const d of decisions) { - if (d.action !== 'keep') continue; - const stream = streams.find(s => s.id === d.stream_id); - if (stream && stream.type === 'Audio') { - d.transcode_codec = transcodeTarget(stream.codec ?? '', stream.title, item.container); + if (d.action !== "keep") continue; + const stream = streams.find((s) => s.id === d.stream_id); + if (stream && stream.type === "Audio") { + d.transcode_codec = transcodeTarget(stream.codec ?? "", stream.title, item.container); } } const keptAudioCodecs = decisions - .filter(d => d.action === 'keep') - .map(d => streams.find(s => s.id === d.stream_id)) - .filter((s): s is MediaStream => !!s && s.type === 'Audio') - .map(s => s.codec ?? ''); + .filter((d) => d.action === "keep") + .map((d) => streams.find((s) => s.id === d.stream_id)) + .filter((s): s is MediaStream => !!s && s.type === "Audio") + .map((s) => s.codec ?? ""); - const needsTranscode = decisions.some(d => d.transcode_codec != null); + const needsTranscode = decisions.some((d) => d.transcode_codec != null); const apple_compat = computeAppleCompat(keptAudioCodecs, item.container); - const job_type = needsTranscode ? 'transcode' as const : 'copy' as const; + const job_type = needsTranscode ? ("transcode" as const) : ("copy" as const); - const hasSubs = streams.some((s) => s.type === 'Subtitle'); + const hasSubs = streams.some((s) => s.type === "Subtitle"); const is_noop = !anyAudioRemoved && !audioOrderChanged && !hasSubs && !needsTranscode; if (!origLang && item.needs_review) { - notes.push('Original language unknown — audio tracks not filtered; manual review required'); + notes.push("Original language unknown — audio tracks not filtered; manual review required"); } - return { is_noop, has_subs: hasSubs, confidence: 'low', apple_compat, job_type, decisions, notes }; + return { is_noop, has_subs: hasSubs, confidence: "low", apple_compat, job_type, decisions, notes }; } -function decideAction( - stream: MediaStream, - origLang: string | null, - audioLanguages: string[], -): 'keep' | 'remove' { +function decideAction(stream: MediaStream, origLang: string | null, audioLanguages: string[]): "keep" | "remove" { switch (stream.type) { - case 'Video': - case 'Data': - case 'EmbeddedImage': - return 'keep'; + case "Video": + case "Data": + case "EmbeddedImage": + return "keep"; - case 'Audio': { - if (!origLang) return 'keep'; - if (!stream.language) return 'keep'; + case "Audio": { + if (!origLang) return "keep"; + if (!stream.language) return "keep"; const normalized = normalizeLanguage(stream.language); - if (normalized === origLang) return 'keep'; - if (audioLanguages.includes(normalized)) return 'keep'; - return 'remove'; + if (normalized === origLang) return "keep"; + if (audioLanguages.includes(normalized)) return "keep"; + return "remove"; } - case 'Subtitle': - return 'remove'; + case "Subtitle": + return "remove"; default: - return 'keep'; + return "keep"; } } @@ -99,19 +95,19 @@ function decideAction( */ export function assignTargetOrder( allStreams: MediaStream[], - decisions: PlanResult['decisions'], + decisions: PlanResult["decisions"], origLang: string | null, audioLanguages: string[], ): void { const keptByType = new Map(); for (const s of allStreams) { - const dec = decisions.find(d => d.stream_id === s.id); - if (dec?.action !== 'keep') continue; + const dec = decisions.find((d) => d.stream_id === s.id); + if (dec?.action !== "keep") continue; if (!keptByType.has(s.type)) keptByType.set(s.type, []); keptByType.get(s.type)!.push(s); } - const audio = keptByType.get('Audio'); + const audio = keptByType.get("Audio"); if (audio) { audio.sort((a, b) => { const aRank = langRank(a.language, origLang, audioLanguages); @@ -123,7 +119,7 @@ export function assignTargetOrder( for (const [, streams] of keptByType) { streams.forEach((s, idx) => { - const dec = decisions.find(d => d.stream_id === s.id); + const dec = decisions.find((d) => d.stream_id === s.id); if (dec) dec.target_index = idx; }); } @@ -144,16 +140,13 @@ function langRank(lang: string | null, origLang: string | null, audioLanguages: * original order in the input. Compares original stream_index order * against target_index order. */ -function checkAudioOrderChanged( - streams: MediaStream[], - decisions: PlanResult['decisions'] -): boolean { +function checkAudioOrderChanged(streams: MediaStream[], decisions: PlanResult["decisions"]): boolean { const keptAudio = streams - .filter(s => s.type === 'Audio' && decisions.find(d => d.stream_id === s.id)?.action === 'keep') + .filter((s) => s.type === "Audio" && decisions.find((d) => d.stream_id === s.id)?.action === "keep") .sort((a, b) => a.stream_index - b.stream_index); for (let i = 0; i < keptAudio.length; i++) { - const dec = decisions.find(d => d.stream_id === keptAudio[i].id); + const dec = decisions.find((d) => d.stream_id === keptAudio[i].id); if (dec?.target_index !== i) return true; } return false; diff --git a/server/services/apple-compat.ts b/server/services/apple-compat.ts index 97c620f..7952136 100644 --- a/server/services/apple-compat.ts +++ b/server/services/apple-compat.ts @@ -3,64 +3,67 @@ // Everything else (DTS family, TrueHD family) needs transcoding. const APPLE_COMPATIBLE_AUDIO = new Set([ - 'aac', 'ac3', 'eac3', 'alac', 'flac', 'mp3', - 'pcm_s16le', 'pcm_s24le', 'pcm_s32le', 'pcm_f32le', - 'pcm_s16be', 'pcm_s24be', 'pcm_s32be', 'pcm_f64le', - 'opus', + "aac", + "ac3", + "eac3", + "alac", + "flac", + "mp3", + "pcm_s16le", + "pcm_s24le", + "pcm_s32le", + "pcm_f32le", + "pcm_s16be", + "pcm_s24be", + "pcm_s32be", + "pcm_f64le", + "opus", ]); // Codec strings Jellyfin may report for DTS variants -const DTS_CODECS = new Set([ - 'dts', 'dca', -]); +const DTS_CODECS = new Set(["dts", "dca"]); -const TRUEHD_CODECS = new Set([ - 'truehd', -]); +const TRUEHD_CODECS = new Set(["truehd"]); export function isAppleCompatible(codec: string): boolean { return APPLE_COMPATIBLE_AUDIO.has(codec.toLowerCase()); } /** Maps (codec, profile, container) → target codec for transcoding. */ -export function transcodeTarget( - codec: string, - profile: string | null, - container: string | null, -): string | null { +export function transcodeTarget(codec: string, profile: string | null, container: string | null): string | null { const c = codec.toLowerCase(); - const isMkv = !container || container.toLowerCase() === 'mkv' || container.toLowerCase() === 'matroska'; + const isMkv = !container || container.toLowerCase() === "mkv" || container.toLowerCase() === "matroska"; if (isAppleCompatible(c)) return null; // no transcode needed // DTS-HD MA and DTS:X are lossless → FLAC in MKV, EAC3 in MP4 if (DTS_CODECS.has(c)) { - const p = (profile ?? '').toLowerCase(); - const isLossless = p.includes('ma') || p.includes('hd ma') || p.includes('x'); - if (isLossless) return isMkv ? 'flac' : 'eac3'; + const p = (profile ?? "").toLowerCase(); + const isLossless = p.includes("ma") || p.includes("hd ma") || p.includes("x"); + if (isLossless) return isMkv ? "flac" : "eac3"; // Lossy DTS variants → EAC3 - return 'eac3'; + return "eac3"; } // TrueHD (including Atmos) → FLAC in MKV, EAC3 in MP4 if (TRUEHD_CODECS.has(c)) { - return isMkv ? 'flac' : 'eac3'; + return isMkv ? "flac" : "eac3"; } // Any other incompatible codec → EAC3 as safe fallback - return 'eac3'; + return "eac3"; } /** Determine overall Apple compatibility for a set of kept audio streams. */ export function computeAppleCompat( keptAudioCodecs: string[], container: string | null, -): 'direct_play' | 'remux' | 'audio_transcode' { - const hasIncompatible = keptAudioCodecs.some(c => !isAppleCompatible(c)); - if (hasIncompatible) return 'audio_transcode'; +): "direct_play" | "remux" | "audio_transcode" { + const hasIncompatible = keptAudioCodecs.some((c) => !isAppleCompatible(c)); + if (hasIncompatible) return "audio_transcode"; - const isMkv = !container || container.toLowerCase() === 'mkv' || container.toLowerCase() === 'matroska'; - if (isMkv) return 'remux'; + const isMkv = !container || container.toLowerCase() === "mkv" || container.toLowerCase() === "matroska"; + if (isMkv) return "remux"; - return 'direct_play'; + return "direct_play"; } diff --git a/server/services/ffmpeg.ts b/server/services/ffmpeg.ts index dd5dcfb..b5c9bd9 100644 --- a/server/services/ffmpeg.ts +++ b/server/services/ffmpeg.ts @@ -1,44 +1,83 @@ -import type { MediaItem, MediaStream, StreamDecision } from '../types'; -import { normalizeLanguage } from './jellyfin'; +import type { MediaItem, MediaStream, StreamDecision } from "../types"; +import { normalizeLanguage } from "./jellyfin"; // ─── Subtitle extraction helpers ────────────────────────────────────────────── /** ISO 639-2/B → ISO 639-1 two-letter codes for subtitle filenames. */ const ISO639_1: Record = { - eng: 'en', deu: 'de', spa: 'es', fra: 'fr', ita: 'it', - por: 'pt', jpn: 'ja', kor: 'ko', zho: 'zh', ara: 'ar', - rus: 'ru', nld: 'nl', swe: 'sv', nor: 'no', dan: 'da', - fin: 'fi', pol: 'pl', tur: 'tr', tha: 'th', hin: 'hi', - hun: 'hu', ces: 'cs', ron: 'ro', ell: 'el', heb: 'he', - fas: 'fa', ukr: 'uk', ind: 'id', cat: 'ca', nob: 'nb', - nno: 'nn', isl: 'is', hrv: 'hr', slk: 'sk', bul: 'bg', - srp: 'sr', slv: 'sl', lav: 'lv', lit: 'lt', est: 'et', + eng: "en", + deu: "de", + spa: "es", + fra: "fr", + ita: "it", + por: "pt", + jpn: "ja", + kor: "ko", + zho: "zh", + ara: "ar", + rus: "ru", + nld: "nl", + swe: "sv", + nor: "no", + dan: "da", + fin: "fi", + pol: "pl", + tur: "tr", + tha: "th", + hin: "hi", + hun: "hu", + ces: "cs", + ron: "ro", + ell: "el", + heb: "he", + fas: "fa", + ukr: "uk", + ind: "id", + cat: "ca", + nob: "nb", + nno: "nn", + isl: "is", + hrv: "hr", + slk: "sk", + bul: "bg", + srp: "sr", + slv: "sl", + lav: "lv", + lit: "lt", + est: "et", }; /** Subtitle codec → external file extension. */ const SUBTITLE_EXT: Record = { - subrip: 'srt', srt: 'srt', ass: 'ass', ssa: 'ssa', - webvtt: 'vtt', vtt: 'vtt', - hdmv_pgs_subtitle: 'sup', pgssub: 'sup', - dvd_subtitle: 'sub', dvbsub: 'sub', - mov_text: 'srt', text: 'srt', + subrip: "srt", + srt: "srt", + ass: "ass", + ssa: "ssa", + webvtt: "vtt", + vtt: "vtt", + hdmv_pgs_subtitle: "sup", + pgssub: "sup", + dvd_subtitle: "sub", + dvbsub: "sub", + mov_text: "srt", + text: "srt", }; function subtitleLang2(lang: string | null): string { - if (!lang) return 'und'; + if (!lang) return "und"; const n = normalizeLanguage(lang); return ISO639_1[n] ?? n; } /** Returns the ffmpeg codec name to use when extracting this subtitle stream. */ function subtitleCodecArg(codec: string | null): string { - if (!codec) return 'copy'; - return codec.toLowerCase() === 'mov_text' ? 'subrip' : 'copy'; + if (!codec) return "copy"; + return codec.toLowerCase() === "mov_text" ? "subrip" : "copy"; } function subtitleExtForCodec(codec: string | null): string { - if (!codec) return 'srt'; - return SUBTITLE_EXT[codec.toLowerCase()] ?? 'srt'; + if (!codec) return "srt"; + return SUBTITLE_EXT[codec.toLowerCase()] ?? "srt"; } /** @@ -60,19 +99,14 @@ interface ExtractionEntry { } /** Compute extraction metadata for all subtitle streams. Shared by buildExtractionOutputs and predictExtractedFiles. */ -function computeExtractionEntries( - allStreams: MediaStream[], - basePath: string -): ExtractionEntry[] { +function computeExtractionEntries(allStreams: MediaStream[], basePath: string): ExtractionEntry[] { const subTypeIdx = new Map(); let subCount = 0; for (const s of [...allStreams].sort((a, b) => a.stream_index - b.stream_index)) { - if (s.type === 'Subtitle') subTypeIdx.set(s.id, subCount++); + if (s.type === "Subtitle") subTypeIdx.set(s.id, subCount++); } - const allSubs = allStreams - .filter((s) => s.type === 'Subtitle') - .sort((a, b) => a.stream_index - b.stream_index); + const allSubs = allStreams.filter((s) => s.type === "Subtitle").sort((a, b) => a.stream_index - b.stream_index); if (allSubs.length === 0) return []; @@ -86,13 +120,13 @@ function computeExtractionEntries( const codecArg = subtitleCodecArg(s.codec); const nameParts = [langCode]; - if (s.is_forced) nameParts.push('forced'); - if (s.is_hearing_impaired) nameParts.push('hi'); + if (s.is_forced) nameParts.push("forced"); + if (s.is_hearing_impaired) nameParts.push("hi"); - let outPath = `${basePath}.${nameParts.join('.')}.${ext}`; + let outPath = `${basePath}.${nameParts.join(".")}.${ext}`; let counter = 2; while (usedNames.has(outPath)) { - outPath = `${basePath}.${nameParts.join('.')}.${counter}.${ext}`; + outPath = `${basePath}.${nameParts.join(".")}.${counter}.${ext}`; counter++; } usedNames.add(outPath); @@ -103,10 +137,7 @@ function computeExtractionEntries( return entries; } -function buildExtractionOutputs( - allStreams: MediaStream[], - basePath: string -): string[] { +function buildExtractionOutputs(allStreams: MediaStream[], basePath: string): string[] { const entries = computeExtractionEntries(allStreams, basePath); const args: string[] = []; for (const e of entries) { @@ -121,9 +152,15 @@ function buildExtractionOutputs( */ export function predictExtractedFiles( item: MediaItem, - streams: MediaStream[] -): Array<{ file_path: string; language: string | null; codec: string | null; is_forced: boolean; is_hearing_impaired: boolean }> { - const basePath = item.file_path.replace(/\.[^.]+$/, ''); + streams: MediaStream[], +): Array<{ + file_path: string; + language: string | null; + codec: string | null; + is_forced: boolean; + is_hearing_impaired: boolean; +}> { + const basePath = item.file_path.replace(/\.[^.]+$/, ""); const entries = computeExtractionEntries(streams, basePath); return entries.map((e) => ({ file_path: e.outPath, @@ -137,21 +174,50 @@ export function predictExtractedFiles( // ───────────────────────────────────────────────────────────────────────────── const LANG_NAMES: Record = { - eng: 'English', deu: 'German', spa: 'Spanish', fra: 'French', - ita: 'Italian', por: 'Portuguese', jpn: 'Japanese', kor: 'Korean', - zho: 'Chinese', ara: 'Arabic', rus: 'Russian', nld: 'Dutch', - swe: 'Swedish', nor: 'Norwegian', dan: 'Danish', fin: 'Finnish', - pol: 'Polish', tur: 'Turkish', tha: 'Thai', hin: 'Hindi', - hun: 'Hungarian', ces: 'Czech', ron: 'Romanian', ell: 'Greek', - heb: 'Hebrew', fas: 'Persian', ukr: 'Ukrainian', ind: 'Indonesian', - cat: 'Catalan', nob: 'Norwegian Bokmål', nno: 'Norwegian Nynorsk', - isl: 'Icelandic', slk: 'Slovak', hrv: 'Croatian', bul: 'Bulgarian', - srp: 'Serbian', slv: 'Slovenian', lav: 'Latvian', lit: 'Lithuanian', - est: 'Estonian', + eng: "English", + deu: "German", + spa: "Spanish", + fra: "French", + ita: "Italian", + por: "Portuguese", + jpn: "Japanese", + kor: "Korean", + zho: "Chinese", + ara: "Arabic", + rus: "Russian", + nld: "Dutch", + swe: "Swedish", + nor: "Norwegian", + dan: "Danish", + fin: "Finnish", + pol: "Polish", + tur: "Turkish", + tha: "Thai", + hin: "Hindi", + hun: "Hungarian", + ces: "Czech", + ron: "Romanian", + ell: "Greek", + heb: "Hebrew", + fas: "Persian", + ukr: "Ukrainian", + ind: "Indonesian", + cat: "Catalan", + nob: "Norwegian Bokmål", + nno: "Norwegian Nynorsk", + isl: "Icelandic", + slk: "Slovak", + hrv: "Croatian", + bul: "Bulgarian", + srp: "Serbian", + slv: "Slovenian", + lav: "Latvian", + lit: "Lithuanian", + est: "Estonian", }; function trackTitle(stream: MediaStream): string | null { - if (stream.type === 'Subtitle') { + if (stream.type === "Subtitle") { // Subtitles always get a clean language-based title so Jellyfin displays // "German", "English (Forced)", etc. regardless of the original file title. // The review UI shows a ⚠ badge when the original title looks like a @@ -171,7 +237,7 @@ function trackTitle(stream: MediaStream): string | null { return LANG_NAMES[lang] ?? lang.toUpperCase(); } -const TYPE_SPEC: Record = { Video: 'v', Audio: 'a', Subtitle: 's' }; +const TYPE_SPEC: Record = { Video: "v", Audio: "a", Subtitle: "s" }; /** * Build -map flags using type-relative specifiers (0:v:N, 0:a:N, 0:s:N). @@ -181,10 +247,7 @@ const TYPE_SPEC: Record = { Video: 'v', Audio: 'a', Subtitle: 's * as attachments). Using the stream's position within its own type group * matches ffmpeg's 0:a:N convention exactly and avoids silent mismatches. */ -function buildMaps( - allStreams: MediaStream[], - kept: { stream: MediaStream; dec: StreamDecision }[] -): string[] { +function buildMaps(allStreams: MediaStream[], kept: { stream: MediaStream; dec: StreamDecision }[]): string[] { // Map each stream id → its 0-based position among streams of the same type, // sorted by stream_index (the order ffmpeg sees them in the input). const typePos = new Map(); @@ -206,15 +269,13 @@ function buildMaps( * - Marks the first kept audio stream as default, clears all others. * - Sets harmonized language-name titles on all kept audio streams. */ -function buildStreamFlags( - kept: { stream: MediaStream; dec: StreamDecision }[] -): string[] { - const audioKept = kept.filter((k) => k.stream.type === 'Audio'); +function buildStreamFlags(kept: { stream: MediaStream; dec: StreamDecision }[]): string[] { + const audioKept = kept.filter((k) => k.stream.type === "Audio"); const args: string[] = []; // Disposition: first audio = default, rest = clear audioKept.forEach((_, i) => { - args.push(`-disposition:a:${i}`, i === 0 ? 'default' : '0'); + args.push(`-disposition:a:${i}`, i === 0 ? "default" : "0"); }); // Titles for audio streams (custom_title overrides generated title) @@ -236,12 +297,12 @@ const TYPE_ORDER: Record = { Video: 0, Audio: 1, Subtitle: 2, Da */ export function sortKeptStreams( streams: MediaStream[], - decisions: StreamDecision[] + decisions: StreamDecision[], ): { stream: MediaStream; dec: StreamDecision }[] { const kept: { stream: MediaStream; dec: StreamDecision }[] = []; for (const s of streams) { - const dec = decisions.find(d => d.stream_id === s.id); - if (dec?.action === 'keep') kept.push({ stream: s, dec }); + const dec = decisions.find((d) => d.stream_id === s.id); + if (dec?.action === "keep") kept.push({ stream: s, dec }); } kept.sort((a, b) => { const ta = TYPE_ORDER[a.stream.type] ?? 9; @@ -258,47 +319,42 @@ export function sortKeptStreams( * * Returns null if all streams are kept and ordering is unchanged (noop). */ -export function buildCommand( - item: MediaItem, - streams: MediaStream[], - decisions: StreamDecision[] -): string { +export function buildCommand(item: MediaItem, streams: MediaStream[], decisions: StreamDecision[]): string { const kept = sortKeptStreams(streams, decisions); const inputPath = item.file_path; - const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? 'mkv'; + const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? "mkv"; const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`); const maps = buildMaps(streams, kept); const streamFlags = buildStreamFlags(kept); const parts: string[] = [ - 'ffmpeg', - '-y', - '-i', shellQuote(inputPath), + "ffmpeg", + "-y", + "-i", + shellQuote(inputPath), ...maps, ...streamFlags, - '-c copy', + "-c copy", shellQuote(tmpPath), - '&&', - 'mv', shellQuote(tmpPath), shellQuote(inputPath), + "&&", + "mv", + shellQuote(tmpPath), + shellQuote(inputPath), ]; - return parts.join(' '); + return parts.join(" "); } /** * Build a command that also changes the container to MKV. * Used when MP4 container can't hold certain subtitle codecs. */ -export function buildMkvConvertCommand( - item: MediaItem, - streams: MediaStream[], - decisions: StreamDecision[] -): string { +export function buildMkvConvertCommand(item: MediaItem, streams: MediaStream[], decisions: StreamDecision[]): string { const inputPath = item.file_path; - const outputPath = inputPath.replace(/\.[^.]+$/, '.mkv'); - const tmpPath = inputPath.replace(/\.[^.]+$/, '.tmp.mkv'); + const outputPath = inputPath.replace(/\.[^.]+$/, ".mkv"); + const tmpPath = inputPath.replace(/\.[^.]+$/, ".tmp.mkv"); const kept = sortKeptStreams(streams, decisions); @@ -306,16 +362,20 @@ export function buildMkvConvertCommand( const streamFlags = buildStreamFlags(kept); return [ - 'ffmpeg', '-y', - '-i', shellQuote(inputPath), + "ffmpeg", + "-y", + "-i", + shellQuote(inputPath), ...maps, ...streamFlags, - '-c copy', - '-f matroska', + "-c copy", + "-f matroska", shellQuote(tmpPath), - '&&', - 'mv', shellQuote(tmpPath), shellQuote(outputPath), - ].join(' '); + "&&", + "mv", + shellQuote(tmpPath), + shellQuote(outputPath), + ].join(" "); } /** @@ -326,37 +386,38 @@ export function buildMkvConvertCommand( * track to its own sidecar file, then the final output copies all * video + audio streams into a temp file without subtitles. */ -export function buildExtractOnlyCommand( - item: MediaItem, - streams: MediaStream[] -): string | null { - const basePath = item.file_path.replace(/\.[^.]+$/, ''); +export function buildExtractOnlyCommand(item: MediaItem, streams: MediaStream[]): string | null { + const basePath = item.file_path.replace(/\.[^.]+$/, ""); const extractionOutputs = buildExtractionOutputs(streams, basePath); if (extractionOutputs.length === 0) return null; const inputPath = item.file_path; - const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? 'mkv'; + const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? "mkv"; const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`); // Only map audio if the file actually has audio streams - const hasAudio = streams.some((s) => s.type === 'Audio'); - const remuxMaps = hasAudio ? ['-map 0:v', '-map 0:a'] : ['-map 0:v']; + const hasAudio = streams.some((s) => s.type === "Audio"); + const remuxMaps = hasAudio ? ["-map 0:v", "-map 0:a"] : ["-map 0:v"]; // Single ffmpeg pass: extract sidecar files + remux without subtitles const parts: string[] = [ - 'ffmpeg', '-y', - '-i', shellQuote(inputPath), + "ffmpeg", + "-y", + "-i", + shellQuote(inputPath), // Subtitle extraction outputs (each to its own file) ...extractionOutputs, // Final output: copy all video + audio, no subtitles ...remuxMaps, - '-c copy', + "-c copy", shellQuote(tmpPath), - '&&', - 'mv', shellQuote(tmpPath), shellQuote(inputPath), + "&&", + "mv", + shellQuote(tmpPath), + shellQuote(inputPath), ]; - return parts.join(' '); + return parts.join(" "); } /** @@ -368,12 +429,21 @@ export function buildExtractOnlyCommand( export function buildPipelineCommand( item: MediaItem, streams: MediaStream[], - decisions: (StreamDecision & { stream?: MediaStream })[] -): { command: string; extractedFiles: Array<{ path: string; language: string | null; codec: string | null; is_forced: number; is_hearing_impaired: number }> } { + decisions: (StreamDecision & { stream?: MediaStream })[], +): { + command: string; + extractedFiles: Array<{ + path: string; + language: string | null; + codec: string | null; + is_forced: number; + is_hearing_impaired: number; + }>; +} { const inputPath = item.file_path; - const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? 'mkv'; + const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? "mkv"; const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`); - const basePath = inputPath.replace(/\.[^.]+$/, ''); + const basePath = inputPath.replace(/\.[^.]+$/, ""); // --- Subtitle extraction outputs --- const extractionEntries = computeExtractionEntries(streams, basePath); @@ -384,21 +454,21 @@ export function buildPipelineCommand( // --- Kept streams for remuxed output --- const kept = sortKeptStreams(streams, decisions as StreamDecision[]); - const enriched = kept.map(k => ({ ...k.dec, stream: k.stream })); + const enriched = kept.map((k) => ({ ...k.dec, stream: k.stream })); // Build -map flags const maps = buildMaps(streams, kept); // Build per-stream codec flags - const codecFlags: string[] = ['-c:v copy']; + const codecFlags: string[] = ["-c:v copy"]; let audioIdx = 0; for (const d of enriched) { - if (d.stream.type === 'Audio') { + if (d.stream.type === "Audio") { if (d.transcode_codec) { codecFlags.push(`-c:a:${audioIdx} ${d.transcode_codec}`); // For EAC3, set a reasonable bitrate based on channel count - if (d.transcode_codec === 'eac3') { - const bitrate = (d.stream.channels ?? 2) >= 6 ? '640k' : '256k'; + if (d.transcode_codec === "eac3") { + const bitrate = (d.stream.channels ?? 2) >= 6 ? "640k" : "256k"; codecFlags.push(`-b:a:${audioIdx} ${bitrate}`); } } else { @@ -409,17 +479,14 @@ export function buildPipelineCommand( } // If no audio transcoding, simplify to -c copy (covers video + audio) - const hasTranscode = enriched.some(d => d.transcode_codec); - const finalCodecFlags = hasTranscode ? codecFlags : ['-c copy']; + const hasTranscode = enriched.some((d) => d.transcode_codec); + const finalCodecFlags = hasTranscode ? codecFlags : ["-c copy"]; // Disposition + metadata flags for audio const streamFlags = buildStreamFlags(kept); // Assemble command - const parts: string[] = [ - 'ffmpeg', '-y', - '-i', shellQuote(inputPath), - ]; + const parts: string[] = ["ffmpeg", "-y", "-i", shellQuote(inputPath)]; // Subtitle extraction outputs first parts.push(...subOutputArgs); @@ -436,12 +503,11 @@ export function buildPipelineCommand( // Output file parts.push(shellQuote(tmpPath)); - const command = parts.join(' ') - + ` && mv ${shellQuote(tmpPath)} ${shellQuote(inputPath)}`; + const command = `${parts.join(" ")} && mv ${shellQuote(tmpPath)} ${shellQuote(inputPath)}`; return { command, - extractedFiles: extractionEntries.map(e => ({ + extractedFiles: extractionEntries.map((e) => ({ path: e.outPath, language: e.stream.language, codec: e.stream.codec, @@ -459,13 +525,13 @@ export function shellQuote(s: string): string { /** Returns a human-readable summary of what will change. */ export function summarizeChanges( streams: MediaStream[], - decisions: StreamDecision[] + decisions: StreamDecision[], ): { removed: MediaStream[]; kept: MediaStream[] } { const removed: MediaStream[] = []; const kept: MediaStream[] = []; for (const s of streams) { const dec = decisions.find((d) => d.stream_id === s.id); - if (!dec || dec.action === 'remove') removed.push(s); + if (!dec || dec.action === "remove") removed.push(s); else kept.push(s); } return { removed, kept }; @@ -477,8 +543,8 @@ export function streamLabel(s: MediaStream): string { if (s.codec) parts.push(s.codec); if (s.language_display || s.language) parts.push(s.language_display ?? s.language!); if (s.title) parts.push(`"${s.title}"`); - if (s.type === 'Audio' && s.channels) parts.push(`${s.channels}ch`); - if (s.is_forced) parts.push('forced'); - if (s.is_hearing_impaired) parts.push('CC'); - return parts.join(' · '); + if (s.type === "Audio" && s.channels) parts.push(`${s.channels}ch`); + if (s.is_forced) parts.push("forced"); + if (s.is_hearing_impaired) parts.push("CC"); + return parts.join(" · "); } diff --git a/server/services/jellyfin.ts b/server/services/jellyfin.ts index 1656ab2..fe01afd 100644 --- a/server/services/jellyfin.ts +++ b/server/services/jellyfin.ts @@ -1,4 +1,4 @@ -import type { JellyfinItem, JellyfinMediaStream, JellyfinUser, MediaStream } from '../types'; +import type { JellyfinItem, JellyfinMediaStream, JellyfinUser, MediaStream } from "../types"; export interface JellyfinConfig { url: string; @@ -16,8 +16,8 @@ const PAGE_SIZE = 200; function headers(apiKey: string): Record { return { - 'X-Emby-Token': apiKey, - 'Content-Type': 'application/json', + "X-Emby-Token": apiKey, + "Content-Type": "application/json", }; } @@ -33,36 +33,36 @@ export async function testConnection(cfg: JellyfinConfig): Promise<{ ok: boolean } } -export async function getUsers(cfg: Pick): Promise { +export async function getUsers(cfg: Pick): Promise { const res = await fetch(`${cfg.url}/Users`, { headers: headers(cfg.apiKey) }); if (!res.ok) throw new Error(`Jellyfin /Users failed: ${res.status}`); return res.json() as Promise; } const ITEM_FIELDS = [ - 'MediaStreams', - 'Path', - 'ProviderIds', - 'OriginalTitle', - 'ProductionYear', - 'Size', - 'Container', -].join(','); + "MediaStreams", + "Path", + "ProviderIds", + "OriginalTitle", + "ProductionYear", + "Size", + "Container", +].join(","); export async function* getAllItems( cfg: JellyfinConfig, - onProgress?: (count: number, total: number) => void + onProgress?: (count: number, total: number) => void, ): AsyncGenerator { let startIndex = 0; let total = 0; do { const url = new URL(itemsBaseUrl(cfg)); - url.searchParams.set('Recursive', 'true'); - url.searchParams.set('IncludeItemTypes', 'Movie,Episode'); - url.searchParams.set('Fields', ITEM_FIELDS); - url.searchParams.set('Limit', String(PAGE_SIZE)); - url.searchParams.set('StartIndex', String(startIndex)); + url.searchParams.set("Recursive", "true"); + url.searchParams.set("IncludeItemTypes", "Movie,Episode"); + url.searchParams.set("Fields", ITEM_FIELDS); + url.searchParams.set("Limit", String(PAGE_SIZE)); + url.searchParams.set("StartIndex", String(startIndex)); const res = await fetch(url.toString(), { headers: headers(cfg.apiKey) }); if (!res.ok) throw new Error(`Jellyfin items failed: ${res.status}`); @@ -86,33 +86,34 @@ export async function* getAllItems( export async function* getDevItems(cfg: JellyfinConfig): AsyncGenerator { // 50 random movies const movieUrl = new URL(itemsBaseUrl(cfg)); - movieUrl.searchParams.set('Recursive', 'true'); - movieUrl.searchParams.set('IncludeItemTypes', 'Movie'); - movieUrl.searchParams.set('SortBy', 'Random'); - movieUrl.searchParams.set('Limit', '50'); - movieUrl.searchParams.set('Fields', ITEM_FIELDS); + movieUrl.searchParams.set("Recursive", "true"); + movieUrl.searchParams.set("IncludeItemTypes", "Movie"); + movieUrl.searchParams.set("SortBy", "Random"); + movieUrl.searchParams.set("Limit", "50"); + movieUrl.searchParams.set("Fields", ITEM_FIELDS); const movieRes = await fetch(movieUrl.toString(), { headers: headers(cfg.apiKey) }); - if (!movieRes.ok) throw new Error(`Jellyfin movies failed: HTTP ${movieRes.status} — check JELLYFIN_URL and JELLYFIN_API_KEY`); + if (!movieRes.ok) + throw new Error(`Jellyfin movies failed: HTTP ${movieRes.status} — check JELLYFIN_URL and JELLYFIN_API_KEY`); const movieBody = (await movieRes.json()) as { Items: JellyfinItem[] }; for (const item of movieBody.Items) yield item; // 10 random series → yield all their episodes const seriesUrl = new URL(itemsBaseUrl(cfg)); - seriesUrl.searchParams.set('Recursive', 'true'); - seriesUrl.searchParams.set('IncludeItemTypes', 'Series'); - seriesUrl.searchParams.set('SortBy', 'Random'); - seriesUrl.searchParams.set('Limit', '10'); + seriesUrl.searchParams.set("Recursive", "true"); + seriesUrl.searchParams.set("IncludeItemTypes", "Series"); + seriesUrl.searchParams.set("SortBy", "Random"); + seriesUrl.searchParams.set("Limit", "10"); const seriesRes = await fetch(seriesUrl.toString(), { headers: headers(cfg.apiKey) }); if (!seriesRes.ok) throw new Error(`Jellyfin series failed: HTTP ${seriesRes.status}`); const seriesBody = (await seriesRes.json()) as { Items: Array<{ Id: string }> }; for (const series of seriesBody.Items) { const epUrl = new URL(itemsBaseUrl(cfg)); - epUrl.searchParams.set('ParentId', series.Id); - epUrl.searchParams.set('Recursive', 'true'); - epUrl.searchParams.set('IncludeItemTypes', 'Episode'); - epUrl.searchParams.set('Fields', ITEM_FIELDS); + epUrl.searchParams.set("ParentId", series.Id); + epUrl.searchParams.set("Recursive", "true"); + epUrl.searchParams.set("IncludeItemTypes", "Episode"); + epUrl.searchParams.set("Fields", ITEM_FIELDS); const epRes = await fetch(epUrl.toString(), { headers: headers(cfg.apiKey) }); if (epRes.ok) { @@ -126,7 +127,7 @@ export async function* getDevItems(cfg: JellyfinConfig): AsyncGenerator { const base = cfg.userId ? `${cfg.url}/Users/${cfg.userId}/Items/${jellyfinId}` : `${cfg.url}/Items/${jellyfinId}`; const url = new URL(base); - url.searchParams.set('Fields', ITEM_FIELDS); + url.searchParams.set("Fields", ITEM_FIELDS); const res = await fetch(url.toString(), { headers: headers(cfg.apiKey) }); if (!res.ok) return null; return res.json() as Promise; @@ -147,11 +148,11 @@ export async function refreshItem(cfg: JellyfinConfig, jellyfinId: string, timeo // 2. Trigger refresh (returns 204 immediately; refresh runs async) const refreshUrl = new URL(`${itemUrl}/Refresh`); - refreshUrl.searchParams.set('MetadataRefreshMode', 'FullRefresh'); - refreshUrl.searchParams.set('ImageRefreshMode', 'None'); - refreshUrl.searchParams.set('ReplaceAllMetadata', 'false'); - refreshUrl.searchParams.set('ReplaceAllImages', 'false'); - const refreshRes = await fetch(refreshUrl.toString(), { method: 'POST', headers: headers(cfg.apiKey) }); + refreshUrl.searchParams.set("MetadataRefreshMode", "FullRefresh"); + refreshUrl.searchParams.set("ImageRefreshMode", "None"); + refreshUrl.searchParams.set("ReplaceAllMetadata", "false"); + refreshUrl.searchParams.set("ReplaceAllImages", "false"); + const refreshRes = await fetch(refreshUrl.toString(), { method: "POST", headers: headers(cfg.apiKey) }); if (!refreshRes.ok) throw new Error(`Jellyfin refresh failed: HTTP ${refreshRes.status}`); // 3. Poll until DateLastRefreshed changes @@ -171,15 +172,15 @@ export function extractOriginalLanguage(item: JellyfinItem): string | null { // Jellyfin doesn't have a direct "original_language" field like TMDb. // The best proxy is the language of the first audio stream. if (!item.MediaStreams) return null; - const firstAudio = item.MediaStreams.find((s) => s.Type === 'Audio'); + const firstAudio = item.MediaStreams.find((s) => s.Type === "Audio"); return firstAudio?.Language ? normalizeLanguage(firstAudio.Language) : null; } /** Map a Jellyfin MediaStream to our internal MediaStream shape (sans id/item_id). */ -export function mapStream(s: JellyfinMediaStream): Omit { +export function mapStream(s: JellyfinMediaStream): Omit { return { stream_index: s.Index, - type: s.Type as MediaStream['type'], + type: s.Type as MediaStream["type"], codec: s.Codec ?? null, language: s.Language ? normalizeLanguage(s.Language) : null, language_display: s.DisplayLanguage ?? null, @@ -197,45 +198,45 @@ export function mapStream(s: JellyfinMediaStream): Omit = { // German: both /T (deu) and /B (ger) → deu - ger: 'deu', + ger: "deu", // Chinese - chi: 'zho', + chi: "zho", // French - fre: 'fra', + fre: "fra", // Dutch - dut: 'nld', + dut: "nld", // Modern Greek - gre: 'ell', + gre: "ell", // Hebrew - heb: 'heb', + heb: "heb", // Farsi - per: 'fas', + per: "fas", // Romanian - rum: 'ron', + rum: "ron", // Malay - may: 'msa', + may: "msa", // Tibetan - tib: 'bod', + tib: "bod", // Burmese - bur: 'mya', + bur: "mya", // Czech - cze: 'ces', + cze: "ces", // Slovak - slo: 'slk', + slo: "slk", // Georgian - geo: 'kat', + geo: "kat", // Icelandic - ice: 'isl', + ice: "isl", // Armenian - arm: 'hye', + arm: "hye", // Basque - baq: 'eus', + baq: "eus", // Albanian - alb: 'sqi', + alb: "sqi", // Macedonian - mac: 'mkd', + mac: "mkd", // Welsh - wel: 'cym', + wel: "cym", }; export function normalizeLanguage(lang: string): string { diff --git a/server/services/radarr.ts b/server/services/radarr.ts index 23a7f69..bd6c353 100644 --- a/server/services/radarr.ts +++ b/server/services/radarr.ts @@ -1,4 +1,4 @@ -import { normalizeLanguage } from './jellyfin'; +import { normalizeLanguage } from "./jellyfin"; export interface RadarrConfig { url: string; @@ -6,7 +6,7 @@ export interface RadarrConfig { } function headers(apiKey: string): Record { - return { 'X-Api-Key': apiKey }; + return { "X-Api-Key": apiKey }; } export async function testConnection(cfg: RadarrConfig): Promise<{ ok: boolean; error?: string }> { @@ -30,7 +30,7 @@ interface RadarrMovie { /** Returns ISO 639-2 original language or null. */ export async function getOriginalLanguage( cfg: RadarrConfig, - ids: { tmdbId?: string; imdbId?: string } + ids: { tmdbId?: string; imdbId?: string }, ): Promise { try { let movie: RadarrMovie | null = null; @@ -65,41 +65,41 @@ export async function getOriginalLanguage( // Radarr returns language names like "English", "French", "German", etc. // Map them to ISO 639-2 codes. const NAME_TO_639_2: Record = { - english: 'eng', - french: 'fra', - german: 'deu', - spanish: 'spa', - italian: 'ita', - portuguese: 'por', - japanese: 'jpn', - korean: 'kor', - chinese: 'zho', - arabic: 'ara', - russian: 'rus', - dutch: 'nld', - swedish: 'swe', - norwegian: 'nor', - danish: 'dan', - finnish: 'fin', - polish: 'pol', - turkish: 'tur', - thai: 'tha', - hindi: 'hin', - hungarian: 'hun', - czech: 'ces', - romanian: 'ron', - greek: 'ell', - hebrew: 'heb', - persian: 'fas', - ukrainian: 'ukr', - indonesian: 'ind', - malay: 'msa', - vietnamese: 'vie', - catalan: 'cat', - tamil: 'tam', - telugu: 'tel', - 'brazilian portuguese': 'por', - 'portuguese (brazil)': 'por', + english: "eng", + french: "fra", + german: "deu", + spanish: "spa", + italian: "ita", + portuguese: "por", + japanese: "jpn", + korean: "kor", + chinese: "zho", + arabic: "ara", + russian: "rus", + dutch: "nld", + swedish: "swe", + norwegian: "nor", + danish: "dan", + finnish: "fin", + polish: "pol", + turkish: "tur", + thai: "tha", + hindi: "hin", + hungarian: "hun", + czech: "ces", + romanian: "ron", + greek: "ell", + hebrew: "heb", + persian: "fas", + ukrainian: "ukr", + indonesian: "ind", + malay: "msa", + vietnamese: "vie", + catalan: "cat", + tamil: "tam", + telugu: "tel", + "brazilian portuguese": "por", + "portuguese (brazil)": "por", }; function iso6391To6392(name: string): string | null { diff --git a/server/services/scheduler.ts b/server/services/scheduler.ts index badcc0d..4b89618 100644 --- a/server/services/scheduler.ts +++ b/server/services/scheduler.ts @@ -1,26 +1,26 @@ -import { getConfig, setConfig } from '../db'; +import { getConfig, setConfig } from "../db"; export interface SchedulerState { job_sleep_seconds: number; schedule_enabled: boolean; schedule_start: string; // "HH:MM" - schedule_end: string; // "HH:MM" + schedule_end: string; // "HH:MM" } export function getSchedulerState(): SchedulerState { return { - job_sleep_seconds: parseInt(getConfig('job_sleep_seconds') ?? '0', 10), - schedule_enabled: getConfig('schedule_enabled') === '1', - schedule_start: getConfig('schedule_start') ?? '01:00', - schedule_end: getConfig('schedule_end') ?? '07:00', + job_sleep_seconds: parseInt(getConfig("job_sleep_seconds") ?? "0", 10), + schedule_enabled: getConfig("schedule_enabled") === "1", + schedule_start: getConfig("schedule_start") ?? "01:00", + schedule_end: getConfig("schedule_end") ?? "07:00", }; } export function updateSchedulerState(updates: Partial): void { - if (updates.job_sleep_seconds != null) setConfig('job_sleep_seconds', String(updates.job_sleep_seconds)); - if (updates.schedule_enabled != null) setConfig('schedule_enabled', updates.schedule_enabled ? '1' : '0'); - if (updates.schedule_start != null) setConfig('schedule_start', updates.schedule_start); - if (updates.schedule_end != null) setConfig('schedule_end', updates.schedule_end); + if (updates.job_sleep_seconds != null) setConfig("job_sleep_seconds", String(updates.job_sleep_seconds)); + if (updates.schedule_enabled != null) setConfig("schedule_enabled", updates.schedule_enabled ? "1" : "0"); + if (updates.schedule_start != null) setConfig("schedule_start", updates.schedule_start); + if (updates.schedule_end != null) setConfig("schedule_end", updates.schedule_end); } /** Check if current time is within the schedule window. */ @@ -63,7 +63,7 @@ export function nextWindowTime(): string { } function parseTime(hhmm: string): number { - const [h, m] = hhmm.split(':').map(Number); + const [h, m] = hhmm.split(":").map(Number); return h * 60 + m; } @@ -71,12 +71,12 @@ function parseTime(hhmm: string): number { export function sleepBetweenJobs(): Promise { const seconds = getSchedulerState().job_sleep_seconds; if (seconds <= 0) return Promise.resolve(); - return new Promise(resolve => setTimeout(resolve, seconds * 1000)); + return new Promise((resolve) => setTimeout(resolve, seconds * 1000)); } /** Wait until the schedule window opens. Resolves immediately if already in window. */ export function waitForWindow(): Promise { if (isInScheduleWindow()) return Promise.resolve(); const ms = msUntilWindow(); - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/server/services/sonarr.ts b/server/services/sonarr.ts index dc2f030..cf61955 100644 --- a/server/services/sonarr.ts +++ b/server/services/sonarr.ts @@ -1,4 +1,4 @@ -import { normalizeLanguage } from './jellyfin'; +import { normalizeLanguage } from "./jellyfin"; export interface SonarrConfig { url: string; @@ -6,7 +6,7 @@ export interface SonarrConfig { } function headers(apiKey: string): Record { - return { 'X-Api-Key': apiKey }; + return { "X-Api-Key": apiKey }; } export async function testConnection(cfg: SonarrConfig): Promise<{ ok: boolean; error?: string }> { @@ -27,10 +27,7 @@ interface SonarrSeries { } /** Returns ISO 639-2 original language for a series or null. */ -export async function getOriginalLanguage( - cfg: SonarrConfig, - tvdbId: string -): Promise { +export async function getOriginalLanguage(cfg: SonarrConfig, tvdbId: string): Promise { try { const res = await fetch(`${cfg.url}/api/v3/series?tvdbId=${tvdbId}`, { headers: headers(cfg.apiKey), @@ -47,36 +44,36 @@ export async function getOriginalLanguage( } const NAME_TO_639_2: Record = { - english: 'eng', - french: 'fra', - german: 'deu', - spanish: 'spa', - italian: 'ita', - portuguese: 'por', - japanese: 'jpn', - korean: 'kor', - chinese: 'zho', - arabic: 'ara', - russian: 'rus', - dutch: 'nld', - swedish: 'swe', - norwegian: 'nor', - danish: 'dan', - finnish: 'fin', - polish: 'pol', - turkish: 'tur', - thai: 'tha', - hindi: 'hin', - hungarian: 'hun', - czech: 'ces', - romanian: 'ron', - greek: 'ell', - hebrew: 'heb', - persian: 'fas', - ukrainian: 'ukr', - indonesian: 'ind', - malay: 'msa', - vietnamese: 'vie', + english: "eng", + french: "fra", + german: "deu", + spanish: "spa", + italian: "ita", + portuguese: "por", + japanese: "jpn", + korean: "kor", + chinese: "zho", + arabic: "ara", + russian: "rus", + dutch: "nld", + swedish: "swe", + norwegian: "nor", + danish: "dan", + finnish: "fin", + polish: "pol", + turkish: "tur", + thai: "tha", + hindi: "hin", + hungarian: "hun", + czech: "ces", + romanian: "ron", + greek: "ell", + hebrew: "heb", + persian: "fas", + ukrainian: "ukr", + indonesian: "ind", + malay: "msa", + vietnamese: "vie", }; function languageNameToCode(name: string): string | null { diff --git a/server/types.ts b/server/types.ts index 1c072f0..6f34b43 100644 --- a/server/types.ts +++ b/server/types.ts @@ -3,7 +3,7 @@ export interface MediaItem { id: number; jellyfin_id: string; - type: 'Movie' | 'Episode'; + type: "Movie" | "Episode"; name: string; series_name: string | null; series_jellyfin_id: string | null; @@ -14,12 +14,12 @@ export interface MediaItem { file_size: number | null; container: string | null; original_language: string | null; - orig_lang_source: 'jellyfin' | 'radarr' | 'sonarr' | 'manual' | null; + orig_lang_source: "jellyfin" | "radarr" | "sonarr" | "manual" | null; needs_review: number; imdb_id: string | null; tmdb_id: string | null; tvdb_id: string | null; - scan_status: 'pending' | 'scanned' | 'error'; + scan_status: "pending" | "scanned" | "error"; scan_error: string | null; last_scanned_at: string | null; created_at: string; @@ -29,7 +29,7 @@ export interface MediaStream { id: number; item_id: number; stream_index: number; - type: 'Video' | 'Audio' | 'Subtitle' | 'Data' | 'EmbeddedImage'; + type: "Video" | "Audio" | "Subtitle" | "Data" | "EmbeddedImage"; codec: string | null; language: string | null; language_display: string | null; @@ -46,11 +46,11 @@ export interface MediaStream { export interface ReviewPlan { id: number; item_id: number; - status: 'pending' | 'approved' | 'skipped' | 'done' | 'error'; + status: "pending" | "approved" | "skipped" | "done" | "error"; is_noop: number; - confidence: 'high' | 'low'; - apple_compat: 'direct_play' | 'remux' | 'audio_transcode' | null; - job_type: 'copy' | 'transcode'; + confidence: "high" | "low"; + apple_compat: "direct_play" | "remux" | "audio_transcode" | null; + job_type: "copy" | "transcode"; subs_extracted: number; notes: string | null; reviewed_at: string | null; @@ -73,7 +73,7 @@ export interface StreamDecision { id: number; plan_id: number; stream_id: number; - action: 'keep' | 'remove'; + action: "keep" | "remove"; target_index: number | null; custom_title: string | null; transcode_codec: string | null; @@ -83,8 +83,8 @@ export interface Job { id: number; item_id: number; command: string; - job_type: 'copy' | 'transcode'; - status: 'pending' | 'running' | 'done' | 'error'; + job_type: "copy" | "transcode"; + status: "pending" | "running" | "done" | "error"; output: string | null; exit_code: number | null; created_at: string; @@ -95,17 +95,22 @@ export interface Job { // ─── Analyzer types ─────────────────────────────────────────────────────────── export interface StreamWithDecision extends MediaStream { - action: 'keep' | 'remove'; + action: "keep" | "remove"; target_index: number | null; } export interface PlanResult { is_noop: boolean; has_subs: boolean; - confidence: 'high' | 'low'; - apple_compat: 'direct_play' | 'remux' | 'audio_transcode' | null; - job_type: 'copy' | 'transcode'; - decisions: Array<{ stream_id: number; action: 'keep' | 'remove'; target_index: number | null; transcode_codec: string | null }>; + confidence: "high" | "low"; + apple_compat: "direct_play" | "remux" | "audio_transcode" | null; + job_type: "copy" | "transcode"; + decisions: Array<{ + stream_id: number; + action: "keep" | "remove"; + target_index: number | null; + transcode_codec: string | null; + }>; notes: string[]; } @@ -161,7 +166,7 @@ export interface ScanProgress { // ─── SSE event helpers ──────────────────────────────────────────────────────── -export type SseEventType = 'progress' | 'log' | 'complete' | 'error'; +export type SseEventType = "progress" | "log" | "complete" | "error"; export interface SseEvent { type: SseEventType; diff --git a/src/features/dashboard/DashboardPage.tsx b/src/features/dashboard/DashboardPage.tsx index 4233a49..35bad5a 100644 --- a/src/features/dashboard/DashboardPage.tsx +++ b/src/features/dashboard/DashboardPage.tsx @@ -1,20 +1,29 @@ -import { useEffect, useState } from 'react'; -import { Link, useNavigate } from '@tanstack/react-router'; -import { api } from '~/shared/lib/api'; -import { Button } from '~/shared/components/ui/button'; -import { Alert } from '~/shared/components/ui/alert'; +import { Link, useNavigate } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { Alert } from "~/shared/components/ui/alert"; +import { Button } from "~/shared/components/ui/button"; +import { api } from "~/shared/lib/api"; interface Stats { - totalItems: number; scanned: number; needsAction: number; - approved: number; done: number; errors: number; noChange: number; + totalItems: number; + scanned: number; + needsAction: number; + approved: number; + done: number; + errors: number; + noChange: number; } -interface DashboardData { stats: Stats; scanRunning: boolean; setupComplete: boolean; } +interface DashboardData { + stats: Stats; + scanRunning: boolean; + setupComplete: boolean; +} function StatCard({ label, value, danger }: { label: string; value: number; danger?: boolean }) { return (
-
+
{value.toLocaleString()}
{label}
@@ -29,17 +38,20 @@ export function DashboardPage() { const [starting, setStarting] = useState(false); useEffect(() => { - api.get('/api/dashboard').then((d) => { - setData(d); - setLoading(false); - if (!d.setupComplete) navigate({ to: '/setup' }); - }).catch(() => setLoading(false)); + api + .get("/api/dashboard") + .then((d) => { + setData(d); + setLoading(false); + if (!d.setupComplete) navigate({ to: "/setup" }); + }) + .catch(() => setLoading(false)); }, [navigate]); const startScan = async () => { setStarting(true); - await api.post('/api/scan/start', {}).catch(() => {}); - navigate({ to: '/scan' }); + await api.post("/api/scan/start", {}).catch(() => {}); + navigate({ to: "/scan" }); }; if (loading) return
Loading…
; @@ -65,18 +77,27 @@ export function DashboardPage() {
{scanRunning ? ( - + ⏳ Scan running… ) : ( )} - + Review changes - + Execute jobs
diff --git a/src/features/execute/ExecutePage.tsx b/src/features/execute/ExecutePage.tsx index 01c0284..85735ea 100644 --- a/src/features/execute/ExecutePage.tsx +++ b/src/features/execute/ExecutePage.tsx @@ -1,39 +1,46 @@ -import { useEffect, useRef, useState } from 'react'; -import { Link, useNavigate, useSearch } from '@tanstack/react-router'; -import { api } from '~/shared/lib/api'; -import { Badge } from '~/shared/components/ui/badge'; -import { Button } from '~/shared/components/ui/button'; -import { FilterTabs } from '~/shared/components/ui/filter-tabs'; -import type { Job, MediaItem } from '~/shared/lib/types'; +import { Link, useNavigate, useSearch } from "@tanstack/react-router"; +import { useEffect, useRef, useState } from "react"; +import { Badge } from "~/shared/components/ui/badge"; +import { Button } from "~/shared/components/ui/button"; +import { FilterTabs } from "~/shared/components/ui/filter-tabs"; +import { api } from "~/shared/lib/api"; +import type { Job, MediaItem } from "~/shared/lib/types"; -interface JobEntry { job: Job; item: MediaItem | null; } -interface ExecuteData { jobs: JobEntry[]; filter: string; totalCounts: Record; } +interface JobEntry { + job: Job; + item: MediaItem | null; +} +interface ExecuteData { + jobs: JobEntry[]; + filter: string; + totalCounts: Record; +} const FILTER_TABS = [ - { key: 'all', label: 'All' }, - { key: 'pending', label: 'Pending' }, - { key: 'running', label: 'Running' }, - { key: 'done', label: 'Done' }, - { key: 'error', label: 'Error' }, + { key: "all", label: "All" }, + { key: "pending", label: "Pending" }, + { key: "running", label: "Running" }, + { key: "done", label: "Done" }, + { key: "error", label: "Error" }, ]; function itemName(job: Job, item: MediaItem | null): string { if (!item) return `Item #${job.item_id}`; - if (item.type === 'Episode' && item.series_name) { - return `${item.series_name} S${String(item.season_number ?? 0).padStart(2, '0')}E${String(item.episode_number ?? 0).padStart(2, '0')}`; + if (item.type === "Episode" && item.series_name) { + return `${item.series_name} S${String(item.season_number ?? 0).padStart(2, "0")}E${String(item.episode_number ?? 0).padStart(2, "0")}`; } return item.name; } function jobTypeLabel(job: Job): string { - return job.job_type === 'subtitle' ? 'ST Extract' : 'Audio Mod'; + return job.job_type === "subtitle" ? "ST Extract" : "Audio Mod"; } // Module-level cache for instant tab switching const cache = new Map(); export function ExecutePage() { - const { filter } = useSearch({ from: '/execute' }); + const { filter } = useSearch({ from: "/execute" }); const navigate = useNavigate(); const [data, setData] = useState(cache.get(filter) ?? null); const [loading, setLoading] = useState(!cache.has(filter)); @@ -46,22 +53,35 @@ export function ExecutePage() { const load = (f?: string) => { const key = f ?? filter; const cached = cache.get(key); - if (cached && key === filter) { setData(cached); setLoading(false); } - else if (key === filter) { setLoading(true); } - api.get(`/api/execute?filter=${key}`) - .then((d) => { cache.set(key, d); if (key === filter) { setData(d); setLoading(false); } }) - .catch(() => { if (key === filter) setLoading(false); }); + if (cached && key === filter) { + setData(cached); + setLoading(false); + } else if (key === filter) { + setLoading(true); + } + api + .get(`/api/execute?filter=${key}`) + .then((d) => { + cache.set(key, d); + if (key === filter) { + setData(d); + setLoading(false); + } + }) + .catch(() => { + if (key === filter) setLoading(false); + }); }; useEffect(() => { load(); - }, [filter]); + }, [load]); // SSE for live job updates useEffect(() => { - const es = new EventSource('/api/execute/events'); + const es = new EventSource("/api/execute/events"); esRef.current = es; - es.addEventListener('job_update', (e) => { + es.addEventListener("job_update", (e) => { const d = JSON.parse(e.data) as { id: number; status: string; output?: string }; // Update job in current list if present @@ -71,7 +91,7 @@ export function ExecutePage() { if (jobIdx === -1) return prev; const oldStatus = prev.jobs[jobIdx].job.status; - const newStatus = d.status as Job['status']; + const newStatus = d.status as Job["status"]; // Live-update totalCounts const newCounts = { ...prev.totalCounts }; @@ -84,18 +104,20 @@ export function ExecutePage() { return { ...prev, totalCounts: newCounts, - jobs: prev.jobs.map((j) => - j.job.id === d.id ? { ...j, job: { ...j.job, status: newStatus } } : j - ), + jobs: prev.jobs.map((j) => (j.job.id === d.id ? { ...j, job: { ...j.job, status: newStatus } } : j)), }; }); if (d.output !== undefined) { - setLogs((prev) => { const m = new Map(prev); m.set(d.id, d.output!); return m; }); + setLogs((prev) => { + const m = new Map(prev); + m.set(d.id, d.output!); + return m; + }); } // Debounced reload on terminal state for accurate list - if (d.status === 'done' || d.status === 'error') { + if (d.status === "done" || d.status === "error") { if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current); reloadTimerRef.current = setTimeout(() => { // Invalidate cache and reload current filter @@ -104,17 +126,50 @@ export function ExecutePage() { }, 1000); } }); - return () => { es.close(); if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current); }; - }, [filter]); + return () => { + es.close(); + if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current); + }; + }, [load]); - const startAll = async () => { await api.post('/api/execute/start'); cache.clear(); load(); }; - const clearQueue = async () => { await api.post('/api/execute/clear'); cache.clear(); load(); }; - const clearCompleted = async () => { await api.post('/api/execute/clear-completed'); cache.clear(); load(); }; - const runJob = async (id: number) => { await api.post(`/api/execute/job/${id}/run`); cache.clear(); load(); }; - const cancelJob = async (id: number) => { await api.post(`/api/execute/job/${id}/cancel`); cache.clear(); load(); }; + const startAll = async () => { + await api.post("/api/execute/start"); + cache.clear(); + load(); + }; + const clearQueue = async () => { + await api.post("/api/execute/clear"); + cache.clear(); + load(); + }; + const clearCompleted = async () => { + await api.post("/api/execute/clear-completed"); + cache.clear(); + load(); + }; + const runJob = async (id: number) => { + await api.post(`/api/execute/job/${id}/run`); + cache.clear(); + load(); + }; + const cancelJob = async (id: number) => { + await api.post(`/api/execute/job/${id}/cancel`); + cache.clear(); + load(); + }; - const toggleLog = (id: number) => setLogVisible((prev) => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; }); - const toggleCmd = (id: number) => setCmdVisible((prev) => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; }); + const toggleLog = (id: number) => + setLogVisible((prev) => { + const s = new Set(prev); + s.has(id) ? s.delete(id) : s.add(id); + return s; + }); + const toggleCmd = (id: number) => + setCmdVisible((prev) => { + const s = new Set(prev); + s.has(id) ? s.delete(id) : s.add(id); + return s; + }); const totalCounts = data?.totalCounts ?? { all: 0, pending: 0, running: 0, done: 0, error: 0 }; const pending = totalCounts.pending ?? 0; @@ -130,27 +185,31 @@ export function ExecutePage() {

Execute Jobs

- {totalCounts.all === 0 && !loading && ( - No jobs yet. - )} - {totalCounts.all === 0 && loading && ( - Loading... - )} - {allDone && ( - All jobs completed - )} + {totalCounts.all === 0 && !loading && No jobs yet.} + {totalCounts.all === 0 && loading && Loading...} + {allDone && All jobs completed} {running > 0 && ( - {running} job{running !== 1 ? 's' : ''} running + + {running} job{running !== 1 ? "s" : ""} running + )} {pending > 0 && ( <> - {pending} job{pending !== 1 ? 's' : ''} pending - - + + {pending} job{pending !== 1 ? "s" : ""} pending + + + )} {(done > 0 || errors > 0) && ( - + )}
@@ -158,83 +217,110 @@ export function ExecutePage() { tabs={FILTER_TABS} filter={filter} totalCounts={totalCounts} - onFilterChange={(key) => navigate({ to: '/execute', search: { filter: key } as never })} + onFilterChange={(key) => navigate({ to: "/execute", search: { filter: key } as never })} /> {loading && !data &&
Loading…
} {jobs.length > 0 && ( -
- - - {['#', 'Item', 'Type', 'Status', 'Actions'].map((h) => ( - - ))} - - - - {jobs.map(({ job, item }: JobEntry) => { - const name = itemName(job, item); - const jobLog = logs.get(job.id) ?? job.output ?? ''; - const showLog = logVisible.has(job.id) || job.status === 'running' || job.status === 'error'; - const showCmd = cmdVisible.has(job.id); +
+
{h}
+ + + {["#", "Item", "Type", "Status", "Actions"].map((h) => ( + + ))} + + + + {jobs.map(({ job, item }: JobEntry) => { + const name = itemName(job, item); + const jobLog = logs.get(job.id) ?? job.output ?? ""; + const showLog = logVisible.has(job.id) || job.status === "running" || job.status === "error"; + const showCmd = cmdVisible.has(job.id); - return ( - <> - - - - - - + + + + - - {showCmd && ( - - + - )} - {jobLog && showLog && ( - - - - )} - - ); - })} - -
+ {h} +
{job.id} -
- {item ? ( - {name} - ) : name} -
-
- {jobTypeLabel(job)} - - {job.status} - {job.exit_code != null && job.exit_code !== 0 && exit {job.exit_code}} - -
- {job.status === 'pending' && ( - <> - - - + return ( + <> +
{job.id} +
+ {item ? ( + + {name} + + ) : ( + name + )} +
+
+ {jobTypeLabel(job)} + + {job.status} + {job.exit_code != null && job.exit_code !== 0 && ( + + exit {job.exit_code} + )} - - {(job.status === 'done' || job.status === 'error') && jobLog && ( - - )} - -
-
- {job.command} +
+
+ {job.status === "pending" && ( + <> + + + + )} + + {(job.status === "done" || job.status === "error") && jobLog && ( + + )}
-
- {jobLog} -
-
+ {showCmd && ( + + +
+ {job.command} +
+ + + )} + {jobLog && showLog && ( + + +
+ {jobLog} +
+ + + )} + + ); + })} + + +
)} {!loading && jobs.length === 0 && totalCounts.all > 0 && ( diff --git a/src/features/paths/PathsPage.tsx b/src/features/paths/PathsPage.tsx index 6a31390..18ea5ca 100644 --- a/src/features/paths/PathsPage.tsx +++ b/src/features/paths/PathsPage.tsx @@ -1,7 +1,7 @@ -import { useEffect, useState } from 'react'; -import { api } from '~/shared/lib/api'; -import { Badge } from '~/shared/components/ui/badge'; -import { Button } from '~/shared/components/ui/button'; +import { useEffect, useState } from "react"; +import { Badge } from "~/shared/components/ui/badge"; +import { Button } from "~/shared/components/ui/button"; +import { api } from "~/shared/lib/api"; interface PathInfo { prefix: string; @@ -17,12 +17,18 @@ export function PathsPage() { const load = () => { setLoading(true); - api.get<{ paths: PathInfo[] }>('/api/paths') - .then((d) => { cache = d.paths; setPaths(d.paths); }) + api + .get<{ paths: PathInfo[] }>("/api/paths") + .then((d) => { + cache = d.paths; + setPaths(d.paths); + }) .finally(() => setLoading(false)); }; - useEffect(() => { if (cache === null) load(); }, []); + useEffect(() => { + if (cache === null) load(); + }, [load]); const allGood = paths.length > 0 && paths.every((p) => p.accessible); const hasBroken = paths.some((p) => !p.accessible); @@ -35,17 +41,16 @@ export function PathsPage() { {paths.length === 0 && !loading && ( No media items scanned yet. Run a scan first. )} - {paths.length === 0 && loading && ( - Checking paths... - )} - {allGood && ( - All {paths.length} paths accessible - )} + {paths.length === 0 && loading && Checking paths...} + {allGood && All {paths.length} paths accessible} {hasBroken && ( - {paths.filter((p) => !p.accessible).length} path{paths.filter((p) => !p.accessible).length !== 1 ? 's' : ''} not mounted + + {paths.filter((p) => !p.accessible).length} path{paths.filter((p) => !p.accessible).length !== 1 ? "s" : ""} not + mounted + )}
@@ -65,11 +70,7 @@ export function PathsPage() { {p.prefix} {p.itemCount} - {p.accessible ? ( - Accessible - ) : ( - Not mounted - )} + {p.accessible ? Accessible : Not mounted} ))} @@ -78,8 +79,8 @@ export function PathsPage() { {paths.some((p) => !p.accessible) && (

- Paths marked "Not mounted" are not reachable from the container. - Mount each path into the Docker container exactly as Jellyfin reports it. + Paths marked "Not mounted" are not reachable from the container. Mount each path into the Docker container + exactly as Jellyfin reports it.

)} diff --git a/src/features/pipeline/DoneColumn.tsx b/src/features/pipeline/DoneColumn.tsx index 40bffbe..076dcd5 100644 --- a/src/features/pipeline/DoneColumn.tsx +++ b/src/features/pipeline/DoneColumn.tsx @@ -1,4 +1,4 @@ -import { Badge } from '~/shared/components/ui/badge'; +import { Badge } from "~/shared/components/ui/badge"; interface DoneColumnProps { items: any[]; @@ -14,14 +14,10 @@ export function DoneColumn({ items }: DoneColumnProps) { {items.map((item: any) => (

{item.name}

- - {item.status} - + {item.status}
))} - {items.length === 0 && ( -

No completed items

- )} + {items.length === 0 &&

No completed items

} ); diff --git a/src/features/pipeline/PipelineCard.tsx b/src/features/pipeline/PipelineCard.tsx index 3a76a98..98687f4 100644 --- a/src/features/pipeline/PipelineCard.tsx +++ b/src/features/pipeline/PipelineCard.tsx @@ -1,5 +1,5 @@ -import { Badge } from '~/shared/components/ui/badge'; -import { LANG_NAMES, langName } from '~/shared/lib/lang'; +import { Badge } from "~/shared/components/ui/badge"; +import { LANG_NAMES, langName } from "~/shared/lib/lang"; interface PipelineCardProps { item: any; @@ -9,15 +9,15 @@ interface PipelineCardProps { } export function PipelineCard({ item, jellyfinUrl, onLanguageChange, onApproveUpTo }: PipelineCardProps) { - const title = item.type === 'Episode' - ? `S${String(item.season_number).padStart(2, '0')}E${String(item.episode_number).padStart(2, '0')} — ${item.name}` - : item.name; + const title = + item.type === "Episode" + ? `S${String(item.season_number).padStart(2, "0")}E${String(item.episode_number).padStart(2, "0")} — ${item.name}` + : item.name; - const confidenceColor = item.confidence === 'high' ? 'bg-green-50 border-green-200' : 'bg-amber-50 border-amber-200'; + const confidenceColor = item.confidence === "high" ? "bg-green-50 border-green-200" : "bg-amber-50 border-amber-200"; - const jellyfinLink = jellyfinUrl && item.jellyfin_id - ? `${jellyfinUrl}/web/index.html#!/details?id=${item.jellyfin_id}` - : null; + const jellyfinLink = + jellyfinUrl && item.jellyfin_id ? `${jellyfinUrl}/web/index.html#!/details?id=${item.jellyfin_id}` : null; return (
@@ -40,12 +40,14 @@ export function PipelineCard({ item, jellyfinUrl, onLanguageChange, onApproveUpT {onLanguageChange ? ( ) : ( @@ -54,12 +56,11 @@ export function PipelineCard({ item, jellyfinUrl, onLanguageChange, onApproveUpT {item.transcode_reasons?.length > 0 ? item.transcode_reasons.map((r: string) => ( - {r} - )) - : item.job_type === 'copy' && ( - copy - ) - } + + {r} + + )) + : item.job_type === "copy" && copy}
diff --git a/src/features/pipeline/PipelinePage.tsx b/src/features/pipeline/PipelinePage.tsx index df220be..68ad39f 100644 --- a/src/features/pipeline/PipelinePage.tsx +++ b/src/features/pipeline/PipelinePage.tsx @@ -1,10 +1,10 @@ -import { useCallback, useEffect, useState } from 'react'; -import { api } from '~/shared/lib/api'; -import { ReviewColumn } from './ReviewColumn'; -import { QueueColumn } from './QueueColumn'; -import { ProcessingColumn } from './ProcessingColumn'; -import { DoneColumn } from './DoneColumn'; -import { ScheduleControls } from './ScheduleControls'; +import { useCallback, useEffect, useState } from "react"; +import { api } from "~/shared/lib/api"; +import { DoneColumn } from "./DoneColumn"; +import { ProcessingColumn } from "./ProcessingColumn"; +import { QueueColumn } from "./QueueColumn"; +import { ReviewColumn } from "./ReviewColumn"; +import { ScheduleControls } from "./ScheduleControls"; interface PipelineData { review: any[]; @@ -43,24 +43,26 @@ export function PipelinePage() { const load = useCallback(async () => { const [pipelineRes, schedulerRes] = await Promise.all([ - api.get('/api/review/pipeline'), - api.get('/api/execute/scheduler'), + api.get("/api/review/pipeline"), + api.get("/api/execute/scheduler"), ]); setData(pipelineRes); setScheduler(schedulerRes); setLoading(false); }, []); - useEffect(() => { load(); }, [load]); + useEffect(() => { + load(); + }, [load]); // SSE for live updates useEffect(() => { - const es = new EventSource('/api/execute/events'); - es.addEventListener('job_update', () => load()); - es.addEventListener('job_progress', (e) => { + const es = new EventSource("/api/execute/events"); + es.addEventListener("job_update", () => load()); + es.addEventListener("job_progress", (e) => { setProgress(JSON.parse((e as MessageEvent).data)); }); - es.addEventListener('queue_status', (e) => { + es.addEventListener("queue_status", (e) => { setQueueStatus(JSON.parse((e as MessageEvent).data)); }); return () => es.close(); diff --git a/src/features/pipeline/ProcessingColumn.tsx b/src/features/pipeline/ProcessingColumn.tsx index 88ae327..b5aeefb 100644 --- a/src/features/pipeline/ProcessingColumn.tsx +++ b/src/features/pipeline/ProcessingColumn.tsx @@ -1,4 +1,4 @@ -import { Badge } from '~/shared/components/ui/badge'; +import { Badge } from "~/shared/components/ui/badge"; interface ProcessingColumnProps { items: any[]; @@ -12,18 +12,18 @@ export function ProcessingColumn({ items, progress, queueStatus }: ProcessingCol const formatTime = (s: number) => { const m = Math.floor(s / 60); const sec = Math.floor(s % 60); - return `${m}:${String(sec).padStart(2, '0')}`; + return `${m}:${String(sec).padStart(2, "0")}`; }; return (
Processing
- {queueStatus && queueStatus.status !== 'running' && ( + {queueStatus && queueStatus.status !== "running" && (
- {queueStatus.status === 'paused' && <>Paused until {queueStatus.until}} - {queueStatus.status === 'sleeping' && <>Sleeping {queueStatus.seconds}s between jobs} - {queueStatus.status === 'idle' && <>Idle} + {queueStatus.status === "paused" && <>Paused until {queueStatus.until}} + {queueStatus.status === "sleeping" && <>Sleeping {queueStatus.seconds}s between jobs} + {queueStatus.status === "idle" && <>Idle}
)} @@ -32,9 +32,7 @@ export function ProcessingColumn({ items, progress, queueStatus }: ProcessingCol

{job.name}

running - - {job.job_type} - + {job.job_type}
{progress && progress.total > 0 && ( diff --git a/src/features/pipeline/QueueColumn.tsx b/src/features/pipeline/QueueColumn.tsx index 56081fe..429f2ff 100644 --- a/src/features/pipeline/QueueColumn.tsx +++ b/src/features/pipeline/QueueColumn.tsx @@ -1,4 +1,4 @@ -import { Badge } from '~/shared/components/ui/badge'; +import { Badge } from "~/shared/components/ui/badge"; interface QueueColumnProps { items: any[]; @@ -14,14 +14,10 @@ export function QueueColumn({ items }: QueueColumnProps) { {items.map((item: any) => (

{item.name}

- - {item.job_type} - + {item.job_type}
))} - {items.length === 0 && ( -

Queue empty

- )} + {items.length === 0 &&

Queue empty

}
); diff --git a/src/features/pipeline/ReviewColumn.tsx b/src/features/pipeline/ReviewColumn.tsx index 060f955..b0492fe 100644 --- a/src/features/pipeline/ReviewColumn.tsx +++ b/src/features/pipeline/ReviewColumn.tsx @@ -1,6 +1,6 @@ -import { api } from '~/shared/lib/api'; -import { PipelineCard } from './PipelineCard'; -import { SeriesCard } from './SeriesCard'; +import { api } from "~/shared/lib/api"; +import { PipelineCard } from "./PipelineCard"; +import { SeriesCard } from "./SeriesCard"; interface ReviewColumnProps { items: any[]; @@ -10,10 +10,10 @@ interface ReviewColumnProps { export function ReviewColumn({ items, jellyfinUrl, onMutate }: ReviewColumnProps) { // Group by series (movies are standalone) - const movies = items.filter((i: any) => i.type === 'Movie'); + const movies = items.filter((i: any) => i.type === "Movie"); const seriesMap = new Map(); - for (const item of items.filter((i: any) => i.type === 'Episode')) { + for (const item of items.filter((i: any) => i.type === "Episode")) { const key = item.series_jellyfin_id ?? item.series_name; if (!seriesMap.has(key)) { seriesMap.set(key, { name: item.series_name, key, jellyfinId: item.series_jellyfin_id, episodes: [] }); @@ -28,11 +28,11 @@ export function ReviewColumn({ items, jellyfinUrl, onMutate }: ReviewColumnProps // Interleave movies and series, sorted by confidence (high first) const allItems = [ - ...movies.map((m: any) => ({ type: 'movie' as const, item: m, sortKey: m.confidence === 'high' ? 0 : 1 })), - ...[...seriesMap.values()].map(s => ({ - type: 'series' as const, + ...movies.map((m: any) => ({ type: "movie" as const, item: m, sortKey: m.confidence === "high" ? 0 : 1 })), + ...[...seriesMap.values()].map((s) => ({ + type: "series" as const, item: s, - sortKey: s.episodes.every((e: any) => e.confidence === 'high') ? 0 : 1, + sortKey: s.episodes.every((e: any) => e.confidence === "high") ? 0 : 1, })), ].sort((a, b) => a.sortKey - b.sortKey); @@ -49,7 +49,7 @@ export function ReviewColumn({ items, jellyfinUrl, onMutate }: ReviewColumnProps
{allItems.map((entry) => { - if (entry.type === 'movie') { + if (entry.type === "movie") { return ( No items to review

- )} + {allItems.length === 0 &&

No items to review

}
); diff --git a/src/features/pipeline/ScheduleControls.tsx b/src/features/pipeline/ScheduleControls.tsx index fc4ec1c..776cb23 100644 --- a/src/features/pipeline/ScheduleControls.tsx +++ b/src/features/pipeline/ScheduleControls.tsx @@ -1,7 +1,7 @@ -import { useState } from 'react'; -import { api } from '~/shared/lib/api'; -import { Input } from '~/shared/components/ui/input'; -import { Button } from '~/shared/components/ui/button'; +import { useState } from "react"; +import { Button } from "~/shared/components/ui/button"; +import { Input } from "~/shared/components/ui/input"; +import { api } from "~/shared/lib/api"; interface ScheduleControlsProps { scheduler: { @@ -18,13 +18,13 @@ export function ScheduleControls({ scheduler, onUpdate }: ScheduleControlsProps) const [state, setState] = useState(scheduler); const save = async () => { - await api.patch('/api/execute/scheduler', state); + await api.patch("/api/execute/scheduler", state); onUpdate(); setOpen(false); }; const startAll = async () => { - await api.post('/api/execute/start'); + await api.post("/api/execute/start"); onUpdate(); }; @@ -33,10 +33,7 @@ export function ScheduleControls({ scheduler, onUpdate }: ScheduleControlsProps) - @@ -49,7 +46,7 @@ export function ScheduleControls({ scheduler, onUpdate }: ScheduleControlsProps) type="number" min={0} value={state.job_sleep_seconds} - onChange={(e) => setState({ ...state, job_sleep_seconds: parseInt(e.target.value) || 0 })} + onChange={(e) => setState({ ...state, job_sleep_seconds: parseInt(e.target.value, 10) || 0 })} className="mb-3" /> @@ -80,7 +77,9 @@ export function ScheduleControls({ scheduler, onUpdate }: ScheduleControlsProps) )} - + )} diff --git a/src/features/pipeline/SeriesCard.tsx b/src/features/pipeline/SeriesCard.tsx index 38e8ce3..153a563 100644 --- a/src/features/pipeline/SeriesCard.tsx +++ b/src/features/pipeline/SeriesCard.tsx @@ -1,7 +1,7 @@ -import { useState } from 'react'; -import { api } from '~/shared/lib/api'; -import { LANG_NAMES } from '~/shared/lib/lang'; -import { PipelineCard } from './PipelineCard'; +import { useState } from "react"; +import { api } from "~/shared/lib/api"; +import { LANG_NAMES } from "~/shared/lib/lang"; +import { PipelineCard } from "./PipelineCard"; interface SeriesCardProps { seriesKey: string; @@ -13,10 +13,18 @@ interface SeriesCardProps { onApproveUpTo?: () => void; } -export function SeriesCard({ seriesKey, seriesName, jellyfinUrl, seriesJellyfinId, episodes, onMutate, onApproveUpTo }: SeriesCardProps) { +export function SeriesCard({ + seriesKey, + seriesName, + jellyfinUrl, + seriesJellyfinId, + episodes, + onMutate, + onApproveUpTo, +}: SeriesCardProps) { const [expanded, setExpanded] = useState(false); - const seriesLang = episodes[0]?.original_language ?? ''; + const seriesLang = episodes[0]?.original_language ?? ""; const setSeriesLanguage = async (lang: string) => { await api.patch(`/api/review/series/${encodeURIComponent(seriesKey)}/language`, { language: lang }); @@ -28,12 +36,11 @@ export function SeriesCard({ seriesKey, seriesName, jellyfinUrl, seriesJellyfinI onMutate(); }; - const highCount = episodes.filter((e: any) => e.confidence === 'high').length; - const lowCount = episodes.filter((e: any) => e.confidence === 'low').length; + const highCount = episodes.filter((e: any) => e.confidence === "high").length; + const lowCount = episodes.filter((e: any) => e.confidence === "low").length; - const jellyfinLink = jellyfinUrl && seriesJellyfinId - ? `${jellyfinUrl}/web/index.html#!/details?id=${seriesJellyfinId}` - : null; + const jellyfinLink = + jellyfinUrl && seriesJellyfinId ? `${jellyfinUrl}/web/index.html#!/details?id=${seriesJellyfinId}` : null; return (
@@ -42,7 +49,7 @@ export function SeriesCard({ seriesKey, seriesName, jellyfinUrl, seriesJellyfinI className="flex items-center gap-2 px-3 pt-3 pb-1 cursor-pointer hover:bg-gray-50 rounded-t-lg" onClick={() => setExpanded(!expanded)} > - {expanded ? '▼' : '▶'} + {expanded ? "▼" : "▶"} {jellyfinLink ? ( { e.stopPropagation(); setSeriesLanguage(e.target.value); }} + onChange={(e) => { + e.stopPropagation(); + setSeriesLanguage(e.target.value); + }} > {Object.entries(LANG_NAMES).map(([code, name]) => ( - + ))} + ) : ( + {action} + )} + + + ); + }), + ]; + })} + + +
); } function TitleInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) { const [localVal, setLocalVal] = useState(value); - useEffect(() => { setLocalVal(value); }, [value]); + useEffect(() => { + setLocalVal(value); + }, [value]); return ( setLocalVal(e.target.value)} - onBlur={(e) => { if (e.target.value !== value) onCommit(e.target.value); }} - onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} + onBlur={(e) => { + if (e.target.value !== value) onCommit(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === "Enter") (e.target as HTMLInputElement).blur(); + }} placeholder="—" className="border border-transparent bg-transparent w-full text-[inherit] font-[inherit] text-inherit px-[0.2em] py-[0.05em] rounded outline-none hover:border-gray-300 hover:bg-gray-50 focus:border-blue-300 focus:bg-white min-w-16" /> @@ -190,40 +208,67 @@ function TitleInput({ value, onCommit }: { value: string; onCommit: (v: string) // ─── Detail page ────────────────────────────────────────────────────────────── export function AudioDetailPage() { - const { id } = useParams({ from: '/review/audio/$id' }); + const { id } = useParams({ from: "/review/audio/$id" }); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [rescanning, setRescanning] = useState(false); - const load = () => api.get(`/api/review/${id}`).then((d) => { setData(d); setLoading(false); }).catch(() => setLoading(false)); - useEffect(() => { load(); }, [id]); + const load = () => + api + .get(`/api/review/${id}`) + .then((d) => { + setData(d); + setLoading(false); + }) + .catch(() => setLoading(false)); + useEffect(() => { + load(); + }, [load]); const setLanguage = async (lang: string) => { const d = await api.patch(`/api/review/${id}/language`, { language: lang || null }); setData(d); }; - const approve = async () => { await api.post(`/api/review/${id}/approve`); load(); }; - const unapprove = async () => { await api.post(`/api/review/${id}/unapprove`); load(); }; - const skip = async () => { await api.post(`/api/review/${id}/skip`); load(); }; - const unskip = async () => { await api.post(`/api/review/${id}/unskip`); load(); }; + const approve = async () => { + await api.post(`/api/review/${id}/approve`); + load(); + }; + const unapprove = async () => { + await api.post(`/api/review/${id}/unapprove`); + load(); + }; + const skip = async () => { + await api.post(`/api/review/${id}/skip`); + load(); + }; + const unskip = async () => { + await api.post(`/api/review/${id}/unskip`); + load(); + }; const rescan = async () => { setRescanning(true); - try { const d = await api.post(`/api/review/${id}/rescan`); setData(d); } - finally { setRescanning(false); } + try { + const d = await api.post(`/api/review/${id}/rescan`); + setData(d); + } finally { + setRescanning(false); + } }; if (loading) return
Loading…
; if (!data) return Item not found.; const { item, plan, command } = data; - const statusKey = plan?.is_noop ? 'noop' : (plan?.status ?? 'pending'); + const statusKey = plan?.is_noop ? "noop" : (plan?.status ?? "pending"); return (

- ← Audio + + ← Audio + {item.name}

@@ -232,12 +277,17 @@ export function AudioDetailPage() { {/* Meta */}
{[ - { label: 'Type', value: item.type }, - ...(item.series_name ? [{ label: 'Series', value: item.series_name }] : []), - ...(item.year ? [{ label: 'Year', value: String(item.year) }] : []), - { label: 'Container', value: item.container ?? '—' }, - { label: 'File size', value: item.file_size ? formatBytes(item.file_size) : '—' }, - { label: 'Status', value: {statusKey} }, + { label: "Type", value: item.type }, + ...(item.series_name ? [{ label: "Series", value: item.series_name }] : []), + ...(item.year ? [{ label: "Year", value: String(item.year) }] : []), + { label: "Container", value: item.container ?? "—" }, + { label: "File size", value: item.file_size ? formatBytes(item.file_size) : "—" }, + { + label: "Status", + value: ( + {statusKey} + ), + }, ].map((entry, i) => (
{entry.label}
@@ -249,7 +299,11 @@ export function AudioDetailPage() {
{item.file_path}
{/* Warnings */} - {plan?.notes && {plan.notes}} + {plan?.notes && ( + + {plan.notes} + + )} {item.needs_review && !item.original_language && ( Original language unknown — audio tracks will NOT be filtered until you set it below. @@ -259,10 +313,16 @@ export function AudioDetailPage() { {/* Language override */}
- setLanguage(e.target.value)} + className="text-[0.79rem] py-0.5 px-1.5 w-auto" + > {Object.entries(LANG_NAMES).map(([code, name]) => ( - + ))} {item.orig_lang_source && {item.orig_lang_source}} @@ -285,33 +345,43 @@ export function AudioDetailPage() { )} {/* Actions */} - {plan?.status === 'pending' && !plan.is_noop && ( + {plan?.status === "pending" && !plan.is_noop && (
- +
)} - {plan?.status === 'approved' && ( + {plan?.status === "approved" && (
- +
)} - {plan?.status === 'skipped' && ( + {plan?.status === "skipped" && (
- +
)} {plan?.is_noop ? ( - Audio is already clean — no audio changes needed. + + Audio is already clean — no audio changes needed. + ) : null} {/* Refresh */}
- {rescanning ? 'Triggering Jellyfin metadata probe and waiting for completion…' : 'Triggers a metadata re-probe in Jellyfin, then re-fetches stream data'} + {rescanning + ? "Triggering Jellyfin metadata probe and waiting for completion…" + : "Triggers a metadata re-probe in Jellyfin, then re-fetches stream data"}
diff --git a/src/features/review/AudioListPage.tsx b/src/features/review/AudioListPage.tsx index 32ebf4a..3a7169b 100644 --- a/src/features/review/AudioListPage.tsx +++ b/src/features/review/AudioListPage.tsx @@ -1,21 +1,34 @@ -import { useState, useEffect } from 'react'; -import { Link, useNavigate, useSearch } from '@tanstack/react-router'; -import { api } from '~/shared/lib/api'; -import { Badge } from '~/shared/components/ui/badge'; -import { Button } from '~/shared/components/ui/button'; -import { FilterTabs } from '~/shared/components/ui/filter-tabs'; -import { langName } from '~/shared/lib/lang'; -import type { MediaItem, ReviewPlan } from '~/shared/lib/types'; +import { Link, useNavigate, useSearch } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { Badge } from "~/shared/components/ui/badge"; +import { Button } from "~/shared/components/ui/button"; +import { FilterTabs } from "~/shared/components/ui/filter-tabs"; +import { api } from "~/shared/lib/api"; +import { langName } from "~/shared/lib/lang"; +import type { MediaItem, ReviewPlan } from "~/shared/lib/types"; // ─── Types ──────────────────────────────────────────────────────────────────── -interface MovieRow { item: MediaItem; plan: ReviewPlan | null; removeCount: number; keepCount: number; } +interface MovieRow { + item: MediaItem; + plan: ReviewPlan | null; + removeCount: number; + keepCount: number; +} interface SeriesGroup { - series_key: string; series_name: string; original_language: string | null; - season_count: number; episode_count: number; - noop_count: number; needs_action_count: number; approved_count: number; - skipped_count: number; done_count: number; error_count: number; manual_count: number; + series_key: string; + series_name: string; + original_language: string | null; + season_count: number; + episode_count: number; + noop_count: number; + needs_action_count: number; + approved_count: number; + skipped_count: number; + done_count: number; + error_count: number; + manual_count: number; } interface ReviewListData { @@ -28,10 +41,14 @@ interface ReviewListData { // ─── Filter tabs ────────────────────────────────────────────────────────────── const FILTER_TABS = [ - { key: 'all', label: 'All' }, { key: 'needs_action', label: 'Needs Action' }, - { key: 'noop', label: 'No Change' }, { key: 'manual', label: 'Manual Review' }, - { key: 'approved', label: 'Approved' }, { key: 'skipped', label: 'Skipped' }, - { key: 'done', label: 'Done' }, { key: 'error', label: 'Error' }, + { key: "all", label: "All" }, + { key: "needs_action", label: "Needs Action" }, + { key: "noop", label: "No Change" }, + { key: "manual", label: "Manual Review" }, + { key: "approved", label: "Approved" }, + { key: "skipped", label: "Skipped" }, + { key: "done", label: "Done" }, + { key: "error", label: "Error" }, ]; // ─── Status pills ───────────────────────────────────────────────────────────── @@ -39,13 +56,41 @@ const FILTER_TABS = [ function StatusPills({ g }: { g: SeriesGroup }) { return ( - {g.noop_count > 0 && {g.noop_count} ok} - {g.needs_action_count > 0 && {g.needs_action_count} action} - {g.approved_count > 0 && {g.approved_count} approved} - {g.done_count > 0 && {g.done_count} done} - {g.error_count > 0 && {g.error_count} err} - {g.skipped_count > 0 && {g.skipped_count} skip} - {g.manual_count > 0 && {g.manual_count} manual} + {g.noop_count > 0 && ( + + {g.noop_count} ok + + )} + {g.needs_action_count > 0 && ( + + {g.needs_action_count} action + + )} + {g.approved_count > 0 && ( + + {g.approved_count} approved + + )} + {g.done_count > 0 && ( + + {g.done_count} done + + )} + {g.error_count > 0 && ( + + {g.error_count} err + + )} + {g.skipped_count > 0 && ( + + {g.skipped_count} skip + + )} + {g.manual_count > 0 && ( + + {g.manual_count} manual + + )} ); } @@ -59,7 +104,7 @@ const Th = ({ children }: { children: React.ReactNode }) => ( ); const Td = ({ children, className }: { children?: React.ReactNode; className?: string }) => ( - {children} + {children} ); // ─── Series row (collapsible) ───────────────────────────────────────────────── @@ -68,8 +113,19 @@ function SeriesRow({ g }: { g: SeriesGroup }) { const [open, setOpen] = useState(false); const urlKey = encodeURIComponent(g.series_key); - interface EpisodeItem { item: MediaItem; plan: ReviewPlan | null; removeCount: number; } - interface SeasonGroup { season: number | null; episodes: EpisodeItem[]; noopCount: number; actionCount: number; approvedCount: number; doneCount: number; } + interface EpisodeItem { + item: MediaItem; + plan: ReviewPlan | null; + removeCount: number; + } + interface SeasonGroup { + season: number | null; + episodes: EpisodeItem[]; + noopCount: number; + actionCount: number; + approvedCount: number; + doneCount: number; + } const [seasons, setSeasons] = useState(null); @@ -93,25 +149,30 @@ function SeriesRow({ g }: { g: SeriesGroup }) { window.location.reload(); }; - const statusKey = (plan: ReviewPlan | null) => plan?.is_noop ? 'noop' : (plan?.status ?? 'pending'); + const statusKey = (plan: ReviewPlan | null) => (plan?.is_noop ? "noop" : (plan?.status ?? "pending")); return ( - + - - {' '}{g.series_name} + + ▶ + {" "} + {g.series_name} {langName(g.original_language)} {g.season_count} {g.episode_count} - + + + e.stopPropagation()}> {g.needs_action_count > 0 && ( - + )} @@ -123,13 +184,32 @@ function SeriesRow({ g }: { g: SeriesGroup }) { {seasons.map((s) => ( <> - - Season {s.season ?? '?'} + + Season {s.season ?? "?"} - {s.noopCount > 0 && {s.noopCount} ok} - {s.actionCount > 0 && {s.actionCount} action} - {s.approvedCount > 0 && {s.approvedCount} approved} - {s.doneCount > 0 && {s.doneCount} done} + {s.noopCount > 0 && ( + + {s.noopCount} ok + + )} + {s.actionCount > 0 && ( + + {s.actionCount} action + + )} + {s.approvedCount > 0 && ( + + {s.approvedCount} approved + + )} + {s.doneCount > 0 && ( + + {s.doneCount} done + + )} {s.actionCount > 0 && ( ; +function ApproveBtn({ itemId, size }: { itemId: number; size?: "xs" | "sm" }) { + const onClick = async () => { + await api.post(`/api/review/${itemId}/approve`); + window.location.reload(); + }; + return ( + + ); } -function SkipBtn({ itemId, size }: { itemId: number; size?: 'xs' | 'sm' }) { - const onClick = async () => { await api.post(`/api/review/${itemId}/skip`); window.location.reload(); }; - return ; +function SkipBtn({ itemId, size }: { itemId: number; size?: "xs" | "sm" }) { + const onClick = async () => { + await api.post(`/api/review/${itemId}/skip`); + window.location.reload(); + }; + return ( + + ); } -function UnskipBtn({ itemId, size }: { itemId: number; size?: 'xs' | 'sm' }) { - const onClick = async () => { await api.post(`/api/review/${itemId}/unskip`); window.location.reload(); }; - return ; +function UnskipBtn({ itemId, size }: { itemId: number; size?: "xs" | "sm" }) { + const onClick = async () => { + await api.post(`/api/review/${itemId}/unskip`); + window.location.reload(); + }; + return ( + + ); } // ─── Cache ──────────────────────────────────────────────────────────────────── @@ -202,22 +308,31 @@ const cache = new Map(); // ─── Main page ──────────────────────────────────────────────────────────────── export function AudioListPage() { - const { filter } = useSearch({ from: '/review/audio/' }); + const { filter } = useSearch({ from: "/review/audio/" }); const navigate = useNavigate(); const [data, setData] = useState(cache.get(filter) ?? null); const [loading, setLoading] = useState(!cache.has(filter)); useEffect(() => { const cached = cache.get(filter); - if (cached) { setData(cached); setLoading(false); } - else { setLoading(true); } - api.get(`/api/review?filter=${filter}`) - .then((d) => { cache.set(filter, d); setData(d); setLoading(false); }) + if (cached) { + setData(cached); + setLoading(false); + } else { + setLoading(true); + } + api + .get(`/api/review?filter=${filter}`) + .then((d) => { + cache.set(filter, d); + setData(d); + setLoading(false); + }) .catch(() => setLoading(false)); }, [filter]); const approveAll = async () => { - await api.post('/api/review/approve-all'); + await api.post("/api/review/approve-all"); cache.clear(); window.location.reload(); }; @@ -227,7 +342,7 @@ export function AudioListPage() { const { movies, series, totalCounts } = data; const hasPending = (totalCounts.needs_action ?? 0) > 0; - const statusKey = (plan: ReviewPlan | null) => plan?.is_noop ? 'noop' : (plan?.status ?? 'pending'); + const statusKey = (plan: ReviewPlan | null) => (plan?.is_noop ? "noop" : (plan?.status ?? "pending")); return (
@@ -236,8 +351,13 @@ export function AudioListPage() {
{hasPending ? ( <> - {totalCounts.needs_action} item{totalCounts.needs_action !== 1 ? 's' : ''} need{totalCounts.needs_action === 1 ? 's' : ''} review - + + {totalCounts.needs_action} item{totalCounts.needs_action !== 1 ? "s" : ""} need + {totalCounts.needs_action === 1 ? "s" : ""} review + + ) : ( All items reviewed @@ -248,12 +368,10 @@ export function AudioListPage() { tabs={FILTER_TABS} filter={filter} totalCounts={totalCounts} - onFilterChange={(key) => navigate({ to: '/review/audio', search: { filter: key } as never })} + onFilterChange={(key) => navigate({ to: "/review/audio", search: { filter: key } as never })} /> - {movies.length === 0 && series.length === 0 && ( -

No items match this filter.

- )} + {movies.length === 0 && series.length === 0 &&

No items match this filter.

} {/* Movies */} {movies.length > 0 && ( @@ -262,54 +380,89 @@ export function AudioListPage() { Movies {movies.length}
- - - - {movies.map(({ item, plan, removeCount }) => ( - - - - - - +
NameLangRemoveStatusActions
- {item.name} - {item.year && ({item.year})} - - {item.needs_review && !item.original_language - ? manual - : {langName(item.original_language)}} - {removeCount > 0 ? −{removeCount} : }{plan?.is_noop ? 'ok' : (plan?.status ?? 'pending')} - {plan?.status === 'pending' && !plan.is_noop && } - {plan?.status === 'pending' && } - {plan?.status === 'skipped' && } - - Detail - -
+ + + + + + + - ))} - -
NameLangRemoveStatusActions
-
+ + + {movies.map(({ item, plan, removeCount }) => ( + + + + {item.name} + + {item.year && ({item.year})} + + + {item.needs_review && !item.original_language ? ( + manual + ) : ( + {langName(item.original_language)} + )} + + + {removeCount > 0 ? −{removeCount} : } + + + + {plan?.is_noop ? "ok" : (plan?.status ?? "pending")} + + + + {plan?.status === "pending" && !plan.is_noop && } + {plan?.status === "pending" && } + {plan?.status === "skipped" && } + + Detail + + + + ))} + + +
)} {/* TV Series */} {series.length > 0 && ( <> -
0 ? 'mt-5' : 'mt-0'}`}> +
0 ? "mt-5" : "mt-0"}`} + > TV Series {series.length}
- - - {series.map((g) => )} -
SeriesLangSEpStatusActions
-
+ + + + + + + + + + + + {series.map((g) => ( + + ))} +
SeriesLangSEpStatusActions
+
)}
); } -import type React from 'react'; +import type React from "react"; diff --git a/src/features/scan/ScanPage.tsx b/src/features/scan/ScanPage.tsx index 69abb94..27e79a4 100644 --- a/src/features/scan/ScanPage.tsx +++ b/src/features/scan/ScanPage.tsx @@ -1,11 +1,21 @@ -import { useEffect, useRef, useState, useCallback } from 'react'; -import { Link } from '@tanstack/react-router'; -import { api } from '~/shared/lib/api'; -import { Button } from '~/shared/components/ui/button'; -import { Badge } from '~/shared/components/ui/badge'; +import { Link } from "@tanstack/react-router"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Badge } from "~/shared/components/ui/badge"; +import { Button } from "~/shared/components/ui/button"; +import { api } from "~/shared/lib/api"; -interface ScanStatus { running: boolean; progress: { scanned: number; total: number; errors: number }; recentItems: { name: string; type: string; scan_status: string; file_path: string }[]; scanLimit: number | null; } -interface LogEntry { name: string; type: string; status: string; file?: string; } +interface ScanStatus { + running: boolean; + progress: { scanned: number; total: number; errors: number }; + recentItems: { name: string; type: string; scan_status: string; file_path: string }[]; + scanLimit: number | null; +} +interface LogEntry { + name: string; + type: string; + status: string; + file?: string; +} // Mutable buffer for SSE data — flushed to React state on an interval interface SseBuf { @@ -20,18 +30,18 @@ interface SseBuf { } function freshBuf(): SseBuf { - return { scanned: 0, total: 0, errors: 0, currentItem: '', newLogs: [], dirty: false, complete: null, lost: false }; + return { scanned: 0, total: 0, errors: 0, currentItem: "", newLogs: [], dirty: false, complete: null, lost: false }; } const FLUSH_MS = 200; export function ScanPage() { const [status, setStatus] = useState(null); - const [limit, setLimit] = useState(''); + const [limit, setLimit] = useState(""); const [log, setLog] = useState([]); - const [statusLabel, setStatusLabel] = useState(''); + const [statusLabel, setStatusLabel] = useState(""); const [scanComplete, setScanComplete] = useState(false); - const [currentItem, setCurrentItem] = useState(''); + const [currentItem, setCurrentItem] = useState(""); const [progressScanned, setProgressScanned] = useState(0); const [progressTotal, setProgressTotal] = useState(0); const [errors, setErrors] = useState(0); @@ -59,19 +69,19 @@ export function ScanPage() { if (b.complete) { const d = b.complete; b.complete = null; - setStatusLabel(`Scan complete — ${d.scanned ?? '?'} items, ${d.errors ?? 0} errors`); + setStatusLabel(`Scan complete — ${d.scanned ?? "?"} items, ${d.errors ?? 0} errors`); setScanComplete(true); - setStatus((prev) => prev ? { ...prev, running: false } : prev); + setStatus((prev) => (prev ? { ...prev, running: false } : prev)); stopFlushing(); } if (b.lost) { b.lost = false; - setStatusLabel('Scan connection lost — refresh to see current status'); - setStatus((prev) => prev ? { ...prev, running: false } : prev); + setStatusLabel("Scan connection lost — refresh to see current status"); + setStatus((prev) => (prev ? { ...prev, running: false } : prev)); stopFlushing(); } - }, []); + }, [stopFlushing]); const startFlushing = useCallback(() => { if (timerRef.current) return; @@ -86,50 +96,57 @@ export function ScanPage() { }, [flush]); // Cleanup timer on unmount - useEffect(() => () => { if (timerRef.current) clearInterval(timerRef.current); }, []); + useEffect( + () => () => { + if (timerRef.current) clearInterval(timerRef.current); + }, + [], + ); const load = async () => { - const s = await api.get('/api/scan'); + const s = await api.get("/api/scan"); setStatus(s); setProgressScanned(s.progress.scanned); setProgressTotal(s.progress.total); setErrors(s.progress.errors); - setStatusLabel(s.running ? 'Scan in progress…' : 'Scan idle'); + setStatusLabel(s.running ? "Scan in progress…" : "Scan idle"); if (s.scanLimit != null) setLimit(String(s.scanLimit)); setLog(s.recentItems.map((i) => ({ name: i.name, type: i.type, status: i.scan_status, file: i.file_path }))); }; - useEffect(() => { load(); }, []); + useEffect(() => { + load(); + }, [load]); const connectSse = useCallback(() => { esRef.current?.close(); const buf = bufRef.current; - const es = new EventSource('/api/scan/events'); + const es = new EventSource("/api/scan/events"); esRef.current = es; - es.addEventListener('progress', (e) => { + es.addEventListener("progress", (e) => { const d = JSON.parse(e.data) as { scanned: number; total: number; errors: number; current_item: string }; buf.scanned = d.scanned; buf.total = d.total; buf.errors = d.errors; - buf.currentItem = d.current_item ?? ''; + buf.currentItem = d.current_item ?? ""; buf.dirty = true; }); - es.addEventListener('log', (e) => { + es.addEventListener("log", (e) => { const d = JSON.parse(e.data) as LogEntry; buf.newLogs.push(d); buf.dirty = true; }); - es.addEventListener('complete', (e) => { - const d = JSON.parse(e.data || '{}') as { scanned?: number; errors?: number }; + es.addEventListener("complete", (e) => { + const d = JSON.parse(e.data || "{}") as { scanned?: number; errors?: number }; es.close(); esRef.current = null; buf.complete = d; }); - es.addEventListener('error', () => { + es.addEventListener("error", () => { es.close(); esRef.current = null; buf.lost = true; @@ -143,7 +160,11 @@ export function ScanPage() { useEffect(() => { if (!status?.running || esRef.current) return; connectSse(); - return () => { esRef.current?.close(); esRef.current = null; stopFlushing(); }; + return () => { + esRef.current?.close(); + esRef.current = null; + stopFlushing(); + }; }, [status?.running, connectSse, stopFlushing]); const startScan = async () => { @@ -151,26 +172,26 @@ export function ScanPage() { setProgressScanned(0); setProgressTotal(0); setErrors(0); - setCurrentItem(''); - setStatusLabel('Scan in progress…'); + setCurrentItem(""); + setStatusLabel("Scan in progress…"); setScanComplete(false); - setStatus((prev) => prev ? { ...prev, running: true } : prev); + setStatus((prev) => (prev ? { ...prev, running: true } : prev)); bufRef.current = freshBuf(); // Connect SSE before starting the scan so no events are missed connectSse(); const limitNum = limit ? Number(limit) : undefined; - await api.post('/api/scan/start', limitNum !== undefined ? { limit: limitNum } : {}); + await api.post("/api/scan/start", limitNum !== undefined ? { limit: limitNum } : {}); }; const stopScan = async () => { - await api.post('/api/scan/stop', {}); + await api.post("/api/scan/stop", {}); esRef.current?.close(); esRef.current = null; stopFlushing(); - setStatus((prev) => prev ? { ...prev, running: false } : prev); - setStatusLabel('Scan stopped'); + setStatus((prev) => (prev ? { ...prev, running: false } : prev)); + setStatusLabel("Scan stopped"); }; const pct = progressTotal > 0 ? Math.round((progressScanned / progressTotal) * 100) : 0; @@ -182,14 +203,16 @@ export function ScanPage() {
- {statusLabel || (running ? 'Scan in progress…' : 'Scan idle')} + {statusLabel || (running ? "Scan in progress…" : "Scan idle")} {scanComplete && ( Review in Pipeline → )} {running ? ( - + ) : (
- +
)} {errors > 0 && {errors} error(s)} @@ -218,7 +243,10 @@ export function ScanPage() {
)}
- {progressScanned}{progressTotal > 0 ? ` / ${progressTotal}` : ''} scanned + + {progressScanned} + {progressTotal > 0 ? ` / ${progressTotal}` : ""} scanned + {currentItem && {currentItem}}
@@ -230,20 +258,27 @@ export function ScanPage() { - {['Type', 'File', 'Status'].map((h) => ( - + {["Type", "File", "Status"].map((h) => ( + ))} {log.map((item, i) => { - const fileName = item.file ? item.file.split('/').pop() ?? item.name : item.name; + const fileName = item.file ? (item.file.split("/").pop() ?? item.name) : item.name; return ( - + ); diff --git a/src/features/setup/SetupPage.tsx b/src/features/setup/SetupPage.tsx index d82b077..cb6f5fb 100644 --- a/src/features/setup/SetupPage.tsx +++ b/src/features/setup/SetupPage.tsx @@ -1,11 +1,14 @@ -import { useEffect, useState } from 'react'; -import { api } from '~/shared/lib/api'; -import { Button } from '~/shared/components/ui/button'; -import { Input } from '~/shared/components/ui/input'; -import { Select } from '~/shared/components/ui/select'; -import { LANG_NAMES } from '~/shared/lib/lang'; +import { useEffect, useState } from "react"; +import { Button } from "~/shared/components/ui/button"; +import { Input } from "~/shared/components/ui/input"; +import { Select } from "~/shared/components/ui/select"; +import { api } from "~/shared/lib/api"; +import { LANG_NAMES } from "~/shared/lib/lang"; -interface SetupData { config: Record; envLocked: string[]; } +interface SetupData { + config: Record; + envLocked: string[]; +} let setupCache: SetupData | null = null; @@ -16,7 +19,7 @@ const LANGUAGE_OPTIONS = Object.entries(LANG_NAMES).map(([code, label]) => ({ co function LockedInput({ locked, ...props }: { locked: boolean } & React.InputHTMLAttributes) { return (
- + {locked && ( - {locked ? '🔒' : '🔓'} {envVar} + {locked ? "🔒" : "🔓"} {envVar} ); } // ─── Section card ────────────────────────────────────────────────────────────── -function SectionCard({ title, subtitle, children }: { title: React.ReactNode; subtitle?: React.ReactNode; children: React.ReactNode }) { +function SectionCard({ + title, + subtitle, + children, +}: { + title: React.ReactNode; + subtitle?: React.ReactNode; + children: React.ReactNode; +}) { return (
{title}
@@ -59,9 +72,13 @@ function SectionCard({ title, subtitle, children }: { title: React.ReactNode; su // ─── Sortable language list ───────────────────────────────────────────────────── function SortableLanguageList({ - langs, onChange, disabled, + langs, + onChange, + disabled, }: { - langs: string[]; onChange: (langs: string[]) => void; disabled: boolean; + langs: string[]; + onChange: (langs: string[]) => void; + disabled: boolean; }) { const available = LANGUAGE_OPTIONS.filter((o) => !langs.includes(o.code)); @@ -88,21 +105,32 @@ function SortableLanguageList({ return (
+ > + ↑ + - {label} ({code}) + > + ↓ + + + {label} ({code}) + + > + ✕ +
); })} @@ -111,12 +139,17 @@ function SortableLanguageList({ {!disabled && available.length > 0 && ( )} @@ -127,20 +160,38 @@ function SortableLanguageList({ // ─── Connection section ──────────────────────────────────────────────────────── function ConnSection({ - title, subtitle, cfg, locked, urlKey, apiKey: apiKeyProp, urlPlaceholder, onSave, + title, + subtitle, + cfg, + locked, + urlKey, + apiKey: apiKeyProp, + urlPlaceholder, + onSave, }: { - title: React.ReactNode; subtitle?: React.ReactNode; cfg: Record; locked: Set; - urlKey: string; apiKey: string; urlPlaceholder: string; onSave: (url: string, apiKey: string) => Promise; + title: React.ReactNode; + subtitle?: React.ReactNode; + cfg: Record; + locked: Set; + urlKey: string; + apiKey: string; + urlPlaceholder: string; + onSave: (url: string, apiKey: string) => Promise; }) { - const [url, setUrl] = useState(cfg[urlKey] ?? ''); - const [key, setKey] = useState(cfg[apiKeyProp] ?? ''); + const [url, setUrl] = useState(cfg[urlKey] ?? ""); + const [key, setKey] = useState(cfg[apiKeyProp] ?? ""); const [status, setStatus] = useState<{ ok: boolean; error?: string } | null>(null); const [saving, setSaving] = useState(false); const save = async () => { setSaving(true); setStatus(null); - try { await onSave(url, key); setStatus({ ok: true }); } catch (e) { setStatus({ ok: false, error: String(e) }); } + try { + await onSave(url, key); + setStatus({ ok: true }); + } catch (e) { + setStatus({ ok: false, error: String(e) }); + } setSaving(false); }; @@ -148,19 +199,32 @@ function ConnSection({
{status && ( - - {status.ok ? '✓ Saved' : `✗ ${status.error ?? 'Connection failed'}`} + + {status.ok ? "✓ Saved" : `✗ ${status.error ?? "Connection failed"}`} )}
@@ -173,54 +237,61 @@ function ConnSection({ export function SetupPage() { const [data, setData] = useState(setupCache); const [loading, setLoading] = useState(setupCache === null); - const [clearStatus, setClearStatus] = useState(''); + const [clearStatus, setClearStatus] = useState(""); const [subLangs, setSubLangs] = useState([]); - const [subSaved, setSubSaved] = useState(''); + const [subSaved, setSubSaved] = useState(""); const [audLangs, setAudLangs] = useState([]); - const [audSaved, setAudSaved] = useState(''); + const [audSaved, setAudSaved] = useState(""); const [langsLoaded, setLangsLoaded] = useState(false); const load = () => { if (!setupCache) setLoading(true); - api.get('/api/setup').then((d) => { - setupCache = d; - setData(d); - if (!langsLoaded) { - setSubLangs(JSON.parse(d.config.subtitle_languages ?? '["eng","deu","spa"]')); - setAudLangs(JSON.parse(d.config.audio_languages ?? '[]')); - setLangsLoaded(true); - } - }).finally(() => setLoading(false)); + api + .get("/api/setup") + .then((d) => { + setupCache = d; + setData(d); + if (!langsLoaded) { + setSubLangs(JSON.parse(d.config.subtitle_languages ?? '["eng","deu","spa"]')); + setAudLangs(JSON.parse(d.config.audio_languages ?? "[]")); + setLangsLoaded(true); + } + }) + .finally(() => setLoading(false)); }; - useEffect(() => { load(); }, []); + useEffect(() => { + load(); + }, [load]); if (loading && !data) return
Loading…
; if (!data) return
Failed to load settings.
; const { config: cfg, envLocked: envLockedArr } = data; const locked = new Set(envLockedArr); - const saveJellyfin = (url: string, apiKey: string) => - api.post('/api/setup/jellyfin', { url, api_key: apiKey }); - const saveRadarr = (url: string, apiKey: string) => - api.post('/api/setup/radarr', { url, api_key: apiKey }); - const saveSonarr = (url: string, apiKey: string) => - api.post('/api/setup/sonarr', { url, api_key: apiKey }); + const saveJellyfin = (url: string, apiKey: string) => api.post("/api/setup/jellyfin", { url, api_key: apiKey }); + const saveRadarr = (url: string, apiKey: string) => api.post("/api/setup/radarr", { url, api_key: apiKey }); + const saveSonarr = (url: string, apiKey: string) => api.post("/api/setup/sonarr", { url, api_key: apiKey }); const saveSubtitleLangs = async () => { - await api.post('/api/setup/subtitle-languages', { langs: subLangs }); - setSubSaved('Saved.'); - setTimeout(() => setSubSaved(''), 2000); + await api.post("/api/setup/subtitle-languages", { langs: subLangs }); + setSubSaved("Saved."); + setTimeout(() => setSubSaved(""), 2000); }; const saveAudioLangs = async () => { - await api.post('/api/setup/audio-languages', { langs: audLangs }); - setAudSaved('Saved.'); - setTimeout(() => setAudSaved(''), 2000); + await api.post("/api/setup/audio-languages", { langs: audLangs }); + setAudSaved("Saved."); + setTimeout(() => setAudSaved(""), 2000); }; const clearScan = async () => { - if (!confirm('Delete all scanned data? This will remove all media items, stream decisions, review plans, and jobs. This cannot be undone.')) return; - await api.post('/api/setup/clear-scan'); - setClearStatus('Cleared.'); + if ( + !confirm( + "Delete all scanned data? This will remove all media items, stream decisions, review plans, and jobs. This cannot be undone.", + ) + ) + return; + await api.post("/api/setup/clear-scan"); + setClearStatus("Cleared."); }; return ( @@ -231,27 +302,53 @@ export function SetupPage() { {/* Jellyfin */} Jellyfin } - urlKey="jellyfin_url" apiKey="jellyfin_api_key" - urlPlaceholder="http://192.168.1.100:8096" cfg={cfg} locked={locked} + title={ + + Jellyfin {" "} + + + } + urlKey="jellyfin_url" + apiKey="jellyfin_api_key" + urlPlaceholder="http://192.168.1.100:8096" + cfg={cfg} + locked={locked} onSave={saveJellyfin} /> {/* Radarr */} Radarr (optional) } + title={ + + Radarr (optional){" "} + {" "} + + + } subtitle="Provides accurate original-language data for movies." - urlKey="radarr_url" apiKey="radarr_api_key" - urlPlaceholder="http://192.168.1.100:7878" cfg={cfg} locked={locked} + urlKey="radarr_url" + apiKey="radarr_api_key" + urlPlaceholder="http://192.168.1.100:7878" + cfg={cfg} + locked={locked} onSave={saveRadarr} /> {/* Sonarr */} Sonarr (optional) } + title={ + + Sonarr (optional){" "} + {" "} + + + } subtitle="Provides original-language data for TV series." - urlKey="sonarr_url" apiKey="sonarr_api_key" - urlPlaceholder="http://192.168.1.100:8989" cfg={cfg} locked={locked} + urlKey="sonarr_url" + apiKey="sonarr_api_key" + urlPlaceholder="http://192.168.1.100:8989" + cfg={cfg} + locked={locked} onSave={saveSonarr} /> @@ -260,14 +357,16 @@ export function SetupPage() { title={ Audio Languages - + } subtitle="Additional audio languages to keep alongside the original language. Order determines stream priority in the output file. The original language is always kept first." > - +
- + {audSaved && {audSaved}}
@@ -277,14 +376,16 @@ export function SetupPage() { title={ Subtitle Languages - + } subtitle="Subtitle tracks in these languages are extracted to sidecar files. Order determines priority. All subtitles are removed from the container during processing." > - +
- + {subSaved && {subSaved}}
@@ -292,9 +393,13 @@ export function SetupPage() { {/* Danger zone */}
Danger Zone
-

These actions are irreversible. Scan data can be regenerated by running a new scan.

+

+ These actions are irreversible. Scan data can be regenerated by running a new scan. +

- + Removes all scanned items, review plans, and jobs.
{clearStatus &&

{clearStatus}

} @@ -303,4 +408,4 @@ export function SetupPage() { ); } -import type React from 'react'; +import type React from "react"; diff --git a/src/features/subtitles/SubtitleDetailPage.tsx b/src/features/subtitles/SubtitleDetailPage.tsx index 919941e..b9b1fc1 100644 --- a/src/features/subtitles/SubtitleDetailPage.tsx +++ b/src/features/subtitles/SubtitleDetailPage.tsx @@ -1,12 +1,12 @@ -import { useEffect, useState } from 'react'; -import { Link, useParams } from '@tanstack/react-router'; -import { api } from '~/shared/lib/api'; -import { Badge } from '~/shared/components/ui/badge'; -import { Button } from '~/shared/components/ui/button'; -import { Alert } from '~/shared/components/ui/alert'; -import { Select } from '~/shared/components/ui/select'; -import { langName, LANG_NAMES } from '~/shared/lib/lang'; -import type { MediaItem, MediaStream, SubtitleFile, ReviewPlan, StreamDecision } from '~/shared/lib/types'; +import { Link, useParams } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { Alert } from "~/shared/components/ui/alert"; +import { Badge } from "~/shared/components/ui/badge"; +import { Button } from "~/shared/components/ui/button"; +import { Select } from "~/shared/components/ui/select"; +import { api } from "~/shared/lib/api"; +import { LANG_NAMES, langName } from "~/shared/lib/lang"; +import type { MediaItem, MediaStream, ReviewPlan, StreamDecision, SubtitleFile } from "~/shared/lib/types"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -30,12 +30,12 @@ function formatBytes(bytes: number): string { } function fileName(filePath: string): string { - return filePath.split('/').pop() ?? filePath; + return filePath.split("/").pop() ?? filePath; } function effectiveTitle(s: MediaStream, dec: StreamDecision | undefined): string { if (dec?.custom_title) return dec.custom_title; - if (!s.language) return ''; + if (!s.language) return ""; const base = langName(s.language); if (s.is_forced) return `${base} (Forced)`; if (s.is_hearing_impaired) return `${base} (CC)`; @@ -46,14 +46,20 @@ function effectiveTitle(s: MediaStream, dec: StreamDecision | undefined): string function TitleInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) { const [localVal, setLocalVal] = useState(value); - useEffect(() => { setLocalVal(value); }, [value]); + useEffect(() => { + setLocalVal(value); + }, [value]); return ( setLocalVal(e.target.value)} - onBlur={(e) => { if (e.target.value !== value) onCommit(e.target.value); }} - onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} + onBlur={(e) => { + if (e.target.value !== value) onCommit(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === "Enter") (e.target as HTMLInputElement).blur(); + }} placeholder="—" className="border border-transparent bg-transparent w-full text-[inherit] font-[inherit] text-inherit px-[0.2em] py-[0.05em] rounded outline-none hover:border-gray-300 hover:bg-gray-50 focus:border-blue-300 focus:bg-white min-w-16" /> @@ -74,72 +80,79 @@ function StreamTable({ streams, decisions, editable, onLanguageChange, onTitleCh if (streams.length === 0) return

No subtitle streams in container.

; return ( -
{h} + {h} +
{item.type}{fileName} + {fileName} + - {item.status} + {item.status}
- - - {['#', 'Codec', 'Language', 'Title / Info', 'Flags', 'Action'].map((h) => ( - - ))} - - - - {streams.map((s) => { - const dec = decisions.find((d) => d.stream_id === s.id); - const title = effectiveTitle(s, dec); - const origTitle = s.title; +
+
{h}
+ + + {["#", "Codec", "Language", "Title / Info", "Flags", "Action"].map((h) => ( + + ))} + + + + {streams.map((s) => { + const dec = decisions.find((d) => d.stream_id === s.id); + const title = effectiveTitle(s, dec); + const origTitle = s.title; - return ( - - - - - - - - - ); - })} - -
+ {h} +
{s.stream_index}{s.codec ?? '—'} - {editable ? ( - - ) : ( - <> - {langName(s.language)} {s.language ? ({s.language}) : null} - - )} - - {editable ? ( - onTitleChange(s.id, v)} - /> - ) : ( - {title || '—'} - )} - {editable && origTitle && origTitle !== title && ( -
orig: {origTitle}
- )} -
- - {s.is_default ? default : null} - {s.is_forced ? forced : null} - {s.is_hearing_impaired ? CC : null} - - - - ↑ Extract - -
+ return ( + + {s.stream_index} + {s.codec ?? "—"} + + {editable ? ( + + ) : ( + <> + {langName(s.language)}{" "} + {s.language ? ({s.language}) : null} + + )} + + + {editable ? ( + onTitleChange(s.id, v)} /> + ) : ( + {title || "—"} + )} + {editable && origTitle && origTitle !== title && ( +
orig: {origTitle}
+ )} + + + + {s.is_default ? default : null} + {s.is_forced ? forced : null} + {s.is_hearing_impaired ? CC : null} + + + + + ↑ Extract + + + + ); + })} + + +
); } @@ -149,54 +162,76 @@ function ExtractedFilesTable({ files, onDelete }: { files: SubtitleFile[]; onDel if (files.length === 0) return

No extracted files yet.

; return ( -
- - - {['File', 'Language', 'Codec', 'Flags', 'Size', ''].map((h) => ( - - ))} - - - - {files.map((f) => ( - - - - - - - +
+
{h}
- {fileName(f.file_path)} - - {f.language ? langName(f.language) : '—'} {f.language ? ({f.language}) : null} - {f.codec ?? '—'} - - {f.is_forced ? forced : null} - {f.is_hearing_impaired ? CC : null} - - - {f.file_size ? formatBytes(f.file_size) : '—'} - - -
+ + + {["File", "Language", "Codec", "Flags", "Size", ""].map((h) => ( + + ))} - ))} - -
+ {h} +
+ + + {files.map((f) => ( + + + {fileName(f.file_path)} + + + {f.language ? langName(f.language) : "—"}{" "} + {f.language ? ({f.language}) : null} + + {f.codec ?? "—"} + + + {f.is_forced ? forced : null} + {f.is_hearing_impaired ? CC : null} + + + + {f.file_size ? formatBytes(f.file_size) : "—"} + + + + + + ))} + + + ); } // ─── Detail page ────────────────────────────────────────────────────────────── export function SubtitleDetailPage() { - const { id } = useParams({ from: '/review/subtitles/$id' }); + const { id } = useParams({ from: "/review/subtitles/$id" }); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [extracting, setExtracting] = useState(false); const [rescanning, setRescanning] = useState(false); - const load = () => api.get(`/api/subtitles/${id}`).then((d) => { setData(d); setLoading(false); }).catch(() => setLoading(false)); - useEffect(() => { load(); }, [id]); + const load = () => + api + .get(`/api/subtitles/${id}`) + .then((d) => { + setData(d); + setLoading(false); + }) + .catch(() => setLoading(false)); + useEffect(() => { + load(); + }, [load]); const changeLanguage = async (streamId: number, lang: string) => { const d = await api.patch(`/api/subtitles/${id}/stream/${streamId}/language`, { language: lang || null }); @@ -213,7 +248,9 @@ export function SubtitleDetailPage() { try { await api.post(`/api/subtitles/${id}/extract`); load(); - } finally { setExtracting(false); } + } finally { + setExtracting(false); + } }; const deleteFile = async (fileId: number) => { @@ -223,8 +260,12 @@ export function SubtitleDetailPage() { const rescan = async () => { setRescanning(true); - try { const d = await api.post(`/api/subtitles/${id}/rescan`); setData(d); } - finally { setRescanning(false); } + try { + const d = await api.post(`/api/subtitles/${id}/rescan`); + setData(d); + } finally { + setRescanning(false); + } }; if (loading) return
Loading…
; @@ -238,7 +279,9 @@ export function SubtitleDetailPage() {

- ← Subtitles + + ← Subtitles + {item.name}

@@ -247,12 +290,15 @@ export function SubtitleDetailPage() { {/* Meta */}
{[ - { label: 'Type', value: item.type }, - ...(item.series_name ? [{ label: 'Series', value: item.series_name }] : []), - ...(item.year ? [{ label: 'Year', value: String(item.year) }] : []), - { label: 'Container', value: item.container ?? '—' }, - { label: 'File size', value: item.file_size ? formatBytes(item.file_size) : '—' }, - { label: 'Status', value: {subs_extracted ? 'extracted' : 'pending'} }, + { label: "Type", value: item.type }, + ...(item.series_name ? [{ label: "Series", value: item.series_name }] : []), + ...(item.year ? [{ label: "Year", value: String(item.year) }] : []), + { label: "Container", value: item.container ?? "—" }, + { label: "File size", value: item.file_size ? formatBytes(item.file_size) : "—" }, + { + label: "Status", + value: {subs_extracted ? "extracted" : "pending"}, + }, ].map((entry, i) => (
{entry.label}
@@ -273,7 +319,9 @@ export function SubtitleDetailPage() { onTitleChange={changeTitle} /> ) : ( - No subtitle streams found in this container. + + No subtitle streams found in this container. + )} {/* Extracted files */} @@ -301,22 +349,26 @@ export function SubtitleDetailPage() { {hasContainerSubs && !subs_extracted && (
)} {subs_extracted ? ( - Subtitles have been extracted to sidecar files. + + Subtitles have been extracted to sidecar files. + ) : null} {/* Refresh */}
- {rescanning ? 'Triggering Jellyfin metadata probe and waiting for completion…' : 'Triggers a metadata re-probe in Jellyfin, then re-fetches stream data'} + {rescanning + ? "Triggering Jellyfin metadata probe and waiting for completion…" + : "Triggers a metadata re-probe in Jellyfin, then re-fetches stream data"}
diff --git a/src/features/subtitles/SubtitleExtractPage.tsx b/src/features/subtitles/SubtitleExtractPage.tsx index f01f857..cbe18de 100644 --- a/src/features/subtitles/SubtitleExtractPage.tsx +++ b/src/features/subtitles/SubtitleExtractPage.tsx @@ -1,25 +1,37 @@ -import { useState, useEffect } from 'react'; -import { Link, useNavigate, useSearch } from '@tanstack/react-router'; -import { api } from '~/shared/lib/api'; -import { Badge } from '~/shared/components/ui/badge'; -import { Button } from '~/shared/components/ui/button'; -import { FilterTabs } from '~/shared/components/ui/filter-tabs'; -import { langName } from '~/shared/lib/lang'; -import type React from 'react'; +import { Link, useNavigate, useSearch } from "@tanstack/react-router"; +import type React from "react"; +import { useEffect, useState } from "react"; +import { Badge } from "~/shared/components/ui/badge"; +import { Button } from "~/shared/components/ui/button"; +import { FilterTabs } from "~/shared/components/ui/filter-tabs"; +import { api } from "~/shared/lib/api"; +import { langName } from "~/shared/lib/lang"; // ─── Types ──────────────────────────────────────────────────────────────────── interface SubListItem { - id: number; name: string; type: string; series_name: string | null; - season_number: number | null; episode_number: number | null; - year: number | null; original_language: string | null; - subs_extracted: number | null; sub_count: number; file_count: number; + id: number; + name: string; + type: string; + series_name: string | null; + season_number: number | null; + episode_number: number | null; + year: number | null; + original_language: string | null; + subs_extracted: number | null; + sub_count: number; + file_count: number; } interface SubSeriesGroup { - series_key: string; series_name: string; original_language: string | null; - season_count: number; episode_count: number; - not_extracted_count: number; extracted_count: number; no_subs_count: number; + series_key: string; + series_name: string; + original_language: string | null; + season_count: number; + episode_count: number; + not_extracted_count: number; + extracted_count: number; + no_subs_count: number; } interface SubListData { @@ -32,14 +44,16 @@ interface SubListData { interface SeasonGroup { season: number | null; episodes: SubListItem[]; - extractedCount: number; notExtractedCount: number; noSubsCount: number; + extractedCount: number; + notExtractedCount: number; + noSubsCount: number; } const FILTER_TABS = [ - { key: 'all', label: 'All' }, - { key: 'not_extracted', label: 'Not Extracted' }, - { key: 'extracted', label: 'Extracted' }, - { key: 'no_subs', label: 'No Subtitles' }, + { key: "all", label: "All" }, + { key: "not_extracted", label: "Not Extracted" }, + { key: "extracted", label: "Extracted" }, + { key: "no_subs", label: "No Subtitles" }, ]; // ─── Table helpers ──────────────────────────────────────────────────────────── @@ -51,27 +65,39 @@ const Th = ({ children }: { children?: React.ReactNode }) => ( ); const Td = ({ children, className }: { children?: React.ReactNode; className?: string }) => ( - {children} + {children} ); -function subStatus(item: SubListItem): 'extracted' | 'not_extracted' | 'no_subs' { - if (item.sub_count === 0) return 'no_subs'; - return item.subs_extracted ? 'extracted' : 'not_extracted'; +function subStatus(item: SubListItem): "extracted" | "not_extracted" | "no_subs" { + if (item.sub_count === 0) return "no_subs"; + return item.subs_extracted ? "extracted" : "not_extracted"; } function StatusBadge({ item }: { item: SubListItem }) { const s = subStatus(item); - if (s === 'extracted') return extracted; - if (s === 'not_extracted') return pending; + if (s === "extracted") return extracted; + if (s === "not_extracted") return pending; return no subs; } function StatusPills({ g }: { g: SubSeriesGroup }) { return ( - {g.extracted_count > 0 && {g.extracted_count} extracted} - {g.not_extracted_count > 0 && {g.not_extracted_count} pending} - {g.no_subs_count > 0 && {g.no_subs_count} no subs} + {g.extracted_count > 0 && ( + + {g.extracted_count} extracted + + )} + {g.not_extracted_count > 0 && ( + + {g.not_extracted_count} pending + + )} + {g.no_subs_count > 0 && ( + + {g.no_subs_count} no subs + + )} ); } @@ -80,16 +106,18 @@ function StatusPills({ g }: { g: SubSeriesGroup }) { function ActionBox({ count, onExtract }: { count: number | null; onExtract: () => void }) { const [extracting, setExtracting] = useState(false); - const [result, setResult] = useState(''); + const [result, setResult] = useState(""); const handleExtract = async () => { setExtracting(true); - setResult(''); + setResult(""); try { - const r = await api.post<{ ok: boolean; queued: number }>('/api/subtitles/extract-all'); - setResult(`Queued ${r.queued} extraction job${r.queued !== 1 ? 's' : ''}.`); + const r = await api.post<{ ok: boolean; queued: number }>("/api/subtitles/extract-all"); + setResult(`Queued ${r.queued} extraction job${r.queued !== 1 ? "s" : ""}.`); onExtract(); - } catch (e) { setResult(`Error: ${e}`); } + } catch (e) { + setResult(`Error: ${e}`); + } setExtracting(false); }; @@ -101,9 +129,11 @@ function ActionBox({ count, onExtract }: { count: number | null; onExtract: () = {allDone && All subtitles extracted} {count !== null && count > 0 && ( <> - {count} item{count !== 1 ? 's have' : ' has'} embedded subtitles to extract + + {count} item{count !== 1 ? "s have" : " has"} embedded subtitles to extract + )} @@ -131,13 +161,19 @@ function SeriesRow({ g }: { g: SubSeriesGroup }) { - - {' '}{g.series_name} + + ▶ + {" "} + {g.series_name} {langName(g.original_language)} {g.season_count} {g.episode_count} - + + + {open && seasons && ( @@ -147,21 +183,41 @@ function SeriesRow({ g }: { g: SubSeriesGroup }) { {seasons.map((s) => ( <> - - Season {s.season ?? '?'} + + Season {s.season ?? "?"} - {s.extractedCount > 0 && {s.extractedCount} extracted} - {s.notExtractedCount > 0 && {s.notExtractedCount} pending} - {s.noSubsCount > 0 && {s.noSubsCount} no subs} + {s.extractedCount > 0 && ( + + {s.extractedCount} extracted + + )} + {s.notExtractedCount > 0 && ( + + {s.notExtractedCount} pending + + )} + {s.noSubsCount > 0 && ( + + {s.noSubsCount} no subs + + )} {s.episodes.map((item) => ( - E{String(item.episode_number ?? 0).padStart(2, '0')} - {' '} - + + E{String(item.episode_number ?? 0).padStart(2, "0")} + {" "} + {item.name} @@ -171,7 +227,11 @@ function SeriesRow({ g }: { g: SubSeriesGroup }) { - + Detail @@ -195,7 +255,7 @@ const cache = new Map(); // ─── Main page ──────────────────────────────────────────────────────────────── export function SubtitleExtractPage() { - const { filter } = useSearch({ from: '/review/subtitles/extract' }); + const { filter } = useSearch({ from: "/review/subtitles/extract" }); const navigate = useNavigate(); const [data, setData] = useState(cache.get(filter) ?? null); const [loading, setLoading] = useState(!cache.has(filter)); @@ -203,21 +263,33 @@ export function SubtitleExtractPage() { const load = () => { if (!cache.has(filter)) setLoading(true); - api.get(`/api/subtitles?filter=${filter}`) - .then((d) => { cache.set(filter, d); setData(d); }) + api + .get(`/api/subtitles?filter=${filter}`) + .then((d) => { + cache.set(filter, d); + setData(d); + }) .catch(() => {}) .finally(() => setLoading(false)); }; const loadEmbedded = () => { - api.get<{ embeddedCount: number }>('/api/subtitles/summary') + api + .get<{ embeddedCount: number }>("/api/subtitles/summary") .then((d) => setEmbeddedCount(d.embeddedCount)) .catch(() => {}); }; - useEffect(() => { load(); loadEmbedded(); }, [filter]); + useEffect(() => { + load(); + loadEmbedded(); + }, [load, loadEmbedded]); - const refresh = () => { cache.clear(); load(); loadEmbedded(); }; + const refresh = () => { + cache.clear(); + load(); + loadEmbedded(); + }; return (
@@ -229,7 +301,7 @@ export function SubtitleExtractPage() { tabs={FILTER_TABS} filter={filter} totalCounts={data?.totalCounts ?? {}} - onFilterChange={(key) => navigate({ to: '/review/subtitles/extract', search: { filter: key } as never })} + onFilterChange={(key) => navigate({ to: "/review/subtitles/extract", search: { filter: key } as never })} /> {loading && !data &&
Loading...
} @@ -247,20 +319,36 @@ export function SubtitleExtractPage() {
- + + + + + + + + + {data.movies.map((item) => ( - + ))} @@ -271,13 +359,25 @@ export function SubtitleExtractPage() { {data.series.length > 0 && ( <> -
0 ? 'mt-5' : 'mt-0'}`}> +
0 ? "mt-5" : "mt-0"}`} + > TV Series {data.series.length}
NameLangSubsFilesStatus
NameLangSubsFilesStatus
- - {item.name} + + + {item.name} + {item.year && ({item.year})} {langName(item.original_language)} {item.sub_count} {item.file_count} + +
- - {data.series.map((g) => )} + + + + + + + + + + {data.series.map((g) => ( + + ))}
SeriesLangSEpStatus
SeriesLangSEpStatus
diff --git a/src/features/subtitles/SubtitleListPage.tsx b/src/features/subtitles/SubtitleListPage.tsx index f2dfda2..adc3b67 100644 --- a/src/features/subtitles/SubtitleListPage.tsx +++ b/src/features/subtitles/SubtitleListPage.tsx @@ -1,14 +1,14 @@ -import { useState, useEffect } from 'react'; -import { api } from '~/shared/lib/api'; -import { Button } from '~/shared/components/ui/button'; -import { langName } from '~/shared/lib/lang'; -import type React from 'react'; +import type React from "react"; +import { useEffect, useState } from "react"; +import { Button } from "~/shared/components/ui/button"; +import { api } from "~/shared/lib/api"; +import { langName } from "~/shared/lib/lang"; // ─── Types ──────────────────────────────────────────────────────────────────── interface SummaryCategory { language: string | null; - variant: 'standard' | 'forced' | 'cc'; + variant: "standard" | "forced" | "cc"; streamCount: number; fileCount: number; } @@ -36,18 +36,22 @@ const Th = ({ children }: { children?: React.ReactNode }) => ( ); const Td = ({ children, className }: { children?: React.ReactNode; className?: string }) => ( - {children} + {children} ); // ─── Language summary table ─────────────────────────────────────────────────── function variantLabel(v: string): string { - if (v === 'forced') return 'Forced'; - if (v === 'cc') return 'CC'; - return 'Standard'; + if (v === "forced") return "Forced"; + if (v === "cc") return "CC"; + return "Standard"; } -function LanguageSummary({ categories, keepLanguages, onDelete }: { +function LanguageSummary({ + categories, + keepLanguages, + onDelete, +}: { categories: SummaryCategory[]; keepLanguages: string[]; onDelete: () => void; @@ -57,21 +61,21 @@ function LanguageSummary({ categories, keepLanguages, onDelete }: { const [checked, setChecked] = useState>(() => { const init: Record = {}; for (const cat of categories) { - const key = `${cat.language ?? '__null__'}|${cat.variant}`; + const key = `${cat.language ?? "__null__"}|${cat.variant}`; init[key] = cat.language !== null && keepSet.has(cat.language); } return init; }); const [deleting, setDeleting] = useState(false); - const [result, setResult] = useState(''); + const [result, setResult] = useState(""); if (categories.length === 0) return null; const toggle = (key: string) => setChecked((prev) => ({ ...prev, [key]: !prev[key] })); const uncheckedCategories = categories.filter((cat) => { - const key = `${cat.language ?? '__null__'}|${cat.variant}`; + const key = `${cat.language ?? "__null__"}|${cat.variant}`; return !checked[key] && cat.fileCount > 0; }); @@ -82,12 +86,14 @@ function LanguageSummary({ categories, keepLanguages, onDelete }: { variant: cat.variant, })); setDeleting(true); - setResult(''); + setResult(""); try { - const r = await api.post<{ ok: boolean; deleted: number }>('/api/subtitles/batch-delete', { categories: toDelete }); - setResult(`Deleted ${r.deleted} file${r.deleted !== 1 ? 's' : ''}.`); + const r = await api.post<{ ok: boolean; deleted: number }>("/api/subtitles/batch-delete", { categories: toDelete }); + setResult(`Deleted ${r.deleted} file${r.deleted !== 1 ? "s" : ""}.`); onDelete(); - } catch (e) { setResult(`Error: ${e}`); } + } catch (e) { + setResult(`Error: ${e}`); + } setDeleting(false); }; @@ -107,7 +113,7 @@ function LanguageSummary({ categories, keepLanguages, onDelete }: { {categories.map((cat) => { - const key = `${cat.language ?? '__null__'}|${cat.variant}`; + const key = `${cat.language ?? "__null__"}|${cat.variant}`; return ( @@ -129,17 +135,13 @@ function LanguageSummary({ categories, keepLanguages, onDelete }: {
- {uncheckedCategories.length > 0 && ( - {uncheckedCategories.reduce((sum, c) => sum + c.fileCount, 0)} file{uncheckedCategories.reduce((sum, c) => sum + c.fileCount, 0) !== 1 ? 's' : ''} will be removed + {uncheckedCategories.reduce((sum, c) => sum + c.fileCount, 0)} file + {uncheckedCategories.reduce((sum, c) => sum + c.fileCount, 0) !== 1 ? "s" : ""} will be removed )} {result && {result}} @@ -150,24 +152,23 @@ function LanguageSummary({ categories, keepLanguages, onDelete }: { // ─── Title harmonization ────────────────────────────────────────────────────── -function TitleHarmonization({ titles, onNormalize }: { - titles: SummaryTitle[]; - onNormalize: () => void; -}) { +function TitleHarmonization({ titles, onNormalize }: { titles: SummaryTitle[]; onNormalize: () => void }) { const [normalizing, setNormalizing] = useState(false); - const [result, setResult] = useState(''); + const [result, setResult] = useState(""); const nonCanonical = titles.filter((t) => !t.isCanonical); if (nonCanonical.length === 0) return null; const handleNormalizeAll = async () => { setNormalizing(true); - setResult(''); + setResult(""); try { - const r = await api.post<{ ok: boolean; normalized: number }>('/api/subtitles/normalize-titles'); - setResult(`Normalized ${r.normalized} stream${r.normalized !== 1 ? 's' : ''}.`); + const r = await api.post<{ ok: boolean; normalized: number }>("/api/subtitles/normalize-titles"); + setResult(`Normalized ${r.normalized} stream${r.normalized !== 1 ? "s" : ""}.`); onNormalize(); - } catch (e) { setResult(`Error: ${e}`); } + } catch (e) { + setResult(`Error: ${e}`); + } setNormalizing(false); }; @@ -181,7 +182,8 @@ function TitleHarmonization({ titles, onNormalize }: { return (
- Title Harmonization ({nonCanonical.length} non-canonical) + Title Harmonization{" "} + ({nonCanonical.length} non-canonical)
@@ -199,19 +201,13 @@ function TitleHarmonization({ titles, onNormalize }: { - + )), )} @@ -220,7 +216,7 @@ function TitleHarmonization({ titles, onNormalize }: {
{result && {result}}
@@ -240,13 +236,19 @@ export function SubtitleListPage() { const loadSummary = () => { if (!summaryCache) setLoading(true); - api.get('/api/subtitles/summary') - .then((d) => { summaryCache = d; setSummary(d); }) + api + .get("/api/subtitles/summary") + .then((d) => { + summaryCache = d; + setSummary(d); + }) .catch(() => {}) .finally(() => setLoading(false)); }; - useEffect(() => { loadSummary(); }, []); + useEffect(() => { + loadSummary(); + }, [loadSummary]); const refresh = () => { summaryCache = null; @@ -264,19 +266,20 @@ export function SubtitleListPage() {

Subtitle Manager

-
+
{hasFiles ? ( - {totalFiles} extracted file{totalFiles !== 1 ? 's' : ''} across {langCount} language{langCount !== 1 ? 's' : ''} — select which to keep below + + {totalFiles} extracted file{totalFiles !== 1 ? "s" : ""} across {langCount} language{langCount !== 1 ? "s" : ""} — + select which to keep below + ) : ( No extracted subtitle files yet. Extract subtitles first. )}
- +
diff --git a/src/index.css b/src/index.css index 2fa9c8f..fe59f57 100644 --- a/src/index.css +++ b/src/index.css @@ -1,6 +1,10 @@ @import "tailwindcss"; @layer base { - * { box-sizing: border-box; } - body { font-family: system-ui, -apple-system, sans-serif; } + * { + box-sizing: border-box; + } + body { + font-family: system-ui, -apple-system, sans-serif; + } } diff --git a/src/main.tsx b/src/main.tsx index 70a078d..0072405 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,17 +1,19 @@ -import './index.css'; -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; -import { RouterProvider, createRouter } from '@tanstack/react-router'; -import { routeTree } from './routeTree.gen'; +import "./index.css"; +import { createRouter, RouterProvider } from "@tanstack/react-router"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { routeTree } from "./routeTree.gen"; -const router = createRouter({ routeTree, defaultPreload: 'intent' }); +const router = createRouter({ routeTree, defaultPreload: "intent" }); -declare module '@tanstack/react-router' { - interface Register { router: typeof router; } +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } } -const root = document.getElementById('root'); -if (!root) throw new Error('No #root element found'); +const root = document.getElementById("root"); +if (!root) throw new Error("No #root element found"); createRoot(root).render( diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index a37e2bd..6a67ba6 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,7 +1,7 @@ -import { createRootRoute, Link, Outlet } from '@tanstack/react-router'; -import { useEffect, useState } from 'react'; -import { cn } from '~/shared/lib/utils'; -import { api } from '~/shared/lib/api'; +import { createRootRoute, Link, Outlet } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { api } from "~/shared/lib/api"; +import { cn } from "~/shared/lib/utils"; declare const __APP_VERSION__: string; @@ -13,8 +13,10 @@ function NavLink({ to, children }: { to: string; children: React.ReactNode }) { return ( {children} @@ -24,13 +26,25 @@ function NavLink({ to, children }: { to: string; children: React.ReactNode }) { function VersionBadge() { const [serverVersion, setServerVersion] = useState(null); - useEffect(() => { api.get<{ version: string }>('/api/version').then((d) => setServerVersion(d.version)).catch(() => {}); }, []); - const buildVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : null; + useEffect(() => { + api + .get<{ version: string }>("/api/version") + .then((d) => setServerVersion(d.version)) + .catch(() => {}); + }, []); + const buildVersion = typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : null; const mismatch = buildVersion && serverVersion && buildVersion !== serverVersion; return ( - - v{serverVersion ?? buildVersion ?? '?'} - {mismatch && } + + v{serverVersion ?? buildVersion ?? "?"} + {mismatch && ( + + ⚠ + + )} ); } @@ -66,4 +80,4 @@ function RootLayout() { ); } -import type React from 'react'; +import type React from "react"; diff --git a/src/routes/execute.tsx b/src/routes/execute.tsx index 53660e0..1d2a2d7 100644 --- a/src/routes/execute.tsx +++ b/src/routes/execute.tsx @@ -1,10 +1,10 @@ -import { createFileRoute } from '@tanstack/react-router'; -import { z } from 'zod'; -import { ExecutePage } from '~/features/execute/ExecutePage'; +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import { ExecutePage } from "~/features/execute/ExecutePage"; -export const Route = createFileRoute('/execute')({ +export const Route = createFileRoute("/execute")({ validateSearch: z.object({ - filter: z.enum(['all', 'pending', 'running', 'done', 'error']).default('pending'), + filter: z.enum(["all", "pending", "running", "done", "error"]).default("pending"), }), component: ExecutePage, }); diff --git a/src/routes/index.tsx b/src/routes/index.tsx index f850b61..b680af8 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,6 +1,6 @@ -import { createFileRoute } from '@tanstack/react-router'; -import { DashboardPage } from '~/features/dashboard/DashboardPage'; +import { createFileRoute } from "@tanstack/react-router"; +import { DashboardPage } from "~/features/dashboard/DashboardPage"; -export const Route = createFileRoute('/')({ +export const Route = createFileRoute("/")({ component: DashboardPage, }); diff --git a/src/routes/paths.tsx b/src/routes/paths.tsx index b2b819a..f898ab5 100644 --- a/src/routes/paths.tsx +++ b/src/routes/paths.tsx @@ -1,6 +1,6 @@ -import { createFileRoute } from '@tanstack/react-router'; -import { PathsPage } from '~/features/paths/PathsPage'; +import { createFileRoute } from "@tanstack/react-router"; +import { PathsPage } from "~/features/paths/PathsPage"; -export const Route = createFileRoute('/paths')({ +export const Route = createFileRoute("/paths")({ component: PathsPage, }); diff --git a/src/routes/pipeline.tsx b/src/routes/pipeline.tsx index 076b31b..6602d8f 100644 --- a/src/routes/pipeline.tsx +++ b/src/routes/pipeline.tsx @@ -1,6 +1,6 @@ -import { createFileRoute } from '@tanstack/react-router'; -import { PipelinePage } from '~/features/pipeline/PipelinePage'; +import { createFileRoute } from "@tanstack/react-router"; +import { PipelinePage } from "~/features/pipeline/PipelinePage"; -export const Route = createFileRoute('/pipeline')({ +export const Route = createFileRoute("/pipeline")({ component: PipelinePage, }); diff --git a/src/routes/review.tsx b/src/routes/review.tsx index 96b97ed..6e8fa21 100644 --- a/src/routes/review.tsx +++ b/src/routes/review.tsx @@ -1,5 +1,5 @@ -import { createFileRoute, Outlet } from '@tanstack/react-router'; +import { createFileRoute, Outlet } from "@tanstack/react-router"; -export const Route = createFileRoute('/review')({ +export const Route = createFileRoute("/review")({ component: () => , }); diff --git a/src/routes/review/audio/$id.tsx b/src/routes/review/audio/$id.tsx index 5ab1f26..d962527 100644 --- a/src/routes/review/audio/$id.tsx +++ b/src/routes/review/audio/$id.tsx @@ -1,6 +1,6 @@ -import { createFileRoute } from '@tanstack/react-router'; -import { AudioDetailPage } from '~/features/review/AudioDetailPage'; +import { createFileRoute } from "@tanstack/react-router"; +import { AudioDetailPage } from "~/features/review/AudioDetailPage"; -export const Route = createFileRoute('/review/audio/$id')({ +export const Route = createFileRoute("/review/audio/$id")({ component: AudioDetailPage, }); diff --git a/src/routes/review/audio/index.tsx b/src/routes/review/audio/index.tsx index 48442cb..1ebeda6 100644 --- a/src/routes/review/audio/index.tsx +++ b/src/routes/review/audio/index.tsx @@ -1,10 +1,10 @@ -import { createFileRoute } from '@tanstack/react-router'; -import { z } from 'zod'; -import { AudioListPage } from '~/features/review/AudioListPage'; +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import { AudioListPage } from "~/features/review/AudioListPage"; -export const Route = createFileRoute('/review/audio/')({ +export const Route = createFileRoute("/review/audio/")({ validateSearch: z.object({ - filter: z.enum(['all', 'needs_action', 'noop', 'manual', 'approved', 'skipped', 'done', 'error']).default('all'), + filter: z.enum(["all", "needs_action", "noop", "manual", "approved", "skipped", "done", "error"]).default("all"), }), component: AudioListPage, }); diff --git a/src/routes/review/index.tsx b/src/routes/review/index.tsx index 1b23c9b..8644144 100644 --- a/src/routes/review/index.tsx +++ b/src/routes/review/index.tsx @@ -1,5 +1,7 @@ -import { createFileRoute, redirect } from '@tanstack/react-router'; +import { createFileRoute, redirect } from "@tanstack/react-router"; -export const Route = createFileRoute('/review/')({ - beforeLoad: () => { throw redirect({ to: '/review/audio' }); }, +export const Route = createFileRoute("/review/")({ + beforeLoad: () => { + throw redirect({ to: "/review/audio" }); + }, }); diff --git a/src/routes/review/subtitles/$id.tsx b/src/routes/review/subtitles/$id.tsx index b073840..26062aa 100644 --- a/src/routes/review/subtitles/$id.tsx +++ b/src/routes/review/subtitles/$id.tsx @@ -1,6 +1,6 @@ -import { createFileRoute } from '@tanstack/react-router'; -import { SubtitleDetailPage } from '~/features/subtitles/SubtitleDetailPage'; +import { createFileRoute } from "@tanstack/react-router"; +import { SubtitleDetailPage } from "~/features/subtitles/SubtitleDetailPage"; -export const Route = createFileRoute('/review/subtitles/$id')({ +export const Route = createFileRoute("/review/subtitles/$id")({ component: SubtitleDetailPage, }); diff --git a/src/routes/review/subtitles/extract.tsx b/src/routes/review/subtitles/extract.tsx index c146027..c861675 100644 --- a/src/routes/review/subtitles/extract.tsx +++ b/src/routes/review/subtitles/extract.tsx @@ -1,10 +1,10 @@ -import { createFileRoute } from '@tanstack/react-router'; -import { z } from 'zod'; -import { SubtitleExtractPage } from '~/features/subtitles/SubtitleExtractPage'; +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import { SubtitleExtractPage } from "~/features/subtitles/SubtitleExtractPage"; -export const Route = createFileRoute('/review/subtitles/extract')({ +export const Route = createFileRoute("/review/subtitles/extract")({ validateSearch: z.object({ - filter: z.enum(['all', 'not_extracted', 'extracted', 'no_subs']).default('not_extracted'), + filter: z.enum(["all", "not_extracted", "extracted", "no_subs"]).default("not_extracted"), }), component: SubtitleExtractPage, }); diff --git a/src/routes/review/subtitles/index.tsx b/src/routes/review/subtitles/index.tsx index 974e7ff..c0b7724 100644 --- a/src/routes/review/subtitles/index.tsx +++ b/src/routes/review/subtitles/index.tsx @@ -1,6 +1,6 @@ -import { createFileRoute } from '@tanstack/react-router'; -import { SubtitleListPage } from '~/features/subtitles/SubtitleListPage'; +import { createFileRoute } from "@tanstack/react-router"; +import { SubtitleListPage } from "~/features/subtitles/SubtitleListPage"; -export const Route = createFileRoute('/review/subtitles/')({ +export const Route = createFileRoute("/review/subtitles/")({ component: SubtitleListPage, }); diff --git a/src/routes/scan.tsx b/src/routes/scan.tsx index 346bf7e..6f40e7a 100644 --- a/src/routes/scan.tsx +++ b/src/routes/scan.tsx @@ -1,6 +1,6 @@ -import { createFileRoute } from '@tanstack/react-router'; -import { ScanPage } from '~/features/scan/ScanPage'; +import { createFileRoute } from "@tanstack/react-router"; +import { ScanPage } from "~/features/scan/ScanPage"; -export const Route = createFileRoute('/scan')({ +export const Route = createFileRoute("/scan")({ component: ScanPage, }); diff --git a/src/routes/settings.tsx b/src/routes/settings.tsx index 8a9313a..fd96734 100644 --- a/src/routes/settings.tsx +++ b/src/routes/settings.tsx @@ -1,6 +1,6 @@ -import { createFileRoute } from '@tanstack/react-router'; -import { SetupPage } from '~/features/setup/SetupPage'; +import { createFileRoute } from "@tanstack/react-router"; +import { SetupPage } from "~/features/setup/SetupPage"; -export const Route = createFileRoute('/settings')({ +export const Route = createFileRoute("/settings")({ component: SetupPage, }); diff --git a/src/shared/components/ui/alert.tsx b/src/shared/components/ui/alert.tsx index 116611e..27250b2 100644 --- a/src/shared/components/ui/alert.tsx +++ b/src/shared/components/ui/alert.tsx @@ -1,20 +1,20 @@ -import type React from 'react'; -import { cn } from '~/shared/lib/utils'; +import type React from "react"; +import { cn } from "~/shared/lib/utils"; const variants = { - info: 'bg-cyan-50 text-cyan-800 border border-cyan-200', - warning: 'bg-amber-50 text-amber-800 border border-amber-200', - error: 'bg-red-50 text-red-800 border border-red-200', - success: 'bg-green-50 text-green-800 border border-green-200', + info: "bg-cyan-50 text-cyan-800 border border-cyan-200", + warning: "bg-amber-50 text-amber-800 border border-amber-200", + error: "bg-red-50 text-red-800 border border-red-200", + success: "bg-green-50 text-green-800 border border-green-200", } as const; interface AlertProps extends React.HTMLAttributes { variant?: keyof typeof variants; } -export function Alert({ variant = 'info', className, children, ...props }: AlertProps) { +export function Alert({ variant = "info", className, children, ...props }: AlertProps) { return ( -
+
{children}
); diff --git a/src/shared/components/ui/badge.tsx b/src/shared/components/ui/badge.tsx index c6952c9..b1c939f 100644 --- a/src/shared/components/ui/badge.tsx +++ b/src/shared/components/ui/badge.tsx @@ -1,28 +1,28 @@ -import { cn } from '~/shared/lib/utils'; +import { cn } from "~/shared/lib/utils"; const variants = { - default: 'bg-gray-100 text-gray-600', - keep: 'bg-green-100 text-green-800', - remove: 'bg-red-100 text-red-800', - pending: 'bg-gray-200 text-gray-600', - approved: 'bg-green-100 text-green-800', - skipped: 'bg-gray-200 text-gray-600', - done: 'bg-cyan-100 text-cyan-800', - error: 'bg-red-100 text-red-800', - noop: 'bg-gray-200 text-gray-600', - running: 'bg-amber-100 text-amber-800', - manual: 'bg-orange-100 text-orange-800', + default: "bg-gray-100 text-gray-600", + keep: "bg-green-100 text-green-800", + remove: "bg-red-100 text-red-800", + pending: "bg-gray-200 text-gray-600", + approved: "bg-green-100 text-green-800", + skipped: "bg-gray-200 text-gray-600", + done: "bg-cyan-100 text-cyan-800", + error: "bg-red-100 text-red-800", + noop: "bg-gray-200 text-gray-600", + running: "bg-amber-100 text-amber-800", + manual: "bg-orange-100 text-orange-800", } as const; interface BadgeProps extends React.HTMLAttributes { variant?: keyof typeof variants; } -export function Badge({ variant = 'default', className, children, ...props }: BadgeProps) { +export function Badge({ variant = "default", className, children, ...props }: BadgeProps) { return ( { - variant?: 'primary' | 'secondary' | 'danger'; - size?: 'default' | 'sm' | 'xs'; + variant?: "primary" | "secondary" | "danger"; + size?: "default" | "sm" | "xs"; } -export function Button({ variant = 'primary', size = 'default', className, ...props }: ButtonProps) { +export function Button({ variant = "primary", size = "default", className, ...props }: ButtonProps) { return ( ); })} diff --git a/src/shared/components/ui/input.tsx b/src/shared/components/ui/input.tsx index 46af396..7c7a13a 100644 --- a/src/shared/components/ui/input.tsx +++ b/src/shared/components/ui/input.tsx @@ -1,13 +1,13 @@ -import type React from 'react'; -import { cn } from '~/shared/lib/utils'; +import type React from "react"; +import { cn } from "~/shared/lib/utils"; export function Input({ className, ...props }: React.InputHTMLAttributes) { return ( ) { return (
{langName(lang)} - - {t.title ? `"${t.title}"` : '(none)'} + + {t.title ? `"${t.title}"` : "(none)"} {t.isCanonical && (canonical)} {t.count} - {!t.isCanonical && ( - - will normalize - - )} - {!t.isCanonical && will normalize}