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
This commit is contained in:
2026-04-13 07:41:19 +02:00
parent f11861658e
commit 874f04b7a5
69 changed files with 3511 additions and 2232 deletions

View File

@@ -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 }, "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"organizeImports": { "enabled": true }, "assist": { "actions": { "source": { "organizeImports": "on" } } },
"formatter": { "formatter": {
"enabled": true, "enabled": true,
"indentStyle": "tab", "indentStyle": "tab",
@@ -12,11 +12,26 @@
"enabled": true, "enabled": true,
"rules": { "rules": {
"recommended": true, "recommended": true,
"suspicious": { "noExplicitAny": "off" }, "suspicious": {
"style": { "noNonNullAssertion": "off" } "noExplicitAny": "off",
"noArrayIndexKey": "off"
},
"style": {
"noNonNullAssertion": "off"
},
"correctness": {
"useExhaustiveDependencies": "off",
"noInvalidUseBeforeDeclaration": "off"
},
"a11y": {
"useButtonType": "off",
"noLabelWithoutControl": "off",
"noStaticElementInteractions": "off",
"useKeyWithClickEvents": "off"
}
} }
}, },
"files": { "files": {
"ignore": ["node_modules", "dist", "src/routeTree.gen.ts"] "includes": ["**", "!**/node_modules", "!**/dist", "!**/src/routeTree.gen.ts"]
} }
} }

View File

@@ -1,22 +1,32 @@
import { Hono } from 'hono'; import { Hono } from "hono";
import { getDb, getConfig } from '../db/index'; import { getConfig, getDb } from "../db/index";
const app = new Hono(); const app = new Hono();
app.get('/', (c) => { app.get("/", (c) => {
const db = getDb(); const db = getDb();
const totalItems = (db.prepare('SELECT COUNT(*) as n FROM media_items').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 scanned = (
const needsAction = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number }).n; db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'scanned'").get() as { n: number }
const noChange = (db.prepare('SELECT COUNT(*) as n FROM review_plans WHERE is_noop = 1').get() as { n: number }).n; ).n;
const approved = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'approved'").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 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 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 scanRunning = getConfig("scan_running") === "1";
const setupComplete = getConfig('setup_complete') === '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; export default app;

View File

@@ -1,11 +1,19 @@
import { Hono } from 'hono'; import { accessSync, constants } from "node:fs";
import { stream } from 'hono/streaming'; import { Hono } from "hono";
import { getDb } from '../db/index'; import { stream } from "hono/streaming";
import type { Job, MediaItem, MediaStream } from '../types'; import { getDb } from "../db/index";
import { predictExtractedFiles } from '../services/ffmpeg'; import { log, error as logError } from "../lib/log";
import { accessSync, constants } from 'node:fs'; import { predictExtractedFiles } from "../services/ffmpeg";
import { log, error as logError } from '../lib/log'; import {
import { getSchedulerState, updateSchedulerState } from '../services/scheduler'; getSchedulerState,
isInScheduleWindow,
msUntilWindow,
nextWindowTime,
sleepBetweenJobs,
updateSchedulerState,
waitForWindow,
} from "../services/scheduler";
import type { Job, MediaItem, MediaStream } from "../types";
const app = new Hono(); const app = new Hono();
@@ -13,17 +21,45 @@ const app = new Hono();
let queueRunning = false; 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<void> { async function runSequential(jobs: Job[]): Promise<void> {
if (queueRunning) return; if (queueRunning) return;
queueRunning = true; queueRunning = true;
try { try {
let first = true;
for (const job of jobs) { 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 // Atomic claim: only pick up jobs still pending
const db = getDb(); const db = getDb();
const claimed = db 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); .run(job.id);
if (claimed.changes === 0) continue; // cancelled or already running if (claimed.changes === 0) continue; // cancelled or already running
emitQueueStatus("running");
try { try {
await runJob(job); await runJob(job);
} catch (err) { } catch (err) {
@@ -32,6 +68,7 @@ async function runSequential(jobs: Job[]): Promise<void> {
} }
} finally { } finally {
queueRunning = false; queueRunning = false;
emitQueueStatus("idle");
} }
} }
@@ -59,49 +96,89 @@ function parseFFmpegDuration(line: string): number | null {
function loadJobRow(jobId: number) { function loadJobRow(jobId: number) {
const db = getDb(); 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, SELECT j.*, mi.id as mi_id, mi.name, mi.type, mi.series_name, mi.season_number,
mi.episode_number, mi.file_path mi.episode_number, mi.file_path
FROM jobs j FROM jobs j
LEFT JOIN media_items mi ON mi.id = j.item_id LEFT JOIN media_items mi ON mi.id = j.item_id
WHERE j.id = ? WHERE j.id = ?
`).get(jobId) as (Job & { `)
mi_id: number | null; name: string | null; type: string | null; .get(jobId) as
series_name: string | null; season_number: number | null; episode_number: number | null; | (Job & {
file_path: string | null; mi_id: number | null;
}) | undefined; 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; 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 }; return { job: row as unknown as Job, item };
} }
// ─── List ───────────────────────────────────────────────────────────────────── // ─── List ─────────────────────────────────────────────────────────────────────
app.get('/', (c) => { app.get("/", (c) => {
const db = getDb(); 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 validFilters = ["all", "pending", "running", "done", "error"];
const whereClause = validFilters.includes(filter) && filter !== 'all' ? `WHERE j.status = ?` : ''; const whereClause = validFilters.includes(filter) && filter !== "all" ? `WHERE j.status = ?` : "";
const params = whereClause ? [filter] : []; 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 SELECT j.*, mi.name, mi.type, mi.series_name, mi.season_number, mi.episode_number, mi.file_path
FROM jobs j FROM jobs j
LEFT JOIN media_items mi ON mi.id = j.item_id LEFT JOIN media_items mi ON mi.id = j.item_id
${whereClause} ${whereClause}
ORDER BY j.created_at DESC ORDER BY j.created_at DESC
LIMIT 200 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) => ({ const jobs = jobRows.map((r) => ({
job: r as unknown as Job, 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<string, number> = { all: 0, pending: 0, running: 0, done: 0, error: 0 }; const totalCounts: Record<string, number> = { all: 0, pending: 0, running: 0, done: 0, error: 0 };
for (const row of countRows) { for (const row of countRows) {
totalCounts[row.status] = row.cnt; totalCounts[row.status] = row.cnt;
@@ -121,22 +198,22 @@ function parseId(raw: string | undefined): number | null {
// ─── Start all pending ──────────────────────────────────────────────────────── // ─── Start all pending ────────────────────────────────────────────────────────
app.post('/start', (c) => { app.post("/start", (c) => {
const db = getDb(); const db = getDb();
const pending = db.prepare("SELECT * FROM jobs WHERE status = 'pending' ORDER BY created_at").all() as Job[]; 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 }); return c.json({ ok: true, started: pending.length });
}); });
// ─── Run single ─────────────────────────────────────────────────────────────── // ─── Run single ───────────────────────────────────────────────────────────────
app.post('/job/:id/run', async (c) => { app.post("/job/:id/run", async (c) => {
const jobId = parseId(c.req.param('id')); const jobId = parseId(c.req.param("id"));
if (jobId == null) return c.json({ error: 'invalid job id' }, 400); if (jobId == null) return c.json({ error: "invalid job id" }, 400);
const db = getDb(); 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) return c.notFound();
if (job.status !== 'pending') { if (job.status !== "pending") {
const result = loadJobRow(jobId); const result = loadJobRow(jobId);
if (!result) return c.notFound(); if (!result) return c.notFound();
return c.json(result); return c.json(result);
@@ -149,9 +226,9 @@ app.post('/job/:id/run', async (c) => {
// ─── Cancel ─────────────────────────────────────────────────────────────────── // ─── Cancel ───────────────────────────────────────────────────────────────────
app.post('/job/:id/cancel', (c) => { app.post("/job/:id/cancel", (c) => {
const jobId = parseId(c.req.param('id')); const jobId = parseId(c.req.param("id"));
if (jobId == null) return c.json({ error: 'invalid job id' }, 400); if (jobId == null) return c.json({ error: "invalid job id" }, 400);
const db = getDb(); const db = getDb();
db.prepare("DELETE FROM jobs WHERE id = ? AND status = 'pending'").run(jobId); db.prepare("DELETE FROM jobs WHERE id = ? AND status = 'pending'").run(jobId);
return c.json({ ok: true }); return c.json({ ok: true });
@@ -159,18 +236,20 @@ app.post('/job/:id/cancel', (c) => {
// ─── Clear queue ────────────────────────────────────────────────────────────── // ─── Clear queue ──────────────────────────────────────────────────────────────
app.post('/clear', (c) => { app.post("/clear", (c) => {
const db = getDb(); const db = getDb();
db.prepare(` db
.prepare(`
UPDATE review_plans SET status = 'pending', reviewed_at = NULL UPDATE review_plans SET status = 'pending', reviewed_at = NULL
WHERE item_id IN (SELECT item_id FROM jobs WHERE status = 'pending') WHERE item_id IN (SELECT item_id FROM jobs WHERE status = 'pending')
AND status = 'approved' AND status = 'approved'
`).run(); `)
.run();
const result = db.prepare("DELETE FROM jobs WHERE status = 'pending'").run(); const result = db.prepare("DELETE FROM jobs WHERE status = 'pending'").run();
return c.json({ ok: true, cleared: result.changes }); return c.json({ ok: true, cleared: result.changes });
}); });
app.post('/clear-completed', (c) => { app.post("/clear-completed", (c) => {
const db = getDb(); const db = getDb();
const result = db.prepare("DELETE FROM jobs WHERE status IN ('done', 'error')").run(); const result = db.prepare("DELETE FROM jobs WHERE status IN ('done', 'error')").run();
return c.json({ ok: true, cleared: result.changes }); return c.json({ ok: true, cleared: result.changes });
@@ -178,26 +257,34 @@ app.post('/clear-completed', (c) => {
// ─── SSE ────────────────────────────────────────────────────────────────────── // ─── SSE ──────────────────────────────────────────────────────────────────────
app.get('/events', (c) => { app.get("/events", (c) => {
return stream(c, async (s) => { return stream(c, async (s) => {
c.header('Content-Type', 'text/event-stream'); c.header("Content-Type", "text/event-stream");
c.header('Cache-Control', 'no-cache'); c.header("Cache-Control", "no-cache");
const queue: string[] = []; const queue: string[] = [];
let resolve: (() => void) | null = null; let resolve: (() => void) | null = null;
const listener = (data: string) => { queue.push(data); resolve?.(); }; const listener = (data: string) => {
queue.push(data);
resolve?.();
};
jobListeners.add(listener); jobListeners.add(listener);
s.onAbort(() => { jobListeners.delete(listener); }); s.onAbort(() => {
jobListeners.delete(listener);
});
try { try {
while (!s.closed) { while (!s.closed) {
if (queue.length > 0) { if (queue.length > 0) {
await s.write(queue.shift()!); await s.write(queue.shift()!);
} else { } else {
await new Promise<void>((res) => { resolve = res; setTimeout(res, 15_000); }); await new Promise<void>((res) => {
resolve = res;
setTimeout(res, 15_000);
});
resolve = null; resolve = null;
if (queue.length === 0) await s.write(': keepalive\n\n'); if (queue.length === 0) await s.write(": keepalive\n\n");
} }
} }
} finally { } finally {
@@ -213,30 +300,34 @@ async function runJob(job: Job): Promise<void> {
log(`Job ${job.id} command: ${job.command}`); log(`Job ${job.id} command: ${job.command}`);
const db = getDb(); 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) { if (itemRow?.file_path) {
try { try {
accessSync(itemRow.file_path, constants.R_OK | constants.W_OK); accessSync(itemRow.file_path, constants.R_OK | constants.W_OK);
} catch (fsErr) { } catch (fsErr) {
const msg = `File not accessible: ${itemRow.file_path}\n${(fsErr as Error).message}`; 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); db
emitJobUpdate(job.id, 'error', msg); .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); db.prepare("UPDATE review_plans SET status = 'error' WHERE item_id = ?").run(job.item_id);
return; return;
} }
} }
emitJobUpdate(job.id, 'running'); emitJobUpdate(job.id, "running");
const outputLines: string[] = []; const outputLines: string[] = [];
let pendingFlush = false; let pendingFlush = false;
let lastFlushAt = 0; let lastFlushAt = 0;
let totalSeconds = 0; let totalSeconds = 0;
let lastProgressEmit = 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 flush = (final = false) => {
const text = outputLines.join('\n'); const text = outputLines.join("\n");
const now = Date.now(); const now = Date.now();
if (final || now - lastFlushAt > 500) { if (final || now - lastFlushAt > 500) {
updateOutput.run(text, job.id); updateOutput.run(text, job.id);
@@ -245,7 +336,7 @@ async function runJob(job: Job): Promise<void> {
} else { } else {
pendingFlush = true; pendingFlush = true;
} }
emitJobUpdate(job.id, 'running', text); emitJobUpdate(job.id, "running", text);
}; };
const consumeProgress = (line: string) => { const consumeProgress = (line: string) => {
@@ -264,18 +355,18 @@ async function runJob(job: Job): Promise<void> {
}; };
try { try {
const proc = Bun.spawn(['sh', '-c', job.command], { stdout: 'pipe', stderr: 'pipe' }); const proc = Bun.spawn(["sh", "-c", job.command], { stdout: "pipe", stderr: "pipe" });
const readStream = async (readable: ReadableStream<Uint8Array>, prefix = '') => { const readStream = async (readable: ReadableStream<Uint8Array>, prefix = "") => {
const reader = readable.getReader(); const reader = readable.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ''; let buffer = "";
try { try {
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
buffer += decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true });
const parts = buffer.split(/\r\n|\n|\r/); const parts = buffer.split(/\r\n|\n|\r/);
buffer = parts.pop() ?? ''; buffer = parts.pop() ?? "";
for (const line of parts) { for (const line of parts) {
if (!line.trim()) continue; if (!line.trim()) continue;
outputLines.push(prefix + line); outputLines.push(prefix + line);
@@ -288,25 +379,29 @@ async function runJob(job: Job): Promise<void> {
consumeProgress(buffer); consumeProgress(buffer);
} }
} catch (err) { } 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; 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}`); 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 // Gather sidecar files to record
const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(job.item_id) as MediaItem | undefined; 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 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 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 insertFile = db.prepare(
const markJobDone = db.prepare("UPDATE jobs SET status = 'done', exit_code = 0, output = ?, completed_at = datetime('now') WHERE id = ?"); "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 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(() => { db.transaction(() => {
markJobDone.run(fullOutput, job.id); markJobDone.run(fullOutput, job.id);
@@ -318,23 +413,25 @@ async function runJob(job: Job): Promise<void> {
})(); })();
log(`Job ${job.id} completed successfully`); log(`Job ${job.id} completed successfully`);
emitJobUpdate(job.id, 'done', fullOutput); emitJobUpdate(job.id, "done", fullOutput);
} catch (err) { } catch (err) {
logError(`Job ${job.id} failed:`, err); logError(`Job ${job.id} failed:`, err);
const fullOutput = outputLines.join('\n') + '\n' + String(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); db
emitJobUpdate(job.id, 'error', fullOutput); .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); db.prepare("UPDATE review_plans SET status = 'error' WHERE item_id = ?").run(job.item_id);
} }
} }
// ─── Scheduler ──────────────────────────────────────────────────────────────── // ─── Scheduler ────────────────────────────────────────────────────────────────
app.get('/scheduler', (c) => { app.get("/scheduler", (c) => {
return c.json(getSchedulerState()); return c.json(getSchedulerState());
}); });
app.patch('/scheduler', async (c) => { app.patch("/scheduler", async (c) => {
const body = await c.req.json(); const body = await c.req.json();
updateSchedulerState(body); updateSchedulerState(body);
return c.json(getSchedulerState()); return c.json(getSchedulerState());

View File

@@ -1,6 +1,6 @@
import { existsSync } from 'node:fs'; import { existsSync } from "node:fs";
import { Hono } from 'hono'; import { Hono } from "hono";
import { getDb } from '../db/index'; import { getDb } from "../db/index";
const app = new Hono(); const app = new Hono();
@@ -10,7 +10,7 @@ interface PathInfo {
accessible: boolean; accessible: boolean;
} }
app.get('/', (c) => { app.get("/", (c) => {
const db = getDb(); const db = getDb();
const rows = db const rows = db
.query<{ prefix: string; count: number }, []>( .query<{ prefix: string; count: number }, []>(

View File

@@ -1,62 +1,96 @@
import { Hono } from 'hono'; import { Hono } from "hono";
import { getDb, getConfig, getAllConfig } from '../db/index'; import { getAllConfig, getConfig, getDb } from "../db/index";
import { analyzeItem, assignTargetOrder } from '../services/analyzer'; import { isOneOf, parseId } from "../lib/validate";
import { buildCommand } from '../services/ffmpeg'; import { analyzeItem, assignTargetOrder } from "../services/analyzer";
import { normalizeLanguage, getItem, refreshItem, mapStream } from '../services/jellyfin'; import { buildCommand } from "../services/ffmpeg";
import { parseId, isOneOf } from '../lib/validate'; import { getItem, mapStream, normalizeLanguage, refreshItem } from "../services/jellyfin";
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from '../types'; import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from "../types";
const app = new Hono(); const app = new Hono();
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
function getSubtitleLanguages(): string[] { 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<typeof getDb>): Record<string, number> { function countsByFilter(db: ReturnType<typeof getDb>): Record<string, number> {
const total = (db.prepare('SELECT COUNT(*) as n FROM review_plans').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 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 pending = (
const approved = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'approved'").get() as { n: number }).n; db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number }
const skipped = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'skipped'").get() as { n: number }).n; ).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 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 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 }; return { all: total, needs_action: pending, noop: noops, approved, skipped, done, error, manual };
} }
function buildWhereClause(filter: string): string { function buildWhereClause(filter: string): string {
switch (filter) { switch (filter) {
case 'needs_action': return "rp.status = 'pending' AND rp.is_noop = 0"; case "needs_action":
case 'noop': return 'rp.is_noop = 1'; return "rp.status = 'pending' AND rp.is_noop = 0";
case 'manual': return 'mi.needs_review = 1 AND mi.original_language IS NULL'; case "noop":
case 'approved': return "rp.status = 'approved'"; return "rp.is_noop = 1";
case 'skipped': return "rp.status = 'skipped'"; case "manual":
case 'done': return "rp.status = 'done'"; return "mi.needs_review = 1 AND mi.original_language IS NULL";
case 'error': return "rp.status = 'error'"; case "approved":
default: return '1=1'; 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 & { type RawRow = MediaItem & {
plan_id: number | null; plan_status: string | null; is_noop: number | null; plan_id: number | null;
plan_notes: string | null; reviewed_at: string | null; plan_created_at: string | null; plan_status: string | null;
remove_count: number; keep_count: number; 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 { function rowToPlan(r: RawRow): ReviewPlan | null {
if (r.plan_id == null) return 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<typeof getDb>, itemId: number) { function loadItemDetail(db: ReturnType<typeof getDb>, 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 }; 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 streams = db
const plan = db.prepare('SELECT * FROM review_plans WHERE item_id = ?').get(itemId) as ReviewPlan | undefined | null; .prepare("SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index")
const decisions = plan ? db.prepare('SELECT * FROM stream_decisions WHERE plan_id = ?').all(plan.id) as StreamDecision[] : []; .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; const command = plan && !plan.is_noop ? buildCommand(item, streams, decisions) : null;
@@ -69,36 +103,57 @@ function loadItemDetail(db: ReturnType<typeof getDb>, itemId: number) {
* survive stream-id changes when Jellyfin re-probes metadata. * survive stream-id changes when Jellyfin re-probes metadata.
*/ */
function titleKey(s: { type: string; language: string | null; stream_index: number; title: string | null }): string { 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<typeof getDb>, itemId: number, preservedTitles?: Map<string, string>): void { function reanalyze(db: ReturnType<typeof getDb>, itemId: number, preservedTitles?: Map<string, string>): 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; 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 subtitleLanguages = getSubtitleLanguages();
const audioLanguages: string[] = JSON.parse(getConfig('audio_languages') ?? '[]'); 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 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) INSERT INTO review_plans (item_id, status, is_noop, confidence, apple_compat, job_type, notes)
VALUES (?, 'pending', ?, ?, ?, ?, ?) 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 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); // Preserve existing custom_titles: prefer by stream_id (streams unchanged);
// fall back to titleKey match (streams regenerated after rescan). // fall back to titleKey match (streams regenerated after rescan).
const byStreamId = new Map<number, string | null>( const byStreamId = new Map<number, string | null>(
(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); 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 (?, ?, ?, ?, ?, ?)'); 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) { for (const dec of analysis.decisions) {
let customTitle = byStreamId.get(dec.stream_id) ?? null; let customTitle = byStreamId.get(dec.stream_id) ?? null;
if (!customTitle && preservedTitles) { if (!customTitle && preservedTitles) {
@@ -114,50 +169,68 @@ function reanalyze(db: ReturnType<typeof getDb>, itemId: number, preservedTitles
* recompute is_noop without wiping user-chosen actions or custom_titles. * recompute is_noop without wiping user-chosen actions or custom_titles.
*/ */
function recomputePlanAfterToggle(db: ReturnType<typeof getDb>, itemId: number): void { function recomputePlanAfterToggle(db: ReturnType<typeof getDb>, 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; 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
const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number } | undefined; .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; 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 { const decisions = db
stream_id: number; action: 'keep' | 'remove'; target_index: number | null; transcode_codec: string | null .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 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 // 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); 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); 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 // 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 anyAudioRemoved = streams.some(
const hasSubs = streams.some(s => s.type === 'Subtitle'); (s) => s.type === "Audio" && decWithIdx.find((d) => d.stream_id === s.id)?.action === "remove",
const needsTranscode = decWithIdx.some(d => d.transcode_codec != null && d.action === 'keep'); );
const hasSubs = streams.some((s) => s.type === "Subtitle");
const needsTranscode = decWithIdx.some((d) => d.transcode_codec != null && d.action === "keep");
const keptAudio = streams 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); .sort((a, b) => a.stream_index - b.stream_index);
let audioOrderChanged = false; let audioOrderChanged = false;
for (let i = 0; i < keptAudio.length; i++) { for (let i = 0; i < keptAudio.length; i++) {
const dec = decWithIdx.find(d => d.stream_id === keptAudio[i].id); const dec = decWithIdx.find((d) => d.stream_id === keptAudio[i].id);
if (dec?.target_index !== i) { audioOrderChanged = true; break; } if (dec?.target_index !== i) {
audioOrderChanged = true;
break;
}
} }
const isNoop = !anyAudioRemoved && !audioOrderChanged && !hasSubs && !needsTranscode; 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 ─────────────────────────────────────────────────────── // ─── Pipeline: summary ───────────────────────────────────────────────────────
app.get('/pipeline', (c) => { app.get("/pipeline", (c) => {
const db = getDb(); 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, SELECT rp.*, mi.name, mi.series_name, mi.series_jellyfin_id,
mi.jellyfin_id, mi.jellyfin_id,
mi.season_number, mi.episode_number, mi.type, mi.container, 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, CASE rp.confidence WHEN 'high' THEN 0 ELSE 1 END,
COALESCE(mi.series_name, mi.name), COALESCE(mi.series_name, mi.name),
mi.season_number, mi.episode_number 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, SELECT j.*, mi.name, mi.series_name, mi.type,
rp.job_type, rp.apple_compat rp.job_type, rp.apple_compat
FROM jobs j FROM jobs j
@@ -179,18 +254,22 @@ app.get('/pipeline', (c) => {
JOIN review_plans rp ON rp.item_id = j.item_id JOIN review_plans rp ON rp.item_id = j.item_id
WHERE j.status = 'pending' WHERE j.status = 'pending'
ORDER BY j.created_at ORDER BY j.created_at
`).all(); `)
.all();
const processing = db.prepare(` const processing = db
.prepare(`
SELECT j.*, mi.name, mi.series_name, mi.type, SELECT j.*, mi.name, mi.series_name, mi.type,
rp.job_type, rp.apple_compat rp.job_type, rp.apple_compat
FROM jobs j FROM jobs j
JOIN media_items mi ON mi.id = j.item_id JOIN media_items mi ON mi.id = j.item_id
JOIN review_plans rp ON rp.item_id = j.item_id JOIN review_plans rp ON rp.item_id = j.item_id
WHERE j.status = 'running' WHERE j.status = 'running'
`).all(); `)
.all();
const done = db.prepare(` const done = db
.prepare(`
SELECT j.*, mi.name, mi.series_name, mi.type, SELECT j.*, mi.name, mi.series_name, mi.type,
rp.job_type, rp.apple_compat rp.job_type, rp.apple_compat
FROM jobs j FROM jobs j
@@ -199,24 +278,27 @@ app.get('/pipeline', (c) => {
WHERE j.status IN ('done', 'error') WHERE j.status IN ('done', 'error')
ORDER BY j.completed_at DESC ORDER BY j.completed_at DESC
LIMIT 50 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) // 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<number, string[]>(); const reasonsByPlan = new Map<number, string[]>();
if (planIds.length > 0) { if (planIds.length > 0) {
const placeholders = planIds.map(() => '?').join(','); const placeholders = planIds.map(() => "?").join(",");
const allReasons = db.prepare(` const allReasons = db
.prepare(`
SELECT DISTINCT sd.plan_id, ms.codec, sd.transcode_codec SELECT DISTINCT sd.plan_id, ms.codec, sd.transcode_codec
FROM stream_decisions sd FROM stream_decisions sd
JOIN media_streams ms ON ms.id = sd.stream_id JOIN media_streams ms ON ms.id = sd.stream_id
WHERE sd.plan_id IN (${placeholders}) AND sd.transcode_codec IS NOT NULL 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) { for (const r of allReasons) {
if (!reasonsByPlan.has(r.plan_id)) reasonsByPlan.set(r.plan_id, []); 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[] }[]) { for (const item of review as { id: number; transcode_reasons?: string[] }[]) {
@@ -228,12 +310,13 @@ app.get('/pipeline', (c) => {
// ─── List ───────────────────────────────────────────────────────────────────── // ─── List ─────────────────────────────────────────────────────────────────────
app.get('/', (c) => { app.get("/", (c) => {
const db = getDb(); const db = getDb();
const filter = c.req.query('filter') ?? 'all'; const filter = c.req.query("filter") ?? "all";
const where = buildWhereClause(filter); 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, 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, rp.reviewed_at, rp.created_at as plan_created_at,
COUNT(CASE WHEN sd.action = 'remove' THEN 1 END) as remove_count, 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 LEFT JOIN stream_decisions sd ON sd.plan_id = rp.id
WHERE mi.type = 'Movie' AND ${where} WHERE mi.type = 'Movie' AND ${where}
GROUP BY mi.id ORDER BY mi.name LIMIT 500 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, SELECT COALESCE(mi.series_jellyfin_id, mi.series_name) as series_key, mi.series_name,
MAX(mi.original_language) as original_language, MAX(mi.original_language) as original_language,
COUNT(DISTINCT mi.season_number) as season_count, COUNT(mi.id) as episode_count, 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 LEFT JOIN review_plans rp ON rp.item_id = mi.id
WHERE mi.type = 'Episode' AND ${where} WHERE mi.type = 'Episode' AND ${where}
GROUP BY series_key ORDER BY mi.series_name GROUP BY series_key ORDER BY mi.series_name
`).all(); `)
.all();
const totalCounts = countsByFilter(db); const totalCounts = countsByFilter(db);
return c.json({ movies, series, filter, totalCounts }); return c.json({ movies, series, filter, totalCounts });
@@ -270,11 +361,12 @@ app.get('/', (c) => {
// ─── Series episodes ────────────────────────────────────────────────────────── // ─── Series episodes ──────────────────────────────────────────────────────────
app.get('/series/:seriesKey/episodes', (c) => { app.get("/series/:seriesKey/episodes", (c) => {
const db = getDb(); 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, 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, 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 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' WHERE mi.type = 'Episode'
AND (mi.series_jellyfin_id = ? OR (mi.series_jellyfin_id IS NULL AND mi.series_name = ?)) 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 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<number | null, unknown[]>(); const seasonMap = new Map<number | null, unknown[]>();
for (const r of rows) { for (const r of rows) {
@@ -299,9 +392,11 @@ app.get('/series/:seriesKey/episodes', (c) => {
season, season,
episodes, episodes,
noopCount: (episodes as { plan: ReviewPlan | null }[]).filter((e) => e.plan?.is_noop).length, 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, actionCount: (episodes as { plan: ReviewPlan | null }[]).filter(
approvedCount: (episodes as { plan: ReviewPlan | null }[]).filter((e) => e.plan?.status === 'approved').length, (e) => e.plan?.status === "pending" && !e.plan.is_noop,
doneCount: (episodes as { plan: ReviewPlan | null }[]).filter((e) => e.plan?.status === 'done').length, ).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 }); return c.json({ seasons });
@@ -309,63 +404,78 @@ app.get('/series/:seriesKey/episodes', (c) => {
// ─── Approve series ─────────────────────────────────────────────────────────── // ─── Approve series ───────────────────────────────────────────────────────────
app.post('/series/:seriesKey/approve-all', (c) => { app.post("/series/:seriesKey/approve-all", (c) => {
const db = getDb(); const db = getDb();
const seriesKey = decodeURIComponent(c.req.param('seriesKey')); const seriesKey = decodeURIComponent(c.req.param("seriesKey"));
const pending = db.prepare(` 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 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 = ?)) 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 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) { for (const plan of pending) {
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id); 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); 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 }); return c.json({ ok: true, count: pending.length });
}); });
// ─── Approve season ─────────────────────────────────────────────────────────── // ─── Approve season ───────────────────────────────────────────────────────────
app.post('/season/:seriesKey/:season/approve-all', (c) => { app.post("/season/:seriesKey/:season/approve-all", (c) => {
const db = getDb(); const db = getDb();
const seriesKey = decodeURIComponent(c.req.param('seriesKey')); const seriesKey = decodeURIComponent(c.req.param("seriesKey"));
const season = Number.parseInt(c.req.param('season') ?? '', 10); const season = Number.parseInt(c.req.param("season") ?? "", 10);
if (!Number.isFinite(season)) return c.json({ error: 'invalid season' }, 400); if (!Number.isFinite(season)) return c.json({ error: "invalid season" }, 400);
const pending = db.prepare(` 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 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 = ?)) 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 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) { for (const plan of pending) {
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id); 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); 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 }); return c.json({ ok: true, count: pending.length });
}); });
// ─── Approve all ────────────────────────────────────────────────────────────── // ─── Approve all ──────────────────────────────────────────────────────────────
app.post('/approve-all', (c) => { app.post("/approve-all", (c) => {
const db = getDb(); const db = getDb();
const pending = db.prepare( const pending = db
"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" .prepare(
).all() as (ReviewPlan & { item_id: number })[]; "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) { for (const plan of pending) {
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id); 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); 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 }); return c.json({ ok: true, count: pending.length });
}); });
// ─── Detail ─────────────────────────────────────────────────────────────────── // ─── Detail ───────────────────────────────────────────────────────────────────
app.get('/:id', (c) => { app.get("/:id", (c) => {
const db = getDb(); const db = getDb();
const id = parseId(c.req.param('id')); const id = parseId(c.req.param("id"));
if (id == null) return c.json({ error: 'invalid id' }, 400); if (id == null) return c.json({ error: "invalid id" }, 400);
const detail = loadItemDetail(db, id); const detail = loadItemDetail(db, id);
if (!detail.item) return c.notFound(); if (!detail.item) return c.notFound();
return c.json(detail); return c.json(detail);
@@ -373,13 +483,14 @@ app.get('/:id', (c) => {
// ─── Override language ──────────────────────────────────────────────────────── // ─── Override language ────────────────────────────────────────────────────────
app.patch('/:id/language', async (c) => { app.patch("/:id/language", async (c) => {
const db = getDb(); const db = getDb();
const id = parseId(c.req.param('id')); const id = parseId(c.req.param("id"));
if (id == null) return c.json({ error: 'invalid id' }, 400); if (id == null) return c.json({ error: "invalid id" }, 400);
const body = await c.req.json<{ language: string | null }>(); const body = await c.req.json<{ language: string | null }>();
const lang = body.language || 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); .run(lang ? normalizeLanguage(lang) : null, id);
reanalyze(db, id); reanalyze(db, id);
const detail = loadItemDetail(db, id); const detail = loadItemDetail(db, id);
@@ -389,16 +500,18 @@ app.patch('/:id/language', async (c) => {
// ─── Edit stream title ──────────────────────────────────────────────────────── // ─── Edit stream title ────────────────────────────────────────────────────────
app.patch('/:id/stream/:streamId/title', async (c) => { app.patch("/:id/stream/:streamId/title", async (c) => {
const db = getDb(); const db = getDb();
const itemId = parseId(c.req.param('id')); const itemId = parseId(c.req.param("id"));
const streamId = parseId(c.req.param('streamId')); const streamId = parseId(c.req.param("streamId"));
if (itemId == null || streamId == null) return c.json({ error: 'invalid id' }, 400); if (itemId == null || streamId == null) return c.json({ error: "invalid id" }, 400);
const body = await c.req.json<{ title: string }>(); 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(); 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); const detail = loadItemDetail(db, itemId);
if (!detail.item) return c.notFound(); if (!detail.item) return c.notFound();
return c.json(detail); return c.json(detail);
@@ -406,26 +519,30 @@ app.patch('/:id/stream/:streamId/title', async (c) => {
// ─── Toggle stream action ───────────────────────────────────────────────────── // ─── Toggle stream action ─────────────────────────────────────────────────────
app.patch('/:id/stream/:streamId', async (c) => { app.patch("/:id/stream/:streamId", async (c) => {
const db = getDb(); const db = getDb();
const itemId = parseId(c.req.param('id')); const itemId = parseId(c.req.param("id"));
const streamId = parseId(c.req.param('streamId')); const streamId = parseId(c.req.param("streamId"));
if (itemId == null || streamId == null) return c.json({ error: 'invalid id' }, 400); if (itemId == null || streamId == null) return c.json({ error: "invalid id" }, 400);
const body = await c.req.json<{ action: unknown }>().catch(() => ({ action: null })); 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); 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) // 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; const stream = db.prepare("SELECT type, item_id FROM media_streams WHERE id = ?").get(streamId) as
if (!stream || stream.item_id !== itemId) return c.json({ error: 'stream not found on item' }, 404); | { type: string; item_id: number }
if (stream.type === 'Subtitle') return c.json({ error: 'Subtitle streams cannot be toggled' }, 400); | 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(); 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); recomputePlanAfterToggle(db, itemId);
@@ -436,63 +553,94 @@ app.patch('/:id/stream/:streamId', async (c) => {
// ─── Approve ────────────────────────────────────────────────────────────────── // ─── Approve ──────────────────────────────────────────────────────────────────
app.post('/:id/approve', (c) => { app.post("/:id/approve", (c) => {
const db = getDb(); const db = getDb();
const id = parseId(c.req.param('id')); const id = parseId(c.req.param("id"));
if (id == null) return c.json({ error: 'invalid id' }, 400); 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 plan = db.prepare("SELECT * FROM review_plans WHERE item_id = ?").get(id) as ReviewPlan | undefined;
if (!plan) return c.notFound(); if (!plan) return c.notFound();
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id); db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id);
if (!plan.is_noop) { if (!plan.is_noop) {
const { item, streams, decisions } = loadItemDetail(db, id); 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 }); return c.json({ ok: true });
}); });
// ─── Unapprove ─────────────────────────────────────────────────────────────── // ─── Unapprove ───────────────────────────────────────────────────────────────
app.post('/:id/unapprove', (c) => { // ─── Retry failed job ─────────────────────────────────────────────────────────
app.post("/:id/retry", (c) => {
const db = getDb(); const db = getDb();
const id = parseId(c.req.param('id')); const id = parseId(c.req.param("id"));
if (id == null) return c.json({ error: 'invalid id' }, 400); 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 plan = db.prepare("SELECT * FROM review_plans WHERE item_id = ?").get(id) as ReviewPlan | undefined;
if (!plan) return c.notFound(); 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 // 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; const job = db.prepare("SELECT * FROM jobs WHERE item_id = ? ORDER BY created_at DESC LIMIT 1").get(id) as
if (job && job.status !== 'pending') return c.json({ ok: false, error: 'Job already started — cannot unapprove' }, 409); | { 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 // 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); db.prepare("UPDATE review_plans SET status = 'pending', reviewed_at = NULL WHERE id = ?").run(plan.id);
return c.json({ ok: true }); return c.json({ ok: true });
}); });
// ─── Skip / Unskip ─────────────────────────────────────────────────────────── // ─── Skip / Unskip ───────────────────────────────────────────────────────────
app.post('/:id/skip', (c) => { app.post("/:id/skip", (c) => {
const db = getDb(); const db = getDb();
const id = parseId(c.req.param('id')); const id = parseId(c.req.param("id"));
if (id == null) return c.json({ error: 'invalid id' }, 400); 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); db.prepare("UPDATE review_plans SET status = 'skipped', reviewed_at = datetime('now') WHERE item_id = ?").run(id);
return c.json({ ok: true }); return c.json({ ok: true });
}); });
app.post('/:id/unskip', (c) => { app.post("/:id/unskip", (c) => {
const db = getDb(); const db = getDb();
const id = parseId(c.req.param('id')); const id = parseId(c.req.param("id"));
if (id == null) return c.json({ error: 'invalid id' }, 400); 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); db
.prepare("UPDATE review_plans SET status = 'pending', reviewed_at = NULL WHERE item_id = ? AND status = 'skipped'")
.run(id);
return c.json({ ok: true }); return c.json({ ok: true });
}); });
// ─── Rescan ─────────────────────────────────────────────────────────────────── // ─── Rescan ───────────────────────────────────────────────────────────────────
app.post('/:id/rescan', async (c) => { app.post("/:id/rescan", async (c) => {
const db = getDb(); const db = getDb();
const id = parseId(c.req.param('id')); const id = parseId(c.req.param("id"));
if (id == null) return c.json({ error: 'invalid id' }, 400); 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(); if (!item) return c.notFound();
const cfg = getAllConfig(); const cfg = getAllConfig();
@@ -505,13 +653,21 @@ app.post('/:id/rescan', async (c) => {
// Snapshot custom_titles keyed by stable properties, since replacing // Snapshot custom_titles keyed by stable properties, since replacing
// media_streams cascades away all stream_decisions. // media_streams cascades away all stream_decisions.
const preservedTitles = new Map<string, string>(); const preservedTitles = new Map<string, string>();
const oldRows = db.prepare(` const oldRows = db
.prepare(`
SELECT ms.type, ms.language, ms.stream_index, ms.title, sd.custom_title SELECT ms.type, ms.language, ms.stream_index, ms.title, sd.custom_title
FROM stream_decisions sd FROM stream_decisions sd
JOIN media_streams ms ON ms.id = sd.stream_id JOIN media_streams ms ON ms.id = sd.stream_id
JOIN review_plans rp ON rp.id = sd.plan_id JOIN review_plans rp ON rp.id = sd.plan_id
WHERE rp.item_id = ? AND sd.custom_title IS NOT NULL 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) { for (const r of oldRows) {
preservedTitles.set(titleKey(r), r.custom_title); 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) title, is_default, is_forced, is_hearing_impaired, channels, channel_layout, bit_rate, sample_rate)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 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 ?? []) { for (const jStream of fresh.MediaStreams ?? []) {
if (jStream.IsExternal) continue; // skip external subs — not embedded in container if (jStream.IsExternal) continue; // skip external subs — not embedded in container
const s = mapStream(jStream); 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 ──────────────────────────────────────────── // ─── Pipeline: approve up to here ────────────────────────────────────────────
app.post('/approve-up-to/:id', (c) => { app.post("/approve-up-to/:id", (c) => {
const targetId = parseId(c.req.param('id')); const targetId = parseId(c.req.param("id"));
if (targetId == null) return c.json({ error: 'invalid id' }, 400); if (targetId == null) return c.json({ error: "invalid id" }, 400);
const db = getDb(); const db = getDb();
const target = db.prepare('SELECT id FROM review_plans WHERE id = ?').get(targetId) as { id: number } | undefined; 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); if (!target) return c.json({ error: "Plan not found" }, 404);
// Get all pending plans sorted by confidence (high first), then name // Get all pending plans sorted by confidence (high first), then name
const pendingPlans = db.prepare(` const pendingPlans = db
.prepare(`
SELECT rp.id SELECT rp.id
FROM review_plans rp FROM review_plans rp
JOIN media_items mi ON mi.id = rp.item_id 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.season_number,
mi.episode_number, mi.episode_number,
mi.name mi.name
`).all() as { id: number }[]; `)
.all() as { id: number }[];
// Find the target and approve everything up to and including it // Find the target and approve everything up to and including it
const toApprove: number[] = []; const toApprove: number[] = [];
@@ -571,10 +744,14 @@ app.post('/approve-up-to/:id', (c) => {
// Batch approve and create jobs // Batch approve and create jobs
for (const planId of toApprove) { for (const planId of toApprove) {
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(planId); 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); const detail = loadItemDetail(db, planRow.item_id);
if (detail.item && detail.command) { 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); .run(planRow.item_id, detail.command, planRow.job_type);
} }
} }
@@ -584,18 +761,21 @@ app.post('/approve-up-to/:id', (c) => {
// ─── Pipeline: series language ─────────────────────────────────────────────── // ─── Pipeline: series language ───────────────────────────────────────────────
app.patch('/series/:seriesKey/language', async (c) => { app.patch("/series/:seriesKey/language", async (c) => {
const seriesKey = decodeURIComponent(c.req.param('seriesKey')); const seriesKey = decodeURIComponent(c.req.param("seriesKey"));
const { language } = await c.req.json<{ language: string }>(); const { language } = await c.req.json<{ language: string }>();
const db = getDb(); const db = getDb();
const items = db.prepare( const items = db
'SELECT id FROM media_items WHERE series_jellyfin_id = ? OR (series_jellyfin_id IS NULL AND series_name = ?)' .prepare(
).all(seriesKey, seriesKey) as { id: number }[]; "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; const normalizedLang = language ? normalizeLanguage(language) : null;
for (const item of items) { 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); .run(normalizedLang, item.id);
} }

View File

@@ -1,12 +1,12 @@
import { Hono } from 'hono'; import { Hono } from "hono";
import { stream } from 'hono/streaming'; import { stream } from "hono/streaming";
import { getDb, getConfig, setConfig, getAllConfig } from '../db/index'; import { getAllConfig, getConfig, getDb, setConfig } from "../db/index";
import { getAllItems, getDevItems, extractOriginalLanguage, mapStream, normalizeLanguage } from '../services/jellyfin'; import { log, error as logError, warn } from "../lib/log";
import { getOriginalLanguage as radarrLang } from '../services/radarr'; import { analyzeItem } from "../services/analyzer";
import { getOriginalLanguage as sonarrLang } from '../services/sonarr'; import { extractOriginalLanguage, getAllItems, getDevItems, mapStream, normalizeLanguage } from "../services/jellyfin";
import { analyzeItem } from '../services/analyzer'; import { getOriginalLanguage as radarrLang } from "../services/radarr";
import type { MediaItem, MediaStream } from '../types'; import { getOriginalLanguage as sonarrLang } from "../services/sonarr";
import { log, warn, error as logError } from '../lib/log'; import type { MediaStream } from "../types";
const app = new Hono(); const app = new Hono();
@@ -21,45 +21,48 @@ function emitSse(type: string, data: unknown): void {
} }
function currentScanLimit(): number | null { function currentScanLimit(): number | null {
const v = getConfig('scan_limit'); const v = getConfig("scan_limit");
return v ? Number(v) : null; return v ? Number(v) : null;
} }
// ─── Status ─────────────────────────────────────────────────────────────────── // ─── Status ───────────────────────────────────────────────────────────────────
app.get('/', (c) => { app.get("/", (c) => {
const db = getDb(); const db = getDb();
const running = getConfig('scan_running') === '1'; const running = getConfig("scan_running") === "1";
const total = (db.prepare('SELECT COUNT(*) as n FROM media_items').get() as { n: number }).n; 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 scanned = (
const errors = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'error'").get() as { n: number }).n; db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'scanned'").get() as { n: number }
const recentItems = db.prepare( ).n;
'SELECT name, type, scan_status, file_path FROM media_items ORDER BY last_scanned_at DESC LIMIT 50' const errors = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'error'").get() as { n: number })
).all() as { name: string; type: string; scan_status: string; file_path: string }[]; .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() }); return c.json({ running, progress: { scanned, total, errors }, recentItems, scanLimit: currentScanLimit() });
}); });
// ─── Start ──────────────────────────────────────────────────────────────────── // ─── Start ────────────────────────────────────────────────────────────────────
app.post('/start', async (c) => { app.post("/start", async (c) => {
const db = getDb(); const db = getDb();
// Atomic claim: only succeed if scan_running is not already '1'. // 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(); const claim = db.prepare("UPDATE config SET value = '1' WHERE key = 'scan_running' AND value != '1'").run();
if (claim.changes === 0) { 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 body = await c.req.json<{ limit?: number }>().catch(() => ({ limit: undefined }));
const formLimit = body.limit ?? null; const formLimit = body.limit ?? null;
const envLimit = process.env.SCAN_LIMIT ? Number(process.env.SCAN_LIMIT) : null; const envLimit = process.env.SCAN_LIMIT ? Number(process.env.SCAN_LIMIT) : null;
const limit = formLimit ?? envLimit ?? null; const limit = formLimit ?? envLimit ?? null;
setConfig('scan_limit', limit != null ? String(limit) : ''); setConfig("scan_limit", limit != null ? String(limit) : "");
runScan(limit).catch((err) => { runScan(limit).catch((err) => {
logError('Scan failed:', err); logError("Scan failed:", err);
setConfig('scan_running', '0'); setConfig("scan_running", "0");
emitSse('error', { message: String(err) }); emitSse("error", { message: String(err) });
}); });
return c.json({ ok: true }); return c.json({ ok: true });
@@ -67,19 +70,19 @@ app.post('/start', async (c) => {
// ─── Stop ───────────────────────────────────────────────────────────────────── // ─── Stop ─────────────────────────────────────────────────────────────────────
app.post('/stop', (c) => { app.post("/stop", (c) => {
scanAbort?.abort(); scanAbort?.abort();
setConfig('scan_running', '0'); setConfig("scan_running", "0");
return c.json({ ok: true }); return c.json({ ok: true });
}); });
// ─── SSE ────────────────────────────────────────────────────────────────────── // ─── SSE ──────────────────────────────────────────────────────────────────────
app.get('/events', (c) => { app.get("/events", (c) => {
return stream(c, async (s) => { return stream(c, async (s) => {
c.header('Content-Type', 'text/event-stream'); c.header("Content-Type", "text/event-stream");
c.header('Cache-Control', 'no-cache'); c.header("Cache-Control", "no-cache");
c.header('Connection', 'keep-alive'); c.header("Connection", "keep-alive");
const queue: string[] = []; const queue: string[] = [];
let resolve: (() => void) | null = null; let resolve: (() => void) | null = null;
@@ -90,7 +93,9 @@ app.get('/events', (c) => {
}; };
scanListeners.add(listener); scanListeners.add(listener);
s.onAbort(() => { scanListeners.delete(listener); }); s.onAbort(() => {
scanListeners.delete(listener);
});
try { try {
while (!s.closed) { while (!s.closed) {
@@ -102,7 +107,7 @@ app.get('/events', (c) => {
setTimeout(res, 25_000); setTimeout(res, 25_000);
}); });
resolve = null; resolve = null;
if (queue.length === 0) await s.write(': keepalive\n\n'); if (queue.length === 0) await s.write(": keepalive\n\n");
} }
} }
} finally { } finally {
@@ -114,25 +119,31 @@ app.get('/events', (c) => {
// ─── Core scan logic ────────────────────────────────────────────────────────── // ─── Core scan logic ──────────────────────────────────────────────────────────
async function runScan(limit: number | null = null): Promise<void> { async function runScan(limit: number | null = null): Promise<void> {
log(`Scan started${limit ? ` (limit: ${limit})` : ''}`); log(`Scan started${limit ? ` (limit: ${limit})` : ""}`);
scanAbort = new AbortController(); scanAbort = new AbortController();
const { signal } = scanAbort; const { signal } = scanAbort;
const isDev = process.env.NODE_ENV === 'development'; const isDev = process.env.NODE_ENV === "development";
const db = getDb(); const db = getDb();
if (isDev) { if (isDev) {
db.prepare('DELETE FROM stream_decisions').run(); // Order matters only if foreign keys are enforced without CASCADE; we
db.prepare('DELETE FROM review_plans').run(); // have ON DELETE CASCADE on media_streams/review_plans/stream_decisions/
db.prepare('DELETE FROM media_streams').run(); // subtitle_files/jobs, so deleting media_items would be enough. List
db.prepare('DELETE FROM media_items').run(); // 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 cfg = getAllConfig();
const jellyfinCfg = { url: cfg.jellyfin_url, apiKey: cfg.jellyfin_api_key, userId: cfg.jellyfin_user_id }; 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 subtitleLanguages: string[] = JSON.parse(cfg.subtitle_languages ?? '["eng","deu","spa"]');
const audioLanguages: string[] = JSON.parse(cfg.audio_languages ?? '[]'); const audioLanguages: string[] = JSON.parse(cfg.audio_languages ?? "[]");
const radarrEnabled = cfg.radarr_enabled === '1'; const radarrEnabled = cfg.radarr_enabled === "1";
const sonarrEnabled = cfg.sonarr_enabled === '1'; const sonarrEnabled = cfg.sonarr_enabled === "1";
let processed = 0; let processed = 0;
let errors = 0; let errors = 0;
@@ -157,7 +168,7 @@ async function runScan(limit: number | null = null): Promise<void> {
scan_status = 'scanned', last_scanned_at = datetime('now') 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(` const insertStream = db.prepare(`
INSERT INTO media_streams ( INSERT INTO media_streams (
item_id, stream_index, type, codec, language, language_display, item_id, stream_index, type, codec, language, language_display,
@@ -181,15 +192,15 @@ async function runScan(limit: number | null = null): Promise<void> {
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(plan_id, stream_id) DO UPDATE SET action = excluded.action, target_index = excluded.target_index, transcode_codec = excluded.transcode_codec 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 getItemByJellyfinId = db.prepare("SELECT id FROM media_items WHERE jellyfin_id = ?");
const getPlanByItemId = db.prepare('SELECT id FROM review_plans WHERE item_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 getStreamsByItemId = db.prepare("SELECT * FROM media_streams WHERE item_id = ?");
const itemSource = isDev const itemSource = isDev
? getDevItems(jellyfinCfg) ? getDevItems(jellyfinCfg)
: getAllItems(jellyfinCfg, (_fetched, jellyfinTotal) => { : 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) { for await (const jellyfinItem of itemSource) {
if (signal.aborted) break; if (signal.aborted) break;
if (!isDev && limit != null && processed >= limit) break; if (!isDev && limit != null && processed >= limit) break;
@@ -199,45 +210,67 @@ async function runScan(limit: number | null = null): Promise<void> {
} }
processed++; 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 { try {
const providerIds = jellyfinItem.ProviderIds ?? {}; const providerIds = jellyfinItem.ProviderIds ?? {};
const imdbId = providerIds['Imdb'] ?? null; const imdbId = providerIds.Imdb ?? null;
const tmdbId = providerIds['Tmdb'] ?? null; const tmdbId = providerIds.Tmdb ?? null;
const tvdbId = providerIds['Tvdb'] ?? null; const tvdbId = providerIds.Tvdb ?? null;
let origLang: string | null = extractOriginalLanguage(jellyfinItem); let origLang: string | null = extractOriginalLanguage(jellyfinItem);
let origLangSource = 'jellyfin'; let origLangSource = "jellyfin";
let needsReview = origLang ? 0 : 1; let needsReview = origLang ? 0 : 1;
if (jellyfinItem.Type === 'Movie' && radarrEnabled && (tmdbId || imdbId)) { 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 }); const lang = await radarrLang(
if (lang) { if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1; origLang = lang; origLangSource = 'radarr'; } { 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); 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 // Compute confidence from source agreement
let confidence: 'high' | 'low' = 'low'; let confidence: "high" | "low" = "low";
if (!origLang) { if (!origLang) {
confidence = 'low'; // unknown language confidence = "low"; // unknown language
} else if (needsReview) { } else if (needsReview) {
confidence = 'low'; // sources disagree confidence = "low"; // sources disagree
} else { } else {
confidence = 'high'; // language known, no conflicts confidence = "high"; // language known, no conflicts
} }
upsertItem.run( upsertItem.run(
jellyfinItem.Id, jellyfinItem.Type === 'Episode' ? 'Episode' : 'Movie', jellyfinItem.Id,
jellyfinItem.Name, jellyfinItem.SeriesName ?? null, jellyfinItem.SeriesId ?? null, jellyfinItem.Type === "Episode" ? "Episode" : "Movie",
jellyfinItem.ParentIndexNumber ?? null, jellyfinItem.IndexNumber ?? null, jellyfinItem.Name,
jellyfinItem.ProductionYear ?? null, jellyfinItem.Path, jellyfinItem.Size ?? null, jellyfinItem.SeriesName ?? null,
jellyfinItem.Container ?? null, origLang, origLangSource, needsReview, jellyfinItem.SeriesId ?? null,
imdbId, tmdbId, tvdbId 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 }; const itemRow = getItemByJellyfinId.get(jellyfinItem.Id) as { id: number };
@@ -247,29 +280,62 @@ async function runScan(limit: number | null = null): Promise<void> {
for (const jStream of jellyfinItem.MediaStreams ?? []) { for (const jStream of jellyfinItem.MediaStreams ?? []) {
if (jStream.IsExternal) continue; // skip external subs — not embedded in container if (jStream.IsExternal) continue; // skip external subs — not embedded in container
const s = mapStream(jStream); 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 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 // Override base confidence with scan-computed value
const finalConfidence = confidence; 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 }; 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) { } catch (err) {
errors++; errors++;
logError(`Error scanning ${jellyfinItem.Name}:`, err); 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 */ } try {
emitSse('log', { name: jellyfinItem.Name, type: jellyfinItem.Type, status: 'error', file: jellyfinItem.Path }); 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`); log(`Scan complete: ${processed} scanned, ${errors} errors`);
emitSse('complete', { scanned: processed, total, errors }); emitSse("complete", { scanned: processed, total, errors });
} }
export default app; export default app;

View File

@@ -1,104 +1,106 @@
import { Hono } from 'hono'; import { Hono } from "hono";
import { setConfig, getAllConfig, getDb, getEnvLockedKeys } from '../db/index'; import { getAllConfig, getDb, getEnvLockedKeys, setConfig } from "../db/index";
import { testConnection as testJellyfin, getUsers } from '../services/jellyfin'; import { getUsers, testConnection as testJellyfin } from "../services/jellyfin";
import { testConnection as testRadarr } from '../services/radarr'; import { testConnection as testRadarr } from "../services/radarr";
import { testConnection as testSonarr } from '../services/sonarr'; import { testConnection as testSonarr } from "../services/sonarr";
const app = new Hono(); const app = new Hono();
app.get('/', (c) => { app.get("/", (c) => {
const config = getAllConfig(); const config = getAllConfig();
const envLocked = Array.from(getEnvLockedKeys()); const envLocked = Array.from(getEnvLockedKeys());
return c.json({ config, envLocked }); 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 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; 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 }); const result = await testJellyfin({ url, apiKey });
if (!result.ok) return c.json({ ok: false, error: result.error }); if (!result.ok) return c.json({ ok: false, error: result.error });
setConfig('jellyfin_url', url); setConfig("jellyfin_url", url);
setConfig('jellyfin_api_key', apiKey); setConfig("jellyfin_api_key", apiKey);
setConfig('setup_complete', '1'); setConfig("setup_complete", "1");
try { try {
const users = await getUsers({ url, apiKey }); const users = await getUsers({ url, apiKey });
const admin = users.find((u) => u.Name === 'admin') ?? users[0]; const admin = users.find((u) => u.Name === "admin") ?? users[0];
if (admin?.Id) setConfig('jellyfin_user_id', admin.Id); if (admin?.Id) setConfig("jellyfin_user_id", admin.Id);
} catch { /* ignore */ } } catch {
/* ignore */
}
return c.json({ ok: true }); 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 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; const apiKey = body.api_key;
if (!url || !apiKey) { if (!url || !apiKey) {
setConfig('radarr_enabled', '0'); setConfig("radarr_enabled", "0");
return c.json({ ok: false, error: 'URL and API key are required' }, 400); return c.json({ ok: false, error: "URL and API key are required" }, 400);
} }
const result = await testRadarr({ url, apiKey }); const result = await testRadarr({ url, apiKey });
if (!result.ok) return c.json({ ok: false, error: result.error }); if (!result.ok) return c.json({ ok: false, error: result.error });
setConfig('radarr_url', url); setConfig("radarr_url", url);
setConfig('radarr_api_key', apiKey); setConfig("radarr_api_key", apiKey);
setConfig('radarr_enabled', '1'); setConfig("radarr_enabled", "1");
return c.json({ ok: true }); 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 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; const apiKey = body.api_key;
if (!url || !apiKey) { if (!url || !apiKey) {
setConfig('sonarr_enabled', '0'); setConfig("sonarr_enabled", "0");
return c.json({ ok: false, error: 'URL and API key are required' }, 400); return c.json({ ok: false, error: "URL and API key are required" }, 400);
} }
const result = await testSonarr({ url, apiKey }); const result = await testSonarr({ url, apiKey });
if (!result.ok) return c.json({ ok: false, error: result.error }); if (!result.ok) return c.json({ ok: false, error: result.error });
setConfig('sonarr_url', url); setConfig("sonarr_url", url);
setConfig('sonarr_api_key', apiKey); setConfig("sonarr_api_key", apiKey);
setConfig('sonarr_enabled', '1'); setConfig("sonarr_enabled", "1");
return c.json({ ok: true }); 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[] }>(); const body = await c.req.json<{ langs: string[] }>();
if (body.langs?.length > 0) { if (body.langs?.length > 0) {
setConfig('subtitle_languages', JSON.stringify(body.langs)); setConfig("subtitle_languages", JSON.stringify(body.langs));
} }
return c.json({ ok: true }); 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[] }>(); 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 }); return c.json({ ok: true });
}); });
app.post('/clear-scan', (c) => { app.post("/clear-scan", (c) => {
const db = getDb(); const db = getDb();
// Delete children first to avoid slow cascade deletes // Delete children first to avoid slow cascade deletes
db.transaction(() => { db.transaction(() => {
db.prepare('DELETE FROM stream_decisions').run(); db.prepare("DELETE FROM stream_decisions").run();
db.prepare('DELETE FROM jobs').run(); db.prepare("DELETE FROM jobs").run();
db.prepare('DELETE FROM subtitle_files').run(); db.prepare("DELETE FROM subtitle_files").run();
db.prepare('DELETE FROM review_plans').run(); db.prepare("DELETE FROM review_plans").run();
db.prepare('DELETE FROM media_streams').run(); db.prepare("DELETE FROM media_streams").run();
db.prepare('DELETE FROM media_items').run(); db.prepare("DELETE FROM media_items").run();
db.prepare("UPDATE config SET value = '0' WHERE key = 'scan_running'").run(); db.prepare("UPDATE config SET value = '0' WHERE key = 'scan_running'").run();
})(); })();
return c.json({ ok: true }); return c.json({ ok: true });

View File

@@ -1,44 +1,67 @@
import { Hono } from 'hono'; import { unlinkSync } from "node:fs";
import { getDb, getConfig, getAllConfig } from '../db/index'; import { dirname, resolve as resolvePath, sep } from "node:path";
import { buildExtractOnlyCommand } from '../services/ffmpeg'; import { Hono } from "hono";
import { normalizeLanguage, getItem, refreshItem, mapStream } from '../services/jellyfin'; import { getAllConfig, getConfig, getDb } from "../db/index";
import { parseId } from '../lib/validate'; import { error as logError } from "../lib/log";
import type { MediaItem, MediaStream, SubtitleFile, ReviewPlan, StreamDecision } from '../types'; import { parseId } from "../lib/validate";
import { unlinkSync } from 'node:fs'; import { buildExtractOnlyCommand } from "../services/ffmpeg";
import { dirname, resolve as resolvePath, sep } from 'node:path'; import { getItem, mapStream, normalizeLanguage, refreshItem } from "../services/jellyfin";
import { error as logError } from '../lib/log'; import type { MediaItem, MediaStream, ReviewPlan, StreamDecision, SubtitleFile } from "../types";
const app = new Hono(); const app = new Hono();
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
interface SubListItem { interface SubListItem {
id: number; jellyfin_id: string; type: string; name: string; id: number;
series_name: string | null; season_number: number | null; jellyfin_id: string;
episode_number: number | null; year: number | null; type: string;
original_language: string | null; file_path: string; name: string;
subs_extracted: number | null; sub_count: number; file_count: number; 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 { interface SubSeriesGroup {
series_key: string; series_name: string; original_language: string | null; series_key: string;
season_count: number; episode_count: number; series_name: string;
not_extracted_count: number; extracted_count: number; no_subs_count: number; original_language: string | null;
season_count: number;
episode_count: number;
not_extracted_count: number;
extracted_count: number;
no_subs_count: number;
} }
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
function loadDetail(db: ReturnType<typeof getDb>, itemId: number) { function loadDetail(db: ReturnType<typeof getDb>, 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; 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 subtitleStreams = db
const files = db.prepare('SELECT * FROM subtitle_files WHERE item_id = ? ORDER BY file_path').all(itemId) as SubtitleFile[]; .prepare("SELECT * FROM media_streams WHERE item_id = ? AND type = 'Subtitle' ORDER BY stream_index")
const plan = db.prepare('SELECT * FROM review_plans WHERE item_id = ?').get(itemId) as ReviewPlan | undefined; .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 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); const extractCommand = buildExtractOnlyCommand(item, allStreams);
return { return {
@@ -56,20 +79,25 @@ function loadDetail(db: ReturnType<typeof getDb>, itemId: number) {
function buildSubWhere(filter: string): string { function buildSubWhere(filter: string): string {
switch (filter) { switch (filter) {
case 'not_extracted': return "sub_count > 0 AND COALESCE(rp.subs_extracted, 0) = 0"; case "not_extracted":
case 'extracted': return "rp.subs_extracted = 1"; return "sub_count > 0 AND COALESCE(rp.subs_extracted, 0) = 0";
case 'no_subs': return "sub_count = 0"; case "extracted":
default: return '1=1'; 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 db = getDb();
const filter = c.req.query('filter') ?? 'all'; const filter = c.req.query("filter") ?? "all";
const where = buildSubWhere(filter); const where = buildSubWhere(filter);
// Movies // 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, 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, mi.episode_number, mi.year, mi.original_language, mi.file_path,
rp.subs_extracted, rp.subs_extracted,
@@ -79,10 +107,12 @@ app.get('/', (c) => {
LEFT JOIN review_plans rp ON rp.item_id = mi.id LEFT JOIN review_plans rp ON rp.item_id = mi.id
WHERE mi.type = 'Movie' AND ${where} WHERE mi.type = 'Movie' AND ${where}
ORDER BY mi.name LIMIT 500 ORDER BY mi.name LIMIT 500
`).all() as SubListItem[]; `)
.all() as SubListItem[];
// Series groups // Series groups
const series = db.prepare(` const series = db
.prepare(`
SELECT COALESCE(mi.series_jellyfin_id, mi.series_name) as series_key, SELECT COALESCE(mi.series_jellyfin_id, mi.series_name) as series_key,
mi.series_name, mi.series_name,
MAX(mi.original_language) as original_language, 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 LEFT JOIN review_plans rp ON rp.item_id = mi.id
WHERE ${where} WHERE ${where}
GROUP BY series_key ORDER BY mi.series_name 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 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 totalExtracted = (
const totalNoSubs = (db.prepare(` 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 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') 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; const totalNotExtracted = totalAll - totalExtracted - totalNoSubs;
return c.json({ return c.json({
@@ -120,11 +157,12 @@ app.get('/', (c) => {
// ─── Series episodes (subtitles) ───────────────────────────────────────────── // ─── Series episodes (subtitles) ─────────────────────────────────────────────
app.get('/series/:seriesKey/episodes', (c) => { app.get("/series/:seriesKey/episodes", (c) => {
const db = getDb(); 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, 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, mi.episode_number, mi.year, mi.original_language, mi.file_path,
rp.subs_extracted, rp.subs_extracted,
@@ -135,7 +173,8 @@ app.get('/series/:seriesKey/episodes', (c) => {
WHERE mi.type = 'Episode' WHERE mi.type = 'Episode'
AND (mi.series_jellyfin_id = ? OR (mi.series_jellyfin_id IS NULL AND mi.series_name = ?)) AND (mi.series_jellyfin_id = ? OR (mi.series_jellyfin_id IS NULL AND mi.series_name = ?))
ORDER BY mi.season_number, mi.episode_number ORDER BY mi.season_number, mi.episode_number
`).all(seriesKey, seriesKey) as SubListItem[]; `)
.all(seriesKey, seriesKey) as SubListItem[];
const seasonMap = new Map<number | null, SubListItem[]>(); const seasonMap = new Map<number | null, SubListItem[]>();
for (const r of rows) { for (const r of rows) {
@@ -159,40 +198,55 @@ app.get('/series/:seriesKey/episodes', (c) => {
// ─── Summary ───────────────────────────────────────────────────────────────── // ─── Summary ─────────────────────────────────────────────────────────────────
interface CategoryRow { language: string | null; is_forced: number; is_hearing_impaired: number; cnt: number } interface CategoryRow {
language: string | null;
function variantOf(row: { is_forced: number; is_hearing_impaired: number }): 'forced' | 'cc' | 'standard' { is_forced: number;
if (row.is_forced) return 'forced'; is_hearing_impaired: number;
if (row.is_hearing_impaired) return 'cc'; cnt: number;
return 'standard';
} }
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(); const db = getDb();
// Embedded count — items with subtitle streams where subs_extracted = 0 // 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 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' 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 LEFT JOIN review_plans rp ON rp.item_id = mi.id
WHERE COALESCE(rp.subs_extracted, 0) = 0 WHERE COALESCE(rp.subs_extracted, 0) = 0
`).get() as { n: number }).n; `)
.get() as { n: number }
).n;
// Stream counts by (language, variant) // Stream counts by (language, variant)
const streamRows = db.prepare(` const streamRows = db
.prepare(`
SELECT language, is_forced, is_hearing_impaired, COUNT(*) as cnt SELECT language, is_forced, is_hearing_impaired, COUNT(*) as cnt
FROM media_streams WHERE type = 'Subtitle' FROM media_streams WHERE type = 'Subtitle'
GROUP BY language, is_forced, is_hearing_impaired GROUP BY language, is_forced, is_hearing_impaired
`).all() as CategoryRow[]; `)
.all() as CategoryRow[];
// File counts by (language, variant) // File counts by (language, variant)
const fileRows = db.prepare(` const fileRows = db
.prepare(`
SELECT language, is_forced, is_hearing_impaired, COUNT(*) as cnt SELECT language, is_forced, is_hearing_impaired, COUNT(*) as cnt
FROM subtitle_files FROM subtitle_files
GROUP BY language, is_forced, is_hearing_impaired GROUP BY language, is_forced, is_hearing_impaired
`).all() as CategoryRow[]; `)
.all() as CategoryRow[];
// Merge into categories // Merge into categories
const catMap = new Map<string, { language: string | null; variant: string; streamCount: number; fileCount: number }>(); const catMap = new Map<string, { language: string | null; variant: string; streamCount: number; fileCount: number }>();
@@ -205,23 +259,28 @@ app.get('/summary', (c) => {
const v = variantOf(r); const v = variantOf(r);
const k = catKey(r.language, v); const k = catKey(r.language, v);
const existing = catMap.get(k); const existing = catMap.get(k);
if (existing) { existing.fileCount = r.cnt; } if (existing) {
else { catMap.set(k, { language: r.language, variant: v, streamCount: 0, fileCount: r.cnt }); } 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 categories = Array.from(catMap.values()).sort((a, b) => {
const la = a.language ?? 'zzz'; const la = a.language ?? "zzz";
const lb = b.language ?? 'zzz'; const lb = b.language ?? "zzz";
if (la !== lb) return la.localeCompare(lb); if (la !== lb) return la.localeCompare(lb);
return a.variant.localeCompare(b.variant); return a.variant.localeCompare(b.variant);
}); });
// Title grouping // Title grouping
const titleRows = db.prepare(` const titleRows = db
.prepare(`
SELECT language, title, COUNT(*) as cnt SELECT language, title, COUNT(*) as cnt
FROM media_streams WHERE type = 'Subtitle' FROM media_streams WHERE type = 'Subtitle'
GROUP BY language, title GROUP BY language, title
ORDER BY language, cnt DESC 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) // Determine canonical title per language (most common)
const canonicalByLang = new Map<string | null, string | null>(); const canonicalByLang = new Map<string | null, string | null>();
@@ -237,19 +296,23 @@ app.get('/summary', (c) => {
})); }));
// Keep languages from config // Keep languages from config
const raw = getConfig('subtitle_languages'); const raw = getConfig("subtitle_languages");
let keepLanguages: string[] = []; let keepLanguages: string[] = [];
try { keepLanguages = JSON.parse(raw ?? '[]'); } catch { /* empty */ } try {
keepLanguages = JSON.parse(raw ?? "[]");
} catch {
/* empty */
}
return c.json({ embeddedCount, categories, titles, keepLanguages }); return c.json({ embeddedCount, categories, titles, keepLanguages });
}); });
// ─── Detail ────────────────────────────────────────────────────────────────── // ─── Detail ──────────────────────────────────────────────────────────────────
app.get('/:id', (c) => { app.get("/:id", (c) => {
const db = getDb(); const db = getDb();
const id = parseId(c.req.param('id')); const id = parseId(c.req.param("id"));
if (id == null) return c.json({ error: 'invalid id' }, 400); if (id == null) return c.json({ error: "invalid id" }, 400);
const detail = loadDetail(db, id); const detail = loadDetail(db, id);
if (!detail) return c.notFound(); if (!detail) return c.notFound();
return c.json(detail); return c.json(detail);
@@ -257,19 +320,21 @@ app.get('/:id', (c) => {
// ─── Edit stream language ──────────────────────────────────────────────────── // ─── Edit stream language ────────────────────────────────────────────────────
app.patch('/:id/stream/:streamId/language', async (c) => { app.patch("/:id/stream/:streamId/language", async (c) => {
const db = getDb(); const db = getDb();
const itemId = parseId(c.req.param('id')); const itemId = parseId(c.req.param("id"));
const streamId = parseId(c.req.param('streamId')); const streamId = parseId(c.req.param("streamId"));
if (itemId == null || streamId == null) return c.json({ error: 'invalid id' }, 400); if (itemId == null || streamId == null) return c.json({ error: "invalid id" }, 400);
const body = await c.req.json<{ language: string }>(); 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(); if (!stream) return c.notFound();
const normalized = lang ? normalizeLanguage(lang) : null; 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); const detail = loadDetail(db, itemId);
if (!detail) return c.notFound(); if (!detail) return c.notFound();
@@ -278,17 +343,19 @@ app.patch('/:id/stream/:streamId/language', async (c) => {
// ─── Edit stream title ────────────────────────────────────────────────────── // ─── Edit stream title ──────────────────────────────────────────────────────
app.patch('/:id/stream/:streamId/title', async (c) => { app.patch("/:id/stream/:streamId/title", async (c) => {
const db = getDb(); const db = getDb();
const itemId = parseId(c.req.param('id')); const itemId = parseId(c.req.param("id"));
const streamId = parseId(c.req.param('streamId')); const streamId = parseId(c.req.param("streamId"));
if (itemId == null || streamId == null) return c.json({ error: 'invalid id' }, 400); if (itemId == null || streamId == null) return c.json({ error: "invalid id" }, 400);
const body = await c.req.json<{ title: string }>(); 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(); 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); const detail = loadDetail(db, itemId);
if (!detail) return c.notFound(); if (!detail) return c.notFound();
@@ -297,22 +364,28 @@ app.patch('/:id/stream/:streamId/title', async (c) => {
// ─── Extract all ────────────────────────────────────────────────────────────── // ─── Extract all ──────────────────────────────────────────────────────────────
app.post('/extract-all', (c) => { app.post("/extract-all", (c) => {
const db = getDb(); const db = getDb();
// Find items with subtitle streams that haven't been extracted yet // 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 SELECT mi.* FROM media_items mi
WHERE EXISTS (SELECT 1 FROM media_streams ms WHERE ms.item_id = mi.id AND ms.type = 'Subtitle') 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 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')) 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; let queued = 0;
for (const item of items) { 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); const command = buildExtractOnlyCommand(item, streams);
if (!command) continue; 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++; queued++;
} }
@@ -321,22 +394,26 @@ app.post('/extract-all', (c) => {
// ─── Extract ───────────────────────────────────────────────────────────────── // ─── Extract ─────────────────────────────────────────────────────────────────
app.post('/:id/extract', (c) => { app.post("/:id/extract", (c) => {
const db = getDb(); const db = getDb();
const id = parseId(c.req.param('id')); const id = parseId(c.req.param("id"));
if (id == null) return c.json({ error: 'invalid id' }, 400); 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(); if (!item) return c.notFound();
const plan = db.prepare('SELECT * FROM review_plans WHERE item_id = ?').get(id) as ReviewPlan | undefined; 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); 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); 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 }); return c.json({ ok: true });
}); });
@@ -352,36 +429,46 @@ function isSidecarOfItem(filePath: string, videoPath: string): boolean {
return targetDir === videoDir || targetDir.startsWith(videoDir + sep); return targetDir === videoDir || targetDir.startsWith(videoDir + sep);
} }
app.delete('/:id/files/:fileId', (c) => { app.delete("/:id/files/:fileId", (c) => {
const db = getDb(); const db = getDb();
const itemId = parseId(c.req.param('id')); const itemId = parseId(c.req.param("id"));
const fileId = parseId(c.req.param('fileId')); const fileId = parseId(c.req.param("fileId"));
if (itemId == null || fileId == null) return c.json({ error: 'invalid id' }, 400); 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(); 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)) { if (!item || !isSidecarOfItem(file.file_path, item.file_path)) {
logError(`Refusing to delete subtitle file outside media dir: ${file.file_path}`); logError(`Refusing to delete subtitle file outside media dir: ${file.file_path}`);
db.prepare('DELETE FROM subtitle_files WHERE id = ?').run(fileId); 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); 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 */ } try {
db.prepare('DELETE FROM subtitle_files WHERE id = ?').run(fileId); 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 }); return c.json({ ok: true, files });
}); });
// ─── Rescan ────────────────────────────────────────────────────────────────── // ─── Rescan ──────────────────────────────────────────────────────────────────
app.post('/:id/rescan', async (c) => { app.post("/:id/rescan", async (c) => {
const db = getDb(); const db = getDb();
const id = parseId(c.req.param('id')); const id = parseId(c.req.param("id"));
if (id == null) return c.json({ error: 'invalid id' }, 400); 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(); if (!item) return c.notFound();
const cfg = getAllConfig(); 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) title, is_default, is_forced, is_hearing_impaired, channels, channel_layout, bit_rate, sample_rate)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 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 ?? []) { for (const jStream of fresh.MediaStreams ?? []) {
if (jStream.IsExternal) continue; if (jStream.IsExternal) continue;
const s = mapStream(jStream); 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 ───────────────────────────────────────────── // ─── Batch delete subtitle files ─────────────────────────────────────────────
app.post('/batch-delete', async (c) => { app.post("/batch-delete", async (c) => {
const db = getDb(); 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; let deleted = 0;
for (const cat of body.categories) { for (const cat of body.categories) {
const isForced = cat.variant === 'forced' ? 1 : 0; const isForced = cat.variant === "forced" ? 1 : 0;
const isHI = cat.variant === 'cc' ? 1 : 0; const isHI = cat.variant === "cc" ? 1 : 0;
let files: SubtitleFile[]; let files: SubtitleFile[];
if (cat.language === null) { if (cat.language === null) {
files = db.prepare(` files = db
.prepare(`
SELECT * FROM subtitle_files SELECT * FROM subtitle_files
WHERE language IS NULL AND is_forced = ? AND is_hearing_impaired = ? WHERE language IS NULL AND is_forced = ? AND is_hearing_impaired = ?
`).all(isForced, isHI) as SubtitleFile[]; `)
.all(isForced, isHI) as SubtitleFile[];
} else { } else {
files = db.prepare(` files = db
.prepare(`
SELECT * FROM subtitle_files SELECT * FROM subtitle_files
WHERE language = ? AND is_forced = ? AND is_hearing_impaired = ? 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) { 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)) { 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 { } else {
logError(`Refusing to delete subtitle file outside media dir: ${file.file_path}`); 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++; deleted++;
} }
// Reset subs_extracted for affected items that now have no subtitle files // Reset subs_extracted for affected items that now have no subtitle files
const affectedItems = new Set(files.map((f) => f.item_id)); const affectedItems = new Set(files.map((f) => f.item_id));
for (const itemId of affectedItems) { 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) { 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 ──────────────────────────────────────────────────────── // ─── Normalize titles ────────────────────────────────────────────────────────
app.post('/normalize-titles', (c) => { app.post("/normalize-titles", (c) => {
const db = getDb(); const db = getDb();
// Get title groups per language // Get title groups per language
const titleRows = db.prepare(` const titleRows = db
.prepare(`
SELECT language, title, COUNT(*) as cnt SELECT language, title, COUNT(*) as cnt
FROM media_streams WHERE type = 'Subtitle' FROM media_streams WHERE type = 'Subtitle'
GROUP BY language, title GROUP BY language, title
ORDER BY language, cnt DESC 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 // Find canonical (most common) title per language
const canonicalByLang = new Map<string | null, string | null>(); const canonicalByLang = new Map<string | null, string | null>();
@@ -484,31 +600,43 @@ app.post('/normalize-titles', (c) => {
// Find all streams matching this language+title and set custom_title on their decisions // Find all streams matching this language+title and set custom_title on their decisions
let streams: { id: number; item_id: number }[]; let streams: { id: number; item_id: number }[];
if (r.language === null) { if (r.language === null) {
streams = db.prepare(` streams = db
.prepare(`
SELECT id, item_id FROM media_streams SELECT id, item_id FROM media_streams
WHERE type = 'Subtitle' AND language IS NULL AND title IS ? 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 { } else {
streams = db.prepare(` streams = db
.prepare(`
SELECT id, item_id FROM media_streams SELECT id, item_id FROM media_streams
WHERE type = 'Subtitle' AND language = ? AND title IS ? 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) { for (const stream of streams) {
// Ensure review_plan exists // 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) { if (!plan) {
db.prepare("INSERT INTO review_plans (item_id, status, is_noop) VALUES (?, 'pending', 0)").run(stream.item_id); 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 // 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) { 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 { } 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++; normalized++;
} }

View File

@@ -1,29 +1,28 @@
import { Database } from 'bun:sqlite'; import { Database } from "bun:sqlite";
import { join } from 'node:path'; import { mkdirSync } from "node:fs";
import { mkdirSync } from 'node:fs'; import { join } from "node:path";
import { SCHEMA, DEFAULT_CONFIG } from './schema'; import { DEFAULT_CONFIG, SCHEMA } from "./schema";
const dataDir = process.env.DATA_DIR ?? './data'; const dataDir = process.env.DATA_DIR ?? "./data";
mkdirSync(dataDir, { recursive: true }); mkdirSync(dataDir, { recursive: true });
const isDev = process.env.NODE_ENV === 'development'; const isDev = process.env.NODE_ENV === "development";
const dbPath = join(dataDir, isDev ? 'netfelix-dev.db' : 'netfelix.db'); const dbPath = join(dataDir, isDev ? "netfelix-dev.db" : "netfelix.db");
// ─── Env-var → config key mapping ───────────────────────────────────────────── // ─── Env-var → config key mapping ─────────────────────────────────────────────
const ENV_MAP: Record<string, string> = { const ENV_MAP: Record<string, string> = {
jellyfin_url: 'JELLYFIN_URL', jellyfin_url: "JELLYFIN_URL",
jellyfin_api_key: 'JELLYFIN_API_KEY', jellyfin_api_key: "JELLYFIN_API_KEY",
jellyfin_user_id: 'JELLYFIN_USER_ID', jellyfin_user_id: "JELLYFIN_USER_ID",
radarr_url: 'RADARR_URL', radarr_url: "RADARR_URL",
radarr_api_key: 'RADARR_API_KEY', radarr_api_key: "RADARR_API_KEY",
radarr_enabled: 'RADARR_ENABLED', radarr_enabled: "RADARR_ENABLED",
sonarr_url: 'SONARR_URL', sonarr_url: "SONARR_URL",
sonarr_api_key: 'SONARR_API_KEY', sonarr_api_key: "SONARR_API_KEY",
sonarr_enabled: 'SONARR_ENABLED', sonarr_enabled: "SONARR_ENABLED",
subtitle_languages: 'SUBTITLE_LANGUAGES', subtitle_languages: "SUBTITLE_LANGUAGES",
audio_languages: 'AUDIO_LANGUAGES', audio_languages: "AUDIO_LANGUAGES",
}; };
/** Read a config key from environment variables (returns null if not set). */ /** 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; if (!envKey) return null;
const val = process.env[envKey]; const val = process.env[envKey];
if (!val) return null; if (!val) return null;
if (key.endsWith('_enabled')) return val === '1' || val.toLowerCase() === 'true' ? '1' : '0'; 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 === "subtitle_languages" || key === "audio_languages")
if (key.endsWith('_url')) return val.replace(/\/$/, ''); return JSON.stringify(val.split(",").map((s) => s.trim()));
if (key.endsWith("_url")) return val.replace(/\/$/, "");
return val; return val;
} }
@@ -52,23 +52,49 @@ export function getDb(): Database {
_db = new Database(dbPath, { create: true }); _db = new Database(dbPath, { create: true });
_db.exec(SCHEMA); _db.exec(SCHEMA);
// Migrations for columns added after initial release // Migrations for columns added after initial release
try { _db.exec('ALTER TABLE stream_decisions ADD COLUMN custom_title TEXT'); } catch { /* already exists */ } try {
try { _db.exec('ALTER TABLE review_plans ADD COLUMN subs_extracted INTEGER NOT NULL DEFAULT 0'); } catch { /* already exists */ } _db.exec("ALTER TABLE stream_decisions ADD COLUMN custom_title TEXT");
try { _db.exec("ALTER TABLE jobs ADD COLUMN job_type TEXT NOT NULL DEFAULT 'audio'"); } catch { /* already exists */ } } 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 // Apple compat pipeline columns
try { _db.exec("ALTER TABLE review_plans ADD COLUMN confidence TEXT NOT NULL DEFAULT 'low'"); } catch { /* already exists */ } try {
try { _db.exec('ALTER TABLE review_plans ADD COLUMN apple_compat TEXT'); } catch { /* already exists */ } _db.exec("ALTER TABLE review_plans ADD COLUMN confidence TEXT NOT NULL DEFAULT 'low'");
try { _db.exec("ALTER TABLE review_plans ADD COLUMN job_type TEXT NOT NULL DEFAULT 'copy'"); } catch { /* already exists */ } } catch {
try { _db.exec('ALTER TABLE stream_decisions ADD COLUMN transcode_codec TEXT'); } catch { /* already exists */ } /* 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); seedDefaults(_db);
return _db; return _db;
} }
function seedDefaults(db: Database): void { function seedDefaults(db: Database): void {
const insert = db.prepare( const insert = db.prepare("INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)");
'INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)'
);
for (const [key, value] of Object.entries(DEFAULT_CONFIG)) { for (const [key, value] of Object.entries(DEFAULT_CONFIG)) {
insert.run(key, value); insert.run(key, value);
} }
@@ -79,17 +105,13 @@ export function getConfig(key: string): string | null {
const fromEnv = envValue(key); const fromEnv = envValue(key);
if (fromEnv !== null) return fromEnv; if (fromEnv !== null) return fromEnv;
// Auto-complete setup when all required Jellyfin env vars are present // Auto-complete setup when all required Jellyfin env vars are present
if (key === 'setup_complete' && isEnvConfigured()) return '1'; if (key === "setup_complete" && isEnvConfigured()) return "1";
const row = getDb() const row = getDb().prepare("SELECT value FROM config WHERE key = ?").get(key) as { value: string } | undefined;
.prepare('SELECT value FROM config WHERE key = ?')
.get(key) as { value: string } | undefined;
return row?.value ?? null; return row?.value ?? null;
} }
export function setConfig(key: string, value: string): void { export function setConfig(key: string, value: string): void {
getDb() getDb().prepare("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)").run(key, value);
.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)')
.run(key, value);
} }
/** Returns the set of config keys currently overridden by environment variables. */ /** Returns the set of config keys currently overridden by environment variables. */
@@ -102,17 +124,14 @@ export function getEnvLockedKeys(): Set<string> {
} }
export function getAllConfig(): Record<string, string> { export function getAllConfig(): Record<string, string> {
const rows = getDb() const rows = getDb().prepare("SELECT key, value FROM config").all() as { key: string; value: string }[];
.prepare('SELECT key, value FROM config') const result = Object.fromEntries(rows.map((r) => [r.key, r.value ?? ""]));
.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 // Apply env overrides on top of DB values
for (const key of Object.keys(ENV_MAP)) { for (const key of Object.keys(ENV_MAP)) {
const fromEnv = envValue(key); const fromEnv = envValue(key);
if (fromEnv !== null) result[key] = fromEnv; if (fromEnv !== null) result[key] = fromEnv;
} }
// Auto-complete setup when all required Jellyfin env vars are present // 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; return result;
} }

View File

@@ -110,22 +110,22 @@ CREATE INDEX IF NOT EXISTS idx_jobs_item_id ON jobs(item_id);
`; `;
export const DEFAULT_CONFIG: Record<string, string> = { export const DEFAULT_CONFIG: Record<string, string> = {
setup_complete: '0', setup_complete: "0",
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: '0', radarr_enabled: "0",
sonarr_url: '', sonarr_url: "",
sonarr_api_key: '', sonarr_api_key: "",
sonarr_enabled: '0', sonarr_enabled: "0",
subtitle_languages: JSON.stringify(['eng', 'deu', 'spa']), subtitle_languages: JSON.stringify(["eng", "deu", "spa"]),
audio_languages: '[]', audio_languages: "[]",
scan_running: '0', scan_running: "0",
job_sleep_seconds: '0', job_sleep_seconds: "0",
schedule_enabled: '0', schedule_enabled: "0",
schedule_start: '01:00', schedule_start: "01:00",
schedule_end: '07:00', schedule_end: "07:00",
}; };

View File

@@ -1,70 +1,69 @@
import { Hono } from 'hono'; import { Hono } from "hono";
import { serveStatic } from 'hono/bun'; import { serveStatic } from "hono/bun";
import { cors } from 'hono/cors'; import { cors } from "hono/cors";
import { getDb, getConfig } from './db/index'; import dashboardRoutes from "./api/dashboard";
import { log } from './lib/log'; import executeRoutes from "./api/execute";
import pathsRoutes from "./api/paths";
import setupRoutes from './api/setup'; import reviewRoutes from "./api/review";
import scanRoutes from './api/scan'; import scanRoutes from "./api/scan";
import reviewRoutes from './api/review'; import setupRoutes from "./api/setup";
import executeRoutes from './api/execute'; import subtitlesRoutes from "./api/subtitles";
import subtitlesRoutes from './api/subtitles'; import { getDb } from "./db/index";
import dashboardRoutes from './api/dashboard'; import { log } from "./lib/log";
import pathsRoutes from './api/paths';
const app = new Hono(); const app = new Hono();
// ─── CORS (dev: Vite on :5173 talks to Hono on :3000) ──────────────────────── // ─── 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 ────────────────────────────────────────────────────────── // ─── Request logging ──────────────────────────────────────────────────────────
app.use('/api/*', async (c, next) => { app.use("/api/*", async (c, next) => {
const start = Date.now(); const start = Date.now();
await next(); await next();
const ms = Date.now() - start; const ms = Date.now() - start;
// Skip noisy SSE/polling endpoints // 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)`); log(`${c.req.method} ${c.req.path}${c.res.status} (${ms}ms)`);
}); });
// ─── API routes ─────────────────────────────────────────────────────────────── // ─── API routes ───────────────────────────────────────────────────────────────
import pkg from '../package.json'; import pkg from "../package.json";
app.get('/api/version', (c) => c.json({ version: pkg.version })); app.get("/api/version", (c) => c.json({ version: pkg.version }));
app.route('/api/dashboard', dashboardRoutes); app.route("/api/dashboard", dashboardRoutes);
app.route('/api/setup', setupRoutes); app.route("/api/setup", setupRoutes);
app.route('/api/scan', scanRoutes); app.route("/api/scan", scanRoutes);
app.route('/api/review', reviewRoutes); app.route("/api/review", reviewRoutes);
app.route('/api/execute', executeRoutes); app.route("/api/execute", executeRoutes);
app.route('/api/subtitles', subtitlesRoutes); app.route("/api/subtitles", subtitlesRoutes);
app.route('/api/paths', pathsRoutes); app.route("/api/paths", pathsRoutes);
// ─── Static assets (production: serve Vite build) ──────────────────────────── // ─── Static assets (production: serve Vite build) ────────────────────────────
app.use('/assets/*', serveStatic({ root: './dist' })); app.use("/assets/*", serveStatic({ root: "./dist" }));
app.use('/favicon.ico', serveStatic({ path: './dist/favicon.ico' })); app.use("/favicon.ico", serveStatic({ path: "./dist/favicon.ico" }));
// ─── SPA fallback ───────────────────────────────────────────────────────────── // ─── SPA fallback ─────────────────────────────────────────────────────────────
// All non-API routes serve the React index.html so TanStack Router handles them. // All non-API routes serve the React index.html so TanStack Router handles them.
app.get('*', (c) => { app.get("*", (c) => {
const accept = c.req.header('Accept') ?? ''; const _accept = c.req.header("Accept") ?? "";
if (c.req.path.startsWith('/api/')) return c.notFound(); if (c.req.path.startsWith("/api/")) return c.notFound();
// In dev the Vite server handles the SPA. In production serve dist/index.html. // In dev the Vite server handles the SPA. In production serve dist/index.html.
try { try {
const html = Bun.file('./dist/index.html').text(); const html = Bun.file("./dist/index.html").text();
return html.then((text) => c.html(text)); return html.then((text) => c.html(text));
} catch { } 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 ──────────────────────────────────────────────────────────────────── // ─── 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}`); log(`netfelix-audio-fix v${pkg.version} starting on http://localhost:${port}`);

View File

@@ -1,34 +1,34 @@
import { describe, test, expect } from 'bun:test'; import { describe, expect, test } from "bun:test";
import { parseId, isOneOf } from '../validate'; import { isOneOf, parseId } from "../validate";
describe('parseId', () => { describe("parseId", () => {
test('returns the integer for valid numeric strings', () => { test("returns the integer for valid numeric strings", () => {
expect(parseId('42')).toBe(42); expect(parseId("42")).toBe(42);
expect(parseId('1')).toBe(1); expect(parseId("1")).toBe(1);
}); });
test('returns null for invalid, negative, zero, or missing ids', () => { test("returns null for invalid, negative, zero, or missing ids", () => {
expect(parseId('0')).toBe(null); expect(parseId("0")).toBe(null);
expect(parseId('-1')).toBe(null); expect(parseId("-1")).toBe(null);
expect(parseId('abc')).toBe(null); expect(parseId("abc")).toBe(null);
expect(parseId('')).toBe(null); expect(parseId("")).toBe(null);
expect(parseId(undefined)).toBe(null); expect(parseId(undefined)).toBe(null);
}); });
test('parses leading integer from mixed strings (parseInt semantics)', () => { test("parses leading integer from mixed strings (parseInt semantics)", () => {
expect(parseId('42abc')).toBe(42); expect(parseId("42abc")).toBe(42);
}); });
}); });
describe('isOneOf', () => { describe("isOneOf", () => {
test('narrows to allowed string literals', () => { test("narrows to allowed string literals", () => {
expect(isOneOf('keep', ['keep', 'remove'] as const)).toBe(true); expect(isOneOf("keep", ["keep", "remove"] as const)).toBe(true);
expect(isOneOf('remove', ['keep', 'remove'] as const)).toBe(true); expect(isOneOf("remove", ["keep", "remove"] as const)).toBe(true);
}); });
test('rejects disallowed values and non-strings', () => { test("rejects disallowed values and non-strings", () => {
expect(isOneOf('delete', ['keep', 'remove'] as const)).toBe(false); expect(isOneOf("delete", ["keep", "remove"] as const)).toBe(false);
expect(isOneOf(null, ['keep', 'remove'] as const)).toBe(false); expect(isOneOf(null, ["keep", "remove"] as const)).toBe(false);
expect(isOneOf(42, ['keep', 'remove'] as const)).toBe(false); expect(isOneOf(42, ["keep", "remove"] as const)).toBe(false);
}); });
}); });

View File

@@ -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. */ /** Parse a route param as a positive integer id. Returns null if invalid. */
export function parseId(raw: string | undefined): number | null { 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. */ /** True if value is one of the allowed strings. */
export function isOneOf<T extends string>(value: unknown, allowed: readonly T[]): value is T { export function isOneOf<T extends string>(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);
} }

View File

@@ -1,8 +1,8 @@
import { describe, test, expect } from 'bun:test'; import { describe, expect, test } from "bun:test";
import { analyzeItem } from '../analyzer'; import type { MediaStream } from "../../types";
import type { MediaStream } from '../../types'; import { analyzeItem } from "../analyzer";
type StreamOverride = Partial<MediaStream> & Pick<MediaStream, 'id' | 'type' | 'stream_index'>; type StreamOverride = Partial<MediaStream> & Pick<MediaStream, "id" | "type" | "stream_index">;
function stream(o: StreamOverride): MediaStream { function stream(o: StreamOverride): MediaStream {
return { 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', () => { describe("analyzeItem — audio keep rules", () => {
test('keeps only OG + configured languages, drops others', () => { test("keeps only OG + configured languages, drops others", () => {
const streams = [ const streams = [
stream({ id: 1, type: 'Video', stream_index: 0, codec: 'h264' }), stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }), 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: 3, type: "Audio", stream_index: 2, codec: "aac", language: "deu" }),
stream({ id: 4, type: 'Audio', stream_index: 3, codec: 'aac', language: 'fra' }), 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: [], subtitleLanguages: [],
audioLanguages: ['deu'], audioLanguages: ["deu"],
}); });
const actions = Object.fromEntries(result.decisions.map(d => [d.stream_id, d.action])); const actions = Object.fromEntries(result.decisions.map((d) => [d.stream_id, d.action]));
expect(actions).toEqual({ 1: 'keep', 2: 'keep', 3: 'keep', 4: 'remove' }); 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 = [ const streams = [
stream({ id: 1, type: 'Audio', stream_index: 0, language: 'eng' }), stream({ id: 1, type: "Audio", stream_index: 0, language: "eng" }),
stream({ id: 2, type: 'Audio', stream_index: 1, language: 'deu' }), stream({ id: 2, type: "Audio", stream_index: 1, language: "deu" }),
stream({ id: 3, type: 'Audio', stream_index: 2, language: 'fra' }), stream({ id: 3, type: "Audio", stream_index: 2, language: "fra" }),
]; ];
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: null, needs_review: 1 }, streams, { const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: null, needs_review: 1 }, streams, {
subtitleLanguages: [], subtitleLanguages: [],
audioLanguages: ['deu'], audioLanguages: ["deu"],
}); });
expect(result.decisions.every(d => d.action === 'keep')).toBe(true); expect(result.decisions.every((d) => d.action === "keep")).toBe(true);
expect(result.notes.some(n => n.includes('manual review'))).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 = [ const streams = [
stream({ id: 1, type: 'Audio', stream_index: 0, language: 'eng' }), stream({ id: 1, type: "Audio", stream_index: 0, language: "eng" }),
stream({ id: 2, type: 'Audio', stream_index: 1, language: null }), 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: [], subtitleLanguages: [],
audioLanguages: [], audioLanguages: [],
}); });
const byId = Object.fromEntries(result.decisions.map(d => [d.stream_id, d.action])); const byId = Object.fromEntries(result.decisions.map((d) => [d.stream_id, d.action]));
expect(byId[2]).toBe('keep'); expect(byId[2]).toBe("keep");
}); });
test('normalizes language codes (ger → deu)', () => { test("normalizes language codes (ger → deu)", () => {
const streams = [ const streams = [stream({ id: 1, type: "Audio", stream_index: 0, language: "ger" })];
stream({ id: 1, type: 'Audio', stream_index: 0, language: 'ger' }), const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "deu" }, streams, {
];
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'deu' }, streams, {
subtitleLanguages: [], subtitleLanguages: [],
audioLanguages: [], audioLanguages: [],
}); });
expect(result.decisions[0].action).toBe('keep'); expect(result.decisions[0].action).toBe("keep");
}); });
}); });
describe('analyzeItem — audio ordering', () => { describe("analyzeItem — audio ordering", () => {
test('OG first, then additional languages in configured order', () => { test("OG first, then additional languages in configured order", () => {
const streams = [ const streams = [
stream({ id: 10, type: 'Audio', stream_index: 0, codec: 'aac', language: 'deu' }), 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: 11, type: "Audio", stream_index: 1, codec: "aac", language: "eng" }),
stream({ id: 12, type: 'Audio', stream_index: 2, codec: 'aac', language: 'spa' }), 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: [], 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[11]).toBe(0); // eng (OG) first
expect(byId[10]).toBe(1); // deu second expect(byId[10]).toBe(1); // deu second
expect(byId[12]).toBe(2); // spa third 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 = [ const streams = [
stream({ id: 1, type: 'Video', stream_index: 0, codec: 'h264' }), stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
stream({ id: 2, type: 'Audio', stream_index: 1, language: 'deu' }), stream({ id: 2, type: "Audio", stream_index: 1, language: "deu" }),
stream({ id: 3, type: 'Audio', stream_index: 2, language: 'eng' }), 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: [], subtitleLanguages: [],
audioLanguages: ['deu'], audioLanguages: ["deu"],
}); });
expect(result.is_noop).toBe(false); 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 = [ const streams = [
stream({ id: 1, type: 'Video', stream_index: 0, codec: 'h264' }), stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }), 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: 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: [], subtitleLanguages: [],
audioLanguages: ['deu'], audioLanguages: ["deu"],
}); });
expect(result.is_noop).toBe(true); 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 = [ const streams = [
stream({ id: 1, type: 'Audio', stream_index: 0, codec: 'aac', language: 'eng' }), 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: 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: [], subtitleLanguages: [],
audioLanguages: [], audioLanguages: [],
}); });
@@ -135,27 +133,27 @@ describe('analyzeItem — audio ordering', () => {
}); });
}); });
describe('analyzeItem — subtitles & is_noop', () => { describe("analyzeItem — subtitles & is_noop", () => {
test('subtitles are always marked remove (extracted to sidecar)', () => { test("subtitles are always marked remove (extracted to sidecar)", () => {
const streams = [ const streams = [
stream({ id: 1, type: 'Audio', stream_index: 0, codec: 'aac', 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' }), stream({ id: 2, type: "Subtitle", stream_index: 1, codec: "subrip", language: "eng" }),
]; ];
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, { const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, {
subtitleLanguages: ['eng'], subtitleLanguages: ["eng"],
audioLanguages: [], audioLanguages: [],
}); });
const subDec = result.decisions.find(d => d.stream_id === 2); const subDec = result.decisions.find((d) => d.stream_id === 2);
expect(subDec?.action).toBe('remove'); expect(subDec?.action).toBe("remove");
expect(result.is_noop).toBe(false); // subs present → not noop 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 = [ const streams = [
stream({ id: 1, type: 'Video', stream_index: 0, codec: 'h264' }), stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }), 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: [], subtitleLanguages: [],
audioLanguages: [], audioLanguages: [],
}); });
@@ -163,29 +161,25 @@ describe('analyzeItem — subtitles & is_noop', () => {
}); });
}); });
describe('analyzeItem — transcode targets', () => { describe("analyzeItem — transcode targets", () => {
test('DTS on mp4 → transcode to eac3', () => { test("DTS on mp4 → transcode to eac3", () => {
const streams = [ const streams = [stream({ id: 1, type: "Audio", stream_index: 0, codec: "dts", language: "eng" })];
stream({ id: 1, type: 'Audio', stream_index: 0, codec: 'dts', language: 'eng' }), const result = analyzeItem({ original_language: "eng", needs_review: 0, container: "mp4" }, streams, {
];
const result = analyzeItem({ original_language: 'eng', needs_review: 0, container: 'mp4' }, streams, {
subtitleLanguages: [], subtitleLanguages: [],
audioLanguages: [], audioLanguages: [],
}); });
expect(result.decisions[0].transcode_codec).toBe('eac3'); expect(result.decisions[0].transcode_codec).toBe("eac3");
expect(result.job_type).toBe('transcode'); expect(result.job_type).toBe("transcode");
expect(result.is_noop).toBe(false); expect(result.is_noop).toBe(false);
}); });
test('AAC passes through without transcode', () => { test("AAC passes through without transcode", () => {
const streams = [ const streams = [stream({ id: 1, type: "Audio", stream_index: 0, codec: "aac", language: "eng" })];
stream({ id: 1, type: 'Audio', stream_index: 0, codec: 'aac', language: 'eng' }), const result = analyzeItem({ original_language: "eng", needs_review: 0, container: "mp4" }, streams, {
];
const result = analyzeItem({ original_language: 'eng', needs_review: 0, container: 'mp4' }, streams, {
subtitleLanguages: [], subtitleLanguages: [],
audioLanguages: [], audioLanguages: [],
}); });
expect(result.decisions[0].transcode_codec).toBe(null); expect(result.decisions[0].transcode_codec).toBe(null);
expect(result.job_type).toBe('copy'); expect(result.job_type).toBe("copy");
}); });
}); });

View File

@@ -1,8 +1,8 @@
import { describe, test, expect } from 'bun:test'; import { describe, expect, test } from "bun:test";
import { buildCommand, buildPipelineCommand, shellQuote, sortKeptStreams, predictExtractedFiles } from '../ffmpeg'; import type { MediaItem, MediaStream, StreamDecision } from "../../types";
import type { MediaItem, MediaStream, StreamDecision } from '../../types'; import { buildCommand, buildPipelineCommand, predictExtractedFiles, shellQuote, sortKeptStreams } from "../ffmpeg";
function stream(o: Partial<MediaStream> & Pick<MediaStream, 'id' | 'type' | 'stream_index'>): MediaStream { function stream(o: Partial<MediaStream> & Pick<MediaStream, "id" | "type" | "stream_index">): MediaStream {
return { return {
item_id: 1, item_id: 1,
codec: null, codec: null,
@@ -20,7 +20,7 @@ function stream(o: Partial<MediaStream> & Pick<MediaStream, 'id' | 'type' | 'str
}; };
} }
function decision(o: Partial<StreamDecision> & Pick<StreamDecision, 'stream_id' | 'action'>): StreamDecision { function decision(o: Partial<StreamDecision> & Pick<StreamDecision, "stream_id" | "action">): StreamDecision {
return { return {
id: 0, id: 0,
plan_id: 1, plan_id: 1,
@@ -32,162 +32,178 @@ function decision(o: Partial<StreamDecision> & Pick<StreamDecision, 'stream_id'
} }
const ITEM: MediaItem = { const ITEM: MediaItem = {
id: 1, jellyfin_id: 'x', type: 'Movie', name: 'Test', series_name: null, id: 1,
series_jellyfin_id: null, season_number: null, episode_number: null, year: null, jellyfin_id: "x",
file_path: '/movies/Test.mkv', file_size: null, container: 'mkv', type: "Movie",
original_language: 'eng', orig_lang_source: 'jellyfin', needs_review: 0, name: "Test",
imdb_id: null, tmdb_id: null, tvdb_id: null, scan_status: 'scanned', series_name: null,
scan_error: null, last_scanned_at: null, created_at: '', series_jellyfin_id: null,
season_number: null,
episode_number: null,
year: null,
file_path: "/movies/Test.mkv",
file_size: null,
container: "mkv",
original_language: "eng",
orig_lang_source: "jellyfin",
needs_review: 0,
imdb_id: null,
tmdb_id: null,
tvdb_id: null,
scan_status: "scanned",
scan_error: null,
last_scanned_at: null,
created_at: "",
}; };
describe('shellQuote', () => { describe("shellQuote", () => {
test('wraps plain strings in single quotes', () => { test("wraps plain strings in single quotes", () => {
expect(shellQuote('hello')).toBe("'hello'"); expect(shellQuote("hello")).toBe("'hello'");
}); });
test('escapes single quotes safely', () => { test("escapes single quotes safely", () => {
expect(shellQuote("it's")).toBe("'it'\\''s'"); expect(shellQuote("it's")).toBe("'it'\\''s'");
}); });
test('handles paths with spaces', () => { test("handles paths with spaces", () => {
expect(shellQuote('/movies/My Movie.mkv')).toBe("'/movies/My Movie.mkv'"); expect(shellQuote("/movies/My Movie.mkv")).toBe("'/movies/My Movie.mkv'");
}); });
}); });
describe('sortKeptStreams', () => { describe("sortKeptStreams", () => {
test('orders by type priority (Video, Audio, Subtitle, Data), then target_index', () => { test("orders by type priority (Video, Audio, Subtitle, Data), then target_index", () => {
const streams = [ const streams = [
stream({ id: 1, type: 'Audio', stream_index: 1 }), stream({ id: 1, type: "Audio", stream_index: 1 }),
stream({ id: 2, type: 'Video', stream_index: 0 }), stream({ id: 2, type: "Video", stream_index: 0 }),
stream({ id: 3, type: 'Audio', stream_index: 2 }), stream({ id: 3, type: "Audio", stream_index: 2 }),
]; ];
const decisions = [ const decisions = [
decision({ stream_id: 1, action: 'keep', target_index: 1 }), decision({ stream_id: 1, action: "keep", target_index: 1 }),
decision({ stream_id: 2, action: 'keep', target_index: 0 }), decision({ stream_id: 2, action: "keep", target_index: 0 }),
decision({ stream_id: 3, action: 'keep', target_index: 0 }), decision({ stream_id: 3, action: "keep", target_index: 0 }),
]; ];
const sorted = sortKeptStreams(streams, decisions); 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', () => { test("drops streams with action remove", () => {
const streams = [stream({ id: 1, type: 'Audio', stream_index: 0 })]; const streams = [stream({ id: 1, type: "Audio", stream_index: 0 })];
const decisions = [decision({ stream_id: 1, action: 'remove' })]; const decisions = [decision({ stream_id: 1, action: "remove" })];
expect(sortKeptStreams(streams, decisions)).toEqual([]); expect(sortKeptStreams(streams, decisions)).toEqual([]);
}); });
}); });
describe('buildCommand', () => { describe("buildCommand", () => {
test('produces ffmpeg remux with tmp-rename pattern', () => { test("produces ffmpeg remux with tmp-rename pattern", () => {
const streams = [ const streams = [
stream({ id: 1, type: 'Video', stream_index: 0, codec: 'h264' }), stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }), stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng" }),
]; ];
const decisions = [ const decisions = [
decision({ stream_id: 1, action: 'keep', target_index: 0 }), decision({ stream_id: 1, action: "keep", target_index: 0 }),
decision({ stream_id: 2, action: 'keep', target_index: 0 }), decision({ stream_id: 2, action: "keep", target_index: 0 }),
]; ];
const cmd = buildCommand(ITEM, streams, decisions); const cmd = buildCommand(ITEM, streams, decisions);
expect(cmd).toContain('ffmpeg'); expect(cmd).toContain("ffmpeg");
expect(cmd).toContain('-map 0:v:0'); expect(cmd).toContain("-map 0:v:0");
expect(cmd).toContain('-map 0:a:0'); expect(cmd).toContain("-map 0:a:0");
expect(cmd).toContain('-c copy'); expect(cmd).toContain("-c copy");
expect(cmd).toContain("'/movies/Test.tmp.mkv'"); expect(cmd).toContain("'/movies/Test.tmp.mkv'");
expect(cmd).toContain("mv '/movies/Test.tmp.mkv' '/movies/Test.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 = [ const streams = [
stream({ id: 1, type: 'Video', stream_index: 0 }), stream({ id: 1, type: "Video", stream_index: 0 }),
stream({ id: 2, type: 'Audio', stream_index: 1 }), stream({ id: 2, type: "Audio", stream_index: 1 }),
stream({ id: 3, type: 'Audio', stream_index: 2 }), stream({ id: 3, type: "Audio", stream_index: 2 }),
]; ];
// Keep only the second audio; still mapped as 0:a:1 // Keep only the second audio; still mapped as 0:a:1
const decisions = [ const decisions = [
decision({ stream_id: 1, action: 'keep', target_index: 0 }), decision({ stream_id: 1, action: "keep", target_index: 0 }),
decision({ stream_id: 2, action: 'remove' }), decision({ stream_id: 2, action: "remove" }),
decision({ stream_id: 3, action: 'keep', target_index: 0 }), decision({ stream_id: 3, action: "keep", target_index: 0 }),
]; ];
const cmd = buildCommand(ITEM, streams, decisions); const cmd = buildCommand(ITEM, streams, decisions);
expect(cmd).toContain('-map 0:a:1'); expect(cmd).toContain("-map 0:a:1");
expect(cmd).not.toContain('-map 0:a:2'); 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 = [ const streams = [
stream({ id: 1, type: 'Video', stream_index: 0 }), stream({ id: 1, type: "Video", stream_index: 0 }),
stream({ id: 2, type: 'Audio', stream_index: 1, language: 'eng' }), stream({ id: 2, type: "Audio", stream_index: 1, language: "eng" }),
stream({ id: 3, type: 'Audio', stream_index: 2, language: 'deu' }), stream({ id: 3, type: "Audio", stream_index: 2, language: "deu" }),
]; ];
const decisions = [ const decisions = [
decision({ stream_id: 1, action: 'keep', target_index: 0 }), decision({ stream_id: 1, action: "keep", target_index: 0 }),
decision({ stream_id: 2, 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: 3, action: "keep", target_index: 1 }),
]; ];
const cmd = buildCommand(ITEM, streams, decisions); const cmd = buildCommand(ITEM, streams, decisions);
expect(cmd).toContain('-disposition:a:0 default'); expect(cmd).toContain("-disposition:a:0 default");
expect(cmd).toContain('-disposition:a:1 0'); expect(cmd).toContain("-disposition:a:1 0");
}); });
}); });
describe('buildPipelineCommand', () => { describe("buildPipelineCommand", () => {
test('emits subtitle extraction outputs and final remux in one pass', () => { test("emits subtitle extraction outputs and final remux in one pass", () => {
const streams = [ const streams = [
stream({ id: 1, type: 'Video', stream_index: 0 }), stream({ id: 1, type: "Video", stream_index: 0 }),
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }), 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: 3, type: "Subtitle", stream_index: 2, codec: "subrip", language: "eng" }),
]; ];
const decisions = [ const decisions = [
decision({ stream_id: 1, action: 'keep', target_index: 0 }), decision({ stream_id: 1, action: "keep", target_index: 0 }),
decision({ stream_id: 2, action: 'keep', target_index: 0 }), decision({ stream_id: 2, action: "keep", target_index: 0 }),
decision({ stream_id: 3, action: 'remove' }), decision({ stream_id: 3, action: "remove" }),
]; ];
const { command, extractedFiles } = buildPipelineCommand(ITEM, streams, decisions); const { command, extractedFiles } = buildPipelineCommand(ITEM, streams, decisions);
expect(command).toContain('-map 0:s:0'); expect(command).toContain("-map 0:s:0");
expect(command).toContain('-c:s copy'); expect(command).toContain("-c:s copy");
expect(command).toContain("'/movies/Test.en.srt'"); expect(command).toContain("'/movies/Test.en.srt'");
expect(command).toContain('-map 0:v:0'); expect(command).toContain("-map 0:v:0");
expect(command).toContain('-map 0:a:0'); expect(command).toContain("-map 0:a:0");
expect(extractedFiles).toHaveLength(1); 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', () => { test("transcodes incompatible audio with per-track codec flag", () => {
const dtsItem = { ...ITEM, container: 'mp4', file_path: '/movies/x.mp4' }; const dtsItem = { ...ITEM, container: "mp4", file_path: "/movies/x.mp4" };
const streams = [ const streams = [
stream({ id: 1, type: 'Video', stream_index: 0 }), stream({ id: 1, type: "Video", stream_index: 0 }),
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'dts', language: 'eng', channels: 6 }), stream({ id: 2, type: "Audio", stream_index: 1, codec: "dts", language: "eng", channels: 6 }),
]; ];
const decisions = [ const decisions = [
decision({ stream_id: 1, action: 'keep', target_index: 0 }), decision({ stream_id: 1, action: "keep", target_index: 0 }),
decision({ stream_id: 2, action: 'keep', target_index: 0, transcode_codec: 'eac3' }), decision({ stream_id: 2, action: "keep", target_index: 0, transcode_codec: "eac3" }),
]; ];
const { command } = buildPipelineCommand(dtsItem, streams, decisions); const { command } = buildPipelineCommand(dtsItem, streams, decisions);
expect(command).toContain('-c:a:0 eac3'); expect(command).toContain("-c:a:0 eac3");
expect(command).toContain('-b:a:0 640k'); // 6 channels → 640k expect(command).toContain("-b:a:0 640k"); // 6 channels → 640k
}); });
}); });
describe('predictExtractedFiles', () => { describe("predictExtractedFiles", () => {
test('predicts sidecar paths matching extraction output', () => { test("predicts sidecar paths matching extraction output", () => {
const streams = [ const streams = [
stream({ id: 1, type: 'Subtitle', stream_index: 0, 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: 'deu', is_forced: 1 }), stream({ id: 2, type: "Subtitle", stream_index: 1, codec: "subrip", language: "deu", is_forced: 1 }),
]; ];
const files = predictExtractedFiles(ITEM, streams); const files = predictExtractedFiles(ITEM, streams);
expect(files).toHaveLength(2); expect(files).toHaveLength(2);
expect(files[0].file_path).toBe('/movies/Test.en.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].file_path).toBe("/movies/Test.de.forced.srt");
expect(files[1].is_forced).toBe(true); expect(files[1].is_forced).toBe(true);
}); });
test('deduplicates paths with a numeric suffix', () => { test("deduplicates paths with a numeric suffix", () => {
const streams = [ const streams = [
stream({ id: 1, type: 'Subtitle', stream_index: 0, 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' }), stream({ id: 2, type: "Subtitle", stream_index: 1, codec: "subrip", language: "eng" }),
]; ];
const files = predictExtractedFiles(ITEM, streams); const files = predictExtractedFiles(ITEM, streams);
expect(files[0].file_path).toBe('/movies/Test.en.srt'); expect(files[0].file_path).toBe("/movies/Test.en.srt");
expect(files[1].file_path).toBe('/movies/Test.en.2.srt'); expect(files[1].file_path).toBe("/movies/Test.en.2.srt");
}); });
}); });

View File

@@ -1,6 +1,6 @@
import type { MediaItem, MediaStream, PlanResult } from '../types'; import type { MediaItem, MediaStream, PlanResult } from "../types";
import { normalizeLanguage } from './jellyfin'; import { computeAppleCompat, transcodeTarget } from "./apple-compat";
import { transcodeTarget, computeAppleCompat } from './apple-compat'; import { normalizeLanguage } from "./jellyfin";
export interface AnalyzerConfig { export interface AnalyzerConfig {
subtitleLanguages: string[]; subtitleLanguages: string[];
@@ -17,77 +17,73 @@ export interface AnalyzerConfig {
* at all. * at all.
*/ */
export function analyzeItem( export function analyzeItem(
item: Pick<MediaItem, 'original_language' | 'needs_review' | 'container'>, item: Pick<MediaItem, "original_language" | "needs_review" | "container">,
streams: MediaStream[], streams: MediaStream[],
config: AnalyzerConfig config: AnalyzerConfig,
): PlanResult { ): PlanResult {
const origLang = item.original_language ? normalizeLanguage(item.original_language) : null; const origLang = item.original_language ? normalizeLanguage(item.original_language) : null;
const notes: string[] = []; const notes: string[] = [];
const decisions: PlanResult['decisions'] = streams.map((s) => { const decisions: PlanResult["decisions"] = streams.map((s) => {
const action = decideAction(s, origLang, config.audioLanguages); const action = decideAction(s, origLang, config.audioLanguages);
return { stream_id: s.id, action, target_index: null, transcode_codec: null }; 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); assignTargetOrder(streams, decisions, origLang, config.audioLanguages);
const audioOrderChanged = checkAudioOrderChanged(streams, decisions); const audioOrderChanged = checkAudioOrderChanged(streams, decisions);
for (const d of decisions) { for (const d of decisions) {
if (d.action !== 'keep') continue; if (d.action !== "keep") continue;
const stream = streams.find(s => s.id === d.stream_id); const stream = streams.find((s) => s.id === d.stream_id);
if (stream && stream.type === 'Audio') { if (stream && stream.type === "Audio") {
d.transcode_codec = transcodeTarget(stream.codec ?? '', stream.title, item.container); d.transcode_codec = transcodeTarget(stream.codec ?? "", stream.title, item.container);
} }
} }
const keptAudioCodecs = decisions const keptAudioCodecs = decisions
.filter(d => d.action === 'keep') .filter((d) => d.action === "keep")
.map(d => streams.find(s => s.id === d.stream_id)) .map((d) => streams.find((s) => s.id === d.stream_id))
.filter((s): s is MediaStream => !!s && s.type === 'Audio') .filter((s): s is MediaStream => !!s && s.type === "Audio")
.map(s => s.codec ?? ''); .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 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; const is_noop = !anyAudioRemoved && !audioOrderChanged && !hasSubs && !needsTranscode;
if (!origLang && item.needs_review) { 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( function decideAction(stream: MediaStream, origLang: string | null, audioLanguages: string[]): "keep" | "remove" {
stream: MediaStream,
origLang: string | null,
audioLanguages: string[],
): 'keep' | 'remove' {
switch (stream.type) { switch (stream.type) {
case 'Video': case "Video":
case 'Data': case "Data":
case 'EmbeddedImage': case "EmbeddedImage":
return 'keep'; return "keep";
case 'Audio': { case "Audio": {
if (!origLang) return 'keep'; if (!origLang) return "keep";
if (!stream.language) return 'keep'; if (!stream.language) return "keep";
const normalized = normalizeLanguage(stream.language); const normalized = normalizeLanguage(stream.language);
if (normalized === origLang) return 'keep'; if (normalized === origLang) return "keep";
if (audioLanguages.includes(normalized)) return 'keep'; if (audioLanguages.includes(normalized)) return "keep";
return 'remove'; return "remove";
} }
case 'Subtitle': case "Subtitle":
return 'remove'; return "remove";
default: default:
return 'keep'; return "keep";
} }
} }
@@ -99,19 +95,19 @@ function decideAction(
*/ */
export function assignTargetOrder( export function assignTargetOrder(
allStreams: MediaStream[], allStreams: MediaStream[],
decisions: PlanResult['decisions'], decisions: PlanResult["decisions"],
origLang: string | null, origLang: string | null,
audioLanguages: string[], audioLanguages: string[],
): void { ): void {
const keptByType = new Map<string, MediaStream[]>(); const keptByType = new Map<string, MediaStream[]>();
for (const s of allStreams) { for (const s of allStreams) {
const dec = decisions.find(d => d.stream_id === s.id); const dec = decisions.find((d) => d.stream_id === s.id);
if (dec?.action !== 'keep') continue; if (dec?.action !== "keep") continue;
if (!keptByType.has(s.type)) keptByType.set(s.type, []); if (!keptByType.has(s.type)) keptByType.set(s.type, []);
keptByType.get(s.type)!.push(s); keptByType.get(s.type)!.push(s);
} }
const audio = keptByType.get('Audio'); const audio = keptByType.get("Audio");
if (audio) { if (audio) {
audio.sort((a, b) => { audio.sort((a, b) => {
const aRank = langRank(a.language, origLang, audioLanguages); const aRank = langRank(a.language, origLang, audioLanguages);
@@ -123,7 +119,7 @@ export function assignTargetOrder(
for (const [, streams] of keptByType) { for (const [, streams] of keptByType) {
streams.forEach((s, idx) => { 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; 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 * original order in the input. Compares original stream_index order
* against target_index order. * against target_index order.
*/ */
function checkAudioOrderChanged( function checkAudioOrderChanged(streams: MediaStream[], decisions: PlanResult["decisions"]): boolean {
streams: MediaStream[],
decisions: PlanResult['decisions']
): boolean {
const keptAudio = streams 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); .sort((a, b) => a.stream_index - b.stream_index);
for (let i = 0; i < keptAudio.length; i++) { 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; if (dec?.target_index !== i) return true;
} }
return false; return false;

View File

@@ -3,64 +3,67 @@
// Everything else (DTS family, TrueHD family) needs transcoding. // Everything else (DTS family, TrueHD family) needs transcoding.
const APPLE_COMPATIBLE_AUDIO = new Set([ const APPLE_COMPATIBLE_AUDIO = new Set([
'aac', 'ac3', 'eac3', 'alac', 'flac', 'mp3', "aac",
'pcm_s16le', 'pcm_s24le', 'pcm_s32le', 'pcm_f32le', "ac3",
'pcm_s16be', 'pcm_s24be', 'pcm_s32be', 'pcm_f64le', "eac3",
'opus', "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 // Codec strings Jellyfin may report for DTS variants
const DTS_CODECS = new Set([ const DTS_CODECS = new Set(["dts", "dca"]);
'dts', 'dca',
]);
const TRUEHD_CODECS = new Set([ const TRUEHD_CODECS = new Set(["truehd"]);
'truehd',
]);
export function isAppleCompatible(codec: string): boolean { export function isAppleCompatible(codec: string): boolean {
return APPLE_COMPATIBLE_AUDIO.has(codec.toLowerCase()); return APPLE_COMPATIBLE_AUDIO.has(codec.toLowerCase());
} }
/** Maps (codec, profile, container) → target codec for transcoding. */ /** Maps (codec, profile, container) → target codec for transcoding. */
export function transcodeTarget( export function transcodeTarget(codec: string, profile: string | null, container: string | null): string | null {
codec: string,
profile: string | null,
container: string | null,
): string | null {
const c = codec.toLowerCase(); 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 if (isAppleCompatible(c)) return null; // no transcode needed
// DTS-HD MA and DTS:X are lossless → FLAC in MKV, EAC3 in MP4 // DTS-HD MA and DTS:X are lossless → FLAC in MKV, EAC3 in MP4
if (DTS_CODECS.has(c)) { if (DTS_CODECS.has(c)) {
const p = (profile ?? '').toLowerCase(); const p = (profile ?? "").toLowerCase();
const isLossless = p.includes('ma') || p.includes('hd ma') || p.includes('x'); const isLossless = p.includes("ma") || p.includes("hd ma") || p.includes("x");
if (isLossless) return isMkv ? 'flac' : 'eac3'; if (isLossless) return isMkv ? "flac" : "eac3";
// Lossy DTS variants → EAC3 // Lossy DTS variants → EAC3
return 'eac3'; return "eac3";
} }
// TrueHD (including Atmos) → FLAC in MKV, EAC3 in MP4 // TrueHD (including Atmos) → FLAC in MKV, EAC3 in MP4
if (TRUEHD_CODECS.has(c)) { if (TRUEHD_CODECS.has(c)) {
return isMkv ? 'flac' : 'eac3'; return isMkv ? "flac" : "eac3";
} }
// Any other incompatible codec → EAC3 as safe fallback // Any other incompatible codec → EAC3 as safe fallback
return 'eac3'; return "eac3";
} }
/** Determine overall Apple compatibility for a set of kept audio streams. */ /** Determine overall Apple compatibility for a set of kept audio streams. */
export function computeAppleCompat( export function computeAppleCompat(
keptAudioCodecs: string[], keptAudioCodecs: string[],
container: string | null, container: string | null,
): 'direct_play' | 'remux' | 'audio_transcode' { ): "direct_play" | "remux" | "audio_transcode" {
const hasIncompatible = keptAudioCodecs.some(c => !isAppleCompatible(c)); const hasIncompatible = keptAudioCodecs.some((c) => !isAppleCompatible(c));
if (hasIncompatible) return 'audio_transcode'; if (hasIncompatible) return "audio_transcode";
const isMkv = !container || container.toLowerCase() === 'mkv' || container.toLowerCase() === 'matroska'; const isMkv = !container || container.toLowerCase() === "mkv" || container.toLowerCase() === "matroska";
if (isMkv) return 'remux'; if (isMkv) return "remux";
return 'direct_play'; return "direct_play";
} }

View File

@@ -1,44 +1,83 @@
import type { MediaItem, MediaStream, StreamDecision } from '../types'; import type { MediaItem, MediaStream, StreamDecision } from "../types";
import { normalizeLanguage } from './jellyfin'; import { normalizeLanguage } from "./jellyfin";
// ─── Subtitle extraction helpers ────────────────────────────────────────────── // ─── Subtitle extraction helpers ──────────────────────────────────────────────
/** ISO 639-2/B → ISO 639-1 two-letter codes for subtitle filenames. */ /** ISO 639-2/B → ISO 639-1 two-letter codes for subtitle filenames. */
const ISO639_1: Record<string, string> = { const ISO639_1: Record<string, string> = {
eng: 'en', deu: 'de', spa: 'es', fra: 'fr', ita: 'it', eng: "en",
por: 'pt', jpn: 'ja', kor: 'ko', zho: 'zh', ara: 'ar', deu: "de",
rus: 'ru', nld: 'nl', swe: 'sv', nor: 'no', dan: 'da', spa: "es",
fin: 'fi', pol: 'pl', tur: 'tr', tha: 'th', hin: 'hi', fra: "fr",
hun: 'hu', ces: 'cs', ron: 'ro', ell: 'el', heb: 'he', ita: "it",
fas: 'fa', ukr: 'uk', ind: 'id', cat: 'ca', nob: 'nb', por: "pt",
nno: 'nn', isl: 'is', hrv: 'hr', slk: 'sk', bul: 'bg', jpn: "ja",
srp: 'sr', slv: 'sl', lav: 'lv', lit: 'lt', est: 'et', 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. */ /** Subtitle codec → external file extension. */
const SUBTITLE_EXT: Record<string, string> = { const SUBTITLE_EXT: Record<string, string> = {
subrip: 'srt', srt: 'srt', ass: 'ass', ssa: 'ssa', subrip: "srt",
webvtt: 'vtt', vtt: 'vtt', srt: "srt",
hdmv_pgs_subtitle: 'sup', pgssub: 'sup', ass: "ass",
dvd_subtitle: 'sub', dvbsub: 'sub', ssa: "ssa",
mov_text: 'srt', text: 'srt', 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 { function subtitleLang2(lang: string | null): string {
if (!lang) return 'und'; if (!lang) return "und";
const n = normalizeLanguage(lang); const n = normalizeLanguage(lang);
return ISO639_1[n] ?? n; return ISO639_1[n] ?? n;
} }
/** Returns the ffmpeg codec name to use when extracting this subtitle stream. */ /** Returns the ffmpeg codec name to use when extracting this subtitle stream. */
function subtitleCodecArg(codec: string | null): string { function subtitleCodecArg(codec: string | null): string {
if (!codec) return 'copy'; if (!codec) return "copy";
return codec.toLowerCase() === 'mov_text' ? 'subrip' : 'copy'; return codec.toLowerCase() === "mov_text" ? "subrip" : "copy";
} }
function subtitleExtForCodec(codec: string | null): string { function subtitleExtForCodec(codec: string | null): string {
if (!codec) return 'srt'; if (!codec) return "srt";
return SUBTITLE_EXT[codec.toLowerCase()] ?? '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. */ /** Compute extraction metadata for all subtitle streams. Shared by buildExtractionOutputs and predictExtractedFiles. */
function computeExtractionEntries( function computeExtractionEntries(allStreams: MediaStream[], basePath: string): ExtractionEntry[] {
allStreams: MediaStream[],
basePath: string
): ExtractionEntry[] {
const subTypeIdx = new Map<number, number>(); const subTypeIdx = new Map<number, number>();
let subCount = 0; let subCount = 0;
for (const s of [...allStreams].sort((a, b) => a.stream_index - b.stream_index)) { 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 const allSubs = allStreams.filter((s) => s.type === "Subtitle").sort((a, b) => a.stream_index - b.stream_index);
.filter((s) => s.type === 'Subtitle')
.sort((a, b) => a.stream_index - b.stream_index);
if (allSubs.length === 0) return []; if (allSubs.length === 0) return [];
@@ -86,13 +120,13 @@ function computeExtractionEntries(
const codecArg = subtitleCodecArg(s.codec); const codecArg = subtitleCodecArg(s.codec);
const nameParts = [langCode]; const nameParts = [langCode];
if (s.is_forced) nameParts.push('forced'); if (s.is_forced) nameParts.push("forced");
if (s.is_hearing_impaired) nameParts.push('hi'); if (s.is_hearing_impaired) nameParts.push("hi");
let outPath = `${basePath}.${nameParts.join('.')}.${ext}`; let outPath = `${basePath}.${nameParts.join(".")}.${ext}`;
let counter = 2; let counter = 2;
while (usedNames.has(outPath)) { while (usedNames.has(outPath)) {
outPath = `${basePath}.${nameParts.join('.')}.${counter}.${ext}`; outPath = `${basePath}.${nameParts.join(".")}.${counter}.${ext}`;
counter++; counter++;
} }
usedNames.add(outPath); usedNames.add(outPath);
@@ -103,10 +137,7 @@ function computeExtractionEntries(
return entries; return entries;
} }
function buildExtractionOutputs( function buildExtractionOutputs(allStreams: MediaStream[], basePath: string): string[] {
allStreams: MediaStream[],
basePath: string
): string[] {
const entries = computeExtractionEntries(allStreams, basePath); const entries = computeExtractionEntries(allStreams, basePath);
const args: string[] = []; const args: string[] = [];
for (const e of entries) { for (const e of entries) {
@@ -121,9 +152,15 @@ function buildExtractionOutputs(
*/ */
export function predictExtractedFiles( export function predictExtractedFiles(
item: MediaItem, item: MediaItem,
streams: MediaStream[] streams: MediaStream[],
): Array<{ file_path: string; language: string | null; codec: string | null; is_forced: boolean; is_hearing_impaired: boolean }> { ): Array<{
const basePath = item.file_path.replace(/\.[^.]+$/, ''); 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); const entries = computeExtractionEntries(streams, basePath);
return entries.map((e) => ({ return entries.map((e) => ({
file_path: e.outPath, file_path: e.outPath,
@@ -137,21 +174,50 @@ export function predictExtractedFiles(
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
const LANG_NAMES: Record<string, string> = { const LANG_NAMES: Record<string, string> = {
eng: 'English', deu: 'German', spa: 'Spanish', fra: 'French', eng: "English",
ita: 'Italian', por: 'Portuguese', jpn: 'Japanese', kor: 'Korean', deu: "German",
zho: 'Chinese', ara: 'Arabic', rus: 'Russian', nld: 'Dutch', spa: "Spanish",
swe: 'Swedish', nor: 'Norwegian', dan: 'Danish', fin: 'Finnish', fra: "French",
pol: 'Polish', tur: 'Turkish', tha: 'Thai', hin: 'Hindi', ita: "Italian",
hun: 'Hungarian', ces: 'Czech', ron: 'Romanian', ell: 'Greek', por: "Portuguese",
heb: 'Hebrew', fas: 'Persian', ukr: 'Ukrainian', ind: 'Indonesian', jpn: "Japanese",
cat: 'Catalan', nob: 'Norwegian Bokmål', nno: 'Norwegian Nynorsk', kor: "Korean",
isl: 'Icelandic', slk: 'Slovak', hrv: 'Croatian', bul: 'Bulgarian', zho: "Chinese",
srp: 'Serbian', slv: 'Slovenian', lav: 'Latvian', lit: 'Lithuanian', ara: "Arabic",
est: 'Estonian', 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 { 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 // Subtitles always get a clean language-based title so Jellyfin displays
// "German", "English (Forced)", etc. regardless of the original file title. // "German", "English (Forced)", etc. regardless of the original file title.
// The review UI shows a ⚠ badge when the original title looks like a // 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(); return LANG_NAMES[lang] ?? lang.toUpperCase();
} }
const TYPE_SPEC: Record<string, string> = { Video: 'v', Audio: 'a', Subtitle: 's' }; const TYPE_SPEC: Record<string, string> = { Video: "v", Audio: "a", Subtitle: "s" };
/** /**
* Build -map flags using type-relative specifiers (0:v:N, 0:a:N, 0:s:N). * Build -map flags using type-relative specifiers (0:v:N, 0:a:N, 0:s:N).
@@ -181,10 +247,7 @@ const TYPE_SPEC: Record<string, string> = { Video: 'v', Audio: 'a', Subtitle: 's
* as attachments). Using the stream's position within its own type group * as attachments). Using the stream's position within its own type group
* matches ffmpeg's 0:a:N convention exactly and avoids silent mismatches. * matches ffmpeg's 0:a:N convention exactly and avoids silent mismatches.
*/ */
function buildMaps( function buildMaps(allStreams: MediaStream[], kept: { stream: MediaStream; dec: StreamDecision }[]): string[] {
allStreams: MediaStream[],
kept: { stream: MediaStream; dec: StreamDecision }[]
): string[] {
// Map each stream id → its 0-based position among streams of the same type, // 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). // sorted by stream_index (the order ffmpeg sees them in the input).
const typePos = new Map<number, number>(); const typePos = new Map<number, number>();
@@ -206,15 +269,13 @@ function buildMaps(
* - Marks the first kept audio stream as default, clears all others. * - Marks the first kept audio stream as default, clears all others.
* - Sets harmonized language-name titles on all kept audio streams. * - Sets harmonized language-name titles on all kept audio streams.
*/ */
function buildStreamFlags( function buildStreamFlags(kept: { stream: MediaStream; dec: StreamDecision }[]): string[] {
kept: { stream: MediaStream; dec: StreamDecision }[] const audioKept = kept.filter((k) => k.stream.type === "Audio");
): string[] {
const audioKept = kept.filter((k) => k.stream.type === 'Audio');
const args: string[] = []; const args: string[] = [];
// Disposition: first audio = default, rest = clear // Disposition: first audio = default, rest = clear
audioKept.forEach((_, i) => { 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) // Titles for audio streams (custom_title overrides generated title)
@@ -236,12 +297,12 @@ const TYPE_ORDER: Record<string, number> = { Video: 0, Audio: 1, Subtitle: 2, Da
*/ */
export function sortKeptStreams( export function sortKeptStreams(
streams: MediaStream[], streams: MediaStream[],
decisions: StreamDecision[] decisions: StreamDecision[],
): { stream: MediaStream; dec: StreamDecision }[] { ): { stream: MediaStream; dec: StreamDecision }[] {
const kept: { stream: MediaStream; dec: StreamDecision }[] = []; const kept: { stream: MediaStream; dec: StreamDecision }[] = [];
for (const s of streams) { for (const s of streams) {
const dec = decisions.find(d => d.stream_id === s.id); const dec = decisions.find((d) => d.stream_id === s.id);
if (dec?.action === 'keep') kept.push({ stream: s, dec }); if (dec?.action === "keep") kept.push({ stream: s, dec });
} }
kept.sort((a, b) => { kept.sort((a, b) => {
const ta = TYPE_ORDER[a.stream.type] ?? 9; 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). * Returns null if all streams are kept and ordering is unchanged (noop).
*/ */
export function buildCommand( export function buildCommand(item: MediaItem, streams: MediaStream[], decisions: StreamDecision[]): string {
item: MediaItem,
streams: MediaStream[],
decisions: StreamDecision[]
): string {
const kept = sortKeptStreams(streams, decisions); const kept = sortKeptStreams(streams, decisions);
const inputPath = item.file_path; const inputPath = item.file_path;
const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? 'mkv'; const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? "mkv";
const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`); const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`);
const maps = buildMaps(streams, kept); const maps = buildMaps(streams, kept);
const streamFlags = buildStreamFlags(kept); const streamFlags = buildStreamFlags(kept);
const parts: string[] = [ const parts: string[] = [
'ffmpeg', "ffmpeg",
'-y', "-y",
'-i', shellQuote(inputPath), "-i",
shellQuote(inputPath),
...maps, ...maps,
...streamFlags, ...streamFlags,
'-c copy', "-c copy",
shellQuote(tmpPath), 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. * Build a command that also changes the container to MKV.
* Used when MP4 container can't hold certain subtitle codecs. * Used when MP4 container can't hold certain subtitle codecs.
*/ */
export function buildMkvConvertCommand( export function buildMkvConvertCommand(item: MediaItem, streams: MediaStream[], decisions: StreamDecision[]): string {
item: MediaItem,
streams: MediaStream[],
decisions: StreamDecision[]
): string {
const inputPath = item.file_path; const inputPath = item.file_path;
const outputPath = inputPath.replace(/\.[^.]+$/, '.mkv'); const outputPath = inputPath.replace(/\.[^.]+$/, ".mkv");
const tmpPath = inputPath.replace(/\.[^.]+$/, '.tmp.mkv'); const tmpPath = inputPath.replace(/\.[^.]+$/, ".tmp.mkv");
const kept = sortKeptStreams(streams, decisions); const kept = sortKeptStreams(streams, decisions);
@@ -306,16 +362,20 @@ export function buildMkvConvertCommand(
const streamFlags = buildStreamFlags(kept); const streamFlags = buildStreamFlags(kept);
return [ return [
'ffmpeg', '-y', "ffmpeg",
'-i', shellQuote(inputPath), "-y",
"-i",
shellQuote(inputPath),
...maps, ...maps,
...streamFlags, ...streamFlags,
'-c copy', "-c copy",
'-f matroska', "-f matroska",
shellQuote(tmpPath), shellQuote(tmpPath),
'&&', "&&",
'mv', shellQuote(tmpPath), shellQuote(outputPath), "mv",
].join(' '); shellQuote(tmpPath),
shellQuote(outputPath),
].join(" ");
} }
/** /**
@@ -326,37 +386,38 @@ export function buildMkvConvertCommand(
* track to its own sidecar file, then the final output copies all * track to its own sidecar file, then the final output copies all
* video + audio streams into a temp file without subtitles. * video + audio streams into a temp file without subtitles.
*/ */
export function buildExtractOnlyCommand( export function buildExtractOnlyCommand(item: MediaItem, streams: MediaStream[]): string | null {
item: MediaItem, const basePath = item.file_path.replace(/\.[^.]+$/, "");
streams: MediaStream[]
): string | null {
const basePath = item.file_path.replace(/\.[^.]+$/, '');
const extractionOutputs = buildExtractionOutputs(streams, basePath); const extractionOutputs = buildExtractionOutputs(streams, basePath);
if (extractionOutputs.length === 0) return null; if (extractionOutputs.length === 0) return null;
const inputPath = item.file_path; const inputPath = item.file_path;
const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? 'mkv'; const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? "mkv";
const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`); const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`);
// Only map audio if the file actually has audio streams // Only map audio if the file actually has audio streams
const hasAudio = streams.some((s) => s.type === 'Audio'); const hasAudio = streams.some((s) => s.type === "Audio");
const remuxMaps = hasAudio ? ['-map 0:v', '-map 0:a'] : ['-map 0:v']; const remuxMaps = hasAudio ? ["-map 0:v", "-map 0:a"] : ["-map 0:v"];
// Single ffmpeg pass: extract sidecar files + remux without subtitles // Single ffmpeg pass: extract sidecar files + remux without subtitles
const parts: string[] = [ const parts: string[] = [
'ffmpeg', '-y', "ffmpeg",
'-i', shellQuote(inputPath), "-y",
"-i",
shellQuote(inputPath),
// Subtitle extraction outputs (each to its own file) // Subtitle extraction outputs (each to its own file)
...extractionOutputs, ...extractionOutputs,
// Final output: copy all video + audio, no subtitles // Final output: copy all video + audio, no subtitles
...remuxMaps, ...remuxMaps,
'-c copy', "-c copy",
shellQuote(tmpPath), 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( export function buildPipelineCommand(
item: MediaItem, item: MediaItem,
streams: MediaStream[], streams: MediaStream[],
decisions: (StreamDecision & { stream?: MediaStream })[] decisions: (StreamDecision & { stream?: MediaStream })[],
): { command: string; extractedFiles: Array<{ path: string; language: string | null; codec: string | null; is_forced: number; is_hearing_impaired: number }> } { ): {
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 inputPath = item.file_path;
const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? 'mkv'; const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? "mkv";
const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`); const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`);
const basePath = inputPath.replace(/\.[^.]+$/, ''); const basePath = inputPath.replace(/\.[^.]+$/, "");
// --- Subtitle extraction outputs --- // --- Subtitle extraction outputs ---
const extractionEntries = computeExtractionEntries(streams, basePath); const extractionEntries = computeExtractionEntries(streams, basePath);
@@ -384,21 +454,21 @@ export function buildPipelineCommand(
// --- Kept streams for remuxed output --- // --- Kept streams for remuxed output ---
const kept = sortKeptStreams(streams, decisions as StreamDecision[]); 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 // Build -map flags
const maps = buildMaps(streams, kept); const maps = buildMaps(streams, kept);
// Build per-stream codec flags // Build per-stream codec flags
const codecFlags: string[] = ['-c:v copy']; const codecFlags: string[] = ["-c:v copy"];
let audioIdx = 0; let audioIdx = 0;
for (const d of enriched) { for (const d of enriched) {
if (d.stream.type === 'Audio') { if (d.stream.type === "Audio") {
if (d.transcode_codec) { if (d.transcode_codec) {
codecFlags.push(`-c:a:${audioIdx} ${d.transcode_codec}`); codecFlags.push(`-c:a:${audioIdx} ${d.transcode_codec}`);
// For EAC3, set a reasonable bitrate based on channel count // For EAC3, set a reasonable bitrate based on channel count
if (d.transcode_codec === 'eac3') { if (d.transcode_codec === "eac3") {
const bitrate = (d.stream.channels ?? 2) >= 6 ? '640k' : '256k'; const bitrate = (d.stream.channels ?? 2) >= 6 ? "640k" : "256k";
codecFlags.push(`-b:a:${audioIdx} ${bitrate}`); codecFlags.push(`-b:a:${audioIdx} ${bitrate}`);
} }
} else { } else {
@@ -409,17 +479,14 @@ export function buildPipelineCommand(
} }
// If no audio transcoding, simplify to -c copy (covers video + audio) // If no audio transcoding, simplify to -c copy (covers video + audio)
const hasTranscode = enriched.some(d => d.transcode_codec); const hasTranscode = enriched.some((d) => d.transcode_codec);
const finalCodecFlags = hasTranscode ? codecFlags : ['-c copy']; const finalCodecFlags = hasTranscode ? codecFlags : ["-c copy"];
// Disposition + metadata flags for audio // Disposition + metadata flags for audio
const streamFlags = buildStreamFlags(kept); const streamFlags = buildStreamFlags(kept);
// Assemble command // Assemble command
const parts: string[] = [ const parts: string[] = ["ffmpeg", "-y", "-i", shellQuote(inputPath)];
'ffmpeg', '-y',
'-i', shellQuote(inputPath),
];
// Subtitle extraction outputs first // Subtitle extraction outputs first
parts.push(...subOutputArgs); parts.push(...subOutputArgs);
@@ -436,12 +503,11 @@ export function buildPipelineCommand(
// Output file // Output file
parts.push(shellQuote(tmpPath)); parts.push(shellQuote(tmpPath));
const command = parts.join(' ') const command = `${parts.join(" ")} && mv ${shellQuote(tmpPath)} ${shellQuote(inputPath)}`;
+ ` && mv ${shellQuote(tmpPath)} ${shellQuote(inputPath)}`;
return { return {
command, command,
extractedFiles: extractionEntries.map(e => ({ extractedFiles: extractionEntries.map((e) => ({
path: e.outPath, path: e.outPath,
language: e.stream.language, language: e.stream.language,
codec: e.stream.codec, codec: e.stream.codec,
@@ -459,13 +525,13 @@ export function shellQuote(s: string): string {
/** Returns a human-readable summary of what will change. */ /** Returns a human-readable summary of what will change. */
export function summarizeChanges( export function summarizeChanges(
streams: MediaStream[], streams: MediaStream[],
decisions: StreamDecision[] decisions: StreamDecision[],
): { removed: MediaStream[]; kept: MediaStream[] } { ): { removed: MediaStream[]; kept: MediaStream[] } {
const removed: MediaStream[] = []; const removed: MediaStream[] = [];
const kept: MediaStream[] = []; const kept: MediaStream[] = [];
for (const s of streams) { for (const s of streams) {
const dec = decisions.find((d) => d.stream_id === s.id); 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); else kept.push(s);
} }
return { removed, kept }; return { removed, kept };
@@ -477,8 +543,8 @@ export function streamLabel(s: MediaStream): string {
if (s.codec) parts.push(s.codec); if (s.codec) parts.push(s.codec);
if (s.language_display || s.language) parts.push(s.language_display ?? s.language!); if (s.language_display || s.language) parts.push(s.language_display ?? s.language!);
if (s.title) parts.push(`"${s.title}"`); if (s.title) parts.push(`"${s.title}"`);
if (s.type === 'Audio' && s.channels) parts.push(`${s.channels}ch`); if (s.type === "Audio" && s.channels) parts.push(`${s.channels}ch`);
if (s.is_forced) parts.push('forced'); if (s.is_forced) parts.push("forced");
if (s.is_hearing_impaired) parts.push('CC'); if (s.is_hearing_impaired) parts.push("CC");
return parts.join(' · '); return parts.join(" · ");
} }

View File

@@ -1,4 +1,4 @@
import type { JellyfinItem, JellyfinMediaStream, JellyfinUser, MediaStream } from '../types'; import type { JellyfinItem, JellyfinMediaStream, JellyfinUser, MediaStream } from "../types";
export interface JellyfinConfig { export interface JellyfinConfig {
url: string; url: string;
@@ -16,8 +16,8 @@ const PAGE_SIZE = 200;
function headers(apiKey: string): Record<string, string> { function headers(apiKey: string): Record<string, string> {
return { return {
'X-Emby-Token': apiKey, "X-Emby-Token": apiKey,
'Content-Type': 'application/json', "Content-Type": "application/json",
}; };
} }
@@ -33,36 +33,36 @@ export async function testConnection(cfg: JellyfinConfig): Promise<{ ok: boolean
} }
} }
export async function getUsers(cfg: Pick<JellyfinConfig, 'url' | 'apiKey'>): Promise<JellyfinUser[]> { export async function getUsers(cfg: Pick<JellyfinConfig, "url" | "apiKey">): Promise<JellyfinUser[]> {
const res = await fetch(`${cfg.url}/Users`, { headers: headers(cfg.apiKey) }); const res = await fetch(`${cfg.url}/Users`, { headers: headers(cfg.apiKey) });
if (!res.ok) throw new Error(`Jellyfin /Users failed: ${res.status}`); if (!res.ok) throw new Error(`Jellyfin /Users failed: ${res.status}`);
return res.json() as Promise<JellyfinUser[]>; return res.json() as Promise<JellyfinUser[]>;
} }
const ITEM_FIELDS = [ const ITEM_FIELDS = [
'MediaStreams', "MediaStreams",
'Path', "Path",
'ProviderIds', "ProviderIds",
'OriginalTitle', "OriginalTitle",
'ProductionYear', "ProductionYear",
'Size', "Size",
'Container', "Container",
].join(','); ].join(",");
export async function* getAllItems( export async function* getAllItems(
cfg: JellyfinConfig, cfg: JellyfinConfig,
onProgress?: (count: number, total: number) => void onProgress?: (count: number, total: number) => void,
): AsyncGenerator<JellyfinItem> { ): AsyncGenerator<JellyfinItem> {
let startIndex = 0; let startIndex = 0;
let total = 0; let total = 0;
do { do {
const url = new URL(itemsBaseUrl(cfg)); const url = new URL(itemsBaseUrl(cfg));
url.searchParams.set('Recursive', 'true'); url.searchParams.set("Recursive", "true");
url.searchParams.set('IncludeItemTypes', 'Movie,Episode'); url.searchParams.set("IncludeItemTypes", "Movie,Episode");
url.searchParams.set('Fields', ITEM_FIELDS); url.searchParams.set("Fields", ITEM_FIELDS);
url.searchParams.set('Limit', String(PAGE_SIZE)); url.searchParams.set("Limit", String(PAGE_SIZE));
url.searchParams.set('StartIndex', String(startIndex)); url.searchParams.set("StartIndex", String(startIndex));
const res = await fetch(url.toString(), { headers: headers(cfg.apiKey) }); const res = await fetch(url.toString(), { headers: headers(cfg.apiKey) });
if (!res.ok) throw new Error(`Jellyfin items failed: ${res.status}`); 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<JellyfinItem> { export async function* getDevItems(cfg: JellyfinConfig): AsyncGenerator<JellyfinItem> {
// 50 random movies // 50 random movies
const movieUrl = new URL(itemsBaseUrl(cfg)); const movieUrl = new URL(itemsBaseUrl(cfg));
movieUrl.searchParams.set('Recursive', 'true'); movieUrl.searchParams.set("Recursive", "true");
movieUrl.searchParams.set('IncludeItemTypes', 'Movie'); movieUrl.searchParams.set("IncludeItemTypes", "Movie");
movieUrl.searchParams.set('SortBy', 'Random'); movieUrl.searchParams.set("SortBy", "Random");
movieUrl.searchParams.set('Limit', '50'); movieUrl.searchParams.set("Limit", "50");
movieUrl.searchParams.set('Fields', ITEM_FIELDS); movieUrl.searchParams.set("Fields", ITEM_FIELDS);
const movieRes = await fetch(movieUrl.toString(), { headers: headers(cfg.apiKey) }); 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[] }; const movieBody = (await movieRes.json()) as { Items: JellyfinItem[] };
for (const item of movieBody.Items) yield item; for (const item of movieBody.Items) yield item;
// 10 random series → yield all their episodes // 10 random series → yield all their episodes
const seriesUrl = new URL(itemsBaseUrl(cfg)); const seriesUrl = new URL(itemsBaseUrl(cfg));
seriesUrl.searchParams.set('Recursive', 'true'); seriesUrl.searchParams.set("Recursive", "true");
seriesUrl.searchParams.set('IncludeItemTypes', 'Series'); seriesUrl.searchParams.set("IncludeItemTypes", "Series");
seriesUrl.searchParams.set('SortBy', 'Random'); seriesUrl.searchParams.set("SortBy", "Random");
seriesUrl.searchParams.set('Limit', '10'); seriesUrl.searchParams.set("Limit", "10");
const seriesRes = await fetch(seriesUrl.toString(), { headers: headers(cfg.apiKey) }); const seriesRes = await fetch(seriesUrl.toString(), { headers: headers(cfg.apiKey) });
if (!seriesRes.ok) throw new Error(`Jellyfin series failed: HTTP ${seriesRes.status}`); if (!seriesRes.ok) throw new Error(`Jellyfin series failed: HTTP ${seriesRes.status}`);
const seriesBody = (await seriesRes.json()) as { Items: Array<{ Id: string }> }; const seriesBody = (await seriesRes.json()) as { Items: Array<{ Id: string }> };
for (const series of seriesBody.Items) { for (const series of seriesBody.Items) {
const epUrl = new URL(itemsBaseUrl(cfg)); const epUrl = new URL(itemsBaseUrl(cfg));
epUrl.searchParams.set('ParentId', series.Id); epUrl.searchParams.set("ParentId", series.Id);
epUrl.searchParams.set('Recursive', 'true'); epUrl.searchParams.set("Recursive", "true");
epUrl.searchParams.set('IncludeItemTypes', 'Episode'); epUrl.searchParams.set("IncludeItemTypes", "Episode");
epUrl.searchParams.set('Fields', ITEM_FIELDS); epUrl.searchParams.set("Fields", ITEM_FIELDS);
const epRes = await fetch(epUrl.toString(), { headers: headers(cfg.apiKey) }); const epRes = await fetch(epUrl.toString(), { headers: headers(cfg.apiKey) });
if (epRes.ok) { if (epRes.ok) {
@@ -126,7 +127,7 @@ export async function* getDevItems(cfg: JellyfinConfig): AsyncGenerator<Jellyfin
export async function getItem(cfg: JellyfinConfig, jellyfinId: string): Promise<JellyfinItem | null> { export async function getItem(cfg: JellyfinConfig, jellyfinId: string): Promise<JellyfinItem | null> {
const base = cfg.userId ? `${cfg.url}/Users/${cfg.userId}/Items/${jellyfinId}` : `${cfg.url}/Items/${jellyfinId}`; const base = cfg.userId ? `${cfg.url}/Users/${cfg.userId}/Items/${jellyfinId}` : `${cfg.url}/Items/${jellyfinId}`;
const url = new URL(base); 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) }); const res = await fetch(url.toString(), { headers: headers(cfg.apiKey) });
if (!res.ok) return null; if (!res.ok) return null;
return res.json() as Promise<JellyfinItem>; return res.json() as Promise<JellyfinItem>;
@@ -147,11 +148,11 @@ export async function refreshItem(cfg: JellyfinConfig, jellyfinId: string, timeo
// 2. Trigger refresh (returns 204 immediately; refresh runs async) // 2. Trigger refresh (returns 204 immediately; refresh runs async)
const refreshUrl = new URL(`${itemUrl}/Refresh`); const refreshUrl = new URL(`${itemUrl}/Refresh`);
refreshUrl.searchParams.set('MetadataRefreshMode', 'FullRefresh'); refreshUrl.searchParams.set("MetadataRefreshMode", "FullRefresh");
refreshUrl.searchParams.set('ImageRefreshMode', 'None'); refreshUrl.searchParams.set("ImageRefreshMode", "None");
refreshUrl.searchParams.set('ReplaceAllMetadata', 'false'); refreshUrl.searchParams.set("ReplaceAllMetadata", "false");
refreshUrl.searchParams.set('ReplaceAllImages', 'false'); refreshUrl.searchParams.set("ReplaceAllImages", "false");
const refreshRes = await fetch(refreshUrl.toString(), { method: 'POST', headers: headers(cfg.apiKey) }); const refreshRes = await fetch(refreshUrl.toString(), { method: "POST", headers: headers(cfg.apiKey) });
if (!refreshRes.ok) throw new Error(`Jellyfin refresh failed: HTTP ${refreshRes.status}`); if (!refreshRes.ok) throw new Error(`Jellyfin refresh failed: HTTP ${refreshRes.status}`);
// 3. Poll until DateLastRefreshed changes // 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. // Jellyfin doesn't have a direct "original_language" field like TMDb.
// The best proxy is the language of the first audio stream. // The best proxy is the language of the first audio stream.
if (!item.MediaStreams) return null; 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; return firstAudio?.Language ? normalizeLanguage(firstAudio.Language) : null;
} }
/** Map a Jellyfin MediaStream to our internal MediaStream shape (sans id/item_id). */ /** Map a Jellyfin MediaStream to our internal MediaStream shape (sans id/item_id). */
export function mapStream(s: JellyfinMediaStream): Omit<MediaStream, 'id' | 'item_id'> { export function mapStream(s: JellyfinMediaStream): Omit<MediaStream, "id" | "item_id"> {
return { return {
stream_index: s.Index, stream_index: s.Index,
type: s.Type as MediaStream['type'], type: s.Type as MediaStream["type"],
codec: s.Codec ?? null, codec: s.Codec ?? null,
language: s.Language ? normalizeLanguage(s.Language) : null, language: s.Language ? normalizeLanguage(s.Language) : null,
language_display: s.DisplayLanguage ?? null, language_display: s.DisplayLanguage ?? null,
@@ -197,45 +198,45 @@ export function mapStream(s: JellyfinMediaStream): Omit<MediaStream, 'id' | 'ite
// ISO 639-2/T → ISO 639-2/B normalization + common aliases // ISO 639-2/T → ISO 639-2/B normalization + common aliases
const LANG_ALIASES: Record<string, string> = { const LANG_ALIASES: Record<string, string> = {
// German: both /T (deu) and /B (ger) → deu // German: both /T (deu) and /B (ger) → deu
ger: 'deu', ger: "deu",
// Chinese // Chinese
chi: 'zho', chi: "zho",
// French // French
fre: 'fra', fre: "fra",
// Dutch // Dutch
dut: 'nld', dut: "nld",
// Modern Greek // Modern Greek
gre: 'ell', gre: "ell",
// Hebrew // Hebrew
heb: 'heb', heb: "heb",
// Farsi // Farsi
per: 'fas', per: "fas",
// Romanian // Romanian
rum: 'ron', rum: "ron",
// Malay // Malay
may: 'msa', may: "msa",
// Tibetan // Tibetan
tib: 'bod', tib: "bod",
// Burmese // Burmese
bur: 'mya', bur: "mya",
// Czech // Czech
cze: 'ces', cze: "ces",
// Slovak // Slovak
slo: 'slk', slo: "slk",
// Georgian // Georgian
geo: 'kat', geo: "kat",
// Icelandic // Icelandic
ice: 'isl', ice: "isl",
// Armenian // Armenian
arm: 'hye', arm: "hye",
// Basque // Basque
baq: 'eus', baq: "eus",
// Albanian // Albanian
alb: 'sqi', alb: "sqi",
// Macedonian // Macedonian
mac: 'mkd', mac: "mkd",
// Welsh // Welsh
wel: 'cym', wel: "cym",
}; };
export function normalizeLanguage(lang: string): string { export function normalizeLanguage(lang: string): string {

View File

@@ -1,4 +1,4 @@
import { normalizeLanguage } from './jellyfin'; import { normalizeLanguage } from "./jellyfin";
export interface RadarrConfig { export interface RadarrConfig {
url: string; url: string;
@@ -6,7 +6,7 @@ export interface RadarrConfig {
} }
function headers(apiKey: string): Record<string, string> { function headers(apiKey: string): Record<string, string> {
return { 'X-Api-Key': apiKey }; return { "X-Api-Key": apiKey };
} }
export async function testConnection(cfg: RadarrConfig): Promise<{ ok: boolean; error?: string }> { 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. */ /** Returns ISO 639-2 original language or null. */
export async function getOriginalLanguage( export async function getOriginalLanguage(
cfg: RadarrConfig, cfg: RadarrConfig,
ids: { tmdbId?: string; imdbId?: string } ids: { tmdbId?: string; imdbId?: string },
): Promise<string | null> { ): Promise<string | null> {
try { try {
let movie: RadarrMovie | null = null; let movie: RadarrMovie | null = null;
@@ -65,41 +65,41 @@ export async function getOriginalLanguage(
// Radarr returns language names like "English", "French", "German", etc. // Radarr returns language names like "English", "French", "German", etc.
// Map them to ISO 639-2 codes. // Map them to ISO 639-2 codes.
const NAME_TO_639_2: Record<string, string> = { const NAME_TO_639_2: Record<string, string> = {
english: 'eng', english: "eng",
french: 'fra', french: "fra",
german: 'deu', german: "deu",
spanish: 'spa', spanish: "spa",
italian: 'ita', italian: "ita",
portuguese: 'por', portuguese: "por",
japanese: 'jpn', japanese: "jpn",
korean: 'kor', korean: "kor",
chinese: 'zho', chinese: "zho",
arabic: 'ara', arabic: "ara",
russian: 'rus', russian: "rus",
dutch: 'nld', dutch: "nld",
swedish: 'swe', swedish: "swe",
norwegian: 'nor', norwegian: "nor",
danish: 'dan', danish: "dan",
finnish: 'fin', finnish: "fin",
polish: 'pol', polish: "pol",
turkish: 'tur', turkish: "tur",
thai: 'tha', thai: "tha",
hindi: 'hin', hindi: "hin",
hungarian: 'hun', hungarian: "hun",
czech: 'ces', czech: "ces",
romanian: 'ron', romanian: "ron",
greek: 'ell', greek: "ell",
hebrew: 'heb', hebrew: "heb",
persian: 'fas', persian: "fas",
ukrainian: 'ukr', ukrainian: "ukr",
indonesian: 'ind', indonesian: "ind",
malay: 'msa', malay: "msa",
vietnamese: 'vie', vietnamese: "vie",
catalan: 'cat', catalan: "cat",
tamil: 'tam', tamil: "tam",
telugu: 'tel', telugu: "tel",
'brazilian portuguese': 'por', "brazilian portuguese": "por",
'portuguese (brazil)': 'por', "portuguese (brazil)": "por",
}; };
function iso6391To6392(name: string): string | null { function iso6391To6392(name: string): string | null {

View File

@@ -1,26 +1,26 @@
import { getConfig, setConfig } from '../db'; import { getConfig, setConfig } from "../db";
export interface SchedulerState { export interface SchedulerState {
job_sleep_seconds: number; job_sleep_seconds: number;
schedule_enabled: boolean; schedule_enabled: boolean;
schedule_start: string; // "HH:MM" schedule_start: string; // "HH:MM"
schedule_end: string; // "HH:MM" schedule_end: string; // "HH:MM"
} }
export function getSchedulerState(): SchedulerState { export function getSchedulerState(): SchedulerState {
return { return {
job_sleep_seconds: parseInt(getConfig('job_sleep_seconds') ?? '0', 10), job_sleep_seconds: parseInt(getConfig("job_sleep_seconds") ?? "0", 10),
schedule_enabled: getConfig('schedule_enabled') === '1', schedule_enabled: getConfig("schedule_enabled") === "1",
schedule_start: getConfig('schedule_start') ?? '01:00', schedule_start: getConfig("schedule_start") ?? "01:00",
schedule_end: getConfig('schedule_end') ?? '07:00', schedule_end: getConfig("schedule_end") ?? "07:00",
}; };
} }
export function updateSchedulerState(updates: Partial<SchedulerState>): void { export function updateSchedulerState(updates: Partial<SchedulerState>): void {
if (updates.job_sleep_seconds != null) setConfig('job_sleep_seconds', String(updates.job_sleep_seconds)); 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_enabled != null) setConfig("schedule_enabled", updates.schedule_enabled ? "1" : "0");
if (updates.schedule_start != null) setConfig('schedule_start', updates.schedule_start); if (updates.schedule_start != null) setConfig("schedule_start", updates.schedule_start);
if (updates.schedule_end != null) setConfig('schedule_end', updates.schedule_end); if (updates.schedule_end != null) setConfig("schedule_end", updates.schedule_end);
} }
/** Check if current time is within the schedule window. */ /** Check if current time is within the schedule window. */
@@ -63,7 +63,7 @@ export function nextWindowTime(): string {
} }
function parseTime(hhmm: string): number { function parseTime(hhmm: string): number {
const [h, m] = hhmm.split(':').map(Number); const [h, m] = hhmm.split(":").map(Number);
return h * 60 + m; return h * 60 + m;
} }
@@ -71,12 +71,12 @@ function parseTime(hhmm: string): number {
export function sleepBetweenJobs(): Promise<void> { export function sleepBetweenJobs(): Promise<void> {
const seconds = getSchedulerState().job_sleep_seconds; const seconds = getSchedulerState().job_sleep_seconds;
if (seconds <= 0) return Promise.resolve(); 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. */ /** Wait until the schedule window opens. Resolves immediately if already in window. */
export function waitForWindow(): Promise<void> { export function waitForWindow(): Promise<void> {
if (isInScheduleWindow()) return Promise.resolve(); if (isInScheduleWindow()) return Promise.resolve();
const ms = msUntilWindow(); const ms = msUntilWindow();
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }

View File

@@ -1,4 +1,4 @@
import { normalizeLanguage } from './jellyfin'; import { normalizeLanguage } from "./jellyfin";
export interface SonarrConfig { export interface SonarrConfig {
url: string; url: string;
@@ -6,7 +6,7 @@ export interface SonarrConfig {
} }
function headers(apiKey: string): Record<string, string> { function headers(apiKey: string): Record<string, string> {
return { 'X-Api-Key': apiKey }; return { "X-Api-Key": apiKey };
} }
export async function testConnection(cfg: SonarrConfig): Promise<{ ok: boolean; error?: string }> { 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. */ /** Returns ISO 639-2 original language for a series or null. */
export async function getOriginalLanguage( export async function getOriginalLanguage(cfg: SonarrConfig, tvdbId: string): Promise<string | null> {
cfg: SonarrConfig,
tvdbId: string
): Promise<string | null> {
try { try {
const res = await fetch(`${cfg.url}/api/v3/series?tvdbId=${tvdbId}`, { const res = await fetch(`${cfg.url}/api/v3/series?tvdbId=${tvdbId}`, {
headers: headers(cfg.apiKey), headers: headers(cfg.apiKey),
@@ -47,36 +44,36 @@ export async function getOriginalLanguage(
} }
const NAME_TO_639_2: Record<string, string> = { const NAME_TO_639_2: Record<string, string> = {
english: 'eng', english: "eng",
french: 'fra', french: "fra",
german: 'deu', german: "deu",
spanish: 'spa', spanish: "spa",
italian: 'ita', italian: "ita",
portuguese: 'por', portuguese: "por",
japanese: 'jpn', japanese: "jpn",
korean: 'kor', korean: "kor",
chinese: 'zho', chinese: "zho",
arabic: 'ara', arabic: "ara",
russian: 'rus', russian: "rus",
dutch: 'nld', dutch: "nld",
swedish: 'swe', swedish: "swe",
norwegian: 'nor', norwegian: "nor",
danish: 'dan', danish: "dan",
finnish: 'fin', finnish: "fin",
polish: 'pol', polish: "pol",
turkish: 'tur', turkish: "tur",
thai: 'tha', thai: "tha",
hindi: 'hin', hindi: "hin",
hungarian: 'hun', hungarian: "hun",
czech: 'ces', czech: "ces",
romanian: 'ron', romanian: "ron",
greek: 'ell', greek: "ell",
hebrew: 'heb', hebrew: "heb",
persian: 'fas', persian: "fas",
ukrainian: 'ukr', ukrainian: "ukr",
indonesian: 'ind', indonesian: "ind",
malay: 'msa', malay: "msa",
vietnamese: 'vie', vietnamese: "vie",
}; };
function languageNameToCode(name: string): string | null { function languageNameToCode(name: string): string | null {

View File

@@ -3,7 +3,7 @@
export interface MediaItem { export interface MediaItem {
id: number; id: number;
jellyfin_id: string; jellyfin_id: string;
type: 'Movie' | 'Episode'; type: "Movie" | "Episode";
name: string; name: string;
series_name: string | null; series_name: string | null;
series_jellyfin_id: string | null; series_jellyfin_id: string | null;
@@ -14,12 +14,12 @@ export interface MediaItem {
file_size: number | null; file_size: number | null;
container: string | null; container: string | null;
original_language: 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; needs_review: number;
imdb_id: string | null; imdb_id: string | null;
tmdb_id: string | null; tmdb_id: string | null;
tvdb_id: string | null; tvdb_id: string | null;
scan_status: 'pending' | 'scanned' | 'error'; scan_status: "pending" | "scanned" | "error";
scan_error: string | null; scan_error: string | null;
last_scanned_at: string | null; last_scanned_at: string | null;
created_at: string; created_at: string;
@@ -29,7 +29,7 @@ export interface MediaStream {
id: number; id: number;
item_id: number; item_id: number;
stream_index: number; stream_index: number;
type: 'Video' | 'Audio' | 'Subtitle' | 'Data' | 'EmbeddedImage'; type: "Video" | "Audio" | "Subtitle" | "Data" | "EmbeddedImage";
codec: string | null; codec: string | null;
language: string | null; language: string | null;
language_display: string | null; language_display: string | null;
@@ -46,11 +46,11 @@ export interface MediaStream {
export interface ReviewPlan { export interface ReviewPlan {
id: number; id: number;
item_id: number; item_id: number;
status: 'pending' | 'approved' | 'skipped' | 'done' | 'error'; status: "pending" | "approved" | "skipped" | "done" | "error";
is_noop: number; is_noop: number;
confidence: 'high' | 'low'; confidence: "high" | "low";
apple_compat: 'direct_play' | 'remux' | 'audio_transcode' | null; apple_compat: "direct_play" | "remux" | "audio_transcode" | null;
job_type: 'copy' | 'transcode'; job_type: "copy" | "transcode";
subs_extracted: number; subs_extracted: number;
notes: string | null; notes: string | null;
reviewed_at: string | null; reviewed_at: string | null;
@@ -73,7 +73,7 @@ export interface StreamDecision {
id: number; id: number;
plan_id: number; plan_id: number;
stream_id: number; stream_id: number;
action: 'keep' | 'remove'; action: "keep" | "remove";
target_index: number | null; target_index: number | null;
custom_title: string | null; custom_title: string | null;
transcode_codec: string | null; transcode_codec: string | null;
@@ -83,8 +83,8 @@ export interface Job {
id: number; id: number;
item_id: number; item_id: number;
command: string; command: string;
job_type: 'copy' | 'transcode'; job_type: "copy" | "transcode";
status: 'pending' | 'running' | 'done' | 'error'; status: "pending" | "running" | "done" | "error";
output: string | null; output: string | null;
exit_code: number | null; exit_code: number | null;
created_at: string; created_at: string;
@@ -95,17 +95,22 @@ export interface Job {
// ─── Analyzer types ─────────────────────────────────────────────────────────── // ─── Analyzer types ───────────────────────────────────────────────────────────
export interface StreamWithDecision extends MediaStream { export interface StreamWithDecision extends MediaStream {
action: 'keep' | 'remove'; action: "keep" | "remove";
target_index: number | null; target_index: number | null;
} }
export interface PlanResult { export interface PlanResult {
is_noop: boolean; is_noop: boolean;
has_subs: boolean; has_subs: boolean;
confidence: 'high' | 'low'; confidence: "high" | "low";
apple_compat: 'direct_play' | 'remux' | 'audio_transcode' | null; apple_compat: "direct_play" | "remux" | "audio_transcode" | null;
job_type: 'copy' | 'transcode'; job_type: "copy" | "transcode";
decisions: Array<{ stream_id: number; action: 'keep' | 'remove'; target_index: number | null; transcode_codec: string | null }>; decisions: Array<{
stream_id: number;
action: "keep" | "remove";
target_index: number | null;
transcode_codec: string | null;
}>;
notes: string[]; notes: string[];
} }
@@ -161,7 +166,7 @@ export interface ScanProgress {
// ─── SSE event helpers ──────────────────────────────────────────────────────── // ─── SSE event helpers ────────────────────────────────────────────────────────
export type SseEventType = 'progress' | 'log' | 'complete' | 'error'; export type SseEventType = "progress" | "log" | "complete" | "error";
export interface SseEvent { export interface SseEvent {
type: SseEventType; type: SseEventType;

View File

@@ -1,20 +1,29 @@
import { useEffect, useState } from 'react'; import { Link, useNavigate } from "@tanstack/react-router";
import { Link, useNavigate } from '@tanstack/react-router'; import { useEffect, useState } from "react";
import { api } from '~/shared/lib/api'; import { Alert } from "~/shared/components/ui/alert";
import { Button } from '~/shared/components/ui/button'; import { Button } from "~/shared/components/ui/button";
import { Alert } from '~/shared/components/ui/alert'; import { api } from "~/shared/lib/api";
interface Stats { interface Stats {
totalItems: number; scanned: number; needsAction: number; totalItems: number;
approved: number; done: number; errors: number; noChange: 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 }) { function StatCard({ label, value, danger }: { label: string; value: number; danger?: boolean }) {
return ( return (
<div className="border border-gray-200 rounded-lg px-3.5 py-2.5"> <div className="border border-gray-200 rounded-lg px-3.5 py-2.5">
<div className={`text-[1.6rem] font-bold leading-[1.1] ${danger ? 'text-red-600' : ''}`}> <div className={`text-[1.6rem] font-bold leading-[1.1] ${danger ? "text-red-600" : ""}`}>
{value.toLocaleString()} {value.toLocaleString()}
</div> </div>
<div className="text-[0.7rem] text-gray-500 mt-0.5">{label}</div> <div className="text-[0.7rem] text-gray-500 mt-0.5">{label}</div>
@@ -29,17 +38,20 @@ export function DashboardPage() {
const [starting, setStarting] = useState(false); const [starting, setStarting] = useState(false);
useEffect(() => { useEffect(() => {
api.get<DashboardData>('/api/dashboard').then((d) => { api
setData(d); .get<DashboardData>("/api/dashboard")
setLoading(false); .then((d) => {
if (!d.setupComplete) navigate({ to: '/setup' }); setData(d);
}).catch(() => setLoading(false)); setLoading(false);
if (!d.setupComplete) navigate({ to: "/setup" });
})
.catch(() => setLoading(false));
}, [navigate]); }, [navigate]);
const startScan = async () => { const startScan = async () => {
setStarting(true); setStarting(true);
await api.post('/api/scan/start', {}).catch(() => {}); await api.post("/api/scan/start", {}).catch(() => {});
navigate({ to: '/scan' }); navigate({ to: "/scan" });
}; };
if (loading) return <div className="text-gray-400 py-8 text-center">Loading</div>; if (loading) return <div className="text-gray-400 py-8 text-center">Loading</div>;
@@ -65,18 +77,27 @@ export function DashboardPage() {
<div className="flex items-center gap-3 mb-8"> <div className="flex items-center gap-3 mb-8">
{scanRunning ? ( {scanRunning ? (
<Link to="/scan" className="inline-flex items-center justify-center gap-1 rounded px-3 py-1.5 text-sm font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline"> <Link
to="/scan"
className="inline-flex items-center justify-center gap-1 rounded px-3 py-1.5 text-sm font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline"
>
Scan running Scan running
</Link> </Link>
) : ( ) : (
<Button onClick={startScan} disabled={starting}> <Button onClick={startScan} disabled={starting}>
{starting ? 'Starting…' : '▶ Start Scan'} {starting ? "Starting…" : "▶ Start Scan"}
</Button> </Button>
)} )}
<Link to="/review" className="inline-flex items-center justify-center gap-1 rounded px-3 py-1.5 text-sm font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline"> <Link
to="/review"
className="inline-flex items-center justify-center gap-1 rounded px-3 py-1.5 text-sm font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline"
>
Review changes Review changes
</Link> </Link>
<Link to="/execute" className="inline-flex items-center justify-center gap-1 rounded px-3 py-1.5 text-sm font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline"> <Link
to="/execute"
className="inline-flex items-center justify-center gap-1 rounded px-3 py-1.5 text-sm font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline"
>
Execute jobs Execute jobs
</Link> </Link>
</div> </div>

View File

@@ -1,39 +1,46 @@
import { useEffect, useRef, useState } from 'react'; import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import { Link, useNavigate, useSearch } from '@tanstack/react-router'; import { useEffect, useRef, useState } from "react";
import { api } from '~/shared/lib/api'; import { Badge } from "~/shared/components/ui/badge";
import { Badge } from '~/shared/components/ui/badge'; import { Button } from "~/shared/components/ui/button";
import { Button } from '~/shared/components/ui/button'; import { FilterTabs } from "~/shared/components/ui/filter-tabs";
import { FilterTabs } from '~/shared/components/ui/filter-tabs'; import { api } from "~/shared/lib/api";
import type { Job, MediaItem } from '~/shared/lib/types'; import type { Job, MediaItem } from "~/shared/lib/types";
interface JobEntry { job: Job; item: MediaItem | null; } interface JobEntry {
interface ExecuteData { jobs: JobEntry[]; filter: string; totalCounts: Record<string, number>; } job: Job;
item: MediaItem | null;
}
interface ExecuteData {
jobs: JobEntry[];
filter: string;
totalCounts: Record<string, number>;
}
const FILTER_TABS = [ const FILTER_TABS = [
{ key: 'all', label: 'All' }, { key: "all", label: "All" },
{ key: 'pending', label: 'Pending' }, { key: "pending", label: "Pending" },
{ key: 'running', label: 'Running' }, { key: "running", label: "Running" },
{ key: 'done', label: 'Done' }, { key: "done", label: "Done" },
{ key: 'error', label: 'Error' }, { key: "error", label: "Error" },
]; ];
function itemName(job: Job, item: MediaItem | null): string { function itemName(job: Job, item: MediaItem | null): string {
if (!item) return `Item #${job.item_id}`; if (!item) return `Item #${job.item_id}`;
if (item.type === 'Episode' && item.series_name) { 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.series_name} S${String(item.season_number ?? 0).padStart(2, "0")}E${String(item.episode_number ?? 0).padStart(2, "0")}`;
} }
return item.name; return item.name;
} }
function jobTypeLabel(job: Job): string { 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 // Module-level cache for instant tab switching
const cache = new Map<string, ExecuteData>(); const cache = new Map<string, ExecuteData>();
export function ExecutePage() { export function ExecutePage() {
const { filter } = useSearch({ from: '/execute' }); const { filter } = useSearch({ from: "/execute" });
const navigate = useNavigate(); const navigate = useNavigate();
const [data, setData] = useState<ExecuteData | null>(cache.get(filter) ?? null); const [data, setData] = useState<ExecuteData | null>(cache.get(filter) ?? null);
const [loading, setLoading] = useState(!cache.has(filter)); const [loading, setLoading] = useState(!cache.has(filter));
@@ -46,22 +53,35 @@ export function ExecutePage() {
const load = (f?: string) => { const load = (f?: string) => {
const key = f ?? filter; const key = f ?? filter;
const cached = cache.get(key); const cached = cache.get(key);
if (cached && key === filter) { setData(cached); setLoading(false); } if (cached && key === filter) {
else if (key === filter) { setLoading(true); } setData(cached);
api.get<ExecuteData>(`/api/execute?filter=${key}`) setLoading(false);
.then((d) => { cache.set(key, d); if (key === filter) { setData(d); setLoading(false); } }) } else if (key === filter) {
.catch(() => { if (key === filter) setLoading(false); }); setLoading(true);
}
api
.get<ExecuteData>(`/api/execute?filter=${key}`)
.then((d) => {
cache.set(key, d);
if (key === filter) {
setData(d);
setLoading(false);
}
})
.catch(() => {
if (key === filter) setLoading(false);
});
}; };
useEffect(() => { useEffect(() => {
load(); load();
}, [filter]); }, [load]);
// SSE for live job updates // SSE for live job updates
useEffect(() => { useEffect(() => {
const es = new EventSource('/api/execute/events'); const es = new EventSource("/api/execute/events");
esRef.current = es; 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 }; const d = JSON.parse(e.data) as { id: number; status: string; output?: string };
// Update job in current list if present // Update job in current list if present
@@ -71,7 +91,7 @@ export function ExecutePage() {
if (jobIdx === -1) return prev; if (jobIdx === -1) return prev;
const oldStatus = prev.jobs[jobIdx].job.status; const oldStatus = prev.jobs[jobIdx].job.status;
const newStatus = d.status as Job['status']; const newStatus = d.status as Job["status"];
// Live-update totalCounts // Live-update totalCounts
const newCounts = { ...prev.totalCounts }; const newCounts = { ...prev.totalCounts };
@@ -84,18 +104,20 @@ export function ExecutePage() {
return { return {
...prev, ...prev,
totalCounts: newCounts, totalCounts: newCounts,
jobs: prev.jobs.map((j) => jobs: prev.jobs.map((j) => (j.job.id === d.id ? { ...j, job: { ...j.job, status: newStatus } } : j)),
j.job.id === d.id ? { ...j, job: { ...j.job, status: newStatus } } : j
),
}; };
}); });
if (d.output !== undefined) { 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 // 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); if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
reloadTimerRef.current = setTimeout(() => { reloadTimerRef.current = setTimeout(() => {
// Invalidate cache and reload current filter // Invalidate cache and reload current filter
@@ -104,17 +126,50 @@ export function ExecutePage() {
}, 1000); }, 1000);
} }
}); });
return () => { es.close(); if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current); }; return () => {
}, [filter]); es.close();
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
};
}, [load]);
const startAll = async () => { await api.post('/api/execute/start'); cache.clear(); load(); }; const startAll = async () => {
const clearQueue = async () => { await api.post('/api/execute/clear'); cache.clear(); load(); }; await api.post("/api/execute/start");
const clearCompleted = async () => { await api.post('/api/execute/clear-completed'); cache.clear(); load(); }; cache.clear();
const runJob = async (id: number) => { await api.post(`/api/execute/job/${id}/run`); cache.clear(); load(); }; load();
const cancelJob = async (id: number) => { await api.post(`/api/execute/job/${id}/cancel`); 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 toggleLog = (id: number) =>
const toggleCmd = (id: number) => setCmdVisible((prev) => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; }); 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 totalCounts = data?.totalCounts ?? { all: 0, pending: 0, running: 0, done: 0, error: 0 };
const pending = totalCounts.pending ?? 0; const pending = totalCounts.pending ?? 0;
@@ -130,27 +185,31 @@ export function ExecutePage() {
<h1 className="text-xl font-bold mb-4">Execute Jobs</h1> <h1 className="text-xl font-bold mb-4">Execute Jobs</h1>
<div className="border border-gray-200 rounded-lg px-4 py-3 mb-6 flex items-center gap-3 flex-wrap"> <div className="border border-gray-200 rounded-lg px-4 py-3 mb-6 flex items-center gap-3 flex-wrap">
{totalCounts.all === 0 && !loading && ( {totalCounts.all === 0 && !loading && <span className="text-sm text-gray-500">No jobs yet.</span>}
<span className="text-sm text-gray-500">No jobs yet.</span> {totalCounts.all === 0 && loading && <span className="text-sm text-gray-400">Loading...</span>}
)} {allDone && <span className="text-sm font-medium">All jobs completed</span>}
{totalCounts.all === 0 && loading && (
<span className="text-sm text-gray-400">Loading...</span>
)}
{allDone && (
<span className="text-sm font-medium">All jobs completed</span>
)}
{running > 0 && ( {running > 0 && (
<span className="text-sm font-medium">{running} job{running !== 1 ? 's' : ''} running</span> <span className="text-sm font-medium">
{running} job{running !== 1 ? "s" : ""} running
</span>
)} )}
{pending > 0 && ( {pending > 0 && (
<> <>
<span className="text-sm font-medium">{pending} job{pending !== 1 ? 's' : ''} pending</span> <span className="text-sm font-medium">
<Button size="sm" onClick={startAll}>Run all pending</Button> {pending} job{pending !== 1 ? "s" : ""} pending
<Button size="sm" variant="secondary" onClick={clearQueue}>Clear queue</Button> </span>
<Button size="sm" onClick={startAll}>
Run all pending
</Button>
<Button size="sm" variant="secondary" onClick={clearQueue}>
Clear queue
</Button>
</> </>
)} )}
{(done > 0 || errors > 0) && ( {(done > 0 || errors > 0) && (
<Button size="sm" variant="secondary" onClick={clearCompleted}>Clear done/errors</Button> <Button size="sm" variant="secondary" onClick={clearCompleted}>
Clear done/errors
</Button>
)} )}
</div> </div>
@@ -158,83 +217,110 @@ export function ExecutePage() {
tabs={FILTER_TABS} tabs={FILTER_TABS}
filter={filter} filter={filter}
totalCounts={totalCounts} totalCounts={totalCounts}
onFilterChange={(key) => navigate({ to: '/execute', search: { filter: key } as never })} onFilterChange={(key) => navigate({ to: "/execute", search: { filter: key } as never })}
/> />
{loading && !data && <div className="text-gray-400 py-8 text-center">Loading</div>} {loading && !data && <div className="text-gray-400 py-8 text-center">Loading</div>}
{jobs.length > 0 && ( {jobs.length > 0 && (
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0"><table className="w-full border-collapse text-[0.82rem]"> <div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0">
<thead> <table className="w-full border-collapse text-[0.82rem]">
<tr> <thead>
{['#', 'Item', 'Type', 'Status', 'Actions'].map((h) => ( <tr>
<th key={h} className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap">{h}</th> {["#", "Item", "Type", "Status", "Actions"].map((h) => (
))} <th
</tr> key={h}
</thead> className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap"
<tbody> >
{jobs.map(({ job, item }: JobEntry) => { {h}
const name = itemName(job, item); </th>
const jobLog = logs.get(job.id) ?? job.output ?? ''; ))}
const showLog = logVisible.has(job.id) || job.status === 'running' || job.status === 'error'; </tr>
const showCmd = cmdVisible.has(job.id); </thead>
<tbody>
{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 ( return (
<> <>
<tr key={job.id} className="hover:bg-gray-50"> <tr key={job.id} className="hover:bg-gray-50">
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{job.id}</td> <td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{job.id}</td>
<td className="py-1.5 px-2 border-b border-gray-100"> <td className="py-1.5 px-2 border-b border-gray-100">
<div className="truncate max-w-[300px]" title={name}> <div className="truncate max-w-[300px]" title={name}>
{item ? ( {item ? (
<Link to="/review/audio/$id" params={{ id: String(item.id) }} className="text-inherit no-underline hover:underline">{name}</Link> <Link
) : name} to="/review/audio/$id"
</div> params={{ id: String(item.id) }}
</td> className="text-inherit no-underline hover:underline"
<td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap"> >
<Badge variant={job.job_type === 'subtitle' ? 'noop' : 'default'}>{jobTypeLabel(job)}</Badge> {name}
</td> </Link>
<td className="py-1.5 px-2 border-b border-gray-100"> ) : (
<Badge variant={job.status}>{job.status}</Badge> name
{job.exit_code != null && job.exit_code !== 0 && <Badge variant="error" className="ml-1">exit {job.exit_code}</Badge>} )}
</td> </div>
<td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap"> </td>
<div className="flex gap-1 items-center"> <td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap">
{job.status === 'pending' && ( <Badge variant={job.job_type === "subtitle" ? "noop" : "default"}>{jobTypeLabel(job)}</Badge>
<> </td>
<Button size="sm" onClick={() => runJob(job.id)}> Run</Button> <td className="py-1.5 px-2 border-b border-gray-100">
<Button size="sm" variant="secondary" onClick={() => cancelJob(job.id)}></Button> <Badge variant={job.status}>{job.status}</Badge>
</> {job.exit_code != null && job.exit_code !== 0 && (
<Badge variant="error" className="ml-1">
exit {job.exit_code}
</Badge>
)} )}
<Button size="sm" variant="secondary" onClick={() => toggleCmd(job.id)}>Cmd</Button> </td>
{(job.status === 'done' || job.status === 'error') && jobLog && ( <td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap">
<Button size="sm" variant="secondary" onClick={() => toggleLog(job.id)}>Log</Button> <div className="flex gap-1 items-center">
)} {job.status === "pending" && (
</div> <>
</td> <Button size="sm" onClick={() => runJob(job.id)}>
</tr> Run
{showCmd && ( </Button>
<tr key={`cmd-${job.id}`}> <Button size="sm" variant="secondary" onClick={() => cancelJob(job.id)}>
<td colSpan={5} className="p-0 border-b border-gray-100">
<div className="font-mono text-[0.74rem] bg-gray-50 text-gray-700 px-3.5 py-2.5 rounded max-h-[120px] overflow-y-auto whitespace-pre-wrap break-all"> </Button>
{job.command} </>
)}
<Button size="sm" variant="secondary" onClick={() => toggleCmd(job.id)}>
Cmd
</Button>
{(job.status === "done" || job.status === "error") && jobLog && (
<Button size="sm" variant="secondary" onClick={() => toggleLog(job.id)}>
Log
</Button>
)}
</div> </div>
</td> </td>
</tr> </tr>
)} {showCmd && (
{jobLog && showLog && ( <tr key={`cmd-${job.id}`}>
<tr key={`log-${job.id}`}> <td colSpan={5} className="p-0 border-b border-gray-100">
<td colSpan={5} className="p-0 border-b border-gray-100"> <div className="font-mono text-[0.74rem] bg-gray-50 text-gray-700 px-3.5 py-2.5 rounded max-h-[120px] overflow-y-auto whitespace-pre-wrap break-all">
<div className="font-mono text-[0.74rem] bg-[#1a1a1a] text-[#d4d4d4] px-3.5 py-2.5 rounded max-h-[260px] overflow-y-auto whitespace-pre-wrap break-all"> {job.command}
{jobLog} </div>
</div> </td>
</td> </tr>
</tr> )}
)} {jobLog && showLog && (
</> <tr key={`log-${job.id}`}>
); <td colSpan={5} className="p-0 border-b border-gray-100">
})} <div className="font-mono text-[0.74rem] bg-[#1a1a1a] text-[#d4d4d4] px-3.5 py-2.5 rounded max-h-[260px] overflow-y-auto whitespace-pre-wrap break-all">
</tbody> {jobLog}
</table></div> </div>
</td>
</tr>
)}
</>
);
})}
</tbody>
</table>
</div>
)} )}
{!loading && jobs.length === 0 && totalCounts.all > 0 && ( {!loading && jobs.length === 0 && totalCounts.all > 0 && (

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from "react";
import { api } from '~/shared/lib/api'; import { Badge } from "~/shared/components/ui/badge";
import { Badge } from '~/shared/components/ui/badge'; import { Button } from "~/shared/components/ui/button";
import { Button } from '~/shared/components/ui/button'; import { api } from "~/shared/lib/api";
interface PathInfo { interface PathInfo {
prefix: string; prefix: string;
@@ -17,12 +17,18 @@ export function PathsPage() {
const load = () => { const load = () => {
setLoading(true); setLoading(true);
api.get<{ paths: PathInfo[] }>('/api/paths') api
.then((d) => { cache = d.paths; setPaths(d.paths); }) .get<{ paths: PathInfo[] }>("/api/paths")
.then((d) => {
cache = d.paths;
setPaths(d.paths);
})
.finally(() => setLoading(false)); .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 allGood = paths.length > 0 && paths.every((p) => p.accessible);
const hasBroken = paths.some((p) => !p.accessible); const hasBroken = paths.some((p) => !p.accessible);
@@ -35,17 +41,16 @@ export function PathsPage() {
{paths.length === 0 && !loading && ( {paths.length === 0 && !loading && (
<span className="text-sm text-gray-500">No media items scanned yet. Run a scan first.</span> <span className="text-sm text-gray-500">No media items scanned yet. Run a scan first.</span>
)} )}
{paths.length === 0 && loading && ( {paths.length === 0 && loading && <span className="text-sm text-gray-400">Checking paths...</span>}
<span className="text-sm text-gray-400">Checking paths...</span> {allGood && <span className="text-sm font-medium">All {paths.length} paths accessible</span>}
)}
{allGood && (
<span className="text-sm font-medium">All {paths.length} paths accessible</span>
)}
{hasBroken && ( {hasBroken && (
<span className="text-sm font-medium text-red-800">{paths.filter((p) => !p.accessible).length} path{paths.filter((p) => !p.accessible).length !== 1 ? 's' : ''} not mounted</span> <span className="text-sm font-medium text-red-800">
{paths.filter((p) => !p.accessible).length} path{paths.filter((p) => !p.accessible).length !== 1 ? "s" : ""} not
mounted
</span>
)} )}
<Button variant="secondary" size="sm" onClick={load} disabled={loading}> <Button variant="secondary" size="sm" onClick={load} disabled={loading}>
{loading ? 'Checking...' : 'Refresh'} {loading ? "Checking..." : "Refresh"}
</Button> </Button>
</div> </div>
@@ -65,11 +70,7 @@ export function PathsPage() {
<td className="py-2 pr-4 font-mono text-sm">{p.prefix}</td> <td className="py-2 pr-4 font-mono text-sm">{p.prefix}</td>
<td className="py-2 pr-4 text-right tabular-nums">{p.itemCount}</td> <td className="py-2 pr-4 text-right tabular-nums">{p.itemCount}</td>
<td className="py-2"> <td className="py-2">
{p.accessible ? ( {p.accessible ? <Badge variant="keep">Accessible</Badge> : <Badge variant="error">Not mounted</Badge>}
<Badge variant="keep">Accessible</Badge>
) : (
<Badge variant="error">Not mounted</Badge>
)}
</td> </td>
</tr> </tr>
))} ))}
@@ -78,8 +79,8 @@ export function PathsPage() {
{paths.some((p) => !p.accessible) && ( {paths.some((p) => !p.accessible) && (
<p className="mt-4 text-xs text-gray-500"> <p className="mt-4 text-xs text-gray-500">
Paths marked "Not mounted" are not reachable from the container. Paths marked "Not mounted" are not reachable from the container. Mount each path into the Docker container
Mount each path into the Docker container exactly as Jellyfin reports it. exactly as Jellyfin reports it.
</p> </p>
)} )}
</> </>

View File

@@ -1,4 +1,4 @@
import { Badge } from '~/shared/components/ui/badge'; import { Badge } from "~/shared/components/ui/badge";
interface DoneColumnProps { interface DoneColumnProps {
items: any[]; items: any[];
@@ -14,14 +14,10 @@ export function DoneColumn({ items }: DoneColumnProps) {
{items.map((item: any) => ( {items.map((item: any) => (
<div key={item.id} className="rounded border bg-white p-2"> <div key={item.id} className="rounded border bg-white p-2">
<p className="text-xs font-medium truncate">{item.name}</p> <p className="text-xs font-medium truncate">{item.name}</p>
<Badge variant={item.status === 'done' ? 'done' : 'error'}> <Badge variant={item.status === "done" ? "done" : "error"}>{item.status}</Badge>
{item.status}
</Badge>
</div> </div>
))} ))}
{items.length === 0 && ( {items.length === 0 && <p className="text-sm text-gray-400 text-center py-8">No completed items</p>}
<p className="text-sm text-gray-400 text-center py-8">No completed items</p>
)}
</div> </div>
</div> </div>
); );

View File

@@ -1,5 +1,5 @@
import { Badge } from '~/shared/components/ui/badge'; import { Badge } from "~/shared/components/ui/badge";
import { LANG_NAMES, langName } from '~/shared/lib/lang'; import { LANG_NAMES, langName } from "~/shared/lib/lang";
interface PipelineCardProps { interface PipelineCardProps {
item: any; item: any;
@@ -9,15 +9,15 @@ interface PipelineCardProps {
} }
export function PipelineCard({ item, jellyfinUrl, onLanguageChange, onApproveUpTo }: PipelineCardProps) { export function PipelineCard({ item, jellyfinUrl, onLanguageChange, onApproveUpTo }: PipelineCardProps) {
const title = item.type === 'Episode' const title =
? `S${String(item.season_number).padStart(2, '0')}E${String(item.episode_number).padStart(2, '0')}${item.name}` item.type === "Episode"
: item.name; ? `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 const jellyfinLink =
? `${jellyfinUrl}/web/index.html#!/details?id=${item.jellyfin_id}` jellyfinUrl && item.jellyfin_id ? `${jellyfinUrl}/web/index.html#!/details?id=${item.jellyfin_id}` : null;
: null;
return ( return (
<div className={`group rounded-lg border p-3 ${confidenceColor}`}> <div className={`group rounded-lg border p-3 ${confidenceColor}`}>
@@ -40,12 +40,14 @@ export function PipelineCard({ item, jellyfinUrl, onLanguageChange, onApproveUpT
{onLanguageChange ? ( {onLanguageChange ? (
<select <select
className="h-6 text-xs border border-gray-300 rounded px-1 bg-white" className="h-6 text-xs border border-gray-300 rounded px-1 bg-white"
value={item.original_language ?? ''} value={item.original_language ?? ""}
onChange={(e) => onLanguageChange(e.target.value)} onChange={(e) => onLanguageChange(e.target.value)}
> >
<option value="">unknown</option> <option value="">unknown</option>
{Object.entries(LANG_NAMES).map(([code, name]) => ( {Object.entries(LANG_NAMES).map(([code, name]) => (
<option key={code} value={code}>{name}</option> <option key={code} value={code}>
{name}
</option>
))} ))}
</select> </select>
) : ( ) : (
@@ -54,12 +56,11 @@ export function PipelineCard({ item, jellyfinUrl, onLanguageChange, onApproveUpT
{item.transcode_reasons?.length > 0 {item.transcode_reasons?.length > 0
? item.transcode_reasons.map((r: string) => ( ? item.transcode_reasons.map((r: string) => (
<Badge key={r} variant="manual">{r}</Badge> <Badge key={r} variant="manual">
)) {r}
: item.job_type === 'copy' && ( </Badge>
<Badge variant="noop">copy</Badge> ))
) : item.job_type === "copy" && <Badge variant="noop">copy</Badge>}
}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,10 +1,10 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from "react";
import { api } from '~/shared/lib/api'; import { api } from "~/shared/lib/api";
import { ReviewColumn } from './ReviewColumn'; import { DoneColumn } from "./DoneColumn";
import { QueueColumn } from './QueueColumn'; import { ProcessingColumn } from "./ProcessingColumn";
import { ProcessingColumn } from './ProcessingColumn'; import { QueueColumn } from "./QueueColumn";
import { DoneColumn } from './DoneColumn'; import { ReviewColumn } from "./ReviewColumn";
import { ScheduleControls } from './ScheduleControls'; import { ScheduleControls } from "./ScheduleControls";
interface PipelineData { interface PipelineData {
review: any[]; review: any[];
@@ -43,24 +43,26 @@ export function PipelinePage() {
const load = useCallback(async () => { const load = useCallback(async () => {
const [pipelineRes, schedulerRes] = await Promise.all([ const [pipelineRes, schedulerRes] = await Promise.all([
api.get<PipelineData>('/api/review/pipeline'), api.get<PipelineData>("/api/review/pipeline"),
api.get<SchedulerState>('/api/execute/scheduler'), api.get<SchedulerState>("/api/execute/scheduler"),
]); ]);
setData(pipelineRes); setData(pipelineRes);
setScheduler(schedulerRes); setScheduler(schedulerRes);
setLoading(false); setLoading(false);
}, []); }, []);
useEffect(() => { load(); }, [load]); useEffect(() => {
load();
}, [load]);
// SSE for live updates // SSE for live updates
useEffect(() => { useEffect(() => {
const es = new EventSource('/api/execute/events'); const es = new EventSource("/api/execute/events");
es.addEventListener('job_update', () => load()); es.addEventListener("job_update", () => load());
es.addEventListener('job_progress', (e) => { es.addEventListener("job_progress", (e) => {
setProgress(JSON.parse((e as MessageEvent).data)); setProgress(JSON.parse((e as MessageEvent).data));
}); });
es.addEventListener('queue_status', (e) => { es.addEventListener("queue_status", (e) => {
setQueueStatus(JSON.parse((e as MessageEvent).data)); setQueueStatus(JSON.parse((e as MessageEvent).data));
}); });
return () => es.close(); return () => es.close();

View File

@@ -1,4 +1,4 @@
import { Badge } from '~/shared/components/ui/badge'; import { Badge } from "~/shared/components/ui/badge";
interface ProcessingColumnProps { interface ProcessingColumnProps {
items: any[]; items: any[];
@@ -12,18 +12,18 @@ export function ProcessingColumn({ items, progress, queueStatus }: ProcessingCol
const formatTime = (s: number) => { const formatTime = (s: number) => {
const m = Math.floor(s / 60); const m = Math.floor(s / 60);
const sec = 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 ( return (
<div className="flex flex-col w-72 min-w-72 min-h-0 bg-gray-50 rounded-lg"> <div className="flex flex-col w-72 min-w-72 min-h-0 bg-gray-50 rounded-lg">
<div className="px-3 py-2 border-b font-medium text-sm">Processing</div> <div className="px-3 py-2 border-b font-medium text-sm">Processing</div>
<div className="flex-1 p-3"> <div className="flex-1 p-3">
{queueStatus && queueStatus.status !== 'running' && ( {queueStatus && queueStatus.status !== "running" && (
<div className="mb-3 text-xs text-gray-500 bg-white rounded border p-2"> <div className="mb-3 text-xs text-gray-500 bg-white rounded border p-2">
{queueStatus.status === 'paused' && <>Paused until {queueStatus.until}</>} {queueStatus.status === "paused" && <>Paused until {queueStatus.until}</>}
{queueStatus.status === 'sleeping' && <>Sleeping {queueStatus.seconds}s between jobs</>} {queueStatus.status === "sleeping" && <>Sleeping {queueStatus.seconds}s between jobs</>}
{queueStatus.status === 'idle' && <>Idle</>} {queueStatus.status === "idle" && <>Idle</>}
</div> </div>
)} )}
@@ -32,9 +32,7 @@ export function ProcessingColumn({ items, progress, queueStatus }: ProcessingCol
<p className="text-sm font-medium truncate">{job.name}</p> <p className="text-sm font-medium truncate">{job.name}</p>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<Badge variant="running">running</Badge> <Badge variant="running">running</Badge>
<Badge variant={job.job_type === 'transcode' ? 'manual' : 'noop'}> <Badge variant={job.job_type === "transcode" ? "manual" : "noop"}>{job.job_type}</Badge>
{job.job_type}
</Badge>
</div> </div>
{progress && progress.total > 0 && ( {progress && progress.total > 0 && (

View File

@@ -1,4 +1,4 @@
import { Badge } from '~/shared/components/ui/badge'; import { Badge } from "~/shared/components/ui/badge";
interface QueueColumnProps { interface QueueColumnProps {
items: any[]; items: any[];
@@ -14,14 +14,10 @@ export function QueueColumn({ items }: QueueColumnProps) {
{items.map((item: any) => ( {items.map((item: any) => (
<div key={item.id} className="rounded border bg-white p-2"> <div key={item.id} className="rounded border bg-white p-2">
<p className="text-xs font-medium truncate">{item.name}</p> <p className="text-xs font-medium truncate">{item.name}</p>
<Badge variant={item.job_type === 'transcode' ? 'manual' : 'noop'}> <Badge variant={item.job_type === "transcode" ? "manual" : "noop"}>{item.job_type}</Badge>
{item.job_type}
</Badge>
</div> </div>
))} ))}
{items.length === 0 && ( {items.length === 0 && <p className="text-sm text-gray-400 text-center py-8">Queue empty</p>}
<p className="text-sm text-gray-400 text-center py-8">Queue empty</p>
)}
</div> </div>
</div> </div>
); );

View File

@@ -1,6 +1,6 @@
import { api } from '~/shared/lib/api'; import { api } from "~/shared/lib/api";
import { PipelineCard } from './PipelineCard'; import { PipelineCard } from "./PipelineCard";
import { SeriesCard } from './SeriesCard'; import { SeriesCard } from "./SeriesCard";
interface ReviewColumnProps { interface ReviewColumnProps {
items: any[]; items: any[];
@@ -10,10 +10,10 @@ interface ReviewColumnProps {
export function ReviewColumn({ items, jellyfinUrl, onMutate }: ReviewColumnProps) { export function ReviewColumn({ items, jellyfinUrl, onMutate }: ReviewColumnProps) {
// Group by series (movies are standalone) // 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<string, { name: string; key: string; jellyfinId: string | null; episodes: any[] }>(); const seriesMap = new Map<string, { name: string; key: string; jellyfinId: string | null; episodes: any[] }>();
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; const key = item.series_jellyfin_id ?? item.series_name;
if (!seriesMap.has(key)) { if (!seriesMap.has(key)) {
seriesMap.set(key, { name: item.series_name, key, jellyfinId: item.series_jellyfin_id, episodes: [] }); 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) // Interleave movies and series, sorted by confidence (high first)
const allItems = [ const allItems = [
...movies.map((m: any) => ({ type: 'movie' as const, item: m, sortKey: m.confidence === 'high' ? 0 : 1 })), ...movies.map((m: any) => ({ type: "movie" as const, item: m, sortKey: m.confidence === "high" ? 0 : 1 })),
...[...seriesMap.values()].map(s => ({ ...[...seriesMap.values()].map((s) => ({
type: 'series' as const, type: "series" as const,
item: s, 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); ].sort((a, b) => a.sortKey - b.sortKey);
@@ -49,7 +49,7 @@ export function ReviewColumn({ items, jellyfinUrl, onMutate }: ReviewColumnProps
</div> </div>
<div className="flex-1 overflow-y-auto p-2 space-y-2"> <div className="flex-1 overflow-y-auto p-2 space-y-2">
{allItems.map((entry) => { {allItems.map((entry) => {
if (entry.type === 'movie') { if (entry.type === "movie") {
return ( return (
<PipelineCard <PipelineCard
key={entry.item.id} key={entry.item.id}
@@ -77,9 +77,7 @@ export function ReviewColumn({ items, jellyfinUrl, onMutate }: ReviewColumnProps
); );
} }
})} })}
{allItems.length === 0 && ( {allItems.length === 0 && <p className="text-sm text-gray-400 text-center py-8">No items to review</p>}
<p className="text-sm text-gray-400 text-center py-8">No items to review</p>
)}
</div> </div>
</div> </div>
); );

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from "react";
import { api } from '~/shared/lib/api'; import { Button } from "~/shared/components/ui/button";
import { Input } from '~/shared/components/ui/input'; import { Input } from "~/shared/components/ui/input";
import { Button } from '~/shared/components/ui/button'; import { api } from "~/shared/lib/api";
interface ScheduleControlsProps { interface ScheduleControlsProps {
scheduler: { scheduler: {
@@ -18,13 +18,13 @@ export function ScheduleControls({ scheduler, onUpdate }: ScheduleControlsProps)
const [state, setState] = useState(scheduler); const [state, setState] = useState(scheduler);
const save = async () => { const save = async () => {
await api.patch('/api/execute/scheduler', state); await api.patch("/api/execute/scheduler", state);
onUpdate(); onUpdate();
setOpen(false); setOpen(false);
}; };
const startAll = async () => { const startAll = async () => {
await api.post('/api/execute/start'); await api.post("/api/execute/start");
onUpdate(); onUpdate();
}; };
@@ -33,10 +33,7 @@ export function ScheduleControls({ scheduler, onUpdate }: ScheduleControlsProps)
<Button variant="primary" size="sm" onClick={startAll}> <Button variant="primary" size="sm" onClick={startAll}>
Start queue Start queue
</Button> </Button>
<button <button onClick={() => setOpen(!open)} className="text-xs text-gray-500 hover:text-gray-700 cursor-pointer">
onClick={() => setOpen(!open)}
className="text-xs text-gray-500 hover:text-gray-700 cursor-pointer"
>
Schedule settings Schedule settings
</button> </button>
@@ -49,7 +46,7 @@ export function ScheduleControls({ scheduler, onUpdate }: ScheduleControlsProps)
type="number" type="number"
min={0} min={0}
value={state.job_sleep_seconds} 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" className="mb-3"
/> />
@@ -80,7 +77,9 @@ export function ScheduleControls({ scheduler, onUpdate }: ScheduleControlsProps)
</div> </div>
)} )}
<Button variant="primary" size="sm" onClick={save}>Save</Button> <Button variant="primary" size="sm" onClick={save}>
Save
</Button>
</div> </div>
)} )}
</div> </div>

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from "react";
import { api } from '~/shared/lib/api'; import { api } from "~/shared/lib/api";
import { LANG_NAMES } from '~/shared/lib/lang'; import { LANG_NAMES } from "~/shared/lib/lang";
import { PipelineCard } from './PipelineCard'; import { PipelineCard } from "./PipelineCard";
interface SeriesCardProps { interface SeriesCardProps {
seriesKey: string; seriesKey: string;
@@ -13,10 +13,18 @@ interface SeriesCardProps {
onApproveUpTo?: () => void; 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 [expanded, setExpanded] = useState(false);
const seriesLang = episodes[0]?.original_language ?? ''; const seriesLang = episodes[0]?.original_language ?? "";
const setSeriesLanguage = async (lang: string) => { const setSeriesLanguage = async (lang: string) => {
await api.patch(`/api/review/series/${encodeURIComponent(seriesKey)}/language`, { language: lang }); await api.patch(`/api/review/series/${encodeURIComponent(seriesKey)}/language`, { language: lang });
@@ -28,12 +36,11 @@ export function SeriesCard({ seriesKey, seriesName, jellyfinUrl, seriesJellyfinI
onMutate(); onMutate();
}; };
const highCount = episodes.filter((e: any) => e.confidence === 'high').length; const highCount = episodes.filter((e: any) => e.confidence === "high").length;
const lowCount = episodes.filter((e: any) => e.confidence === 'low').length; const lowCount = episodes.filter((e: any) => e.confidence === "low").length;
const jellyfinLink = jellyfinUrl && seriesJellyfinId const jellyfinLink =
? `${jellyfinUrl}/web/index.html#!/details?id=${seriesJellyfinId}` jellyfinUrl && seriesJellyfinId ? `${jellyfinUrl}/web/index.html#!/details?id=${seriesJellyfinId}` : null;
: null;
return ( return (
<div className="group rounded-lg border bg-white overflow-hidden"> <div className="group rounded-lg border bg-white overflow-hidden">
@@ -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" className="flex items-center gap-2 px-3 pt-3 pb-1 cursor-pointer hover:bg-gray-50 rounded-t-lg"
onClick={() => setExpanded(!expanded)} onClick={() => setExpanded(!expanded)}
> >
<span className="text-xs text-gray-400 shrink-0">{expanded ? '▼' : '▶'}</span> <span className="text-xs text-gray-400 shrink-0">{expanded ? "▼" : "▶"}</span>
{jellyfinLink ? ( {jellyfinLink ? (
<a <a
href={jellyfinLink} href={jellyfinLink}
@@ -67,15 +74,23 @@ export function SeriesCard({ seriesKey, seriesName, jellyfinUrl, seriesJellyfinI
<select <select
className="h-6 text-xs border border-gray-300 rounded px-1 bg-white shrink-0" className="h-6 text-xs border border-gray-300 rounded px-1 bg-white shrink-0"
value={seriesLang} value={seriesLang}
onChange={(e) => { e.stopPropagation(); setSeriesLanguage(e.target.value); }} onChange={(e) => {
e.stopPropagation();
setSeriesLanguage(e.target.value);
}}
> >
<option value="">unknown</option> <option value="">unknown</option>
{Object.entries(LANG_NAMES).map(([code, name]) => ( {Object.entries(LANG_NAMES).map(([code, name]) => (
<option key={code} value={code}>{name}</option> <option key={code} value={code}>
{name}
</option>
))} ))}
</select> </select>
<button <button
onClick={(e) => { e.stopPropagation(); approveSeries(); }} onClick={(e) => {
e.stopPropagation();
approveSeries();
}}
className="text-xs px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 cursor-pointer whitespace-nowrap shrink-0" className="text-xs px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 cursor-pointer whitespace-nowrap shrink-0"
> >
Approve all Approve all

View File

@@ -1,18 +1,20 @@
import { useEffect, useState } from 'react'; import { Link, useParams } from "@tanstack/react-router";
import { Link, useParams } from '@tanstack/react-router'; import { useEffect, useState } from "react";
import { api } from '~/shared/lib/api'; import { Alert } from "~/shared/components/ui/alert";
import { Badge } from '~/shared/components/ui/badge'; import { Badge } from "~/shared/components/ui/badge";
import { Button } from '~/shared/components/ui/button'; import { Button } from "~/shared/components/ui/button";
import { Alert } from '~/shared/components/ui/alert'; import { Select } from "~/shared/components/ui/select";
import { Select } from '~/shared/components/ui/select'; import { api } from "~/shared/lib/api";
import { langName, LANG_NAMES } from '~/shared/lib/lang'; import { LANG_NAMES, langName } from "~/shared/lib/lang";
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from '~/shared/lib/types'; import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from "~/shared/lib/types";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
interface DetailData { interface DetailData {
item: MediaItem; streams: MediaStream[]; item: MediaItem;
plan: ReviewPlan | null; decisions: StreamDecision[]; streams: MediaStream[];
plan: ReviewPlan | null;
decisions: StreamDecision[];
command: string | null; command: string | null;
} }
@@ -28,15 +30,15 @@ function formatBytes(bytes: number): string {
function effectiveLabel(s: MediaStream, dec: StreamDecision | undefined): string { function effectiveLabel(s: MediaStream, dec: StreamDecision | undefined): string {
if (dec?.custom_title) return dec.custom_title; if (dec?.custom_title) return dec.custom_title;
if (s.title) return s.title; if (s.title) return s.title;
if (s.type === 'Audio' && s.channels) return `${s.channels}ch ${s.channel_layout ?? ''}`.trim(); if (s.type === "Audio" && s.channels) return `${s.channels}ch ${s.channel_layout ?? ""}`.trim();
return s.language ? langName(s.language) : ''; return s.language ? langName(s.language) : "";
} }
// ─── Stream table ───────────────────────────────────────────────────────────── // ─── Stream table ─────────────────────────────────────────────────────────────
const STREAM_SECTIONS = [ const STREAM_SECTIONS = [
{ type: 'Video', label: 'Video' }, { type: "Video", label: "Video" },
{ type: 'Audio', label: 'Audio' }, { type: "Audio", label: "Audio" },
]; ];
const TYPE_ORDER: Record<string, number> = { Video: 0, Audio: 1, Subtitle: 2, Data: 3, EmbeddedImage: 4 }; const TYPE_ORDER: Record<string, number> = { Video: 0, Audio: 1, Subtitle: 2, Data: 3, EmbeddedImage: 4 };
@@ -44,10 +46,10 @@ const TYPE_ORDER: Record<string, number> = { Video: 0, Audio: 1, Subtitle: 2, Da
/** Compute per-type output indices for kept streams (e.g. a:0, a:1). */ /** Compute per-type output indices for kept streams (e.g. a:0, a:1). */
function computeOutIdx(streams: MediaStream[], decisions: StreamDecision[]): Map<number, string> { function computeOutIdx(streams: MediaStream[], decisions: StreamDecision[]): Map<number, string> {
const mappedKept = streams const mappedKept = streams
.filter((s) => ['Video', 'Audio'].includes(s.type)) .filter((s) => ["Video", "Audio"].includes(s.type))
.filter((s) => { .filter((s) => {
const action = decisions.find((d) => d.stream_id === s.id)?.action; const action = decisions.find((d) => d.stream_id === s.id)?.action;
return action === 'keep'; return action === "keep";
}) })
.sort((a, b) => { .sort((a, b) => {
const ta = TYPE_ORDER[a.type] ?? 9; const ta = TYPE_ORDER[a.type] ?? 9;
@@ -60,7 +62,7 @@ function computeOutIdx(streams: MediaStream[], decisions: StreamDecision[]): Map
const m = new Map<number, string>(); const m = new Map<number, string>();
const typeCounts: Record<string, number> = {}; const typeCounts: Record<string, number> = {};
for (const s of mappedKept) { for (const s of mappedKept) {
const prefix = s.type === 'Video' ? 'v' : 'a'; const prefix = s.type === "Video" ? "v" : "a";
const idx = typeCounts[s.type] ?? 0; const idx = typeCounts[s.type] ?? 0;
m.set(s.id, `${prefix}:${idx}`); m.set(s.id, `${prefix}:${idx}`);
typeCounts[s.type] = idx + 1; typeCounts[s.type] = idx + 1;
@@ -68,14 +70,19 @@ function computeOutIdx(streams: MediaStream[], decisions: StreamDecision[]): Map
return m; return m;
} }
interface StreamTableProps { data: DetailData; onUpdate: (d: DetailData) => void; } interface StreamTableProps {
data: DetailData;
onUpdate: (d: DetailData) => void;
}
function StreamTable({ data, onUpdate }: StreamTableProps) { function StreamTable({ data, onUpdate }: StreamTableProps) {
const { item, streams, plan, decisions } = data; const { item, streams, plan, decisions } = data;
const outIdx = computeOutIdx(streams, decisions); const outIdx = computeOutIdx(streams, decisions);
const toggleStream = async (streamId: number, currentAction: 'keep' | 'remove') => { const toggleStream = async (streamId: number, currentAction: "keep" | "remove") => {
const d = await api.patch<DetailData>(`/api/review/${item.id}/stream/${streamId}`, { action: currentAction === 'keep' ? 'remove' : 'keep' }); const d = await api.patch<DetailData>(`/api/review/${item.id}/stream/${streamId}`, {
action: currentAction === "keep" ? "remove" : "keep",
});
onUpdate(d); onUpdate(d);
}; };
@@ -85,102 +92,113 @@ function StreamTable({ data, onUpdate }: StreamTableProps) {
}; };
return ( return (
<div className="overflow-x-auto -mx-4 px-4 sm:mx-0 sm:px-0"><table className="w-full border-collapse text-[0.79rem] mt-1"> <div className="overflow-x-auto -mx-4 px-4 sm:mx-0 sm:px-0">
<thead> <table className="w-full border-collapse text-[0.79rem] mt-1">
<tr> <thead>
{['Out', 'Codec', 'Language', 'Title / Info', 'Flags', 'Action'].map((h) => ( <tr>
<th key={h} className="text-left text-[0.67rem] uppercase tracking-[0.05em] text-gray-500 py-1 px-2 border-b border-gray-200">{h}</th> {["Out", "Codec", "Language", "Title / Info", "Flags", "Action"].map((h) => (
))} <th
</tr> key={h}
</thead> className="text-left text-[0.67rem] uppercase tracking-[0.05em] text-gray-500 py-1 px-2 border-b border-gray-200"
<tbody> >
{STREAM_SECTIONS.flatMap(({ type, label }) => { {h}
const group = streams.filter((s) => s.type === type); </th>
if (group.length === 0) return []; ))}
return [ </tr>
<tr key={`hdr-${type}`}> </thead>
<td colSpan={6} className="text-[0.67rem] font-bold uppercase tracking-[0.06em] text-gray-500 bg-gray-50 py-0.5 px-2 border-b border-gray-100"> <tbody>
{label} {STREAM_SECTIONS.flatMap(({ type, label }) => {
</td> const group = streams.filter((s) => s.type === type);
</tr>, if (group.length === 0) return [];
...group.map((s) => { return [
const dec = decisions.find((d) => d.stream_id === s.id); <tr key={`hdr-${type}`}>
const action = dec?.action ?? 'keep'; <td
const isAudio = s.type === 'Audio'; colSpan={6}
className="text-[0.67rem] font-bold uppercase tracking-[0.06em] text-gray-500 bg-gray-50 py-0.5 px-2 border-b border-gray-100"
>
{label}
</td>
</tr>,
...group.map((s) => {
const dec = decisions.find((d) => d.stream_id === s.id);
const action = dec?.action ?? "keep";
const isAudio = s.type === "Audio";
const outputNum = outIdx.get(s.id); const outputNum = outIdx.get(s.id);
const lbl = effectiveLabel(s, dec); const lbl = effectiveLabel(s, dec);
const origTitle = s.title; const origTitle = s.title;
const lang = langName(s.language); const lang = langName(s.language);
const isEditable = plan?.status === 'pending' && isAudio; const isEditable = plan?.status === "pending" && isAudio;
const rowBg = action === 'keep' ? 'bg-green-50' : 'bg-red-50'; const rowBg = action === "keep" ? "bg-green-50" : "bg-red-50";
return ( return (
<tr key={s.id} className={rowBg}> <tr key={s.id} className={rowBg}>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs"> <td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">
{outputNum !== undefined ? outputNum : <span className="text-gray-400"></span>} {outputNum !== undefined ? outputNum : <span className="text-gray-400"></span>}
</td> </td>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{s.codec ?? '—'}</td> <td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{s.codec ?? "—"}</td>
<td className="py-1.5 px-2 border-b border-gray-100"> <td className="py-1.5 px-2 border-b border-gray-100">
{isAudio ? ( {isAudio ? (
<>{lang} {s.language ? <span className="text-gray-400 font-mono text-xs">({s.language})</span> : null}</> <>
) : ( {lang} {s.language ? <span className="text-gray-400 font-mono text-xs">({s.language})</span> : null}
<span className="text-gray-400"></span> </>
)} ) : (
</td> <span className="text-gray-400"></span>
<td className="py-1.5 px-2 border-b border-gray-100"> )}
{isEditable ? ( </td>
<TitleInput <td className="py-1.5 px-2 border-b border-gray-100">
value={lbl} {isEditable ? <TitleInput value={lbl} onCommit={(v) => updateTitle(s.id, v)} /> : <span>{lbl || "—"}</span>}
onCommit={(v) => updateTitle(s.id, v)} {isEditable && origTitle && origTitle !== lbl && (
/> <div className="text-gray-400 text-[0.7rem] mt-0.5">orig: {origTitle}</div>
) : ( )}
<span>{lbl || '—'}</span> </td>
)} <td className="py-1.5 px-2 border-b border-gray-100">
{isEditable && origTitle && origTitle !== lbl && ( <span className="inline-flex gap-1">
<div className="text-gray-400 text-[0.7rem] mt-0.5">orig: {origTitle}</div> {s.is_default ? <Badge>default</Badge> : null}
)} {s.is_forced ? <Badge variant="manual">forced</Badge> : null}
</td> {s.is_hearing_impaired ? <Badge>CC</Badge> : null}
<td className="py-1.5 px-2 border-b border-gray-100"> </span>
<span className="inline-flex gap-1"> </td>
{s.is_default ? <Badge>default</Badge> : null} <td className="py-1.5 px-2 border-b border-gray-100">
{s.is_forced ? <Badge variant="manual">forced</Badge> : null} {plan?.status === "pending" && isAudio ? (
{s.is_hearing_impaired ? <Badge>CC</Badge> : null} <button
</span> type="button"
</td> onClick={() => toggleStream(s.id, action)}
<td className="py-1.5 px-2 border-b border-gray-100"> className={`border-0 rounded px-2 py-0.5 text-[0.72rem] font-semibold cursor-pointer min-w-[4.5rem] ${action === "keep" ? "bg-green-600 text-white" : "bg-red-600 text-white"}`}
{plan?.status === 'pending' && isAudio ? ( >
<button {action === "keep" ? "✓ Keep" : "✗ Remove"}
type="button" </button>
onClick={() => toggleStream(s.id, action)} ) : (
className={`border-0 rounded px-2 py-0.5 text-[0.72rem] font-semibold cursor-pointer min-w-[4.5rem] ${action === 'keep' ? 'bg-green-600 text-white' : 'bg-red-600 text-white'}`} <Badge variant={action === "keep" ? "keep" : "remove"}>{action}</Badge>
> )}
{action === 'keep' ? '✓ Keep' : '✗ Remove'} </td>
</button> </tr>
) : ( );
<Badge variant={action === 'keep' ? 'keep' : 'remove'}>{action}</Badge> }),
)} ];
</td> })}
</tr> </tbody>
); </table>
}), </div>
];
})}
</tbody>
</table></div>
); );
} }
function TitleInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) { function TitleInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) {
const [localVal, setLocalVal] = useState(value); const [localVal, setLocalVal] = useState(value);
useEffect(() => { setLocalVal(value); }, [value]); useEffect(() => {
setLocalVal(value);
}, [value]);
return ( return (
<input <input
type="text" type="text"
value={localVal} value={localVal}
onChange={(e) => setLocalVal(e.target.value)} onChange={(e) => setLocalVal(e.target.value)}
onBlur={(e) => { if (e.target.value !== value) onCommit(e.target.value); }} onBlur={(e) => {
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} if (e.target.value !== value) onCommit(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
}}
placeholder="—" 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" 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 ────────────────────────────────────────────────────────────── // ─── Detail page ──────────────────────────────────────────────────────────────
export function AudioDetailPage() { export function AudioDetailPage() {
const { id } = useParams({ from: '/review/audio/$id' }); const { id } = useParams({ from: "/review/audio/$id" });
const [data, setData] = useState<DetailData | null>(null); const [data, setData] = useState<DetailData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [rescanning, setRescanning] = useState(false); const [rescanning, setRescanning] = useState(false);
const load = () => api.get<DetailData>(`/api/review/${id}`).then((d) => { setData(d); setLoading(false); }).catch(() => setLoading(false)); const load = () =>
useEffect(() => { load(); }, [id]); api
.get<DetailData>(`/api/review/${id}`)
.then((d) => {
setData(d);
setLoading(false);
})
.catch(() => setLoading(false));
useEffect(() => {
load();
}, [load]);
const setLanguage = async (lang: string) => { const setLanguage = async (lang: string) => {
const d = await api.patch<DetailData>(`/api/review/${id}/language`, { language: lang || null }); const d = await api.patch<DetailData>(`/api/review/${id}/language`, { language: lang || null });
setData(d); setData(d);
}; };
const approve = async () => { await api.post(`/api/review/${id}/approve`); load(); }; const approve = async () => {
const unapprove = async () => { await api.post(`/api/review/${id}/unapprove`); load(); }; await api.post(`/api/review/${id}/approve`);
const skip = async () => { await api.post(`/api/review/${id}/skip`); load(); }; load();
const unskip = async () => { await api.post(`/api/review/${id}/unskip`); 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 () => { const rescan = async () => {
setRescanning(true); setRescanning(true);
try { const d = await api.post<DetailData>(`/api/review/${id}/rescan`); setData(d); } try {
finally { setRescanning(false); } const d = await api.post<DetailData>(`/api/review/${id}/rescan`);
setData(d);
} finally {
setRescanning(false);
}
}; };
if (loading) return <div className="text-gray-400 py-8 text-center">Loading</div>; if (loading) return <div className="text-gray-400 py-8 text-center">Loading</div>;
if (!data) return <Alert variant="error">Item not found.</Alert>; if (!data) return <Alert variant="error">Item not found.</Alert>;
const { item, plan, command } = data; const { item, plan, command } = data;
const statusKey = plan?.is_noop ? 'noop' : (plan?.status ?? 'pending'); const statusKey = plan?.is_noop ? "noop" : (plan?.status ?? "pending");
return ( return (
<div> <div>
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<h1 className="text-xl font-bold m-0"> <h1 className="text-xl font-bold m-0">
<Link to="/review/audio" className="font-normal mr-2 no-underline text-gray-500 hover:text-gray-700"> Audio</Link> <Link to="/review/audio" className="font-normal mr-2 no-underline text-gray-500 hover:text-gray-700">
Audio
</Link>
{item.name} {item.name}
</h1> </h1>
</div> </div>
@@ -232,12 +277,17 @@ export function AudioDetailPage() {
{/* Meta */} {/* Meta */}
<dl className="flex flex-wrap gap-5 mb-3 text-[0.82rem]"> <dl className="flex flex-wrap gap-5 mb-3 text-[0.82rem]">
{[ {[
{ label: 'Type', value: item.type }, { label: "Type", value: item.type },
...(item.series_name ? [{ label: 'Series', value: item.series_name }] : []), ...(item.series_name ? [{ label: "Series", value: item.series_name }] : []),
...(item.year ? [{ label: 'Year', value: String(item.year) }] : []), ...(item.year ? [{ label: "Year", value: String(item.year) }] : []),
{ label: 'Container', value: item.container ?? '—' }, { label: "Container", value: item.container ?? "—" },
{ label: 'File size', value: item.file_size ? formatBytes(item.file_size) : '—' }, { label: "File size", value: item.file_size ? formatBytes(item.file_size) : "—" },
{ label: 'Status', value: <Badge variant={statusKey as 'noop' | 'pending' | 'approved' | 'skipped' | 'done' | 'error'}>{statusKey}</Badge> }, {
label: "Status",
value: (
<Badge variant={statusKey as "noop" | "pending" | "approved" | "skipped" | "done" | "error"}>{statusKey}</Badge>
),
},
].map((entry, i) => ( ].map((entry, i) => (
<div key={i}> <div key={i}>
<dt className="text-gray-500 text-[0.68rem] uppercase tracking-[0.05em] mb-0.5">{entry.label}</dt> <dt className="text-gray-500 text-[0.68rem] uppercase tracking-[0.05em] mb-0.5">{entry.label}</dt>
@@ -249,7 +299,11 @@ export function AudioDetailPage() {
<div className="font-mono text-gray-400 text-[0.78rem] mb-4 break-all">{item.file_path}</div> <div className="font-mono text-gray-400 text-[0.78rem] mb-4 break-all">{item.file_path}</div>
{/* Warnings */} {/* Warnings */}
{plan?.notes && <Alert variant="warning" className="mb-3">{plan.notes}</Alert>} {plan?.notes && (
<Alert variant="warning" className="mb-3">
{plan.notes}
</Alert>
)}
{item.needs_review && !item.original_language && ( {item.needs_review && !item.original_language && (
<Alert variant="warning" className="mb-3"> <Alert variant="warning" className="mb-3">
Original language unknown audio tracks will NOT be filtered until you set it below. Original language unknown audio tracks will NOT be filtered until you set it below.
@@ -259,10 +313,16 @@ export function AudioDetailPage() {
{/* Language override */} {/* Language override */}
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<label className="text-[0.85rem] m-0">Original language:</label> <label className="text-[0.85rem] m-0">Original language:</label>
<Select value={item.original_language ?? ''} onChange={(e) => setLanguage(e.target.value)} className="text-[0.79rem] py-0.5 px-1.5 w-auto"> <Select
value={item.original_language ?? ""}
onChange={(e) => setLanguage(e.target.value)}
className="text-[0.79rem] py-0.5 px-1.5 w-auto"
>
<option value=""> Unknown </option> <option value=""> Unknown </option>
{Object.entries(LANG_NAMES).map(([code, name]) => ( {Object.entries(LANG_NAMES).map(([code, name]) => (
<option key={code} value={code}>{name} ({code})</option> <option key={code} value={code}>
{name} ({code})
</option>
))} ))}
</Select> </Select>
{item.orig_lang_source && <Badge>{item.orig_lang_source}</Badge>} {item.orig_lang_source && <Badge>{item.orig_lang_source}</Badge>}
@@ -285,33 +345,43 @@ export function AudioDetailPage() {
)} )}
{/* Actions */} {/* Actions */}
{plan?.status === 'pending' && !plan.is_noop && ( {plan?.status === "pending" && !plan.is_noop && (
<div className="flex gap-2 mt-6"> <div className="flex gap-2 mt-6">
<Button onClick={approve}> Approve</Button> <Button onClick={approve}> Approve</Button>
<Button variant="secondary" onClick={skip}>Skip</Button> <Button variant="secondary" onClick={skip}>
Skip
</Button>
</div> </div>
)} )}
{plan?.status === 'approved' && ( {plan?.status === "approved" && (
<div className="mt-6"> <div className="mt-6">
<Button variant="secondary" onClick={unapprove}>Unapprove</Button> <Button variant="secondary" onClick={unapprove}>
Unapprove
</Button>
</div> </div>
)} )}
{plan?.status === 'skipped' && ( {plan?.status === "skipped" && (
<div className="mt-6"> <div className="mt-6">
<Button variant="secondary" onClick={unskip}>Unskip</Button> <Button variant="secondary" onClick={unskip}>
Unskip
</Button>
</div> </div>
)} )}
{plan?.is_noop ? ( {plan?.is_noop ? (
<Alert variant="success" className="mt-4">Audio is already clean no audio changes needed.</Alert> <Alert variant="success" className="mt-4">
Audio is already clean no audio changes needed.
</Alert>
) : null} ) : null}
{/* Refresh */} {/* Refresh */}
<div className="flex items-center gap-3 mt-6 pt-3 border-t border-gray-200"> <div className="flex items-center gap-3 mt-6 pt-3 border-t border-gray-200">
<Button variant="secondary" size="sm" onClick={rescan} disabled={rescanning}> <Button variant="secondary" size="sm" onClick={rescan} disabled={rescanning}>
{rescanning ? '↻ Refreshing…' : '↻ Refresh from Jellyfin'} {rescanning ? "↻ Refreshing…" : "↻ Refresh from Jellyfin"}
</Button> </Button>
<span className="text-gray-400 text-[0.75rem]"> <span className="text-gray-400 text-[0.75rem]">
{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"}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -1,21 +1,34 @@
import { useState, useEffect } from 'react'; import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import { Link, useNavigate, useSearch } from '@tanstack/react-router'; import { useEffect, useState } from "react";
import { api } from '~/shared/lib/api'; import { Badge } from "~/shared/components/ui/badge";
import { Badge } from '~/shared/components/ui/badge'; import { Button } from "~/shared/components/ui/button";
import { Button } from '~/shared/components/ui/button'; import { FilterTabs } from "~/shared/components/ui/filter-tabs";
import { FilterTabs } from '~/shared/components/ui/filter-tabs'; import { api } from "~/shared/lib/api";
import { langName } from '~/shared/lib/lang'; import { langName } from "~/shared/lib/lang";
import type { MediaItem, ReviewPlan } from '~/shared/lib/types'; import type { MediaItem, ReviewPlan } from "~/shared/lib/types";
// ─── 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 { interface SeriesGroup {
series_key: string; series_name: string; original_language: string | null; series_key: string;
season_count: number; episode_count: number; series_name: string;
noop_count: number; needs_action_count: number; approved_count: number; original_language: string | null;
skipped_count: number; done_count: number; error_count: number; manual_count: number; 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 { interface ReviewListData {
@@ -28,10 +41,14 @@ interface ReviewListData {
// ─── Filter tabs ────────────────────────────────────────────────────────────── // ─── Filter tabs ──────────────────────────────────────────────────────────────
const FILTER_TABS = [ const FILTER_TABS = [
{ key: 'all', label: 'All' }, { key: 'needs_action', label: 'Needs Action' }, { key: "all", label: "All" },
{ key: 'noop', label: 'No Change' }, { key: 'manual', label: 'Manual Review' }, { key: "needs_action", label: "Needs Action" },
{ key: 'approved', label: 'Approved' }, { key: 'skipped', label: 'Skipped' }, { key: "noop", label: "No Change" },
{ key: 'done', label: 'Done' }, { key: 'error', label: 'Error' }, { key: "manual", label: "Manual Review" },
{ key: "approved", label: "Approved" },
{ key: "skipped", label: "Skipped" },
{ key: "done", label: "Done" },
{ key: "error", label: "Error" },
]; ];
// ─── Status pills ───────────────────────────────────────────────────────────── // ─── Status pills ─────────────────────────────────────────────────────────────
@@ -39,13 +56,41 @@ const FILTER_TABS = [
function StatusPills({ g }: { g: SeriesGroup }) { function StatusPills({ g }: { g: SeriesGroup }) {
return ( return (
<span className="inline-flex flex-wrap gap-1 items-center"> <span className="inline-flex flex-wrap gap-1 items-center">
{g.noop_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-gray-200 text-gray-600">{g.noop_count} ok</span>} {g.noop_count > 0 && (
{g.needs_action_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-red-100 text-red-800">{g.needs_action_count} action</span>} <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-gray-200 text-gray-600">
{g.approved_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-green-100 text-green-800">{g.approved_count} approved</span>} {g.noop_count} ok
{g.done_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-cyan-100 text-cyan-800">{g.done_count} done</span>} </span>
{g.error_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-red-100 text-red-800">{g.error_count} err</span>} )}
{g.skipped_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-gray-200 text-gray-600">{g.skipped_count} skip</span>} {g.needs_action_count > 0 && (
{g.manual_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-orange-100 text-orange-800">{g.manual_count} manual</span>} <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-red-100 text-red-800">
{g.needs_action_count} action
</span>
)}
{g.approved_count > 0 && (
<span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-green-100 text-green-800">
{g.approved_count} approved
</span>
)}
{g.done_count > 0 && (
<span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-cyan-100 text-cyan-800">
{g.done_count} done
</span>
)}
{g.error_count > 0 && (
<span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-red-100 text-red-800">
{g.error_count} err
</span>
)}
{g.skipped_count > 0 && (
<span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-gray-200 text-gray-600">
{g.skipped_count} skip
</span>
)}
{g.manual_count > 0 && (
<span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-orange-100 text-orange-800">
{g.manual_count} manual
</span>
)}
</span> </span>
); );
} }
@@ -59,7 +104,7 @@ const Th = ({ children }: { children: React.ReactNode }) => (
); );
const Td = ({ children, className }: { children?: React.ReactNode; className?: string }) => ( const Td = ({ children, className }: { children?: React.ReactNode; className?: string }) => (
<td className={`py-1.5 px-2 border-b border-gray-100 align-middle ${className ?? ''}`}>{children}</td> <td className={`py-1.5 px-2 border-b border-gray-100 align-middle ${className ?? ""}`}>{children}</td>
); );
// ─── Series row (collapsible) ───────────────────────────────────────────────── // ─── Series row (collapsible) ─────────────────────────────────────────────────
@@ -68,8 +113,19 @@ function SeriesRow({ g }: { g: SeriesGroup }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const urlKey = encodeURIComponent(g.series_key); const urlKey = encodeURIComponent(g.series_key);
interface EpisodeItem { item: MediaItem; plan: ReviewPlan | null; removeCount: number; } interface EpisodeItem {
interface SeasonGroup { season: number | null; episodes: EpisodeItem[]; noopCount: number; actionCount: number; approvedCount: number; doneCount: number; } 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<SeasonGroup[] | null>(null); const [seasons, setSeasons] = useState<SeasonGroup[] | null>(null);
@@ -93,25 +149,30 @@ function SeriesRow({ g }: { g: SeriesGroup }) {
window.location.reload(); 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 ( return (
<tbody> <tbody>
<tr <tr className="cursor-pointer hover:bg-gray-50" onClick={toggle}>
className="cursor-pointer hover:bg-gray-50"
onClick={toggle}
>
<td className="py-1.5 px-2 border-b border-gray-100 font-medium"> <td className="py-1.5 px-2 border-b border-gray-100 font-medium">
<span className={`inline-flex items-center justify-center w-4 h-4 rounded text-[0.65rem] text-gray-500 transition-transform ${open ? 'rotate-90' : ''}`}></span> <span
{' '}<strong>{g.series_name}</strong> className={`inline-flex items-center justify-center w-4 h-4 rounded text-[0.65rem] text-gray-500 transition-transform ${open ? "rotate-90" : ""}`}
>
</span>{" "}
<strong>{g.series_name}</strong>
</td> </td>
<Td>{langName(g.original_language)}</Td> <Td>{langName(g.original_language)}</Td>
<td className="py-1.5 px-2 border-b border-gray-100 text-gray-500">{g.season_count}</td> <td className="py-1.5 px-2 border-b border-gray-100 text-gray-500">{g.season_count}</td>
<td className="py-1.5 px-2 border-b border-gray-100 text-gray-500">{g.episode_count}</td> <td className="py-1.5 px-2 border-b border-gray-100 text-gray-500">{g.episode_count}</td>
<Td><StatusPills g={g} /></Td> <Td>
<StatusPills g={g} />
</Td>
<td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap" onClick={(e) => e.stopPropagation()}> <td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap" onClick={(e) => e.stopPropagation()}>
{g.needs_action_count > 0 && ( {g.needs_action_count > 0 && (
<Button size="xs" onClick={approveAll}>Approve all</Button> <Button size="xs" onClick={approveAll}>
Approve all
</Button>
)} )}
</td> </td>
</tr> </tr>
@@ -123,13 +184,32 @@ function SeriesRow({ g }: { g: SeriesGroup }) {
{seasons.map((s) => ( {seasons.map((s) => (
<> <>
<tr key={`season-${s.season}`} className="bg-gray-50"> <tr key={`season-${s.season}`} className="bg-gray-50">
<td colSpan={4} className="text-[0.7rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-0.5 px-8 border-b border-gray-100"> <td
Season {s.season ?? '?'} colSpan={4}
className="text-[0.7rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-0.5 px-8 border-b border-gray-100"
>
Season {s.season ?? "?"}
<span className="ml-3 inline-flex gap-1"> <span className="ml-3 inline-flex gap-1">
{s.noopCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-gray-200 text-gray-600 text-[0.7rem]">{s.noopCount} ok</span>} {s.noopCount > 0 && (
{s.actionCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-red-100 text-red-800 text-[0.7rem]">{s.actionCount} action</span>} <span className="px-1.5 py-0.5 rounded-full bg-gray-200 text-gray-600 text-[0.7rem]">
{s.approvedCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-green-100 text-green-800 text-[0.7rem]">{s.approvedCount} approved</span>} {s.noopCount} ok
{s.doneCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-cyan-100 text-cyan-800 text-[0.7rem]">{s.doneCount} done</span>} </span>
)}
{s.actionCount > 0 && (
<span className="px-1.5 py-0.5 rounded-full bg-red-100 text-red-800 text-[0.7rem]">
{s.actionCount} action
</span>
)}
{s.approvedCount > 0 && (
<span className="px-1.5 py-0.5 rounded-full bg-green-100 text-green-800 text-[0.7rem]">
{s.approvedCount} approved
</span>
)}
{s.doneCount > 0 && (
<span className="px-1.5 py-0.5 rounded-full bg-cyan-100 text-cyan-800 text-[0.7rem]">
{s.doneCount} done
</span>
)}
</span> </span>
{s.actionCount > 0 && ( {s.actionCount > 0 && (
<Button size="xs" variant="secondary" className="ml-3" onClick={(e) => approveSeason(e, s.season)}> <Button size="xs" variant="secondary" className="ml-3" onClick={(e) => approveSeason(e, s.season)}>
@@ -141,27 +221,32 @@ function SeriesRow({ g }: { g: SeriesGroup }) {
{s.episodes.map(({ item, plan, removeCount }) => ( {s.episodes.map(({ item, plan, removeCount }) => (
<tr key={item.id} className="hover:bg-gray-50"> <tr key={item.id} className="hover:bg-gray-50">
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem] pl-10"> <td className="py-1 px-2 border-b border-gray-100 text-[0.8rem] pl-10">
<span className="text-gray-400 font-mono text-xs">E{String(item.episode_number ?? 0).padStart(2, '0')}</span> <span className="text-gray-400 font-mono text-xs">
{' '} E{String(item.episode_number ?? 0).padStart(2, "0")}
</span>{" "}
<span className="truncate inline-block max-w-xs align-bottom">{item.name}</span> <span className="truncate inline-block max-w-xs align-bottom">{item.name}</span>
</td> </td>
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem]"> <td className="py-1 px-2 border-b border-gray-100 text-[0.8rem]">
{removeCount > 0 ? <Badge variant="remove">{removeCount}</Badge> : <span className="text-gray-400"></span>} {removeCount > 0 ? (
<Badge variant="remove">{removeCount}</Badge>
) : (
<span className="text-gray-400"></span>
)}
</td> </td>
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem]"> <td className="py-1 px-2 border-b border-gray-100 text-[0.8rem]">
<Badge variant={statusKey(plan) as 'noop' | 'pending' | 'approved' | 'skipped' | 'done' | 'error'}>{plan?.is_noop ? 'ok' : (plan?.status ?? 'pending')}</Badge> <Badge variant={statusKey(plan) as "noop" | "pending" | "approved" | "skipped" | "done" | "error"}>
{plan?.is_noop ? "ok" : (plan?.status ?? "pending")}
</Badge>
</td> </td>
<td className="py-1 px-2 border-b border-gray-100 whitespace-nowrap flex gap-1 items-center"> <td className="py-1 px-2 border-b border-gray-100 whitespace-nowrap flex gap-1 items-center">
{plan?.status === 'pending' && !plan.is_noop && ( {plan?.status === "pending" && !plan.is_noop && <ApproveBtn itemId={item.id} size="xs" />}
<ApproveBtn itemId={item.id} size="xs" /> {plan?.status === "pending" && <SkipBtn itemId={item.id} size="xs" />}
)} {plan?.status === "skipped" && <UnskipBtn itemId={item.id} size="xs" />}
{plan?.status === 'pending' && ( <Link
<SkipBtn itemId={item.id} size="xs" /> to="/review/audio/$id"
)} params={{ id: String(item.id) }}
{plan?.status === 'skipped' && ( className="inline-flex items-center justify-center gap-1 rounded px-2 py-0.5 text-xs font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline"
<UnskipBtn itemId={item.id} size="xs" /> >
)}
<Link to="/review/audio/$id" params={{ id: String(item.id) }} className="inline-flex items-center justify-center gap-1 rounded px-2 py-0.5 text-xs font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline">
Detail Detail
</Link> </Link>
</td> </td>
@@ -180,19 +265,40 @@ function SeriesRow({ g }: { g: SeriesGroup }) {
// ─── Action buttons ─────────────────────────────────────────────────────────── // ─── Action buttons ───────────────────────────────────────────────────────────
function ApproveBtn({ itemId, size }: { itemId: number; size?: 'xs' | 'sm' }) { function ApproveBtn({ itemId, size }: { itemId: number; size?: "xs" | "sm" }) {
const onClick = async () => { await api.post(`/api/review/${itemId}/approve`); window.location.reload(); }; const onClick = async () => {
return <Button size={size ?? 'xs'} onClick={onClick}>Approve</Button>; await api.post(`/api/review/${itemId}/approve`);
window.location.reload();
};
return (
<Button size={size ?? "xs"} onClick={onClick}>
Approve
</Button>
);
} }
function SkipBtn({ itemId, size }: { itemId: number; size?: 'xs' | 'sm' }) { function SkipBtn({ itemId, size }: { itemId: number; size?: "xs" | "sm" }) {
const onClick = async () => { await api.post(`/api/review/${itemId}/skip`); window.location.reload(); }; const onClick = async () => {
return <Button size={size ?? 'xs'} variant="secondary" onClick={onClick}>Skip</Button>; await api.post(`/api/review/${itemId}/skip`);
window.location.reload();
};
return (
<Button size={size ?? "xs"} variant="secondary" onClick={onClick}>
Skip
</Button>
);
} }
function UnskipBtn({ itemId, size }: { itemId: number; size?: 'xs' | 'sm' }) { function UnskipBtn({ itemId, size }: { itemId: number; size?: "xs" | "sm" }) {
const onClick = async () => { await api.post(`/api/review/${itemId}/unskip`); window.location.reload(); }; const onClick = async () => {
return <Button size={size ?? 'xs'} variant="secondary" onClick={onClick}>Unskip</Button>; await api.post(`/api/review/${itemId}/unskip`);
window.location.reload();
};
return (
<Button size={size ?? "xs"} variant="secondary" onClick={onClick}>
Unskip
</Button>
);
} }
// ─── Cache ──────────────────────────────────────────────────────────────────── // ─── Cache ────────────────────────────────────────────────────────────────────
@@ -202,22 +308,31 @@ const cache = new Map<string, ReviewListData>();
// ─── Main page ──────────────────────────────────────────────────────────────── // ─── Main page ────────────────────────────────────────────────────────────────
export function AudioListPage() { export function AudioListPage() {
const { filter } = useSearch({ from: '/review/audio/' }); const { filter } = useSearch({ from: "/review/audio/" });
const navigate = useNavigate(); const navigate = useNavigate();
const [data, setData] = useState<ReviewListData | null>(cache.get(filter) ?? null); const [data, setData] = useState<ReviewListData | null>(cache.get(filter) ?? null);
const [loading, setLoading] = useState(!cache.has(filter)); const [loading, setLoading] = useState(!cache.has(filter));
useEffect(() => { useEffect(() => {
const cached = cache.get(filter); const cached = cache.get(filter);
if (cached) { setData(cached); setLoading(false); } if (cached) {
else { setLoading(true); } setData(cached);
api.get<ReviewListData>(`/api/review?filter=${filter}`) setLoading(false);
.then((d) => { cache.set(filter, d); setData(d); setLoading(false); }) } else {
setLoading(true);
}
api
.get<ReviewListData>(`/api/review?filter=${filter}`)
.then((d) => {
cache.set(filter, d);
setData(d);
setLoading(false);
})
.catch(() => setLoading(false)); .catch(() => setLoading(false));
}, [filter]); }, [filter]);
const approveAll = async () => { const approveAll = async () => {
await api.post('/api/review/approve-all'); await api.post("/api/review/approve-all");
cache.clear(); cache.clear();
window.location.reload(); window.location.reload();
}; };
@@ -227,7 +342,7 @@ export function AudioListPage() {
const { movies, series, totalCounts } = data; const { movies, series, totalCounts } = data;
const hasPending = (totalCounts.needs_action ?? 0) > 0; 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 ( return (
<div> <div>
@@ -236,8 +351,13 @@ export function AudioListPage() {
<div className="border border-gray-200 rounded-lg px-4 py-3 mb-6 flex items-center gap-3 flex-wrap"> <div className="border border-gray-200 rounded-lg px-4 py-3 mb-6 flex items-center gap-3 flex-wrap">
{hasPending ? ( {hasPending ? (
<> <>
<span className="text-sm font-medium">{totalCounts.needs_action} item{totalCounts.needs_action !== 1 ? 's' : ''} need{totalCounts.needs_action === 1 ? 's' : ''} review</span> <span className="text-sm font-medium">
<Button size="sm" onClick={approveAll}>Approve all pending</Button> {totalCounts.needs_action} item{totalCounts.needs_action !== 1 ? "s" : ""} need
{totalCounts.needs_action === 1 ? "s" : ""} review
</span>
<Button size="sm" onClick={approveAll}>
Approve all pending
</Button>
</> </>
) : ( ) : (
<span className="text-sm font-medium">All items reviewed</span> <span className="text-sm font-medium">All items reviewed</span>
@@ -248,12 +368,10 @@ export function AudioListPage() {
tabs={FILTER_TABS} tabs={FILTER_TABS}
filter={filter} filter={filter}
totalCounts={totalCounts} 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 && ( {movies.length === 0 && series.length === 0 && <p className="text-gray-500">No items match this filter.</p>}
<p className="text-gray-500">No items match this filter.</p>
)}
{/* Movies */} {/* Movies */}
{movies.length > 0 && ( {movies.length > 0 && (
@@ -262,54 +380,89 @@ export function AudioListPage() {
Movies <span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded-full">{movies.length}</span> Movies <span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded-full">{movies.length}</span>
</div> </div>
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0"> <div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0">
<table className="w-full border-collapse text-[0.82rem]"> <table className="w-full border-collapse text-[0.82rem]">
<thead><tr><Th>Name</Th><Th>Lang</Th><Th>Remove</Th><Th>Status</Th><Th>Actions</Th></tr></thead> <thead>
<tbody> <tr>
{movies.map(({ item, plan, removeCount }) => ( <Th>Name</Th>
<tr key={item.id} className="hover:bg-gray-50"> <Th>Lang</Th>
<Td> <Th>Remove</Th>
<span className="truncate inline-block max-w-[200px] sm:max-w-[360px]" title={item.name}>{item.name}</span> <Th>Status</Th>
{item.year && <span className="text-gray-400 text-[0.72rem]"> ({item.year})</span>} <Th>Actions</Th>
</Td>
<Td>
{item.needs_review && !item.original_language
? <Badge variant="manual">manual</Badge>
: <span>{langName(item.original_language)}</span>}
</Td>
<Td>{removeCount > 0 ? <Badge variant="remove">{removeCount}</Badge> : <span className="text-gray-400"></span>}</Td>
<Td><Badge variant={statusKey(plan) as 'noop' | 'pending' | 'approved' | 'skipped' | 'done' | 'error'}>{plan?.is_noop ? 'ok' : (plan?.status ?? 'pending')}</Badge></Td>
<td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap flex gap-1 items-center">
{plan?.status === 'pending' && !plan.is_noop && <ApproveBtn itemId={item.id} />}
{plan?.status === 'pending' && <SkipBtn itemId={item.id} />}
{plan?.status === 'skipped' && <UnskipBtn itemId={item.id} />}
<Link to="/review/audio/$id" params={{ id: String(item.id) }} className="inline-flex items-center justify-center gap-1 rounded px-2 py-0.5 text-xs font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline">
Detail
</Link>
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {movies.map(({ item, plan, removeCount }) => (
</div> <tr key={item.id} className="hover:bg-gray-50">
<Td>
<span className="truncate inline-block max-w-[200px] sm:max-w-[360px]" title={item.name}>
{item.name}
</span>
{item.year && <span className="text-gray-400 text-[0.72rem]"> ({item.year})</span>}
</Td>
<Td>
{item.needs_review && !item.original_language ? (
<Badge variant="manual">manual</Badge>
) : (
<span>{langName(item.original_language)}</span>
)}
</Td>
<Td>
{removeCount > 0 ? <Badge variant="remove">{removeCount}</Badge> : <span className="text-gray-400"></span>}
</Td>
<Td>
<Badge variant={statusKey(plan) as "noop" | "pending" | "approved" | "skipped" | "done" | "error"}>
{plan?.is_noop ? "ok" : (plan?.status ?? "pending")}
</Badge>
</Td>
<td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap flex gap-1 items-center">
{plan?.status === "pending" && !plan.is_noop && <ApproveBtn itemId={item.id} />}
{plan?.status === "pending" && <SkipBtn itemId={item.id} />}
{plan?.status === "skipped" && <UnskipBtn itemId={item.id} />}
<Link
to="/review/audio/$id"
params={{ id: String(item.id) }}
className="inline-flex items-center justify-center gap-1 rounded px-2 py-0.5 text-xs font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline"
>
Detail
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</> </>
)} )}
{/* TV Series */} {/* TV Series */}
{series.length > 0 && ( {series.length > 0 && (
<> <>
<div className={`flex items-center gap-2 mb-1.5 text-[0.72rem] font-bold uppercase tracking-[0.07em] text-gray-500 ${movies.length > 0 ? 'mt-5' : 'mt-0'}`}> <div
className={`flex items-center gap-2 mb-1.5 text-[0.72rem] font-bold uppercase tracking-[0.07em] text-gray-500 ${movies.length > 0 ? "mt-5" : "mt-0"}`}
>
TV Series <span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded-full">{series.length}</span> TV Series <span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded-full">{series.length}</span>
</div> </div>
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0"> <div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0">
<table className="w-full border-collapse text-[0.82rem]"> <table className="w-full border-collapse text-[0.82rem]">
<thead><tr><Th>Series</Th><Th>Lang</Th><Th>S</Th><Th>Ep</Th><Th>Status</Th><Th>Actions</Th></tr></thead> <thead>
{series.map((g) => <SeriesRow key={g.series_key} g={g} />)} <tr>
</table> <Th>Series</Th>
</div> <Th>Lang</Th>
<Th>S</Th>
<Th>Ep</Th>
<Th>Status</Th>
<Th>Actions</Th>
</tr>
</thead>
{series.map((g) => (
<SeriesRow key={g.series_key} g={g} />
))}
</table>
</div>
</> </>
)} )}
</div> </div>
); );
} }
import type React from 'react'; import type React from "react";

View File

@@ -1,11 +1,21 @@
import { useEffect, useRef, useState, useCallback } from 'react'; import { Link } from "@tanstack/react-router";
import { Link } from '@tanstack/react-router'; import { useCallback, useEffect, useRef, useState } from "react";
import { api } from '~/shared/lib/api'; import { Badge } from "~/shared/components/ui/badge";
import { Button } from '~/shared/components/ui/button'; import { Button } from "~/shared/components/ui/button";
import { Badge } from '~/shared/components/ui/badge'; 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 ScanStatus {
interface LogEntry { name: string; type: string; status: string; file?: string; } 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 // Mutable buffer for SSE data — flushed to React state on an interval
interface SseBuf { interface SseBuf {
@@ -20,18 +30,18 @@ interface SseBuf {
} }
function freshBuf(): 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; const FLUSH_MS = 200;
export function ScanPage() { export function ScanPage() {
const [status, setStatus] = useState<ScanStatus | null>(null); const [status, setStatus] = useState<ScanStatus | null>(null);
const [limit, setLimit] = useState(''); const [limit, setLimit] = useState("");
const [log, setLog] = useState<LogEntry[]>([]); const [log, setLog] = useState<LogEntry[]>([]);
const [statusLabel, setStatusLabel] = useState(''); const [statusLabel, setStatusLabel] = useState("");
const [scanComplete, setScanComplete] = useState(false); const [scanComplete, setScanComplete] = useState(false);
const [currentItem, setCurrentItem] = useState(''); const [currentItem, setCurrentItem] = useState("");
const [progressScanned, setProgressScanned] = useState(0); const [progressScanned, setProgressScanned] = useState(0);
const [progressTotal, setProgressTotal] = useState(0); const [progressTotal, setProgressTotal] = useState(0);
const [errors, setErrors] = useState(0); const [errors, setErrors] = useState(0);
@@ -59,19 +69,19 @@ export function ScanPage() {
if (b.complete) { if (b.complete) {
const d = b.complete; const d = b.complete;
b.complete = null; 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); setScanComplete(true);
setStatus((prev) => prev ? { ...prev, running: false } : prev); setStatus((prev) => (prev ? { ...prev, running: false } : prev));
stopFlushing(); stopFlushing();
} }
if (b.lost) { if (b.lost) {
b.lost = false; b.lost = false;
setStatusLabel('Scan connection lost — refresh to see current status'); setStatusLabel("Scan connection lost — refresh to see current status");
setStatus((prev) => prev ? { ...prev, running: false } : prev); setStatus((prev) => (prev ? { ...prev, running: false } : prev));
stopFlushing(); stopFlushing();
} }
}, []); }, [stopFlushing]);
const startFlushing = useCallback(() => { const startFlushing = useCallback(() => {
if (timerRef.current) return; if (timerRef.current) return;
@@ -86,50 +96,57 @@ export function ScanPage() {
}, [flush]); }, [flush]);
// Cleanup timer on unmount // Cleanup timer on unmount
useEffect(() => () => { if (timerRef.current) clearInterval(timerRef.current); }, []); useEffect(
() => () => {
if (timerRef.current) clearInterval(timerRef.current);
},
[],
);
const load = async () => { const load = async () => {
const s = await api.get<ScanStatus>('/api/scan'); const s = await api.get<ScanStatus>("/api/scan");
setStatus(s); setStatus(s);
setProgressScanned(s.progress.scanned); setProgressScanned(s.progress.scanned);
setProgressTotal(s.progress.total); setProgressTotal(s.progress.total);
setErrors(s.progress.errors); 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)); 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 }))); 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(() => { const connectSse = useCallback(() => {
esRef.current?.close(); esRef.current?.close();
const buf = bufRef.current; const buf = bufRef.current;
const es = new EventSource('/api/scan/events'); const es = new EventSource("/api/scan/events");
esRef.current = es; 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 }; const d = JSON.parse(e.data) as { scanned: number; total: number; errors: number; current_item: string };
buf.scanned = d.scanned; buf.scanned = d.scanned;
buf.total = d.total; buf.total = d.total;
buf.errors = d.errors; buf.errors = d.errors;
buf.currentItem = d.current_item ?? ''; buf.currentItem = d.current_item ?? "";
buf.dirty = true; buf.dirty = true;
}); });
es.addEventListener('log', (e) => { es.addEventListener("log", (e) => {
const d = JSON.parse(e.data) as LogEntry; const d = JSON.parse(e.data) as LogEntry;
buf.newLogs.push(d); buf.newLogs.push(d);
buf.dirty = true; buf.dirty = true;
}); });
es.addEventListener('complete', (e) => { es.addEventListener("complete", (e) => {
const d = JSON.parse(e.data || '{}') as { scanned?: number; errors?: number }; const d = JSON.parse(e.data || "{}") as { scanned?: number; errors?: number };
es.close(); es.close();
esRef.current = null; esRef.current = null;
buf.complete = d; buf.complete = d;
}); });
es.addEventListener('error', () => { es.addEventListener("error", () => {
es.close(); es.close();
esRef.current = null; esRef.current = null;
buf.lost = true; buf.lost = true;
@@ -143,7 +160,11 @@ export function ScanPage() {
useEffect(() => { useEffect(() => {
if (!status?.running || esRef.current) return; if (!status?.running || esRef.current) return;
connectSse(); connectSse();
return () => { esRef.current?.close(); esRef.current = null; stopFlushing(); }; return () => {
esRef.current?.close();
esRef.current = null;
stopFlushing();
};
}, [status?.running, connectSse, stopFlushing]); }, [status?.running, connectSse, stopFlushing]);
const startScan = async () => { const startScan = async () => {
@@ -151,26 +172,26 @@ export function ScanPage() {
setProgressScanned(0); setProgressScanned(0);
setProgressTotal(0); setProgressTotal(0);
setErrors(0); setErrors(0);
setCurrentItem(''); setCurrentItem("");
setStatusLabel('Scan in progress…'); setStatusLabel("Scan in progress…");
setScanComplete(false); setScanComplete(false);
setStatus((prev) => prev ? { ...prev, running: true } : prev); setStatus((prev) => (prev ? { ...prev, running: true } : prev));
bufRef.current = freshBuf(); bufRef.current = freshBuf();
// Connect SSE before starting the scan so no events are missed // Connect SSE before starting the scan so no events are missed
connectSse(); connectSse();
const limitNum = limit ? Number(limit) : undefined; 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 () => { const stopScan = async () => {
await api.post('/api/scan/stop', {}); await api.post("/api/scan/stop", {});
esRef.current?.close(); esRef.current?.close();
esRef.current = null; esRef.current = null;
stopFlushing(); stopFlushing();
setStatus((prev) => prev ? { ...prev, running: false } : prev); setStatus((prev) => (prev ? { ...prev, running: false } : prev));
setStatusLabel('Scan stopped'); setStatusLabel("Scan stopped");
}; };
const pct = progressTotal > 0 ? Math.round((progressScanned / progressTotal) * 100) : 0; const pct = progressTotal > 0 ? Math.round((progressScanned / progressTotal) * 100) : 0;
@@ -182,14 +203,16 @@ export function ScanPage() {
<div className="border border-gray-200 rounded-lg px-4 py-3 mb-6"> <div className="border border-gray-200 rounded-lg px-4 py-3 mb-6">
<div className="flex items-center flex-wrap gap-2 mb-3"> <div className="flex items-center flex-wrap gap-2 mb-3">
<span className="text-sm font-medium">{statusLabel || (running ? 'Scan in progress…' : 'Scan idle')}</span> <span className="text-sm font-medium">{statusLabel || (running ? "Scan in progress…" : "Scan idle")}</span>
{scanComplete && ( {scanComplete && (
<Link to="/pipeline" className="text-blue-600 hover:underline text-sm"> <Link to="/pipeline" className="text-blue-600 hover:underline text-sm">
Review in Pipeline Review in Pipeline
</Link> </Link>
)} )}
{running ? ( {running ? (
<Button variant="secondary" size="sm" onClick={stopScan}>Stop</Button> <Button variant="secondary" size="sm" onClick={stopScan}>
Stop
</Button>
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="flex items-center gap-1.5 text-xs m-0"> <label className="flex items-center gap-1.5 text-xs m-0">
@@ -204,7 +227,9 @@ export function ScanPage() {
/> />
items items
</label> </label>
<Button size="sm" onClick={startScan}>Start Scan</Button> <Button size="sm" onClick={startScan}>
Start Scan
</Button>
</div> </div>
)} )}
{errors > 0 && <Badge variant="error">{errors} error(s)</Badge>} {errors > 0 && <Badge variant="error">{errors} error(s)</Badge>}
@@ -218,7 +243,10 @@ export function ScanPage() {
</div> </div>
)} )}
<div className="flex items-center gap-2 text-gray-500 text-xs"> <div className="flex items-center gap-2 text-gray-500 text-xs">
<span>{progressScanned}{progressTotal > 0 ? ` / ${progressTotal}` : ''} scanned</span> <span>
{progressScanned}
{progressTotal > 0 ? ` / ${progressTotal}` : ""} scanned
</span>
{currentItem && <span className="truncate max-w-xs text-gray-400">{currentItem}</span>} {currentItem && <span className="truncate max-w-xs text-gray-400">{currentItem}</span>}
</div> </div>
</> </>
@@ -230,20 +258,27 @@ export function ScanPage() {
<table className="w-full border-collapse text-[0.82rem]"> <table className="w-full border-collapse text-[0.82rem]">
<thead> <thead>
<tr> <tr>
{['Type', 'File', 'Status'].map((h) => ( {["Type", "File", "Status"].map((h) => (
<th key={h} className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap">{h}</th> <th
key={h}
className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap"
>
{h}
</th>
))} ))}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{log.map((item, i) => { {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 ( return (
<tr key={i} className="hover:bg-gray-50"> <tr key={i} className="hover:bg-gray-50">
<td className="py-1.5 px-2 border-b border-gray-100">{item.type}</td> <td className="py-1.5 px-2 border-b border-gray-100">{item.type}</td>
<td className="py-1.5 px-2 border-b border-gray-100" title={item.file ?? item.name}>{fileName}</td> <td className="py-1.5 px-2 border-b border-gray-100" title={item.file ?? item.name}>
{fileName}
</td>
<td className="py-1.5 px-2 border-b border-gray-100"> <td className="py-1.5 px-2 border-b border-gray-100">
<Badge variant={item.status as 'error' | 'done' | 'pending'}>{item.status}</Badge> <Badge variant={item.status as "error" | "done" | "pending"}>{item.status}</Badge>
</td> </td>
</tr> </tr>
); );

View File

@@ -1,11 +1,14 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from "react";
import { api } from '~/shared/lib/api'; import { Button } from "~/shared/components/ui/button";
import { Button } from '~/shared/components/ui/button'; import { Input } from "~/shared/components/ui/input";
import { Input } from '~/shared/components/ui/input'; import { Select } from "~/shared/components/ui/select";
import { Select } from '~/shared/components/ui/select'; import { api } from "~/shared/lib/api";
import { LANG_NAMES } from '~/shared/lib/lang'; import { LANG_NAMES } from "~/shared/lib/lang";
interface SetupData { config: Record<string, string>; envLocked: string[]; } interface SetupData {
config: Record<string, string>;
envLocked: string[];
}
let setupCache: SetupData | null = null; 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<HTMLInputElement>) { function LockedInput({ locked, ...props }: { locked: boolean } & React.InputHTMLAttributes<HTMLInputElement>) {
return ( return (
<div className="relative"> <div className="relative">
<Input {...props} disabled={locked || props.disabled} className={locked ? 'pr-9' : ''} /> <Input {...props} disabled={locked || props.disabled} className={locked ? "pr-9" : ""} />
{locked && ( {locked && (
<span <span
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[0.9rem] opacity-40 pointer-events-none select-none" className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[0.9rem] opacity-40 pointer-events-none select-none"
@@ -35,18 +38,28 @@ function EnvBadge({ envVar, locked }: { envVar: string; locked: boolean }) {
return ( return (
<span <span
className="inline-flex items-center gap-1 text-[0.67rem] font-semibold px-1.5 py-0.5 rounded bg-gray-100 text-gray-500 border border-gray-200" className="inline-flex items-center gap-1 text-[0.67rem] font-semibold px-1.5 py-0.5 rounded bg-gray-100 text-gray-500 border border-gray-200"
title={locked title={
? `Set via environment variable ${envVar} — edit your .env file to change` locked
: `Can be set via environment variable ${envVar}`} ? `Set via environment variable ${envVar} — edit your .env file to change`
: `Can be set via environment variable ${envVar}`
}
> >
{locked ? '🔒' : '🔓'} <span className="font-mono">{envVar}</span> {locked ? "🔒" : "🔓"} <span className="font-mono">{envVar}</span>
</span> </span>
); );
} }
// ─── Section card ────────────────────────────────────────────────────────────── // ─── 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 ( return (
<div className="border border-gray-200 rounded-lg p-4 mb-4"> <div className="border border-gray-200 rounded-lg p-4 mb-4">
<div className="font-semibold text-sm mb-1">{title}</div> <div className="font-semibold text-sm mb-1">{title}</div>
@@ -59,9 +72,13 @@ function SectionCard({ title, subtitle, children }: { title: React.ReactNode; su
// ─── Sortable language list ───────────────────────────────────────────────────── // ─── Sortable language list ─────────────────────────────────────────────────────
function SortableLanguageList({ 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)); const available = LANGUAGE_OPTIONS.filter((o) => !langs.includes(o.code));
@@ -88,21 +105,32 @@ function SortableLanguageList({
return ( return (
<div key={code} className="flex items-center gap-1.5 text-sm"> <div key={code} className="flex items-center gap-1.5 text-sm">
<button <button
type="button" disabled={disabled || i === 0} type="button"
disabled={disabled || i === 0}
onClick={() => move(i, -1)} onClick={() => move(i, -1)}
className="w-6 h-6 flex items-center justify-center rounded border border-gray-200 bg-white text-gray-500 hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer text-xs" className="w-6 h-6 flex items-center justify-center rounded border border-gray-200 bg-white text-gray-500 hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer text-xs"
></button> >
</button>
<button <button
type="button" disabled={disabled || i === langs.length - 1} type="button"
disabled={disabled || i === langs.length - 1}
onClick={() => move(i, 1)} onClick={() => move(i, 1)}
className="w-6 h-6 flex items-center justify-center rounded border border-gray-200 bg-white text-gray-500 hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer text-xs" className="w-6 h-6 flex items-center justify-center rounded border border-gray-200 bg-white text-gray-500 hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer text-xs"
></button> >
<span className="min-w-[8rem]">{label} <span className="text-gray-400 text-xs font-mono">({code})</span></span>
</button>
<span className="min-w-[8rem]">
{label} <span className="text-gray-400 text-xs font-mono">({code})</span>
</span>
<button <button
type="button" disabled={disabled} type="button"
disabled={disabled}
onClick={() => remove(i)} onClick={() => remove(i)}
className="text-red-400 hover:text-red-600 text-sm border-0 bg-transparent cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed" className="text-red-400 hover:text-red-600 text-sm border-0 bg-transparent cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
></button> >
</button>
</div> </div>
); );
})} })}
@@ -111,12 +139,17 @@ function SortableLanguageList({
{!disabled && available.length > 0 && ( {!disabled && available.length > 0 && (
<Select <Select
value="" value=""
onChange={(e) => { add(e.target.value); e.target.value = ''; }} onChange={(e) => {
add(e.target.value);
e.target.value = "";
}}
className="text-sm max-w-[14rem]" className="text-sm max-w-[14rem]"
> >
<option value="">+ Add language</option> <option value="">+ Add language</option>
{available.map(({ code, label }) => ( {available.map(({ code, label }) => (
<option key={code} value={code}>{label} ({code})</option> <option key={code} value={code}>
{label} ({code})
</option>
))} ))}
</Select> </Select>
)} )}
@@ -127,20 +160,38 @@ function SortableLanguageList({
// ─── Connection section ──────────────────────────────────────────────────────── // ─── Connection section ────────────────────────────────────────────────────────
function ConnSection({ 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<string, string>; locked: Set<string>; title: React.ReactNode;
urlKey: string; apiKey: string; urlPlaceholder: string; onSave: (url: string, apiKey: string) => Promise<void>; subtitle?: React.ReactNode;
cfg: Record<string, string>;
locked: Set<string>;
urlKey: string;
apiKey: string;
urlPlaceholder: string;
onSave: (url: string, apiKey: string) => Promise<void>;
}) { }) {
const [url, setUrl] = useState(cfg[urlKey] ?? ''); const [url, setUrl] = useState(cfg[urlKey] ?? "");
const [key, setKey] = useState(cfg[apiKeyProp] ?? ''); const [key, setKey] = useState(cfg[apiKeyProp] ?? "");
const [status, setStatus] = useState<{ ok: boolean; error?: string } | null>(null); const [status, setStatus] = useState<{ ok: boolean; error?: string } | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const save = async () => { const save = async () => {
setSaving(true); setSaving(true);
setStatus(null); 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); setSaving(false);
}; };
@@ -148,19 +199,32 @@ function ConnSection({
<SectionCard title={title} subtitle={subtitle}> <SectionCard title={title} subtitle={subtitle}>
<label className="block text-sm text-gray-700 mb-1"> <label className="block text-sm text-gray-700 mb-1">
URL URL
<LockedInput locked={locked.has(urlKey)} type="url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder={urlPlaceholder} className="mt-0.5 max-w-sm" /> <LockedInput
locked={locked.has(urlKey)}
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={urlPlaceholder}
className="mt-0.5 max-w-sm"
/>
</label> </label>
<label className="block text-sm text-gray-700 mb-1 mt-3"> <label className="block text-sm text-gray-700 mb-1 mt-3">
API Key API Key
<LockedInput locked={locked.has(apiKeyProp)} value={key} onChange={(e) => setKey(e.target.value)} placeholder="your-api-key" className="mt-0.5 max-w-xs" /> <LockedInput
locked={locked.has(apiKeyProp)}
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="your-api-key"
className="mt-0.5 max-w-xs"
/>
</label> </label>
<div className="flex items-center gap-2 mt-3"> <div className="flex items-center gap-2 mt-3">
<Button onClick={save} disabled={saving || (locked.has(urlKey) && locked.has(apiKeyProp))}> <Button onClick={save} disabled={saving || (locked.has(urlKey) && locked.has(apiKeyProp))}>
{saving ? 'Saving…' : 'Test & Save'} {saving ? "Saving…" : "Test & Save"}
</Button> </Button>
{status && ( {status && (
<span className={`text-sm ${status.ok ? 'text-green-700' : 'text-red-600'}`}> <span className={`text-sm ${status.ok ? "text-green-700" : "text-red-600"}`}>
{status.ok ? '✓ Saved' : `${status.error ?? 'Connection failed'}`} {status.ok ? "✓ Saved" : `${status.error ?? "Connection failed"}`}
</span> </span>
)} )}
</div> </div>
@@ -173,54 +237,61 @@ function ConnSection({
export function SetupPage() { export function SetupPage() {
const [data, setData] = useState<SetupData | null>(setupCache); const [data, setData] = useState<SetupData | null>(setupCache);
const [loading, setLoading] = useState(setupCache === null); const [loading, setLoading] = useState(setupCache === null);
const [clearStatus, setClearStatus] = useState(''); const [clearStatus, setClearStatus] = useState("");
const [subLangs, setSubLangs] = useState<string[]>([]); const [subLangs, setSubLangs] = useState<string[]>([]);
const [subSaved, setSubSaved] = useState(''); const [subSaved, setSubSaved] = useState("");
const [audLangs, setAudLangs] = useState<string[]>([]); const [audLangs, setAudLangs] = useState<string[]>([]);
const [audSaved, setAudSaved] = useState(''); const [audSaved, setAudSaved] = useState("");
const [langsLoaded, setLangsLoaded] = useState(false); const [langsLoaded, setLangsLoaded] = useState(false);
const load = () => { const load = () => {
if (!setupCache) setLoading(true); if (!setupCache) setLoading(true);
api.get<SetupData>('/api/setup').then((d) => { api
setupCache = d; .get<SetupData>("/api/setup")
setData(d); .then((d) => {
if (!langsLoaded) { setupCache = d;
setSubLangs(JSON.parse(d.config.subtitle_languages ?? '["eng","deu","spa"]')); setData(d);
setAudLangs(JSON.parse(d.config.audio_languages ?? '[]')); if (!langsLoaded) {
setLangsLoaded(true); setSubLangs(JSON.parse(d.config.subtitle_languages ?? '["eng","deu","spa"]'));
} setAudLangs(JSON.parse(d.config.audio_languages ?? "[]"));
}).finally(() => setLoading(false)); setLangsLoaded(true);
}
})
.finally(() => setLoading(false));
}; };
useEffect(() => { load(); }, []); useEffect(() => {
load();
}, [load]);
if (loading && !data) return <div className="text-gray-400 py-8 text-center">Loading</div>; if (loading && !data) return <div className="text-gray-400 py-8 text-center">Loading</div>;
if (!data) return <div className="text-red-600">Failed to load settings.</div>; if (!data) return <div className="text-red-600">Failed to load settings.</div>;
const { config: cfg, envLocked: envLockedArr } = data; const { config: cfg, envLocked: envLockedArr } = data;
const locked = new Set(envLockedArr); const locked = new Set(envLockedArr);
const saveJellyfin = (url: string, apiKey: string) => const saveJellyfin = (url: string, apiKey: string) => api.post("/api/setup/jellyfin", { url, api_key: apiKey });
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 saveRadarr = (url: string, apiKey: string) => const saveSonarr = (url: string, apiKey: string) => api.post("/api/setup/sonarr", { url, api_key: apiKey });
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 () => { const saveSubtitleLangs = async () => {
await api.post('/api/setup/subtitle-languages', { langs: subLangs }); await api.post("/api/setup/subtitle-languages", { langs: subLangs });
setSubSaved('Saved.'); setSubSaved("Saved.");
setTimeout(() => setSubSaved(''), 2000); setTimeout(() => setSubSaved(""), 2000);
}; };
const saveAudioLangs = async () => { const saveAudioLangs = async () => {
await api.post('/api/setup/audio-languages', { langs: audLangs }); await api.post("/api/setup/audio-languages", { langs: audLangs });
setAudSaved('Saved.'); setAudSaved("Saved.");
setTimeout(() => setAudSaved(''), 2000); setTimeout(() => setAudSaved(""), 2000);
}; };
const clearScan = async () => { 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; if (
await api.post('/api/setup/clear-scan'); !confirm(
setClearStatus('Cleared.'); "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 ( return (
@@ -231,27 +302,53 @@ export function SetupPage() {
{/* Jellyfin */} {/* Jellyfin */}
<ConnSection <ConnSection
title={<span className="flex items-center gap-2">Jellyfin <EnvBadge envVar="JELLYFIN_URL" locked={locked.has('jellyfin_url')} /> <EnvBadge envVar="JELLYFIN_API_KEY" locked={locked.has('jellyfin_api_key')} /></span>} title={
urlKey="jellyfin_url" apiKey="jellyfin_api_key" <span className="flex items-center gap-2">
urlPlaceholder="http://192.168.1.100:8096" cfg={cfg} locked={locked} Jellyfin <EnvBadge envVar="JELLYFIN_URL" locked={locked.has("jellyfin_url")} />{" "}
<EnvBadge envVar="JELLYFIN_API_KEY" locked={locked.has("jellyfin_api_key")} />
</span>
}
urlKey="jellyfin_url"
apiKey="jellyfin_api_key"
urlPlaceholder="http://192.168.1.100:8096"
cfg={cfg}
locked={locked}
onSave={saveJellyfin} onSave={saveJellyfin}
/> />
{/* Radarr */} {/* Radarr */}
<ConnSection <ConnSection
title={<span className="flex items-center gap-2">Radarr <span className="text-gray-400 font-normal">(optional)</span> <EnvBadge envVar="RADARR_URL" locked={locked.has('radarr_url')} /> <EnvBadge envVar="RADARR_API_KEY" locked={locked.has('radarr_api_key')} /></span>} title={
<span className="flex items-center gap-2">
Radarr <span className="text-gray-400 font-normal">(optional)</span>{" "}
<EnvBadge envVar="RADARR_URL" locked={locked.has("radarr_url")} />{" "}
<EnvBadge envVar="RADARR_API_KEY" locked={locked.has("radarr_api_key")} />
</span>
}
subtitle="Provides accurate original-language data for movies." subtitle="Provides accurate original-language data for movies."
urlKey="radarr_url" apiKey="radarr_api_key" urlKey="radarr_url"
urlPlaceholder="http://192.168.1.100:7878" cfg={cfg} locked={locked} apiKey="radarr_api_key"
urlPlaceholder="http://192.168.1.100:7878"
cfg={cfg}
locked={locked}
onSave={saveRadarr} onSave={saveRadarr}
/> />
{/* Sonarr */} {/* Sonarr */}
<ConnSection <ConnSection
title={<span className="flex items-center gap-2">Sonarr <span className="text-gray-400 font-normal">(optional)</span> <EnvBadge envVar="SONARR_URL" locked={locked.has('sonarr_url')} /> <EnvBadge envVar="SONARR_API_KEY" locked={locked.has('sonarr_api_key')} /></span>} title={
<span className="flex items-center gap-2">
Sonarr <span className="text-gray-400 font-normal">(optional)</span>{" "}
<EnvBadge envVar="SONARR_URL" locked={locked.has("sonarr_url")} />{" "}
<EnvBadge envVar="SONARR_API_KEY" locked={locked.has("sonarr_api_key")} />
</span>
}
subtitle="Provides original-language data for TV series." subtitle="Provides original-language data for TV series."
urlKey="sonarr_url" apiKey="sonarr_api_key" urlKey="sonarr_url"
urlPlaceholder="http://192.168.1.100:8989" cfg={cfg} locked={locked} apiKey="sonarr_api_key"
urlPlaceholder="http://192.168.1.100:8989"
cfg={cfg}
locked={locked}
onSave={saveSonarr} onSave={saveSonarr}
/> />
@@ -260,14 +357,16 @@ export function SetupPage() {
title={ title={
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
Audio Languages Audio Languages
<EnvBadge envVar="AUDIO_LANGUAGES" locked={locked.has('audio_languages')} /> <EnvBadge envVar="AUDIO_LANGUAGES" locked={locked.has("audio_languages")} />
</span> </span>
} }
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." 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."
> >
<SortableLanguageList langs={audLangs} onChange={setAudLangs} disabled={locked.has('audio_languages')} /> <SortableLanguageList langs={audLangs} onChange={setAudLangs} disabled={locked.has("audio_languages")} />
<div className="flex items-center gap-2 mt-3"> <div className="flex items-center gap-2 mt-3">
<Button onClick={saveAudioLangs} disabled={locked.has('audio_languages')}>Save</Button> <Button onClick={saveAudioLangs} disabled={locked.has("audio_languages")}>
Save
</Button>
{audSaved && <span className="text-green-700 text-sm">{audSaved}</span>} {audSaved && <span className="text-green-700 text-sm">{audSaved}</span>}
</div> </div>
</SectionCard> </SectionCard>
@@ -277,14 +376,16 @@ export function SetupPage() {
title={ title={
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
Subtitle Languages Subtitle Languages
<EnvBadge envVar="SUBTITLE_LANGUAGES" locked={locked.has('subtitle_languages')} /> <EnvBadge envVar="SUBTITLE_LANGUAGES" locked={locked.has("subtitle_languages")} />
</span> </span>
} }
subtitle="Subtitle tracks in these languages are extracted to sidecar files. Order determines priority. All subtitles are removed from the container during processing." subtitle="Subtitle tracks in these languages are extracted to sidecar files. Order determines priority. All subtitles are removed from the container during processing."
> >
<SortableLanguageList langs={subLangs} onChange={setSubLangs} disabled={locked.has('subtitle_languages')} /> <SortableLanguageList langs={subLangs} onChange={setSubLangs} disabled={locked.has("subtitle_languages")} />
<div className="flex items-center gap-2 mt-3"> <div className="flex items-center gap-2 mt-3">
<Button onClick={saveSubtitleLangs} disabled={locked.has('subtitle_languages')}>Save</Button> <Button onClick={saveSubtitleLangs} disabled={locked.has("subtitle_languages")}>
Save
</Button>
{subSaved && <span className="text-green-700 text-sm">{subSaved}</span>} {subSaved && <span className="text-green-700 text-sm">{subSaved}</span>}
</div> </div>
</SectionCard> </SectionCard>
@@ -292,9 +393,13 @@ export function SetupPage() {
{/* Danger zone */} {/* Danger zone */}
<div className="border border-red-400 rounded-lg p-4 mb-4"> <div className="border border-red-400 rounded-lg p-4 mb-4">
<div className="font-semibold text-sm text-red-700 mb-1">Danger Zone</div> <div className="font-semibold text-sm text-red-700 mb-1">Danger Zone</div>
<p className="text-gray-500 text-sm mb-3">These actions are irreversible. Scan data can be regenerated by running a new scan.</p> <p className="text-gray-500 text-sm mb-3">
These actions are irreversible. Scan data can be regenerated by running a new scan.
</p>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="danger" onClick={clearScan}>Clear all scan data</Button> <Button variant="danger" onClick={clearScan}>
Clear all scan data
</Button>
<span className="text-gray-400 text-sm">Removes all scanned items, review plans, and jobs.</span> <span className="text-gray-400 text-sm">Removes all scanned items, review plans, and jobs.</span>
</div> </div>
{clearStatus && <p className="text-green-700 text-sm mt-2">{clearStatus}</p>} {clearStatus && <p className="text-green-700 text-sm mt-2">{clearStatus}</p>}
@@ -303,4 +408,4 @@ export function SetupPage() {
); );
} }
import type React from 'react'; import type React from "react";

View File

@@ -1,12 +1,12 @@
import { useEffect, useState } from 'react'; import { Link, useParams } from "@tanstack/react-router";
import { Link, useParams } from '@tanstack/react-router'; import { useEffect, useState } from "react";
import { api } from '~/shared/lib/api'; import { Alert } from "~/shared/components/ui/alert";
import { Badge } from '~/shared/components/ui/badge'; import { Badge } from "~/shared/components/ui/badge";
import { Button } from '~/shared/components/ui/button'; import { Button } from "~/shared/components/ui/button";
import { Alert } from '~/shared/components/ui/alert'; import { Select } from "~/shared/components/ui/select";
import { Select } from '~/shared/components/ui/select'; import { api } from "~/shared/lib/api";
import { langName, LANG_NAMES } from '~/shared/lib/lang'; import { LANG_NAMES, langName } from "~/shared/lib/lang";
import type { MediaItem, MediaStream, SubtitleFile, ReviewPlan, StreamDecision } from '~/shared/lib/types'; import type { MediaItem, MediaStream, ReviewPlan, StreamDecision, SubtitleFile } from "~/shared/lib/types";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -30,12 +30,12 @@ function formatBytes(bytes: number): string {
} }
function fileName(filePath: string): string { function fileName(filePath: string): string {
return filePath.split('/').pop() ?? filePath; return filePath.split("/").pop() ?? filePath;
} }
function effectiveTitle(s: MediaStream, dec: StreamDecision | undefined): string { function effectiveTitle(s: MediaStream, dec: StreamDecision | undefined): string {
if (dec?.custom_title) return dec.custom_title; if (dec?.custom_title) return dec.custom_title;
if (!s.language) return ''; if (!s.language) return "";
const base = langName(s.language); const base = langName(s.language);
if (s.is_forced) return `${base} (Forced)`; if (s.is_forced) return `${base} (Forced)`;
if (s.is_hearing_impaired) return `${base} (CC)`; 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 }) { function TitleInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) {
const [localVal, setLocalVal] = useState(value); const [localVal, setLocalVal] = useState(value);
useEffect(() => { setLocalVal(value); }, [value]); useEffect(() => {
setLocalVal(value);
}, [value]);
return ( return (
<input <input
type="text" type="text"
value={localVal} value={localVal}
onChange={(e) => setLocalVal(e.target.value)} onChange={(e) => setLocalVal(e.target.value)}
onBlur={(e) => { if (e.target.value !== value) onCommit(e.target.value); }} onBlur={(e) => {
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} if (e.target.value !== value) onCommit(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
}}
placeholder="—" 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" 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 <p className="text-gray-500 text-sm">No subtitle streams in container.</p>; if (streams.length === 0) return <p className="text-gray-500 text-sm">No subtitle streams in container.</p>;
return ( return (
<div className="overflow-x-auto -mx-4 px-4 sm:mx-0 sm:px-0"><table className="w-full border-collapse text-[0.79rem] mt-1"> <div className="overflow-x-auto -mx-4 px-4 sm:mx-0 sm:px-0">
<thead> <table className="w-full border-collapse text-[0.79rem] mt-1">
<tr> <thead>
{['#', 'Codec', 'Language', 'Title / Info', 'Flags', 'Action'].map((h) => ( <tr>
<th key={h} className="text-left text-[0.67rem] uppercase tracking-[0.05em] text-gray-500 py-1 px-2 border-b border-gray-200">{h}</th> {["#", "Codec", "Language", "Title / Info", "Flags", "Action"].map((h) => (
))} <th
</tr> key={h}
</thead> className="text-left text-[0.67rem] uppercase tracking-[0.05em] text-gray-500 py-1 px-2 border-b border-gray-200"
<tbody> >
{streams.map((s) => { {h}
const dec = decisions.find((d) => d.stream_id === s.id); </th>
const title = effectiveTitle(s, dec); ))}
const origTitle = s.title; </tr>
</thead>
<tbody>
{streams.map((s) => {
const dec = decisions.find((d) => d.stream_id === s.id);
const title = effectiveTitle(s, dec);
const origTitle = s.title;
return ( return (
<tr key={s.id} className="bg-sky-50"> <tr key={s.id} className="bg-sky-50">
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{s.stream_index}</td> <td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{s.stream_index}</td>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{s.codec ?? '—'}</td> <td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{s.codec ?? "—"}</td>
<td className="py-1.5 px-2 border-b border-gray-100"> <td className="py-1.5 px-2 border-b border-gray-100">
{editable ? ( {editable ? (
<Select <Select
value={s.language ?? ''} value={s.language ?? ""}
onChange={(e) => onLanguageChange(s.id, e.target.value)} onChange={(e) => onLanguageChange(s.id, e.target.value)}
className="text-[0.79rem] py-0.5 px-1.5 w-auto" className="text-[0.79rem] py-0.5 px-1.5 w-auto"
> >
<option value=""> Unknown </option> <option value=""> Unknown </option>
{Object.entries(LANG_NAMES).map(([code, name]) => ( {Object.entries(LANG_NAMES).map(([code, name]) => (
<option key={code} value={code}>{name} ({code})</option> <option key={code} value={code}>
))} {name} ({code})
</Select> </option>
) : ( ))}
<> </Select>
{langName(s.language)} {s.language ? <span className="text-gray-400 font-mono text-xs">({s.language})</span> : null} ) : (
</> <>
)} {langName(s.language)}{" "}
</td> {s.language ? <span className="text-gray-400 font-mono text-xs">({s.language})</span> : null}
<td className="py-1.5 px-2 border-b border-gray-100"> </>
{editable ? ( )}
<TitleInput </td>
value={title} <td className="py-1.5 px-2 border-b border-gray-100">
onCommit={(v) => onTitleChange(s.id, v)} {editable ? (
/> <TitleInput value={title} onCommit={(v) => onTitleChange(s.id, v)} />
) : ( ) : (
<span>{title || '—'}</span> <span>{title || "—"}</span>
)} )}
{editable && origTitle && origTitle !== title && ( {editable && origTitle && origTitle !== title && (
<div className="text-gray-400 text-[0.7rem] mt-0.5">orig: {origTitle}</div> <div className="text-gray-400 text-[0.7rem] mt-0.5">orig: {origTitle}</div>
)} )}
</td> </td>
<td className="py-1.5 px-2 border-b border-gray-100"> <td className="py-1.5 px-2 border-b border-gray-100">
<span className="inline-flex gap-1"> <span className="inline-flex gap-1">
{s.is_default ? <Badge>default</Badge> : null} {s.is_default ? <Badge>default</Badge> : null}
{s.is_forced ? <Badge variant="manual">forced</Badge> : null} {s.is_forced ? <Badge variant="manual">forced</Badge> : null}
{s.is_hearing_impaired ? <Badge>CC</Badge> : null} {s.is_hearing_impaired ? <Badge>CC</Badge> : null}
</span> </span>
</td> </td>
<td className="py-1.5 px-2 border-b border-gray-100"> <td className="py-1.5 px-2 border-b border-gray-100">
<span className="inline-block border-0 rounded px-2 py-0.5 text-[0.72rem] font-semibold bg-sky-600 text-white min-w-[4.5rem]"> <span className="inline-block border-0 rounded px-2 py-0.5 text-[0.72rem] font-semibold bg-sky-600 text-white min-w-[4.5rem]">
Extract Extract
</span> </span>
</td> </td>
</tr> </tr>
); );
})} })}
</tbody> </tbody>
</table></div> </table>
</div>
); );
} }
@@ -149,54 +162,76 @@ function ExtractedFilesTable({ files, onDelete }: { files: SubtitleFile[]; onDel
if (files.length === 0) return <p className="text-gray-500 text-sm">No extracted files yet.</p>; if (files.length === 0) return <p className="text-gray-500 text-sm">No extracted files yet.</p>;
return ( return (
<div className="overflow-x-auto -mx-4 px-4 sm:mx-0 sm:px-0"><table className="w-full border-collapse text-[0.82rem]"> <div className="overflow-x-auto -mx-4 px-4 sm:mx-0 sm:px-0">
<thead> <table className="w-full border-collapse text-[0.82rem]">
<tr> <thead>
{['File', 'Language', 'Codec', 'Flags', 'Size', ''].map((h) => ( <tr>
<th key={h} className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap">{h}</th> {["File", "Language", "Codec", "Flags", "Size", ""].map((h) => (
))} <th
</tr> key={h}
</thead> className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap"
<tbody> >
{files.map((f) => ( {h}
<tr key={f.id} className="hover:bg-gray-50"> </th>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs max-w-[200px] sm:max-w-[360px] truncate" title={f.file_path}> ))}
{fileName(f.file_path)}
</td>
<td className="py-1.5 px-2 border-b border-gray-100">
{f.language ? langName(f.language) : '—'} {f.language ? <span className="text-gray-400 font-mono text-xs">({f.language})</span> : null}
</td>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{f.codec ?? '—'}</td>
<td className="py-1.5 px-2 border-b border-gray-100">
<span className="inline-flex gap-1">
{f.is_forced ? <Badge variant="manual">forced</Badge> : null}
{f.is_hearing_impaired ? <Badge>CC</Badge> : null}
</span>
</td>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">
{f.file_size ? formatBytes(f.file_size) : '—'}
</td>
<td className="py-1.5 px-2 border-b border-gray-100 text-right">
<Button variant="danger" size="xs" onClick={() => onDelete(f.id)}>Delete</Button>
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table></div> {files.map((f) => (
<tr key={f.id} className="hover:bg-gray-50">
<td
className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs max-w-[200px] sm:max-w-[360px] truncate"
title={f.file_path}
>
{fileName(f.file_path)}
</td>
<td className="py-1.5 px-2 border-b border-gray-100">
{f.language ? langName(f.language) : "—"}{" "}
{f.language ? <span className="text-gray-400 font-mono text-xs">({f.language})</span> : null}
</td>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{f.codec ?? "—"}</td>
<td className="py-1.5 px-2 border-b border-gray-100">
<span className="inline-flex gap-1">
{f.is_forced ? <Badge variant="manual">forced</Badge> : null}
{f.is_hearing_impaired ? <Badge>CC</Badge> : null}
</span>
</td>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">
{f.file_size ? formatBytes(f.file_size) : "—"}
</td>
<td className="py-1.5 px-2 border-b border-gray-100 text-right">
<Button variant="danger" size="xs" onClick={() => onDelete(f.id)}>
Delete
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
); );
} }
// ─── Detail page ────────────────────────────────────────────────────────────── // ─── Detail page ──────────────────────────────────────────────────────────────
export function SubtitleDetailPage() { export function SubtitleDetailPage() {
const { id } = useParams({ from: '/review/subtitles/$id' }); const { id } = useParams({ from: "/review/subtitles/$id" });
const [data, setData] = useState<DetailData | null>(null); const [data, setData] = useState<DetailData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [extracting, setExtracting] = useState(false); const [extracting, setExtracting] = useState(false);
const [rescanning, setRescanning] = useState(false); const [rescanning, setRescanning] = useState(false);
const load = () => api.get<DetailData>(`/api/subtitles/${id}`).then((d) => { setData(d); setLoading(false); }).catch(() => setLoading(false)); const load = () =>
useEffect(() => { load(); }, [id]); api
.get<DetailData>(`/api/subtitles/${id}`)
.then((d) => {
setData(d);
setLoading(false);
})
.catch(() => setLoading(false));
useEffect(() => {
load();
}, [load]);
const changeLanguage = async (streamId: number, lang: string) => { const changeLanguage = async (streamId: number, lang: string) => {
const d = await api.patch<DetailData>(`/api/subtitles/${id}/stream/${streamId}/language`, { language: lang || null }); const d = await api.patch<DetailData>(`/api/subtitles/${id}/stream/${streamId}/language`, { language: lang || null });
@@ -213,7 +248,9 @@ export function SubtitleDetailPage() {
try { try {
await api.post(`/api/subtitles/${id}/extract`); await api.post(`/api/subtitles/${id}/extract`);
load(); load();
} finally { setExtracting(false); } } finally {
setExtracting(false);
}
}; };
const deleteFile = async (fileId: number) => { const deleteFile = async (fileId: number) => {
@@ -223,8 +260,12 @@ export function SubtitleDetailPage() {
const rescan = async () => { const rescan = async () => {
setRescanning(true); setRescanning(true);
try { const d = await api.post<DetailData>(`/api/subtitles/${id}/rescan`); setData(d); } try {
finally { setRescanning(false); } const d = await api.post<DetailData>(`/api/subtitles/${id}/rescan`);
setData(d);
} finally {
setRescanning(false);
}
}; };
if (loading) return <div className="text-gray-400 py-8 text-center">Loading</div>; if (loading) return <div className="text-gray-400 py-8 text-center">Loading</div>;
@@ -238,7 +279,9 @@ export function SubtitleDetailPage() {
<div> <div>
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<h1 className="text-xl font-bold m-0"> <h1 className="text-xl font-bold m-0">
<Link to="/review/subtitles" className="font-normal mr-2 no-underline text-gray-500 hover:text-gray-700"> Subtitles</Link> <Link to="/review/subtitles" className="font-normal mr-2 no-underline text-gray-500 hover:text-gray-700">
Subtitles
</Link>
{item.name} {item.name}
</h1> </h1>
</div> </div>
@@ -247,12 +290,15 @@ export function SubtitleDetailPage() {
{/* Meta */} {/* Meta */}
<dl className="flex flex-wrap gap-5 mb-3 text-[0.82rem]"> <dl className="flex flex-wrap gap-5 mb-3 text-[0.82rem]">
{[ {[
{ label: 'Type', value: item.type }, { label: "Type", value: item.type },
...(item.series_name ? [{ label: 'Series', value: item.series_name }] : []), ...(item.series_name ? [{ label: "Series", value: item.series_name }] : []),
...(item.year ? [{ label: 'Year', value: String(item.year) }] : []), ...(item.year ? [{ label: "Year", value: String(item.year) }] : []),
{ label: 'Container', value: item.container ?? '—' }, { label: "Container", value: item.container ?? "—" },
{ label: 'File size', value: item.file_size ? formatBytes(item.file_size) : '—' }, { label: "File size", value: item.file_size ? formatBytes(item.file_size) : "—" },
{ label: 'Status', value: <Badge variant={subs_extracted ? 'done' : 'pending'}>{subs_extracted ? 'extracted' : 'pending'}</Badge> }, {
label: "Status",
value: <Badge variant={subs_extracted ? "done" : "pending"}>{subs_extracted ? "extracted" : "pending"}</Badge>,
},
].map((entry, i) => ( ].map((entry, i) => (
<div key={i}> <div key={i}>
<dt className="text-gray-500 text-[0.68rem] uppercase tracking-[0.05em] mb-0.5">{entry.label}</dt> <dt className="text-gray-500 text-[0.68rem] uppercase tracking-[0.05em] mb-0.5">{entry.label}</dt>
@@ -273,7 +319,9 @@ export function SubtitleDetailPage() {
onTitleChange={changeTitle} onTitleChange={changeTitle}
/> />
) : ( ) : (
<Alert variant="warning" className="mb-4">No subtitle streams found in this container.</Alert> <Alert variant="warning" className="mb-4">
No subtitle streams found in this container.
</Alert>
)} )}
{/* Extracted files */} {/* Extracted files */}
@@ -301,22 +349,26 @@ export function SubtitleDetailPage() {
{hasContainerSubs && !subs_extracted && ( {hasContainerSubs && !subs_extracted && (
<div className="flex gap-2 mt-6"> <div className="flex gap-2 mt-6">
<Button onClick={extract} disabled={extracting}> <Button onClick={extract} disabled={extracting}>
{extracting ? 'Queuing…' : '✓ Extract All'} {extracting ? "Queuing…" : "✓ Extract All"}
</Button> </Button>
</div> </div>
)} )}
{subs_extracted ? ( {subs_extracted ? (
<Alert variant="success" className="mt-4">Subtitles have been extracted to sidecar files.</Alert> <Alert variant="success" className="mt-4">
Subtitles have been extracted to sidecar files.
</Alert>
) : null} ) : null}
{/* Refresh */} {/* Refresh */}
<div className="flex items-center gap-3 mt-6 pt-3 border-t border-gray-200"> <div className="flex items-center gap-3 mt-6 pt-3 border-t border-gray-200">
<Button variant="secondary" size="sm" onClick={rescan} disabled={rescanning}> <Button variant="secondary" size="sm" onClick={rescan} disabled={rescanning}>
{rescanning ? '↻ Refreshing…' : '↻ Refresh from Jellyfin'} {rescanning ? "↻ Refreshing…" : "↻ Refresh from Jellyfin"}
</Button> </Button>
<span className="text-gray-400 text-[0.75rem]"> <span className="text-gray-400 text-[0.75rem]">
{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"}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -1,25 +1,37 @@
import { useState, useEffect } from 'react'; import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import { Link, useNavigate, useSearch } from '@tanstack/react-router'; import type React from "react";
import { api } from '~/shared/lib/api'; import { useEffect, useState } from "react";
import { Badge } from '~/shared/components/ui/badge'; import { Badge } from "~/shared/components/ui/badge";
import { Button } from '~/shared/components/ui/button'; import { Button } from "~/shared/components/ui/button";
import { FilterTabs } from '~/shared/components/ui/filter-tabs'; import { FilterTabs } from "~/shared/components/ui/filter-tabs";
import { langName } from '~/shared/lib/lang'; import { api } from "~/shared/lib/api";
import type React from 'react'; import { langName } from "~/shared/lib/lang";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
interface SubListItem { interface SubListItem {
id: number; name: string; type: string; series_name: string | null; id: number;
season_number: number | null; episode_number: number | null; name: string;
year: number | null; original_language: string | null; type: string;
subs_extracted: number | null; sub_count: number; file_count: number; 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 { interface SubSeriesGroup {
series_key: string; series_name: string; original_language: string | null; series_key: string;
season_count: number; episode_count: number; series_name: string;
not_extracted_count: number; extracted_count: number; no_subs_count: number; original_language: string | null;
season_count: number;
episode_count: number;
not_extracted_count: number;
extracted_count: number;
no_subs_count: number;
} }
interface SubListData { interface SubListData {
@@ -32,14 +44,16 @@ interface SubListData {
interface SeasonGroup { interface SeasonGroup {
season: number | null; season: number | null;
episodes: SubListItem[]; episodes: SubListItem[];
extractedCount: number; notExtractedCount: number; noSubsCount: number; extractedCount: number;
notExtractedCount: number;
noSubsCount: number;
} }
const FILTER_TABS = [ const FILTER_TABS = [
{ key: 'all', label: 'All' }, { key: "all", label: "All" },
{ key: 'not_extracted', label: 'Not Extracted' }, { key: "not_extracted", label: "Not Extracted" },
{ key: 'extracted', label: 'Extracted' }, { key: "extracted", label: "Extracted" },
{ key: 'no_subs', label: 'No Subtitles' }, { key: "no_subs", label: "No Subtitles" },
]; ];
// ─── Table helpers ──────────────────────────────────────────────────────────── // ─── Table helpers ────────────────────────────────────────────────────────────
@@ -51,27 +65,39 @@ const Th = ({ children }: { children?: React.ReactNode }) => (
); );
const Td = ({ children, className }: { children?: React.ReactNode; className?: string }) => ( const Td = ({ children, className }: { children?: React.ReactNode; className?: string }) => (
<td className={`py-1.5 px-2 border-b border-gray-100 align-middle ${className ?? ''}`}>{children}</td> <td className={`py-1.5 px-2 border-b border-gray-100 align-middle ${className ?? ""}`}>{children}</td>
); );
function subStatus(item: SubListItem): 'extracted' | 'not_extracted' | 'no_subs' { function subStatus(item: SubListItem): "extracted" | "not_extracted" | "no_subs" {
if (item.sub_count === 0) return 'no_subs'; if (item.sub_count === 0) return "no_subs";
return item.subs_extracted ? 'extracted' : 'not_extracted'; return item.subs_extracted ? "extracted" : "not_extracted";
} }
function StatusBadge({ item }: { item: SubListItem }) { function StatusBadge({ item }: { item: SubListItem }) {
const s = subStatus(item); const s = subStatus(item);
if (s === 'extracted') return <Badge variant="keep">extracted</Badge>; if (s === "extracted") return <Badge variant="keep">extracted</Badge>;
if (s === 'not_extracted') return <Badge variant="pending">pending</Badge>; if (s === "not_extracted") return <Badge variant="pending">pending</Badge>;
return <Badge variant="noop">no subs</Badge>; return <Badge variant="noop">no subs</Badge>;
} }
function StatusPills({ g }: { g: SubSeriesGroup }) { function StatusPills({ g }: { g: SubSeriesGroup }) {
return ( return (
<span className="inline-flex flex-wrap gap-1 items-center"> <span className="inline-flex flex-wrap gap-1 items-center">
{g.extracted_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-green-100 text-green-800">{g.extracted_count} extracted</span>} {g.extracted_count > 0 && (
{g.not_extracted_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-red-100 text-red-800">{g.not_extracted_count} pending</span>} <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-green-100 text-green-800">
{g.no_subs_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-gray-200 text-gray-600">{g.no_subs_count} no subs</span>} {g.extracted_count} extracted
</span>
)}
{g.not_extracted_count > 0 && (
<span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-red-100 text-red-800">
{g.not_extracted_count} pending
</span>
)}
{g.no_subs_count > 0 && (
<span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-gray-200 text-gray-600">
{g.no_subs_count} no subs
</span>
)}
</span> </span>
); );
} }
@@ -80,16 +106,18 @@ function StatusPills({ g }: { g: SubSeriesGroup }) {
function ActionBox({ count, onExtract }: { count: number | null; onExtract: () => void }) { function ActionBox({ count, onExtract }: { count: number | null; onExtract: () => void }) {
const [extracting, setExtracting] = useState(false); const [extracting, setExtracting] = useState(false);
const [result, setResult] = useState(''); const [result, setResult] = useState("");
const handleExtract = async () => { const handleExtract = async () => {
setExtracting(true); setExtracting(true);
setResult(''); setResult("");
try { try {
const r = await api.post<{ ok: boolean; queued: number }>('/api/subtitles/extract-all'); const r = await api.post<{ ok: boolean; queued: number }>("/api/subtitles/extract-all");
setResult(`Queued ${r.queued} extraction job${r.queued !== 1 ? 's' : ''}.`); setResult(`Queued ${r.queued} extraction job${r.queued !== 1 ? "s" : ""}.`);
onExtract(); onExtract();
} catch (e) { setResult(`Error: ${e}`); } } catch (e) {
setResult(`Error: ${e}`);
}
setExtracting(false); setExtracting(false);
}; };
@@ -101,9 +129,11 @@ function ActionBox({ count, onExtract }: { count: number | null; onExtract: () =
{allDone && <span className="text-sm font-medium">All subtitles extracted</span>} {allDone && <span className="text-sm font-medium">All subtitles extracted</span>}
{count !== null && count > 0 && ( {count !== null && count > 0 && (
<> <>
<span className="text-sm font-medium">{count} item{count !== 1 ? 's have' : ' has'} embedded subtitles to extract</span> <span className="text-sm font-medium">
{count} item{count !== 1 ? "s have" : " has"} embedded subtitles to extract
</span>
<Button size="sm" onClick={handleExtract} disabled={extracting}> <Button size="sm" onClick={handleExtract} disabled={extracting}>
{extracting ? 'Queuing...' : 'Extract All'} {extracting ? "Queuing..." : "Extract All"}
</Button> </Button>
</> </>
)} )}
@@ -131,13 +161,19 @@ function SeriesRow({ g }: { g: SubSeriesGroup }) {
<tbody> <tbody>
<tr className="cursor-pointer hover:bg-gray-50" onClick={toggle}> <tr className="cursor-pointer hover:bg-gray-50" onClick={toggle}>
<td className="py-1.5 px-2 border-b border-gray-100 font-medium"> <td className="py-1.5 px-2 border-b border-gray-100 font-medium">
<span className={`inline-flex items-center justify-center w-4 h-4 rounded text-[0.65rem] text-gray-500 transition-transform ${open ? 'rotate-90' : ''}`}></span> <span
{' '}<strong>{g.series_name}</strong> className={`inline-flex items-center justify-center w-4 h-4 rounded text-[0.65rem] text-gray-500 transition-transform ${open ? "rotate-90" : ""}`}
>
</span>{" "}
<strong>{g.series_name}</strong>
</td> </td>
<Td>{langName(g.original_language)}</Td> <Td>{langName(g.original_language)}</Td>
<td className="py-1.5 px-2 border-b border-gray-100 text-gray-500">{g.season_count}</td> <td className="py-1.5 px-2 border-b border-gray-100 text-gray-500">{g.season_count}</td>
<td className="py-1.5 px-2 border-b border-gray-100 text-gray-500">{g.episode_count}</td> <td className="py-1.5 px-2 border-b border-gray-100 text-gray-500">{g.episode_count}</td>
<Td><StatusPills g={g} /></Td> <Td>
<StatusPills g={g} />
</Td>
</tr> </tr>
{open && seasons && ( {open && seasons && (
<tr> <tr>
@@ -147,21 +183,41 @@ function SeriesRow({ g }: { g: SubSeriesGroup }) {
{seasons.map((s) => ( {seasons.map((s) => (
<> <>
<tr key={`season-${s.season}`} className="bg-gray-50"> <tr key={`season-${s.season}`} className="bg-gray-50">
<td colSpan={5} className="text-[0.7rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-0.5 px-8 border-b border-gray-100"> <td
Season {s.season ?? '?'} colSpan={5}
className="text-[0.7rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-0.5 px-8 border-b border-gray-100"
>
Season {s.season ?? "?"}
<span className="ml-3 inline-flex gap-1"> <span className="ml-3 inline-flex gap-1">
{s.extractedCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-green-100 text-green-800 text-[0.7rem]">{s.extractedCount} extracted</span>} {s.extractedCount > 0 && (
{s.notExtractedCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-red-100 text-red-800 text-[0.7rem]">{s.notExtractedCount} pending</span>} <span className="px-1.5 py-0.5 rounded-full bg-green-100 text-green-800 text-[0.7rem]">
{s.noSubsCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-gray-200 text-gray-600 text-[0.7rem]">{s.noSubsCount} no subs</span>} {s.extractedCount} extracted
</span>
)}
{s.notExtractedCount > 0 && (
<span className="px-1.5 py-0.5 rounded-full bg-red-100 text-red-800 text-[0.7rem]">
{s.notExtractedCount} pending
</span>
)}
{s.noSubsCount > 0 && (
<span className="px-1.5 py-0.5 rounded-full bg-gray-200 text-gray-600 text-[0.7rem]">
{s.noSubsCount} no subs
</span>
)}
</span> </span>
</td> </td>
</tr> </tr>
{s.episodes.map((item) => ( {s.episodes.map((item) => (
<tr key={item.id} className="hover:bg-gray-50"> <tr key={item.id} className="hover:bg-gray-50">
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem] pl-10"> <td className="py-1 px-2 border-b border-gray-100 text-[0.8rem] pl-10">
<span className="text-gray-400 font-mono text-xs">E{String(item.episode_number ?? 0).padStart(2, '0')}</span> <span className="text-gray-400 font-mono text-xs">
{' '} E{String(item.episode_number ?? 0).padStart(2, "0")}
<Link to="/review/subtitles/$id" params={{ id: String(item.id) }} className="no-underline text-blue-600 hover:text-blue-800"> </span>{" "}
<Link
to="/review/subtitles/$id"
params={{ id: String(item.id) }}
className="no-underline text-blue-600 hover:text-blue-800"
>
<span className="truncate inline-block max-w-xs align-bottom">{item.name}</span> <span className="truncate inline-block max-w-xs align-bottom">{item.name}</span>
</Link> </Link>
</td> </td>
@@ -171,7 +227,11 @@ function SeriesRow({ g }: { g: SubSeriesGroup }) {
<StatusBadge item={item} /> <StatusBadge item={item} />
</td> </td>
<td className="py-1 px-2 border-b border-gray-100 whitespace-nowrap"> <td className="py-1 px-2 border-b border-gray-100 whitespace-nowrap">
<Link to="/review/subtitles/$id" params={{ id: String(item.id) }} className="inline-flex items-center justify-center gap-1 rounded px-2 py-0.5 text-xs font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline"> <Link
to="/review/subtitles/$id"
params={{ id: String(item.id) }}
className="inline-flex items-center justify-center gap-1 rounded px-2 py-0.5 text-xs font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline"
>
Detail Detail
</Link> </Link>
</td> </td>
@@ -195,7 +255,7 @@ const cache = new Map<string, SubListData>();
// ─── Main page ──────────────────────────────────────────────────────────────── // ─── Main page ────────────────────────────────────────────────────────────────
export function SubtitleExtractPage() { export function SubtitleExtractPage() {
const { filter } = useSearch({ from: '/review/subtitles/extract' }); const { filter } = useSearch({ from: "/review/subtitles/extract" });
const navigate = useNavigate(); const navigate = useNavigate();
const [data, setData] = useState<SubListData | null>(cache.get(filter) ?? null); const [data, setData] = useState<SubListData | null>(cache.get(filter) ?? null);
const [loading, setLoading] = useState(!cache.has(filter)); const [loading, setLoading] = useState(!cache.has(filter));
@@ -203,21 +263,33 @@ export function SubtitleExtractPage() {
const load = () => { const load = () => {
if (!cache.has(filter)) setLoading(true); if (!cache.has(filter)) setLoading(true);
api.get<SubListData>(`/api/subtitles?filter=${filter}`) api
.then((d) => { cache.set(filter, d); setData(d); }) .get<SubListData>(`/api/subtitles?filter=${filter}`)
.then((d) => {
cache.set(filter, d);
setData(d);
})
.catch(() => {}) .catch(() => {})
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}; };
const loadEmbedded = () => { const loadEmbedded = () => {
api.get<{ embeddedCount: number }>('/api/subtitles/summary') api
.get<{ embeddedCount: number }>("/api/subtitles/summary")
.then((d) => setEmbeddedCount(d.embeddedCount)) .then((d) => setEmbeddedCount(d.embeddedCount))
.catch(() => {}); .catch(() => {});
}; };
useEffect(() => { load(); loadEmbedded(); }, [filter]); useEffect(() => {
load();
loadEmbedded();
}, [load, loadEmbedded]);
const refresh = () => { cache.clear(); load(); loadEmbedded(); }; const refresh = () => {
cache.clear();
load();
loadEmbedded();
};
return ( return (
<div> <div>
@@ -229,7 +301,7 @@ export function SubtitleExtractPage() {
tabs={FILTER_TABS} tabs={FILTER_TABS}
filter={filter} filter={filter}
totalCounts={data?.totalCounts ?? {}} 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 && <div className="text-gray-400 py-4 text-center text-sm">Loading...</div>} {loading && !data && <div className="text-gray-400 py-4 text-center text-sm">Loading...</div>}
@@ -247,20 +319,36 @@ export function SubtitleExtractPage() {
</div> </div>
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0"> <div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0">
<table className="w-full border-collapse text-[0.82rem]"> <table className="w-full border-collapse text-[0.82rem]">
<thead><tr><Th>Name</Th><Th>Lang</Th><Th>Subs</Th><Th>Files</Th><Th>Status</Th></tr></thead> <thead>
<tr>
<Th>Name</Th>
<Th>Lang</Th>
<Th>Subs</Th>
<Th>Files</Th>
<Th>Status</Th>
</tr>
</thead>
<tbody> <tbody>
{data.movies.map((item) => ( {data.movies.map((item) => (
<tr key={item.id} className="hover:bg-gray-50"> <tr key={item.id} className="hover:bg-gray-50">
<Td> <Td>
<Link to="/review/subtitles/$id" params={{ id: String(item.id) }} className="no-underline text-blue-600 hover:text-blue-800"> <Link
<span className="truncate inline-block max-w-[200px] sm:max-w-[360px]" title={item.name}>{item.name}</span> to="/review/subtitles/$id"
params={{ id: String(item.id) }}
className="no-underline text-blue-600 hover:text-blue-800"
>
<span className="truncate inline-block max-w-[200px] sm:max-w-[360px]" title={item.name}>
{item.name}
</span>
</Link> </Link>
{item.year && <span className="text-gray-400 text-[0.72rem]"> ({item.year})</span>} {item.year && <span className="text-gray-400 text-[0.72rem]"> ({item.year})</span>}
</Td> </Td>
<Td>{langName(item.original_language)}</Td> <Td>{langName(item.original_language)}</Td>
<Td className="font-mono text-xs">{item.sub_count}</Td> <Td className="font-mono text-xs">{item.sub_count}</Td>
<Td className="font-mono text-xs">{item.file_count}</Td> <Td className="font-mono text-xs">{item.file_count}</Td>
<Td><StatusBadge item={item} /></Td> <Td>
<StatusBadge item={item} />
</Td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -271,13 +359,25 @@ export function SubtitleExtractPage() {
{data.series.length > 0 && ( {data.series.length > 0 && (
<> <>
<div className={`flex items-center gap-2 mb-1.5 text-[0.72rem] font-bold uppercase tracking-[0.07em] text-gray-500 ${data.movies.length > 0 ? 'mt-5' : 'mt-0'}`}> <div
className={`flex items-center gap-2 mb-1.5 text-[0.72rem] font-bold uppercase tracking-[0.07em] text-gray-500 ${data.movies.length > 0 ? "mt-5" : "mt-0"}`}
>
TV Series <span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded-full">{data.series.length}</span> TV Series <span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded-full">{data.series.length}</span>
</div> </div>
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0"> <div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0">
<table className="w-full border-collapse text-[0.82rem]"> <table className="w-full border-collapse text-[0.82rem]">
<thead><tr><Th>Series</Th><Th>Lang</Th><Th>S</Th><Th>Ep</Th><Th>Status</Th></tr></thead> <thead>
{data.series.map((g) => <SeriesRow key={g.series_key} g={g} />)} <tr>
<Th>Series</Th>
<Th>Lang</Th>
<Th>S</Th>
<Th>Ep</Th>
<Th>Status</Th>
</tr>
</thead>
{data.series.map((g) => (
<SeriesRow key={g.series_key} g={g} />
))}
</table> </table>
</div> </div>
</> </>

View File

@@ -1,14 +1,14 @@
import { useState, useEffect } from 'react'; import type React from "react";
import { api } from '~/shared/lib/api'; import { useEffect, useState } from "react";
import { Button } from '~/shared/components/ui/button'; import { Button } from "~/shared/components/ui/button";
import { langName } from '~/shared/lib/lang'; import { api } from "~/shared/lib/api";
import type React from 'react'; import { langName } from "~/shared/lib/lang";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
interface SummaryCategory { interface SummaryCategory {
language: string | null; language: string | null;
variant: 'standard' | 'forced' | 'cc'; variant: "standard" | "forced" | "cc";
streamCount: number; streamCount: number;
fileCount: number; fileCount: number;
} }
@@ -36,18 +36,22 @@ const Th = ({ children }: { children?: React.ReactNode }) => (
); );
const Td = ({ children, className }: { children?: React.ReactNode; className?: string }) => ( const Td = ({ children, className }: { children?: React.ReactNode; className?: string }) => (
<td className={`py-1.5 px-2 border-b border-gray-100 align-middle ${className ?? ''}`}>{children}</td> <td className={`py-1.5 px-2 border-b border-gray-100 align-middle ${className ?? ""}`}>{children}</td>
); );
// ─── Language summary table ─────────────────────────────────────────────────── // ─── Language summary table ───────────────────────────────────────────────────
function variantLabel(v: string): string { function variantLabel(v: string): string {
if (v === 'forced') return 'Forced'; if (v === "forced") return "Forced";
if (v === 'cc') return 'CC'; if (v === "cc") return "CC";
return 'Standard'; return "Standard";
} }
function LanguageSummary({ categories, keepLanguages, onDelete }: { function LanguageSummary({
categories,
keepLanguages,
onDelete,
}: {
categories: SummaryCategory[]; categories: SummaryCategory[];
keepLanguages: string[]; keepLanguages: string[];
onDelete: () => void; onDelete: () => void;
@@ -57,21 +61,21 @@ function LanguageSummary({ categories, keepLanguages, onDelete }: {
const [checked, setChecked] = useState<Record<string, boolean>>(() => { const [checked, setChecked] = useState<Record<string, boolean>>(() => {
const init: Record<string, boolean> = {}; const init: Record<string, boolean> = {};
for (const cat of categories) { 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); init[key] = cat.language !== null && keepSet.has(cat.language);
} }
return init; return init;
}); });
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [result, setResult] = useState(''); const [result, setResult] = useState("");
if (categories.length === 0) return null; if (categories.length === 0) return null;
const toggle = (key: string) => setChecked((prev) => ({ ...prev, [key]: !prev[key] })); const toggle = (key: string) => setChecked((prev) => ({ ...prev, [key]: !prev[key] }));
const uncheckedCategories = categories.filter((cat) => { 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; return !checked[key] && cat.fileCount > 0;
}); });
@@ -82,12 +86,14 @@ function LanguageSummary({ categories, keepLanguages, onDelete }: {
variant: cat.variant, variant: cat.variant,
})); }));
setDeleting(true); setDeleting(true);
setResult(''); setResult("");
try { try {
const r = await api.post<{ ok: boolean; deleted: number }>('/api/subtitles/batch-delete', { categories: toDelete }); const r = await api.post<{ ok: boolean; deleted: number }>("/api/subtitles/batch-delete", { categories: toDelete });
setResult(`Deleted ${r.deleted} file${r.deleted !== 1 ? 's' : ''}.`); setResult(`Deleted ${r.deleted} file${r.deleted !== 1 ? "s" : ""}.`);
onDelete(); onDelete();
} catch (e) { setResult(`Error: ${e}`); } } catch (e) {
setResult(`Error: ${e}`);
}
setDeleting(false); setDeleting(false);
}; };
@@ -107,7 +113,7 @@ function LanguageSummary({ categories, keepLanguages, onDelete }: {
</thead> </thead>
<tbody> <tbody>
{categories.map((cat) => { {categories.map((cat) => {
const key = `${cat.language ?? '__null__'}|${cat.variant}`; const key = `${cat.language ?? "__null__"}|${cat.variant}`;
return ( return (
<tr key={key} className="hover:bg-gray-50"> <tr key={key} className="hover:bg-gray-50">
<Td> <Td>
@@ -129,17 +135,13 @@ function LanguageSummary({ categories, keepLanguages, onDelete }: {
</table> </table>
</div> </div>
<div className="flex items-center gap-3 mt-2"> <div className="flex items-center gap-3 mt-2">
<Button <Button size="sm" variant="danger" onClick={handleDelete} disabled={deleting || uncheckedCategories.length === 0}>
size="sm" {deleting ? "Deleting..." : "Delete Unchecked Files"}
variant="danger"
onClick={handleDelete}
disabled={deleting || uncheckedCategories.length === 0}
>
{deleting ? 'Deleting...' : 'Delete Unchecked Files'}
</Button> </Button>
{uncheckedCategories.length > 0 && ( {uncheckedCategories.length > 0 && (
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500">
{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
</span> </span>
)} )}
{result && <span className="text-sm text-gray-600">{result}</span>} {result && <span className="text-sm text-gray-600">{result}</span>}
@@ -150,24 +152,23 @@ function LanguageSummary({ categories, keepLanguages, onDelete }: {
// ─── Title harmonization ────────────────────────────────────────────────────── // ─── Title harmonization ──────────────────────────────────────────────────────
function TitleHarmonization({ titles, onNormalize }: { function TitleHarmonization({ titles, onNormalize }: { titles: SummaryTitle[]; onNormalize: () => void }) {
titles: SummaryTitle[];
onNormalize: () => void;
}) {
const [normalizing, setNormalizing] = useState(false); const [normalizing, setNormalizing] = useState(false);
const [result, setResult] = useState(''); const [result, setResult] = useState("");
const nonCanonical = titles.filter((t) => !t.isCanonical); const nonCanonical = titles.filter((t) => !t.isCanonical);
if (nonCanonical.length === 0) return null; if (nonCanonical.length === 0) return null;
const handleNormalizeAll = async () => { const handleNormalizeAll = async () => {
setNormalizing(true); setNormalizing(true);
setResult(''); setResult("");
try { try {
const r = await api.post<{ ok: boolean; normalized: number }>('/api/subtitles/normalize-titles'); const r = await api.post<{ ok: boolean; normalized: number }>("/api/subtitles/normalize-titles");
setResult(`Normalized ${r.normalized} stream${r.normalized !== 1 ? 's' : ''}.`); setResult(`Normalized ${r.normalized} stream${r.normalized !== 1 ? "s" : ""}.`);
onNormalize(); onNormalize();
} catch (e) { setResult(`Error: ${e}`); } } catch (e) {
setResult(`Error: ${e}`);
}
setNormalizing(false); setNormalizing(false);
}; };
@@ -181,7 +182,8 @@ function TitleHarmonization({ titles, onNormalize }: {
return ( return (
<details className="mb-6"> <details className="mb-6">
<summary className="text-sm font-bold uppercase tracking-wide text-gray-500 mb-2 cursor-pointer select-none"> <summary className="text-sm font-bold uppercase tracking-wide text-gray-500 mb-2 cursor-pointer select-none">
Title Harmonization <span className="text-xs font-normal normal-case text-amber-600">({nonCanonical.length} non-canonical)</span> Title Harmonization{" "}
<span className="text-xs font-normal normal-case text-amber-600">({nonCanonical.length} non-canonical)</span>
</summary> </summary>
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0 mt-2"> <div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0 mt-2">
<table className="w-full border-collapse text-[0.82rem]"> <table className="w-full border-collapse text-[0.82rem]">
@@ -199,19 +201,13 @@ function TitleHarmonization({ titles, onNormalize }: {
<tr key={`${lang}|${t.title}`} className="hover:bg-gray-50"> <tr key={`${lang}|${t.title}`} className="hover:bg-gray-50">
<Td>{langName(lang)}</Td> <Td>{langName(lang)}</Td>
<Td> <Td>
<span className={`font-mono text-xs ${t.isCanonical ? 'text-gray-900' : 'text-amber-700'}`}> <span className={`font-mono text-xs ${t.isCanonical ? "text-gray-900" : "text-amber-700"}`}>
{t.title ? `"${t.title}"` : '(none)'} {t.title ? `"${t.title}"` : "(none)"}
</span> </span>
{t.isCanonical && <span className="ml-2 text-[0.68rem] text-gray-400">(canonical)</span>} {t.isCanonical && <span className="ml-2 text-[0.68rem] text-gray-400">(canonical)</span>}
</Td> </Td>
<Td className="font-mono text-xs">{t.count}</Td> <Td className="font-mono text-xs">{t.count}</Td>
<Td> <Td>{!t.isCanonical && <span className="text-[0.72rem] text-gray-400">will normalize</span>}</Td>
{!t.isCanonical && (
<span className="text-[0.72rem] text-gray-400">
will normalize
</span>
)}
</Td>
</tr> </tr>
)), )),
)} )}
@@ -220,7 +216,7 @@ function TitleHarmonization({ titles, onNormalize }: {
</div> </div>
<div className="flex items-center gap-3 mt-2"> <div className="flex items-center gap-3 mt-2">
<Button size="sm" onClick={handleNormalizeAll} disabled={normalizing}> <Button size="sm" onClick={handleNormalizeAll} disabled={normalizing}>
{normalizing ? 'Normalizing...' : 'Normalize All'} {normalizing ? "Normalizing..." : "Normalize All"}
</Button> </Button>
{result && <span className="text-sm text-gray-600">{result}</span>} {result && <span className="text-sm text-gray-600">{result}</span>}
</div> </div>
@@ -240,13 +236,19 @@ export function SubtitleListPage() {
const loadSummary = () => { const loadSummary = () => {
if (!summaryCache) setLoading(true); if (!summaryCache) setLoading(true);
api.get<SummaryData>('/api/subtitles/summary') api
.then((d) => { summaryCache = d; setSummary(d); }) .get<SummaryData>("/api/subtitles/summary")
.then((d) => {
summaryCache = d;
setSummary(d);
})
.catch(() => {}) .catch(() => {})
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}; };
useEffect(() => { loadSummary(); }, []); useEffect(() => {
loadSummary();
}, [loadSummary]);
const refresh = () => { const refresh = () => {
summaryCache = null; summaryCache = null;
@@ -264,19 +266,20 @@ export function SubtitleListPage() {
<div> <div>
<h1 className="text-xl font-bold mb-4">Subtitle Manager</h1> <h1 className="text-xl font-bold mb-4">Subtitle Manager</h1>
<div className={`rounded-lg px-4 py-3 mb-6 flex items-center gap-3 flex-wrap ${hasFiles ? 'border border-gray-200' : 'border border-gray-200'}`}> <div
className={`rounded-lg px-4 py-3 mb-6 flex items-center gap-3 flex-wrap ${hasFiles ? "border border-gray-200" : "border border-gray-200"}`}
>
{hasFiles ? ( {hasFiles ? (
<span className="text-sm font-medium">{totalFiles} extracted file{totalFiles !== 1 ? 's' : ''} across {langCount} language{langCount !== 1 ? 's' : ''} select which to keep below</span> <span className="text-sm font-medium">
{totalFiles} extracted file{totalFiles !== 1 ? "s" : ""} across {langCount} language{langCount !== 1 ? "s" : ""}
select which to keep below
</span>
) : ( ) : (
<span className="text-sm text-gray-500">No extracted subtitle files yet. Extract subtitles first.</span> <span className="text-sm text-gray-500">No extracted subtitle files yet. Extract subtitles first.</span>
)} )}
</div> </div>
<LanguageSummary <LanguageSummary categories={summary.categories} keepLanguages={summary.keepLanguages} onDelete={refresh} />
categories={summary.categories}
keepLanguages={summary.keepLanguages}
onDelete={refresh}
/>
<TitleHarmonization titles={summary.titles} onNormalize={refresh} /> <TitleHarmonization titles={summary.titles} onNormalize={refresh} />
</div> </div>

View File

@@ -1,6 +1,10 @@
@import "tailwindcss"; @import "tailwindcss";
@layer base { @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;
}
} }

View File

@@ -1,17 +1,19 @@
import './index.css'; import "./index.css";
import { StrictMode } from 'react'; import { createRouter, RouterProvider } from "@tanstack/react-router";
import { createRoot } from 'react-dom/client'; import { StrictMode } from "react";
import { RouterProvider, createRouter } from '@tanstack/react-router'; import { createRoot } from "react-dom/client";
import { routeTree } from './routeTree.gen'; import { routeTree } from "./routeTree.gen";
const router = createRouter({ routeTree, defaultPreload: 'intent' }); const router = createRouter({ routeTree, defaultPreload: "intent" });
declare module '@tanstack/react-router' { declare module "@tanstack/react-router" {
interface Register { router: typeof router; } interface Register {
router: typeof router;
}
} }
const root = document.getElementById('root'); const root = document.getElementById("root");
if (!root) throw new Error('No #root element found'); if (!root) throw new Error("No #root element found");
createRoot(root).render( createRoot(root).render(
<StrictMode> <StrictMode>

View File

@@ -1,7 +1,7 @@
import { createRootRoute, Link, Outlet } from '@tanstack/react-router'; import { createRootRoute, Link, Outlet } from "@tanstack/react-router";
import { useEffect, useState } from 'react'; import { useEffect, useState } from "react";
import { cn } from '~/shared/lib/utils'; import { api } from "~/shared/lib/api";
import { api } from '~/shared/lib/api'; import { cn } from "~/shared/lib/utils";
declare const __APP_VERSION__: string; declare const __APP_VERSION__: string;
@@ -13,8 +13,10 @@ function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
return ( return (
<Link <Link
to={to} to={to}
className={cn('px-2.5 py-1 rounded text-[0.85rem] no-underline transition-colors text-gray-500 hover:bg-gray-100 hover:text-gray-900')} className={cn(
activeProps={{ className: 'bg-gray-100 text-gray-900 font-medium' }} "px-2.5 py-1 rounded text-[0.85rem] no-underline transition-colors text-gray-500 hover:bg-gray-100 hover:text-gray-900",
)}
activeProps={{ className: "bg-gray-100 text-gray-900 font-medium" }}
activeOptions={{ exact: true }} activeOptions={{ exact: true }}
> >
{children} {children}
@@ -24,13 +26,25 @@ function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
function VersionBadge() { function VersionBadge() {
const [serverVersion, setServerVersion] = useState<string | null>(null); const [serverVersion, setServerVersion] = useState<string | null>(null);
useEffect(() => { api.get<{ version: string }>('/api/version').then((d) => setServerVersion(d.version)).catch(() => {}); }, []); useEffect(() => {
const buildVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : null; 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; const mismatch = buildVersion && serverVersion && buildVersion !== serverVersion;
return ( return (
<span className="text-[0.65rem] text-gray-400 font-mono ml-1" title={mismatch ? `Frontend: ${buildVersion}, Server: ${serverVersion}` : undefined}> <span
v{serverVersion ?? buildVersion ?? '?'} className="text-[0.65rem] text-gray-400 font-mono ml-1"
{mismatch && <span className="text-amber-500 ml-1" title="Frontend and server versions differ — rebuild or refresh"></span>} title={mismatch ? `Frontend: ${buildVersion}, Server: ${serverVersion}` : undefined}
>
v{serverVersion ?? buildVersion ?? "?"}
{mismatch && (
<span className="text-amber-500 ml-1" title="Frontend and server versions differ — rebuild or refresh">
</span>
)}
</span> </span>
); );
} }
@@ -66,4 +80,4 @@ function RootLayout() {
); );
} }
import type React from 'react'; import type React from "react";

View File

@@ -1,10 +1,10 @@
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from "@tanstack/react-router";
import { z } from 'zod'; import { z } from "zod";
import { ExecutePage } from '~/features/execute/ExecutePage'; import { ExecutePage } from "~/features/execute/ExecutePage";
export const Route = createFileRoute('/execute')({ export const Route = createFileRoute("/execute")({
validateSearch: z.object({ 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, component: ExecutePage,
}); });

View File

@@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from "@tanstack/react-router";
import { DashboardPage } from '~/features/dashboard/DashboardPage'; import { DashboardPage } from "~/features/dashboard/DashboardPage";
export const Route = createFileRoute('/')({ export const Route = createFileRoute("/")({
component: DashboardPage, component: DashboardPage,
}); });

View File

@@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from "@tanstack/react-router";
import { PathsPage } from '~/features/paths/PathsPage'; import { PathsPage } from "~/features/paths/PathsPage";
export const Route = createFileRoute('/paths')({ export const Route = createFileRoute("/paths")({
component: PathsPage, component: PathsPage,
}); });

View File

@@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from "@tanstack/react-router";
import { PipelinePage } from '~/features/pipeline/PipelinePage'; import { PipelinePage } from "~/features/pipeline/PipelinePage";
export const Route = createFileRoute('/pipeline')({ export const Route = createFileRoute("/pipeline")({
component: PipelinePage, component: PipelinePage,
}); });

View File

@@ -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: () => <Outlet />, component: () => <Outlet />,
}); });

View File

@@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from "@tanstack/react-router";
import { AudioDetailPage } from '~/features/review/AudioDetailPage'; import { AudioDetailPage } from "~/features/review/AudioDetailPage";
export const Route = createFileRoute('/review/audio/$id')({ export const Route = createFileRoute("/review/audio/$id")({
component: AudioDetailPage, component: AudioDetailPage,
}); });

View File

@@ -1,10 +1,10 @@
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from "@tanstack/react-router";
import { z } from 'zod'; import { z } from "zod";
import { AudioListPage } from '~/features/review/AudioListPage'; import { AudioListPage } from "~/features/review/AudioListPage";
export const Route = createFileRoute('/review/audio/')({ export const Route = createFileRoute("/review/audio/")({
validateSearch: z.object({ 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, component: AudioListPage,
}); });

View File

@@ -1,5 +1,7 @@
import { createFileRoute, redirect } from '@tanstack/react-router'; import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute('/review/')({ export const Route = createFileRoute("/review/")({
beforeLoad: () => { throw redirect({ to: '/review/audio' }); }, beforeLoad: () => {
throw redirect({ to: "/review/audio" });
},
}); });

View File

@@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from "@tanstack/react-router";
import { SubtitleDetailPage } from '~/features/subtitles/SubtitleDetailPage'; import { SubtitleDetailPage } from "~/features/subtitles/SubtitleDetailPage";
export const Route = createFileRoute('/review/subtitles/$id')({ export const Route = createFileRoute("/review/subtitles/$id")({
component: SubtitleDetailPage, component: SubtitleDetailPage,
}); });

View File

@@ -1,10 +1,10 @@
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from "@tanstack/react-router";
import { z } from 'zod'; import { z } from "zod";
import { SubtitleExtractPage } from '~/features/subtitles/SubtitleExtractPage'; import { SubtitleExtractPage } from "~/features/subtitles/SubtitleExtractPage";
export const Route = createFileRoute('/review/subtitles/extract')({ export const Route = createFileRoute("/review/subtitles/extract")({
validateSearch: z.object({ 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, component: SubtitleExtractPage,
}); });

View File

@@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from "@tanstack/react-router";
import { SubtitleListPage } from '~/features/subtitles/SubtitleListPage'; import { SubtitleListPage } from "~/features/subtitles/SubtitleListPage";
export const Route = createFileRoute('/review/subtitles/')({ export const Route = createFileRoute("/review/subtitles/")({
component: SubtitleListPage, component: SubtitleListPage,
}); });

View File

@@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from "@tanstack/react-router";
import { ScanPage } from '~/features/scan/ScanPage'; import { ScanPage } from "~/features/scan/ScanPage";
export const Route = createFileRoute('/scan')({ export const Route = createFileRoute("/scan")({
component: ScanPage, component: ScanPage,
}); });

View File

@@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from "@tanstack/react-router";
import { SetupPage } from '~/features/setup/SetupPage'; import { SetupPage } from "~/features/setup/SetupPage";
export const Route = createFileRoute('/settings')({ export const Route = createFileRoute("/settings")({
component: SetupPage, component: SetupPage,
}); });

View File

@@ -1,20 +1,20 @@
import type React from 'react'; import type React from "react";
import { cn } from '~/shared/lib/utils'; import { cn } from "~/shared/lib/utils";
const variants = { const variants = {
info: 'bg-cyan-50 text-cyan-800 border border-cyan-200', info: "bg-cyan-50 text-cyan-800 border border-cyan-200",
warning: 'bg-amber-50 text-amber-800 border border-amber-200', warning: "bg-amber-50 text-amber-800 border border-amber-200",
error: 'bg-red-50 text-red-800 border border-red-200', error: "bg-red-50 text-red-800 border border-red-200",
success: 'bg-green-50 text-green-800 border border-green-200', success: "bg-green-50 text-green-800 border border-green-200",
} as const; } as const;
interface AlertProps extends React.HTMLAttributes<HTMLDivElement> { interface AlertProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: keyof typeof variants; variant?: keyof typeof variants;
} }
export function Alert({ variant = 'info', className, children, ...props }: AlertProps) { export function Alert({ variant = "info", className, children, ...props }: AlertProps) {
return ( return (
<div className={cn('p-3 rounded text-sm', variants[variant], className)} {...props}> <div className={cn("p-3 rounded text-sm", variants[variant], className)} {...props}>
{children} {children}
</div> </div>
); );

View File

@@ -1,28 +1,28 @@
import { cn } from '~/shared/lib/utils'; import { cn } from "~/shared/lib/utils";
const variants = { const variants = {
default: 'bg-gray-100 text-gray-600', default: "bg-gray-100 text-gray-600",
keep: 'bg-green-100 text-green-800', keep: "bg-green-100 text-green-800",
remove: 'bg-red-100 text-red-800', remove: "bg-red-100 text-red-800",
pending: 'bg-gray-200 text-gray-600', pending: "bg-gray-200 text-gray-600",
approved: 'bg-green-100 text-green-800', approved: "bg-green-100 text-green-800",
skipped: 'bg-gray-200 text-gray-600', skipped: "bg-gray-200 text-gray-600",
done: 'bg-cyan-100 text-cyan-800', done: "bg-cyan-100 text-cyan-800",
error: 'bg-red-100 text-red-800', error: "bg-red-100 text-red-800",
noop: 'bg-gray-200 text-gray-600', noop: "bg-gray-200 text-gray-600",
running: 'bg-amber-100 text-amber-800', running: "bg-amber-100 text-amber-800",
manual: 'bg-orange-100 text-orange-800', manual: "bg-orange-100 text-orange-800",
} as const; } as const;
interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> { interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
variant?: keyof typeof variants; variant?: keyof typeof variants;
} }
export function Badge({ variant = 'default', className, children, ...props }: BadgeProps) { export function Badge({ variant = "default", className, children, ...props }: BadgeProps) {
return ( return (
<span <span
className={cn( className={cn(
'inline-block text-[0.67rem] font-semibold px-[0.45em] py-[0.1em] rounded-full uppercase tracking-[0.03em] whitespace-nowrap', "inline-block text-[0.67rem] font-semibold px-[0.45em] py-[0.1em] rounded-full uppercase tracking-[0.03em] whitespace-nowrap",
variants[variant], variants[variant],
className, className,
)} )}
@@ -33,4 +33,4 @@ export function Badge({ variant = 'default', className, children, ...props }: Ba
); );
} }
import type React from 'react'; import type React from "react";

View File

@@ -1,23 +1,23 @@
import type React from 'react'; import type React from "react";
import { cn } from '~/shared/lib/utils'; import { cn } from "~/shared/lib/utils";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger'; variant?: "primary" | "secondary" | "danger";
size?: 'default' | 'sm' | 'xs'; 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 ( return (
<button <button
className={cn( className={cn(
'inline-flex items-center justify-center gap-1 rounded font-medium cursor-pointer transition-colors border-0', "inline-flex items-center justify-center gap-1 rounded font-medium cursor-pointer transition-colors border-0",
variant === 'primary' && 'bg-blue-600 text-white hover:bg-blue-700', variant === "primary" && "bg-blue-600 text-white hover:bg-blue-700",
variant === 'secondary' && 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50', variant === "secondary" && "bg-white text-gray-700 border border-gray-300 hover:bg-gray-50",
variant === 'danger' && 'bg-white text-red-600 border border-red-400 hover:bg-red-50', variant === "danger" && "bg-white text-red-600 border border-red-400 hover:bg-red-50",
size === 'default' && 'px-3 py-1.5 text-sm', size === "default" && "px-3 py-1.5 text-sm",
size === 'sm' && 'px-2.5 py-1 text-xs', size === "sm" && "px-2.5 py-1 text-xs",
size === 'xs' && 'px-2 py-0.5 text-xs', size === "xs" && "px-2 py-0.5 text-xs",
props.disabled && 'opacity-50 cursor-not-allowed', props.disabled && "opacity-50 cursor-not-allowed",
className, className,
)} )}
{...props} {...props}

View File

@@ -11,20 +11,20 @@ interface FilterTabsProps {
} }
const ACTIVE_COLORS: Record<string, string> = { const ACTIVE_COLORS: Record<string, string> = {
all: 'bg-blue-600 border-blue-600', all: "bg-blue-600 border-blue-600",
pending: 'bg-gray-500 border-gray-500', pending: "bg-gray-500 border-gray-500",
needs_action: 'bg-gray-500 border-gray-500', needs_action: "bg-gray-500 border-gray-500",
noop: 'bg-gray-500 border-gray-500', noop: "bg-gray-500 border-gray-500",
not_extracted: 'bg-gray-500 border-gray-500', not_extracted: "bg-gray-500 border-gray-500",
no_subs: 'bg-gray-500 border-gray-500', no_subs: "bg-gray-500 border-gray-500",
skipped: 'bg-gray-500 border-gray-500', skipped: "bg-gray-500 border-gray-500",
running: 'bg-amber-500 border-amber-500', running: "bg-amber-500 border-amber-500",
done: 'bg-green-600 border-green-600', done: "bg-green-600 border-green-600",
approved: 'bg-green-600 border-green-600', approved: "bg-green-600 border-green-600",
extracted: 'bg-green-600 border-green-600', extracted: "bg-green-600 border-green-600",
keep: 'bg-green-600 border-green-600', keep: "bg-green-600 border-green-600",
error: 'bg-red-600 border-red-600', error: "bg-red-600 border-red-600",
manual: 'bg-orange-500 border-orange-500', manual: "bg-orange-500 border-orange-500",
}; };
export function FilterTabs({ tabs, filter, totalCounts, onFilterChange }: FilterTabsProps) { export function FilterTabs({ tabs, filter, totalCounts, onFilterChange }: FilterTabsProps) {
@@ -32,16 +32,21 @@ export function FilterTabs({ tabs, filter, totalCounts, onFilterChange }: Filter
<div className="flex gap-1 flex-wrap mb-3 items-center"> <div className="flex gap-1 flex-wrap mb-3 items-center">
{tabs.map((tab) => { {tabs.map((tab) => {
const isActive = filter === tab.key; const isActive = filter === tab.key;
const activeColor = ACTIVE_COLORS[tab.key] ?? 'bg-blue-600 border-blue-600'; const activeColor = ACTIVE_COLORS[tab.key] ?? "bg-blue-600 border-blue-600";
return ( return (
<button <button
key={tab.key} key={tab.key}
type="button" type="button"
onClick={() => onFilterChange(tab.key)} onClick={() => onFilterChange(tab.key)}
className={`px-2.5 py-0.5 rounded text-[0.8rem] border cursor-pointer transition-colors leading-[1.4] ${isActive ? `${activeColor} text-white` : 'border-gray-200 bg-transparent text-gray-500 hover:bg-gray-50'}`} className={`px-2.5 py-0.5 rounded text-[0.8rem] border cursor-pointer transition-colors leading-[1.4] ${isActive ? `${activeColor} text-white` : "border-gray-200 bg-transparent text-gray-500 hover:bg-gray-50"}`}
> >
{tab.label} {tab.label}
{totalCounts[tab.key] != null && <> <span className="text-[0.72rem] font-bold">{totalCounts[tab.key]}</span></>} {totalCounts[tab.key] != null && (
<>
{" "}
<span className="text-[0.72rem] font-bold">{totalCounts[tab.key]}</span>
</>
)}
</button> </button>
); );
})} })}

View File

@@ -1,13 +1,13 @@
import type React from 'react'; import type React from "react";
import { cn } from '~/shared/lib/utils'; import { cn } from "~/shared/lib/utils";
export function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) { export function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
return ( return (
<input <input
className={cn( className={cn(
'border border-gray-300 rounded px-2.5 py-1.5 text-sm bg-white w-full', "border border-gray-300 rounded px-2.5 py-1.5 text-sm bg-white w-full",
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent', "focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent",
'disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed', "disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed",
className, className,
)} )}
{...props} {...props}

View File

@@ -1,13 +1,13 @@
import type React from 'react'; import type React from "react";
import { cn } from '~/shared/lib/utils'; import { cn } from "~/shared/lib/utils";
export function Select({ className, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) { export function Select({ className, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) {
return ( return (
<select <select
className={cn( className={cn(
'border border-gray-300 rounded px-2 py-1.5 text-sm bg-white', "border border-gray-300 rounded px-2 py-1.5 text-sm bg-white",
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent', "focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent",
'disabled:bg-gray-100 disabled:cursor-not-allowed', "disabled:bg-gray-100 disabled:cursor-not-allowed",
className, className,
)} )}
{...props} {...props}

View File

@@ -1,12 +1,12 @@
import type React from 'react'; import type React from "react";
import { cn } from '~/shared/lib/utils'; import { cn } from "~/shared/lib/utils";
export function Textarea({ className, ...props }: React.TextareaHTMLAttributes<HTMLTextAreaElement>) { export function Textarea({ className, ...props }: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
return ( return (
<textarea <textarea
className={cn( className={cn(
'border border-gray-300 rounded px-2.5 py-1.5 text-sm bg-white w-full resize-vertical', "border border-gray-300 rounded px-2.5 py-1.5 text-sm bg-white w-full resize-vertical",
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent', "focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent",
className, className,
)} )}
{...props} {...props}

View File

@@ -1,9 +1,9 @@
/** Base URL for API calls. In dev Vite proxies /api → :3000. */ /** Base URL for API calls. In dev Vite proxies /api → :3000. */
const BASE = ''; const BASE = "";
async function request<T>(path: string, init?: RequestInit): Promise<T> { async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(BASE + path, { const res = await fetch(BASE + path, {
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) }, headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
...init, ...init,
}); });
if (!res.ok) { if (!res.ok) {
@@ -16,11 +16,10 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
export const api = { export const api = {
get: <T>(path: string) => request<T>(path), get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) => post: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'POST', body: body !== undefined ? JSON.stringify(body) : undefined }), request<T>(path, { method: "POST", body: body !== undefined ? JSON.stringify(body) : undefined }),
patch: <T>(path: string, body?: unknown) => patch: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'PATCH', body: body !== undefined ? JSON.stringify(body) : undefined }), request<T>(path, { method: "PATCH", body: body !== undefined ? JSON.stringify(body) : undefined }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }), delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
/** POST multipart/form-data (file upload). Omit Content-Type so browser sets boundary. */ /** POST multipart/form-data (file upload). Omit Content-Type so browser sets boundary. */
postForm: <T>(path: string, body: FormData) => postForm: <T>(path: string, body: FormData) => request<T>(path, { method: "POST", body, headers: {} }),
request<T>(path, { method: 'POST', body, headers: {} }),
}; };

View File

@@ -1,18 +1,53 @@
export const LANG_NAMES: Record<string, string> = { export const LANG_NAMES: Record<string, string> = {
eng: 'English', deu: 'German', spa: 'Spanish', fra: 'French', ita: 'Italian', eng: "English",
por: 'Portuguese', jpn: 'Japanese', kor: 'Korean', zho: 'Chinese', ara: 'Arabic', deu: "German",
rus: 'Russian', nld: 'Dutch', swe: 'Swedish', nor: 'Norwegian', dan: 'Danish', spa: "Spanish",
fin: 'Finnish', pol: 'Polish', tur: 'Turkish', tha: 'Thai', hin: 'Hindi', fra: "French",
hun: 'Hungarian', ces: 'Czech', ron: 'Romanian', ell: 'Greek', heb: 'Hebrew', ita: "Italian",
fas: 'Persian', ukr: 'Ukrainian', ind: 'Indonesian', msa: 'Malay', vie: 'Vietnamese', por: "Portuguese",
cat: 'Catalan', tam: 'Tamil', tel: 'Telugu', slk: 'Slovak', hrv: 'Croatian', jpn: "Japanese",
bul: 'Bulgarian', srp: 'Serbian', slv: 'Slovenian', lav: 'Latvian', lit: 'Lithuanian', kor: "Korean",
est: 'Estonian', isl: 'Icelandic', nob: 'Norwegian Bokmål', nno: 'Norwegian Nynorsk', 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",
msa: "Malay",
vie: "Vietnamese",
cat: "Catalan",
tam: "Tamil",
tel: "Telugu",
slk: "Slovak",
hrv: "Croatian",
bul: "Bulgarian",
srp: "Serbian",
slv: "Slovenian",
lav: "Latvian",
lit: "Lithuanian",
est: "Estonian",
isl: "Icelandic",
nob: "Norwegian Bokmål",
nno: "Norwegian Nynorsk",
}; };
export const KNOWN_LANG_NAMES = new Set(Object.values(LANG_NAMES).map((n) => n.toLowerCase())); export const KNOWN_LANG_NAMES = new Set(Object.values(LANG_NAMES).map((n) => n.toLowerCase()));
export function langName(code: string | null | undefined): string { export function langName(code: string | null | undefined): string {
if (!code) return '—'; if (!code) return "—";
return LANG_NAMES[code.toLowerCase()] ?? code.toUpperCase(); return LANG_NAMES[code.toLowerCase()] ?? code.toUpperCase();
} }

View File

@@ -3,7 +3,7 @@
export interface MediaItem { export interface MediaItem {
id: number; id: number;
jellyfin_id: string; jellyfin_id: string;
type: 'Movie' | 'Episode'; type: "Movie" | "Episode";
name: string; name: string;
series_name: string | null; series_name: string | null;
series_jellyfin_id: string | null; series_jellyfin_id: string | null;
@@ -46,9 +46,9 @@ export interface ReviewPlan {
item_id: number; item_id: number;
status: string; status: string;
is_noop: number; is_noop: number;
confidence: 'high' | 'low'; confidence: "high" | "low";
apple_compat: 'direct_play' | 'remux' | 'audio_transcode' | null; apple_compat: "direct_play" | "remux" | "audio_transcode" | null;
job_type: 'copy' | 'transcode'; job_type: "copy" | "transcode";
subs_extracted: number; subs_extracted: number;
notes: string | null; notes: string | null;
reviewed_at: string | null; reviewed_at: string | null;
@@ -71,7 +71,7 @@ export interface StreamDecision {
id: number; id: number;
plan_id: number; plan_id: number;
stream_id: number; stream_id: number;
action: 'keep' | 'remove'; action: "keep" | "remove";
target_index: number | null; target_index: number | null;
custom_title: string | null; custom_title: string | null;
transcode_codec: string | null; transcode_codec: string | null;
@@ -81,8 +81,8 @@ export interface Job {
id: number; id: number;
item_id: number; item_id: number;
command: string; command: string;
job_type: 'copy' | 'transcode'; job_type: "copy" | "transcode";
status: 'pending' | 'running' | 'done' | 'error'; status: "pending" | "running" | "done" | "error";
output: string | null; output: string | null;
exit_code: number | null; exit_code: number | null;
created_at: string; created_at: string;

View File

@@ -1,5 +1,5 @@
import { clsx, type ClassValue } from 'clsx'; import { type ClassValue, clsx } from "clsx";
import { twMerge } from 'tailwind-merge'; import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));

View File

@@ -1,32 +1,28 @@
import { defineConfig } from 'vite'; import { resolve } from "node:path";
import react from '@vitejs/plugin-react-swc'; import tailwindcss from "@tailwindcss/vite";
import tailwindcss from '@tailwindcss/vite'; import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'; import react from "@vitejs/plugin-react-swc";
import { resolve } from 'node:path'; import { defineConfig } from "vite";
import pkg from './package.json' with { type: 'json' }; import pkg from "./package.json" with { type: "json" };
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [TanStackRouterVite({ target: "react", autoCodeSplitting: true }), react(), tailwindcss()],
TanStackRouterVite({ target: 'react', autoCodeSplitting: true }),
react(),
tailwindcss(),
],
resolve: { resolve: {
alias: { alias: {
'~': resolve(__dirname, 'src'), "~": resolve(__dirname, "src"),
}, },
}, },
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {
'/api': { target: 'http://localhost:3000', changeOrigin: true }, "/api": { target: "http://localhost:3000", changeOrigin: true },
}, },
}, },
define: { define: {
__APP_VERSION__: JSON.stringify(pkg.version), __APP_VERSION__: JSON.stringify(pkg.version),
}, },
build: { build: {
outDir: 'dist', outDir: "dist",
emptyOutDir: true, emptyOutDir: true,
}, },
}); });