# 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

Transcript

Chargement…
Aucun sous-titre disponible pour cette vidéo.
{{ line.t | duration }} {{ line.text }}
``` #### 5.1.3 Composant TypeScript ```ts transcriptOpen = false; transcriptLoading = false; transcriptAvailable = false; transcriptLanguages: string[] = []; transcriptLines: { t: number; dur: number; text: string }[] = []; selectedLang = 'fr'; toggleTranscript() { this.transcriptOpen = !this.transcriptOpen; if (this.transcriptOpen && !this.transcriptLines.length) { this.loadTranscript(); } } async loadTranscript() { this.transcriptLoading = true; const res = await fetch( `/api/transcript/${this.provider}/${this.videoId}?lang=${this.selectedLang}` ); const data = await res.json(); this.transcriptAvailable = data.available; this.transcriptLanguages = data.languages || []; this.transcriptLines = data.lines || []; this.transcriptLoading = false; } ``` #### 5.1.4 Gestion de l'absence de sous-titres - Si `available: false`, afficher un message clair. - Le bouton peut rester visible mais le panneau indique l'absence. - Ne jamais casser la page Watch. --- ### 5.2 Variante UI #1 — recommandée - Affichage du transcript. - Sélecteur de langue. - Pas de seek. **Avantages :** simple, robuste, peu de code. --- ### 5.3 Variante UI #2 — optionnelle - Comme #1, plus : clic sur une ligne = seek dans la vidéo. **Implémentation :** - Player natif : `seekBy` dans `video-player.component.ts:112`. - Iframe YouTube : `seekTo` dans `iframe-progress.service.ts:104`. **Inconvénient :** le seek iframe n'est attaché que sous certaines conditions. Il faut brancher par provider. Sensiblement plus de code. **Recommandation :** garder #2 pour une phase ultérieure. --- ## 6. Référence API ### 6.1 Endpoint ```http GET /api/transcript/:provider/:videoId ``` ### 6.2 Paramètres | Nom | Type | Requis | Description | |---|---|---|---| | `provider` | string | Oui | `youtube`, `dailymotion`, `twitch`, `peertube`, `odysee`, `rumble` | | `videoId` | string | Oui | Identifiant de la vidéo | | `lang` | string | Non | Langue souhaitée (ex. `fr`, `en`). Défaut : `fr` | | `instance` | string | Non | Pour PeerTube | | `slug` | string | Non | Pour PeerTube | | `sourceUrl` | string | Non | Pour Odysee / Rumble | ### 6.3 Réponse succès ```json { "lang": "fr", "available": true, "languages": ["fr", "en"], "lines": [ { "t": 0, "dur": 2.5, "text": "Bonjour" }, { "t": 2.5, "dur": 3.1, "text": "Bienvenue" } ] } ``` ### 6.4 Réponse absence de sous-titres ```json { "lang": null, "available": false, "languages": [], "lines": [] } ``` HTTP 200. ### 6.5 Réponse erreur ```json { "available": false, "error": "transcript_fetch_failed" } ``` HTTP 502. --- ## 7. Tests ### 7.1 Tests unitaires Fichier : `server/tests/transcript.test.mjs` - `pickTrack()` : - manuel prioritaire sur auto. - `fr` → `fr-*` → autre langue. - aucun track → `available: false`. - `parseJson3()` : - events valides. - events sans `segs`. - texte vide filtré. - `parseVtt()` : - blocs valides. - timestamps corrects. - contenu multi-lignes. ### 7.2 Tests d'intégration - Mocker `youtubedl` pour renvoyer un JSON avec `subtitles`. - Vérifier la route `/api/transcript/...`. - Vérifier le cache : deuxième appel ne déclenche pas `youtubedl`. - Vérifier le rate-limit. ### 7.3 Tests manuels | Provider | Vidéo testée | Résultat attendu | |---|---|---| | YouTube | Vidéo avec auto-captions | `available: true`, ~100 langues | | Dailymotion | Vidéo avec pistes | `available: true` | | PeerTube | Vidéo framatube | souvent `available: false` | | Twitch | VOD | `available: false` | | Odysee | Vidéo | `available: false` | | Rumble | Vidéo | `available: false` | ### 7.4 Script npm ```json { "scripts": { "test:transcript": "node --test server/tests/transcript.test.mjs" } } ``` --- ## 8. Développement pas à pas ### Checklist - [ ] Créer `server/transcript.mjs` avec `pickTrack`, `parseJson3`, `parseVtt`. - [ ] Ajouter la route `GET /api/transcript/:provider/:videoId` dans `server/index.mjs`. - [ ] Ajouter le cache `transcriptCache` avec TTL long. - [ ] Ajouter le rate-limit sur le motif `channelsLimiter`. - [ ] Ajouter la gestion des erreurs 429 et le fallback temp dir. - [ ] Créer `server/tests/transcript.test.mjs`. - [ ] Ajouter `test:transcript` dans `package.json`. - [ ] Ajouter le bouton et le panneau dans `watch.component.html`. - [ ] Ajouter la logique dans `watch.component.ts`. - [ ] Mettre à jour le README : cocher `⏳ Sous-titres & transcripts`. - [ ] Bumper la version selon la règle du projet. - [ ] Tester manuellement sur YouTube et Dailymotion. - [ ] Vérifier la dégradation propre sur Twitch, Odysee, Rumble. **Volume estimé :** 150 à 200 lignes, 4 fichiers. --- ## 9. Déploiement et configuration ### 9.1 Variables d'environnement | Variable | Description | Défaut | |---|---|---| | `YTDLP_PATH` | Chemin vers `yt-dlp` | `yt-dlp` | | `TRANSCRIPT_CACHE_TTL` | TTL du cache en ms | `86400000` (24 h) | | `TRANSCRIPT_RATE_LIMIT` | Requêtes / minute / IP | `10` | | `TRANSCRIPT_FALLBACK_TMP` | Activer le fallback temp dir | `false` | ### 9.2 Mise à jour de `yt-dlp` - `yt-dlp` évolue vite. - Prévoir une mise à jour régulière. - Surveiller les breaking changes. ### 9.3 Monitoring - Compter les HTTP 429 sur `timedtext`. - Compter les `available: false` par provider. - Mesurer le temps de réponse de `/api/transcript/...`. - Alerter si le taux d'échec dépasse un seuil. --- ## 10. Risques et mitigations | Risque | Impact | Mitigation | |---|---|---| | ToS des plateformes | Élevé | Usage personnel / auto-hébergé, ne pas revendre | | HTTP 429 YouTube | Moyen | Cache long, rate-limit, fallback temp dir | | `yt-dlp` cassé | Élevé | Mise à jour régulière, tests de non-régression | | Providers sans sous-titres | Faible | Dégradation propre `available: false` | | Cache mémoire non partagé | Moyen | Redis en production multi-instances | | Seek iframe complexe | Faible | Reporter en phase 2 | --- ## 11. Alternatives écartées | Alternative | Raison du rejet | |---|---| | YouTube Data API `captions.download` | Nécessite OAuth du propriétaire | | Un implémentation par provider | 6 chemins de code pour le même résultat | | Fetch navigateur `timedtext` | CORS non garanti + casse le proxy clé API | | Services tiers / LLM de transcription | Dépendance, coût, vie privée, YAGNI | | Scrapers custom par plateforme | Maintenance impossible | --- ## 12. Roadmap ### Phase 1 — Base (recommandée) - Route `/api/transcript/...` - Module `transcript.mjs` - UI #1 : affichage + sélecteur de langue - Cache + rate-limit - Tests ### Phase 2 — Confort - UI #2 : clic sur une ligne = seek - Branchement par provider - Meilleure gestion des timecodes ### Phase 3 — Scalabilité - Cache Redis - Proxies rotatifs - Monitoring avancé - Support de nouveaux formats --- ## 13. Annexes ### 13.1 Format `json3` Exemple simplifié : ```json { "events": [ { "tStartMs": 0, "dDurationMs": 2500, "segs": [{ "utf8": "Bonjour" }] }, { "tStartMs": 2500, "dDurationMs": 3100, "segs": [{ "utf8": "Bienvenue" }] } ] } ``` ### 13.2 Format VTT Exemple simplifié : ```vtt WEBVTT 00:00:00.000 --> 00:00:02.500 Bonjour 00:00:02.500 --> 00:00:05.600 Bienvenue ``` ### 13.3 Exemple de réponse complète ```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" }, { "t": 5.6, "dur": 4.2, "text": "Aujourd'hui, nous allons voir..." } ] } ``` ### 13.4 Arborescence des fichiers modifiés ```text server/ index.mjs # route + cache + rate-limit transcript.mjs # fonctions pures tests/ transcript.test.mjs # tests watch.component.ts # logique UI watch.component.html # panneau Transcript package.json # script test:transcript README.md # roadmap + version ``` --- **Fin du guide.**