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
This commit is contained in:
2026-09-25 19:38:46 -04:00
parent 300e5c45b1
commit 4bdcd393fe
25 changed files with 4112 additions and 168 deletions
+769
View File
@@ -0,0 +1,769 @@
# 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
<button class="btn-transcript" (click)="toggleTranscript()">
Transcript
</button>
```
#### 5.1.2 Panneau
Copier le motif du panneau Download (`watch.component.html:165`).
```html
<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
```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.**