Files
NewTube/todo.md
T
bruno f1f673040e
CI / build-and-test (push) Successful in 14m10s
feat(search): panneau de filtres unifié, clavier/focus du typeahead
Barre de recherche
- Panneau de filtres unifié (remplace le ProviderPicker) : sources, type
  (vidéos/shorts/direct/chaînes), période (heure/jour/semaine/mois/année),
  durée (<4 / 4-20 / >20 min) et tri. Pastilles + roving tabindex
  (←/→ dans un groupe, ↑/↓ entre groupes), focus trap, Échap, Ctrl/⌘+Maj+F,
  recherches récentes, « mémoriser par défaut », « Réinitialiser ».
- Pastilles des filtres actifs sous la barre (retrait en un clic) + compteur.
- Typeahead : un seul keydown (fin du double Enter), ↑/↓ bouclants,
  Home/End, PageUp/PageDown, Entrée valide la ligne surlignée (sinon
  recherche brute), Tab complète sans chercher, Échap ferme puis vide,
  ligne « Rechercher <q> », défilement de l'option active, aria à jour.
- Focus : le panneau se referme dès que le focus quitte la barre
  (focusout + relatedTarget + activeElement), pointerdown neutralisé sur les
  lignes pour garder le focus dans l'input (plus de scintillement).
- Opérateurs en clair : `linux live:`, `tuto today: long:` deviennent des
  filtres et sont retirés de la requête (autocomplétion après le `:`).

Filtres côté serveur
- Modèle partagé : src/app/search/filters.ts + server/search-filters.mjs.
- /api/search?type=&duration=&period=&sort= : InnerTube (upload_date, type,
  duration, features), Data API v3 (type, videoDuration, publishedAfter),
  yt-dlp --dateafter ; post-filtrage pour les providers sans filtre natif.
  Un champ manquant ne fait jamais disparaître un résultat.
- Filtres dans l'URL (partageables) + adapters + SearchService (clé de cache).
- Correction : la recherche Shorts renvoyait 0 (ShortsLockupView/GridShelfView
  non mappés, id/titre dans l'endpoint) -> mappage + shelf + vignette/vues.

Tests / docs
- 23 tests unitaires (clavier, focus, opérateurs, panneau de filtres),
  server/tests/search-filters.test.mjs (npm run test:filters) + CI,
  scénarios e2e des filtres, README/api/README/todo/MCP à jour.
2026-09-28 17:42:19 -04:00

29 KiB

Search UX — Steps status

All 14 steps: DONE (100%) ✅

Done

  • Step 1: Audit current codebase for existing search and provider logic on frontend and server
  • Step 2: Design and scaffold Provider Registry on frontend and server
  • Step 3: Implement SearchService with multi-provider params and HTTP request
  • Step 4: Build SearchBoxComponent with chips, @autocomplete, and keyboard shortcuts
  • Step 5: Build ProviderPickerComponent (Ctrl/⌘+K modal) with filtering, History column and apply
  • Step 6: Build SearchSuggestionsComponent with grouped sections, per-provider unsupported rows and deep-links
  • Step 7: Add router query-param sync and default provider preference loading (Search page fallback to user preference)
  • Step 8: Implement backend /api/search with providers param and fan-out to server/providers handlers
  • Step 9: Persist default providers preference in user profile (client + server endpoints wired, tested)
  • Step 10: Add keyboard accessibility and a11y behaviors (Tab/Shift+Tab focus trap, Esc, aria, focus restore)
  • Step 11: Write unit tests (SearchService, ProviderPicker toggle, @ parsing)
  • Step 12: Write basic e2e scenarios for providers filtering and deep-link
  • Step 13: Add minimal telemetry hooks and events
  • Step 14: Update README with search UX section, keyboard shortcuts and usage notes
  • Step 15: Auto-complétion de la requête (typeahead) dans la barre de recherche
  • Step 16: Transcripts de vidéos sur la page Watch (API + UI, Phase 1 du doc)
  • Step 19: Panneau de filtres unifié (remplace le ProviderPicker) + filtres serveur + clavier/focus du panneau de suggestions

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

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

  • Aucune requête réseau tant que q.trim().length < 2
  • frappe rapide = une seule requête en vol (pas d'empilement / de réponse qui écrase la dernière)
  • Esc ferme le panneau sans effacer le texte ; Tab complète sans lancer la recherche
  • Un provider sans suggestions ou en échec n'affiche ni trou ni erreur dans le panneau
  • 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

  • server/transcript.mjs : fonctions pures pickTrack(json, lang), parseJson3(data), parseVtt(text) (testables sans réseau ni DB)
  • 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()).
  • transcriptCache : Map + TTL long (24 h), clé transcript:${provider}:${videoId}:${lang}.
  • Rate-limit (~10 req/min/IP) sur le motif channelsLimiter.
  • Aucun sous-titre → HTTP 200 { available: false } (pas une erreur).
  • 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)

  • Bouton « Transcript » à côté du bouton Download (watch.component.html:159).
  • Panneau copié sur le motif du Download Panel (watch.component.html:185) : toggleTranscript() + loadTranscript(), chargement/absent/lines, <select> de langue (langue par défaut = préférence utilisateur language).
  • Le panneau ne doit jamais casser la page Watch (providers sans sous-titres : Twitch, Odysee, Rumble).

Tests & docs

  • server/tests/transcript.test.mjs : fixtures json3/vtt offline (parseurs) + intégrité du contrat API.
  • Script npm run test:transcript + l'ajouter à .github/workflows/ci.yml.
  • 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

  • 1 appel API, 1 parsing, 1 UI identiques quel que soit le provider
  • 2ᵉ requête sur la même vidéo = réponse depuis le cache (pas d'appel yt-dlp)
  • yt-dlp en échec → 502 { available: false, error: 'transcript_fetch_failed' }, Watch intacte
  • 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 ✅ DONE : modèle partagé (src/app/search/filters.ts + server/search-filters.mjs), panneau unique (sources / type / période / durée / tri) au clavier, filtres dans l'URL + /api/search, appliqués nativement (InnerTube, Data API, --dateafter) ou en post-traitement, opérateurs live:/today:/long:, langue encore locale ~
2 Pagination / infinite scroll ✅ DONE (Step 18) : backend pages illimitées via continuations InnerTube (page=2,3…, pageSize ≤ 50) + front loadNextPage() + <app-infinite-anchor> + 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

npm run test:search        # Step 11/19 — unit tests: SearchService, @ parsing, clavier/focus, panneau de filtres
npm run test:filters      # Step 19 — filtres serveur (offline)
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

  • 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)
  • 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)
  • 2ᵉ appel identique < 50 ms (hit cache mémoire/SQLite, pas d'appel yt-dlp). (mesuré : 3971 ms → 30 ms)
  • /healthz expose ytdlpVersion, mode, cacheHitRate. (/healthz + /api/healthz : mode, ytdlp bin/version, antiban, cache mem+sqlite, metrics jour, clés)
  • 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.