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; } 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; } // ─── Helpers ───────────────────────────────────────────────────────────────── function loadDetail(db: ReturnType, itemId: number) { 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 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[]) : []; 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 { item, subtitleStreams, files, plan: plan ?? null, decisions, subs_extracted: plan?.subs_extracted ?? 0, extractCommand, }; } // ─── List ──────────────────────────────────────────────────────────────────── 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"; } } app.get("/", (c) => { const db = getDb(); const filter = c.req.query("filter") ?? "all"; const where = buildSubWhere(filter); // Movies 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, (SELECT COUNT(*) FROM media_streams ms WHERE ms.item_id = mi.id AND ms.type = 'Subtitle') as sub_count, (SELECT COUNT(*) FROM subtitle_files sf WHERE sf.item_id = mi.id) as file_count FROM media_items mi 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[]; // Series groups 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 sub_count > 0 AND COALESCE(rp.subs_extracted, 0) = 0 THEN 1 ELSE 0 END) as not_extracted_count, SUM(CASE WHEN rp.subs_extracted = 1 THEN 1 ELSE 0 END) as extracted_count, SUM(CASE WHEN sub_count = 0 THEN 1 ELSE 0 END) as no_subs_count FROM ( SELECT mi.*, (SELECT COUNT(*) FROM media_streams ms WHERE ms.item_id = mi.id AND ms.type = 'Subtitle') as sub_count FROM media_items mi WHERE mi.type = 'Episode' ) mi 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[]; 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; const totalNotExtracted = totalAll - totalExtracted - totalNoSubs; return c.json({ movies: movieRows, series, filter, totalCounts: { all: totalAll, not_extracted: totalNotExtracted, extracted: totalExtracted, no_subs: totalNoSubs }, }); }); // ─── Series episodes (subtitles) ───────────────────────────────────────────── app.get("/series/:seriesKey/episodes", (c) => { const db = getDb(); const seriesKey = decodeURIComponent(c.req.param("seriesKey")); 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, (SELECT COUNT(*) FROM media_streams ms WHERE ms.item_id = mi.id AND ms.type = 'Subtitle') as sub_count, (SELECT COUNT(*) FROM subtitle_files sf WHERE sf.item_id = mi.id) as file_count FROM media_items mi LEFT JOIN review_plans rp ON rp.item_id = mi.id 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[]; const seasonMap = new Map(); for (const r of rows) { const season = r.season_number ?? null; if (!seasonMap.has(season)) seasonMap.set(season, []); seasonMap.get(season)!.push(r); } const seasons = Array.from(seasonMap.entries()) .sort(([a], [b]) => (a ?? -1) - (b ?? -1)) .map(([season, episodes]) => ({ season, episodes, extractedCount: episodes.filter((e) => e.subs_extracted === 1).length, notExtractedCount: episodes.filter((e) => e.sub_count > 0 && !e.subs_extracted).length, noSubsCount: episodes.filter((e) => e.sub_count === 0).length, })); return c.json({ seasons }); }); // ─── 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"; } 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(` 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; // Stream counts by (language, variant) 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[]; // File counts by (language, variant) 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[]; // Merge into categories const catMap = new Map(); for (const r of streamRows) { const v = variantOf(r); const k = catKey(r.language, v); catMap.set(k, { language: r.language, variant: v, streamCount: r.cnt, fileCount: 0 }); } for (const r of fileRows) { 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 }); } } const categories = Array.from(catMap.values()).sort((a, b) => { 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(` 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 }[]; // Determine canonical title per language (most common) const canonicalByLang = new Map(); for (const r of titleRows) { if (!canonicalByLang.has(r.language)) canonicalByLang.set(r.language, r.title); } const titles = titleRows.map((r) => ({ language: r.language, title: r.title, count: r.cnt, isCanonical: canonicalByLang.get(r.language) === r.title, })); // Keep languages from config const raw = getConfig("subtitle_languages"); let keepLanguages: string[] = []; try { keepLanguages = JSON.parse(raw ?? "[]"); } catch { /* empty */ } return c.json({ embeddedCount, categories, titles, keepLanguages }); }); // ─── 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 = loadDetail(db, id); if (!detail) return c.notFound(); return c.json(detail); }); // ─── Edit stream language ──────────────────────────────────────────────────── 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 body = await c.req.json<{ language: string }>(); 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; if (!stream) return c.notFound(); const normalized = lang ? normalizeLanguage(lang) : null; db.prepare("UPDATE media_streams SET language = ? WHERE id = ?").run(normalized, streamId); const detail = loadDetail(db, itemId); if (!detail) 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 = loadDetail(db, itemId); if (!detail) return c.notFound(); return c.json(detail); }); // ─── Extract all ────────────────────────────────────────────────────────────── app.post("/extract-all", (c) => { const db = getDb(); // Find items with subtitle streams that haven't been extracted yet 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[]; 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 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); queued++; } return c.json({ ok: true, queued }); }); // ─── Extract ───────────────────────────────────────────────────────────────── 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 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 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); db .prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'subtitle', 'pending')") .run(id, command); return c.json({ ok: true }); }); // ─── Delete file ───────────────────────────────────────────────────────────── /** * Verify a sidecar file path lives inside the directory of its owning * media item. Guards against path-traversal via malformed DB state. */ function isSidecarOfItem(filePath: string, videoPath: string): boolean { const videoDir = resolvePath(dirname(videoPath)); const targetDir = resolvePath(dirname(filePath)); return targetDir === videoDir || targetDir.startsWith(videoDir + sep); } 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 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; 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); } 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[]; return c.json({ ok: true, files }); }); // ─── 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 }; await refreshItem(jfCfg, item.jellyfin_id); 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; 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, ); } } const detail = loadDetail(db, id); if (!detail) return c.notFound(); return c.json(detail); }); // ─── Batch delete subtitle files ───────────────────────────────────────────── app.post("/batch-delete", async (c) => { const db = getDb(); 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; let files: SubtitleFile[]; if (cat.language === null) { files = db .prepare(` SELECT * FROM subtitle_files WHERE language IS NULL AND is_forced = ? AND is_hearing_impaired = ? `) .all(isForced, isHI) as SubtitleFile[]; } else { files = db .prepare(` SELECT * FROM subtitle_files WHERE language = ? AND is_forced = ? AND is_hearing_impaired = ? `) .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; if (item && isSidecarOfItem(file.file_path, item.file_path)) { 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); 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; if (remaining === 0) { db.prepare("UPDATE review_plans SET subs_extracted = 0 WHERE item_id = ?").run(itemId); } } } return c.json({ ok: true, deleted }); }); // ─── Normalize titles ──────────────────────────────────────────────────────── app.post("/normalize-titles", (c) => { const db = getDb(); // Get title groups per language 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 }[]; // Find canonical (most common) title per language const canonicalByLang = new Map(); for (const r of titleRows) { if (!canonicalByLang.has(r.language)) canonicalByLang.set(r.language, r.title); } let normalized = 0; for (const r of titleRows) { const canonical = canonicalByLang.get(r.language); if (r.title === canonical) continue; // 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(` 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 }[]; } else { 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 }[]; } 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; 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 }; } // 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); if (existing) { 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); } normalized++; } } return c.json({ ok: true, normalized }); }); export default app;