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:
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 }, []>(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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(" · ");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user