- InnerTube layer via pinned youtubei.js 18.1.0 (no quota, no key): search with merged continuations (unlimited pages), watch-next related with LockupView mapping, caption-track discovery - 3-layer dispatcher (YT_SEARCH_MODE, default innertube-first): innertube -> yt-dlp scrape -> official API, graceful errors.yt - Robust yt-dlp binary resolution (YT_DLP_PATH > PATH > bundled) with systematic API fallback (fixes spawn ENOENT in UI) - Transcript: InnerTube caption discovery (YT_TRANSCRIPT_SOURCE), reusing pickTrack/orderedTracks/parseTrackText; yt-dlp fallback kept - Watch: sidebar uses real watch-next related[] (/api/details), title-search fallback for other providers - Cache: memory LRU + SQLite (youtube_search_cache, youtube_metrics), never persist empty pages; /healthz observability; /api/trending - Includes pending Step 15/16 leftovers in same files (suggest, test scripts); unrelated provider adapters left uncommitted
19 KiB
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
- Contexte
- Objectifs et non-objectifs
- Architecture globale
- Backend
- Frontend
- Référence API
- Tests
- Développement pas à pas
- Déploiement et configuration
- Risques et mitigations
- Alternatives écartées
- Roadmap
- 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:10importeyoutube-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-dlprenvoie dans ce même JSON les champssubtitlesetautomatic_captions.- Côté UI,
watch.component.html:154et:165fournissent 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
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 :
- Sous-titres manuels (
subtitles) dans la langue demandée. - Sous-titres manuels dans une langue proche (
fr-*). - Sous-titres automatiques (
automatic_captions) dans la langue demandée. - Sous-titres automatiques dans une langue proche.
- Première piste disponible.
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.
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.
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
{
"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 :
{
"lang": null,
"available": false,
"languages": [],
"lines": []
}
4.2 Route API dans server/index.mjs
4.2.1 Signature
GET /api/transcript/:provider/:videoId?lang=&instance=&slug=&sourceUrl=
4.2.2 Flux d'exécution
- Valider le provider.
- Construire la clé de cache :
transcript:${provider}:${videoId}:${lang}. - Vérifier le cache.
- 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 (
json3ouvtt). - Mettre en cache.
- Répondre.
4.2.3 Pseudo-code
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 :
Mapavec TTL, commeytCache(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.
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 :
- Laisser
yt-dlpécrire le VTT dans un dossier temporaire. - Lire le fichier.
- Parser avec
parseVtt().
Pattern déjà utilisé pour les downloads : youtubedl.exec.
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 ».
<button class="btn-transcript" (click)="toggleTranscript()">
Transcript
</button>
5.1.2 Panneau
Copier le motif du panneau Download (watch.component.html:165).
<div *ngIf="transcriptOpen" class="transcript-panel">
<div class="transcript-header">
<h3>Transcript</h3>
<select [(ngModel)]="selectedLang" (change)="loadTranscript()">
<option *ngFor="let lang of transcriptLanguages" [value]="lang">
{{ lang }}
</option>
</select>
</div>
<div *ngIf="transcriptLoading" class="transcript-loading">
Chargement…
</div>
<div *ngIf="!transcriptLoading && !transcriptAvailable" class="transcript-empty">
Aucun sous-titre disponible pour cette vidéo.
</div>
<div *ngIf="transcriptAvailable" class="transcript-lines">
<div *ngFor="let line of transcriptLines" class="transcript-line">
<span class="transcript-time">{{ line.t | duration }}</span>
<span class="transcript-text">{{ line.text }}</span>
</div>
</div>
</div>
5.1.3 Composant TypeScript
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 :
seekBydansvideo-player.component.ts:112. - Iframe YouTube :
seekTodansiframe-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
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
{
"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
{
"lang": null,
"available": false,
"languages": [],
"lines": []
}
HTTP 200.
6.5 Réponse erreur
{
"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
youtubedlpour renvoyer un JSON avecsubtitles. - 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
{
"scripts": {
"test:transcript": "node --test server/tests/transcript.test.mjs"
}
}
8. Développement pas à pas
Checklist
- Créer
server/transcript.mjsavecpickTrack,parseJson3,parseVtt. - Ajouter la route
GET /api/transcript/:provider/:videoIddansserver/index.mjs. - Ajouter le cache
transcriptCacheavec 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:transcriptdanspackage.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: falsepar 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é :
{
"events": [
{
"tStartMs": 0,
"dDurationMs": 2500,
"segs": [{ "utf8": "Bonjour" }]
},
{
"tStartMs": 2500,
"dDurationMs": 3100,
"segs": [{ "utf8": "Bienvenue" }]
}
]
}
13.2 Format VTT
Exemple simplifié :
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
{
"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
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.