Files
NewTube/docs/transcript_architecture_dev.md
bruno 4bdcd393fe feat(youtube): InnerTube-first search, transcripts and watch-next related (Steps 15-18)
- 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
2026-09-25 19:38:46 -04:00

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

  1. Contexte
  2. Objectifs et non-objectifs
  3. Architecture globale
  4. Backend
  5. Frontend
  6. Référence API
  7. Tests
  8. Développement pas à pas
  9. Déploiement et configuration
  10. Risques et mitigations
  11. Alternatives écartées
  12. Roadmap
  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

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.
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

  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

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.
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.

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 : 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

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 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

{
  "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é :

{
  "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.