remove path mappings, add subtitle summary endpoint, cache setup page, bump version
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m50s

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 12:02:26 +01:00
parent 99274d3ae8
commit 76d3b1acfb
10 changed files with 216 additions and 51 deletions

View File

@@ -1,5 +1,5 @@
import { Hono } from 'hono';
import { getDb, getAllConfig } from '../db/index';
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';
@@ -295,4 +295,194 @@ app.post('/:id/rescan', async (c) => {
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;