Files
netfelix-audio-fix/server/api/review.ts
T
felixfoertsch 8112bfeb65
Build and Push Docker Image / build (push) Successful in 3m3s
per-track language override on audio detail page
adds stream_decisions.custom_language (ISO 639-2 code or null) so the
user can correct a mislabeled audio track — e.g. a Spanish dub tagged
"und" in the container — without going through Jellyfin. the override
wins over stream.language everywhere it matters: the analyzer reads it
for keep/remove decisions and track ordering, the ffmpeg command builder
writes it as both the language metadata tag and the harmonized track
title, and reanalyze preserves it across reruns and rescans.

on the audio detail page, each pending audio row swaps its language
cell for an inline <select> populated from LANG_NAMES. picking the raw
file language clears the override; anything else sets it and triggers a
server-side reanalyze so keep/remove + target_index update immediately.
a small ✎ hint marks overridden tracks. rebuilt commands tag the output
accordingly so Jellyfin reads the corrected language.

PATCH /api/review/:id/stream/:streamId/language validates the code
against LANG_NAMES (accepts ISO 639-1/2/2B aliases, rejects garbage)
and runs reanalyze inside.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 00:05:31 +02:00

1407 lines
55 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, LANG_NAMES } from "../services/ffmpeg";
import { getItem, mapStream, normalizeLanguage, refreshItem } from "../services/jellyfin";
import type { Job, MediaItem, MediaStream, ReviewPlan, StreamDecision } from "../types";
import { emitInboxSorted, emitInboxSortProgress, emitInboxSortStart, maybeStartQueueProcessor } from "./execute";
const app = new Hono();
// ─── Helpers ──────────────────────────────────────────────────────────────────
export 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);
if (result.changes > 0) {
// Kick the processor if the user has opted into hands-off queue
// draining. No-ops when the runner is already active or the toggle
// is off, so every enqueue path gets the behaviour for free.
maybeStartQueueProcessor();
}
return result.changes > 0;
}
export interface SortInboxResult {
moved_to_queue: number;
moved_to_review: number;
}
export interface SortInboxHooks {
onStart?: (total: number) => void;
onProgress?: (processed: number, total: number) => void;
}
// Yield to the event loop every N items so SSE writes flush and other
// requests get a turn. 10 lands around 20 yields/second at typical
// reanalyze speed — smooth progress without thrashing.
const SORT_INBOX_YIELD_EVERY = 10;
/**
* Distribute every unsorted (sorted=0) pending plan to its final bucket:
* auto → sorted=1, status='approved', job enqueued (→ Queue)
* auto_heuristic → sorted=1 (→ Review, badge ⚡)
* manual → sorted=1 (→ Review, badge ✋)
*
* Emits progress via the optional hooks so the UI can render a live
* counter during long sorts; ticks after every item (including noops
* that get skipped) so the progress bar tracks real work done.
*/
export async function sortInbox(
db: ReturnType<typeof getDb>,
audioLanguages: string[],
hooks?: SortInboxHooks,
): Promise<SortInboxResult> {
// Snapshot the ids first — reanalyze() below rewrites stream_decisions and
// the plan's auto_class/is_noop so we must re-read the plan after each
// reanalysis rather than trusting this initial select.
const unsortedIds = db
.prepare(`
SELECT rp.item_id
FROM review_plans rp
WHERE rp.status = 'pending' AND rp.is_noop = 0 AND rp.sorted = 0
`)
.all() as { item_id: number }[];
const total = unsortedIds.length;
hooks?.onStart?.(total);
let movedToQueue = 0;
let movedToReview = 0;
let processed = 0;
for (const { item_id } of unsortedIds) {
// Re-run the analyzer so settings changes made after the scan (e.g.
// toggling an audio_languages entry) shape the decisions and class
// before distribution. Without this the user's only path to apply a
// config change to already-scanned items is a full per-item rescan.
reanalyze(db, item_id, audioLanguages);
const plan = db.prepare("SELECT id, auto_class, is_noop FROM review_plans WHERE item_id = ?").get(item_id) as
| { id: number; auto_class: string | null; is_noop: number }
| undefined;
if (plan && !plan.is_noop) {
if (plan.auto_class === "auto") {
db
.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now'), sorted = 1 WHERE id = ?")
.run(plan.id);
const { item, streams, decisions } = loadItemDetail(db, item_id);
if (item) enqueueAudioJob(db, item_id, buildCommand(item, streams, decisions));
movedToQueue += 1;
} else {
db.prepare("UPDATE review_plans SET sorted = 1 WHERE id = ?").run(plan.id);
movedToReview += 1;
}
}
// plans that vanished (!plan) or became noops fall through — the
// is_noop filter already excludes them from both Inbox and Review.
processed += 1;
hooks?.onProgress?.(processed, total);
if (processed % SORT_INBOX_YIELD_EVERY === 0 && processed < total) {
await Bun.sleep(0);
}
}
return { moved_to_queue: movedToQueue, moved_to_review: movedToReview };
}
/**
* Bulk-approve every ⚡ Ready (auto_heuristic) plan currently in the Review
* column (sorted=1, status='pending'). Manual items are untouched.
*/
export function approveReady(db: ReturnType<typeof getDb>): number {
const ready = 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.sorted = 1 AND rp.auto_class = 'auto_heuristic'
`)
.all() as (ReviewPlan & { item_id: number })[];
let count = 0;
for (const plan of ready) {
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));
count += 1;
}
}
return count;
}
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, job: 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;
const job = db
.prepare(
`SELECT id, item_id, command, job_type, status, output, exit_code,
created_at, started_at, completed_at
FROM jobs WHERE item_id = ? ORDER BY created_at DESC LIMIT 1`,
)
.get(itemId) as Job | undefined;
return { item, streams, plan: plan ?? null, decisions, command, job: job ?? null };
}
/**
* 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,
audioLanguages: string[],
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[];
// Pull prior decisions once so we can pass any custom_language overrides
// into the analyzer (so reanalysis respects them) and re-attach them +
// custom_title onto the freshly-written decision rows below. Keyed by
// stream_id; survives rescan as long as the stream_id is stable.
const priorPlan = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(itemId) as
| { id: number }
| undefined;
const priorDecisions = priorPlan
? (db
.prepare("SELECT stream_id, custom_title, custom_language FROM stream_decisions WHERE plan_id = ?")
.all(priorPlan.id) as { stream_id: number; custom_title: string | null; custom_language: string | null }[])
: [];
const titleByStreamId = new Map<number, string | null>(priorDecisions.map((r) => [r.stream_id, r.custom_title]));
const languageByStreamId = new Map<number, string | null>(priorDecisions.map((r) => [r.stream_id, r.custom_language]));
const languageOverrides = new Map<number, string>();
for (const [streamId, lang] of languageByStreamId) {
if (lang) languageOverrides.set(streamId, lang);
}
const analysis = analyzeItem(
{
original_language: item.original_language,
orig_lang_source: item.orig_lang_source,
needs_review: item.needs_review,
container: item.container,
},
streams,
{ audioLanguages },
languageOverrides.size > 0 ? languageOverrides : undefined,
);
db
.prepare(`
INSERT INTO review_plans (item_id, status, is_noop, auto_class, apple_compat, job_type, notes)
VALUES (?, 'pending', ?, ?, ?, ?, ?)
ON CONFLICT(item_id) DO UPDATE SET
status = 'pending',
is_noop = excluded.is_noop,
auto_class = excluded.auto_class,
apple_compat = excluded.apple_compat,
job_type = excluded.job_type,
notes = excluded.notes
`)
.run(
itemId,
analysis.is_noop ? 1 : 0,
analysis.auto_class,
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_title/custom_language: prefer by stream_id
// (streams unchanged); fall back to titleKey match (streams regenerated
// after rescan — only applies to titles since custom_language didn't
// exist at the time of the original title snapshot API).
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, custom_language, transcode_codec) VALUES (?, ?, ?, ?, ?, ?, ?)",
);
for (const dec of analysis.decisions) {
let customTitle = titleByStreamId.get(dec.stream_id) ?? null;
if (!customTitle && preservedTitles) {
const s = streamById.get(dec.stream_id);
if (s) customTitle = preservedTitles.get(titleKey(s)) ?? null;
}
const customLanguage = languageByStreamId.get(dec.stream_id) ?? null;
insertDecision.run(
plan.id,
dec.stream_id,
dec.action,
dec.target_index,
customTitle,
customLanguage,
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, custom_language, transcode_codec FROM stream_decisions WHERE plan_id = ?",
)
.all(plan.id) as {
stream_id: number;
action: "keep" | "remove";
target_index: number | null;
custom_language: string | null;
transcode_codec: string | null;
}[];
const origLang = item.original_language ? normalizeLanguage(item.original_language) : null;
const audioLanguages = getAudioLanguages();
// Per-stream language overrides drive track ordering (OG first, then
// configured keep-languages) so a "und → spa" rename reorders the output
// correctly on the next pass.
const languageOverrides = new Map<number, string>();
for (const d of decisions) {
if (d.custom_language) languageOverrides.set(d.stream_id, d.custom_language);
}
// 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, languageOverrides);
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";
}
type EnrichableRow = { id?: number; plan_id?: number; item_id: number } & {
transcode_reasons?: string[];
audio_streams?: PipelineAudioStream[];
};
/**
* Enrich review/queued rows with transcode-reason badges and pre-checked audio
* streams. Works for both the Review column (where `id` is the plan id) and
* the Queued column (where `plan_id` is explicit and `id` is the job id).
*/
function enrichWithStreamsAndReasons(db: ReturnType<typeof getDb>, rows: EnrichableRow[]): void {
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) ?? [];
}
}
// ─── Review groups (paginated, always returns complete series) ──────────────
interface ReviewItemRow {
id: number;
item_id: number;
status: string;
is_noop: number;
auto_class: "auto" | "auto_heuristic" | "manual" | null;
apple_compat: ReviewPlan["apple_compat"];
job_type: "copy" | "transcode";
name: string;
series_name: string | null;
series_jellyfin_id: string | null;
jellyfin_id: string;
season_number: number | null;
episode_number: number | null;
type: "Movie" | "Episode";
container: string | null;
original_language: string | null;
orig_lang_source: string | null;
file_path: string;
transcode_reasons?: string[];
audio_streams?: PipelineAudioStream[];
}
type ReviewGroup =
| { kind: "movie"; item: ReviewItemRow }
| {
kind: "series";
seriesKey: string;
seriesName: string;
seriesJellyfinId: string | null;
episodeCount: number;
readyCount: number;
originalLanguage: string | null;
seasons: Array<{ season: number | null; episodes: ReviewItemRow[] }>;
};
export interface BuildReviewGroupsOpts {
bucket: "inbox" | "review";
}
export function buildReviewGroups(
db: ReturnType<typeof getDb>,
opts: BuildReviewGroupsOpts,
): { groups: ReviewGroup[]; totalItems: number } {
const sortedFilter = opts.bucket === "inbox" ? "rp.sorted = 0" : "rp.sorted = 1";
const rows = 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 AND ${sortedFilter}
ORDER BY
CASE rp.auto_class WHEN 'auto_heuristic' THEN 0 WHEN 'manual' THEN 1 ELSE 2 END,
COALESCE(mi.series_name, mi.name),
mi.season_number, mi.episode_number
`)
.all() as ReviewItemRow[];
const movieGroups: ReviewGroup[] = [];
interface SeriesAccum {
seriesName: string;
seriesJellyfinId: string | null;
seasons: Map<number | null, ReviewItemRow[]>;
originalLanguage: string | null;
readyCount: number;
}
const seriesMap = new Map<string, SeriesAccum>();
for (const row of rows) {
if (row.type === "Movie") {
movieGroups.push({ kind: "movie", item: row });
continue;
}
const key = row.series_jellyfin_id ?? row.series_name ?? String(row.item_id);
let entry = seriesMap.get(key);
if (!entry) {
entry = {
seriesName: row.series_name ?? "",
seriesJellyfinId: row.series_jellyfin_id,
seasons: new Map(),
originalLanguage: row.original_language,
readyCount: 0,
};
seriesMap.set(key, entry);
}
let bucket = entry.seasons.get(row.season_number);
if (!bucket) {
bucket = [];
entry.seasons.set(row.season_number, bucket);
}
bucket.push(row);
if (row.auto_class === "auto_heuristic") entry.readyCount += 1;
}
const seriesGroups: ReviewGroup[] = [];
for (const [seriesKey, entry] of seriesMap) {
const seasonKeys = [...entry.seasons.keys()].sort((a, b) => {
if (a === null) return 1;
if (b === null) return -1;
return a - b;
});
const seasons = seasonKeys.map((season) => ({
season,
episodes: (entry.seasons.get(season) ?? []).sort((a, b) => (a.episode_number ?? 0) - (b.episode_number ?? 0)),
}));
const episodeCount = seasons.reduce((sum, s) => sum + s.episodes.length, 0);
seriesGroups.push({
kind: "series",
seriesKey,
seriesName: entry.seriesName,
seriesJellyfinId: entry.seriesJellyfinId,
episodeCount,
readyCount: entry.readyCount,
originalLanguage: entry.originalLanguage,
seasons,
});
}
const allGroups = [...movieGroups, ...seriesGroups].sort((a, b) => {
const rankA = a.kind === "movie" ? autoClassRank(a.item.auto_class) : a.readyCount > 0 ? 0 : 1;
const rankB = b.kind === "movie" ? autoClassRank(b.item.auto_class) : b.readyCount > 0 ? 0 : 1;
if (rankA !== rankB) return rankA - rankB;
const nameA = a.kind === "movie" ? a.item.name : a.seriesName;
const nameB = b.kind === "movie" ? b.item.name : b.seriesName;
return nameA.localeCompare(nameB);
});
const totalItems =
movieGroups.length + seriesGroups.reduce((sum, g) => sum + (g.kind === "series" ? g.episodeCount : 0), 0);
return { groups: allGroups, totalItems };
}
function autoClassRank(cls: string | null): number {
if (cls === "auto_heuristic") return 0;
if (cls === "manual") return 1;
return 2;
}
app.get("/groups", (c) => {
const db = getDb();
const offset = Math.max(0, Number.parseInt(c.req.query("offset") ?? "0", 10) || 0);
const limit = Math.max(1, Math.min(200, Number.parseInt(c.req.query("limit") ?? "25", 10) || 25));
const bucketParam = c.req.query("bucket") ?? "review";
const bucket = bucketParam === "inbox" ? "inbox" : "review";
const { groups, totalItems } = buildReviewGroups(db, { bucket });
const page = groups.slice(offset, offset + limit);
const flat: EnrichableRow[] = [];
for (const g of page) {
if (g.kind === "movie") flat.push(g.item as EnrichableRow);
else for (const s of g.seasons) for (const ep of s.episodes) flat.push(ep as EnrichableRow);
}
enrichWithStreamsAndReasons(db, flat);
return c.json({
groups: page,
totalGroups: groups.length,
totalItems,
hasMore: offset + limit < groups.length,
});
});
app.get("/pipeline", (c) => {
const db = getDb();
const jellyfinUrl = getConfig("jellyfin_url") ?? "";
const inboxTotal = (
db
.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0 AND sorted = 0")
.get() as { n: number }
).n;
const reviewItemsTotal = (
db
.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0 AND sorted = 1")
.get() as { n: number }
).n;
const reviewReadyCount = (
db
.prepare(
"SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0 AND sorted = 1 AND auto_class = 'auto_heuristic'",
)
.get() as { n: number }
).n;
const reviewManualCount = reviewItemsTotal - reviewReadyCount;
const autoProcessing = getConfig("auto_processing") === "1";
const autoProcessQueue = getConfig("auto_process_queue") === "1";
// Queued carries stream + transcode-reason enrichment so the card renders
// 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.auto_class, 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
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;
enrichWithStreamsAndReasons(db, queued as EnrichableRow[]);
return c.json({
inboxTotal,
reviewItemsTotal,
reviewReadyCount,
reviewManualCount,
autoProcessing,
autoProcessQueue,
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 });
});
// ─── Batch approve (by item id list) ─────────────────────────────────────────
// Used by the "approve up to here" affordance in the review column. The
// client knows the visible order (movies + series sort-key) and passes in
// the prefix of item ids it wants approved in one round-trip. Items that
// aren't pending (already approved / skipped / done) are silently ignored
// so the endpoint is idempotent against stale client state.
app.post("/approve-batch", async (c) => {
const db = getDb();
const body = await c.req.json<{ itemIds?: unknown }>().catch(() => ({ itemIds: undefined }));
if (
!Array.isArray(body.itemIds) ||
!body.itemIds.every((v) => typeof v === "number" && Number.isInteger(v) && v > 0)
) {
return c.json({ ok: false, error: "itemIds must be an array of positive integers" }, 400);
}
const ids = body.itemIds as number[];
if (ids.length === 0) return c.json({ ok: true, count: 0 });
const placeholders = ids.map(() => "?").join(",");
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 mi.id IN (${placeholders})`,
)
.all(...ids) as (ReviewPlan & { item_id: number })[];
let count = 0;
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));
count++;
}
}
return c.json({ ok: true, count });
});
// ─── Sort inbox ──────────────────────────────────────────────────────────────
// Distributor: walks every unsorted plan and moves it to Queue (auto) or Review
// (auto_heuristic / manual). Called by the user's "Auto Review" button and by
// the rescan hook when auto_processing is enabled.
// Module-level guard so a second "Auto Review" click (or an auto-scan hook
// racing with the user's click) can't start a parallel sort against the same
// inbox. The second caller gets a 409 and the UI keeps the first sort's
// progress visible.
let sortInboxRunning = false;
app.post("/sort-inbox", async (c) => {
if (sortInboxRunning) {
return c.json({ ok: false, error: "sort already running" }, 409);
}
sortInboxRunning = true;
try {
const db = getDb();
const result = await sortInbox(db, getAudioLanguages(), {
onStart: emitInboxSortStart,
onProgress: emitInboxSortProgress,
});
emitInboxSorted(result);
return c.json({ ok: true, ...result });
} finally {
sortInboxRunning = false;
}
});
// ─── Approve all ready ───────────────────────────────────────────────────────
// Bulk-approves every auto_heuristic-classified plan currently in Review.
app.post("/approve-ready", (c) => {
const db = getDb();
const count = approveReady(db);
return c.json({ ok: true, count });
});
// ─── Unsort all (Review → Inbox) ─────────────────────────────────────────────
// Flip every sorted, pending plan back to the Inbox so the distributor can
// re-classify on the next "Auto Review". Symmetric backward counterpart of
// sort-inbox; only touches plans currently visible in the Review column.
export function unsortAll(db: ReturnType<typeof getDb>): number {
const result = db
.prepare("UPDATE review_plans SET sorted = 0 WHERE status = 'pending' AND is_noop = 0 AND sorted = 1")
.run();
return result.changes;
}
app.post("/unsort-all", (c) => {
const count = unsortAll(getDb());
return c.json({ ok: true, count });
});
// ─── Reopen all done/errored (Done → Inbox) ──────────────────────────────────
// Backward counterpart of the per-item reopen: flips every finished plan back
// to pending, sends it to the Inbox (sorted=0) so the next Auto Review pass
// can reclassify it, and drops the finished job rows so the Done column
// clears. We target Inbox rather than Review because settings may have
// changed since the original pass; Inbox forces reanalysis.
export function reopenAllDone(db: ReturnType<typeof getDb>): number {
let count = 0;
db.transaction(() => {
const result = db
.prepare(
"UPDATE review_plans SET status = 'pending', reviewed_at = NULL, sorted = 0 WHERE status IN ('done', 'error')",
)
.run();
count = result.changes;
db.prepare("DELETE FROM jobs WHERE status IN ('done', 'error')").run();
})();
return count;
}
app.post("/reopen-all", (c) => {
const count = reopenAllDone(getDb());
return c.json({ ok: true, count });
});
// ─── 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, getAudioLanguages());
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);
});
// ─── Override stream language ────────────────────────────────────────────────
// Per-stream language override. Used to correct an "und" or mislabeled audio
// track without going through Jellyfin. Pass `language: null` to clear the
// override. The value is normalized (e.g. "es"/"spa"/"spanish" → "spa") before
// storage; invalid codes are rejected. Reanalysis runs after the update so
// keep/remove decisions, track ordering, and is_noop reflect the new language
// immediately.
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: unknown }>().catch(() => ({ language: undefined }));
let language: string | null;
if (body.language === null || body.language === "") {
language = null;
} else if (typeof body.language === "string") {
const normalized = normalizeLanguage(body.language);
// Guard against typos and arbitrary strings — only accept codes the
// app's lang dictionary knows about so downstream display + ffmpeg
// metadata stays consistent.
if (!(normalized in LANG_NAMES)) {
return c.json({ error: `unknown language code: ${body.language}` }, 400);
}
language = normalized;
} else {
return c.json({ error: "language must be a string or null" }, 400);
}
// Only audio streams carry a meaningful language override; video/data
// streams have no language semantics, and subtitle streams are always
// removed from the container (managed separately from this app).
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 !== "Audio") return c.json({ error: "language override only applies to audio streams" }, 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 custom_language = ? WHERE plan_id = ? AND stream_id = ?")
.run(language, plan.id, streamId);
// Full reanalysis: a language change can flip the track's keep/remove
// status (if the new language isn't in the keep list), shuffle target
// indices (OG-match goes first), or flip is_noop. Cheaper and more
// predictable than trying to patch each derived field in place.
reanalyze(db, itemId, getAudioLanguages());
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 });
});
// Reopen a completed or errored plan: flip it back to pending and send it to
// the Inbox (sorted=0) so the user can adjust settings and have Auto Review
// redo the classification. Used by the Done column's hover "Back to inbox"
// affordance. Unlike /unapprove (which rolls back an approved-but-not-yet-
// running plan), this handles the post-job states and drops the lingering
// job row so the pipeline doesn't show leftover history for an item that's
// about to be re-sorted.
app.post("/:id/reopen", (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 !== "done" && plan.status !== "error") {
return c.json({ ok: false, error: "Can only reopen plans with status done or error" }, 409);
}
db.transaction(() => {
// Leave plan.notes alone so the user keeps any ffmpeg error summary
// from the prior run — useful context when redeciding decisions.
db.prepare("UPDATE review_plans SET status = 'pending', reviewed_at = NULL, sorted = 0 WHERE id = ?").run(plan.id);
db.prepare("DELETE FROM jobs WHERE item_id = ? AND status IN ('done', 'error')").run(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, getAudioLanguages(), 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
const audioLanguages = getAudioLanguages();
for (const item of items) {
reanalyze(db, item.id, audioLanguages);
}
return c.json({ updated: items.length });
});
export default app;