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:
@@ -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.**
|
||||
Reference in New Issue
Block a user