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 { Job, MediaItem, MediaStream, ReviewPlan, StreamDecision } from "../types"; const app = new Hono(); // ─── Helpers ────────────────────────────────────────────────────────────────── function getAudioLanguages(): string[] { return parseLanguageList(getConfig("audio_languages"), []); } function parseLanguageList(raw: string | null, fallback: string[]): string[] { if (!raw) return fallback; try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === "string") : fallback; } catch { return fallback; } } /** * Insert a pending audio job for the given item only if no pending job * already exists for it. Guards against duplicate jobs from rapid-fire * approve clicks, overlapping individual + bulk approvals, or any other * path that could race two POSTs for the same item. Returns true if a * job was actually inserted. */ export function enqueueAudioJob(db: ReturnType, itemId: number, command: string): boolean { const result = db .prepare(` INSERT INTO jobs (item_id, command, job_type, status) SELECT ?, ?, 'audio', 'pending' WHERE NOT EXISTS (SELECT 1 FROM jobs WHERE item_id = ? AND status = 'pending') `) .run(itemId, command, itemId); return result.changes > 0; } function countsByFilter(db: ReturnType): Record { const total = (db.prepare("SELECT COUNT(*) as n FROM review_plans").get() as { n: number }).n; const noops = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE is_noop = 1").get() as { n: number }).n; const pending = ( db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number } ).n; const approved = ( db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'approved'").get() as { n: number } ).n; const skipped = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'skipped'").get() as { n: number }) .n; const 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; 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"; } } 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; }; 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; } function loadItemDetail(db: ReturnType, itemId: number) { 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, job: 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 command = plan && !plan.is_noop ? buildCommand(item, streams, decisions) : null; const job = db .prepare( `SELECT id, item_id, command, job_type, status, output, exit_code, created_at, started_at, completed_at FROM jobs WHERE item_id = ? ORDER BY created_at DESC LIMIT 1`, ) .get(itemId) as Job | undefined; return { item, streams, plan: plan ?? null, decisions, command, job: job ?? null }; } /** * Match old custom_titles to new stream IDs after rescan. Keys by a * composite of (type, language, stream_index, title) so user overrides * survive stream-id changes when Jellyfin re-probes metadata. */ export 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 ?? ""}`; } export function reanalyze(db: ReturnType, itemId: number, preservedTitles?: Map): void { 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 audioLanguages = getAudioLanguages(); const analysis = analyzeItem( { original_language: item.original_language, needs_review: item.needs_review, container: item.container }, streams, { audioLanguages }, ); 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, ); const plan = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(itemId) as { id: number }; // Preserve existing custom_titles: prefer by stream_id (streams unchanged); // fall back to titleKey match (streams regenerated after rescan). const byStreamId = new Map( ( db.prepare("SELECT stream_id, custom_title FROM stream_decisions WHERE plan_id = ?").all(plan.id) as { stream_id: number; custom_title: string | null; }[] ).map((r) => [r.stream_id, r.custom_title]), ); 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 (?, ?, ?, ?, ?, ?)", ); for (const dec of analysis.decisions) { let customTitle = byStreamId.get(dec.stream_id) ?? null; if (!customTitle && preservedTitles) { const s = streamById.get(dec.stream_id); if (s) customTitle = preservedTitles.get(titleKey(s)) ?? null; } insertDecision.run(plan.id, dec.stream_id, dec.action, dec.target_index, customTitle, dec.transcode_codec); } } /** * After the user toggles a stream action, re-run assignTargetOrder and * recompute is_noop without wiping user-chosen actions or custom_titles. */ function recomputePlanAfterToggle(db: ReturnType, itemId: number): void { const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(itemId) as MediaItem | undefined; 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; 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 origLang = item.original_language ? normalizeLanguage(item.original_language) : null; const audioLanguages = getAudioLanguages(); // 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, })); assignTargetOrder(streams, decWithIdx, origLang, audioLanguages); 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 keptAudio = streams .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 isNoop = !anyAudioRemoved && !audioOrderChanged && !hasSubs && !needsTranscode; db.prepare("UPDATE review_plans SET is_noop = ? WHERE id = ?").run(isNoop ? 1 : 0, plan.id); } // ─── Pipeline: summary ─────────────────────────────────────────────────────── interface PipelineAudioStream { id: number; language: string | null; codec: string | null; channels: number | null; title: string | null; is_default: number; action: "keep" | "remove"; } app.get("/pipeline", (c) => { const db = getDb(); const jellyfinUrl = getConfig("jellyfin_url") ?? ""; // Cap the review column to keep the page snappy at scale; pipelines // with thousands of pending items would otherwise ship 10k+ rows on // every refresh and re-render every card. const REVIEW_LIMIT = 500; 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, mi.original_language, mi.orig_lang_source, mi.file_path FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id WHERE rp.status = 'pending' AND rp.is_noop = 0 ORDER BY CASE rp.confidence WHEN 'high' THEN 0 ELSE 1 END, COALESCE(mi.series_name, mi.name), mi.season_number, mi.episode_number LIMIT ${REVIEW_LIMIT} `) .all(); const reviewTotal = ( db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number } ).n; // Queued gets the same enrichment as review so the card can render // streams + transcode reasons read-only (with a "Back to review" button). const queued = db .prepare(` SELECT j.id, j.item_id, j.status, j.started_at, j.completed_at, mi.name, mi.series_name, mi.series_jellyfin_id, mi.jellyfin_id, mi.season_number, mi.episode_number, mi.type, mi.container, mi.original_language, mi.orig_lang_source, mi.file_path, rp.id as plan_id, rp.job_type, rp.apple_compat, rp.confidence, rp.is_noop 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 = 'pending' ORDER BY j.created_at `) .all(); 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(); const done = 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 IN ('done', 'error') ORDER BY j.completed_at DESC LIMIT 50 `) .all(); // "Done" = files already in the desired end state. Either the analyzer // says nothing to do (is_noop=1) or a job finished. Use two indexable // counts and add — the OR form (is_noop=1 OR status='done') can't use // our single-column indexes and gets slow on large libraries. const noopRow = db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE is_noop = 1").get() as { n: number }; const doneRow = db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'done' AND is_noop = 0").get() as { n: number; }; const doneCount = noopRow.n + doneRow.n; // Enrich rows that have (plan_id, item_id) with the transcode-reason // badges and pre-checked audio streams. Used for both review and queued // columns so the queued card can render read-only with the same info. type EnrichableRow = { id?: number; plan_id?: number; item_id: number } & { transcode_reasons?: string[]; audio_streams?: PipelineAudioStream[]; }; const enrichWithStreamsAndReasons = (rows: EnrichableRow[]) => { if (rows.length === 0) return; const planIdFor = (r: EnrichableRow): number => (r.plan_id ?? r.id) as number; const planIds = rows.map(planIdFor); const itemIds = rows.map((r) => r.item_id); const reasonPh = 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 (${reasonPh}) AND sd.transcode_codec IS NOT NULL `) .all(...planIds) as { plan_id: number; codec: string | null; transcode_codec: string }[]; const reasonsByPlan = new Map(); 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()}`); } const streamPh = itemIds.map(() => "?").join(","); const streamRows = db .prepare(` SELECT ms.id, ms.item_id, ms.language, ms.codec, ms.channels, ms.title, ms.is_default, sd.action FROM media_streams ms JOIN review_plans rp ON rp.item_id = ms.item_id LEFT JOIN stream_decisions sd ON sd.plan_id = rp.id AND sd.stream_id = ms.id WHERE ms.item_id IN (${streamPh}) AND ms.type = 'Audio' ORDER BY ms.item_id, ms.stream_index `) .all(...itemIds) as { id: number; item_id: number; language: string | null; codec: string | null; channels: number | null; title: string | null; is_default: number; action: "keep" | "remove" | null; }[]; const streamsByItem = new Map(); for (const r of streamRows) { if (!streamsByItem.has(r.item_id)) streamsByItem.set(r.item_id, []); streamsByItem.get(r.item_id)!.push({ id: r.id, language: r.language, codec: r.codec, channels: r.channels, title: r.title, is_default: r.is_default, action: r.action ?? "keep", }); } for (const r of rows) { r.transcode_reasons = reasonsByPlan.get(planIdFor(r)) ?? []; r.audio_streams = streamsByItem.get(r.item_id) ?? []; } }; enrichWithStreamsAndReasons(review as EnrichableRow[]); enrichWithStreamsAndReasons(queued as EnrichableRow[]); return c.json({ review, reviewTotal, queued, processing, done, doneCount, jellyfinUrl }); }); // ─── List ───────────────────────────────────────────────────────────────────── app.get("/", (c) => { const db = getDb(); const filter = c.req.query("filter") ?? "all"; const where = buildWhereClause(filter); 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, COUNT(CASE WHEN sd.action = 'keep' THEN 1 END) as keep_count FROM media_items mi LEFT JOIN review_plans rp ON rp.item_id = mi.id 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[]; 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(` 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, SUM(CASE WHEN rp.is_noop = 1 THEN 1 ELSE 0 END) as noop_count, SUM(CASE WHEN rp.status = 'pending' AND rp.is_noop = 0 THEN 1 ELSE 0 END) as needs_action_count, SUM(CASE WHEN rp.status = 'approved' THEN 1 ELSE 0 END) as approved_count, SUM(CASE WHEN rp.status = 'skipped' THEN 1 ELSE 0 END) as skipped_count, SUM(CASE WHEN rp.status = 'done' THEN 1 ELSE 0 END) as done_count, SUM(CASE WHEN rp.status = 'error' THEN 1 ELSE 0 END) as error_count, SUM(CASE WHEN mi.needs_review = 1 AND mi.original_language IS NULL THEN 1 ELSE 0 END) as manual_count FROM media_items mi 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(); const totalCounts = countsByFilter(db); return c.json({ movies, series, filter, totalCounts }); }); // ─── Series episodes ────────────────────────────────────────────────────────── app.get("/series/:seriesKey/episodes", (c) => { const db = getDb(); const seriesKey = decodeURIComponent(c.req.param("seriesKey")); 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 FROM media_items mi LEFT JOIN review_plans rp ON rp.item_id = mi.id LEFT JOIN stream_decisions sd ON sd.plan_id = rp.id 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[]; const seasonMap = new Map(); for (const r of rows) { const season = (r as unknown as { season_number: number | null }).season_number ?? null; if (!seasonMap.has(season)) seasonMap.set(season, []); seasonMap.get(season)!.push({ item: r as unknown as MediaItem, plan: rowToPlan(r), removeCount: r.remove_count }); } const seasons = Array.from(seasonMap.entries()) .sort(([a], [b]) => (a ?? -1) - (b ?? -1)) .map(([season, episodes]) => ({ 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, })); return c.json({ seasons }); }); // ─── Approve series ─────────────────────────────────────────────────────────── app.post("/series/:seriesKey/approve-all", (c) => { const db = getDb(); 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 })[]; 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) enqueueAudioJob(db, 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) => { 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(` 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 })[]; 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) enqueueAudioJob(db, plan.item_id, buildCommand(item, streams, decisions)); } return c.json({ ok: true, count: pending.length }); }); // ─── Approve all ────────────────────────────────────────────────────────────── 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 })[]; 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) enqueueAudioJob(db, plan.item_id, buildCommand(item, streams, decisions)); } return c.json({ ok: true, count: pending.length }); }); // ─── Batch approve (by item id list) ───────────────────────────────────────── // Used by the "approve up to here" affordance in the review column. The // client knows the visible order (movies + series sort-key) and passes in // the prefix of item ids it wants approved in one round-trip. Items that // aren't pending (already approved / skipped / done) are silently ignored // so the endpoint is idempotent against stale client state. app.post("/approve-batch", async (c) => { const db = getDb(); const body = await c.req.json<{ itemIds?: unknown }>().catch(() => ({ itemIds: undefined })); if ( !Array.isArray(body.itemIds) || !body.itemIds.every((v) => typeof v === "number" && Number.isInteger(v) && v > 0) ) { return c.json({ ok: false, error: "itemIds must be an array of positive integers" }, 400); } const ids = body.itemIds as number[]; if (ids.length === 0) return c.json({ ok: true, count: 0 }); const placeholders = ids.map(() => "?").join(","); 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 AND mi.id IN (${placeholders})`, ) .all(...ids) as (ReviewPlan & { item_id: number })[]; let count = 0; 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) { enqueueAudioJob(db, plan.item_id, buildCommand(item, streams, decisions)); count++; } } return c.json({ ok: true, count }); }); // ─── Auto-approve high-confidence ──────────────────────────────────────────── // Approves every pending plan whose original language came from an authoritative // source (radarr/sonarr). Anything with low confidence keeps needing a human. app.post("/auto-approve", (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 AND rp.confidence = 'high'", ) .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) enqueueAudioJob(db, plan.item_id, buildCommand(item, streams, decisions)); } return c.json({ ok: true, count: pending.length }); }); // ─── Detail ─────────────────────────────────────────────────────────────────── 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 detail = loadItemDetail(db, id); if (!detail.item) return c.notFound(); return c.json(detail); }); // ─── Override language ──────────────────────────────────────────────────────── 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 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 = ?") .run(lang ? normalizeLanguage(lang) : null, id); reanalyze(db, id); const detail = loadItemDetail(db, id); if (!detail.item) return c.notFound(); return c.json(detail); }); // ─── Edit stream title ──────────────────────────────────────────────────────── 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 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; if (!plan) return c.notFound(); 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); }); // ─── Toggle stream action ───────────────────────────────────────────────────── 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 body = await c.req.json<{ action: unknown }>().catch(() => ({ action: null })); 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; // 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 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); recomputePlanAfterToggle(db, itemId); const detail = loadItemDetail(db, itemId); if (!detail.item) return c.notFound(); return c.json(detail); }); // ─── Approve ────────────────────────────────────────────────────────────────── 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; 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) enqueueAudioJob(db, id, buildCommand(item, streams, decisions)); } return c.json({ ok: true }); }); // ─── Unapprove ─────────────────────────────────────────────────────────────── // ─── 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; if (!plan) return c.notFound(); 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); enqueueAudioJob(db, id, command); db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id); return c.json({ ok: true }); }); // Reopen a completed or errored plan: flip it back to pending so the user // can adjust decisions and re-approve. Used by the Done column's hover // "Back to review" affordance. Unlike /unapprove (which rolls back an // approved-but-not-yet-running plan), this handles the post-job states // and drops the lingering job row so the pipeline doesn't show leftover // history for an item that's about to be re-queued. app.post("/:id/reopen", (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 !== "done" && plan.status !== "error") { return c.json({ ok: false, error: "Can only reopen plans with status done or error" }, 409); } db.transaction(() => { // Leave plan.notes alone so the user keeps any ffmpeg error summary // from the prior run — useful context when redeciding decisions. db.prepare("UPDATE review_plans SET status = 'pending', reviewed_at = NULL WHERE id = ?").run(plan.id); db.prepare("DELETE FROM jobs WHERE item_id = ? AND status IN ('done', 'error')").run(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); // Delete the pending job and revert plan status 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("/skip-all", (c) => { const db = getDb(); const result = db .prepare( "UPDATE review_plans SET status = 'skipped', reviewed_at = datetime('now') WHERE status = 'pending' AND is_noop = 0", ) .run(); return c.json({ ok: true, skipped: result.changes }); }); 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); 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) => { 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); return c.json({ ok: true }); }); // ─── Rescan ─────────────────────────────────────────────────────────────────── 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; if (!item) return c.notFound(); const cfg = getAllConfig(); const jfCfg = { url: cfg.jellyfin_url, apiKey: cfg.jellyfin_api_key, userId: cfg.jellyfin_user_id }; // Trigger Jellyfin's internal metadata probe and wait for it to finish // so the streams we fetch afterwards reflect the current file on disk. await refreshItem(jfCfg, item.jellyfin_id); // Snapshot custom_titles keyed by stable properties, since replacing // media_streams cascades away all stream_decisions. const preservedTitles = new Map(); 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; }[]; for (const r of oldRows) { preservedTitles.set(titleKey(r), r.custom_title); } const fresh = await getItem(jfCfg, item.jellyfin_id); if (fresh) { const insertStream = db.prepare(` INSERT INTO media_streams (item_id, stream_index, type, codec, language, language_display, 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); 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, ); } } reanalyze(db, id, preservedTitles); const detail = loadItemDetail(db, id); if (!detail.item) return c.notFound(); return c.json(detail); }); // ─── Pipeline: series language ─────────────────────────────────────────────── 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 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 = ?") .run(normalizedLang, item.id); } // Re-analyze all episodes for (const item of items) { reanalyze(db, item.id); } return c.json({ updated: items.length }); }); export default app;