Files
netfelix-audio-fix/server/api/subtitles.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

203 lines
9.4 KiB
TypeScript

import { Hono } from 'hono';
import { getDb, getAllConfig } from '../db/index';
import { buildExtractOnlyCommand, buildDockerExtractOnlyCommand, predictExtractedFiles } from '../services/ffmpeg';
import { normalizeLanguage, getItem, refreshItem, mapStream } from '../services/jellyfin';
import type { MediaItem, MediaStream, SubtitleFile, ReviewPlan, StreamDecision } from '../types';
import { unlinkSync } from 'node:fs';
const app = new Hono();
// ─── Helpers ─────────────────────────────────────────────────────────────────
function loadDetail(db: ReturnType<typeof getDb>, 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);
const cfg = getAllConfig();
const dockerResult = buildDockerExtractOnlyCommand(item, allStreams, { moviesPath: cfg.movies_path || undefined, seriesPath: cfg.series_path || undefined });
return {
item,
subtitleStreams,
files,
plan: plan ?? null,
decisions,
subs_extracted: plan?.subs_extracted ?? 0,
extractCommand,
dockerCommand: dockerResult?.command ?? null,
dockerMountDir: dockerResult?.mountDir ?? null,
};
}
// ─── List ────────────────────────────────────────────────────────────────────
app.get('/', (c) => {
const db = getDb();
const filter = c.req.query('filter') ?? 'all';
let where = '1=1';
switch (filter) {
case 'not_extracted': where = 'rp.subs_extracted = 0 AND sub_count > 0'; break;
case 'extracted': where = 'rp.subs_extracted = 1'; break;
case 'no_subs': where = 'sub_count = 0'; break;
}
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 ${where}
ORDER BY mi.name
LIMIT 500
`).all() as (Pick<MediaItem, 'id' | 'jellyfin_id' | 'type' | 'name' | 'series_name' | 'season_number' | 'episode_number' | 'year' | 'original_language' | 'file_path'> & {
subs_extracted: number | null;
sub_count: number;
file_count: number;
})[];
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({
items: rows,
filter,
totalCounts: { all: totalAll, not_extracted: totalNotExtracted, extracted: totalExtracted, no_subs: totalNoSubs },
});
});
// ─── Detail ──────────────────────────────────────────────────────────────────
app.get('/:id', (c) => {
const db = getDb();
const detail = loadDetail(db, Number(c.req.param('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 = Number(c.req.param('id'));
const streamId = Number(c.req.param('streamId'));
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 = 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 = loadDetail(db, itemId);
if (!detail) return c.notFound();
return c.json(detail);
});
// ─── Extract ─────────────────────────────────────────────────────────────────
app.post('/:id/extract', (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 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, status) VALUES (?, ?, 'pending')").run(id, command);
return c.json({ ok: true });
});
// ─── Delete file ─────────────────────────────────────────────────────────────
app.delete('/:id/files/:fileId', (c) => {
const db = getDb();
const itemId = Number(c.req.param('id'));
const fileId = Number(c.req.param('fileId'));
const file = db.prepare('SELECT * FROM subtitle_files WHERE id = ? AND item_id = ?').get(fileId, itemId) as SubtitleFile | undefined;
if (!file) return c.notFound();
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 = 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 };
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);
});
export default app;