import { Hono } from 'hono'; import { getDb, getConfig, getAllConfig } from '../db/index'; import { analyzeItem } from '../services/analyzer'; import { buildCommand } from '../services/ffmpeg'; import { normalizeLanguage, getItem, refreshItem, mapStream } 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"]'); } 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 }; 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; return { item, streams, plan: plan ?? null, decisions, command }; } function reanalyze(db: ReturnType, itemId: number): 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 subtitleLanguages = getSubtitleLanguages(); const analysis = analyzeItem({ original_language: item.original_language, needs_review: item.needs_review }, streams, { subtitleLanguages }); db.prepare(` INSERT INTO review_plans (item_id, status, is_noop, notes) VALUES (?, 'pending', ?, ?) ON CONFLICT(item_id) DO UPDATE SET status = 'pending', is_noop = excluded.is_noop, notes = excluded.notes `).run(itemId, analysis.is_noop ? 1 : 0, analysis.notes); const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number }; const existingTitles = 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]) ); db.prepare('DELETE FROM stream_decisions WHERE plan_id = ?').run(plan.id); for (const dec of analysis.decisions) { db.prepare('INSERT INTO stream_decisions (plan_id, stream_id, action, target_index, custom_title) VALUES (?, ?, ?, ?, ?)') .run(plan.id, dec.stream_id, dec.action, dec.target_index, existingTitles.get(dec.stream_id) ?? null); } } // ─── 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) db.prepare("INSERT INTO jobs (item_id, command, status) VALUES (?, ?, '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) => { const db = getDb(); const seriesKey = decodeURIComponent(c.req.param('seriesKey')); const season = Number(c.req.param('season')); 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) db.prepare("INSERT INTO jobs (item_id, command, status) VALUES (?, ?, 'pending')").run(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) db.prepare("INSERT INTO jobs (item_id, command, status) VALUES (?, ?, 'pending')").run(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 = Number(c.req.param('id')); 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 = Number(c.req.param('id')); 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 = Number(c.req.param('id')); const streamId = Number(c.req.param('streamId')); 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 = Number(c.req.param('id')); const streamId = Number(c.req.param('streamId')); const body = await c.req.json<{ action: 'keep' | 'remove' }>(); const action = body.action; // Only audio streams can be toggled — subtitles are always removed (extracted to sidecar) const stream = db.prepare('SELECT type FROM media_streams WHERE id = ?').get(streamId) as { type: string } | undefined; 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); // is_noop only considers audio streams (subtitle removal is implicit) const audioNotKept = (db.prepare(` SELECT COUNT(*) as n FROM stream_decisions sd JOIN media_streams ms ON ms.id = sd.stream_id WHERE sd.plan_id = ? AND ms.type = 'Audio' AND sd.action != 'keep' `).get(plan.id) as { n: number }).n; // Also check audio ordering const isNoop = audioNotKept === 0; // simplified — full recheck would need analyzer db.prepare('UPDATE review_plans SET is_noop = ? WHERE id = ?').run(isNoop ? 1 : 0, plan.id); 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 = Number(c.req.param('id')); 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, status) VALUES (?, ?, 'pending')").run(id, buildCommand(item, streams, decisions)); } return c.json({ ok: true }); }); // ─── Unapprove ─────────────────────────────────────────────────────────────── app.post('/:id/unapprove', (c) => { const db = getDb(); const id = Number(c.req.param('id')); 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('/:id/skip', (c) => { const db = getDb(); const id = Number(c.req.param('id')); 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 = Number(c.req.param('id')); 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 = Number(c.req.param('id')); 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); 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); const detail = loadItemDetail(db, id); if (!detail.item) return c.notFound(); return c.json(detail); }); export default app;