rewrite from monolithic hono jsx to react 19 spa with tanstack router + hono json api backend. add scan, review, execute, nodes, and setup pages. multi-stage dockerfile (node for vite build, bun for runtime). previously, server/ and src/shared/lib/ were silently excluded by global gitignore patterns (/server/ from emacs, lib/ from python). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
373 lines
20 KiB
TypeScript
373 lines
20 KiB
TypeScript
import { Hono } from 'hono';
|
|
import { getDb, getConfig, getAllConfig } from '../db/index';
|
|
import { analyzeItem } from '../services/analyzer';
|
|
import { buildCommand, buildDockerCommand } 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<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 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<typeof getDb>, 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, dockerCommand: null, dockerMountDir: 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 cfg = getAllConfig();
|
|
let dockerCommand: string | null = null;
|
|
let dockerMountDir: string | null = null;
|
|
if (plan && !plan.is_noop) {
|
|
const result = buildDockerCommand(item, streams, decisions, { moviesPath: cfg.movies_path || undefined, seriesPath: cfg.series_path || undefined });
|
|
dockerCommand = result.command;
|
|
dockerMountDir = result.mountDir;
|
|
}
|
|
|
|
return { item, streams, plan: plan ?? null, decisions, command, dockerCommand, dockerMountDir };
|
|
}
|
|
|
|
function reanalyze(db: ReturnType<typeof getDb>, 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<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('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<number | null, unknown[]>();
|
|
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 });
|
|
});
|
|
|
|
// ─── 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;
|