All checks were successful
Build and Push Docker Image / build (push) Successful in 1m30s
worked through AUDIT.md. triage: - finding 2 (subtitle rescan wipes decisions): confirmed. /:id/rescan now snapshots custom_titles and calls reanalyze() after the stream delete/ insert, mirroring the review rescan flow. exported reanalyze + titleKey from review.ts so both routes share the logic. - finding 3 (scan limit accepts NaN/negatives): confirmed. extracted parseScanLimit into a pure helper, added unit tests covering NaN, negatives, floats, infinity, numeric strings. invalid input 400s and releases the scan_running lock. - finding 4 (parseId lenient): confirmed. tightened the regex to /^\d+$/ so "42abc", "abc42", "+42", "42.0" all return null. rewrote the test that codified the old lossy behaviour. - finding 5 (setup_complete set before jellyfin test passes): confirmed. the /jellyfin endpoint still persists url+key unconditionally, but now only flips setup_complete=1 on a successful connection test. - finding 6 (swallowed errors): partial. the mqtt restart and version- fetch swallows are intentional best-effort with downstream surfaces (getMqttStatus, UI fallback). only the scan.ts db-update swallow was a real visibility gap — logs via logError now. - finding 1 (auth): left as-is. redacting secrets on GET without auth on POST is security theater; real fix is an auth layer, which is a design decision not a bugfix. audit removed from the tree. - lint fail on ffmpeg.test.ts: formatted. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
609 lines
21 KiB
TypeScript
609 lines
21 KiB
TypeScript
import { unlinkSync } from "node:fs";
|
|
import { dirname, resolve as resolvePath, sep } from "node:path";
|
|
import { Hono } from "hono";
|
|
import { getAllConfig, getDb } from "../db/index";
|
|
import { error as logError } from "../lib/log";
|
|
import { parseId } from "../lib/validate";
|
|
import { getItem, mapStream, normalizeLanguage, refreshItem } from "../services/jellyfin";
|
|
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision, SubtitleFile } from "../types";
|
|
import { reanalyze, titleKey } from "./review";
|
|
|
|
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[])
|
|
: [];
|
|
return {
|
|
item,
|
|
subtitleStreams,
|
|
files,
|
|
plan: plan ?? null,
|
|
decisions,
|
|
subs_extracted: plan?.subs_extracted ?? 0,
|
|
};
|
|
}
|
|
|
|
// ─── 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 });
|
|
});
|
|
|
|
// ─── 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,
|
|
}));
|
|
|
|
return c.json({ embeddedCount, categories, titles });
|
|
});
|
|
|
|
// ─── Detail ──────────────────────────────────────────────────────────────────
|
|
|
|
app.get("/:id", (c) => {
|
|
const db = getDb();
|
|
const id = parseId(c.req.param("id"));
|
|
if (id == null) return c.json({ error: "invalid id" }, 400);
|
|
const detail = loadDetail(db, 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 = parseId(c.req.param("id"));
|
|
const streamId = parseId(c.req.param("streamId"));
|
|
if (itemId == null || streamId == null) return c.json({ error: "invalid id" }, 400);
|
|
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 = parseId(c.req.param("id"));
|
|
const streamId = parseId(c.req.param("streamId"));
|
|
if (itemId == null || streamId == null) return c.json({ error: "invalid id" }, 400);
|
|
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);
|
|
});
|
|
|
|
// ─── Delete file ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Verify a sidecar file path lives inside the directory of its owning
|
|
* media item. Guards against path-traversal via malformed DB state.
|
|
*/
|
|
function isSidecarOfItem(filePath: string, videoPath: string): boolean {
|
|
const videoDir = resolvePath(dirname(videoPath));
|
|
const targetDir = resolvePath(dirname(filePath));
|
|
return targetDir === videoDir || targetDir.startsWith(videoDir + sep);
|
|
}
|
|
|
|
app.delete("/:id/files/:fileId", (c) => {
|
|
const db = getDb();
|
|
const itemId = parseId(c.req.param("id"));
|
|
const fileId = parseId(c.req.param("fileId"));
|
|
if (itemId == null || fileId == null) return c.json({ error: "invalid id" }, 400);
|
|
|
|
const file = db.prepare("SELECT * FROM subtitle_files WHERE id = ? AND item_id = ?").get(fileId, itemId) as
|
|
| SubtitleFile
|
|
| undefined;
|
|
if (!file) return c.notFound();
|
|
|
|
const item = db.prepare("SELECT file_path FROM media_items WHERE id = ?").get(itemId) as
|
|
| { file_path: string }
|
|
| undefined;
|
|
if (!item || !isSidecarOfItem(file.file_path, item.file_path)) {
|
|
logError(`Refusing to delete subtitle file outside media dir: ${file.file_path}`);
|
|
db.prepare("DELETE FROM subtitle_files WHERE id = ?").run(fileId);
|
|
return c.json({ ok: false, error: "file path outside media directory; DB entry removed without touching disk" }, 400);
|
|
}
|
|
|
|
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 = parseId(c.req.param("id"));
|
|
if (id == null) return c.json({ error: "invalid id" }, 400);
|
|
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);
|
|
|
|
// Snapshot custom_titles before the DELETE cascades stream_decisions away,
|
|
// so reanalyze() can re-attach them to the corresponding new stream rows.
|
|
// Without this rescanning subtitles also wipes per-audio-stream title
|
|
// overrides the user made in the review UI.
|
|
const preservedTitles = new Map<string, string>();
|
|
const oldTitleRows = db
|
|
.prepare(`
|
|
SELECT ms.type, ms.language, ms.stream_index, ms.title, sd.custom_title
|
|
FROM stream_decisions sd
|
|
JOIN media_streams ms ON ms.id = sd.stream_id
|
|
JOIN review_plans rp ON rp.id = sd.plan_id
|
|
WHERE rp.item_id = ? AND sd.custom_title IS NOT NULL
|
|
`)
|
|
.all(id) as {
|
|
type: string;
|
|
language: string | null;
|
|
stream_index: number;
|
|
title: string | null;
|
|
custom_title: string;
|
|
}[];
|
|
for (const r of oldTitleRows) {
|
|
preservedTitles.set(titleKey(r), r.custom_title);
|
|
}
|
|
|
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
// DELETE cascades to stream_decisions via FK. reanalyze() below
|
|
// rebuilds them from the fresh streams; without it the plan would
|
|
// keep status='done'/'approved' but reference zero decisions, and
|
|
// ffmpeg would emit a no-op command.
|
|
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,
|
|
);
|
|
}
|
|
}
|
|
|
|
reanalyze(db, id, preservedTitles);
|
|
|
|
const detail = loadDetail(db, id);
|
|
if (!detail) return c.notFound();
|
|
return c.json(detail);
|
|
});
|
|
|
|
// ─── 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) {
|
|
const item = db.prepare("SELECT file_path FROM media_items WHERE id = ?").get(file.item_id) as
|
|
| { file_path: string }
|
|
| undefined;
|
|
if (item && isSidecarOfItem(file.file_path, item.file_path)) {
|
|
try {
|
|
unlinkSync(file.file_path);
|
|
} catch {
|
|
/* file may not exist */
|
|
}
|
|
} else {
|
|
logError(`Refusing to delete subtitle file outside media dir: ${file.file_path}`);
|
|
}
|
|
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) ?? null;
|
|
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;
|