per-track language override on audio detail page
Build and Push Docker Image / build (push) Successful in 3m3s

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>
This commit is contained in:
2026-04-20 00:05:31 +02:00
parent fada511ecc
commit 8112bfeb65
11 changed files with 290 additions and 47 deletions
+109 -16
View File
@@ -2,7 +2,7 @@ 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 { 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";
@@ -278,6 +278,26 @@ export function reanalyze(
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,
@@ -287,6 +307,7 @@ export function reanalyze(
},
streams,
{ audioLanguages },
languageOverrides.size > 0 ? languageOverrides : undefined,
);
db
@@ -312,29 +333,32 @@ export function reanalyze(
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]),
);
// 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, transcode_codec) VALUES (?, ?, ?, ?, ?, ?)",
"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 = byStreamId.get(dec.stream_id) ?? null;
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;
}
insertDecision.run(plan.id, dec.stream_id, dec.action, dec.target_index, customTitle, dec.transcode_codec);
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,
);
}
}
@@ -351,17 +375,28 @@ function recomputePlanAfterToggle(db: ReturnType<typeof getDb>, itemId: number):
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 = ?")
.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,
@@ -369,7 +404,7 @@ function recomputePlanAfterToggle(db: ReturnType<typeof getDb>, itemId: number):
target_index: null as number | null,
transcode_codec: d.transcode_codec,
}));
assignTargetOrder(streams, decWithIdx, origLang, audioLanguages);
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);
@@ -1058,6 +1093,64 @@ app.patch("/:id/stream/:streamId/title", async (c) => {
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) => {