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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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> = {
setup_complete: '0',
jellyfin_url: '',
jellyfin_api_key: '',
jellyfin_user_id: '',
radarr_url: '',
radarr_api_key: '',
radarr_enabled: '0',
sonarr_url: '',
sonarr_api_key: '',
sonarr_enabled: '0',
subtitle_languages: JSON.stringify(['eng', 'deu', 'spa']),
audio_languages: '[]',
setup_complete: "0",
jellyfin_url: "",
jellyfin_api_key: "",
jellyfin_user_id: "",
radarr_url: "",
radarr_api_key: "",
radarr_enabled: "0",
sonarr_url: "",
sonarr_api_key: "",
sonarr_enabled: "0",
subtitle_languages: JSON.stringify(["eng", "deu", "spa"]),
audio_languages: "[]",
scan_running: '0',
job_sleep_seconds: '0',
schedule_enabled: '0',
schedule_start: '01:00',
schedule_end: '07:00',
scan_running: "0",
job_sleep_seconds: "0",
schedule_enabled: "0",
schedule_start: "01:00",
schedule_end: "07:00",
};

View File

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

View File

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

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. */
export function parseId(raw: string | undefined): number | null {
@@ -22,5 +22,5 @@ export function requireId(c: Context, name: string): number | null {
/** True if value is one of the allowed strings. */
export function isOneOf<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 { analyzeItem } from '../analyzer';
import type { MediaStream } from '../../types';
import { describe, expect, test } from "bun:test";
import type { MediaStream } from "../../types";
import { analyzeItem } from "../analyzer";
type StreamOverride = Partial<MediaStream> & Pick<MediaStream, 'id' | 'type' | 'stream_index'>;
type StreamOverride = Partial<MediaStream> & Pick<MediaStream, "id" | "type" | "stream_index">;
function stream(o: StreamOverride): MediaStream {
return {
@@ -22,112 +22,110 @@ function stream(o: StreamOverride): MediaStream {
};
}
const ITEM_DEFAULTS = { needs_review: 0 as number, container: 'mkv' as string | null };
const ITEM_DEFAULTS = { needs_review: 0 as number, container: "mkv" as string | null };
describe('analyzeItem — audio keep rules', () => {
test('keeps only OG + configured languages, drops others', () => {
describe("analyzeItem — audio keep rules", () => {
test("keeps only OG + configured languages, drops others", () => {
const streams = [
stream({ id: 1, type: 'Video', stream_index: 0, codec: 'h264' }),
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }),
stream({ id: 3, type: 'Audio', stream_index: 2, codec: 'aac', language: 'deu' }),
stream({ id: 4, type: 'Audio', stream_index: 3, codec: 'aac', language: 'fra' }),
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng" }),
stream({ id: 3, type: "Audio", stream_index: 2, codec: "aac", language: "deu" }),
stream({ id: 4, type: "Audio", stream_index: 3, codec: "aac", language: "fra" }),
];
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, {
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, {
subtitleLanguages: [],
audioLanguages: ['deu'],
audioLanguages: ["deu"],
});
const actions = Object.fromEntries(result.decisions.map(d => [d.stream_id, d.action]));
expect(actions).toEqual({ 1: 'keep', 2: 'keep', 3: 'keep', 4: 'remove' });
const actions = Object.fromEntries(result.decisions.map((d) => [d.stream_id, d.action]));
expect(actions).toEqual({ 1: "keep", 2: "keep", 3: "keep", 4: "remove" });
});
test('keeps all audio when OG language unknown', () => {
test("keeps all audio when OG language unknown", () => {
const streams = [
stream({ id: 1, type: 'Audio', stream_index: 0, language: 'eng' }),
stream({ id: 2, type: 'Audio', stream_index: 1, language: 'deu' }),
stream({ id: 3, type: 'Audio', stream_index: 2, language: 'fra' }),
stream({ id: 1, type: "Audio", stream_index: 0, language: "eng" }),
stream({ id: 2, type: "Audio", stream_index: 1, language: "deu" }),
stream({ id: 3, type: "Audio", stream_index: 2, language: "fra" }),
];
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: null, needs_review: 1 }, streams, {
subtitleLanguages: [],
audioLanguages: ['deu'],
audioLanguages: ["deu"],
});
expect(result.decisions.every(d => d.action === 'keep')).toBe(true);
expect(result.notes.some(n => n.includes('manual review'))).toBe(true);
expect(result.decisions.every((d) => d.action === "keep")).toBe(true);
expect(result.notes.some((n) => n.includes("manual review"))).toBe(true);
});
test('keeps audio tracks with undetermined language', () => {
test("keeps audio tracks with undetermined language", () => {
const streams = [
stream({ id: 1, type: 'Audio', stream_index: 0, language: 'eng' }),
stream({ id: 2, type: 'Audio', stream_index: 1, language: null }),
stream({ id: 1, type: "Audio", stream_index: 0, language: "eng" }),
stream({ id: 2, type: "Audio", stream_index: 1, language: null }),
];
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, {
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, {
subtitleLanguages: [],
audioLanguages: [],
});
const byId = Object.fromEntries(result.decisions.map(d => [d.stream_id, d.action]));
expect(byId[2]).toBe('keep');
const byId = Object.fromEntries(result.decisions.map((d) => [d.stream_id, d.action]));
expect(byId[2]).toBe("keep");
});
test('normalizes language codes (ger → deu)', () => {
const streams = [
stream({ id: 1, type: 'Audio', stream_index: 0, language: 'ger' }),
];
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'deu' }, streams, {
test("normalizes language codes (ger → deu)", () => {
const streams = [stream({ id: 1, type: "Audio", stream_index: 0, language: "ger" })];
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "deu" }, streams, {
subtitleLanguages: [],
audioLanguages: [],
});
expect(result.decisions[0].action).toBe('keep');
expect(result.decisions[0].action).toBe("keep");
});
});
describe('analyzeItem — audio ordering', () => {
test('OG first, then additional languages in configured order', () => {
describe("analyzeItem — audio ordering", () => {
test("OG first, then additional languages in configured order", () => {
const streams = [
stream({ id: 10, type: 'Audio', stream_index: 0, codec: 'aac', language: 'deu' }),
stream({ id: 11, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }),
stream({ id: 12, type: 'Audio', stream_index: 2, codec: 'aac', language: 'spa' }),
stream({ id: 10, type: "Audio", stream_index: 0, codec: "aac", language: "deu" }),
stream({ id: 11, type: "Audio", stream_index: 1, codec: "aac", language: "eng" }),
stream({ id: 12, type: "Audio", stream_index: 2, codec: "aac", language: "spa" }),
];
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, {
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, {
subtitleLanguages: [],
audioLanguages: ['deu', 'spa'],
audioLanguages: ["deu", "spa"],
});
const byId = Object.fromEntries(result.decisions.map(d => [d.stream_id, d.target_index]));
const byId = Object.fromEntries(result.decisions.map((d) => [d.stream_id, d.target_index]));
expect(byId[11]).toBe(0); // eng (OG) first
expect(byId[10]).toBe(1); // deu second
expect(byId[12]).toBe(2); // spa third
});
test('audioOrderChanged is_noop=false when OG audio is not first in input', () => {
test("audioOrderChanged is_noop=false when OG audio is not first in input", () => {
const streams = [
stream({ id: 1, type: 'Video', stream_index: 0, codec: 'h264' }),
stream({ id: 2, type: 'Audio', stream_index: 1, language: 'deu' }),
stream({ id: 3, type: 'Audio', stream_index: 2, language: 'eng' }),
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
stream({ id: 2, type: "Audio", stream_index: 1, language: "deu" }),
stream({ id: 3, type: "Audio", stream_index: 2, language: "eng" }),
];
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, {
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, {
subtitleLanguages: [],
audioLanguages: ['deu'],
audioLanguages: ["deu"],
});
expect(result.is_noop).toBe(false);
});
test('audioOrderChanged is_noop=true when OG audio is already first', () => {
test("audioOrderChanged is_noop=true when OG audio is already first", () => {
const streams = [
stream({ id: 1, type: 'Video', stream_index: 0, codec: 'h264' }),
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }),
stream({ id: 3, type: 'Audio', stream_index: 2, codec: 'aac', language: 'deu' }),
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng" }),
stream({ id: 3, type: "Audio", stream_index: 2, codec: "aac", language: "deu" }),
];
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, {
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, {
subtitleLanguages: [],
audioLanguages: ['deu'],
audioLanguages: ["deu"],
});
expect(result.is_noop).toBe(true);
});
test('removing an audio track triggers non-noop even if OG first', () => {
test("removing an audio track triggers non-noop even if OG first", () => {
const streams = [
stream({ id: 1, type: 'Audio', stream_index: 0, codec: 'aac', language: 'eng' }),
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'fra' }),
stream({ id: 1, type: "Audio", stream_index: 0, codec: "aac", language: "eng" }),
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "fra" }),
];
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, {
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, {
subtitleLanguages: [],
audioLanguages: [],
});
@@ -135,27 +133,27 @@ describe('analyzeItem — audio ordering', () => {
});
});
describe('analyzeItem — subtitles & is_noop', () => {
test('subtitles are always marked remove (extracted to sidecar)', () => {
describe("analyzeItem — subtitles & is_noop", () => {
test("subtitles are always marked remove (extracted to sidecar)", () => {
const streams = [
stream({ id: 1, type: 'Audio', stream_index: 0, codec: 'aac', language: 'eng' }),
stream({ id: 2, type: 'Subtitle', stream_index: 1, codec: 'subrip', language: 'eng' }),
stream({ id: 1, type: "Audio", stream_index: 0, codec: "aac", language: "eng" }),
stream({ id: 2, type: "Subtitle", stream_index: 1, codec: "subrip", language: "eng" }),
];
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, {
subtitleLanguages: ['eng'],
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, {
subtitleLanguages: ["eng"],
audioLanguages: [],
});
const subDec = result.decisions.find(d => d.stream_id === 2);
expect(subDec?.action).toBe('remove');
const subDec = result.decisions.find((d) => d.stream_id === 2);
expect(subDec?.action).toBe("remove");
expect(result.is_noop).toBe(false); // subs present → not noop
});
test('no audio change, no subs → is_noop true', () => {
test("no audio change, no subs → is_noop true", () => {
const streams = [
stream({ id: 1, type: 'Video', stream_index: 0, codec: 'h264' }),
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }),
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng" }),
];
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, {
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, {
subtitleLanguages: [],
audioLanguages: [],
});
@@ -163,29 +161,25 @@ describe('analyzeItem — subtitles & is_noop', () => {
});
});
describe('analyzeItem — transcode targets', () => {
test('DTS on mp4 → transcode to eac3', () => {
const streams = [
stream({ id: 1, type: 'Audio', stream_index: 0, codec: 'dts', language: 'eng' }),
];
const result = analyzeItem({ original_language: 'eng', needs_review: 0, container: 'mp4' }, streams, {
describe("analyzeItem — transcode targets", () => {
test("DTS on mp4 → transcode to eac3", () => {
const streams = [stream({ id: 1, type: "Audio", stream_index: 0, codec: "dts", language: "eng" })];
const result = analyzeItem({ original_language: "eng", needs_review: 0, container: "mp4" }, streams, {
subtitleLanguages: [],
audioLanguages: [],
});
expect(result.decisions[0].transcode_codec).toBe('eac3');
expect(result.job_type).toBe('transcode');
expect(result.decisions[0].transcode_codec).toBe("eac3");
expect(result.job_type).toBe("transcode");
expect(result.is_noop).toBe(false);
});
test('AAC passes through without transcode', () => {
const streams = [
stream({ id: 1, type: 'Audio', stream_index: 0, codec: 'aac', language: 'eng' }),
];
const result = analyzeItem({ original_language: 'eng', needs_review: 0, container: 'mp4' }, streams, {
test("AAC passes through without transcode", () => {
const streams = [stream({ id: 1, type: "Audio", stream_index: 0, codec: "aac", language: "eng" })];
const result = analyzeItem({ original_language: "eng", needs_review: 0, container: "mp4" }, streams, {
subtitleLanguages: [],
audioLanguages: [],
});
expect(result.decisions[0].transcode_codec).toBe(null);
expect(result.job_type).toBe('copy');
expect(result.job_type).toBe("copy");
});
});

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -1,4 +1,4 @@
import { getConfig, setConfig } from '../db';
import { getConfig, setConfig } from "../db";
export interface SchedulerState {
job_sleep_seconds: number;
@@ -9,18 +9,18 @@ export interface SchedulerState {
export function getSchedulerState(): SchedulerState {
return {
job_sleep_seconds: parseInt(getConfig('job_sleep_seconds') ?? '0', 10),
schedule_enabled: getConfig('schedule_enabled') === '1',
schedule_start: getConfig('schedule_start') ?? '01:00',
schedule_end: getConfig('schedule_end') ?? '07:00',
job_sleep_seconds: parseInt(getConfig("job_sleep_seconds") ?? "0", 10),
schedule_enabled: getConfig("schedule_enabled") === "1",
schedule_start: getConfig("schedule_start") ?? "01:00",
schedule_end: getConfig("schedule_end") ?? "07:00",
};
}
export function updateSchedulerState(updates: Partial<SchedulerState>): void {
if (updates.job_sleep_seconds != null) setConfig('job_sleep_seconds', String(updates.job_sleep_seconds));
if (updates.schedule_enabled != null) setConfig('schedule_enabled', updates.schedule_enabled ? '1' : '0');
if (updates.schedule_start != null) setConfig('schedule_start', updates.schedule_start);
if (updates.schedule_end != null) setConfig('schedule_end', updates.schedule_end);
if (updates.job_sleep_seconds != null) setConfig("job_sleep_seconds", String(updates.job_sleep_seconds));
if (updates.schedule_enabled != null) setConfig("schedule_enabled", updates.schedule_enabled ? "1" : "0");
if (updates.schedule_start != null) setConfig("schedule_start", updates.schedule_start);
if (updates.schedule_end != null) setConfig("schedule_end", updates.schedule_end);
}
/** Check if current time is within the schedule window. */
@@ -63,7 +63,7 @@ export function nextWindowTime(): string {
}
function parseTime(hhmm: string): number {
const [h, m] = hhmm.split(':').map(Number);
const [h, m] = hhmm.split(":").map(Number);
return h * 60 + m;
}
@@ -71,12 +71,12 @@ function parseTime(hhmm: string): number {
export function sleepBetweenJobs(): Promise<void> {
const seconds = getSchedulerState().job_sleep_seconds;
if (seconds <= 0) return Promise.resolve();
return new Promise(resolve => setTimeout(resolve, seconds * 1000));
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
}
/** Wait until the schedule window opens. Resolves immediately if already in window. */
export function waitForWindow(): Promise<void> {
if (isInScheduleWindow()) return Promise.resolve();
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 {
url: string;
@@ -6,7 +6,7 @@ export interface SonarrConfig {
}
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 }> {
@@ -27,10 +27,7 @@ interface SonarrSeries {
}
/** Returns ISO 639-2 original language for a series or null. */
export async function getOriginalLanguage(
cfg: SonarrConfig,
tvdbId: string
): Promise<string | null> {
export async function getOriginalLanguage(cfg: SonarrConfig, tvdbId: string): Promise<string | null> {
try {
const res = await fetch(`${cfg.url}/api/v3/series?tvdbId=${tvdbId}`, {
headers: headers(cfg.apiKey),
@@ -47,36 +44,36 @@ export async function getOriginalLanguage(
}
const NAME_TO_639_2: Record<string, string> = {
english: 'eng',
french: 'fra',
german: 'deu',
spanish: 'spa',
italian: 'ita',
portuguese: 'por',
japanese: 'jpn',
korean: 'kor',
chinese: 'zho',
arabic: 'ara',
russian: 'rus',
dutch: 'nld',
swedish: 'swe',
norwegian: 'nor',
danish: 'dan',
finnish: 'fin',
polish: 'pol',
turkish: 'tur',
thai: 'tha',
hindi: 'hin',
hungarian: 'hun',
czech: 'ces',
romanian: 'ron',
greek: 'ell',
hebrew: 'heb',
persian: 'fas',
ukrainian: 'ukr',
indonesian: 'ind',
malay: 'msa',
vietnamese: 'vie',
english: "eng",
french: "fra",
german: "deu",
spanish: "spa",
italian: "ita",
portuguese: "por",
japanese: "jpn",
korean: "kor",
chinese: "zho",
arabic: "ara",
russian: "rus",
dutch: "nld",
swedish: "swe",
norwegian: "nor",
danish: "dan",
finnish: "fin",
polish: "pol",
turkish: "tur",
thai: "tha",
hindi: "hin",
hungarian: "hun",
czech: "ces",
romanian: "ron",
greek: "ell",
hebrew: "heb",
persian: "fas",
ukrainian: "ukr",
indonesian: "ind",
malay: "msa",
vietnamese: "vie",
};
function languageNameToCode(name: string): string | null {

View File

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

View File

@@ -1,20 +1,29 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from '@tanstack/react-router';
import { api } from '~/shared/lib/api';
import { Button } from '~/shared/components/ui/button';
import { Alert } from '~/shared/components/ui/alert';
import { Link, useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { Alert } from "~/shared/components/ui/alert";
import { Button } from "~/shared/components/ui/button";
import { api } from "~/shared/lib/api";
interface Stats {
totalItems: number; scanned: number; needsAction: number;
approved: number; done: number; errors: number; noChange: number;
totalItems: number;
scanned: number;
needsAction: number;
approved: number;
done: number;
errors: number;
noChange: number;
}
interface DashboardData { stats: Stats; scanRunning: boolean; setupComplete: boolean; }
interface DashboardData {
stats: Stats;
scanRunning: boolean;
setupComplete: boolean;
}
function StatCard({ label, value, danger }: { label: string; value: number; danger?: boolean }) {
return (
<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()}
</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);
useEffect(() => {
api.get<DashboardData>('/api/dashboard').then((d) => {
api
.get<DashboardData>("/api/dashboard")
.then((d) => {
setData(d);
setLoading(false);
if (!d.setupComplete) navigate({ to: '/setup' });
}).catch(() => setLoading(false));
if (!d.setupComplete) navigate({ to: "/setup" });
})
.catch(() => setLoading(false));
}, [navigate]);
const startScan = async () => {
setStarting(true);
await api.post('/api/scan/start', {}).catch(() => {});
navigate({ to: '/scan' });
await api.post("/api/scan/start", {}).catch(() => {});
navigate({ to: "/scan" });
};
if (loading) return <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">
{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
</Link>
) : (
<Button onClick={startScan} disabled={starting}>
{starting ? 'Starting…' : '▶ Start Scan'}
{starting ? "Starting…" : "▶ Start Scan"}
</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
</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
</Link>
</div>

View File

@@ -1,39 +1,46 @@
import { useEffect, useRef, useState } from 'react';
import { Link, useNavigate, useSearch } from '@tanstack/react-router';
import { api } from '~/shared/lib/api';
import { Badge } from '~/shared/components/ui/badge';
import { Button } from '~/shared/components/ui/button';
import { FilterTabs } from '~/shared/components/ui/filter-tabs';
import type { Job, MediaItem } from '~/shared/lib/types';
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { Badge } from "~/shared/components/ui/badge";
import { Button } from "~/shared/components/ui/button";
import { FilterTabs } from "~/shared/components/ui/filter-tabs";
import { api } from "~/shared/lib/api";
import type { Job, MediaItem } from "~/shared/lib/types";
interface JobEntry { job: Job; item: MediaItem | null; }
interface ExecuteData { jobs: JobEntry[]; filter: string; totalCounts: Record<string, number>; }
interface JobEntry {
job: Job;
item: MediaItem | null;
}
interface ExecuteData {
jobs: JobEntry[];
filter: string;
totalCounts: Record<string, number>;
}
const FILTER_TABS = [
{ key: 'all', label: 'All' },
{ key: 'pending', label: 'Pending' },
{ key: 'running', label: 'Running' },
{ key: 'done', label: 'Done' },
{ key: 'error', label: 'Error' },
{ key: "all", label: "All" },
{ key: "pending", label: "Pending" },
{ key: "running", label: "Running" },
{ key: "done", label: "Done" },
{ key: "error", label: "Error" },
];
function itemName(job: Job, item: MediaItem | null): string {
if (!item) return `Item #${job.item_id}`;
if (item.type === 'Episode' && item.series_name) {
return `${item.series_name} S${String(item.season_number ?? 0).padStart(2, '0')}E${String(item.episode_number ?? 0).padStart(2, '0')}`;
if (item.type === "Episode" && item.series_name) {
return `${item.series_name} S${String(item.season_number ?? 0).padStart(2, "0")}E${String(item.episode_number ?? 0).padStart(2, "0")}`;
}
return item.name;
}
function jobTypeLabel(job: Job): string {
return job.job_type === 'subtitle' ? 'ST Extract' : 'Audio Mod';
return job.job_type === "subtitle" ? "ST Extract" : "Audio Mod";
}
// Module-level cache for instant tab switching
const cache = new Map<string, ExecuteData>();
export function ExecutePage() {
const { filter } = useSearch({ from: '/execute' });
const { filter } = useSearch({ from: "/execute" });
const navigate = useNavigate();
const [data, setData] = useState<ExecuteData | null>(cache.get(filter) ?? null);
const [loading, setLoading] = useState(!cache.has(filter));
@@ -46,22 +53,35 @@ export function ExecutePage() {
const load = (f?: string) => {
const key = f ?? filter;
const cached = cache.get(key);
if (cached && key === filter) { setData(cached); setLoading(false); }
else if (key === filter) { setLoading(true); }
api.get<ExecuteData>(`/api/execute?filter=${key}`)
.then((d) => { cache.set(key, d); if (key === filter) { setData(d); setLoading(false); } })
.catch(() => { if (key === filter) setLoading(false); });
if (cached && key === filter) {
setData(cached);
setLoading(false);
} else if (key === filter) {
setLoading(true);
}
api
.get<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(() => {
load();
}, [filter]);
}, [load]);
// SSE for live job updates
useEffect(() => {
const es = new EventSource('/api/execute/events');
const es = new EventSource("/api/execute/events");
esRef.current = es;
es.addEventListener('job_update', (e) => {
es.addEventListener("job_update", (e) => {
const d = JSON.parse(e.data) as { id: number; status: string; output?: string };
// Update job in current list if present
@@ -71,7 +91,7 @@ export function ExecutePage() {
if (jobIdx === -1) return prev;
const oldStatus = prev.jobs[jobIdx].job.status;
const newStatus = d.status as Job['status'];
const newStatus = d.status as Job["status"];
// Live-update totalCounts
const newCounts = { ...prev.totalCounts };
@@ -84,18 +104,20 @@ export function ExecutePage() {
return {
...prev,
totalCounts: newCounts,
jobs: prev.jobs.map((j) =>
j.job.id === d.id ? { ...j, job: { ...j.job, status: newStatus } } : j
),
jobs: prev.jobs.map((j) => (j.job.id === d.id ? { ...j, job: { ...j.job, status: newStatus } } : j)),
};
});
if (d.output !== undefined) {
setLogs((prev) => { const m = new Map(prev); m.set(d.id, d.output!); return m; });
setLogs((prev) => {
const m = new Map(prev);
m.set(d.id, d.output!);
return m;
});
}
// Debounced reload on terminal state for accurate list
if (d.status === 'done' || d.status === 'error') {
if (d.status === "done" || d.status === "error") {
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
reloadTimerRef.current = setTimeout(() => {
// Invalidate cache and reload current filter
@@ -104,17 +126,50 @@ export function ExecutePage() {
}, 1000);
}
});
return () => { es.close(); if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current); };
}, [filter]);
return () => {
es.close();
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
};
}, [load]);
const startAll = async () => { await api.post('/api/execute/start'); cache.clear(); load(); };
const clearQueue = async () => { await api.post('/api/execute/clear'); cache.clear(); load(); };
const clearCompleted = async () => { await api.post('/api/execute/clear-completed'); cache.clear(); load(); };
const runJob = async (id: number) => { await api.post(`/api/execute/job/${id}/run`); cache.clear(); load(); };
const cancelJob = async (id: number) => { await api.post(`/api/execute/job/${id}/cancel`); cache.clear(); load(); };
const startAll = async () => {
await api.post("/api/execute/start");
cache.clear();
load();
};
const clearQueue = async () => {
await api.post("/api/execute/clear");
cache.clear();
load();
};
const clearCompleted = async () => {
await api.post("/api/execute/clear-completed");
cache.clear();
load();
};
const runJob = async (id: number) => {
await api.post(`/api/execute/job/${id}/run`);
cache.clear();
load();
};
const cancelJob = async (id: number) => {
await api.post(`/api/execute/job/${id}/cancel`);
cache.clear();
load();
};
const toggleLog = (id: number) => setLogVisible((prev) => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; });
const toggleCmd = (id: number) => setCmdVisible((prev) => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; });
const toggleLog = (id: number) =>
setLogVisible((prev) => {
const s = new Set(prev);
s.has(id) ? s.delete(id) : s.add(id);
return s;
});
const toggleCmd = (id: number) =>
setCmdVisible((prev) => {
const s = new Set(prev);
s.has(id) ? s.delete(id) : s.add(id);
return s;
});
const totalCounts = data?.totalCounts ?? { all: 0, pending: 0, running: 0, done: 0, error: 0 };
const pending = totalCounts.pending ?? 0;
@@ -130,27 +185,31 @@ export function ExecutePage() {
<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">
{totalCounts.all === 0 && !loading && (
<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-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>}
{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 && (
<>
<span className="text-sm font-medium">{pending} job{pending !== 1 ? 's' : ''} pending</span>
<Button size="sm" onClick={startAll}>Run all pending</Button>
<Button size="sm" variant="secondary" onClick={clearQueue}>Clear queue</Button>
<span className="text-sm font-medium">
{pending} job{pending !== 1 ? "s" : ""} pending
</span>
<Button size="sm" onClick={startAll}>
Run all pending
</Button>
<Button size="sm" variant="secondary" onClick={clearQueue}>
Clear queue
</Button>
</>
)}
{(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>
@@ -158,25 +217,31 @@ export function ExecutePage() {
tabs={FILTER_TABS}
filter={filter}
totalCounts={totalCounts}
onFilterChange={(key) => navigate({ to: '/execute', search: { filter: key } as never })}
onFilterChange={(key) => navigate({ to: "/execute", search: { filter: key } as never })}
/>
{loading && !data && <div className="text-gray-400 py-8 text-center">Loading</div>}
{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">
<table className="w-full border-collapse text-[0.82rem]">
<thead>
<tr>
{['#', 'Item', 'Type', 'Status', 'Actions'].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>
{["#", "Item", "Type", "Status", "Actions"].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>
))}
</tr>
</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 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 (
@@ -186,28 +251,48 @@ export function ExecutePage() {
<td className="py-1.5 px-2 border-b border-gray-100">
<div className="truncate max-w-[300px]" title={name}>
{item ? (
<Link to="/review/audio/$id" params={{ id: String(item.id) }} className="text-inherit no-underline hover:underline">{name}</Link>
) : name}
<Link
to="/review/audio/$id"
params={{ id: String(item.id) }}
className="text-inherit no-underline hover:underline"
>
{name}
</Link>
) : (
name
)}
</div>
</td>
<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>
<Badge variant={job.job_type === "subtitle" ? "noop" : "default"}>{jobTypeLabel(job)}</Badge>
</td>
<td className="py-1.5 px-2 border-b border-gray-100">
<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>}
{job.exit_code != null && job.exit_code !== 0 && (
<Badge variant="error" className="ml-1">
exit {job.exit_code}
</Badge>
)}
</td>
<td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap">
<div className="flex gap-1 items-center">
{job.status === 'pending' && (
{job.status === "pending" && (
<>
<Button size="sm" onClick={() => runJob(job.id)}> Run</Button>
<Button size="sm" variant="secondary" onClick={() => cancelJob(job.id)}></Button>
<Button size="sm" onClick={() => runJob(job.id)}>
Run
</Button>
<Button size="sm" variant="secondary" onClick={() => cancelJob(job.id)}>
</Button>
</>
)}
<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>
<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>
</td>
@@ -234,7 +319,8 @@ export function ExecutePage() {
);
})}
</tbody>
</table></div>
</table>
</div>
)}
{!loading && jobs.length === 0 && totalCounts.all > 0 && (

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { Badge } from '~/shared/components/ui/badge';
import { Badge } from "~/shared/components/ui/badge";
interface ProcessingColumnProps {
items: any[];
@@ -12,18 +12,18 @@ export function ProcessingColumn({ items, progress, queueStatus }: ProcessingCol
const formatTime = (s: number) => {
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${String(sec).padStart(2, '0')}`;
return `${m}:${String(sec).padStart(2, "0")}`;
};
return (
<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="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">
{queueStatus.status === 'paused' && <>Paused until {queueStatus.until}</>}
{queueStatus.status === 'sleeping' && <>Sleeping {queueStatus.seconds}s between jobs</>}
{queueStatus.status === 'idle' && <>Idle</>}
{queueStatus.status === "paused" && <>Paused until {queueStatus.until}</>}
{queueStatus.status === "sleeping" && <>Sleeping {queueStatus.seconds}s between jobs</>}
{queueStatus.status === "idle" && <>Idle</>}
</div>
)}
@@ -32,9 +32,7 @@ export function ProcessingColumn({ items, progress, queueStatus }: ProcessingCol
<p className="text-sm font-medium truncate">{job.name}</p>
<div className="flex items-center gap-2 mt-1">
<Badge variant="running">running</Badge>
<Badge variant={job.job_type === 'transcode' ? 'manual' : 'noop'}>
{job.job_type}
</Badge>
<Badge variant={job.job_type === "transcode" ? "manual" : "noop"}>{job.job_type}</Badge>
</div>
{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 {
items: any[];
@@ -14,14 +14,10 @@ export function QueueColumn({ items }: QueueColumnProps) {
{items.map((item: any) => (
<div key={item.id} className="rounded border bg-white p-2">
<p className="text-xs font-medium truncate">{item.name}</p>
<Badge variant={item.job_type === 'transcode' ? 'manual' : 'noop'}>
{item.job_type}
</Badge>
<Badge variant={item.job_type === "transcode" ? "manual" : "noop"}>{item.job_type}</Badge>
</div>
))}
{items.length === 0 && (
<p className="text-sm text-gray-400 text-center py-8">Queue empty</p>
)}
{items.length === 0 && <p className="text-sm text-gray-400 text-center py-8">Queue empty</p>}
</div>
</div>
);

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,20 @@
import { useEffect, useState } from 'react';
import { Link, useParams } from '@tanstack/react-router';
import { api } from '~/shared/lib/api';
import { Badge } from '~/shared/components/ui/badge';
import { Button } from '~/shared/components/ui/button';
import { Alert } from '~/shared/components/ui/alert';
import { Select } from '~/shared/components/ui/select';
import { langName, LANG_NAMES } from '~/shared/lib/lang';
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from '~/shared/lib/types';
import { Link, useParams } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { Alert } from "~/shared/components/ui/alert";
import { Badge } from "~/shared/components/ui/badge";
import { Button } from "~/shared/components/ui/button";
import { Select } from "~/shared/components/ui/select";
import { api } from "~/shared/lib/api";
import { LANG_NAMES, langName } from "~/shared/lib/lang";
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from "~/shared/lib/types";
// ─── Types ────────────────────────────────────────────────────────────────────
interface DetailData {
item: MediaItem; streams: MediaStream[];
plan: ReviewPlan | null; decisions: StreamDecision[];
item: MediaItem;
streams: MediaStream[];
plan: ReviewPlan | null;
decisions: StreamDecision[];
command: string | null;
}
@@ -28,15 +30,15 @@ function formatBytes(bytes: number): string {
function effectiveLabel(s: MediaStream, dec: StreamDecision | undefined): string {
if (dec?.custom_title) return dec.custom_title;
if (s.title) return s.title;
if (s.type === 'Audio' && s.channels) return `${s.channels}ch ${s.channel_layout ?? ''}`.trim();
return s.language ? langName(s.language) : '';
if (s.type === "Audio" && s.channels) return `${s.channels}ch ${s.channel_layout ?? ""}`.trim();
return s.language ? langName(s.language) : "";
}
// ─── Stream table ─────────────────────────────────────────────────────────────
const STREAM_SECTIONS = [
{ type: 'Video', label: 'Video' },
{ type: 'Audio', label: 'Audio' },
{ type: "Video", label: "Video" },
{ type: "Audio", label: "Audio" },
];
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). */
function computeOutIdx(streams: MediaStream[], decisions: StreamDecision[]): Map<number, string> {
const mappedKept = streams
.filter((s) => ['Video', 'Audio'].includes(s.type))
.filter((s) => ["Video", "Audio"].includes(s.type))
.filter((s) => {
const action = decisions.find((d) => d.stream_id === s.id)?.action;
return action === 'keep';
return action === "keep";
})
.sort((a, b) => {
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 typeCounts: Record<string, number> = {};
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;
m.set(s.id, `${prefix}:${idx}`);
typeCounts[s.type] = idx + 1;
@@ -68,14 +70,19 @@ function computeOutIdx(streams: MediaStream[], decisions: StreamDecision[]): Map
return m;
}
interface StreamTableProps { data: DetailData; onUpdate: (d: DetailData) => void; }
interface StreamTableProps {
data: DetailData;
onUpdate: (d: DetailData) => void;
}
function StreamTable({ data, onUpdate }: StreamTableProps) {
const { item, streams, plan, decisions } = data;
const outIdx = computeOutIdx(streams, decisions);
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 toggleStream = async (streamId: number, currentAction: "keep" | "remove") => {
const d = await api.patch<DetailData>(`/api/review/${item.id}/stream/${streamId}`, {
action: currentAction === "keep" ? "remove" : "keep",
});
onUpdate(d);
};
@@ -85,11 +92,17 @@ function StreamTable({ data, onUpdate }: StreamTableProps) {
};
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">
<table className="w-full border-collapse text-[0.79rem] mt-1">
<thead>
<tr>
{['Out', 'Codec', 'Language', 'Title / Info', 'Flags', 'Action'].map((h) => (
<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
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>
))}
</tr>
</thead>
@@ -99,44 +112,42 @@ function StreamTable({ data, onUpdate }: StreamTableProps) {
if (group.length === 0) return [];
return [
<tr key={`hdr-${type}`}>
<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">
<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"
>
{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 action = dec?.action ?? "keep";
const isAudio = s.type === "Audio";
const outputNum = outIdx.get(s.id);
const lbl = effectiveLabel(s, dec);
const origTitle = s.title;
const lang = langName(s.language);
const isEditable = plan?.status === 'pending' && isAudio;
const rowBg = action === 'keep' ? 'bg-green-50' : 'bg-red-50';
const isEditable = plan?.status === "pending" && isAudio;
const rowBg = action === "keep" ? "bg-green-50" : "bg-red-50";
return (
<tr key={s.id} className={rowBg}>
<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>}
</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">
{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>
<td className="py-1.5 px-2 border-b border-gray-100">
{isEditable ? (
<TitleInput
value={lbl}
onCommit={(v) => updateTitle(s.id, v)}
/>
) : (
<span>{lbl || '—'}</span>
)}
{isEditable ? <TitleInput value={lbl} onCommit={(v) => updateTitle(s.id, v)} /> : <span>{lbl || "—"}</span>}
{isEditable && origTitle && origTitle !== lbl && (
<div className="text-gray-400 text-[0.7rem] mt-0.5">orig: {origTitle}</div>
)}
@@ -149,16 +160,16 @@ function StreamTable({ data, onUpdate }: StreamTableProps) {
</span>
</td>
<td className="py-1.5 px-2 border-b border-gray-100">
{plan?.status === 'pending' && isAudio ? (
{plan?.status === "pending" && isAudio ? (
<button
type="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'}`}
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"}`}
>
{action === 'keep' ? '✓ Keep' : '✗ Remove'}
{action === "keep" ? "✓ Keep" : "✗ Remove"}
</button>
) : (
<Badge variant={action === 'keep' ? 'keep' : 'remove'}>{action}</Badge>
<Badge variant={action === "keep" ? "keep" : "remove"}>{action}</Badge>
)}
</td>
</tr>
@@ -167,20 +178,27 @@ function StreamTable({ data, onUpdate }: StreamTableProps) {
];
})}
</tbody>
</table></div>
</table>
</div>
);
}
function TitleInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) {
const [localVal, setLocalVal] = useState(value);
useEffect(() => { setLocalVal(value); }, [value]);
useEffect(() => {
setLocalVal(value);
}, [value]);
return (
<input
type="text"
value={localVal}
onChange={(e) => setLocalVal(e.target.value)}
onBlur={(e) => { if (e.target.value !== value) onCommit(e.target.value); }}
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
onBlur={(e) => {
if (e.target.value !== value) onCommit(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
}}
placeholder="—"
className="border border-transparent bg-transparent w-full text-[inherit] font-[inherit] text-inherit px-[0.2em] py-[0.05em] rounded outline-none hover:border-gray-300 hover:bg-gray-50 focus:border-blue-300 focus:bg-white min-w-16"
/>
@@ -190,40 +208,67 @@ function TitleInput({ value, onCommit }: { value: string; onCommit: (v: string)
// ─── Detail page ──────────────────────────────────────────────────────────────
export function AudioDetailPage() {
const { id } = useParams({ from: '/review/audio/$id' });
const { id } = useParams({ from: "/review/audio/$id" });
const [data, setData] = useState<DetailData | null>(null);
const [loading, setLoading] = useState(true);
const [rescanning, setRescanning] = useState(false);
const load = () => api.get<DetailData>(`/api/review/${id}`).then((d) => { setData(d); setLoading(false); }).catch(() => setLoading(false));
useEffect(() => { load(); }, [id]);
const load = () =>
api
.get<DetailData>(`/api/review/${id}`)
.then((d) => {
setData(d);
setLoading(false);
})
.catch(() => setLoading(false));
useEffect(() => {
load();
}, [load]);
const setLanguage = async (lang: string) => {
const d = await api.patch<DetailData>(`/api/review/${id}/language`, { language: lang || null });
setData(d);
};
const approve = async () => { await api.post(`/api/review/${id}/approve`); load(); };
const unapprove = async () => { await api.post(`/api/review/${id}/unapprove`); load(); };
const skip = async () => { await api.post(`/api/review/${id}/skip`); load(); };
const unskip = async () => { await api.post(`/api/review/${id}/unskip`); load(); };
const approve = async () => {
await api.post(`/api/review/${id}/approve`);
load();
};
const unapprove = async () => {
await api.post(`/api/review/${id}/unapprove`);
load();
};
const skip = async () => {
await api.post(`/api/review/${id}/skip`);
load();
};
const unskip = async () => {
await api.post(`/api/review/${id}/unskip`);
load();
};
const rescan = async () => {
setRescanning(true);
try { const d = await api.post<DetailData>(`/api/review/${id}/rescan`); setData(d); }
finally { setRescanning(false); }
try {
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 (!data) return <Alert variant="error">Item not found.</Alert>;
const { item, plan, command } = data;
const statusKey = plan?.is_noop ? 'noop' : (plan?.status ?? 'pending');
const statusKey = plan?.is_noop ? "noop" : (plan?.status ?? "pending");
return (
<div>
<div className="flex items-center gap-2 mb-4">
<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}
</h1>
</div>
@@ -232,12 +277,17 @@ export function AudioDetailPage() {
{/* Meta */}
<dl className="flex flex-wrap gap-5 mb-3 text-[0.82rem]">
{[
{ label: 'Type', value: item.type },
...(item.series_name ? [{ label: 'Series', value: item.series_name }] : []),
...(item.year ? [{ label: 'Year', value: String(item.year) }] : []),
{ label: 'Container', value: item.container ?? '—' },
{ label: 'File size', value: item.file_size ? formatBytes(item.file_size) : '—' },
{ label: 'Status', value: <Badge variant={statusKey as 'noop' | 'pending' | 'approved' | 'skipped' | 'done' | 'error'}>{statusKey}</Badge> },
{ label: "Type", value: item.type },
...(item.series_name ? [{ label: "Series", value: item.series_name }] : []),
...(item.year ? [{ label: "Year", value: String(item.year) }] : []),
{ label: "Container", value: item.container ?? "—" },
{ label: "File size", value: item.file_size ? formatBytes(item.file_size) : "—" },
{
label: "Status",
value: (
<Badge variant={statusKey as "noop" | "pending" | "approved" | "skipped" | "done" | "error"}>{statusKey}</Badge>
),
},
].map((entry, i) => (
<div key={i}>
<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>
{/* 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 && (
<Alert variant="warning" className="mb-3">
Original language unknown audio tracks will NOT be filtered until you set it below.
@@ -259,10 +313,16 @@ export function AudioDetailPage() {
{/* Language override */}
<div className="flex items-center gap-2 mb-4">
<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>
{Object.entries(LANG_NAMES).map(([code, name]) => (
<option key={code} value={code}>{name} ({code})</option>
<option key={code} value={code}>
{name} ({code})
</option>
))}
</Select>
{item.orig_lang_source && <Badge>{item.orig_lang_source}</Badge>}
@@ -285,33 +345,43 @@ export function AudioDetailPage() {
)}
{/* Actions */}
{plan?.status === 'pending' && !plan.is_noop && (
{plan?.status === "pending" && !plan.is_noop && (
<div className="flex gap-2 mt-6">
<Button onClick={approve}> Approve</Button>
<Button variant="secondary" onClick={skip}>Skip</Button>
<Button variant="secondary" onClick={skip}>
Skip
</Button>
</div>
)}
{plan?.status === 'approved' && (
{plan?.status === "approved" && (
<div className="mt-6">
<Button variant="secondary" onClick={unapprove}>Unapprove</Button>
<Button variant="secondary" onClick={unapprove}>
Unapprove
</Button>
</div>
)}
{plan?.status === 'skipped' && (
{plan?.status === "skipped" && (
<div className="mt-6">
<Button variant="secondary" onClick={unskip}>Unskip</Button>
<Button variant="secondary" onClick={unskip}>
Unskip
</Button>
</div>
)}
{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}
{/* Refresh */}
<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}>
{rescanning ? '↻ Refreshing…' : '↻ Refresh from Jellyfin'}
{rescanning ? "↻ Refreshing…" : "↻ Refresh from Jellyfin"}
</Button>
<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>
</div>
</div>

View File

@@ -1,21 +1,34 @@
import { useState, useEffect } from 'react';
import { Link, useNavigate, useSearch } from '@tanstack/react-router';
import { api } from '~/shared/lib/api';
import { Badge } from '~/shared/components/ui/badge';
import { Button } from '~/shared/components/ui/button';
import { FilterTabs } from '~/shared/components/ui/filter-tabs';
import { langName } from '~/shared/lib/lang';
import type { MediaItem, ReviewPlan } from '~/shared/lib/types';
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { Badge } from "~/shared/components/ui/badge";
import { Button } from "~/shared/components/ui/button";
import { FilterTabs } from "~/shared/components/ui/filter-tabs";
import { api } from "~/shared/lib/api";
import { langName } from "~/shared/lib/lang";
import type { MediaItem, ReviewPlan } from "~/shared/lib/types";
// ─── Types ────────────────────────────────────────────────────────────────────
interface MovieRow { item: MediaItem; plan: ReviewPlan | null; removeCount: number; keepCount: number; }
interface MovieRow {
item: MediaItem;
plan: ReviewPlan | null;
removeCount: number;
keepCount: number;
}
interface SeriesGroup {
series_key: string; series_name: string; original_language: string | null;
season_count: number; episode_count: number;
noop_count: number; needs_action_count: number; approved_count: number;
skipped_count: number; done_count: number; error_count: number; manual_count: number;
series_key: string;
series_name: string;
original_language: string | null;
season_count: number;
episode_count: number;
noop_count: number;
needs_action_count: number;
approved_count: number;
skipped_count: number;
done_count: number;
error_count: number;
manual_count: number;
}
interface ReviewListData {
@@ -28,10 +41,14 @@ interface ReviewListData {
// ─── Filter tabs ──────────────────────────────────────────────────────────────
const FILTER_TABS = [
{ key: 'all', label: 'All' }, { key: 'needs_action', label: 'Needs Action' },
{ key: 'noop', label: 'No Change' }, { key: 'manual', label: 'Manual Review' },
{ key: 'approved', label: 'Approved' }, { key: 'skipped', label: 'Skipped' },
{ key: 'done', label: 'Done' }, { key: 'error', label: 'Error' },
{ key: "all", label: "All" },
{ key: "needs_action", label: "Needs Action" },
{ key: "noop", label: "No Change" },
{ key: "manual", label: "Manual Review" },
{ key: "approved", label: "Approved" },
{ key: "skipped", label: "Skipped" },
{ key: "done", label: "Done" },
{ key: "error", label: "Error" },
];
// ─── Status pills ─────────────────────────────────────────────────────────────
@@ -39,13 +56,41 @@ const FILTER_TABS = [
function StatusPills({ g }: { g: SeriesGroup }) {
return (
<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.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>}
{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>}
{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.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>
)}
{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>
);
}
@@ -59,7 +104,7 @@ const Th = ({ children }: { children: React.ReactNode }) => (
);
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) ─────────────────────────────────────────────────
@@ -68,8 +113,19 @@ function SeriesRow({ g }: { g: SeriesGroup }) {
const [open, setOpen] = useState(false);
const urlKey = encodeURIComponent(g.series_key);
interface EpisodeItem { item: MediaItem; plan: ReviewPlan | null; removeCount: number; }
interface SeasonGroup { season: number | null; episodes: EpisodeItem[]; noopCount: number; actionCount: number; approvedCount: number; doneCount: number; }
interface EpisodeItem {
item: MediaItem;
plan: ReviewPlan | null;
removeCount: number;
}
interface SeasonGroup {
season: number | null;
episodes: EpisodeItem[];
noopCount: number;
actionCount: number;
approvedCount: number;
doneCount: number;
}
const [seasons, setSeasons] = useState<SeasonGroup[] | null>(null);
@@ -93,25 +149,30 @@ function SeriesRow({ g }: { g: SeriesGroup }) {
window.location.reload();
};
const statusKey = (plan: ReviewPlan | null) => plan?.is_noop ? 'noop' : (plan?.status ?? 'pending');
const statusKey = (plan: ReviewPlan | null) => (plan?.is_noop ? "noop" : (plan?.status ?? "pending"));
return (
<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">
<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>
{' '}<strong>{g.series_name}</strong>
<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>{" "}
<strong>{g.series_name}</strong>
</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.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()}>
{g.needs_action_count > 0 && (
<Button size="xs" onClick={approveAll}>Approve all</Button>
<Button size="xs" onClick={approveAll}>
Approve all
</Button>
)}
</td>
</tr>
@@ -123,13 +184,32 @@ function SeriesRow({ g }: { g: SeriesGroup }) {
{seasons.map((s) => (
<>
<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">
Season {s.season ?? '?'}
<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"
>
Season {s.season ?? "?"}
<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.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>}
{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.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>
{s.actionCount > 0 && (
<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 }) => (
<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">
<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>
</td>
<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 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 className="py-1 px-2 border-b border-gray-100 whitespace-nowrap flex gap-1 items-center">
{plan?.status === 'pending' && !plan.is_noop && (
<ApproveBtn itemId={item.id} size="xs" />
)}
{plan?.status === 'pending' && (
<SkipBtn itemId={item.id} size="xs" />
)}
{plan?.status === 'skipped' && (
<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">
{plan?.status === "pending" && !plan.is_noop && <ApproveBtn itemId={item.id} size="xs" />}
{plan?.status === "pending" && <SkipBtn itemId={item.id} size="xs" />}
{plan?.status === "skipped" && <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
</Link>
</td>
@@ -180,19 +265,40 @@ function SeriesRow({ g }: { g: SeriesGroup }) {
// ─── Action buttons ───────────────────────────────────────────────────────────
function ApproveBtn({ itemId, size }: { itemId: number; size?: 'xs' | 'sm' }) {
const onClick = async () => { await api.post(`/api/review/${itemId}/approve`); window.location.reload(); };
return <Button size={size ?? 'xs'} onClick={onClick}>Approve</Button>;
function ApproveBtn({ itemId, size }: { itemId: number; size?: "xs" | "sm" }) {
const onClick = async () => {
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' }) {
const onClick = async () => { await api.post(`/api/review/${itemId}/skip`); window.location.reload(); };
return <Button size={size ?? 'xs'} variant="secondary" onClick={onClick}>Skip</Button>;
function SkipBtn({ itemId, size }: { itemId: number; size?: "xs" | "sm" }) {
const onClick = async () => {
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' }) {
const onClick = async () => { await api.post(`/api/review/${itemId}/unskip`); window.location.reload(); };
return <Button size={size ?? 'xs'} variant="secondary" onClick={onClick}>Unskip</Button>;
function UnskipBtn({ itemId, size }: { itemId: number; size?: "xs" | "sm" }) {
const onClick = async () => {
await api.post(`/api/review/${itemId}/unskip`);
window.location.reload();
};
return (
<Button size={size ?? "xs"} variant="secondary" onClick={onClick}>
Unskip
</Button>
);
}
// ─── Cache ────────────────────────────────────────────────────────────────────
@@ -202,22 +308,31 @@ const cache = new Map<string, ReviewListData>();
// ─── Main page ────────────────────────────────────────────────────────────────
export function AudioListPage() {
const { filter } = useSearch({ from: '/review/audio/' });
const { filter } = useSearch({ from: "/review/audio/" });
const navigate = useNavigate();
const [data, setData] = useState<ReviewListData | null>(cache.get(filter) ?? null);
const [loading, setLoading] = useState(!cache.has(filter));
useEffect(() => {
const cached = cache.get(filter);
if (cached) { setData(cached); setLoading(false); }
else { setLoading(true); }
api.get<ReviewListData>(`/api/review?filter=${filter}`)
.then((d) => { cache.set(filter, d); setData(d); setLoading(false); })
if (cached) {
setData(cached);
setLoading(false);
} else {
setLoading(true);
}
api
.get<ReviewListData>(`/api/review?filter=${filter}`)
.then((d) => {
cache.set(filter, d);
setData(d);
setLoading(false);
})
.catch(() => setLoading(false));
}, [filter]);
const approveAll = async () => {
await api.post('/api/review/approve-all');
await api.post("/api/review/approve-all");
cache.clear();
window.location.reload();
};
@@ -227,7 +342,7 @@ export function AudioListPage() {
const { movies, series, totalCounts } = data;
const hasPending = (totalCounts.needs_action ?? 0) > 0;
const statusKey = (plan: ReviewPlan | null) => plan?.is_noop ? 'noop' : (plan?.status ?? 'pending');
const statusKey = (plan: ReviewPlan | null) => (plan?.is_noop ? "noop" : (plan?.status ?? "pending"));
return (
<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">
{hasPending ? (
<>
<span className="text-sm font-medium">{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">
{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>
@@ -248,12 +368,10 @@ export function AudioListPage() {
tabs={FILTER_TABS}
filter={filter}
totalCounts={totalCounts}
onFilterChange={(key) => navigate({ to: '/review/audio', search: { filter: key } as never })}
onFilterChange={(key) => navigate({ to: "/review/audio", search: { filter: key } as never })}
/>
{movies.length === 0 && series.length === 0 && (
<p className="text-gray-500">No items match this filter.</p>
)}
{movies.length === 0 && series.length === 0 && <p className="text-gray-500">No items match this filter.</p>}
{/* Movies */}
{movies.length > 0 && (
@@ -263,26 +381,48 @@ export function AudioListPage() {
</div>
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0">
<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>
<tr>
<Th>Name</Th>
<Th>Lang</Th>
<Th>Remove</Th>
<Th>Status</Th>
<Th>Actions</Th>
</tr>
</thead>
<tbody>
{movies.map(({ item, plan, removeCount }) => (
<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>
<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>}
{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>{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">
{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>
@@ -297,13 +437,26 @@ export function AudioListPage() {
{/* TV Series */}
{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>
</div>
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0">
<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>
{series.map((g) => <SeriesRow key={g.series_key} g={g} />)}
<thead>
<tr>
<Th>Series</Th>
<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>
</>
@@ -312,4 +465,4 @@ export function AudioListPage() {
);
}
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 { api } from '~/shared/lib/api';
import { Button } from '~/shared/components/ui/button';
import { Badge } from '~/shared/components/ui/badge';
import { Link } from "@tanstack/react-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { Badge } from "~/shared/components/ui/badge";
import { Button } from "~/shared/components/ui/button";
import { api } from "~/shared/lib/api";
interface ScanStatus { running: boolean; progress: { scanned: number; total: number; errors: number }; recentItems: { name: string; type: string; scan_status: string; file_path: string }[]; scanLimit: number | null; }
interface LogEntry { name: string; type: string; status: string; file?: string; }
interface ScanStatus {
running: boolean;
progress: { scanned: number; total: number; errors: number };
recentItems: { name: string; type: string; scan_status: string; file_path: string }[];
scanLimit: number | null;
}
interface LogEntry {
name: string;
type: string;
status: string;
file?: string;
}
// Mutable buffer for SSE data — flushed to React state on an interval
interface SseBuf {
@@ -20,18 +30,18 @@ interface SseBuf {
}
function freshBuf(): SseBuf {
return { scanned: 0, total: 0, errors: 0, currentItem: '', newLogs: [], dirty: false, complete: null, lost: false };
return { scanned: 0, total: 0, errors: 0, currentItem: "", newLogs: [], dirty: false, complete: null, lost: false };
}
const FLUSH_MS = 200;
export function ScanPage() {
const [status, setStatus] = useState<ScanStatus | null>(null);
const [limit, setLimit] = useState('');
const [limit, setLimit] = useState("");
const [log, setLog] = useState<LogEntry[]>([]);
const [statusLabel, setStatusLabel] = useState('');
const [statusLabel, setStatusLabel] = useState("");
const [scanComplete, setScanComplete] = useState(false);
const [currentItem, setCurrentItem] = useState('');
const [currentItem, setCurrentItem] = useState("");
const [progressScanned, setProgressScanned] = useState(0);
const [progressTotal, setProgressTotal] = useState(0);
const [errors, setErrors] = useState(0);
@@ -59,19 +69,19 @@ export function ScanPage() {
if (b.complete) {
const d = b.complete;
b.complete = null;
setStatusLabel(`Scan complete — ${d.scanned ?? '?'} items, ${d.errors ?? 0} errors`);
setStatusLabel(`Scan complete — ${d.scanned ?? "?"} items, ${d.errors ?? 0} errors`);
setScanComplete(true);
setStatus((prev) => prev ? { ...prev, running: false } : prev);
setStatus((prev) => (prev ? { ...prev, running: false } : prev));
stopFlushing();
}
if (b.lost) {
b.lost = false;
setStatusLabel('Scan connection lost — refresh to see current status');
setStatus((prev) => prev ? { ...prev, running: false } : prev);
setStatusLabel("Scan connection lost — refresh to see current status");
setStatus((prev) => (prev ? { ...prev, running: false } : prev));
stopFlushing();
}
}, []);
}, [stopFlushing]);
const startFlushing = useCallback(() => {
if (timerRef.current) return;
@@ -86,50 +96,57 @@ export function ScanPage() {
}, [flush]);
// Cleanup timer on unmount
useEffect(() => () => { if (timerRef.current) clearInterval(timerRef.current); }, []);
useEffect(
() => () => {
if (timerRef.current) clearInterval(timerRef.current);
},
[],
);
const load = async () => {
const s = await api.get<ScanStatus>('/api/scan');
const s = await api.get<ScanStatus>("/api/scan");
setStatus(s);
setProgressScanned(s.progress.scanned);
setProgressTotal(s.progress.total);
setErrors(s.progress.errors);
setStatusLabel(s.running ? 'Scan in progress…' : 'Scan idle');
setStatusLabel(s.running ? "Scan in progress…" : "Scan idle");
if (s.scanLimit != null) setLimit(String(s.scanLimit));
setLog(s.recentItems.map((i) => ({ name: i.name, type: i.type, status: i.scan_status, file: i.file_path })));
};
useEffect(() => { load(); }, []);
useEffect(() => {
load();
}, [load]);
const connectSse = useCallback(() => {
esRef.current?.close();
const buf = bufRef.current;
const es = new EventSource('/api/scan/events');
const es = new EventSource("/api/scan/events");
esRef.current = es;
es.addEventListener('progress', (e) => {
es.addEventListener("progress", (e) => {
const d = JSON.parse(e.data) as { scanned: number; total: number; errors: number; current_item: string };
buf.scanned = d.scanned;
buf.total = d.total;
buf.errors = d.errors;
buf.currentItem = d.current_item ?? '';
buf.currentItem = d.current_item ?? "";
buf.dirty = true;
});
es.addEventListener('log', (e) => {
es.addEventListener("log", (e) => {
const d = JSON.parse(e.data) as LogEntry;
buf.newLogs.push(d);
buf.dirty = true;
});
es.addEventListener('complete', (e) => {
const d = JSON.parse(e.data || '{}') as { scanned?: number; errors?: number };
es.addEventListener("complete", (e) => {
const d = JSON.parse(e.data || "{}") as { scanned?: number; errors?: number };
es.close();
esRef.current = null;
buf.complete = d;
});
es.addEventListener('error', () => {
es.addEventListener("error", () => {
es.close();
esRef.current = null;
buf.lost = true;
@@ -143,7 +160,11 @@ export function ScanPage() {
useEffect(() => {
if (!status?.running || esRef.current) return;
connectSse();
return () => { esRef.current?.close(); esRef.current = null; stopFlushing(); };
return () => {
esRef.current?.close();
esRef.current = null;
stopFlushing();
};
}, [status?.running, connectSse, stopFlushing]);
const startScan = async () => {
@@ -151,26 +172,26 @@ export function ScanPage() {
setProgressScanned(0);
setProgressTotal(0);
setErrors(0);
setCurrentItem('');
setStatusLabel('Scan in progress…');
setCurrentItem("");
setStatusLabel("Scan in progress…");
setScanComplete(false);
setStatus((prev) => prev ? { ...prev, running: true } : prev);
setStatus((prev) => (prev ? { ...prev, running: true } : prev));
bufRef.current = freshBuf();
// Connect SSE before starting the scan so no events are missed
connectSse();
const limitNum = limit ? Number(limit) : undefined;
await api.post('/api/scan/start', limitNum !== undefined ? { limit: limitNum } : {});
await api.post("/api/scan/start", limitNum !== undefined ? { limit: limitNum } : {});
};
const stopScan = async () => {
await api.post('/api/scan/stop', {});
await api.post("/api/scan/stop", {});
esRef.current?.close();
esRef.current = null;
stopFlushing();
setStatus((prev) => prev ? { ...prev, running: false } : prev);
setStatusLabel('Scan stopped');
setStatus((prev) => (prev ? { ...prev, running: false } : prev));
setStatusLabel("Scan stopped");
};
const pct = progressTotal > 0 ? Math.round((progressScanned / progressTotal) * 100) : 0;
@@ -182,14 +203,16 @@ export function ScanPage() {
<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">
<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 && (
<Link to="/pipeline" className="text-blue-600 hover:underline text-sm">
Review in Pipeline
</Link>
)}
{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">
<label className="flex items-center gap-1.5 text-xs m-0">
@@ -204,7 +227,9 @@ export function ScanPage() {
/>
items
</label>
<Button size="sm" onClick={startScan}>Start Scan</Button>
<Button size="sm" onClick={startScan}>
Start Scan
</Button>
</div>
)}
{errors > 0 && <Badge variant="error">{errors} error(s)</Badge>}
@@ -218,7 +243,10 @@ export function ScanPage() {
</div>
)}
<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>}
</div>
</>
@@ -230,20 +258,27 @@ export function ScanPage() {
<table className="w-full border-collapse text-[0.82rem]">
<thead>
<tr>
{['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>
{["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>
))}
</tr>
</thead>
<tbody>
{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 (
<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" 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">
<Badge variant={item.status as 'error' | 'done' | 'pending'}>{item.status}</Badge>
<Badge variant={item.status as "error" | "done" | "pending"}>{item.status}</Badge>
</td>
</tr>
);

View File

@@ -1,11 +1,14 @@
import { useEffect, useState } from 'react';
import { api } from '~/shared/lib/api';
import { Button } from '~/shared/components/ui/button';
import { Input } from '~/shared/components/ui/input';
import { Select } from '~/shared/components/ui/select';
import { LANG_NAMES } from '~/shared/lib/lang';
import { useEffect, useState } from "react";
import { Button } from "~/shared/components/ui/button";
import { Input } from "~/shared/components/ui/input";
import { Select } from "~/shared/components/ui/select";
import { api } from "~/shared/lib/api";
import { LANG_NAMES } from "~/shared/lib/lang";
interface SetupData { config: Record<string, string>; envLocked: string[]; }
interface SetupData {
config: Record<string, string>;
envLocked: string[];
}
let setupCache: SetupData | null = null;
@@ -16,7 +19,7 @@ const LANGUAGE_OPTIONS = Object.entries(LANG_NAMES).map(([code, label]) => ({ co
function LockedInput({ locked, ...props }: { locked: boolean } & React.InputHTMLAttributes<HTMLInputElement>) {
return (
<div className="relative">
<Input {...props} disabled={locked || props.disabled} className={locked ? 'pr-9' : ''} />
<Input {...props} disabled={locked || props.disabled} className={locked ? "pr-9" : ""} />
{locked && (
<span
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 (
<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"
title={locked
title={
locked
? `Set via environment variable ${envVar} — edit your .env file to change`
: `Can be set via environment variable ${envVar}`}
: `Can be set via environment variable ${envVar}`
}
>
{locked ? '🔒' : '🔓'} <span className="font-mono">{envVar}</span>
{locked ? "🔒" : "🔓"} <span className="font-mono">{envVar}</span>
</span>
);
}
// ─── 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 (
<div className="border border-gray-200 rounded-lg p-4 mb-4">
<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 ─────────────────────────────────────────────────────
function SortableLanguageList({
langs, onChange, disabled,
langs,
onChange,
disabled,
}: {
langs: string[]; onChange: (langs: string[]) => void; disabled: boolean;
langs: string[];
onChange: (langs: string[]) => void;
disabled: boolean;
}) {
const available = LANGUAGE_OPTIONS.filter((o) => !langs.includes(o.code));
@@ -88,21 +105,32 @@ function SortableLanguageList({
return (
<div key={code} className="flex items-center gap-1.5 text-sm">
<button
type="button" disabled={disabled || i === 0}
type="button"
disabled={disabled || i === 0}
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"
></button>
>
</button>
<button
type="button" disabled={disabled || i === langs.length - 1}
type="button"
disabled={disabled || i === langs.length - 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"
></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
type="button" disabled={disabled}
type="button"
disabled={disabled}
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"
></button>
>
</button>
</div>
);
})}
@@ -111,12 +139,17 @@ function SortableLanguageList({
{!disabled && available.length > 0 && (
<Select
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]"
>
<option value="">+ Add language</option>
{available.map(({ code, label }) => (
<option key={code} value={code}>{label} ({code})</option>
<option key={code} value={code}>
{label} ({code})
</option>
))}
</Select>
)}
@@ -127,20 +160,38 @@ function SortableLanguageList({
// ─── Connection section ────────────────────────────────────────────────────────
function ConnSection({
title, subtitle, cfg, locked, urlKey, apiKey: apiKeyProp, urlPlaceholder, onSave,
title,
subtitle,
cfg,
locked,
urlKey,
apiKey: apiKeyProp,
urlPlaceholder,
onSave,
}: {
title: React.ReactNode; subtitle?: React.ReactNode; cfg: Record<string, string>; locked: Set<string>;
urlKey: string; apiKey: string; urlPlaceholder: string; onSave: (url: string, apiKey: string) => Promise<void>;
title: React.ReactNode;
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 [key, setKey] = useState(cfg[apiKeyProp] ?? '');
const [url, setUrl] = useState(cfg[urlKey] ?? "");
const [key, setKey] = useState(cfg[apiKeyProp] ?? "");
const [status, setStatus] = useState<{ ok: boolean; error?: string } | null>(null);
const [saving, setSaving] = useState(false);
const save = async () => {
setSaving(true);
setStatus(null);
try { await onSave(url, key); setStatus({ ok: true }); } catch (e) { setStatus({ ok: false, error: String(e) }); }
try {
await onSave(url, key);
setStatus({ ok: true });
} catch (e) {
setStatus({ ok: false, error: String(e) });
}
setSaving(false);
};
@@ -148,19 +199,32 @@ function ConnSection({
<SectionCard title={title} subtitle={subtitle}>
<label className="block text-sm text-gray-700 mb-1">
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 className="block text-sm text-gray-700 mb-1 mt-3">
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>
<div className="flex items-center gap-2 mt-3">
<Button onClick={save} disabled={saving || (locked.has(urlKey) && locked.has(apiKeyProp))}>
{saving ? 'Saving…' : 'Test & Save'}
{saving ? "Saving…" : "Test & Save"}
</Button>
{status && (
<span className={`text-sm ${status.ok ? 'text-green-700' : 'text-red-600'}`}>
{status.ok ? '✓ Saved' : `${status.error ?? 'Connection failed'}`}
<span className={`text-sm ${status.ok ? "text-green-700" : "text-red-600"}`}>
{status.ok ? "✓ Saved" : `${status.error ?? "Connection failed"}`}
</span>
)}
</div>
@@ -173,54 +237,61 @@ function ConnSection({
export function SetupPage() {
const [data, setData] = useState<SetupData | null>(setupCache);
const [loading, setLoading] = useState(setupCache === null);
const [clearStatus, setClearStatus] = useState('');
const [clearStatus, setClearStatus] = useState("");
const [subLangs, setSubLangs] = useState<string[]>([]);
const [subSaved, setSubSaved] = useState('');
const [subSaved, setSubSaved] = useState("");
const [audLangs, setAudLangs] = useState<string[]>([]);
const [audSaved, setAudSaved] = useState('');
const [audSaved, setAudSaved] = useState("");
const [langsLoaded, setLangsLoaded] = useState(false);
const load = () => {
if (!setupCache) setLoading(true);
api.get<SetupData>('/api/setup').then((d) => {
api
.get<SetupData>("/api/setup")
.then((d) => {
setupCache = d;
setData(d);
if (!langsLoaded) {
setSubLangs(JSON.parse(d.config.subtitle_languages ?? '["eng","deu","spa"]'));
setAudLangs(JSON.parse(d.config.audio_languages ?? '[]'));
setAudLangs(JSON.parse(d.config.audio_languages ?? "[]"));
setLangsLoaded(true);
}
}).finally(() => setLoading(false));
})
.finally(() => setLoading(false));
};
useEffect(() => { load(); }, []);
useEffect(() => {
load();
}, [load]);
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>;
const { config: cfg, envLocked: envLockedArr } = data;
const locked = new Set(envLockedArr);
const saveJellyfin = (url: string, apiKey: string) =>
api.post('/api/setup/jellyfin', { url, api_key: apiKey });
const saveRadarr = (url: string, apiKey: string) =>
api.post('/api/setup/radarr', { url, api_key: apiKey });
const saveSonarr = (url: string, apiKey: string) =>
api.post('/api/setup/sonarr', { url, api_key: apiKey });
const saveJellyfin = (url: string, apiKey: string) => api.post("/api/setup/jellyfin", { url, api_key: apiKey });
const saveRadarr = (url: string, apiKey: string) => api.post("/api/setup/radarr", { url, api_key: apiKey });
const saveSonarr = (url: string, apiKey: string) => api.post("/api/setup/sonarr", { url, api_key: apiKey });
const saveSubtitleLangs = async () => {
await api.post('/api/setup/subtitle-languages', { langs: subLangs });
setSubSaved('Saved.');
setTimeout(() => setSubSaved(''), 2000);
await api.post("/api/setup/subtitle-languages", { langs: subLangs });
setSubSaved("Saved.");
setTimeout(() => setSubSaved(""), 2000);
};
const saveAudioLangs = async () => {
await api.post('/api/setup/audio-languages', { langs: audLangs });
setAudSaved('Saved.');
setTimeout(() => setAudSaved(''), 2000);
await api.post("/api/setup/audio-languages", { langs: audLangs });
setAudSaved("Saved.");
setTimeout(() => setAudSaved(""), 2000);
};
const clearScan = async () => {
if (!confirm('Delete all scanned data? This will remove all media items, stream decisions, review plans, and jobs. This cannot be undone.')) return;
await api.post('/api/setup/clear-scan');
setClearStatus('Cleared.');
if (
!confirm(
"Delete all scanned data? This will remove all media items, stream decisions, review plans, and jobs. This cannot be undone.",
)
)
return;
await api.post("/api/setup/clear-scan");
setClearStatus("Cleared.");
};
return (
@@ -231,27 +302,53 @@ export function SetupPage() {
{/* Jellyfin */}
<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>}
urlKey="jellyfin_url" apiKey="jellyfin_api_key"
urlPlaceholder="http://192.168.1.100:8096" cfg={cfg} locked={locked}
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>
}
urlKey="jellyfin_url"
apiKey="jellyfin_api_key"
urlPlaceholder="http://192.168.1.100:8096"
cfg={cfg}
locked={locked}
onSave={saveJellyfin}
/>
{/* Radarr */}
<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."
urlKey="radarr_url" apiKey="radarr_api_key"
urlPlaceholder="http://192.168.1.100:7878" cfg={cfg} locked={locked}
urlKey="radarr_url"
apiKey="radarr_api_key"
urlPlaceholder="http://192.168.1.100:7878"
cfg={cfg}
locked={locked}
onSave={saveRadarr}
/>
{/* Sonarr */}
<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."
urlKey="sonarr_url" apiKey="sonarr_api_key"
urlPlaceholder="http://192.168.1.100:8989" cfg={cfg} locked={locked}
urlKey="sonarr_url"
apiKey="sonarr_api_key"
urlPlaceholder="http://192.168.1.100:8989"
cfg={cfg}
locked={locked}
onSave={saveSonarr}
/>
@@ -260,14 +357,16 @@ export function SetupPage() {
title={
<span className="flex items-center gap-2">
Audio Languages
<EnvBadge envVar="AUDIO_LANGUAGES" locked={locked.has('audio_languages')} />
<EnvBadge envVar="AUDIO_LANGUAGES" locked={locked.has("audio_languages")} />
</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."
>
<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">
<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>}
</div>
</SectionCard>
@@ -277,14 +376,16 @@ export function SetupPage() {
title={
<span className="flex items-center gap-2">
Subtitle Languages
<EnvBadge envVar="SUBTITLE_LANGUAGES" locked={locked.has('subtitle_languages')} />
<EnvBadge envVar="SUBTITLE_LANGUAGES" locked={locked.has("subtitle_languages")} />
</span>
}
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">
<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>}
</div>
</SectionCard>
@@ -292,9 +393,13 @@ export function SetupPage() {
{/* Danger zone */}
<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>
<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">
<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>
</div>
{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 { api } from '~/shared/lib/api';
import { Badge } from '~/shared/components/ui/badge';
import { Button } from '~/shared/components/ui/button';
import { Alert } from '~/shared/components/ui/alert';
import { Select } from '~/shared/components/ui/select';
import { langName, LANG_NAMES } from '~/shared/lib/lang';
import type { MediaItem, MediaStream, SubtitleFile, ReviewPlan, StreamDecision } from '~/shared/lib/types';
import { Link, useParams } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { Alert } from "~/shared/components/ui/alert";
import { Badge } from "~/shared/components/ui/badge";
import { Button } from "~/shared/components/ui/button";
import { Select } from "~/shared/components/ui/select";
import { api } from "~/shared/lib/api";
import { LANG_NAMES, langName } from "~/shared/lib/lang";
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision, SubtitleFile } from "~/shared/lib/types";
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -30,12 +30,12 @@ function formatBytes(bytes: number): string {
}
function fileName(filePath: string): string {
return filePath.split('/').pop() ?? filePath;
return filePath.split("/").pop() ?? filePath;
}
function effectiveTitle(s: MediaStream, dec: StreamDecision | undefined): string {
if (dec?.custom_title) return dec.custom_title;
if (!s.language) return '';
if (!s.language) return "";
const base = langName(s.language);
if (s.is_forced) return `${base} (Forced)`;
if (s.is_hearing_impaired) return `${base} (CC)`;
@@ -46,14 +46,20 @@ function effectiveTitle(s: MediaStream, dec: StreamDecision | undefined): string
function TitleInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) {
const [localVal, setLocalVal] = useState(value);
useEffect(() => { setLocalVal(value); }, [value]);
useEffect(() => {
setLocalVal(value);
}, [value]);
return (
<input
type="text"
value={localVal}
onChange={(e) => setLocalVal(e.target.value)}
onBlur={(e) => { if (e.target.value !== value) onCommit(e.target.value); }}
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
onBlur={(e) => {
if (e.target.value !== value) onCommit(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
}}
placeholder="—"
className="border border-transparent bg-transparent w-full text-[inherit] font-[inherit] text-inherit px-[0.2em] py-[0.05em] rounded outline-none hover:border-gray-300 hover:bg-gray-50 focus:border-blue-300 focus:bg-white min-w-16"
/>
@@ -74,11 +80,17 @@ 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>;
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">
<table className="w-full border-collapse text-[0.79rem] mt-1">
<thead>
<tr>
{['#', 'Codec', 'Language', 'Title / Info', 'Flags', 'Action'].map((h) => (
<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
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>
))}
</tr>
</thead>
@@ -91,33 +103,33 @@ function StreamTable({ streams, decisions, editable, onLanguageChange, onTitleCh
return (
<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.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">
{editable ? (
<Select
value={s.language ?? ''}
value={s.language ?? ""}
onChange={(e) => onLanguageChange(s.id, e.target.value)}
className="text-[0.79rem] py-0.5 px-1.5 w-auto"
>
<option value=""> Unknown </option>
{Object.entries(LANG_NAMES).map(([code, name]) => (
<option key={code} value={code}>{name} ({code})</option>
<option key={code} value={code}>
{name} ({code})
</option>
))}
</Select>
) : (
<>
{langName(s.language)} {s.language ? <span className="text-gray-400 font-mono text-xs">({s.language})</span> : null}
{langName(s.language)}{" "}
{s.language ? <span className="text-gray-400 font-mono text-xs">({s.language})</span> : null}
</>
)}
</td>
<td className="py-1.5 px-2 border-b border-gray-100">
{editable ? (
<TitleInput
value={title}
onCommit={(v) => onTitleChange(s.id, v)}
/>
<TitleInput value={title} onCommit={(v) => onTitleChange(s.id, v)} />
) : (
<span>{title || '—'}</span>
<span>{title || "—"}</span>
)}
{editable && origTitle && origTitle !== title && (
<div className="text-gray-400 text-[0.7rem] mt-0.5">orig: {origTitle}</div>
@@ -139,7 +151,8 @@ function StreamTable({ streams, decisions, editable, onLanguageChange, onTitleCh
);
})}
</tbody>
</table></div>
</table>
</div>
);
}
@@ -149,24 +162,34 @@ function ExtractedFilesTable({ files, onDelete }: { files: SubtitleFile[]; onDel
if (files.length === 0) return <p className="text-gray-500 text-sm">No extracted files yet.</p>;
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">
<table className="w-full border-collapse text-[0.82rem]">
<thead>
<tr>
{['File', 'Language', 'Codec', 'Flags', 'Size', ''].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>
{["File", "Language", "Codec", "Flags", "Size", ""].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>
))}
</tr>
</thead>
<tbody>
{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}>
<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}
{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 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}
@@ -174,29 +197,41 @@ function ExtractedFilesTable({ files, onDelete }: { files: SubtitleFile[]; onDel
</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) : '—'}
{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>
<Button variant="danger" size="xs" onClick={() => onDelete(f.id)}>
Delete
</Button>
</td>
</tr>
))}
</tbody>
</table></div>
</table>
</div>
);
}
// ─── Detail page ──────────────────────────────────────────────────────────────
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 [loading, setLoading] = useState(true);
const [extracting, setExtracting] = useState(false);
const [rescanning, setRescanning] = useState(false);
const load = () => api.get<DetailData>(`/api/subtitles/${id}`).then((d) => { setData(d); setLoading(false); }).catch(() => setLoading(false));
useEffect(() => { load(); }, [id]);
const load = () =>
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 d = await api.patch<DetailData>(`/api/subtitles/${id}/stream/${streamId}/language`, { language: lang || null });
@@ -213,7 +248,9 @@ export function SubtitleDetailPage() {
try {
await api.post(`/api/subtitles/${id}/extract`);
load();
} finally { setExtracting(false); }
} finally {
setExtracting(false);
}
};
const deleteFile = async (fileId: number) => {
@@ -223,8 +260,12 @@ export function SubtitleDetailPage() {
const rescan = async () => {
setRescanning(true);
try { const d = await api.post<DetailData>(`/api/subtitles/${id}/rescan`); setData(d); }
finally { setRescanning(false); }
try {
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>;
@@ -238,7 +279,9 @@ export function SubtitleDetailPage() {
<div>
<div className="flex items-center gap-2 mb-4">
<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}
</h1>
</div>
@@ -247,12 +290,15 @@ export function SubtitleDetailPage() {
{/* Meta */}
<dl className="flex flex-wrap gap-5 mb-3 text-[0.82rem]">
{[
{ label: 'Type', value: item.type },
...(item.series_name ? [{ label: 'Series', value: item.series_name }] : []),
...(item.year ? [{ label: 'Year', value: String(item.year) }] : []),
{ label: 'Container', value: item.container ?? '—' },
{ label: 'File size', value: item.file_size ? formatBytes(item.file_size) : '—' },
{ label: 'Status', value: <Badge variant={subs_extracted ? 'done' : 'pending'}>{subs_extracted ? 'extracted' : 'pending'}</Badge> },
{ label: "Type", value: item.type },
...(item.series_name ? [{ label: "Series", value: item.series_name }] : []),
...(item.year ? [{ label: "Year", value: String(item.year) }] : []),
{ label: "Container", value: item.container ?? "—" },
{ label: "File size", value: item.file_size ? formatBytes(item.file_size) : "—" },
{
label: "Status",
value: <Badge variant={subs_extracted ? "done" : "pending"}>{subs_extracted ? "extracted" : "pending"}</Badge>,
},
].map((entry, i) => (
<div key={i}>
<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}
/>
) : (
<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 */}
@@ -301,22 +349,26 @@ export function SubtitleDetailPage() {
{hasContainerSubs && !subs_extracted && (
<div className="flex gap-2 mt-6">
<Button onClick={extract} disabled={extracting}>
{extracting ? 'Queuing…' : '✓ Extract All'}
{extracting ? "Queuing…" : "✓ Extract All"}
</Button>
</div>
)}
{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}
{/* Refresh */}
<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}>
{rescanning ? '↻ Refreshing…' : '↻ Refresh from Jellyfin'}
{rescanning ? "↻ Refreshing…" : "↻ Refresh from Jellyfin"}
</Button>
<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>
</div>
</div>

View File

@@ -1,25 +1,37 @@
import { useState, useEffect } from 'react';
import { Link, useNavigate, useSearch } from '@tanstack/react-router';
import { api } from '~/shared/lib/api';
import { Badge } from '~/shared/components/ui/badge';
import { Button } from '~/shared/components/ui/button';
import { FilterTabs } from '~/shared/components/ui/filter-tabs';
import { langName } from '~/shared/lib/lang';
import type React from 'react';
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import type React from "react";
import { useEffect, useState } from "react";
import { Badge } from "~/shared/components/ui/badge";
import { Button } from "~/shared/components/ui/button";
import { FilterTabs } from "~/shared/components/ui/filter-tabs";
import { api } from "~/shared/lib/api";
import { langName } from "~/shared/lib/lang";
// ─── Types ────────────────────────────────────────────────────────────────────
interface SubListItem {
id: number; name: string; type: string; series_name: string | null;
season_number: number | null; episode_number: number | null;
year: number | null; original_language: string | null;
subs_extracted: number | null; sub_count: number; file_count: number;
id: number;
name: string;
type: string;
series_name: string | null;
season_number: number | null;
episode_number: number | null;
year: number | null;
original_language: string | null;
subs_extracted: number | null;
sub_count: number;
file_count: number;
}
interface SubSeriesGroup {
series_key: string; series_name: string; original_language: string | null;
season_count: number; episode_count: number;
not_extracted_count: number; extracted_count: number; no_subs_count: number;
series_key: string;
series_name: string;
original_language: string | null;
season_count: number;
episode_count: number;
not_extracted_count: number;
extracted_count: number;
no_subs_count: number;
}
interface SubListData {
@@ -32,14 +44,16 @@ interface SubListData {
interface SeasonGroup {
season: number | null;
episodes: SubListItem[];
extractedCount: number; notExtractedCount: number; noSubsCount: number;
extractedCount: number;
notExtractedCount: number;
noSubsCount: number;
}
const FILTER_TABS = [
{ key: 'all', label: 'All' },
{ key: 'not_extracted', label: 'Not Extracted' },
{ key: 'extracted', label: 'Extracted' },
{ key: 'no_subs', label: 'No Subtitles' },
{ key: "all", label: "All" },
{ key: "not_extracted", label: "Not Extracted" },
{ key: "extracted", label: "Extracted" },
{ key: "no_subs", label: "No Subtitles" },
];
// ─── Table helpers ────────────────────────────────────────────────────────────
@@ -51,27 +65,39 @@ const Th = ({ children }: { children?: React.ReactNode }) => (
);
const Td = ({ children, className }: { children?: React.ReactNode; className?: string }) => (
<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' {
if (item.sub_count === 0) return 'no_subs';
return item.subs_extracted ? 'extracted' : 'not_extracted';
function subStatus(item: SubListItem): "extracted" | "not_extracted" | "no_subs" {
if (item.sub_count === 0) return "no_subs";
return item.subs_extracted ? "extracted" : "not_extracted";
}
function StatusBadge({ item }: { item: SubListItem }) {
const s = subStatus(item);
if (s === 'extracted') return <Badge variant="keep">extracted</Badge>;
if (s === 'not_extracted') return <Badge variant="pending">pending</Badge>;
if (s === "extracted") return <Badge variant="keep">extracted</Badge>;
if (s === "not_extracted") return <Badge variant="pending">pending</Badge>;
return <Badge variant="noop">no subs</Badge>;
}
function StatusPills({ g }: { g: SubSeriesGroup }) {
return (
<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.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>}
{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.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>
);
}
@@ -80,16 +106,18 @@ function StatusPills({ g }: { g: SubSeriesGroup }) {
function ActionBox({ count, onExtract }: { count: number | null; onExtract: () => void }) {
const [extracting, setExtracting] = useState(false);
const [result, setResult] = useState('');
const [result, setResult] = useState("");
const handleExtract = async () => {
setExtracting(true);
setResult('');
setResult("");
try {
const r = await api.post<{ ok: boolean; queued: number }>('/api/subtitles/extract-all');
setResult(`Queued ${r.queued} extraction job${r.queued !== 1 ? 's' : ''}.`);
const r = await api.post<{ ok: boolean; queued: number }>("/api/subtitles/extract-all");
setResult(`Queued ${r.queued} extraction job${r.queued !== 1 ? "s" : ""}.`);
onExtract();
} catch (e) { setResult(`Error: ${e}`); }
} catch (e) {
setResult(`Error: ${e}`);
}
setExtracting(false);
};
@@ -101,9 +129,11 @@ function ActionBox({ count, onExtract }: { count: number | null; onExtract: () =
{allDone && <span className="text-sm font-medium">All subtitles extracted</span>}
{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}>
{extracting ? 'Queuing...' : 'Extract All'}
{extracting ? "Queuing..." : "Extract All"}
</Button>
</>
)}
@@ -131,13 +161,19 @@ function SeriesRow({ g }: { g: SubSeriesGroup }) {
<tbody>
<tr className="cursor-pointer hover:bg-gray-50" onClick={toggle}>
<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>
{' '}<strong>{g.series_name}</strong>
<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>{" "}
<strong>{g.series_name}</strong>
</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.episode_count}</td>
<Td><StatusPills g={g} /></Td>
<Td>
<StatusPills g={g} />
</Td>
</tr>
{open && seasons && (
<tr>
@@ -147,21 +183,41 @@ function SeriesRow({ g }: { g: SubSeriesGroup }) {
{seasons.map((s) => (
<>
<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">
Season {s.season ?? '?'}
<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"
>
Season {s.season ?? "?"}
<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.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>}
{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.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>
</td>
</tr>
{s.episodes.map((item) => (
<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">
<span className="text-gray-400 font-mono text-xs">E{String(item.episode_number ?? 0).padStart(2, '0')}</span>
{' '}
<Link to="/review/subtitles/$id" params={{ id: String(item.id) }} className="no-underline text-blue-600 hover:text-blue-800">
<span className="text-gray-400 font-mono text-xs">
E{String(item.episode_number ?? 0).padStart(2, "0")}
</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>
</Link>
</td>
@@ -171,7 +227,11 @@ function SeriesRow({ g }: { g: SubSeriesGroup }) {
<StatusBadge item={item} />
</td>
<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
</Link>
</td>
@@ -195,7 +255,7 @@ const cache = new Map<string, SubListData>();
// ─── Main page ────────────────────────────────────────────────────────────────
export function SubtitleExtractPage() {
const { filter } = useSearch({ from: '/review/subtitles/extract' });
const { filter } = useSearch({ from: "/review/subtitles/extract" });
const navigate = useNavigate();
const [data, setData] = useState<SubListData | null>(cache.get(filter) ?? null);
const [loading, setLoading] = useState(!cache.has(filter));
@@ -203,21 +263,33 @@ export function SubtitleExtractPage() {
const load = () => {
if (!cache.has(filter)) setLoading(true);
api.get<SubListData>(`/api/subtitles?filter=${filter}`)
.then((d) => { cache.set(filter, d); setData(d); })
api
.get<SubListData>(`/api/subtitles?filter=${filter}`)
.then((d) => {
cache.set(filter, d);
setData(d);
})
.catch(() => {})
.finally(() => setLoading(false));
};
const loadEmbedded = () => {
api.get<{ embeddedCount: number }>('/api/subtitles/summary')
api
.get<{ embeddedCount: number }>("/api/subtitles/summary")
.then((d) => setEmbeddedCount(d.embeddedCount))
.catch(() => {});
};
useEffect(() => { load(); loadEmbedded(); }, [filter]);
useEffect(() => {
load();
loadEmbedded();
}, [load, loadEmbedded]);
const refresh = () => { cache.clear(); load(); loadEmbedded(); };
const refresh = () => {
cache.clear();
load();
loadEmbedded();
};
return (
<div>
@@ -229,7 +301,7 @@ export function SubtitleExtractPage() {
tabs={FILTER_TABS}
filter={filter}
totalCounts={data?.totalCounts ?? {}}
onFilterChange={(key) => navigate({ to: '/review/subtitles/extract', search: { filter: key } as never })}
onFilterChange={(key) => navigate({ to: "/review/subtitles/extract", search: { filter: key } as never })}
/>
{loading && !data && <div className="text-gray-400 py-4 text-center text-sm">Loading...</div>}
@@ -247,20 +319,36 @@ export function SubtitleExtractPage() {
</div>
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0">
<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>
{data.movies.map((item) => (
<tr key={item.id} className="hover:bg-gray-50">
<Td>
<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-[200px] sm:max-w-[360px]" title={item.name}>{item.name}</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-[200px] sm:max-w-[360px]" title={item.name}>
{item.name}
</span>
</Link>
{item.year && <span className="text-gray-400 text-[0.72rem]"> ({item.year})</span>}
</Td>
<Td>{langName(item.original_language)}</Td>
<Td className="font-mono text-xs">{item.sub_count}</Td>
<Td className="font-mono text-xs">{item.file_count}</Td>
<Td><StatusBadge item={item} /></Td>
<Td>
<StatusBadge item={item} />
</Td>
</tr>
))}
</tbody>
@@ -271,13 +359,25 @@ export function SubtitleExtractPage() {
{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>
</div>
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0">
<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>
{data.series.map((g) => <SeriesRow key={g.series_key} g={g} />)}
<thead>
<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>
</div>
</>

View File

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

View File

@@ -1,6 +1,10 @@
@import "tailwindcss";
@layer base {
* { box-sizing: border-box; }
body { font-family: system-ui, -apple-system, sans-serif; }
* {
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, sans-serif;
}
}

View File

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

View File

@@ -1,7 +1,7 @@
import { createRootRoute, Link, Outlet } from '@tanstack/react-router';
import { useEffect, useState } from 'react';
import { cn } from '~/shared/lib/utils';
import { api } from '~/shared/lib/api';
import { createRootRoute, Link, Outlet } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { api } from "~/shared/lib/api";
import { cn } from "~/shared/lib/utils";
declare const __APP_VERSION__: string;
@@ -13,8 +13,10 @@ function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
return (
<Link
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')}
activeProps={{ className: 'bg-gray-100 text-gray-900 font-medium' }}
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",
)}
activeProps={{ className: "bg-gray-100 text-gray-900 font-medium" }}
activeOptions={{ exact: true }}
>
{children}
@@ -24,13 +26,25 @@ function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
function VersionBadge() {
const [serverVersion, setServerVersion] = useState<string | null>(null);
useEffect(() => { api.get<{ version: string }>('/api/version').then((d) => setServerVersion(d.version)).catch(() => {}); }, []);
const buildVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : null;
useEffect(() => {
api
.get<{ version: string }>("/api/version")
.then((d) => setServerVersion(d.version))
.catch(() => {});
}, []);
const buildVersion = typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : null;
const mismatch = buildVersion && serverVersion && buildVersion !== serverVersion;
return (
<span className="text-[0.65rem] text-gray-400 font-mono ml-1" 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
className="text-[0.65rem] text-gray-400 font-mono ml-1"
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>
);
}
@@ -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 { z } from 'zod';
import { ExecutePage } from '~/features/execute/ExecutePage';
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
import { ExecutePage } from "~/features/execute/ExecutePage";
export const Route = createFileRoute('/execute')({
export const Route = createFileRoute("/execute")({
validateSearch: z.object({
filter: z.enum(['all', 'pending', 'running', 'done', 'error']).default('pending'),
filter: z.enum(["all", "pending", "running", "done", "error"]).default("pending"),
}),
component: ExecutePage,
});

View File

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

View File

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

View File

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

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 />,
});

View File

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

View File

@@ -1,10 +1,10 @@
import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod';
import { AudioListPage } from '~/features/review/AudioListPage';
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
import { AudioListPage } from "~/features/review/AudioListPage";
export const Route = createFileRoute('/review/audio/')({
export const Route = createFileRoute("/review/audio/")({
validateSearch: z.object({
filter: z.enum(['all', 'needs_action', 'noop', 'manual', 'approved', 'skipped', 'done', 'error']).default('all'),
filter: z.enum(["all", "needs_action", "noop", "manual", "approved", "skipped", "done", "error"]).default("all"),
}),
component: AudioListPage,
});

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/')({
beforeLoad: () => { throw redirect({ to: '/review/audio' }); },
export const Route = createFileRoute("/review/")({
beforeLoad: () => {
throw redirect({ to: "/review/audio" });
},
});

View File

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

View File

@@ -1,10 +1,10 @@
import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod';
import { SubtitleExtractPage } from '~/features/subtitles/SubtitleExtractPage';
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
import { SubtitleExtractPage } from "~/features/subtitles/SubtitleExtractPage";
export const Route = createFileRoute('/review/subtitles/extract')({
export const Route = createFileRoute("/review/subtitles/extract")({
validateSearch: z.object({
filter: z.enum(['all', 'not_extracted', 'extracted', 'no_subs']).default('not_extracted'),
filter: z.enum(["all", "not_extracted", "extracted", "no_subs"]).default("not_extracted"),
}),
component: SubtitleExtractPage,
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,28 +1,28 @@
import { cn } from '~/shared/lib/utils';
import { cn } from "~/shared/lib/utils";
const variants = {
default: 'bg-gray-100 text-gray-600',
keep: 'bg-green-100 text-green-800',
remove: 'bg-red-100 text-red-800',
pending: 'bg-gray-200 text-gray-600',
approved: 'bg-green-100 text-green-800',
skipped: 'bg-gray-200 text-gray-600',
done: 'bg-cyan-100 text-cyan-800',
error: 'bg-red-100 text-red-800',
noop: 'bg-gray-200 text-gray-600',
running: 'bg-amber-100 text-amber-800',
manual: 'bg-orange-100 text-orange-800',
default: "bg-gray-100 text-gray-600",
keep: "bg-green-100 text-green-800",
remove: "bg-red-100 text-red-800",
pending: "bg-gray-200 text-gray-600",
approved: "bg-green-100 text-green-800",
skipped: "bg-gray-200 text-gray-600",
done: "bg-cyan-100 text-cyan-800",
error: "bg-red-100 text-red-800",
noop: "bg-gray-200 text-gray-600",
running: "bg-amber-100 text-amber-800",
manual: "bg-orange-100 text-orange-800",
} as const;
interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
variant?: keyof typeof variants;
}
export function Badge({ variant = 'default', className, children, ...props }: BadgeProps) {
export function Badge({ variant = "default", className, children, ...props }: BadgeProps) {
return (
<span
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],
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 { cn } from '~/shared/lib/utils';
import type React from "react";
import { cn } from "~/shared/lib/utils";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'default' | 'sm' | 'xs';
variant?: "primary" | "secondary" | "danger";
size?: "default" | "sm" | "xs";
}
export function Button({ variant = 'primary', size = 'default', className, ...props }: ButtonProps) {
export function Button({ variant = "primary", size = "default", className, ...props }: ButtonProps) {
return (
<button
className={cn(
'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 === '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',
size === 'default' && 'px-3 py-1.5 text-sm',
size === 'sm' && 'px-2.5 py-1 text-xs',
size === 'xs' && 'px-2 py-0.5 text-xs',
props.disabled && 'opacity-50 cursor-not-allowed',
"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 === "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",
size === "default" && "px-3 py-1.5 text-sm",
size === "sm" && "px-2.5 py-1 text-xs",
size === "xs" && "px-2 py-0.5 text-xs",
props.disabled && "opacity-50 cursor-not-allowed",
className,
)}
{...props}

View File

@@ -11,20 +11,20 @@ interface FilterTabsProps {
}
const ACTIVE_COLORS: Record<string, string> = {
all: 'bg-blue-600 border-blue-600',
pending: 'bg-gray-500 border-gray-500',
needs_action: 'bg-gray-500 border-gray-500',
noop: 'bg-gray-500 border-gray-500',
not_extracted: 'bg-gray-500 border-gray-500',
no_subs: 'bg-gray-500 border-gray-500',
skipped: 'bg-gray-500 border-gray-500',
running: 'bg-amber-500 border-amber-500',
done: 'bg-green-600 border-green-600',
approved: 'bg-green-600 border-green-600',
extracted: 'bg-green-600 border-green-600',
keep: 'bg-green-600 border-green-600',
error: 'bg-red-600 border-red-600',
manual: 'bg-orange-500 border-orange-500',
all: "bg-blue-600 border-blue-600",
pending: "bg-gray-500 border-gray-500",
needs_action: "bg-gray-500 border-gray-500",
noop: "bg-gray-500 border-gray-500",
not_extracted: "bg-gray-500 border-gray-500",
no_subs: "bg-gray-500 border-gray-500",
skipped: "bg-gray-500 border-gray-500",
running: "bg-amber-500 border-amber-500",
done: "bg-green-600 border-green-600",
approved: "bg-green-600 border-green-600",
extracted: "bg-green-600 border-green-600",
keep: "bg-green-600 border-green-600",
error: "bg-red-600 border-red-600",
manual: "bg-orange-500 border-orange-500",
};
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">
{tabs.map((tab) => {
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 (
<button
key={tab.key}
type="button"
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}
{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>
);
})}

View File

@@ -1,13 +1,13 @@
import type React from 'react';
import { cn } from '~/shared/lib/utils';
import type React from "react";
import { cn } from "~/shared/lib/utils";
export function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
return (
<input
className={cn(
'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',
'disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed',
"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",
"disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed",
className,
)}
{...props}

View File

@@ -1,13 +1,13 @@
import type React from 'react';
import { cn } from '~/shared/lib/utils';
import type React from "react";
import { cn } from "~/shared/lib/utils";
export function Select({ className, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) {
return (
<select
className={cn(
'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',
'disabled:bg-gray-100 disabled:cursor-not-allowed',
"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",
"disabled:bg-gray-100 disabled:cursor-not-allowed",
className,
)}
{...props}

View File

@@ -1,12 +1,12 @@
import type React from 'react';
import { cn } from '~/shared/lib/utils';
import type React from "react";
import { cn } from "~/shared/lib/utils";
export function Textarea({ className, ...props }: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
return (
<textarea
className={cn(
'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',
"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",
className,
)}
{...props}

View File

@@ -1,9 +1,9 @@
/** 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> {
const res = await fetch(BASE + path, {
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
...init,
});
if (!res.ok) {
@@ -16,11 +16,10 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
export const api = {
get: <T>(path: string) => request<T>(path),
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) =>
request<T>(path, { method: 'PATCH', body: body !== undefined ? JSON.stringify(body) : undefined }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
request<T>(path, { method: "PATCH", body: body !== undefined ? JSON.stringify(body) : undefined }),
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
/** POST multipart/form-data (file upload). Omit Content-Type so browser sets boundary. */
postForm: <T>(path: string, body: FormData) =>
request<T>(path, { method: 'POST', body, headers: {} }),
postForm: <T>(path: string, body: FormData) => request<T>(path, { method: "POST", body, headers: {} }),
};

View File

@@ -1,18 +1,53 @@
export const LANG_NAMES: Record<string, string> = {
eng: 'English', deu: 'German', spa: 'Spanish', fra: 'French', ita: 'Italian',
por: 'Portuguese', jpn: 'Japanese', kor: 'Korean', zho: 'Chinese', ara: 'Arabic',
rus: 'Russian', nld: 'Dutch', swe: 'Swedish', nor: 'Norwegian', dan: 'Danish',
fin: 'Finnish', pol: 'Polish', tur: 'Turkish', tha: 'Thai', hin: 'Hindi',
hun: 'Hungarian', ces: 'Czech', ron: 'Romanian', ell: 'Greek', heb: 'Hebrew',
fas: 'Persian', ukr: 'Ukrainian', ind: 'Indonesian', 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',
eng: "English",
deu: "German",
spa: "Spanish",
fra: "French",
ita: "Italian",
por: "Portuguese",
jpn: "Japanese",
kor: "Korean",
zho: "Chinese",
ara: "Arabic",
rus: "Russian",
nld: "Dutch",
swe: "Swedish",
nor: "Norwegian",
dan: "Danish",
fin: "Finnish",
pol: "Polish",
tur: "Turkish",
tha: "Thai",
hin: "Hindi",
hun: "Hungarian",
ces: "Czech",
ron: "Romanian",
ell: "Greek",
heb: "Hebrew",
fas: "Persian",
ukr: "Ukrainian",
ind: "Indonesian",
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 function langName(code: string | null | undefined): string {
if (!code) return '—';
if (!code) return "—";
return LANG_NAMES[code.toLowerCase()] ?? code.toUpperCase();
}

View File

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

View File

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

View File

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