Files
netfelix-audio-fix/server/api/review.ts
Felix Förtsch 5ac44b7551 restructure to react spa + hono api, fix missing server/ and lib/
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>
2026-03-02 22:57:40 +01:00

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;