Files
netfelix-audio-fix/server/api/subtitles.ts
Felix Förtsch 76d3b1acfb
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m50s
remove path mappings, add subtitle summary endpoint, cache setup page, bump version
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:02:26 +01:00

489 lines
21 KiB
TypeScript

import { Hono } from 'hono';
import { getDb, getConfig, getAllConfig } from '../db/index';
import { buildExtractOnlyCommand } 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();
// ─── 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<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);
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<number | null, SubListItem[]>();
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 });
});
// ─── 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 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, status) VALUES (?, ?, 'pending')").run(item.id, command);
queued++;
}
return c.json({ ok: true, queued });
});
// ─── 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);
});
// ─── 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<string, { language: string | null; variant: string; streamCount: number; fileCount: number }>();
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<string | null, string | null>();
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 });
});
// ─── 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) {
try { unlinkSync(file.file_path); } catch { /* file may not exist */ }
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<string | null, string | null>();
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;