diff --git a/package.json b/package.json index 3000bdd..c100610 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "netfelix-audio-fix", - "version": "2026.04.19.11", + "version": "2026.04.20", "scripts": { "dev:server": "NODE_ENV=development bun --hot server/index.tsx", "dev:client": "vite", diff --git a/server/api/review.ts b/server/api/review.ts index 9df5109..a3ccc47 100644 --- a/server/api/review.ts +++ b/server/api/review.ts @@ -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(priorDecisions.map((r) => [r.stream_id, r.custom_title])); + const languageByStreamId = new Map(priorDecisions.map((r) => [r.stream_id, r.custom_language])); + const languageOverrides = new Map(); + 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( - ( - 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, 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(); + 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, 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) => { diff --git a/server/db/index.ts b/server/db/index.ts index b7bc234..721c51f 100644 --- a/server/db/index.ts +++ b/server/db/index.ts @@ -84,6 +84,11 @@ function migrate(db: Database): void { alter("ALTER TABLE review_plans ADD COLUMN auto_class TEXT"); alter("ALTER TABLE review_plans ADD COLUMN sorted INTEGER NOT NULL DEFAULT 0"); alter("ALTER TABLE review_plans DROP COLUMN confidence"); + // Per-stream language override — lets the user correct an "und" (or + // mislabeled) audio track without round-tripping through Jellyfin. Read + // in preference to stream.language by the analyzer and the ffmpeg + // command builder; preserved across reanalyze and rescan like custom_title. + alter("ALTER TABLE stream_decisions ADD COLUMN custom_language TEXT"); // Indexes for new columns — must run after the columns exist on existing DBs alter("CREATE INDEX IF NOT EXISTS idx_review_plans_sorted ON review_plans(sorted)"); alter("CREATE INDEX IF NOT EXISTS idx_review_plans_auto_class ON review_plans(auto_class)"); diff --git a/server/db/schema.ts b/server/db/schema.ts index f33e550..9ded551 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -82,6 +82,7 @@ CREATE TABLE IF NOT EXISTS stream_decisions ( action TEXT NOT NULL, target_index INTEGER, custom_title TEXT, + custom_language TEXT, transcode_codec TEXT, UNIQUE(plan_id, stream_id) ); diff --git a/server/services/__tests__/analyzer.test.ts b/server/services/__tests__/analyzer.test.ts index 763dde9..8555330 100644 --- a/server/services/__tests__/analyzer.test.ts +++ b/server/services/__tests__/analyzer.test.ts @@ -79,6 +79,33 @@ describe("analyzeItem — audio keep rules", () => { }); expect(result.decisions[0].action).toBe("keep"); }); + + test("custom_language override wins over stream.language for keep/remove", () => { + // File says UND, user corrects it to Spanish. With OG=eng and no extra + // keep languages, the track should be removed — the override flowed + // into the decision just like a real "spa" tag would have. + const streams = [ + stream({ id: 1, type: "Video", stream_index: 0 }), + stream({ id: 2, type: "Audio", stream_index: 1, language: null }), + ]; + const overrides = new Map([[2, "spa"]]); + const removing = analyzeItem( + { ...ITEM_DEFAULTS, original_language: "eng" }, + streams, + { audioLanguages: [] }, + overrides, + ); + expect(removing.decisions.find((d) => d.stream_id === 2)?.action).toBe("remove"); + + // Same file, but Spanish is now in the keep list → kept. + const keeping = analyzeItem( + { ...ITEM_DEFAULTS, original_language: "eng" }, + streams, + { audioLanguages: ["spa"] }, + overrides, + ); + expect(keeping.decisions.find((d) => d.stream_id === 2)?.action).toBe("keep"); + }); }); describe("analyzeItem — audio ordering", () => { diff --git a/server/services/__tests__/ffmpeg.test.ts b/server/services/__tests__/ffmpeg.test.ts index 05326d1..84bbd5a 100644 --- a/server/services/__tests__/ffmpeg.test.ts +++ b/server/services/__tests__/ffmpeg.test.ts @@ -28,6 +28,7 @@ function decision(o: Partial & Pick | undefined): string | null { + const override = overrides?.get(stream.id); + if (override) return override; + return stream.language; +} + /** * Given an item and its streams, compute what action to take for each stream * and whether the file needs audio remuxing. @@ -17,17 +28,24 @@ export interface AnalyzerConfig { * sidecar files). is_noop considers audio removal/reorder, subtitle * extraction, and transcode — a "noop" is a file that needs no changes * at all. + * + * `languageOverrides` maps stream_id → ISO code and lets the user correct a + * mislabeled track ("und" → "spa") before the analyzer groups and filters. + * When present, the override wins over `MediaStream.language` for every + * language-aware decision (keep/remove, dedup, ordering, is_noop). */ export function analyzeItem( item: Pick, streams: MediaStream[], config: AnalyzerConfig, + languageOverrides?: Map, ): PlanResult { const origLang = item.original_language ? normalizeLanguage(item.original_language) : null; const notes: string[] = []; const decisions: PlanResult["decisions"] = streams.map((s) => { - const action = decideAction(s, origLang, config.audioLanguages); + const lang = effectiveLanguage(s, languageOverrides); + const action = decideAction(s, lang, origLang, config.audioLanguages); return { stream_id: s.id, action, target_index: null, transcode_codec: null }; }); @@ -39,11 +57,11 @@ export function analyzeItem( // and alternate formats so we end up with exactly one audio stream per // language. The user doesn't need 2× English (main + director's // commentary) — one well-chosen track is enough. - deduplicateAudioByLanguage(streams, decisions, origLang); + deduplicateAudioByLanguage(streams, decisions, origLang, languageOverrides); const anyAudioRemoved = streams.some((s, i) => s.type === "Audio" && decisions[i].action === "remove"); - assignTargetOrder(streams, decisions, origLang, config.audioLanguages); + assignTargetOrder(streams, decisions, origLang, config.audioLanguages, languageOverrides); const audioOrderChanged = checkAudioOrderChanged(streams, decisions); @@ -82,9 +100,18 @@ export function analyzeItem( const firstKeptAudio = keptAudioSorted[0]; const defaultMismatch = !!firstKeptAudio && firstKeptAudio.is_default !== 1; const nonDefaultHasDefault = keptAudioSorted.slice(1).some((s) => s.is_default === 1); - const languageMismatch = keptAudioStreams.some( - (s) => s.language != null && s.language !== normalizeLanguage(s.language), - ); + // Non-canonical language tag in the file (e.g. "ger" instead of "deu", or + // "en" instead of "eng") or a user-provided custom_language that differs + // from the stream's raw tag — either one means ffmpeg would rewrite the + // metadata, so the file isn't already in the desired state. + const languageMismatch = keptAudioStreams.some((s) => { + const override = languageOverrides?.get(s.id); + if (override) { + const canonical = normalizeLanguage(override); + return s.language !== canonical; + } + return s.language != null && s.language !== normalizeLanguage(s.language); + }); const is_noop = !anyAudioRemoved && @@ -119,7 +146,10 @@ export function analyzeItem( const authoritativeOg = !!origLang && !!origLangSource && AUTHORITATIVE_ORIG_SOURCES.has(origLangSource) && item.needs_review === 0; - const keptAudioLanguages = keptAudioStreams.map((s) => (s.language ? normalizeLanguage(s.language) : null)); + const keptAudioLanguages = keptAudioStreams.map((s) => { + const lang = effectiveLanguage(s, languageOverrides); + return lang ? normalizeLanguage(lang) : null; + }); const ogPresent = !!origLang && keptAudioLanguages.includes(origLang); const everyKeptHasLanguage = keptAudioStreams.length > 0 && keptAudioLanguages.every((l) => l != null); @@ -191,6 +221,7 @@ function deduplicateAudioByLanguage( streams: MediaStream[], decisions: PlanResult["decisions"], origLang: string | null, + languageOverrides: Map | undefined, ): void { const decisionById = new Map(decisions.map((d) => [d.stream_id, d])); const keptAudio = streams.filter((s) => s.type === "Audio" && decisionById.get(s.id)?.action === "keep"); @@ -203,21 +234,23 @@ function deduplicateAudioByLanguage( } } - // 2. Group remaining kept-audio streams by normalized language and keep - // one winner per group. Streams without a language tag are handled - // specially: when OG language is unknown we keep them all (ambiguity - // means we can't safely drop anything); when OG is known they've - // already been kept by decideAction's "unknown language falls - // through" clause, so still dedupe within them. + // 2. Group remaining kept-audio streams by normalized effective language + // and keep one winner per group. Streams without a language (no raw + // tag and no override) are handled specially: when OG language is + // unknown we keep them all (ambiguity means we can't safely drop + // anything); when OG is known they've already been kept by + // decideAction's "unknown language falls through" clause, so still + // dedupe within them. const stillKept = keptAudio.filter((s) => decisionById.get(s.id)?.action === "keep"); const byLang = new Map(); const noLang: MediaStream[] = []; for (const s of stillKept) { - if (!s.language) { + const lang = effectiveLanguage(s, languageOverrides); + if (!lang) { noLang.push(s); continue; } - const key = normalizeLanguage(s.language); + const key = normalizeLanguage(lang); if (!byLang.has(key)) byLang.set(key, []); byLang.get(key)!.push(s); } @@ -245,7 +278,12 @@ function deduplicateAudioByLanguage( } } -function decideAction(stream: MediaStream, origLang: string | null, audioLanguages: string[]): "keep" | "remove" { +function decideAction( + stream: MediaStream, + effectiveLang: string | null, + origLang: string | null, + audioLanguages: string[], +): "keep" | "remove" { switch (stream.type) { case "Video": case "Data": @@ -254,8 +292,8 @@ function decideAction(stream: MediaStream, origLang: string | null, audioLanguag case "Audio": { if (!origLang) return "keep"; - if (!stream.language) return "keep"; - const normalized = normalizeLanguage(stream.language); + if (!effectiveLang) return "keep"; + const normalized = normalizeLanguage(effectiveLang); if (normalized === origLang) return "keep"; if (audioLanguages.includes(normalized)) return "keep"; return "remove"; @@ -280,6 +318,7 @@ export function assignTargetOrder( decisions: PlanResult["decisions"], origLang: string | null, audioLanguages: string[], + languageOverrides?: Map, ): void { const keptByType = new Map(); for (const s of allStreams) { @@ -292,8 +331,8 @@ export function assignTargetOrder( const audio = keptByType.get("Audio"); if (audio) { audio.sort((a, b) => { - const aRank = langRank(a.language, origLang, audioLanguages); - const bRank = langRank(b.language, origLang, audioLanguages); + const aRank = langRank(effectiveLanguage(a, languageOverrides), origLang, audioLanguages); + const bRank = langRank(effectiveLanguage(b, languageOverrides), origLang, audioLanguages); if (aRank !== bRank) return aRank - bRank; return a.stream_index - b.stream_index; }); diff --git a/server/services/ffmpeg.ts b/server/services/ffmpeg.ts index 7953a59..a698f00 100644 --- a/server/services/ffmpeg.ts +++ b/server/services/ffmpeg.ts @@ -150,7 +150,7 @@ function computeExtractionEntries(allStreams: MediaStream[], basePath: string): // ───────────────────────────────────────────────────────────────────────────── -const LANG_NAMES: Record = { +export const LANG_NAMES: Record = { eng: "English", deu: "German", spa: "Spanish", @@ -207,7 +207,7 @@ function formatChannels(n: number | null): string | null { return `${n}ch`; } -function trackTitle(stream: MediaStream): string | null { +function trackTitle(stream: MediaStream, customLanguage: string | null = null): string | null { if (stream.type === "Subtitle") { // Subtitles always get a clean language-based title so Jellyfin displays // "German", "English (Forced)", etc. regardless of the original file title. @@ -225,8 +225,11 @@ function trackTitle(stream: MediaStream): string | null { // the review UI to drop unwanted tracks before we get here, so by this // point every kept audio track is a primary track that deserves a clean // canonical label. If a user wants a different title, custom_title on - // the decision still wins (see buildStreamFlags). - const lang = stream.language ? normalizeLanguage(stream.language) : null; + // the decision still wins (see buildStreamFlags). A per-stream language + // override comes through as customLanguage so "UND → Spanish" renames + // flow through to the harmonized title too. + const rawLang = customLanguage ?? stream.language; + const lang = rawLang ? normalizeLanguage(rawLang) : null; const langPart = lang ? lang.toUpperCase() : null; const codecPart = stream.codec ? stream.codec.toUpperCase() : null; const channelsPart = formatChannels(stream.channels); @@ -278,10 +281,13 @@ function buildStreamFlags(kept: { stream: MediaStream; dec: StreamDecision }[]): audioKept.forEach((k, i) => { args.push(`-disposition:a:${i}`, i === 0 ? "default" : "0"); - const title = k.dec.custom_title ?? trackTitle(k.stream); + const title = k.dec.custom_title ?? trackTitle(k.stream, k.dec.custom_language); if (title) args.push(`-metadata:s:a:${i}`, `title=${shellQuote(title)}`); - const lang = k.stream.language ? normalizeLanguage(k.stream.language) : "und"; + // Per-stream language override wins over the raw file tag so the + // ffmpeg output carries the corrected language (e.g. "und" → "spa"). + const rawLang = k.dec.custom_language ?? k.stream.language; + const lang = rawLang ? normalizeLanguage(rawLang) : "und"; args.push(`-metadata:s:a:${i}`, `language=${lang}`); }); diff --git a/server/types.ts b/server/types.ts index 6f87730..c7546bd 100644 --- a/server/types.ts +++ b/server/types.ts @@ -76,6 +76,11 @@ export interface StreamDecision { action: "keep" | "remove"; target_index: number | null; custom_title: string | null; + /** Per-stream language override. When set, the analyzer and ffmpeg + * command builder both read this in preference to the raw + * media_streams.language. Lets the user correct an "und" or + * mislabeled audio track without going through Jellyfin. */ + custom_language: string | null; transcode_codec: string | null; } diff --git a/src/features/review/AudioDetailPage.tsx b/src/features/review/AudioDetailPage.tsx index ebaa783..10857dd 100644 --- a/src/features/review/AudioDetailPage.tsx +++ b/src/features/review/AudioDetailPage.tsx @@ -92,6 +92,11 @@ function StreamTable({ data, onUpdate }: StreamTableProps) { onUpdate(d); }; + const updateLanguage = async (streamId: number, language: string | null) => { + const d = await api.patch(`/api/review/${item.id}/stream/${streamId}/language`, { language }); + onUpdate(d); + }; + return (
@@ -128,7 +133,6 @@ function StreamTable({ data, onUpdate }: StreamTableProps) { const outputNum = outIdx.get(s.id); const lbl = effectiveLabel(s, dec); const origTitle = s.title; - const lang = langName(s.language); const isEditable = plan?.status === "pending" && isAudio; const rowBg = action === "keep" ? "bg-green-50" : "bg-red-50"; @@ -140,9 +144,20 @@ function StreamTable({ data, onUpdate }: StreamTableProps) {
{s.codec ?? "—"} {isAudio ? ( - <> - {lang} {s.language ? ({s.language}) : null} - + isEditable ? ( + updateLanguage(s.id, v)} + /> + ) : ( + <> + {langName(dec?.custom_language ?? s.language)}{" "} + {(dec?.custom_language ?? s.language) && ( + ({dec?.custom_language ?? s.language}) + )} + + ) ) : ( )} @@ -184,6 +199,53 @@ function StreamTable({ data, onUpdate }: StreamTableProps) { ); } +// Per-stream language override. Shows the effective language (override if +// set, otherwise the file's own tag) and lets the user pick any ISO code the +// app knows about, including "—" to clear the override. Commits on change so +// the backend can reanalyze and the table redraws with the new keep/remove +// decision, target order, and track title. A tiny ✎ hint marks tracks whose +// language has been user-overridden vs. inherited from the file. +function LanguageSelect({ + rawLanguage, + customLanguage, + onCommit, +}: { + rawLanguage: string | null; + customLanguage: string | null; + onCommit: (v: string | null) => void; +}) { + const effective = customLanguage ?? rawLanguage ?? ""; + const overridden = !!customLanguage && customLanguage !== rawLanguage; + return ( + + + {overridden && ( + + ✎ + + )} + + ); +} + function TitleInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) { const [localVal, setLocalVal] = useState(value); useEffect(() => { diff --git a/src/shared/lib/types.ts b/src/shared/lib/types.ts index e9f8355..050b0b3 100644 --- a/src/shared/lib/types.ts +++ b/src/shared/lib/types.ts @@ -56,6 +56,9 @@ export interface ReviewPlan { created_at: string; } +/** Per-stream language override. When set, both the analyzer and the + * ffmpeg command builder read `custom_language` in preference to + * `MediaStream.language`. Corrects "und"/mislabeled audio tracks. */ export interface StreamDecision { id: number; plan_id: number; @@ -63,6 +66,7 @@ export interface StreamDecision { action: "keep" | "remove"; target_index: number | null; custom_title: string | null; + custom_language: string | null; transcode_codec: string | null; }