Files
netfelix-audio-fix/server/api/review.ts
Felix Förtsch 1de5b8a89e
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m30s
address audit findings: subtitle rescan decisions, scan limit, parseId, setup gate
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>
2026-04-14 17:41:36 +02:00

870 lines
35 KiB
TypeScript

import { Hono } from "hono";
import { getAllConfig, getConfig, getDb } from "../db/index";
import { isOneOf, parseId } from "../lib/validate";
import { analyzeItem, assignTargetOrder } from "../services/analyzer";
import { buildCommand } from "../services/ffmpeg";
import { getItem, mapStream, normalizeLanguage, refreshItem } from "../services/jellyfin";
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from "../types";
const app = new Hono();
// ─── Helpers ──────────────────────────────────────────────────────────────────
function getAudioLanguages(): string[] {
return parseLanguageList(getConfig("audio_languages"), []);
}
function parseLanguageList(raw: string | null, fallback: string[]): string[] {
if (!raw) return fallback;
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === "string") : fallback;
} catch {
return fallback;
}
}
/**
* Insert a pending audio job for the given item only if no pending job
* already exists for it. Guards against duplicate jobs from rapid-fire
* approve clicks, overlapping individual + bulk approvals, or any other
* path that could race two POSTs for the same item. Returns true if a
* job was actually inserted.
*/
export function enqueueAudioJob(db: ReturnType<typeof getDb>, itemId: number, command: string): boolean {
const result = db
.prepare(`
INSERT INTO jobs (item_id, command, job_type, status)
SELECT ?, ?, 'audio', 'pending'
WHERE NOT EXISTS (SELECT 1 FROM jobs WHERE item_id = ? AND status = 'pending')
`)
.run(itemId, command, itemId);
return result.changes > 0;
}
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 };
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;
return { item, streams, plan: plan ?? null, decisions, command };
}
/**
* Match old custom_titles to new stream IDs after rescan. Keys by a
* composite of (type, language, stream_index, title) so user overrides
* survive stream-id changes when Jellyfin re-probes metadata.
*/
export function titleKey(s: {
type: string;
language: string | null;
stream_index: number;
title: string | null;
}): string {
return `${s.type}|${s.language ?? ""}|${s.stream_index}|${s.title ?? ""}`;
}
export function reanalyze(db: ReturnType<typeof getDb>, itemId: number, preservedTitles?: Map<string, string>): 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 audioLanguages = getAudioLanguages();
const analysis = analyzeItem(
{ original_language: item.original_language, needs_review: item.needs_review, container: item.container },
streams,
{ audioLanguages },
);
db
.prepare(`
INSERT INTO review_plans (item_id, status, is_noop, confidence, apple_compat, job_type, notes)
VALUES (?, 'pending', ?, ?, ?, ?, ?)
ON CONFLICT(item_id) DO UPDATE SET status = 'pending', is_noop = excluded.is_noop, confidence = excluded.confidence, apple_compat = excluded.apple_compat, job_type = excluded.job_type, notes = excluded.notes
`)
.run(
itemId,
analysis.is_noop ? 1 : 0,
analysis.confidence,
analysis.apple_compat,
analysis.job_type,
analysis.notes.length > 0 ? analysis.notes.join("\n") : null,
);
const plan = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(itemId) as { id: number };
// Preserve existing custom_titles: prefer by stream_id (streams unchanged);
// fall back to titleKey match (streams regenerated after rescan).
const byStreamId = 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]),
);
const streamById = new Map(streams.map((s) => [s.id, s] as const));
db.prepare("DELETE FROM stream_decisions WHERE plan_id = ?").run(plan.id);
const insertDecision = db.prepare(
"INSERT INTO stream_decisions (plan_id, stream_id, action, target_index, custom_title, transcode_codec) VALUES (?, ?, ?, ?, ?, ?)",
);
for (const dec of analysis.decisions) {
let customTitle = byStreamId.get(dec.stream_id) ?? null;
if (!customTitle && preservedTitles) {
const s = streamById.get(dec.stream_id);
if (s) customTitle = preservedTitles.get(titleKey(s)) ?? null;
}
insertDecision.run(plan.id, dec.stream_id, dec.action, dec.target_index, customTitle, dec.transcode_codec);
}
}
/**
* After the user toggles a stream action, re-run assignTargetOrder and
* recompute is_noop without wiping user-chosen actions or custom_titles.
*/
function recomputePlanAfterToggle(db: ReturnType<typeof getDb>, itemId: number): void {
const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(itemId) as MediaItem | undefined;
if (!item) return;
const streams = db
.prepare("SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index")
.all(itemId) as MediaStream[];
const plan = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(itemId) as { id: number } | undefined;
if (!plan) return;
const decisions = db
.prepare("SELECT stream_id, action, target_index, transcode_codec FROM stream_decisions WHERE plan_id = ?")
.all(plan.id) as {
stream_id: number;
action: "keep" | "remove";
target_index: number | null;
transcode_codec: string | null;
}[];
const origLang = item.original_language ? normalizeLanguage(item.original_language) : null;
const audioLanguages = getAudioLanguages();
// Re-assign target_index based on current actions
const decWithIdx = decisions.map((d) => ({
stream_id: d.stream_id,
action: d.action,
target_index: null as number | null,
transcode_codec: d.transcode_codec,
}));
assignTargetOrder(streams, decWithIdx, origLang, audioLanguages);
const updateIdx = db.prepare("UPDATE stream_decisions SET target_index = ? WHERE plan_id = ? AND stream_id = ?");
for (const d of decWithIdx) updateIdx.run(d.target_index, plan.id, d.stream_id);
// Recompute is_noop: audio removed OR reordered OR subs exist OR transcode needed
const anyAudioRemoved = streams.some(
(s) => s.type === "Audio" && decWithIdx.find((d) => d.stream_id === s.id)?.action === "remove",
);
const hasSubs = streams.some((s) => s.type === "Subtitle");
const needsTranscode = decWithIdx.some((d) => d.transcode_codec != null && d.action === "keep");
const keptAudio = streams
.filter((s) => s.type === "Audio" && decWithIdx.find((d) => d.stream_id === s.id)?.action === "keep")
.sort((a, b) => a.stream_index - b.stream_index);
let audioOrderChanged = false;
for (let i = 0; i < keptAudio.length; i++) {
const dec = decWithIdx.find((d) => d.stream_id === keptAudio[i].id);
if (dec?.target_index !== i) {
audioOrderChanged = true;
break;
}
}
const isNoop = !anyAudioRemoved && !audioOrderChanged && !hasSubs && !needsTranscode;
db.prepare("UPDATE review_plans SET is_noop = ? WHERE id = ?").run(isNoop ? 1 : 0, plan.id);
}
// ─── Pipeline: summary ───────────────────────────────────────────────────────
interface PipelineAudioStream {
id: number;
language: string | null;
codec: string | null;
channels: number | null;
title: string | null;
is_default: number;
action: "keep" | "remove";
}
app.get("/pipeline", (c) => {
const db = getDb();
const jellyfinUrl = getConfig("jellyfin_url") ?? "";
// Cap the review column to keep the page snappy at scale; pipelines
// with thousands of pending items would otherwise ship 10k+ rows on
// every refresh and re-render every card.
const REVIEW_LIMIT = 500;
const review = db
.prepare(`
SELECT rp.*, mi.name, mi.series_name, mi.series_jellyfin_id,
mi.jellyfin_id,
mi.season_number, mi.episode_number, mi.type, mi.container,
mi.original_language, mi.orig_lang_source, mi.file_path
FROM review_plans rp
JOIN media_items mi ON mi.id = rp.item_id
WHERE rp.status = 'pending' AND rp.is_noop = 0
ORDER BY
CASE rp.confidence WHEN 'high' THEN 0 ELSE 1 END,
COALESCE(mi.series_name, mi.name),
mi.season_number, mi.episode_number
LIMIT ${REVIEW_LIMIT}
`)
.all();
const reviewTotal = (
db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number }
).n;
// Queued gets the same enrichment as review so the card can render
// streams + transcode reasons read-only (with a "Back to review" button).
const queued = db
.prepare(`
SELECT j.id, j.item_id, j.status, j.started_at, j.completed_at,
mi.name, mi.series_name, mi.series_jellyfin_id, mi.jellyfin_id,
mi.season_number, mi.episode_number, mi.type, mi.container,
mi.original_language, mi.orig_lang_source, mi.file_path,
rp.id as plan_id, rp.job_type, rp.apple_compat,
rp.confidence, rp.is_noop
FROM jobs j
JOIN media_items mi ON mi.id = j.item_id
JOIN review_plans rp ON rp.item_id = j.item_id
WHERE j.status = 'pending'
ORDER BY j.created_at
`)
.all();
const processing = db
.prepare(`
SELECT j.*, mi.name, mi.series_name, mi.type,
rp.job_type, rp.apple_compat
FROM jobs j
JOIN media_items mi ON mi.id = j.item_id
JOIN review_plans rp ON rp.item_id = j.item_id
WHERE j.status = 'running'
`)
.all();
const done = db
.prepare(`
SELECT j.*, mi.name, mi.series_name, mi.type,
rp.job_type, rp.apple_compat, rp.verified
FROM jobs j
JOIN media_items mi ON mi.id = j.item_id
JOIN review_plans rp ON rp.item_id = j.item_id
WHERE j.status IN ('done', 'error')
ORDER BY j.completed_at DESC
LIMIT 50
`)
.all();
// "Done" = files already in the desired end state. Either the analyzer
// says nothing to do (is_noop=1) or a job finished. Use two indexable
// counts and add — the OR form (is_noop=1 OR status='done') can't use
// our single-column indexes and gets slow on large libraries.
const noopRow = db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE is_noop = 1").get() as { n: number };
const doneRow = db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'done' AND is_noop = 0").get() as {
n: number;
};
const doneCount = noopRow.n + doneRow.n;
// Enrich rows that have (plan_id, item_id) with the transcode-reason
// badges and pre-checked audio streams. Used for both review and queued
// columns so the queued card can render read-only with the same info.
type EnrichableRow = { id?: number; plan_id?: number; item_id: number } & {
transcode_reasons?: string[];
audio_streams?: PipelineAudioStream[];
};
const enrichWithStreamsAndReasons = (rows: EnrichableRow[]) => {
if (rows.length === 0) return;
const planIdFor = (r: EnrichableRow): number => (r.plan_id ?? r.id) as number;
const planIds = rows.map(planIdFor);
const itemIds = rows.map((r) => r.item_id);
const reasonPh = planIds.map(() => "?").join(",");
const allReasons = db
.prepare(`
SELECT DISTINCT sd.plan_id, ms.codec, sd.transcode_codec
FROM stream_decisions sd
JOIN media_streams ms ON ms.id = sd.stream_id
WHERE sd.plan_id IN (${reasonPh}) AND sd.transcode_codec IS NOT NULL
`)
.all(...planIds) as { plan_id: number; codec: string | null; transcode_codec: string }[];
const reasonsByPlan = new Map<number, string[]>();
for (const r of allReasons) {
if (!reasonsByPlan.has(r.plan_id)) reasonsByPlan.set(r.plan_id, []);
reasonsByPlan.get(r.plan_id)!.push(`${(r.codec ?? "").toUpperCase()}${r.transcode_codec.toUpperCase()}`);
}
const streamPh = itemIds.map(() => "?").join(",");
const streamRows = db
.prepare(`
SELECT ms.id, ms.item_id, ms.language, ms.codec, ms.channels, ms.title,
ms.is_default, sd.action
FROM media_streams ms
JOIN review_plans rp ON rp.item_id = ms.item_id
LEFT JOIN stream_decisions sd ON sd.plan_id = rp.id AND sd.stream_id = ms.id
WHERE ms.item_id IN (${streamPh}) AND ms.type = 'Audio'
ORDER BY ms.item_id, ms.stream_index
`)
.all(...itemIds) as {
id: number;
item_id: number;
language: string | null;
codec: string | null;
channels: number | null;
title: string | null;
is_default: number;
action: "keep" | "remove" | null;
}[];
const streamsByItem = new Map<number, PipelineAudioStream[]>();
for (const r of streamRows) {
if (!streamsByItem.has(r.item_id)) streamsByItem.set(r.item_id, []);
streamsByItem.get(r.item_id)!.push({
id: r.id,
language: r.language,
codec: r.codec,
channels: r.channels,
title: r.title,
is_default: r.is_default,
action: r.action ?? "keep",
});
}
for (const r of rows) {
r.transcode_reasons = reasonsByPlan.get(planIdFor(r)) ?? [];
r.audio_streams = streamsByItem.get(r.item_id) ?? [];
}
};
enrichWithStreamsAndReasons(review as EnrichableRow[]);
enrichWithStreamsAndReasons(queued as EnrichableRow[]);
return c.json({ review, reviewTotal, queued, processing, done, doneCount, jellyfinUrl });
});
// ─── 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) enqueueAudioJob(db, 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.parseInt(c.req.param("season") ?? "", 10);
if (!Number.isFinite(season)) return c.json({ error: "invalid season" }, 400);
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) enqueueAudioJob(db, 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) enqueueAudioJob(db, plan.item_id, buildCommand(item, streams, decisions));
}
return c.json({ ok: true, count: pending.length });
});
// ─── Auto-approve high-confidence ────────────────────────────────────────────
// Approves every pending plan whose original language came from an authoritative
// source (radarr/sonarr). Anything with low confidence keeps needing a human.
app.post("/auto-approve", (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 AND rp.confidence = 'high'",
)
.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) enqueueAudioJob(db, 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 = parseId(c.req.param("id"));
if (id == null) return c.json({ error: "invalid id" }, 400);
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 = parseId(c.req.param("id"));
if (id == null) return c.json({ error: "invalid id" }, 400);
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 = 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 = 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 = 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<{ action: unknown }>().catch(() => ({ action: null }));
if (!isOneOf(body.action, ["keep", "remove"] as const)) {
return c.json({ error: 'action must be "keep" or "remove"' }, 400);
}
const action: "keep" | "remove" = body.action;
// Only audio streams can be toggled — subtitles are always removed (extracted to sidecar)
const stream = db.prepare("SELECT type, item_id FROM media_streams WHERE id = ?").get(streamId) as
| { type: string; item_id: number }
| undefined;
if (!stream || stream.item_id !== itemId) return c.json({ error: "stream not found on item" }, 404);
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);
recomputePlanAfterToggle(db, itemId);
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 = parseId(c.req.param("id"));
if (id == null) return c.json({ error: "invalid id" }, 400);
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) enqueueAudioJob(db, id, buildCommand(item, streams, decisions));
}
return c.json({ ok: true });
});
// ─── Unapprove ───────────────────────────────────────────────────────────────
// ─── Retry failed job ─────────────────────────────────────────────────────────
app.post("/:id/retry", (c) => {
const db = getDb();
const id = parseId(c.req.param("id"));
if (id == null) return c.json({ error: "invalid id" }, 400);
const plan = db.prepare("SELECT * FROM review_plans WHERE item_id = ?").get(id) as ReviewPlan | undefined;
if (!plan) return c.notFound();
if (plan.status !== "error") return c.json({ ok: false, error: "Only failed plans can be retried" }, 409);
// Clear old errored/done jobs for this item so the queue starts clean
db.prepare("DELETE FROM jobs WHERE item_id = ? AND status IN ('error', 'done')").run(id);
// Rebuild the command from the current decisions (streams may have been edited)
const { item, command } = loadItemDetail(db, id);
if (!item || !command) return c.json({ ok: false, error: "Cannot rebuild command" }, 400);
enqueueAudioJob(db, id, command);
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id);
return c.json({ ok: true });
});
app.post("/:id/unapprove", (c) => {
const db = getDb();
const id = parseId(c.req.param("id"));
if (id == null) return c.json({ error: "invalid id" }, 400);
const plan = db.prepare("SELECT * FROM review_plans WHERE item_id = ?").get(id) as ReviewPlan | undefined;
if (!plan) return c.notFound();
if (plan.status !== "approved")
return c.json({ ok: false, error: "Can only unapprove items with status approved" }, 409);
// Only allow if the associated job hasn't started yet
const job = db.prepare("SELECT * FROM jobs WHERE item_id = ? ORDER BY created_at DESC LIMIT 1").get(id) as
| { id: number; status: string }
| undefined;
if (job && job.status !== "pending")
return c.json({ ok: false, error: "Job already started — cannot unapprove" }, 409);
// Delete the pending job and revert plan status
if (job) db.prepare("DELETE FROM jobs WHERE id = ?").run(job.id);
db.prepare("UPDATE review_plans SET status = 'pending', reviewed_at = NULL WHERE id = ?").run(plan.id);
return c.json({ ok: true });
});
// ─── Skip / Unskip ───────────────────────────────────────────────────────────
app.post("/skip-all", (c) => {
const db = getDb();
const result = db
.prepare(
"UPDATE review_plans SET status = 'skipped', reviewed_at = datetime('now') WHERE status = 'pending' AND is_noop = 0",
)
.run();
return c.json({ ok: true, skipped: result.changes });
});
app.post("/:id/skip", (c) => {
const db = getDb();
const id = parseId(c.req.param("id"));
if (id == null) return c.json({ error: "invalid id" }, 400);
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 = parseId(c.req.param("id"));
if (id == null) return c.json({ error: "invalid id" }, 400);
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 = 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 };
// 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);
// Snapshot custom_titles keyed by stable properties, since replacing
// media_streams cascades away all stream_decisions.
const preservedTitles = new Map<string, string>();
const oldRows = 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 oldRows) {
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
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, preservedTitles);
const detail = loadItemDetail(db, id);
if (!detail.item) return c.notFound();
return c.json(detail);
});
// ─── Pipeline: series language ───────────────────────────────────────────────
app.patch("/series/:seriesKey/language", async (c) => {
const seriesKey = decodeURIComponent(c.req.param("seriesKey"));
const { language } = await c.req.json<{ language: string }>();
const db = getDb();
const items = db
.prepare(
"SELECT id FROM media_items WHERE series_jellyfin_id = ? OR (series_jellyfin_id IS NULL AND series_name = ?)",
)
.all(seriesKey, seriesKey) as { id: number }[];
const normalizedLang = language ? normalizeLanguage(language) : null;
for (const item of items) {
db
.prepare("UPDATE media_items SET original_language = ?, orig_lang_source = 'manual', needs_review = 0 WHERE id = ?")
.run(normalizedLang, item.id);
}
// Re-analyze all episodes
for (const item of items) {
reanalyze(db, item.id);
}
return c.json({ updated: items.length });
});
export default app;