# Guide d'architecture et de développement — Transcripts multi-fournisseurs pour NewTube **Version :** 1.0 **Date :** 2026-09-25 **Statut :** Proposition d'implémentation **Périmètre :** Ajout de la fonctionnalité « Transcripts / Sous-titres » sur NewTube, pour les 6 fournisseurs supportés. --- ## Table des matières 1. [Contexte](#1-contexte) 2. [Objectifs et non-objectifs](#2-objectifs-et-non-objectifs) 3. [Architecture globale](#3-architecture-globale) 4. [Backend](#4-backend) 5. [Frontend](#5-frontend) 6. [Référence API](#6-référence-api) 7. [Tests](#7-tests) 8. [Développement pas à pas](#8-développement-pas-à-pas) 9. [Déploiement et configuration](#9-déploiement-et-configuration) 10. [Risques et mitigations](#10-risques-et-mitigations) 11. [Alternatives écartées](#11-alternatives-écartées) 12. [Roadmap](#12-roadmap) 13. [Annexes](#13-annexes) --- ## 1. Contexte NewTube est un agrégateur multi-fournisseurs qui s'appuie déjà sur `yt-dlp` via `youtube-dl-exec` pour extraire les métadonnées et les formats de téléchargement. Fournisseurs supportés : - YouTube - Dailymotion - Twitch - PeerTube - Odysee - Rumble L'analyse du code existant montre que : - `server/index.mjs:10` importe `youtube-dl-exec`. - `providerUrlFrom()` (`server/index.mjs:541`) construit une URL normalisée pour les 6 fournisseurs. - Les routes `/api/details/:provider/:videoId` (`l.988`) et `/api/download/.../formats` (`l.1061`) utilisent déjà `youtubedl(url, { dumpSingleJson, skipDownload })`. - `yt-dlp` renvoie dans ce même JSON les champs `subtitles` et `automatic_captions`. - Côté UI, `watch.component.html:154` et `:165` fournissent le motif du panneau « Download » à copier pour un panneau « Transcript ». - Le README contient déjà une ligne roadmap : `⏳ Sous-titres & transcripts` (`ligne 245`). La fonctionnalité peut donc être ajoutée **sans nouveau registre de providers**, **sans adaptateur par fournisseur**, et **sans migration de base de données**. --- ## 2. Objectifs et non-objectifs ### 2.1 Objectifs - Extraire les sous-titres manuels et automatiques via `yt-dlp`. - Supporter plusieurs langues avec un sélecteur. - Offrir une dégradation propre quand aucune piste n'existe. - Mettre en cache les transcripts (immuables) pour éviter les appels répétés. - Limiter le débit pour éviter les erreurs HTTP 429 de YouTube. - Ajouter une UI simple dans la page « Watch ». - Rester compatible avec les 6 fournisseurs sans code spécifique par plateforme. ### 2.2 Non-objectifs - Traduction automatique des transcripts. - Transcription audio via LLM ou service tiers. - Téléchargement de la vidéo complète. - Authentification OAuth. - Seek avancé dans la vidéo depuis le transcript (optionnel, phase 2). - Support garanti des sous-titres sur Twitch, Odysee et Rumble. --- ## 3. Architecture globale ### 3.1 Schéma ```mermaid flowchart LR A[Client Watch] -->|GET /api/transcript/:provider/:videoId| B[API NewTube] B --> C{Cache ?} C -->|Oui| D[Retour JSON] C -->|Non| E[providerUrlFrom] E --> F[yt-dlp dumpSingleJson] F --> G[subtitles / automatic_captions] G --> H[pickTrack] H --> I[Fetch piste json3/vtt] I --> J[parseJson3 / parseVtt] J --> K[Mise en cache] K --> D D --> A ``` ### 3.2 Composants | Composant | Fichier | Rôle | |---|---|---| | Module transcript | `server/transcript.mjs` | Fonctions pures : sélection de piste, parsing json3/vtt | | Route API | `server/index.mjs` | Endpoint `/api/transcript/...`, cache, rate-limit | | UI Watch | `watch.component.ts/html` | Bouton, panneau, sélecteur de langue, affichage | | Tests | `server/tests/transcript.test.mjs` | Tests unitaires et d'intégration | | Script npm | `package.json` | `test:transcript` | | Documentation | `README.md` | Case roadmap cochée + bump de version | ### 3.3 Principe directeur > **Rung 2 : réutiliser, pas réinventer.** Une seule route, un seul module de parsing, une seule UI. La capacité dépend de la plateforme, mais le code ne change pas. --- ## 4. Backend ### 4.1 Module `server/transcript.mjs` Fonctions pures, testables sans réseau ni base de données. #### 4.1.1 `pickTrack(json, lang)` Sélectionne la meilleure piste disponible. Priorités : 1. Sous-titres manuels (`subtitles`) dans la langue demandée. 2. Sous-titres manuels dans une langue proche (`fr-*`). 3. Sous-titres automatiques (`automatic_captions`) dans la langue demandée. 4. Sous-titres automatiques dans une langue proche. 5. Première piste disponible. ```js export function pickTrack(json, lang = 'fr') { const manual = json.subtitles || {}; const auto = json.automatic_captions || {}; const all = { ...auto, ...manual }; const languages = Object.keys(all); if (!languages.length) { return { track: null, languages: [], lang: null }; } const findLang = (dict, code) => dict[code] || Object.keys(dict).find(k => k.startsWith(code + '-')); const chosenLang = findLang(manual, lang) ? lang : findLang(auto, lang) ? lang : languages[0]; const track = manual[chosenLang] || auto[chosenLang] || manual[languages[0]] || auto[languages[0]]; return { track, languages, lang: chosenLang }; } ``` #### 4.1.2 `parseJson3(data)` Convertit le format `json3` de YouTube en lignes normalisées. ```js export function parseJson3(data) { const events = data.events || []; return events .filter(e => e.segs) .map(e => ({ t: (e.tStartMs || 0) / 1000, dur: (e.dDurationMs || 0) / 1000, text: e.segs.map(s => s.utf8).join('').trim() })) .filter(line => line.text); } ``` #### 4.1.3 `parseVtt(text)` Convertit un fichier VTT en lignes normalisées. ```js export function parseVtt(text) { const lines = []; const blocks = text.split(/\n\n+/); for (const block of blocks) { const match = block.match(/(\d{2}):(\d{2}):(\d{2})\.(\d{3}) --> (\d{2}):(\d{2}):(\d{2})\.(\d{3})/); if (!match) continue; const start = parseInt(match[1]) * 3600 + parseInt(match[2]) * 60 + parseInt(match[3]) + parseInt(match[4]) / 1000; const end = parseInt(match[5]) * 3600 + parseInt(match[6]) * 60 + parseInt(match[7]) + parseInt(match[8]) / 1000; const content = block .split('\n') .filter(line => !line.includes('-->') && !/^\d+$/.test(line)) .join(' ') .trim(); if (content) { lines.push({ t: start, dur: end - start, text: content }); } } return lines; } ``` #### 4.1.4 Structure de retour normalisée ```json { "lang": "fr", "available": true, "languages": ["fr", "en", "es"], "lines": [ { "t": 0, "dur": 2.5, "text": "Bonjour à tous" }, { "t": 2.5, "dur": 3.1, "text": "Bienvenue dans cette vidéo" } ] } ``` En l'absence de piste : ```json { "lang": null, "available": false, "languages": [], "lines": [] } ``` --- ### 4.2 Route API dans `server/index.mjs` #### 4.2.1 Signature ```http GET /api/transcript/:provider/:videoId?lang=&instance=&slug=&sourceUrl= ``` #### 4.2.2 Flux d'exécution 1. Valider le provider. 2. Construire la clé de cache : `transcript:${provider}:${videoId}:${lang}`. 3. Vérifier le cache. 4. Si absent : - `providerUrlFrom()` construit l'URL. - `youtubedl(url, { dumpSingleJson: true, skipDownload: true })`. - `pickTrack(json, lang)`. - Si aucune piste : répondre `{ available: false }` avec HTTP 200. - Sinon : `fetch(track.url)`. - Parser selon le format (`json3` ou `vtt`). - Mettre en cache. 5. Répondre. #### 4.2.3 Pseudo-code ```js app.get('/api/transcript/:provider/:videoId', channelsLimiter, async (req, res) => { const { provider, videoId } = req.params; const { lang = 'fr', instance, slug, sourceUrl } = req.query; const cacheKey = `transcript:${provider}:${videoId}:${lang}`; const cached = transcriptCache.get(cacheKey); if (cached) return res.json(cached); try { const url = providerUrlFrom(provider, videoId, { instance, slug, sourceUrl }); const json = await youtubedl(url, { dumpSingleJson: true, skipDownload: true, }); const { track, languages, lang: chosenLang } = pickTrack(json, lang); if (!track) { const empty = { lang: null, available: false, languages: [], lines: [] }; transcriptCache.set(cacheKey, empty, TTL_LONG); return res.json(empty); } const response = await fetch(track.url); const text = await response.text(); const lines = track.ext === 'json3' ? parseJson3(JSON.parse(text)) : parseVtt(text); const result = { lang: chosenLang, available: true, languages, lines, }; transcriptCache.set(cacheKey, result, TTL_LONG); res.json(result); } catch (err) { console.error('Transcript error:', err); res.status(502).json({ available: false, error: 'transcript_fetch_failed' }); } }); ``` --- ### 4.3 Cache - **Type :** `Map` avec TTL, comme `ytCache` (`server/index.mjs:838`). - **Clé :** `transcript:${provider}:${videoId}:${lang}`. - **TTL :** long (ex. 24 h) car les transcripts sont immuables. - **Évolution :** pour un déploiement multi-instances, remplacer par Redis ou Memcached. ```js const transcriptCache = { data: new Map(), get(key) { const entry = this.data.get(key); if (!entry) return null; if (Date.now() > entry.expires) { this.data.delete(key); return null; } return entry.value; }, set(key, value, ttlMs) { this.data.set(key, { value, expires: Date.now() + ttlMs }); } }; ``` --- ### 4.4 Rate limiting - Réutiliser le motif `channelsLimiter`. - Limiter par IP, par exemple 10 requêtes / minute sur `/api/transcript/...`. - En cas de HTTP 429 de YouTube : - Ne pas réessayer immédiatement. - Attendre 20 s minimum. - Utiliser un backoff exponentiel. - Si le problème persiste, activer le fallback. --- ### 4.5 Fallback en cas de 429 persistant Si le endpoint `timedtext` de YouTube renvoie trop de 429 : 1. Laisser `yt-dlp` écrire le VTT dans un dossier temporaire. 2. Lire le fichier. 3. Parser avec `parseVtt()`. Pattern déjà utilisé pour les downloads : `youtubedl.exec`. ```js const tmp = path.join(os.tmpdir(), `transcript-${Date.now()}.vtt`); await youtubedl.exec(url, { writeAutoSub: true, subLang: lang, subFormat: 'vtt', output: tmp, }); const text = await fs.readFile(tmp, 'utf8'); const lines = parseVtt(text); ``` --- ## 5. Frontend ### 5.1 UI dans `watch.component` #### 5.1.1 Bouton Ajouter un bouton « Transcript » à côté du bouton « Download ». ```html ``` #### 5.1.2 Panneau Copier le motif du panneau Download (`watch.component.html:165`). ```html