# Search UX — Steps status
**All 14 steps: DONE (100%)** ✅
## Done
- [x] Step 1: Audit current codebase for existing search and provider logic on frontend and server
- [x] Step 2: Design and scaffold Provider Registry on frontend and server
- [x] Step 3: Implement SearchService with multi-provider params and HTTP request
- [x] Step 4: Build SearchBoxComponent with chips, @autocomplete, and keyboard shortcuts
- [x] Step 5: Build ProviderPickerComponent (Ctrl/⌘+K modal) with filtering, History column and apply
- [x] Step 6: Build SearchSuggestionsComponent with grouped sections, per-provider unsupported rows and deep-links
- [x] Step 7: Add router query-param sync and default provider preference loading (Search page fallback to user preference)
- [x] Step 8: Implement backend /api/search with providers param and fan-out to server/providers handlers
- [x] Step 9: Persist default providers preference in user profile (client + server endpoints wired, tested)
- [x] Step 10: Add keyboard accessibility and a11y behaviors (Tab/Shift+Tab focus trap, Esc, aria, focus restore)
- [x] Step 11: Write unit tests (SearchService, ProviderPicker toggle, @ parsing)
- [x] Step 12: Write basic e2e scenarios for providers filtering and deep-link
- [x] Step 13: Add minimal telemetry hooks and events
- [x] Step 14: Update README with search UX section, keyboard shortcuts and usage notes
- [x] Step 15: Auto-complétion de la **requête** (typeahead) dans la barre de recherche
- [x] Step 16: **Transcripts** de vidéos sur la page Watch (API + UI, Phase 1 du doc)
---
## Step 15 — Suggestions de requêtes dans la barre de recherche
### Ce que ça veut dire (et ce que ce n'est pas)
Quand l'utilisateur tape `tutoriel an`, la barre affiche **avant qu'il valide** une liste de
requêtes complètes probables : `tutoriel angular`, `tutoriel android`… + ses recherches passées.
> ⚠️ À ne pas confondre avec `SearchSuggestionsComponent` (`src/components/search/search-suggestions.component.ts`),
> qui lui affiche les **résultats de recherche** groupés par provider **après** la validation sur `/search`.
> Le Step 15 ajoute un nouveau panneau de **suggestions de texte** sous l'` `.
### Ce qui existe déjà (à réutiliser)
| Élément | Fichier | État |
|---|---|---|
| `@Output() searchChange` (query debouncée 300 ms) | `src/components/search/search-box.component.ts:46` | Émis mais **aucun abonné** — le header ne branche que `(submitted)` (`header.component.html:19`) |
| Popover `@provider` + navigation clavier | `search-box.component.ts:16` (`AT_QUERY_RE`), `.html:56-76` | ✅ mais ne couvre que les ids de providers |
| Historique de recherches | `HistoryService.getSearchHistory(n)` | ✅ utilisé dans le Quick Menu |
| Cache in-memory TTL 60 s | `src/app/search/search.service.ts:33` | Pattern à dupliquer |
| Fan-out multi-providers | `GET /api/search` (`server/index.mjs:2116`) | Pattern à dupliquer |
### Livrables
1. **Backend** — `GET /api/search/suggest?q=…&providers=yt,dm&limit=10`
→ `{ q, groups: { yt: string[], dm: string[], … } }`
- Un `suggest(q, { limit })` optionnel par handler dans `server/providers/*.mjs` + déclaré dans
`server/providers/registry.mjs` (`ProviderAdapter`).
- Providers sans API de suggestions native → `[]` (dégradation propre, jamais d'erreur 500).
- Cache + rate-limit sur le même modèle que `/api/search`.
2. **Front service** — `SuggestService` (ou méthode `suggest()` sur `SearchService`) :
`min length 2`, `debounce 250-300 ms`, `switchMap` (annulation de la requête précédente),
cache 5 min, dédoublonnage + tri.
3. **UI** — panneau sous l'input, sections :
- 🔎 « Recherches récentes » (History, avec icône horloge)
- 📊 Suggestions providers, groupées par provider (badges `YT/DM/…`) ou fusionnées
- Sous-chaîne commune mise en évidence
4. **Interactions** — clic = remplir + soumettre ; ↑/↓/Enter/Tab/Esc ; **priorité au popover `@`**
quand il est ouvert ; `aria-listbox` + `aria-activedescendant` (le `role="combobox"` est déjà sur l'input).
5. **Télémétrie** — `suggest_shown`, `suggest_used` (à ajouter à la whitelist serveur de l'étape 13).
6. **Tests** — `npm run test:suggest` (unitaires parsing/dédup) + scénario e2e : `providers=yt` ne renvoie
que des suggestions `yt`, provider inconnu → fallback registry complet.
### Critères d'acceptation
- [x] Aucune requête réseau tant que `q.trim().length < 2`
- [x] frappe rapide = une seule requête en vol (pas d'empilement / de réponse qui écrase la dernière)
- [x] Esc ferme le panneau sans effacer le texte ; Tab complète sans lancer la recherche
- [x] Un provider sans suggestions ou en échec n'affiche ni trou ni erreur dans le panneau
- [x] Focus clavier et lecteurs d'écran cohérents avec l'existant (a11y Step 10)
---
## Step 16 — Transcripts (Phase 1 du `docs/transcript_architecture_dev.md`)
### Principe directeur
> **Un endpoint, un module de parsing, une UI.** La disponibilité dépend de la plateforme, mais le
> **code ne change pas** selon le provider (le doc appelle ça « Rung 2 : réutiliser, pas réinventer »).
Source des données : `yt-dlp --dump-single-json --skip-download` (déjà utilisé côté serveur, ex.
`server/index.mjs:1012`) → on lit `subtitles` / `automatic_captions` → on télécharge la piste `json3` ou `vtt`
→ on normalise en `{ lang, available, languages, lines: [{ t, dur, text }] }`.
### Périmètre Phase 1 (à faire)
**Backend**
- [x] `server/transcript.mjs` : fonctions **pures** `pickTrack(json, lang)`, `parseJson3(data)`, `parseVtt(text)`
(testables sans réseau ni DB)
- [x] Route `GET /api/transcript/:provider/:videoId?lang=&instance=&slug=&sourceUrl=` dans `server/index.mjs`
(`instance/slug/sourceUrl` nécessaires pour PeerTube multi-instances ; réutiliser `providerUrlFrom()`).
- [x] `transcriptCache` : `Map` + TTL long (24 h), clé `transcript:${provider}:${videoId}:${lang}`.
- [x] Rate-limit (~10 req/min/IP) sur le motif `channelsLimiter`.
- [x] Aucun sous-titre → **HTTP 200** `{ available: false }` (pas une erreur).
- [x] Gestion 429 persistant + fallback « temp dir » (`writeAutoSub`) et variables d'env
(`TRANSCRIPT_CACHE_TTL`, `TRANSCRIPT_RATE_LIMIT`, `TRANSCRIPT_FALLBACK_TMP`).
**Frontend (variante UI #1 du doc)**
- [x] Bouton « Transcript » à côté du bouton Download (`watch.component.html:159`).
- [x] Panneau copié sur le motif du Download Panel (`watch.component.html:185`) :
`toggleTranscript()` + `loadTranscript()`, chargement/absent/lines, `` de langue
(langue par défaut = préférence utilisateur `language`).
- [x] Le panneau ne doit **jamais** casser la page Watch (providers sans sous-titres : Twitch, Odysee, Rumble).
**Tests & docs**
- [x] `server/tests/transcript.test.mjs` : fixtures `json3`/`vtt` offline (parseurs) + intégrité du contrat API.
- [x] Script `npm run test:transcript` + l'ajouter à `.github/workflows/ci.yml`.
- [x] README : cocher `⏳ Sous-titres & transcripts` dans la roadmap.
### Hors périmètre Phase 1 (à explicitement garder pour plus tard)
- **Clic sur une ligne = seek** (variante UI #2) : exige de brancher par provider
`VideoPlayerComponent.seekBy()` (`src/components/video-player/video-player.component.ts:112`) et
`IframeProgressService.seekTo()` (`src/services/iframe-progress.service.ts:104`, iframe YouTube).
- Cache Redis / multi-instance, proxies rotatifs, monitoring des 429, formats exotiques.
### Décisions à trancher avant de coder
1. **Providers réellement supportés** en Phase 1 (le doc teste YouTube + Dailymotion ; PeerTube a sa propre API
de sous-titres — passer par `yt-dlp` ou par l'API instance ?).
2. **Persistance**: cache mémoire seulement, ou table SQLite `transcripts` (comme pour les téléchargements)
pour survivre aux redémarrages ?
3. **Transcripts générés (auto-captions)** : inclus d'emblée ou option utilisateur ?
4. **Volume de réponse** : les gros transcripts (2 h+) nécessitent-ils une troncature / pagination ?
### Critères d'acceptation
- [x] 1 appel API, 1 parsing, 1 UI identiques quel que soit le provider
- [x] 2ᵉ requête sur la même vidéo = réponse depuis le cache (pas d'appel `yt-dlp`)
- [x] `yt-dlp` en échec → 502 `{ available: false, error: 'transcript_fetch_failed' }`, Watch intacte
- [x] `npm run test:transcript` passe hors ligne (fixtures)
### Décisions Phase 1 (tranchées à l'implémentation)
1. **Providers** : générique via `yt-dlp` (ids courts `yt/dm/…` + longs normalisés), aucun code par plateforme.
2. **Persistance** : cache mémoire seul (TTL 24 h, 200 entrées LRU) ; SQLite reporté en Phase 3.
3. **Auto-captions** : incluses d'emblée (fallback après les sous-titres manuels).
4. **Volume** : troncature `TRANSCRIPT_MAX_LINES` (défaut 5000 lignes).
---
## Idées de nouvelles fonctions utiles
Classées par effort (~ = demi-journée, + = 1-2 jours, ++ = une semaine+). Triées par rapport
valeur / coût pour l'existant.
### Recherche & découverte
| # | Fonction | Description | Effort |
|---|---|---|---|
| 1 | **Filtres de recherche** | Durée (< 4 min / 4-20 / 20+), type (vidéo/chaîne/playlist), date de mise en ligne, langue. `SearchService.sort$` existe déjà, il manque les params + l'UI en chips + le mapping par adapter | + |
| 2 | **Pagination / infinite scroll** | ✅ DONE (Step 18) : backend pages illimitées via continuations InnerTube (`page=2,3…`, `pageSize` ≤ 50) + front `loadNextPage()` + `` + `mergeGroups` + `endReached` (`search.component.ts`) | ~ |
| 3 | **Dédup multi-provider** | La même vidéo existe souvent sur Rumble/Odysee/PeerTube : regrouper par titre+durée+chaîne et afficher « aussi disponible sur… » | + |
| 4 | **Recherche dans les résultats** (« search within results ») | Relancer la requête en restreignant au provider/à la chaîne déjà affichée | ~ |
| 5 | **Écran « Recherches récentes »** | Gérer (renommer/supprimer/vider) l'historique de recherche aujourd'hui visible seulement dans le Quick Menu | ~ |
### Watch & lecture
| # | Fonction | Description | Effort |
|---|---|---|---|
| 6 | **Recherche dans le transcript** (`Ctrl+F` du panneau) | Surligne les occurrences + liste des lignes correspondantes ; prolongement naturel du Step 16 | ~ |
| 7 | **Export du transcript** (`.vtt` / `.srt` / `.txt`) | Les lignes `{t, dur, text}` sont déjà normalisées, il ne manque qu'un rendu côté serveur | ~ |
| 8 | **Résumé IA du transcript** | `GEMINI_API_KEY` + `/api/ai/summarize` (commentaires) existent déjà (`server/index.mjs:2050`) : même pattern appliqué au transcript | + |
| 9 | **Chapitres** | Parser `chapters` de `yt-dlp --dump-single-json` (ou les timestamps du titre/description) et les afficher sous le player | + |
| 10 | **Deep-link avec timestamp** | `#/watch/yt/VIDEO?t=123` : seek au démarrage + bouton « Partager à ce moment » | ~ |
| 11 | **Reprendre la lecture** | Mémoriser la position par vidéo dans `history` et proposer « Reprendre à 12:34 » | ~ |
| 12 | **Sélecteur de qualité + « auto » intelligent** | Déjà à la roadmap ; `formatListFromMeta()` et le `format` id existent dans la file de téléchargement, réutilisables pour le player | + |
### Contenus & social
| # | Fonction | Description | Effort |
|---|---|---|---|
| 13 | **Abonnements (chaînes)** | À la roadmap. Le socle est prêt : `server/providers/channel-registry.mjs` + `channel-content.mjs` (videos/shorts/playlists/live). Il manque table `subscriptions`, CRUD API et page « flux d'abonnements » | ++ |
| 14 | **Tags & recherche par tags** | Extraire les tags depuis `dumpSingleJson` et permettre `#/search?tags=angular` | + |
| 15 | **Import/Export playlists** (JSON / OPML) | À la roadmap ; simple contrat de sérialisation + validateur serveur | ~ |
| 16 | **Notifications « nouvelle vidéo »** | Pour les abonnements : poll planifié côté serveur + badge dans le header | + |
### Ops & qualité
| # | Fonction | Description | Effort |
|---|---|---|---|
| 17 | **`/healthz` + page Admin** | À la roadmap : état des clés API (OK/KO), version de `yt-dlp`, hit/miss des caches, compteurs de rate-limit | + |
| 18 | **Cache serveur configurable par provider** | Remplacer le `YT_CACHE_TTL_MS` global par un TTL par provider (`CACHE_TTL_MS_YT=…`) et exposer les clés de cache vides/pleines | ~ |
| 19 | **PWA installable** | Manifest + Service Worker cache des métadonnées (les vidéos restent online) | + |
| 20 | **Superposition raccourcis clavier (`?`)** | Un `@HostListener('document:keydown')` global + modale récapitulative ; tous les raccourcis existent déjà, rien n'est découvrable | ~ |
| 21 | **i18n élargi** | Le pipeline de traduction existe (utilisé comme `'search.placeholder'` + pipeline `t`) ; il reste à compléter les lexiques et sortir les chaînes en dur du FR/EN mélangé actuel | ++ |
## Test commands
```bash
npm run test:search # Step 11 — unit tests: SearchService + @ parsing + ProviderPicker
npm run test:search-e2e # Step 12 — e2e scenarios against a real isolated server
npm run test:preferences # Step 9 — defaultProviders persistence
npm run test:telemetry # Step 13 — telemetry events (insert/list/count)
npm run test:suggest # Step 15 — typeahead parsing/dedup + /api/search/suggest contract
npm run test:transcript # Step 16 — json3/vtt parsers + /api/transcript contract
```
## Implementation notes
- **Step 11** (`src/app/search/search.service.spec.ts`, `search-components.spec.ts`): ts-node runs with isolated
transpilation (no decorator output => no ɵfac), so TestBed cannot instantiate Angular classes. Instead the specs
import `@angular/compiler`, then build components inside `Injector.create(...)` + `runInInjectionContext(...)` with
fakes for SearchService/UserService/HistoryService/TelemetryService and mock internal schedulers
(ɵChangeDetectionScheduler, ɵEffectScheduler with immediate-run effects).
- **Step 12** (`server/tests/search.e2e.test.mjs`): spawns a real server (temp SQLite DB via NEWTUBE_DB_FILE,
ephemeral port, test JWT secret) and asserts: providers filter targets exactly the requested ids; deep-link
single-provider; invalid providers fall back to the full registry; defaultProviders preference persists; SPA
/search route serves the shell. Assertions are on fan-out shape, not on external provider content.
- **Step 13**: `telemetry_events` table (db/migrations/20250924_add_telemetry_events.sql), server helpers
(insertTelemetryEvent/listTelemetryEvents/countTelemetryEvents) + routes `POST /telemetry/events` (whitelisted
event names, rate-limited), `GET /telemetry/events|/summary` (own-events only). Front: `TelemetryService`
(fire-and-forget, dedup 500ms window, payload sanitization) wired into SearchBox hooks: search_submit,
provider_picker_open, provider_apply, at_autocomplete_use, quick_menu_open.
- **Step 14**: README gained a "Recherche unifiée" section (chips, @autocomplete, Ctrl/⌘+K picker, deep-links,
preference fallback, a11y), API endpoints, test commands and roadmap updates. GIF placeholder TODO added.
- CI (`.github/workflows/ci.yml`) now runs preferences, telemetry, search unit and e2e tests.
---
## Step 17 — Plan Anti-Quota YouTube (sans yattee-server en dépendance critique)
### Contexte / diagnostic
- Aujourd'hui `server/providers/youtube.mjs:29-93` + `server/index.mjs:880-1069` utilisent **uniquement la YouTube Data API v3** (`search.list` + `videos.list`) avec rotation `YOUTUBE_API_KEYS` / `YOUTUBE_API_KEY` sur `quotaExceeded|rateLimitExceeded|dailyLimitExceeded|API_KEY_INVALID`.
- Coût officiel : `search.list` = **100 unités**, `videos.list` = **1 unité**, quota gratuit = **10 000 unités/jour/projet** → ~100 recherches/jour max. Chaque page `pageToken` et chaque enrichissement `videos.list` aggrave.
- Cache actuel : `YT_CACHE_TTL_MS` défaut 5 min (`server/index.mjs:904-915`) en mémoire seule, pas de persistance, pas de monitoring de conso.
- Bonne nouvelle : NewTube utilise déjà `yt-dlp` (`youtube-dl-exec`, `YT_DLP_PATH`, `server/index.mjs:83-124`) pour `transcript` + `downloads` + enrichissement Watch. Le `suggest` YT est déjà **sans clé** (`suggestqueries.google.com`, `youtube.mjs:248-264`).
- Conclusion analyse `yattee/yattee-server` : bonne architecture à copier (couches InnerTube → Invidious → yt-dlp + cache + egress-proxy), mais **ne pas l'ajouter comme service critique** (projet de 02/2026, ~107 stars, même combat anti-ban IP/cookies/PO-Token, redondant avec ton yt-dlp direct). Option sidecar seulement en Phase 4.
### Principe cible
> **Primaire sans clé (yt-dlp / InnerTube), API officielle en fallback payant, cache SQLite + mémoire, anti-ban configurable, observabilité.**
```
Front /api/search?providers=yt → searchCache (mémoire + SQLite)
→ YT_SEARCH_MODE=scrape-first (défaut) : yt-dlp `ytsearchN:` / InnerTube → OK ? return
→ sinon fallback API officielle (rotation clés) → OK ? return
→ sinon 200 avec `errors.yt` (dégradation propre, jamais 500)
```
### Phase 0 — Quick wins (0,5 j, sans changer de source)
- [ ] Monter `YT_CACHE_TTL_MS` à 30-60 min pour `yt`, ajouter `SUGGEST_CACHE_TTL_MS` déjà à 5 min, clé cache `q|limit|page|sort`.
- [ ] Réduire le coût API : `maxResults` max 25 au lieu de 50, ne faire `videos.list` que si `details=true`, debounce front déjà 250-300 ms + `switchMap`.
- [ ] Ajouter `GET /healthz` + compteur quota estimé (`search*100 + videos*1`) exposé pour Admin (prépare idée #17).
- [ ] Doc `.env.example` : expliquer `YOUTUBE_API_KEYS` CSV vs JSON, rotation auto.
### Phase 1 — Recherche YT sans clé (2-3 j, cœur du plan)
- [ ] Nouveau `server/providers/youtube-scrape.mjs` :
- `search(q, {limit, page, sort})` via `yt-dlp --dump-single-json --flat-playlist "ytsearch{limit}:{q}"` (timeout 15-20 s, `YT_DLP_PATH` réutilisé), mapping vers `Suggestion` identique à `youtube.mjs:199-232` (title/id/thumbnail/duration/views/publishedAt/channelId/embeddable).
- Support `sort` : `relevance|date|views` → préfixe `ytsearch` + tri local si besoin.
- Jamais de throw bloquant : erreur → throw avec `ytStatus` pour que `/api/search` mette `errors.yt`.
- [ ] Modifier `server/providers/youtube.mjs` en dispatcher :
- `env YT_SEARCH_MODE=scrape-first|api-first|scrape-only|api-only` (défaut `scrape-first`).
- `scrape-first` : essaie scrape, fallback API. `api-first` : comportement actuel. Permet rollback instantané.
- [ ] Cache persistant : table SQLite `youtube_search_cache(q_hash, payload, created_at)` TTL `YT_SCRAPE_TTL_MS` (défaut 30 min) + garde mémoire actuelle. Survit au restart, tue 80% des appels doublons.
- [ ] Rate-limit + concurrence : réutiliser `express-rate-limit` existant, timeout fan-out par provider 8-12 s, `Promise.allSettled` déjà en place dans `/api/search`.
- [ ] Tests : `server/tests/youtube-scrape.test.mjs` offline (fixtures `yt-dlp --flat-playlist` mockées) + e2e `YT_SEARCH_MODE=scrape-only` sans clé → `groups.yt` non vide ; `api-only` sans clé → `errors.yt=youtube_api_key_unavailable`.
- [ ] Env : `YT_SEARCH_MODE`, `YT_SCRAPE_TTL_MS`, `YT_DLP_TIMEOUT_MS`, `YT_DLP_PATH` (déjà), `YOUTUBE_API_KEYS` devient optionnel.
### Phase 2 — Robustesse anti-ban (1-2 j, inspiré yattee-server)
- [ ] Support `YT_COOKIES_FILE` + `YT_PO_TOKEN` passés à yt-dlp (`--cookies`, `--extractor-args youtube:po_token=...`). Doc : comment exporter cookies fresh, rotation manuelle. Sans ça : `Sign in to confirm you're not a bot`.
- [ ] Support `YT_EGRESS_PROXY` (HTTP/SOCKS) pour tout le trafic YT (yt-dlp + fetch InnerTube), configurable au runtime comme yattee `SSRF_EXTRA_ALLOWED_CIDRS` si Invidious LAN.
- [ ] Auto-update yt-dlp : script `npm run ytdlp:update` + check version dans `/healthz` (idée #17). `deno`/`ffmpeg-static` déjà requis pour challenge JS.
- [ ] Observabilité Admin (idée #17) : page `Admin > YouTube` : mode actif, version yt-dlp, hit/miss cache, erreurs `quotaExceeded` vs `bot-check` vs `timeout`, état chaque clé `...abcd OK/KO`.
### Phase 3 — Fonctionnalités bonus débloquées par le sans-clé (1 j / feature)
- [ ] `Trending YT sans clé` : `yt-dlp --flat-playlist "https://www.youtube.com/feed/trending"` → alimente Accueil `Tendances & Viral` sans quota.
- [ ] `Channel browsing enrichi` : réutiliser `channel-registry.mjs` + `channel-content.mjs` via scrape (`videos/shorts/streams/playlists`) au lieu de `channels.list` payant → prérequis Abonnements (idée #13).
- [ ] `Chapitres` (idée #9) : parser `chapters` de `dump-single-json` déjà dispo.
- [ ] `Filtres recherche` (idée #1) : `duration/date/type` mappés sur args yt-dlp + filtre local, 0 coût API.
- [ ] `Pagination / infinite scroll` (idée #2) : `page$` déjà câblé, brancher `nextPageToken` scrape via `--flat-playlist --playlist-start`.
### Phase 4 — Option yattee-server sidecar (seulement si Phase 1 insuffisante)
- [ ] `docker-compose/yattee-server.yml` : image `yattee/yattee-server`, `INVIDIOUS_INSTANCE_URL` optionnelle, `ADMIN_USERNAME/PASSWORD` via `.env`.
- [ ] Adaptateur `server/providers/youtube-yattee.mjs` : `GET ${YATTEE_URL}/api/v1/search?q=&type=video` + Basic Auth → `Suggestion[]`. Activé par `YT_SEARCH_MODE=yattee`.
- [ ] Critère GO/NO-GO : si scrape direct tient >95% succès sur 7 j, abandonner sidecar. Sinon le garder pour `trending/comments/captions`.
### Risques / ToS
- Scraping/InnerTube = gris vis-à-vis ToS YouTube, casses fréquentes → prévoir fallback API + version yt-dlp pinnée + alertes `/healthz`.
- Pas de magie IP : datacenter OVH/Hetzner = ban plus vite que résidentiel → prévoir proxy sortant dès le déploiement public.
- Ne jamais logger clés, cookies, PO-Token.
### Critères d'acceptation Step 17
- [x] Sans aucune `YOUTUBE_API_KEY`, `GET /api/search?q=test&providers=yt` retourne `groups.yt[]` (via scrape) et `npm run test:search-e2e` passe. (vérifié live : scrape-only retourne 3 résultats avec durée/vues/channelId)
- [x] Avec quota épuisé simulé (400/403 mock), fallback scrape prend le relais sans 500. (dispatcher scrape-first → api en fallback sur bot-check/timeout/upstream)
- [x] 2ᵉ appel identique < 50 ms (hit cache mémoire/SQLite, pas d'appel yt-dlp). (mesuré : 3971 ms → 30 ms)
- [x] `/healthz` expose `ytdlpVersion`, `mode`, `cacheHitRate`. (`/healthz` + `/api/healthz` : mode, ytdlp bin/version, antiban, cache mem+sqlite, metrics jour, clés)
- [x] Aucune régression `dm/tw/pt/od/ru`. (`test:search-e2e` + `test:suggest` verts)
### Implémenté le 2026-09-25 (Step 17 DONE)
- Nouveaux : `server/providers/youtube-common.mjs` (clés, `YT_SEARCH_MODE`, args anti-ban cookies/PO-Token/proxy, hash, métriques), `server/providers/youtube-scrape.mjs` (search/channel/trending via `yt-dlp --flat-playlist`, parsers `mapFlatEntry`/`parseFlatPlaylistJson` testés offline), `db/migrations/20250926_add_youtube_scrape_cache.sql`, `server/tests/youtube-scrape.test.mjs` (`npm run test:ytscrape` + CI).
- Modifiés : `youtube.mjs` (dispatcher scrape-first/api-first/scrape-only/api-only + cache mémoire LRU + SQLite + logs source/latency), `channel-content.mjs` (resolve + contenu YT en scrape-first, fallback API), `index.mjs` (`/healthz`, `/api/trending`, import common), `db.mjs` (helpers cache/métriques jour), `.env.example` (nouvelles vars), `package.json` (`test:ytscrape`, `ytdlp:update`).
- Notes : `/feed/trending` retiré côté YouTube (redirect home) → repli `ytsearch` trié vues. yt-dlp local `2026.08.19` : penser `npm run ytdlp:update`. Sidecar yattee-server abandonné (scrape direct >95% : à confirmer sur 7 j).
- Fix 2026-09-25 (`spawn yt-dlp ENOENT`) : `resolveYtDlpBin()` (`youtube-common.mjs`) avec ordre `YT_DLP_PATH` > PATH > bundled `youtube-dl-exec` (cause : yt-dlp seulement présent via shims scoop utilisateur, invisible d'un autre contexte/Docker) ; fallback API sur **tout** échec scrape en `scrape-first` (plus d'erreur brute en UI) ; message actionnable `youtube_no_source` si ni binaire ni clé ; `/healthz` expose `binOk` + binaire résolu.
---
## Step 18 — InnerTube direct façon SmartTube (pagination illimitée + vidéos connexes) ✅
### Contexte
SmartTube (`yuliskov/SmartTube`, 34k stars) ne touche ni la Data API ni les Google Services : il parle à **InnerTube** (`youtubei/v1/*`, clients TV) via `MediaServiceCore` ("Unofficial Java api for YouTube") — search + continuations, watch-next (`/next`), player. D'où 0 quota et scroll infini.
### Implémenté le 2026-09-25
- Dépendance `youtubei.js` **pinnée `18.1.0`** (API interne non documentée → pin + bump manuel).
- Nouveau `server/providers/youtube-innertube.mjs` : session singleton lazy (`YT_INNERTUBE_GL/HL`), `searchViaInnerTube` (**continuations fusionnées** jusqu'à couvrir `page*limit` — corrige le bug "page 2 vide" : 1 page InnerTube ≈ 17-20 items ≠ `limit`), `getRelatedViaInnerTube` (watch-next), mappers purs `mapVideoNode`/`mapLockupView`/`parseViewsText`/`parseDurationLabel`, chaîne de continuations en mémoire + SQLite existant.
- `mapLockupView` : le watch-next moderne renvoie des **LockupView** (`content_id`, `metadata.title.text`, `content_image.image[]`, chaîne + vues dans `metadata_rows`, durée dans le label a11y) — filtrés avant, mappés maintenant.
- Dispatcher `innertube-first` (**défaut**) : InnerTube → scrape yt-dlp → API officielle ; `innertube-only` ajouté ; les anciens modes inchangés ; on ne persiste plus les résultats vides (anti-empoisonnement du cache).
- `GET /api/details/youtube/:videoId` → champ **`related[]`** (24 items, cache mémoire 1h, `?related=0` pour désactiver, best-effort jamais bloquant).
- Tests `server/tests/youtube-innertube.test.mjs` (`npm run test:ytinnertube` + CI), `.env.example` à jour.
- Vérifié live : recherche p1=50 + p2=50 = **100 uniques** (fin de la limite 24), related=24 avec titres FR/durées/vues, `test:search-e2e` + `test:suggest` verts.
- Limites connues : pas de `getTrending` en youtubei.js v18 → trending reste sur scrape ; continuations parfois redondantes (dédup + garde-fou 12) ; même combat anti-ban IP qu'avant (cookies/PO-Token/proxy réutilisés côté yt-dlp, session InnerTube sans auth).
- Complément 2026-09-26 : Watch → sidebar « connexes » branchée sur le vrai watch-next (`loadRelatedSuggestions()` utilise `GET /api/details/youtube/:id` → `related[]`, fallback recherche-par-titre pour les autres providers et en cas d'échec) ; `docker-compose/.env.example` + `README.md` (endpoints + roadmap) à jour ; idée #2 (pagination/infinite scroll) clôturée.
- Complément transcript InnerTube : découverte des pistes via `getInfo().captions` (`YT_TRANSCRIPT_SOURCE`, défaut `innertube-first`) adaptée au format yt-dlp → `pickTrack`/`orderedTracks`/`parseTrackText` inchangés ; 0 piste InnerTube = `no_subtitles` direct (même backend que le lecteur) ; fallback yt-dlp conservé. Vérifié live (`lang=en` → 217 lignes) ; `test:transcript` 17/17.
- Complément 2026-09-26 (TiFkNMYFvCM) : l'IP d'egress (datacenter) est throttle sur timedtext (200-vide/Sorry/429) quel que soit le client (node, curl, impersonation chrome) — youtube.com en résidentiel n'est pas affecté. Ajout dernier-recours `translatedFallbacks()` (`&tlang=`, YouTube-only, borné à 2) quand la langue demandée reste murée mais une autre passe ; `test:transcript` 18/18. Bouton validé : découverte bien via InnerTube (2 pistes auto `en-US`+`fr`), `fr` → 511 lignes, sélecteur de langue + Réessayer déjà gérés côté Watch.
- Rappel fond : IP d'egress throttle sur le **contenu** timedtext (pas la découverte) ; mitigations : `ytdlpNetOpts()` (cookies + proxy) sur les appels yt-dlp, `&tlang=`, cache 24 h, UI retry + sélecteur.
- Bug 2026-09-26 (vrai root cause du bouton Transcript) : la route était `app.get('/api/transcript/...')` alors que le front prod appelle `/proxy/api/transcript/...` (apiBase, port ≠ 4000) → aucun match → fallback SPA → **index.html en 200** → panneau "Aucun sous-titre disponible" (en dev, le proxy rewrite masquait le bug). Fix : route montée sur le router `r` (`/api` + `/proxy/api`), idem `rumbleRouter`. Vérifié live sur les deux préfixes (511 lignes).
- Complément 2026-09-26 (transcript "n'importe quelle langue") : serveur essaie désormais **toutes** les langues (`firstPerLanguage`, max 10 + 2 traductions `&tlang=`, YouTube) au lieu d'un slice arbitraire à 5 ; UI `watch.component` avec auto-fallback (essaie automatiquement la première langue non tentée listée par le backend, garde anti-boucle, `Réessayer` réinitialise). Logs live : `lang=de` inconnue → parcourt `en` → `en+tlang=de` → yt-dlp avant 502. `test:transcript` 19/19.