// Step 16 — Pure transcript helpers (no network, no DB). // Data source: `yt-dlp --dump-single-json --skip-download` exposes // `subtitles` (manual) and `automatic_captions` (auto-generated). // Track entries are arrays of { url, ext, name } (or single objects). /** * @typedef {{ t: number, dur: number, text: string }} TranscriptLine */ const MAX_LINES_DEFAULT = Number(process.env.TRANSCRIPT_MAX_LINES || 5000); /** Normalize a language code for comparison (lowercase, `_` -> `-`). */ function normLang(code) { return String(code || '').trim().toLowerCase().replace(/_/g, '-'); } /** Pick the first usable track object from an array-or-single entry. */ function firstTrack(entry) { if (!entry) return null; const list = Array.isArray(entry) ? entry : [entry]; return list.find((t) => t && typeof t.url === 'string' && t.url) || null; } function trackExtOf(track) { const ext = String(track?.ext || '').toLowerCase(); if (ext) return ext; try { const u = new URL(String(track?.url || '')); const m = /\.([a-z0-9]+)(?:[?#]|$)/i.exec(u.pathname || ''); if (m) return m[1].toLowerCase(); } catch {} return ''; } /** Find a language key in `dict` matching `code` exactly or by prefix (`fr` -> `fr-*`). */ function findLangKey(dict, code) { const want = normLang(code); if (!want || !dict) return null; const keys = Object.keys(dict); const exact = keys.find((k) => normLang(k) === want); if (exact) return exact; // `fr-ca` requested, `fr` available (and vice versa): match on primary subtag const primary = want.split('-')[0]; return keys.find((k) => normLang(k).split('-')[0] === primary) || null; } /** * Select the best subtitle track. * Priority: manual exact > manual prefix/primary > auto exact > auto prefix/primary > first available. * @param {any} json yt-dlp dump-single-json * @param {string} [lang] * @returns {{ track: any|null, languages: string[], lang: string|null }} */ export function pickTrack(json, lang = 'fr') { const manual = (json && json.subtitles) || {}; const auto = (json && json.automatic_captions) || {}; const manualKeys = Object.keys(manual); const autoKeys = Object.keys(auto); const languages = Array.from(new Set([...manualKeys, ...autoKeys])); if (languages.length === 0) return { track: null, languages: [], lang: null }; const manualKey = findLangKey(manual, lang); if (manualKey) { const track = firstTrack(manual[manualKey]); if (track) return { track, languages, lang: manualKey }; } const autoKey = findLangKey(auto, lang); if (autoKey) { const track = firstTrack(auto[autoKey]); if (track) return { track, languages, lang: autoKey }; } // Fallback: first available track (manual preferred) for (const key of manualKeys) { const track = firstTrack(manual[key]); if (track) return { track, languages, lang: key }; } for (const key of autoKeys) { const track = firstTrack(auto[key]); if (track) return { track, languages, lang: key }; } return { track: null, languages, lang: null }; } /** * Order candidate tracks to try when the preferred one cannot be fetched. * YouTube auto-translated tracks (`tlang=xx`) are often rate-limited (HTTP 429 * or "Sorry" HTML pages) while the original language still works: always try * the requested language first, then the original (`en`), then everything * else (manual preferred). De-duplicated by URL. * @param {any} json yt-dlp dump-single-json * @param {string} [lang] * @returns {Array<{ track: any, lang: string|null }>} */ export function orderedTracks(json, lang = 'fr') { const manual = (json && json.subtitles) || {}; const auto = (json && json.automatic_captions) || {}; const seen = new Set(); const out = []; const push = (key, dict) => { if (key == null) return; const t = firstTrack(dict[key]); if (!t || !t.url || seen.has(String(t.url))) return; seen.add(String(t.url)); out.push({ track: t, lang: key }); }; const primary = pickTrack(json, lang); if (primary.track) { seen.add(String(primary.track.url)); out.push({ track: primary.track, lang: primary.lang }); } // Original language first fallback (avoids translated-track rate limits) const reqPrimary = String(lang || '').toLowerCase().split('-')[0]; for (const fallbackLang of ['en', 'fr']) { if (fallbackLang === reqPrimary) continue; const k = findLangKey(manual, fallbackLang); if (k) push(k, manual); const ka = findLangKey(auto, fallbackLang); if (ka) push(ka, auto); } for (const key of Object.keys(manual)) push(key, manual); for (const key of Object.keys(auto)) push(key, auto); return out; } /** * Collapse YouTube rolling-window auto-captions into readable cues. * * Auto-generated tracks repeat a sliding window on every event: * `Tac.` -> `Tac. David...` -> `David...` -> `David... bonjour.` * Without dedup the UI shows each partial 2-3x (see issue example). * * Strategy (order-preserving, lossless on words): * - drop exact consecutive duplicates (extend duration instead), * - if the current cue starts with / contains the previous one (or vice * versa), keep the longest and extend its duration, * - else strip the longest suffix->prefix word overlap and merge the * remainder into the previous cue when timestamps are close; * long cues are split to keep every line readable (<= ~220 chars). * @param {TranscriptLine[]} lines raw parsed cues * @returns {TranscriptLine[]} */ export function dedupeTranscriptLines(lines) { const list = Array.isArray(lines) ? lines : []; if (list.length < 2) return list.slice(); const MAX_CHARS = 220; const MAX_GAP = 3.0; const clean = (s) => String(s || '').replace(/\s+/g, ' ').trim(); const norm = (s) => clean(s).toLowerCase(); const endOf = (l) => Number(l?.t || 0) + Math.max(0, Number(l?.dur || 0)); /** Longest overlap (in words) where suffix(prev) == prefix(curr). */ const wordOverlap = (prevTokens, currTokens) => { const n = Math.min(prevTokens.length, currTokens.length); // Don't consume the whole current cue as "overlap". for (let k = n - 1; k >= 1; k--) { let ok = true; for (let i = 0; i < k; i++) { if (prevTokens[prevTokens.length - k + i] !== currTokens[i]) { ok = false; break; } } if (ok) return k; } return 0; }; const out = []; for (const raw of list) { const text = clean(raw?.text); if (!text) continue; let t = Number(raw?.t || 0); if (!Number.isFinite(t) || t < 0) t = 0; let dur = Number(raw?.dur || 0); if (!Number.isFinite(dur) || dur < 0) dur = 0; const cur = { t, dur, text }; const prev = out[out.length - 1]; if (!prev) { out.push(cur); continue; } const pn = norm(prev.text); const cn = norm(cur.text); if (!pn || !cn) continue; // 1. Exact consecutive duplicate -> extend, skip. if (pn === cn) { prev.dur = Math.max(prev.dur, Math.max(0, endOf(cur) - prev.t)); continue; } // 2. Inclusion / prefix extension (rolling window) -> keep longest. if (cn.startsWith(pn)) { prev.text = cur.text; prev.dur = Math.max(prev.dur, Math.max(0, endOf(cur) - prev.t)); continue; } if (pn.includes(cn) && cur.t - prev.t < MAX_GAP) { prev.dur = Math.max(prev.dur, Math.max(0, endOf(cur) - prev.t)); continue; } // 3. Partial sliding overlap -> merge remainder into previous cue. const prevTokens = pn.split(' ').filter(Boolean); const currTokens = cn.split(' ').filter(Boolean); const k = wordOverlap(prevTokens, currTokens); if (k >= 1 && cur.t - prev.t <= MAX_GAP) { const overlapChars = prevTokens.slice(prevTokens.length - k).join(' ').length; if (overlapChars >= 3) { const remainder = clean(cur.text.split(/\s+/).slice(k).join(' ')); if (!remainder) { prev.dur = Math.max(prev.dur, Math.max(0, endOf(cur) - prev.t)); continue; } if (prev.text.length + remainder.length + 1 <= MAX_CHARS) { prev.text = `${prev.text} ${remainder}`.trim(); prev.dur = Math.max(prev.dur, Math.max(0, endOf(cur) - prev.t)); continue; } // Previous cue already full: emit the new content as its own cue. out.push({ t: cur.t, dur: cur.dur, text: remainder }); continue; } } out.push(cur); } return out; } /** * Parse a YouTube `json3` timedtext payload into normalized lines. * @param {any} data parsed JSON * @returns {TranscriptLine[]} */ export function parseJson3(data) { const events = (data && data.events) || []; if (!Array.isArray(events)) return []; const out = []; for (const e of events) { if (!e || !Array.isArray(e.segs)) continue; const text = e.segs.map((s) => String(s?.utf8 ?? '')).join('').replace(/\n+/g, ' ').trim(); if (!text) continue; out.push({ t: Number(e.tStartMs || 0) / 1000, dur: Number(e.dDurationMs || 0) / 1000, text, }); } return capLines(dedupeTranscriptLines(out)); } /** Decode a handful of HTML entities found in VTT payloads. */ function decodeEntities(s) { return String(s || '') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/ /g, ' '); } /** Strip XML/HTML tags and decode entities to plain cue text. */ function xmlTextToPlain(s) { return decodeEntities(String(s || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()).trim(); } function parseTimeAttrToSeconds(v) { if (v == null || v === '') return null; const s = String(v).trim(); if (!s) return null; if (/^\d+(\.\d+)?$/.test(s)) return Number(s); const m = /(?:(\d+):)?([0-5]?\d):([0-5]\d)(?:\.(\d{1,3}))?/.exec(s); if (!m) return null; return Number(m[1] || 0) * 3600 + Number(m[2]) * 60 + Number(m[3]) + Number((m[4] || '0').padEnd(3, '0')) / 1000; } /** * Parse YouTube XML caption formats (`srv1`/`srv2`/`srv3` `
` cues, * `ttml` `
` cues) into normalized lines. * @param {string} text raw XML * @returns {TranscriptLine[]} */ export function parseXmlCaptions(text) { const out = []; const src = String(text || ''); if (!src || !/<(p|text|tt|transcript)\b/i.test(src)) return capLines(out); // YouTube srv*:
Hello world
]*?(?:t|start)="([^"]*)"[^>]*?(?:d|dur)="([^"]*)"[^>]*>([\s\S]*?)<\/p\s*>/gi; // Generic ttml:
...
const ttmlRe = /]*?begin="([^"]*)"[^>]*?end="([^"]*)"[^>]*>([\s\S]*?)<\/p\s*>/gi;
let m;
while ((m = srvRe.exec(src)) !== null) {
const start = Number(m[1]) / 1000;
const dur = Number(m[2]) / 1000;
const cleaned = xmlTextToPlain(m[3]);
if (!cleaned || !Number.isFinite(start)) continue;
out.push({ t: start, dur: Number.isFinite(dur) && dur > 0 ? dur : 0, text: cleaned });
}
if (out.length === 0) {
while ((m = ttmlRe.exec(src)) !== null) {
const start = parseTimeAttrToSeconds(m[1]);
const end = parseTimeAttrToSeconds(m[2]);
const cleaned = xmlTextToPlain(m[3]);
if (!cleaned || start == null || end == null) continue;
out.push({ t: start, dur: Math.max(0, end - start), text: cleaned });
}
}
// Plain