à tous
+
+12
+00:00:02.500 --> 00:00:05.600
+Bienvenue
+dans cette vidéo
+`;
+ it('parses cues with identifiers, tags and multi-line content', () => {
+ const lines = parseVtt(vtt);
+ assert.equal(lines.length, 2);
+ assert.equal(lines[0].t, 0);
+ assert.equal(lines[0].dur, 2.5);
+ assert.equal(lines[0].text, 'Bonjour à tous');
+ assert.equal(lines[1].text, 'Bienvenue dans cette vidéo');
+ assert.ok(Math.abs(lines[1].t - 2.5) < 1e-9);
+ });
+
+ it('supports MM:SS.mmm timestamps and decodes entities', () => {
+ const lines = parseVtt('WEBVTT\n\n01:02.000 --> 01:04.500\nFish & chips\n');
+ assert.equal(lines.length, 1);
+ assert.equal(lines[0].t, 62);
+ assert.equal(lines[0].text, 'Fish & chips');
+ });
+
+ it('ignores garbage blocks', () => {
+ assert.deepEqual(parseVtt('WEBVTT\n\nNOTE nothing here\n'), []);
+ });
+});
+
+describe('orderedTracks + parseTrackText + XML captions', () => {
+ const auto = {
+ fr: [{ url: 'https://x/fr.json3', ext: 'json3' }],
+ en: [{ url: 'https://x/en.json3', ext: 'json3' }],
+ es: [{ url: 'https://x/es.vtt', ext: 'vtt' }],
+ };
+ it('tries requested lang first, then original en', () => {
+ const langs = orderedTracks({ subtitles: {}, automatic_captions: auto }, 'fr').map((o) => o.lang);
+ assert.deepEqual(langs, ['fr', 'en', 'es']);
+ });
+ it('dedupes identical track URLs', () => {
+ const dup = { subtitles: {}, automatic_captions: { en: [{ url: 'https://x/same', ext: 'vtt' }], fr: [{ url: 'https://x/same', ext: 'vtt' }] } };
+ assert.equal(orderedTracks(dup, 'fr').length, 1);
+ });
+ it('detects HTML error pages instead of JSON-parsing them', () => {
+ assert.equal(looksLikeHtmlError('Sorry'), true);
+ assert.equal(looksLikeHtmlError('WEBVTT\n\n00:00:00.000 --> 1'), false);
+ assert.deepEqual(parseTrackText(' nope', 'json3'), []);
+ });
+ it('parses srv/ttml XML captions', () => {
+ const srv = parseXmlCaptions('Hello
');
+ assert.equal(srv.length, 1);
+ assert.equal(srv[0].text, 'Hello');
+ const ttml = parseXmlCaptions('');
+ assert.equal(ttml.length, 1);
+ assert.equal(ttml[0].t, 1);
+ });
+ it('parseTrackText handles json3 and vtt payloads', () => {
+ const j = parseTrackText(JSON.stringify({ events: [{ tStartMs: 0, dDurationMs: 1000, segs: [{ utf8: 'Hi' }] }] }), 'json3');
+ assert.equal(j.length, 1);
+ const v = parseTrackText('WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nHello\n', 'vtt');
+ assert.equal(v.length, 1);
+ });
+});
+
+describe('capLines + normalizeTranscriptProvider', () => { it('truncates very long transcripts', () => {
+ const lines = Array.from({ length: 10 }, (_, i) => ({ t: i, dur: 1, text: `l${i}` }));
+ assert.equal(capLines(lines, 3).length, 3);
+ });
+ it('maps short and long provider ids', () => {
+ assert.equal(normalizeTranscriptProvider('yt'), 'youtube');
+ assert.equal(normalizeTranscriptProvider('youtube'), 'youtube');
+ assert.equal(normalizeTranscriptProvider('dm'), 'dailymotion');
+ assert.equal(normalizeTranscriptProvider('pt'), 'peertube');
+ assert.equal(normalizeTranscriptProvider('xx'), null);
+ });
+});
+
+describe('API contract', () => {
+ it('rejects unknown providers with 400 { available: false }', async () => {
+ const port = await new Promise((resolve) => {
+ const srv = net.createServer();
+ srv.listen(0, '127.0.0.1', () => {
+ const p = srv.address().port;
+ srv.close(() => resolve(p));
+ });
+ });
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-transcript-test-'));
+ const server = spawn(process.execPath, ['./server/index.mjs'], {
+ env: {
+ ...process.env,
+ PORT: String(port),
+ NEWTUBE_DB_FILE: path.join(tmpDir, 'transcript.db'),
+ JWT_SECRET: 'transcript-test-secret',
+ NODE_ENV: 'test',
+ },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ cwd: path.resolve(import.meta.dirname, '..', '..'),
+ });
+ const baseUrl = `http://127.0.0.1:${port}`;
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+ try {
+ let up = false;
+ const start = Date.now();
+ while (Date.now() - start < 20000) {
+ try {
+ const res = await fetch(`${baseUrl}/`);
+ if (res.status < 500) { up = true; break; }
+ } catch {}
+ await sleep(300);
+ }
+ assert.ok(up, 'isolated server starts');
+ const res = await fetch(`${baseUrl}/api/transcript/xx/abc123?lang=fr`);
+ assert.equal(res.status, 400);
+ const body = await res.json();
+ assert.equal(body.available, false);
+ assert.ok(typeof body.error === 'string');
+ } finally {
+ server.kill();
+ }
+ });
+});
diff --git a/server/tests/youtube-innertube.test.mjs b/server/tests/youtube-innertube.test.mjs
new file mode 100644
index 0000000..14e84ce
--- /dev/null
+++ b/server/tests/youtube-innertube.test.mjs
@@ -0,0 +1,131 @@
+// Step 18 — tests offline InnerTube (AUCUN réseau : mappers purs + modes).
+import assert from 'node:assert/strict';
+import { mapVideoNode, mapLockupView, mapNodes, mapCaptionTracks, parseViewsText, parseDurationLabel } from '../providers/youtube-innertube.mjs';
+import { pickTrack, orderedTracks } from '../transcript.mjs';
+import { getSearchMode, YT_SEARCH_MODES } from '../providers/youtube-common.mjs';
+
+function ok(msg) { console.log(`✓ ${msg}`); }
+
+// 1) parseViewsText FR/EN
+{
+ assert.equal(parseViewsText('81 973 vues'), 81973);
+ assert.equal(parseViewsText('1,2 M vues'), 1200000);
+ assert.equal(parseViewsText('57K'), 57000);
+ assert.equal(parseViewsText('3 k vues'), 3000);
+ assert.equal(parseViewsText(''), undefined);
+ assert.equal(parseViewsText(null), undefined);
+ ok('parseViewsText FR/EN');
+}
+
+// 1b) parseDurationLabel (a11y)
+{
+ assert.equal(parseDurationLabel("What's new in Angular 18 2 minutes, 27 seconds"), 147);
+ assert.equal(parseDurationLabel('Live 1 heure, 5 minutes'), 3900);
+ assert.equal(parseDurationLabel('nonsense'), undefined);
+ ok('parseDurationLabel');
+}
+
+// 2) mapVideoNode type Video (forme réelle youtubei.js v18)
+{
+ const m = mapVideoNode({
+ type: 'Video', id: 'abc123',
+ title: { text: 'Demo' },
+ duration: { text: '15:22', seconds: 922 },
+ thumbnails: [{ url: 'http://t/low.jpg' }, { url: 'http://t/high.jpg' }],
+ author: { id: 'UC999', name: 'Chaine' },
+ published: { text: 'il y a 2 ans' },
+ view_count: { text: '81 973 vues' },
+ badges: [{ label: 'Sous-titres' }],
+ is_live: false,
+ });
+ assert.equal(m.id, 'abc123');
+ assert.equal(m.url, 'https://www.youtube.com/watch?v=abc123');
+ assert.equal(m.thumbnail, 'http://t/high.jpg');
+ assert.equal(m.duration, 922);
+ assert.equal(m.views, 81973);
+ assert.equal(m.channelId, 'UC999');
+ assert.deepEqual(m.badges, ['Sous-titres']);
+ assert.equal(m.isShort, undefined);
+ ok('mapVideoNode Video');
+}
+
+// 3) Shorts/Reel -> isShort ; live -> isLive ; bruit filtré
+{
+ const s = mapVideoNode({ type: 'ShortsLockupView', id: 'sh1', title: { text: 'Short' }, thumbnails: [{ url: 'u' }] });
+ assert.equal(s.isShort, true);
+ const l = mapVideoNode({ type: 'Video', id: 'lv1', title: { text: 'Live' }, thumbnails: [{ url: 'u' }], is_live: true });
+ assert.equal(l.isLive, true);
+ assert.equal(mapVideoNode({ type: 'Playlist', id: 'PL1', title: { text: 'PL' } }), null);
+ assert.equal(mapVideoNode({ type: 'Channel', id: 'UC1', title: { text: 'C' } }), null);
+ assert.equal(mapVideoNode(null), null);
+ assert.equal(mapVideoNode({ type: 'Video', title: { text: 'no id' } }), null);
+ ok('mapVideoNode shorts/live/filtrage');
+}
+
+// 4) mapNodes déduplique
+{
+ const mk = (id) => ({ type: 'Video', id, title: { text: id }, thumbnails: [{ url: 'u' }] });
+ const out = mapNodes([mk('a'), mk('a'), mk('b'), null]);
+ assert.deepEqual(out.map((x) => x.id), ['a', 'b']);
+ ok('mapNodes dédup');
+}
+
+// 4b) mapLockupView (renderer moderne du watch-next façon SmartTube)
+{
+ const m = mapLockupView({
+ type: 'LockupView', content_id: '0wIL6d5TxGc', content_type: 'VIDEO',
+ content_image: { image: [{ url: 'http://t/small.jpg', width: 168 }, { url: 'http://t/big.jpg', width: 336 }] },
+ metadata: {
+ title: { text: "What's new" },
+ metadata: { metadata_rows: [
+ { metadata_parts: [{ text: { text: 'Rainer Hahnekamp' } }] },
+ { metadata_parts: [{ text: { text: '57K' } }] },
+ ] },
+ image: { a11y_label: 'Go to channel Rainer Hahnekamp' },
+ },
+ renderer_context: { accessibility_context: { label: "What's new 2 minutes, 27 seconds" } },
+ });
+ assert.equal(m.id, '0wIL6d5TxGc');
+ assert.equal(m.url, 'https://www.youtube.com/watch?v=0wIL6d5TxGc');
+ assert.equal(m.thumbnail, 'http://t/big.jpg');
+ assert.equal(m.uploaderName, 'Rainer Hahnekamp');
+ assert.equal(m.views, 57000);
+ assert.equal(m.duration, 147);
+ assert.equal(mapVideoNode({ type: 'LockupView', content_id: 'x', content_type: 'VIDEO', metadata: { title: { text: 'T' } } }).id, 'x');
+ assert.equal(mapLockupView({ type: 'LockupView', content_type: 'PLAYLIST', content_id: 'PL1', metadata: { title: { text: 'PL' } } }), null);
+ ok('mapLockupView watch-next');
+}
+
+// 5) modes innertube connus
+{
+ assert.ok(YT_SEARCH_MODES.includes('innertube-first'));
+ assert.ok(YT_SEARCH_MODES.includes('innertube-only'));
+ const prev = process.env.YT_SEARCH_MODE;
+ process.env.YT_SEARCH_MODE = 'innertube-only';
+ assert.equal(getSearchMode(), 'innertube-only');
+ if (prev === undefined) delete process.env.YT_SEARCH_MODE; else process.env.YT_SEARCH_MODE = prev;
+ ok('modes innertube-first/only');
+}
+
+// 6) mapCaptionTracks : forme InnerTube -> pseudo dump yt-dlp, interop pickTrack
+{
+ const cap = mapCaptionTracks([
+ { base_url: 'https://x/t?caps=asr&x=1', language_code: 'en', kind: 'asr', name: { text: 'English (auto-generated)' } },
+ { base_url: 'https://x/t?caps=m&x=2', language_code: 'fr', name: { text: 'Français' } },
+ { base_url: '', language_code: 'de', name: 'Deutsch' },
+ ]);
+ assert.deepEqual(cap.languages.sort(), ['en', 'fr']);
+ assert.equal(cap.trackCount, 2);
+ assert.match(cap.automatic_captions.en[0].url, /fmt=json3/);
+ assert.equal(cap.automatic_captions.en[0].ext, 'json3');
+ assert.equal(cap.subtitles.fr[0].name, 'Français');
+ // Interop : le pseudo dump passe dans pickTrack/orderedTracks sans modification
+ const picked = pickTrack({ subtitles: cap.subtitles, automatic_captions: cap.automatic_captions }, 'fr');
+ assert.equal(picked.lang, 'fr');
+ assert.ok(picked.track.url.includes('fmt=json3'));
+ const ordered = orderedTracks({ subtitles: cap.subtitles, automatic_captions: cap.automatic_captions }, 'de');
+ assert.ok(ordered.length >= 2);
+ ok('mapCaptionTracks + interop pickTrack/orderedTracks');
+}
+
+console.log('youtube-innertube tests passed');
diff --git a/server/tests/youtube-scrape.test.mjs b/server/tests/youtube-scrape.test.mjs
new file mode 100644
index 0000000..1a45b6c
--- /dev/null
+++ b/server/tests/youtube-scrape.test.mjs
@@ -0,0 +1,98 @@
+// Step 17 — tests offline : parsers scrape + dispatcher (AUCUN réseau, AUCUNE clé).
+import assert from 'node:assert/strict';
+import { mapFlatEntry, parseFlatPlaylistJson, classifyScrapeError } from '../providers/youtube-scrape.mjs';
+import { getSearchMode, hashSearchKey, buildYtDlpExtraArgs, resolveYtDlpBin, resetYtDlpBinCache } from '../providers/youtube-common.mjs';
+
+function ok(msg) { console.log(`✓ ${msg}`); }
+
+// 1) mapFlatEntry
+{
+ const s = mapFlatEntry({
+ id: 'dQw4w9WgXcQ', title: 'Demo', channel_id: 'UC123', channel: 'Chaine',
+ duration: 212, view_count: 1000, timestamp: 1700000000,
+ thumbnails: [{ url: 'http://t/low.jpg', height: 90 }, { url: 'http://t/high.jpg', height: 720 }],
+ });
+ assert.equal(s.id, 'dQw4w9WgXcQ');
+ assert.equal(s.url, 'https://www.youtube.com/watch?v=dQw4w9WgXcQ');
+ assert.equal(s.thumbnail, 'http://t/high.jpg');
+ assert.equal(s.duration, 212);
+ ok('mapFlatEntry maps flat entry to Suggestion');
+}
+assert.equal(mapFlatEntry(null), null);
+assert.equal(mapFlatEntry({}), null);
+ok('mapFlatEntry null-safe');
+
+// 2) parseFlatPlaylistJson : objet + NDJSON + vide
+{
+ const raw = JSON.stringify({ entries: [{ id: 'a', title: 'A' }, { id: '', title: 'x' }, null] });
+ assert.equal(parseFlatPlaylistJson(raw).length, 1);
+ ok('parseFlatPlaylistJson objet .entries');
+}
+{
+ const raw = `{"id":"a","title":"A"}\n{"id":"b","title":"B"}\nnot-json\n`;
+ assert.equal(parseFlatPlaylistJson(raw).length, 2);
+ ok('parseFlatPlaylistJson NDJSON tolerant');
+}
+assert.deepEqual(parseFlatPlaylistJson(''), []);
+assert.deepEqual(parseFlatPlaylistJson('{{{'), []);
+ok('parseFlatPlaylistJson vide/invalide -> []');
+
+// 3) getSearchMode défaut innertube-first + normalisation
+{
+ const prev = process.env.YT_SEARCH_MODE;
+ delete process.env.YT_SEARCH_MODE;
+ assert.equal(getSearchMode(), 'innertube-first');
+ process.env.YT_SEARCH_MODE = 'API-FIRST';
+ assert.equal(getSearchMode(), 'api-first');
+ process.env.YT_SEARCH_MODE = 'scrape-first';
+ assert.equal(getSearchMode(), 'scrape-first');
+ process.env.YT_SEARCH_MODE = 'nonsense';
+ assert.equal(getSearchMode(), 'innertube-first');
+ if (prev === undefined) delete process.env.YT_SEARCH_MODE; else process.env.YT_SEARCH_MODE = prev;
+ ok('getSearchMode défaut + normalisation');
+}
+
+// 4) hash stable + extra args jamais de secret en clair autre que proxy path
+{
+ assert.equal(hashSearchKey('a'), hashSearchKey('a'));
+ assert.notEqual(hashSearchKey('a'), hashSearchKey('b'));
+ const args = buildYtDlpExtraArgs();
+ assert.ok(Array.isArray(args));
+ ok('hashSearchKey + buildYtDlpExtraArgs');
+}
+
+// 5) dispatcher scrape-only sans clé : ne doit PAS exiger YOUTUBE_API_KEY au import,
+// et searchViaScrape vide sur query courte
+{
+ const { searchViaScrape } = await import('../providers/youtube-scrape.mjs');
+ assert.deepEqual(await searchViaScrape('a'), []);
+ ok('searchViaScrape query courte -> [] sans réseau');
+}
+
+// 6) classifyScrapeError : ENOENT -> yt_scrape_no_binary (message actionnable, jamais de spawn brut en UI)
+{
+ const e = classifyScrapeError(Object.assign(new Error('spawn yt-dlp ENOENT'), { code: 'ENOENT', errno: 'ENOENT' }));
+ assert.equal(e.code, 'yt_scrape_no_binary');
+ assert.equal(e.ytStatus, 503);
+ assert.match(e.message, /YT_DLP_PATH/);
+ const passthrough = classifyScrapeError(Object.assign(new Error('x'), { code: 'yt_scrape_no_binary' }));
+ assert.equal(passthrough.code, 'yt_scrape_no_binary');
+ ok('classifyScrapeError ENOENT -> yt_scrape_no_binary');
+}
+
+// 7) resolveYtDlpBin : YT_DLP_PATH inexistant -> fallback PATH/bundled (jamais de throw si un binaire existe)
+{
+ resetYtDlpBinCache();
+ const prev = process.env.YT_DLP_PATH;
+ process.env.YT_DLP_PATH = '/nonexistent/yt-dlp';
+ try {
+ const bin = await resolveYtDlpBin();
+ assert.ok(typeof bin === 'string' && bin.length > 0);
+ ok(`resolveYtDlpBin fallback -> ${bin}`);
+ } finally {
+ resetYtDlpBinCache();
+ if (prev === undefined) delete process.env.YT_DLP_PATH; else process.env.YT_DLP_PATH = prev;
+ }
+}
+
+console.log('youtube-scrape tests passed');
diff --git a/server/transcript.mjs b/server/transcript.mjs
new file mode 100644
index 0000000..8a6c411
--- /dev/null
+++ b/server/transcript.mjs
@@ -0,0 +1,309 @@
+// 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;
+}
+
+/**
+ * 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(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
+ const srvRe = /]*?(?: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 Fallback variant
+ if (out.length === 0) {
+ const textRe = /]*?start="([^"]*)"[^>]*?(?:dur="([^"]*)")?[^>]*>([\s\S]*?)<\/text\s*>/gi;
+ while ((m = textRe.exec(src)) !== null) {
+ const start = Number(m[1]);
+ const dur = Number(m[2] || 0);
+ const cleaned = xmlTextToPlain(m[3]);
+ if (!cleaned || !Number.isFinite(start)) continue;
+ out.push({ t: start, dur: dur > 0 ? dur : 0, text: cleaned });
+ }
+ }
+ return capLines(out);
+}
+
+/** Detect YouTube "Sorry / automated queries" style HTML error pages. */
+export function looksLikeHtmlError(text) {
+ const s = String(text || '').trimStart().slice(0, 512).toLowerCase();
+ return s.startsWith('`, ``, timestamps).
+ * @param {string} text raw VTT
+ * @returns {TranscriptLine[]}
+ */
+export function parseVtt(text) {
+ if (looksLikeHtmlError(text)) return [];
+ const out = [];
+ const blocks = String(text || '').replace(/\r\n/g, '\n').split(/\n{2,}/);
+ const tsRe = /(?:(\d{2,}):)?([0-5]?\d):([0-5]\d)\.(\d{3})\s*-->\s*(?:(\d{2,}):)?([0-5]?\d):([0-5]\d)\.(\d{3})/;
+ for (const block of blocks) {
+ const lines = String(block || '').split('\n');
+ const timingIdx = lines.findIndex((l) => l.includes('-->'));
+ if (timingIdx === -1) continue;
+ const m = tsRe.exec(lines[timingIdx]);
+ if (!m) continue;
+ const start = vttTimestampToSeconds(m[1] || '0', m[2], m[3], m[4]);
+ const end = vttTimestampToSeconds(m[5] || '0', m[6], m[7], m[8]);
+ const content = lines
+ .slice(timingIdx + 1)
+ .join(' ')
+ // strip inline VTT tags (, , <00:00:01.000>, ...)
+ .replace(/<[^>]*>/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+ const decoded = decodeEntities(content).trim();
+ // Skip WEBVTT header leftovers and empty cues
+ if (!decoded || /^WEBVTT/i.test(decoded)) continue;
+ out.push({ t: start, dur: Math.max(0, end - start), text: decoded });
+ }
+ return capLines(out);
+}
+
+/** Bound response volume for very long transcripts (Phase 1 decision: truncate). */
+export function capLines(lines, max = MAX_LINES_DEFAULT) {
+ const list = Array.isArray(lines) ? lines : [];
+ const n = Math.max(1, Number(max || MAX_LINES_DEFAULT));
+ return list.length > n ? list.slice(0, n) : list;
+}
+
+/**
+ * Parse a downloaded timedtext payload regardless of its format.
+ * Tries `json3`, then `vtt`, then XML captions (`srv*`/`ttml`).
+ * HTML error pages always yield `[]`.
+ */
+export function parseTrackText(text, ext = '') {
+ if (looksLikeHtmlError(text)) return [];
+ const e = String(ext || '').toLowerCase();
+ const asJson3 = () => {
+ try { return parseJson3(JSON.parse(String(text))); } catch { return []; }
+ };
+ if (e === 'json3') {
+ const lines = asJson3();
+ if (lines.length) return lines;
+ const vtt = parseVtt(text);
+ if (vtt.length) return vtt;
+ return parseXmlCaptions(text);
+ }
+ const vtt = parseVtt(text);
+ if (vtt.length) return vtt;
+ const j = asJson3();
+ if (j.length) return j;
+ return parseXmlCaptions(text);
+}
+
+/** Map short (`yt`) and long (`youtube`) provider ids to `providerUrlFrom()` names. */
+export function normalizeTranscriptProvider(provider) {
+ const p = String(provider || '').trim().toLowerCase();
+ const map = {
+ yt: 'youtube', youtube: 'youtube',
+ dm: 'dailymotion', dailymotion: 'dailymotion',
+ tw: 'twitch', twitch: 'twitch',
+ pt: 'peertube', peertube: 'peertube',
+ od: 'odysee', odysee: 'odysee',
+ ru: 'rumble', rumble: 'rumble',
+ };
+ return map[p] || null;
+}
+
+export { trackExtOf as transcriptTrackExt, firstTrack as transcriptFirstTrack };
diff --git a/src/app/search/suggest.service.ts b/src/app/search/suggest.service.ts
new file mode 100644
index 0000000..0e08de9
--- /dev/null
+++ b/src/app/search/suggest.service.ts
@@ -0,0 +1,72 @@
+import { Injectable, inject } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { Observable, of } from 'rxjs';
+import { tap } from 'rxjs/operators';
+import { dedupeSort } from './suggest.util';
+
+export interface SuggestResponse {
+ q: string;
+ groups: Record;
+}
+
+interface SuggestCacheEntry {
+ t: number;
+ data: SuggestResponse;
+}
+
+/**
+ * Step 15 — Query typeahead service.
+ *
+ * Contract: no network while `q.trim().length < 2`. Callers debounce
+ * (250-300ms) + `switchMap` (in-flight cancellation). Responses are cached
+ * 5 minutes, deduped and sorted per provider group.
+ */
+@Injectable({ providedIn: 'root' })
+export class SuggestService {
+ private http = inject(HttpClient);
+ private cache = new Map();
+ private cacheTtlMs = 5 * 60 * 1000;
+
+ private apiBase(): string {
+ try {
+ const port = window?.location?.port || '';
+ return port && port !== '4000' ? '/proxy/api' : '/api';
+ } catch {
+ return '/api';
+ }
+ }
+
+ /** Single suggest round-trip (no debounce here — the caller owns it). */
+ suggestOnce(q: string, providers?: string[] | 'all', limit = 10): Observable {
+ const query = String(q ?? '').trim();
+ if (query.length < 2) return of({ q: query, groups: {} });
+ const prov = Array.isArray(providers) && providers.length > 0 ? [...providers].sort().join(',') : 'all';
+ const key = `${prov}|${query.toLowerCase()}|${limit}`;
+ const now = Date.now();
+ const hit = this.cache.get(key);
+ if (hit && now - hit.t < this.cacheTtlMs) return of(hit.data);
+
+ const params = new URLSearchParams({ q: query, limit: String(limit) });
+ if (Array.isArray(providers) && providers.length > 0) params.set('providers', providers.join(','));
+ return this.http.get(`${this.apiBase()}/search/suggest?${params.toString()}`).pipe(
+ tap((res) => {
+ try {
+ const groups: Record = {};
+ for (const [pid, list] of Object.entries(res?.groups || {})) {
+ groups[pid] = dedupeSort(Array.isArray(list) ? list : [], query).slice(0, limit);
+ }
+ const clean: SuggestResponse = { q: res?.q ?? query, groups };
+ if (this.cache.size > 200) this.cache.clear();
+ this.cache.set(key, { t: Date.now(), data: clean });
+ // Mutate in place so subscribers see the cleaned payload
+ res.q = clean.q;
+ res.groups = clean.groups;
+ } catch {}
+ }),
+ );
+ }
+
+ clearCache(): void {
+ this.cache.clear();
+ }
+}
diff --git a/src/app/search/suggest.spec.ts b/src/app/search/suggest.spec.ts
new file mode 100644
index 0000000..2783e80
--- /dev/null
+++ b/src/app/search/suggest.spec.ts
@@ -0,0 +1,103 @@
+import 'zone.js/node';
+import '@angular/compiler'; // JIT needed: decorators are evaluated at import time under ts-node
+import { firstValueFrom, of } from 'rxjs';
+import { Injector, runInInjectionContext } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { SuggestService } from './suggest.service';
+import { dedupeSort, mergeGroups, highlightParts } from './suggest.util';
+
+/**
+ * Step 15 — Unit tests for query typeahead (parsing / dedup / min-length guard).
+ *
+ * Same harness as Step 11: ts-node isolated transpilation, manual injection
+ * with a minimal HttpClient fake returning real Observables.
+ * Run with: npm run test:suggest
+ */
+
+function assertEqual(actual: unknown, expected: unknown, message: string): void {
+ const a = JSON.stringify(actual);
+ const b = JSON.stringify(expected);
+ if (a !== b) {
+ throw new Error(`${message} (expected ${b}, got ${a})`);
+ }
+}
+
+function assert(condition: unknown, message: string): void {
+ if (!condition) {
+ throw new Error(message);
+ }
+}
+
+function logOk(msg: string): void {
+ console.log(`✓ ${msg}`);
+}
+
+(async () => {
+ // --- Pure helpers: dedupe + sort ---
+ assertEqual(
+ dedupeSort(['Tutoriel Angular', 'tutoriel angular ', ' ', 'Tutoriel Android'], 'tutoriel an'),
+ ['Tutoriel Android', 'Tutoriel Angular'],
+ 'dedupeSort dedupes case-insensitively and sorts prefix-first then alpha',
+ );
+ logOk('dedupeSort dedup + tri');
+
+ assertEqual(dedupeSort(['b', 'a', 'c']), ['a', 'b', 'c'], 'dedupeSort sorts alphabetically without query');
+ logOk('dedupeSort alpha fallback');
+
+ // --- Pure helpers: merge groups ---
+ const merged = mergeGroups({ dm: ['Tutoriel android'], yt: ['Tutoriel Angular', 'tutoriel android'], xx: ['zz'] });
+ assertEqual(
+ merged,
+ [
+ { text: 'Tutoriel Angular', provider: 'yt' },
+ { text: 'tutoriel android', provider: 'yt' },
+ { text: 'zz', provider: 'xx' },
+ ],
+ 'mergeGroups respects registry order, dedupes across providers, unknowns last',
+ );
+ logOk('mergeGroups order + dedup');
+
+ // --- Pure helpers: highlight ---
+ assertEqual(
+ highlightParts('Tutoriel Angular', 'toriel an'),
+ { pre: 'Tu', match: 'toriel An', post: 'gular' },
+ 'highlightParts splits on first case-insensitive occurrence',
+ );
+ assertEqual(
+ highlightParts('Bonjour', 'zzz'),
+ { pre: 'Bonjour', match: '', post: '' },
+ 'highlightParts without occurrence returns plain text',
+ );
+ logOk('highlightParts');
+
+ // --- Service: min length guard (no network below 2 chars) ---
+ let httpCalls = 0;
+ const httpFake = {
+ get: (url: string) => {
+ httpCalls += 1;
+ assert(url.includes('/search/suggest'), `suggest hits /api/search/suggest (got ${url})`);
+ return of({ q: 'angular', groups: { yt: ['angular tutorial', 'Angular'], dm: ['angular tutorial'] } });
+ },
+ };
+ const injector = Injector.create([{ provide: HttpClient, useValue: httpFake }]);
+ const svc: SuggestService = runInInjectionContext(injector, () => new SuggestService());
+
+ const short = await firstValueFrom(svc.suggestOnce('a'));
+ assertEqual(short, { q: 'a', groups: {} }, 'q < 2 chars returns empty without network');
+ assertEqual(httpCalls, 0, 'no HTTP call for short queries');
+ logOk('suggestOnce min-length guard (no network)');
+
+ // --- Service: dedup + cache ---
+ const first = await firstValueFrom(svc.suggestOnce('angular', ['yt', 'dm']));
+ assertEqual(httpCalls, 1, 'one HTTP call for a fresh query');
+ assertEqual(first.groups['yt'], ['Angular', 'angular tutorial'], 'per-provider groups deduped + sorted');
+ const second = await firstValueFrom(svc.suggestOnce('angular', ['dm', 'yt']));
+ assertEqual(httpCalls, 1, 'second identical query served from cache (provider order-insensitive)');
+ assert(second === first || JSON.stringify(second) === JSON.stringify(first), 'cached payload matches');
+ logOk('suggestOnce dedup + cache 5 min');
+
+ console.log('\nAll suggest tests passed.');
+})().catch((e) => {
+ console.error(e instanceof Error ? e.stack : String(e));
+ process.exit(1);
+});
diff --git a/src/app/search/suggest.util.ts b/src/app/search/suggest.util.ts
new file mode 100644
index 0000000..ff24ef0
--- /dev/null
+++ b/src/app/search/suggest.util.ts
@@ -0,0 +1,72 @@
+/**
+ * Step 15 — Pure helpers for query typeahead (testable without Angular).
+ */
+
+/** Normalize a suggestion string for dedup comparisons. */
+export function normSuggest(s: string): string {
+ return String(s ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
+}
+
+/**
+ * Deduplicate (case-insensitive, first occurrence wins) then sort:
+ * items starting with `q` come first, alphabetical within each group.
+ */
+export function dedupeSort(items: string[], q = ''): string[] {
+ const seen = new Set();
+ const clean: string[] = [];
+ for (const raw of items || []) {
+ const t = String(raw ?? '').trim();
+ if (!t) continue;
+ const k = normSuggest(t);
+ if (seen.has(k)) continue;
+ seen.add(k);
+ clean.push(t);
+ }
+ const nq = normSuggest(q);
+ clean.sort((a, b) => {
+ const na = normSuggest(a);
+ const nb = normSuggest(b);
+ const pa = nq ? (na.startsWith(nq) ? 0 : 1) : 0;
+ const pb = nq ? (nb.startsWith(nq) ? 0 : 1) : 0;
+ if (pa !== pb) return pa - pb;
+ return na < nb ? -1 : na > nb ? 1 : 0;
+ });
+ return clean;
+}
+
+/**
+ * Flatten provider groups into a single ordered list (registry order),
+ * deduped case-insensitively. Unknown provider keys go last (insertion order).
+ */
+export function mergeGroups(
+ groups: Record | null | undefined,
+ order: string[] = ['yt', 'dm', 'tw', 'pt', 'od', 'ru'],
+): Array<{ text: string; provider: string }> {
+ const out: Array<{ text: string; provider: string }> = [];
+ const seen = new Set();
+ const push = (pid: string, list: string[] | undefined) => {
+ for (const raw of list || []) {
+ const t = String(raw ?? '').trim();
+ if (!t) continue;
+ const k = normSuggest(t);
+ if (seen.has(k)) continue;
+ seen.add(k);
+ out.push({ text: t, provider: pid });
+ }
+ };
+ for (const pid of order) push(pid, groups?.[pid]);
+ for (const pid of Object.keys(groups || {})) {
+ if (!order.includes(pid)) push(pid, (groups as Record)[pid]);
+ }
+ return out;
+}
+
+/** Split `text` on the first case-insensitive occurrence of `q` for highlighting. */
+export function highlightParts(text: string, q: string): { pre: string; match: string; post: string } {
+ const t = String(text ?? '');
+ const needle = String(q ?? '').trim();
+ if (!needle) return { pre: t, match: '', post: '' };
+ const idx = t.toLowerCase().indexOf(needle.toLowerCase());
+ if (idx === -1) return { pre: t, match: '', post: '' };
+ return { pre: t.slice(0, idx), match: t.slice(idx, idx + needle.length), post: t.slice(idx + needle.length) };
+}
diff --git a/src/components/watch/watch.component.ts b/src/components/watch/watch.component.ts
index bcbabde..5c72a97 100644
--- a/src/components/watch/watch.component.ts
+++ b/src/components/watch/watch.component.ts
@@ -23,6 +23,7 @@ import { formatAbsoluteFr, formatNumberFr } from '../../utils/date.util';
import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component';
import { ChannelIdentityComponent } from '../../app/shared/components/channel-identity/channel-identity.component';
import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
+import { UserService } from '../../services/user.service';
@Component({
selector: 'app-watch',
@@ -44,6 +45,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
private auth = inject(AuthService);
private http = inject(HttpClient);
private iframeProgress = inject(IframeProgressService);
+ private users = inject(UserService);
private channels = inject(ChannelsService);
private subs = inject(SubscriptionsService);
private routeSubscription!: Subscription;
@@ -197,6 +199,8 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
// Build a direct Twitch URL as a fallback open-in-new-tab action
twitchOpenUrl(): string | null {
if (this.provider() !== 'twitch') return null;
+ const clip = this.twitchClip();
+ if (clip) return `https://clips.twitch.tv/${encodeURIComponent(clip)}`;
const ch = this.twitchChannel();
const id = this.videoId();
const cur = this.video();
@@ -286,6 +290,20 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
liked = signal(false);
likeBusy = signal(false);
+ // --- Transcript state (Step 16, Phase 1: display + language selector, no seek) ---
+ transcriptOpen = signal(false);
+ transcriptLoading = signal(false);
+ transcriptAvailable = signal(false);
+ transcriptError = signal(null);
+ // Machine-readable unavailability reason from the API: 'no_subtitles' |
+ // 'provider_unsupported' | 'temporarily_unavailable' | null.
+ transcriptReason = signal(null);
+ // True when retrying may succeed (transient YouTube rate-limit/fetch failure).
+ transcriptRetryable = signal(false);
+ transcriptLanguages = signal([]);
+ transcriptLines = signal>([]);
+ selectedTranscriptLang = signal('fr');
+
// --- Watch history tracking for native player ---
watchHistoryId = signal(null);
startPositionSeconds = signal(null);
@@ -296,6 +314,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
private providerSel = signal('');
provider = computed(() => this.providerSel() || this.instances.selectedProvider());
private twitchChannel = signal(null);
+ private twitchClip = signal(null);
// Backend-provided embed URL for providers that need it (e.g., Rumble)
private rumbleEmbed = signal(null);
@@ -303,9 +322,10 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
const id = this.videoId();
const p = this.provider();
const ch = this.twitchChannel();
+ const clip = this.twitchClip();
const slug = this.odyseeSlug();
const start = Math.max(0, this.startPositionSeconds() || 0);
- if (!id && !(p === 'twitch' && ch) && !(p === 'odysee' && slug)) return null;
+ if (!id && !(p === 'twitch' && (ch || clip)) && !(p === 'odysee' && slug)) return null;
try {
const host = (location && location.hostname) ? location.hostname : 'localhost';
if (p === 'youtube') {
@@ -324,9 +344,15 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'twitch') {
- // Channel or VOD; Twitch requires one or more 'parent' params that match the embedding domain
+ // Channel, clip ou VOD ; Twitch exige 'parent' = domaine d'embed.
const parentsSet = new Set([host, 'localhost', '127.0.0.1']);
const parentParams = Array.from(parentsSet).map(h => `parent=${encodeURIComponent(h)}`).join('&');
+ // Clips : player dédié clips.twitch.tv (le player VOD rejette les slugs).
+ const clipId = clip || (/^\d+$/.test(String(id || '')) ? null : ((this.video() as any)?.kind === 'clip' ? id : null));
+ if (clipId) {
+ const u = `https://clips.twitch.tv/embed?clip=${encodeURIComponent(clipId)}&${parentParams}&autoplay=false`;
+ return this.sanitizer.bypassSecurityTrustResourceUrl(u);
+ }
const base = 'https://player.twitch.tv/';
// ?video= exige un id de VOD numérique ; un login (non numérique) sans
// ?channel= doit être traité comme une chaîne (résultats de recherche).
@@ -461,11 +487,13 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
const slug = this.route.snapshot.queryParamMap.get('slug');
const p = (this.route.snapshot.queryParamMap.get('p') || providerFromPath) as Provider | null;
const ch = this.route.snapshot.queryParamMap.get('channel');
+ const clip = this.route.snapshot.queryParamMap.get('clip');
if (id) {
this.videoId.set(id);
this.odyseeSlug.set(slug);
this.providerSel.set(p || '');
this.twitchChannel.set(ch);
+ this.twitchClip.set(clip);
// Choisir un provider final (paramètre présent, segment d'URL, ou fallback depuis état courant)
let finalProvider = p as Provider | null;
@@ -583,6 +611,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
this.summaryError.set(null);
this.selectedQuality.set(null);
this.resetDownloadUi();
+ this.resetTranscriptUi();
// Reset like state while loading
this.liked.set(false);
this.likeBusy.set(false);
@@ -861,6 +890,189 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
this.selectedQuality.set(value || null);
}
+ // --- Transcript logic (Step 16) ---
+ private resetTranscriptUi() {
+ this.transcriptOpen.set(false);
+ this.transcriptLoading.set(false);
+ this.transcriptAvailable.set(false);
+ this.transcriptError.set(null);
+ this.transcriptReason.set(null);
+ this.transcriptRetryable.set(false);
+ this.transcriptLanguages.set([]);
+ this.transcriptLines.set([]);
+ try {
+ const prefLang = String(this.users.preferences()?.language || '').trim().toLowerCase().slice(0, 12);
+ this.selectedTranscriptLang.set(prefLang || 'fr');
+ } catch {
+ this.selectedTranscriptLang.set('fr');
+ }
+ }
+
+ toggleTranscript() {
+ const next = !this.transcriptOpen();
+ this.transcriptOpen.set(next);
+ if (next && this.transcriptLines().length === 0 && !this.transcriptLoading()) {
+ this.loadTranscript();
+ }
+ }
+
+ onTranscriptLangChange(value: string) {
+ const lang = String(value || '').trim().slice(0, 12) || 'fr';
+ this.selectedTranscriptLang.set(lang);
+ this.loadTranscript();
+ }
+
+ /** Map a backend transcript state (reason/error/retryable) to the UI:
+ * user-facing message + whether a "Réessayer" button makes sense. */
+ private transcriptStatusFor(data: any): { message: string | null; retryable: boolean; reason: string | null } {
+ const reason = typeof data?.reason === 'string' ? data.reason : null;
+ const code = typeof data?.error === 'string' ? data.error : '';
+ const retryable = data?.retryable === true
+ || code === 'transcript_temporarily_unavailable'
+ || code === 'rate_limited';
+ if (reason === 'provider_unsupported') {
+ return {
+ message: 'Les transcripts ne sont pas pris en charge pour ce fournisseur (aucun sous-titre exposé).',
+ retryable: false,
+ reason,
+ };
+ }
+ if (reason === 'temporarily_unavailable' || code === 'transcript_temporarily_unavailable') {
+ return {
+ message: 'Sous-titres temporairement indisponibles (limite YouTube). Réessayez dans quelques instants.',
+ retryable: true,
+ reason: reason || 'temporarily_unavailable',
+ };
+ }
+ if (reason === 'no_subtitles') {
+ return { message: null, retryable: false, reason };
+ }
+ // Legacy / unknown error code: sanitize, never leak HTML or parse noise.
+ const clean = this.transcriptDisplayError(code);
+ if (clean) return { message: `Transcript indisponible. (${clean})`, retryable, reason };
+ if (retryable) {
+ return {
+ message: 'Sous-titres temporairement indisponibles. Réessayez dans quelques instants.',
+ retryable: true,
+ reason: reason || 'temporarily_unavailable',
+ };
+ }
+ return { message: null, retryable: false, reason };
+ }
+
+ /** Sanitize a backend error code for display: never leak HTML pages or raw
+ * JSON-parse noise (e.g. proxy / CDN ";
+ const params = new URLSearchParams({ lang: this.selectedTranscriptLang() });
+ if (opts['instance']) params.set('instance', opts['instance']);
+ if (opts['slug']) params.set('slug', opts['slug']);
+ const url = `${this.apiBase()}/transcript/${encodeURIComponent(p)}/${encodeURIComponent(id)}?${params.toString()}`;
+ this.http.get(url).subscribe({
+ next: (data) => {
+ this.transcriptLoading.set(false);
+ this.transcriptAvailable.set(!!data?.available);
+ this.transcriptLanguages.set(Array.isArray(data?.languages) ? data.languages : []);
+ this.transcriptLines.set(Array.isArray(data?.lines) ? data.lines : []);
+ if (data?.available && typeof data?.lang === 'string' && data.lang) {
+ this.selectedTranscriptLang.set(data.lang);
+ }
+ if (!data?.available) {
+ const st = this.transcriptStatusFor(data);
+ this.transcriptReason.set(st.reason);
+ this.transcriptRetryable.set(st.retryable);
+ this.transcriptError.set(st.message);
+ } else {
+ this.transcriptReason.set(null);
+ this.transcriptRetryable.set(false);
+ this.transcriptError.set(null);
+ }
+ },
+ error: (err) => {
+ this.transcriptLoading.set(false);
+ this.transcriptAvailable.set(false);
+ this.transcriptLines.set([]);
+ const body = err?.error;
+ if (body && typeof body === 'object' && Array.isArray(body.languages)) {
+ this.transcriptLanguages.set(body.languages);
+ }
+ const raw =
+ (body && typeof body === 'object' && (body.error || body.details || body.reason)) ||
+ (typeof body === 'string' ? body : '') ||
+ err?.message ||
+ '';
+ const status = err?.status;
+ if (status === 429 || String(raw || '').trim() === 'rate_limited') {
+ this.transcriptReason.set('temporarily_unavailable');
+ this.transcriptRetryable.set(true);
+ this.transcriptError.set('Trop de requêtes. Réessayez dans une minute.');
+ return;
+ }
+ const st = this.transcriptStatusFor(
+ body && typeof body === 'object' ? body : { error: raw },
+ );
+ this.transcriptReason.set(st.reason);
+ this.transcriptRetryable.set(st.retryable);
+ this.transcriptError.set(st.message);
+ },
+ });
+ } catch {
+ this.transcriptLoading.set(false);
+ this.transcriptAvailable.set(false);
+ }
+ }
+
// --- Download logic ---
openDownloadPanel() {
this.downloadOpen.set(true);
@@ -1053,6 +1265,10 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
if (slug.startsWith('/')) slug = slug.slice(1);
qp.slug = slug;
}
+ if (p === 'twitch' && String((v as any).kind || '').toLowerCase() === 'clip') {
+ qp.clip = v.videoId;
+ return qp;
+ }
if (p === 'twitch' && (v.type === 'channel' || (v as any).type === 'live')) {
// Prefer explicit videoId as channel login; fallback to parsing URL
if (v.videoId && !/^\d+$/.test(String(v.videoId))) {
@@ -1072,18 +1288,64 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
private loadRelatedSuggestions(): void {
const cur = this.video();
if (!cur) return;
- const title = (cur.title || '').trim();
const maxItems = 12;
- if (title) {
- this.apiService.searchVideosPage(title).subscribe(res => {
- const items = (res.items || []).filter(v => v.videoId !== cur.videoId).slice(0, maxItems);
- this.video.update(v => v ? { ...v, relatedStreams: items } : v);
- });
- } else {
- this.apiService.getTrendingPage().subscribe(res => {
- const items = (res.items || []).filter(v => v.videoId !== cur.videoId).slice(0, maxItems);
- this.video.update(v => v ? { ...v, relatedStreams: items } : v);
- });
- }
+ const apply = (items: any[]) => {
+ // Ignore les réponses arrivées après une navigation (race).
+ try {
+ if ((this.videoId() || '') !== (cur.videoId || '')) return;
+ } catch {}
+ const list = (items || []).filter((v: any) => (v?.videoId || v?.id) !== cur.videoId).slice(0, maxItems);
+ this.video.update(v => v ? { ...v, relatedStreams: list } : v);
+ };
+ const titleFallback = () => {
+ const title = (cur.title || '').trim();
+ if (title) {
+ this.apiService.searchVideosPage(title).subscribe(res => {
+ apply(res.items || []);
+ });
+ } else {
+ this.apiService.getTrendingPage().subscribe(res => {
+ apply(res.items || []);
+ });
+ }
+ };
+ // YouTube : vrai watch-next InnerTube via /api/details (0 quota, 0 recherche
+ // supplémentaire) — façon SmartTube. Autres providers : fallback existant.
+ try {
+ if (this.provider() === 'youtube' && cur.videoId) {
+ const id = cur.videoId;
+ this.http.get(`/api/details/youtube/${encodeURIComponent(id)}`).subscribe({
+ next: (data: any) => {
+ const rel = Array.isArray(data?.related) ? data.related : [];
+ if (rel.length > 0) {
+ apply(rel.map((it: any) => ({
+ url: it.url || `https://www.youtube.com/watch?v=${it.id}`,
+ type: 'video',
+ title: it.title || '',
+ thumbnail: it.thumbnail || '',
+ uploaderName: it.uploaderName || '',
+ uploaderAvatar: '',
+ channelId: it.channelId || undefined,
+ channelHandle: it.channelHandle || undefined,
+ channelExternalId: it.channelExternalId || it.channelId || undefined,
+ uploadedDate: it.publishedAt || '',
+ duration: typeof it.duration === 'number' ? it.duration : 0,
+ views: typeof it.views === 'number' ? it.views : 0,
+ uploaded: 0,
+ videoId: String(it.id || ''),
+ provider: 'youtube',
+ isShort: it.isShort === true ? true : undefined,
+ isLive: it.isLive === true ? true : undefined,
+ } as Video)));
+ } else {
+ titleFallback();
+ }
+ },
+ error: () => titleFallback(),
+ });
+ return;
+ }
+ } catch {}
+ titleFallback();
}
}
diff --git a/todo.md b/todo.md
index 5513cc8..3993649 100644
--- a/todo.md
+++ b/todo.md
@@ -17,6 +17,179 @@
- [x] Step 12: Write basic e2e scenarios for providers filtering and deep-link
- [x] Step 13: Add minimal telemetry hooks and events
- [x] Step 14: Update README with search UX section, keyboard shortcuts and usage notes
+- [x] Step 15: Auto-complétion de la **requête** (typeahead) dans la barre de recherche
+- [x] Step 16: **Transcripts** de vidéos sur la page Watch (API + UI, Phase 1 du doc)
+
+---
+
+## Step 15 — Suggestions de requêtes dans la barre de recherche
+
+### Ce que ça veut dire (et ce que ce n'est pas)
+
+Quand l'utilisateur tape `tutoriel an`, la barre affiche **avant qu'il valide** une liste de
+requêtes complètes probables : `tutoriel angular`, `tutoriel android`… + ses recherches passées.
+
+> ⚠️ À ne pas confondre avec `SearchSuggestionsComponent` (`src/components/search/search-suggestions.component.ts`),
+> qui lui affiche les **résultats de recherche** groupés par provider **après** la validation sur `/search`.
+> Le Step 15 ajoute un nouveau panneau de **suggestions de texte** sous l'``.
+
+### Ce qui existe déjà (à réutiliser)
+
+| Élément | Fichier | État |
+|---|---|---|
+| `@Output() searchChange` (query debouncée 300 ms) | `src/components/search/search-box.component.ts:46` | Émis mais **aucun abonné** — le header ne branche que `(submitted)` (`header.component.html:19`) |
+| Popover `@provider` + navigation clavier | `search-box.component.ts:16` (`AT_QUERY_RE`), `.html:56-76` | ✅ mais ne couvre que les ids de providers |
+| Historique de recherches | `HistoryService.getSearchHistory(n)` | ✅ utilisé dans le Quick Menu |
+| Cache in-memory TTL 60 s | `src/app/search/search.service.ts:33` | Pattern à dupliquer |
+| Fan-out multi-providers | `GET /api/search` (`server/index.mjs:2116`) | Pattern à dupliquer |
+
+### Livrables
+
+1. **Backend** — `GET /api/search/suggest?q=…&providers=yt,dm&limit=10`
+ → `{ q, groups: { yt: string[], dm: string[], … } }`
+ - Un `suggest(q, { limit })` optionnel par handler dans `server/providers/*.mjs` + déclaré dans
+ `server/providers/registry.mjs` (`ProviderAdapter`).
+ - Providers sans API de suggestions native → `[]` (dégradation propre, jamais d'erreur 500).
+ - Cache + rate-limit sur le même modèle que `/api/search`.
+2. **Front service** — `SuggestService` (ou méthode `suggest()` sur `SearchService`) :
+ `min length 2`, `debounce 250-300 ms`, `switchMap` (annulation de la requête précédente),
+ cache 5 min, dédoublonnage + tri.
+3. **UI** — panneau sous l'input, sections :
+ - 🔎 « Recherches récentes » (History, avec icône horloge)
+ - 📊 Suggestions providers, groupées par provider (badges `YT/DM/…`) ou fusionnées
+ - Sous-chaîne commune mise en évidence
+4. **Interactions** — clic = remplir + soumettre ; ↑/↓/Enter/Tab/Esc ; **priorité au popover `@`**
+ quand il est ouvert ; `aria-listbox` + `aria-activedescendant` (le `role="combobox"` est déjà sur l'input).
+5. **Télémétrie** — `suggest_shown`, `suggest_used` (à ajouter à la whitelist serveur de l'étape 13).
+6. **Tests** — `npm run test:suggest` (unitaires parsing/dédup) + scénario e2e : `providers=yt` ne renvoie
+ que des suggestions `yt`, provider inconnu → fallback registry complet.
+
+### Critères d'acceptation
+
+- [x] Aucune requête réseau tant que `q.trim().length < 2`
+- [x] frappe rapide = une seule requête en vol (pas d'empilement / de réponse qui écrase la dernière)
+- [x] Esc ferme le panneau sans effacer le texte ; Tab complète sans lancer la recherche
+- [x] Un provider sans suggestions ou en échec n'affiche ni trou ni erreur dans le panneau
+- [x] Focus clavier et lecteurs d'écran cohérents avec l'existant (a11y Step 10)
+
+---
+
+## Step 16 — Transcripts (Phase 1 du `docs/transcript_architecture_dev.md`)
+
+### Principe directeur
+
+> **Un endpoint, un module de parsing, une UI.** La disponibilité dépend de la plateforme, mais le
+> **code ne change pas** selon le provider (le doc appelle ça « Rung 2 : réutiliser, pas réinventer »).
+
+Source des données : `yt-dlp --dump-single-json --skip-download` (déjà utilisé côté serveur, ex.
+`server/index.mjs:1012`) → on lit `subtitles` / `automatic_captions` → on télécharge la piste `json3` ou `vtt`
+→ on normalise en `{ lang, available, languages, lines: [{ t, dur, text }] }`.
+
+### Périmètre Phase 1 (à faire)
+
+**Backend**
+
+- [x] `server/transcript.mjs` : fonctions **pures** `pickTrack(json, lang)`, `parseJson3(data)`, `parseVtt(text)`
+ (testables sans réseau ni DB)
+- [x] Route `GET /api/transcript/:provider/:videoId?lang=&instance=&slug=&sourceUrl=` dans `server/index.mjs`
+ (`instance/slug/sourceUrl` nécessaires pour PeerTube multi-instances ; réutiliser `providerUrlFrom()`).
+- [x] `transcriptCache` : `Map` + TTL long (24 h), clé `transcript:${provider}:${videoId}:${lang}`.
+- [x] Rate-limit (~10 req/min/IP) sur le motif `channelsLimiter`.
+- [x] Aucun sous-titre → **HTTP 200** `{ available: false }` (pas une erreur).
+- [x] Gestion 429 persistant + fallback « temp dir » (`writeAutoSub`) et variables d'env
+ (`TRANSCRIPT_CACHE_TTL`, `TRANSCRIPT_RATE_LIMIT`, `TRANSCRIPT_FALLBACK_TMP`).
+
+**Frontend (variante UI #1 du doc)**
+
+- [x] Bouton « Transcript » à côté du bouton Download (`watch.component.html:159`).
+- [x] Panneau copié sur le motif du Download Panel (`watch.component.html:185`) :
+ `toggleTranscript()` + `loadTranscript()`, chargement/absent/lines, `