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,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;
file_path: string | null;
}) | undefined;
`)
.get(jobId) as
| (Job & {
mi_id: number | null;
name: string | null;
type: string | null;
series_name: string | null;
season_number: number | null;
episode_number: number | null;
file_path: string | null;
})
| undefined;
if (!row) return null;
const item = row.name ? { id: row.item_id, name: row.name, type: row.type, series_name: row.series_name, season_number: row.season_number, episode_number: row.episode_number, file_path: row.file_path } as unknown as MediaItem : null;
const item = row.name
? ({
id: row.item_id,
name: row.name,
type: row.type,
series_name: row.series_name,
season_number: row.season_number,
episode_number: row.episode_number,
file_path: row.file_path,
} as unknown as MediaItem)
: null;
return { job: row as unknown as Job, item };
}
// ─── List ─────────────────────────────────────────────────────────────────────
app.get('/', (c) => {
app.get("/", (c) => {
const db = getDb();
const filter = (c.req.query('filter') ?? 'pending') as 'all' | 'pending' | 'running' | 'done' | 'error';
const filter = (c.req.query("filter") ?? "pending") as "all" | "pending" | "running" | "done" | "error";
const validFilters = ['all', 'pending', 'running', 'done', 'error'];
const whereClause = validFilters.includes(filter) && filter !== 'all' ? `WHERE j.status = ?` : '';
const validFilters = ["all", "pending", "running", "done", "error"];
const whereClause = validFilters.includes(filter) && filter !== "all" ? `WHERE j.status = ?` : "";
const params = whereClause ? [filter] : [];
const jobRows = db.prepare(`
const jobRows = db
.prepare(`
SELECT j.*, mi.name, mi.type, mi.series_name, mi.season_number, mi.episode_number, mi.file_path
FROM jobs j
LEFT JOIN media_items mi ON mi.id = j.item_id
${whereClause}
ORDER BY j.created_at DESC
LIMIT 200
`).all(...params) as (Job & { name: string; type: string; series_name: string | null; season_number: number | null; episode_number: number | null; file_path: string })[];
`)
.all(...params) as (Job & {
name: string;
type: string;
series_name: string | null;
season_number: number | null;
episode_number: number | null;
file_path: string;
})[];
const jobs = jobRows.map((r) => ({
job: r as unknown as Job,
item: r.name ? { id: r.item_id, name: r.name, type: r.type, series_name: r.series_name, season_number: r.season_number, episode_number: r.episode_number, file_path: r.file_path } as unknown as MediaItem : null,
item: r.name
? ({
id: r.item_id,
name: r.name,
type: r.type,
series_name: r.series_name,
season_number: r.season_number,
episode_number: r.episode_number,
file_path: r.file_path,
} as unknown as MediaItem)
: null,
}));
const countRows = db.prepare('SELECT status, COUNT(*) as cnt FROM jobs GROUP BY status').all() as { status: string; cnt: number }[];
const countRows = db.prepare("SELECT status, COUNT(*) as cnt FROM jobs GROUP BY status").all() as {
status: string;
cnt: number;
}[];
const totalCounts: Record<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,15 +192,15 @@ 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)
: getAllItems(jellyfinCfg, (_fetched, jellyfinTotal) => {
total = limit != null ? Math.min(limit, jellyfinTotal) : jellyfinTotal;
});
total = limit != null ? Math.min(limit, jellyfinTotal) : jellyfinTotal;
});
for await (const jellyfinItem of itemSource) {
if (signal.aborted) break;
if (!isDev && limit != null && processed >= limit) break;
@@ -199,45 +210,67 @@ async function runScan(limit: number | null = null): Promise<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,26 +1,26 @@
import { getConfig, setConfig } from '../db';
import { getConfig, setConfig } from "../db";
export interface SchedulerState {
job_sleep_seconds: number;
schedule_enabled: boolean;
schedule_start: string; // "HH:MM"
schedule_end: string; // "HH:MM"
schedule_end: string; // "HH:MM"
}
export function getSchedulerState(): SchedulerState {
return {
job_sleep_seconds: parseInt(getConfig('job_sleep_seconds') ?? '0', 10),
schedule_enabled: getConfig('schedule_enabled') === '1',
schedule_start: getConfig('schedule_start') ?? '01:00',
schedule_end: getConfig('schedule_end') ?? '07:00',
job_sleep_seconds: parseInt(getConfig("job_sleep_seconds") ?? "0", 10),
schedule_enabled: getConfig("schedule_enabled") === "1",
schedule_start: getConfig("schedule_start") ?? "01:00",
schedule_end: getConfig("schedule_end") ?? "07:00",
};
}
export function updateSchedulerState(updates: Partial<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;