From 285566f0c65fe4e2cd61217bec085c7229625187 Mon Sep 17 00:00:00 2001 From: kuanpo Date: Wed, 24 Jun 2026 12:30:12 +0800 Subject: [PATCH] fix(youtube): auto-detect caption language instead of hardcoding English MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The yt-dlp provider requested `--sub-langs en.*` for every video, so a non-English channel was either skipped or, worse, distilled from YouTube's English auto-translation — losing the speaker's actual voice. Probe each video's tracks (`yt-dlp -J`) and pick the original language via a new pure, unit-tested `pickSubtitleLang()`: explicit override → original language (manual track first) → any human-authored track → the original auto-caption track. Machine-translated tracks (keyed `-`, e.g. `en-zh-TW`) are avoided. `MASK_YT_SUBLANGS` overrides with an explicit yt-dlp --sub-langs expression when a specific language is wanted. Co-Authored-By: Claude Opus 4.8 (1M context) --- ingest/youtube/index.ts | 99 +++++++++++++++++++++++++++++++++++++++-- test/youtube.test.ts | 45 +++++++++++++++++++ 2 files changed, 141 insertions(+), 3 deletions(-) diff --git a/ingest/youtube/index.ts b/ingest/youtube/index.ts index 49d29f4..b065da6 100644 --- a/ingest/youtube/index.ts +++ b/ingest/youtube/index.ts @@ -128,6 +128,75 @@ export async function ingestYoutube(opts: IngestYoutubeOptions): Promise-` (e.g. `en-zh-TW`) alongside the + * original track keyed by the bare source (`zh-TW`). The source therefore shows + * up as the shared "after the first hyphen" suffix across translations *and* as + * a standalone track — that standalone key is the original. + */ +function inferOriginalAuto(auto: string[]): string | null { + if (!auto.length) return null; + const counts = new Map(); + for (const code of auto) { + const i = code.indexOf("-"); + if (i > 0) { + const source = code.slice(i + 1); + counts.set(source, (counts.get(source) ?? 0) + 1); + } + } + let best: string | null = null; + let bestN = 0; + for (const [source, n] of counts) { + if (auto.includes(source) && n > bestN) { + best = source; + bestN = n; + } + } + // A lone auto track (e.g. an English-only video listing just `en`) is itself the original. + if (!best && auto.length === 1) return auto[0]!; + return best; +} + +/** + * Choose which subtitle track to download, preferring (in order): an explicit + * `prefer` override, the video's original language (manual track first), any + * human-authored track, then the original auto-caption track. Machine-translated + * auto tracks (keyed `-`, e.g. `en-zh-TW`) are avoided so a + * Chinese lecture is not distilled from its English auto-translation. Returns + * null when no track can be confidently chosen (the caller skips the video). + */ +export function pickSubtitleLang(tracks: SubtitleTracks, prefer?: string | null): string | null { + // 1. explicit override — passed through verbatim so comma lists / `zh.*` + // wildcards still reach yt-dlp's --sub-langs. + if (prefer && prefer.trim()) return prefer.trim(); + + const manual = tracks.manual ?? []; + const auto = tracks.auto ?? []; + const original = tracks.original?.trim() || null; + + // 2. original language — human-authored track first, then the auto one. + if (original && manual.includes(original)) return original; + if (original && auto.includes(original)) return original; + + // 3. any human-authored track (manual subs are never machine translations). + if (manual.length) return manual[0]!; + + // 4. the original auto-caption track (never a translation). + return inferOriginalAuto(auto); +} + // --- default provider (yt-dlp), exercised live; tests inject a fake --- import { mkdtemp, readdir, readFile, rm } from "node:fs/promises"; @@ -139,6 +208,20 @@ async function runYtDlp(args: string[]): Promise { return runCapture(["yt-dlp", ...args]); } +/** Probe a single video's available subtitle tracks + original language. */ +async function listSubtitleTracks(url: string): Promise { + try { + const info = JSON.parse(await runYtDlp(["-J", "--skip-download", "--no-warnings", url])); + return { + manual: Object.keys(info.subtitles ?? {}), + auto: Object.keys(info.automatic_captions ?? {}), + original: typeof info.language === "string" ? info.language : null, + }; + } catch { + return { manual: [], auto: [], original: null }; + } +} + export const defaultProvider: YoutubeProvider = { async listVideos(source, limit) { const out = await runYtDlp([ @@ -160,6 +243,14 @@ export const defaultProvider: YoutubeProvider = { }, async fetchTranscript(video) { + // Auto-detect the caption language: probe the video's tracks and pick the + // original (not a machine translation). MASK_YT_SUBLANGS overrides it with + // an explicit yt-dlp --sub-langs expression when the user wants a specific + // language (e.g. an English auto-translation). + const prefer = process.env.MASK_YT_SUBLANGS ?? null; + const lang = pickSubtitleLang(await listSubtitleTracks(video.url), prefer); + if (!lang) return null; + const dir = await mkdtemp(join(tmpdir(), "mask-yt-")); try { await runYtDlp([ @@ -167,7 +258,7 @@ export const defaultProvider: YoutubeProvider = { "--write-subs", "--write-auto-subs", "--sub-langs", - "en.*", + lang, "--sub-format", "vtt", "--no-warnings", @@ -175,10 +266,12 @@ export const defaultProvider: YoutubeProvider = { join(dir, "%(id)s.%(ext)s"), video.url, ]); - // Deterministic pick: prefer a plain `.en.vtt` over regional/auto variants. + // Deterministic pick: the chosen lang's file (strip a trailing `.*` + // wildcard from an override), else the first available .vtt. const files = (await readdir(dir)).filter((f) => f.endsWith(".vtt")).sort(); if (!files.length) return null; - const pick = files.find((f) => /\.en\.vtt$/.test(f)) ?? files[0]!; + const primary = lang.split(",")[0]!.replace(/\.\*$/, ""); + const pick = files.find((f) => f.endsWith(`.${primary}.vtt`)) ?? files[0]!; return await readFile(join(dir, pick), "utf8"); } finally { await rm(dir, { recursive: true, force: true }); diff --git a/test/youtube.test.ts b/test/youtube.test.ts index 1166726..47c4570 100644 --- a/test/youtube.test.ts +++ b/test/youtube.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "bun:test"; import { ingestYoutube, parseVtt, + pickSubtitleLang, isVideoUrl, videoIdFromUrl, type YoutubeProvider, @@ -78,6 +79,50 @@ if a < b > c then R&D expect(got).not.toContain("&"); }); +test("pickSubtitleLang prefers the original language, manual track first", () => { + // Chinese lecture: manual zh-TW alongside a wall of auto-translations. + expect( + pickSubtitleLang({ + manual: ["zh-TW"], + auto: ["en-zh-TW", "ja-zh-TW", "zh-Hant-zh-TW"], + original: "zh-TW", + }), + ).toBe("zh-TW"); +}); + +test("pickSubtitleLang picks the original auto track over machine translations", () => { + // No manual subs; the original auto track sits among `-` translations. + expect( + pickSubtitleLang({ + manual: [], + auto: ["zh-TW", "en-zh-TW", "ja-zh-TW", "fr-zh-TW"], + original: "zh-TW", + }), + ).toBe("zh-TW"); +}); + +test("pickSubtitleLang infers the original when yt-dlp gives no `language`", () => { + // Source language is the shared suffix of the translation keys, present as its own track. + expect( + pickSubtitleLang({ + manual: [], + auto: ["en-zh-TW", "fr-zh-TW", "zh-TW", "ja-zh-TW"], + original: null, + }), + ).toBe("zh-TW"); // never the English translation +}); + +test("pickSubtitleLang handles an English-original video and a lone auto track", () => { + expect(pickSubtitleLang({ manual: ["en"], auto: ["en", "es-en"], original: "en" })).toBe("en"); + expect(pickSubtitleLang({ manual: [], auto: ["en"], original: null })).toBe("en"); +}); + +test("pickSubtitleLang honors an explicit override and returns null when nothing fits", () => { + // Override wins even when an original track exists, and passes wildcards through verbatim. + expect(pickSubtitleLang({ manual: ["zh-TW"], auto: [], original: "zh-TW" }, "en.*")).toBe("en.*"); + expect(pickSubtitleLang({ manual: [], auto: [], original: null })).toBeNull(); +}); + test("ingestYoutube expands a channel into stable-id samples, skipping thin/unreachable", async () => { const videos: YoutubeVideo[] = [ { id: "v1", title: "Episode One", url: "https://www.youtube.com/watch?v=v1" },