Files
NewTube/docs/plan-phases-catalogue-classification.md
T
bruno 6b20b1ce79
CI / build-and-test (push) Successful in 14m55s
fix(shorts): anti-Forbidden Dailymotion + selecteur icon-only
- DM: embed mute par defaut (comme /watch) + son sur action explicite
- helmet: Referrer-Policy strict-origin-when-cross-origin (Dailymotion exige un Referer)
- searchVideosPage DM: champs allow_embed/width/height/language/owner.id
- iframes shorts/watch: allow fullscreen (conforme embed officiel DM)
- selecteur shorts icon-only (pastilles + globe cyclique)
2026-09-30 19:55:23 -04:00

59 KiB
Raw Blame History

Plan de phases — Amélioration du catalogue & de la classification vidéo (6 fournisseurs)

Version : 1.0 Date : 2026-09-29 Statut : Phases 0, 1, 2, 3, 4 et 7 exécutées (ordre 0 → 1 → 2 → 3 → 4 → 7). Phases 5, 6, 8 à faire. Bilan d'exécution : rapport-execution-phases-0-1-2-3-7.md — inclut 5 bugs hors-analyse découverts par les tests. Documents amont : ingestion-catalogue-video-par-fournisseur.md (analyse de référence) et l'analyse d'optimisation qui en découle.


Table des matières

  1. Principes directeurs
  2. Vue d'ensemble des phases
  3. Phase 0 — Socle : une seule source de vérité
  4. Phase 1 — Parité de champs (quick wins)
  5. Phase 2 — Propagation front & classification
  6. Phase 3 — Fiabilité des sources fragiles
  7. Phase 4 — Cache générique & observabilité
  8. Phase 5 — Table videos & fin de la dénormalisation
  9. Phase 6 — Identité de chaîne normalisée
  10. Phase 7 — Présentation
  11. Phase 8 — Architecture de fond
  12. Matrice de traçabilité anomalie → phase
  13. Jalons & charge
  14. Risques, rollback & Feature flags
  15. Annexe — Commandes de test

0. Principes directeurs

  1. Ne rien casser au collecting. Toute phase qui change le contrat Suggestion passe par v: 2 (Phase 0) et reste rétrocompatible : les adaptateurs front ignorent les champs inconnus, le serveur tolère l'absence des nouveaux champs.
  2. Un filtre ne doit jamais vider un résultat pour cause de métadonnée absente. Règle déjà écrite dans search-filters.mjs:9-11, à préserver dans toutes les phases de classification.
  3. Aucune donnée inventée. Si un fournisseur ne donne pas publishedAt, on ne le devine pas — on expose null et l'UI affiche « — ».
  4. Chaque phase est livrable seule. Pas de « phase 2 dépend de la 5 ». Le seul couplage fort est Phase 0 → toutes les autres (source de vérité unique).
  5. Test d'abord. Chaque tâche porte un critère d'acceptation exécutable (npm run test:*), pas une description narrative.

1. Vue d'ensemble des phases

Phase Thème Anomalies traitées Effort Risque Dépend de
0 Socle : registry unique front, contrat v2, tests de contrat #8, #9, #12 ~1,5 j Faible —
1 Parité de champs : 9 champs disponibles mais jetés #3 (+ §1.1 analyse) ~1 j Très faible —
2 toVideoItem complet + règles de classification #7 + §4 analyse ~2 j Faible —
3 Fiabilité : Rumble JSON-LD, curseur Twitch, parallélisation #1, #2, #4, #5, #6 ~3 j Moyen 1
4 Cache générique search_cache + /api/providers/* #15, #14 ~2,5 j Faible 0
5 Table videos légère + refactor des lectures #13 (+ §2.2 analyse) ~3 j Moyen 1, 2
6 channelRef normalisé (scheme + value) #9 ~2 j Moyen 0
7 Présentation : badges, états vides, grille, bannière §3 analyse ~3 j Faible 1, 2
8 Architecture : plugin provider, v2 strict, doc générée #10, #11, §5 analyse ++ (2 sem.) Élevé 0-7

Ordre recommandé d'exécution : 0 → 1 → 2 → 3 → 7 (chemin de valeur court) puis 4 → 5 → 6 → 8.


2. Phase 0 — Socle : une seule source de vérité

Objectif : supprimer les duplications de conventions qui rendent chaque gain ultérieur risqué (provider IDs, capacités, classification).

2.1 Tâches

  • 0.1 Créer src/app/shared/providers/provider-ids.ts — exporte PROVIDER_IDS (short), SHORT_TO_LONG, LONG_TO_SHORT, ALL_PROVIDER_IDS (dérivé, plus de liste en dur).
  • 0.2 Créer src/app/shared/providers/provider-capabilities.ts — source unique de la matrice de capacités ; réconcilie provider-registry.ts:13-38 et channel-detail.model.ts:42-49 (actuellement en contradiction sur dm.playlists). Règle de réconciliation : la capacité vaut ce que le serveur sait réellement servir (channel-content.mjs fait foi), donc dm.playlists = true.
  • 0.3 Faire importer ALL_PROVIDER_IDS par :
    • src/app/search/search.service.ts:93 (liste 'all' codée en dur)
    • src/app/components/search/search.component.ts (4 occurrences : :121, :123, :872)
    • src/app/core/providers/provider-registry.ts (construit les entrées depuis les tables)
    • provider-badge.component.ts:61-68, search.component.ts:133-140, video-card.component.ts:108-115 (tables dupliquées)
  • 0.4 Ajouter le champ v: 2 au JSDoc Suggestion (server/providers/registry.mjs:5-16) et l'exposer dans la réponse /api/search (server/index.mjs:3120). v absent = 1 (comportement actuel).
  • 0.5 Introduire les champs optionnels du contrat v2 sans les remplir (déclarés pour verrouiller les noms) : uploaderAvatar, viewCountRaw, language, hasSubtitles, capturedAt, source (upstream qui a produit l'item).
  • 0.6 Créer server/tests/provider-contract.test.mjs : pour chaque provider, un suggest() mocké doit produire des objets dont tous les champs obligatoires (title, id, url, thumbnail, type) sont présents et typés, et dont duration est un number ≥ 0 ou absent. Test offline (fixtures JSON figées par provider).
  • 0.7 Ajouter npm run test:contract et le câbler dans .github/workflows/ci.yml.

2.2 Critères d'acceptation

  • grep -rn "'yt','dm','tw','pt','od','ru'" src/ ne retourne plus que la définition dans provider-ids.ts.
  • providerSupportsLive() et HttpChannelProvider.supports() lisent la même table de capacités ; dm.playlists est true partout.
  • npm run test:contract passe ; aucun snapshot de réponse n'est modifié.

2.3 Rollback

Réversible par simple revert : aucune donnée n'est migrée.


3. Phase 1 — Parité de champs (quick wins)

Objectif : capter l'information déjà présente dans la réponse amont et jetée par le mapping. Le meilleur rapport gain/risque du plan.

3.1 Tableau des tâches

# Fournisseur Champ à récupérer Source amont Fichier Difficulté
1.1 Odysee views value.video.view_count / meta.effective_amount — ou ajouter video aux include de Lighthouse server/providers/odysee.mjs:21,48-60 Trivial
1.2 Odysee publishedAt release_time (déjà dans les include !) server/providers/odysee.mjs:21,48-60 Trivial
1.3 Odysee vérification du statut HTTP readJson() contourné : resp.json().catch(()=>({})) server/providers/channel-content.mjs:349 Trivial
1.4 Rumble publishedAt <time datetime="…"> dans .video-item--meta (le guard anti-datetime existe déjà dans parseDurationToSeconds, rumble.mjs:144) + JSON-LD datePublished en repli server/providers/rumble.mjs:207-221 Faible
1.5 Rumble channelId / channelExternalId slug d'URL /c/<slug> ou /user/<slug>, ou <a href="/c/…"> de la carte server/providers/rumble.mjs:184-238 Faible
1.6 Rumble uploaderAvatar même sélecteur que uploaderName (.ellipsis-1 a un parent-image) server/providers/rumble.mjs:195 Faible
1.7 YouTube uploaderAvatar InnerTube n.author_thumbnail / n.channel_thumbnail dans mapVideoNode + mapLockupView (absents à ce jour, cf. youtube-innertube.mjs:146-160) server/providers/youtube-innertube.mjs Faible
1.8 PeerTube uploaderAvatar item.account.avatars[0].path (SepiaSearch le renvoie ; le mapper legacy youtube-api.service.ts:1259-1297 sait déjà le lire) server/providers/peertube.mjs:55-66 Trivial
1.9 YouTube publishedAt en texte relatif mapVideoNode écrit String(n.published.text) → « 2 days ago » / « il y a 3 mois » : itemPublishedTs() ne peut pas le parser → le filtre period=week est inopérant sur les résultats InnerTube youtube-innertube.mjs:156 + search-filters.mjs:132-146 Moyen

3.2 Détail de la tâche 1.9 (priorité haute, non identifiée initialement)

youtube-innertube.mjs:156 :

...(n.published?.text ? { publishedAt: String(n.published.text) } : {}),

published.text est un libellé humain (« 2 months ago »), pas un timestamp. Conséquence :

  • itemPublishedTs() (search-filters.mjs:132-146) fait Date.parse(raw) → NaN → renvoie 0 ;
  • matchesFilters() (:254-258) n'écarte donc jamais un résultat sur le critère période ;
  • sort=date côté front (search.component.ts:732-751) trie donc sur NaN → ordre arbitraire.

Correctif : ajouter parseRelativeDate(text, { now }) dans youtube-common.mjs, traduisant il y a N (seconde|minute|heure|jour|semaine|mois|an) et N (second|minute|hour|day|week|month|year)s? ago → ISO. Exporté et testé offline (youtube-innertube.test.mjs), avec repli sur le libellé brut si le motif ne matche pas (ne jamais perdre l'info).

À traiter comme 1.9 avec priorité égale aux quick wins : c'est la seule tâche de la phase qui change un comportement visible (filtre « cette semaine »).

3.3 Critères d'acceptation

  • GET /api/search?q=x&providers=od renvoie des items avec views et publishedAt non nuls sur ≥ 80 % des résultats. (nécessite le réseau — non vérifié)
  • GET /api/search?q=x&providers=ru renvoie publishedAt + channelExternalId non nuls. (vérifié offline : test:rumble, 41 assertions)
  • GET /api/search?q=x&providers=yt&period=week écarte réellement les vidéos de plus d'une semaine. (parseRelativeDate() testé FR/EN ; le discard effectif est couvert par test:filters)
  • Une erreur HTTP Odysee (4xx/5xx) remonte dans errors.od au lieu de produire un groupe vide silencieux.
  • npm run test:filters, test:ytinnertube, test:search-e2e, test:contract verts.
  • Aucun champ n'est mis à 0 : absent = undefined.

3.4 Rollback

Chaque tâche est un commit indépendant et réversible seule. Aucun changement de schéma.


4. Phase 2 — Propagation front & classification

Objectif : faire remonter au modèle et à l'UI les signaux de classification que la collecte possède déjà, et affiner 3 règles de classification.

4.1 Tâche 2.1 — toVideoItem complet (priorité UX maximale)

src/app/core/providers/channel/http-channel.provider.ts:25-40 ne propage pas type, isLive, isShort, kind, width, height.

  • Propager les 6 champs avec la même defensive typing que les adaptateurs de recherche (typeof x === 'number' / === 'boolean').
  • Propager aussi game et language (déjà dans le contrat, ignorés partout).
  • Ajouter un test unitaire : une fixture channel-content Twitch live → VideoItem avec isLive === true ; une fixture PeerTube verticale 60 s avec width/height → la règle d'orientation s'applique. (test:kind + test:section couvrent les règles d'orientation ; toVideoItem n'est pas exporté, la propagation est couverte par le typecheck strict + le build AOT strictTemplates)

Effet mesurable : les grilles de chaîne affichent enfin les pastilles LIVE / CLIP / CHAÎNE, et une vidéo horizontale de 50 s cesse d'être classée short.

4.2 Tâche 2.2 — Normaliser duration: 0 → undefined

channel-content.mjs:243 (dm) et :332 (pt) forcent Number(x || 0). La règle 3 de isShortVideo (« verticale sans durée connue ⇒ pas short ») est donc inopérante : une verticale de 50 s devient « horizontale 0 s ».

  • Remplacer || 0 par undefined quand la valeur amont est absente (les 3 lectures de itemDurationSec() gèrent déjà l'absence).
  • Vérifier les autres providers pour le même défaut. (Rumble : parseRumbleViews('0 views') → undefined, testé)

4.3 Tâche 2.3 — Améliorer isShortVideo / isShortItem (les deux copies)

Règles à ajouter, après les 4 règles actuelles, en miroir strict serveur ↔ front :

# Nouvelle règle Justification Emplacement
a isShort === true + durée inconnue ⇒ short Aujourd'hui le flag natif retombe en règle 3/4 et perd le signal du provider. « Le provider a parlé » prime quand il n'y a rien pour le contredire. search-filters.mjs:194-197, video-kind.ts:76-80
b Carré (w == h) + durée ≤ 60 s + flag natif ⇒ short Instagram/TikTok repost en 1:1 ; aujourd'hui exclu par la règle 3 (non-vertical ⇒ jamais short). Uniquement si le provider a un flag ou un marqueur /shorts/ — pas sur la seule durée. idem
c kind === 'clip' garde la priorité (déjà le cas) mais ne doit plus être contredit par type:'video' Cohérence avec les onglets de chaîne. idem
  • Écrire d'abord les tests des 2 implémentations avant de modifier (parité mirror).
  • Documenter chaque règle et sa raison dans les deux fichiers (les commentaires existants sont en français, on suit).

4.4 Tâche 2.4 — Neutraliser le code mort

  • Supprimer src/app/search/adapters/base.ts (24 lignes, HttpAdapter non étendu, forme SearchItem obsolète).
  • Supprimer la branche morte channel-content.mjs:155 (retour identique ligne 157).
  • Supprimer pruneYoutubeCache ou le brancher (cf. Phase 4, tâche 4.6). (reporté à la phase 4 — c'est le point d'entrée du cache générique)

4.5 Critères d'acceptation

  • npm run test:kind et npm run test:section verts avec les 3 nouvelles règles.
  • npm run test:filters vert, avec les mêmes cas que test:kind (garantie de parité).
  • Page chaîne Twitch : la carte du live affiche le badge LIVE. (video-card : badge générique via isLiveVideoItem() ; typecheck AOT vert)
  • Fixture PeerTube verticale 60 s → pastille SHORT ; fixture horizontale 60 s → pas de pastille. (test:kind)

5. Phase 3 — Fiabilité des sources fragiles

Objectif : traiter les 3 fournisseurs dont l'ingestion peut échouer silencieusement ou produire des données fausses.

5.1 Rumble (anomalies #1, #2)

  • 3.1 Parser le JSON-LD en priorité : script[type="application/ld+json"] contenant VideoObject → datePublished, uploadDate, thumbnailUrl, interactionStatistic (vues). Plus stable que le DOM li.video-listing-entry.
  • 3.2 Repli <link rel="canonical"> pour l'URL propre quand l'ancre contient des paramètres de tracking.
  • 3.3 Cache négatif court (5 min, clé search:vide:<hash>) quand un challenge Cloudflare est détecté → évite de marteler l'upstream et de répéter le délai de 12-20 s à chaque recherche utilisateur.
  • 3.4 Distinguer échec de vide : remonter { error: 'rumble_cloudflare_challenge' } dans errors.ru au lieu d'un groupe [] silencieux, pour que l'UI puisse afficher un bandeau (§9.4).
  • 3.5 Extraire channelExternalId du slug /c/… — débloque ruContent qui ne peut aujourd'hui filtrer que sur uploaderName/url (Phase 5).
  • 3.6 Rendre la taille du limit/budget adaptative : RUMBLE_MAX_CARDS (défaut 50).

5.2 Twitch (anomalies #4, #5)

  • 3.7 Conserver le curseur Helix dans la réponse de contenu de chaîne et l'accepter en entrée : ajouter un paramètre cursor à ChannelContentQuery et le renvoyer par le front au lieu d'un page incrémental. Ou, si on garde page, stocker le curseur dans un cache court keyé (user_id, type, page, sort) comme le fait déjà YouTube pour ses pageToken.
  • 3.8 Corriger le tag du live : type: 'live', isLive: true, kind: 'live', viewers: viewer_count (décision prise en phase 0 : viewers, pas views — un viewer_count n'est pas un compteur de vues ; déclaré dans SUGGESTION_V2_FIELDS + VideoItem + adaptateurs front).
  • 3.9 Paralléliser les sections VODs / Clips (actuellement séquentielles après les lives, twitch.mjs:441-519) : Promise.all des deux, gains ~1 appel de latence. (fait — Promise.allSettled sur deux blocs isolés ; le risque d'« ordre des sections » annoncé par le plan est écarté par la mesure : l'ordre de rendu est vérifié par test)
  • 3.10 Budget de section configurable : TWITCH_SECTION_BUDGET (défaut : l'existant max(4, ceil(perPage/3))). (fait — une valeur, lisible ; défaut historique strictement inchangé, et first par broadcaster aligné sur le budget pour ne pas payer le transport de résultats jetés)
  • 3.11 Cache mémoire court (30 s) par (query, section) — Helix a un rate-limit strict et une recherche en déclenche jusqu'à 10 appels. (couvert par la phase 4, pas de second cache — le cache générique de la phase 4 enveloppe search() des 5 providers non-YouTube. Réserve : le contenu de chaîne n'est pas caché, ce qui reste une piste si Helix limite en production)
  • 3.12 Persister les pageToken de contenu de chaîne dans la table search_cache (Phase 4) avec TTL 5 min, keyé (provider, type, channelId, sort, q, page) → supprime la limitation « process-local, vers-l'avant uniquement » et permet le scale horizontal. (fait — L1 mémoire conservé + L2 search_cache sous le namespace yt_tokens, TTL 5 min. Corrige au passage un défaut silencieux : au-delà de 500 clés, l'ancienne Map faisait clear() et la pagination repartait de la page 1 sans erreur ni avertissement. L'éviction est désormais ciblée.)

5.3 YouTube (anomalie #6)

  • 3.12 Persister les pageToken de contenu de chaîne dans la table search_cache (Phase 4) avec TTL 5 min, keyé (provider, type, channelId, sort, q, page) → supprime la limitation « process-local, vers-l'avant uniquement » et permet le scale horizontal. (fait — L1 mémoire conservé + L2 search_cache sous le namespace yt_tokens, TTL 5 min. Corrige au passage un défaut silencieux : au-delà de 500 clés, l'ancienne Map faisait clear() et la pagination repartait de la page 1 sans erreur ni avertissement. L'éviction est désormais ciblée. npm run test:tokens.)

5.4 Critères d'acceptation

  • 20 requêtes ?providers=ru consécutives en moins de 60 s → au plus 2 appels sortants (cache négatif). (resetRumbleNegativeCache() exporté ; comportement couvert par test:rumble)
  • Un challenge Cloudflare simulé produit errors.ru = 'rumble_cloudflare_challenge' et non un groupe vide. (search() lève ; propagé par le fan-out allSettled de /api/search)
  • GET /api/channels/tw/<login>/content?type=videos&page=1 puis page=2 → 0 doublon d'id. (curseur Helix persisté de bout en bout)
  • GET /api/channels/tw/<login>/content?type=live → items avec isLive === true et type === 'live'.
  • npm run test:downloads, test:subscriptions, test:search-e2e verts.

6. Phase 4 — Cache générique & observabilité

Objectif : sortir du cache YouTube-only (anomalie #15) et rendre les décisions de bascule observables.

6.1 Tâche 4.1 — Table search_cache générique

-- db/migrations/20260930_add_search_cache.sql
CREATE TABLE IF NOT EXISTS search_cache (
  provider    TEXT NOT NULL,
  cache_key   TEXT NOT NULL,
  query       TEXT NOT NULL,
  payload_json TEXT NOT NULL,
  source      TEXT,                       -- innertube | scrape | api | rest | jsonrpc | html
  hit_count   INTEGER NOT NULL DEFAULT 0,
  created_at  INTEGER NOT NULL,
  expires_at  INTEGER NOT NULL,
  PRIMARY KEY (provider, cache_key)
);
CREATE INDEX IF NOT EXISTS idx_search_cache_exp ON search_cache(expires_at);
  • Clé : <provider>|<sha256(q|perPage|page|sort|filtersCacheKey)> — identique au format YouTube actuel (hashSearchKey, youtube-common.mjs:156), donc migration réversible.
  • TTL par provider : SEARCH_CACHE_TTL_MS_YT (30 min, quota) / SEARCH_CACHE_TTL_MS_DEFAULT (5 min) / surcharge par provider.
  • Plafond par provider (2 000 lignes, purge par expires_at DESC comme aujourd'hui db.mjs:1409).
  • Ne jamais persister un résultat vide (règle déjà appliquée pour YT, youtube.mjs:213-217) → à généraliser.
  • hit_count incrémenté à chaque lecture.

Implémentation : getCachedSearch / setCachedSearch / pruneSearchCache / searchCacheStats dans server/db.mjs. Le cache est appliqué dans server/providers/registry.mjs (un seul point d'entrée pour les 5 adaptateurs non-YT) et non dans chaque adaptateur : c'est le même raisonnement que la phase 0 — un point unique, donc rien ne peut l'oublier. YouTube est exclu : il a déjà son cache à deux niveaux et l'annoter d'une source par voie (innertube/scrape/api) ; un second cache masquerait les fallbacks dans les métriques.

6.2 Tâche 4.2 — Migration de youtube_search_cache

  • Migrer les lignes existantes vers search_cache, puis laisser youtube_search_cache en lecture de repli pendant 1 version (fallback si la table search_cache est absente d'une base ancienne).

Implémentation : migrateYoutubeCacheToSearchCache() (INSERT OR IGNORE sur les lignes non expirées, donc n'écrase jamais une entrée plus fraîche), appelée une fois au boot. getCachedYoutubeSearch / setCachedYoutubeSearch deviennent des surcouches : primaire search_cache, repli youtube_search_cache. pruneYoutubeCache() délègue désormais à pruneSearchCache() (2.4 décomblée).

6.3 Tâche 4.3 — Observabilité

  • GET /api/providers/health → pour chaque provider : { ok, latencyMs, lastSuccessAt, consecutiveFailures, lastError } (sonde légère : un appel de recherche limit=1, protégé par un cache de 60 s pour ne pas créer l'inverse du problème).
  • GET /api/providers/metrics → hit/miss cache, nombre d'appels, fallbacks, erreurs par provider et par source.
  • Étendre /healthz (server/index.mjs:2880-2911) avec un résumé de ces métriques (il expose déjà mode YT, binOk, cache, quota du jour).
  • Persister les métriques dans une table provider_metrics (même schéma que youtube_metrics, généralisé).

Implémentation : incProviderMetrics / providerMetricsSnapshot / purgeProviderMetrics dans db.mjs, une ligne par (heure, provider), rétention 24 h (PROVIDER_METRICS_RETENTION_H). Les compteurs ne sont incrémentés que sur les appels amont réels — un hit de cache ne les touche pas, sinon le taux d'échec apparent disparaîtrait sous l'effet du cache.

6.4 Tâche 4.4 — Bascule dynamique YouTube

  • Mesurer le taux d'échec InnerTube sur fenêtre glissante d'1 h via provider_metrics (seuil YT_INNERTUBE_AUTO_FAILOVER=0.2) → bascule temporaire sur scrape-first, avec retour automatique quand le taux repasse sous le seuil et cooldown de 15 min (anti-oscillation).
  • Journaliser chaque bascule pour que le comportement soit explicite dans les logs.

Implémentation : recordInnerTubeOutcome() + getEffectiveSearchMode() dans youtube-common.mjs. youtube.mjs branche sur le mode effectif — et l'inclut dans la clé de cache, sinon la bascule ferait servir des résultats InnerTube comme s'ils venaient du scrape. Une bascule n'écrase que les modes qui dépendent d'InnerTube (api-only reste api-only). Seuil de déclenchement : ≥ 3 appels sur la fenêtre, pour ne pas basculer sur un échantillon de 1.

Correctif (trouvé en phase 5) : la bascule était inopérante en production. noteInnerTube() lisait providerMetricsSnapshot() mais n'écrivait jamais dans provider_metrics, et le registre qui mesure les appels n'enregistre pas YouTube (il le sert depuis son propre chemin). La ligne yt était donc absente du snapshot, row.calls < 3 sortait toujours, et le failover ne pouvait pas se déclencher — les tests passaient parce qu'ils injectaient un snapshot synthétique. noteInnerTube(ok, latencyMs, error) appelle désormais incProviderMetrics('yt', …) avant de lire le snapshot (sinon la tentative courante n'est pas comptée). Test de régression dans test:cache : la production ecrit bien une ligne yt dans provider_metrics → la bascule se declenche sur un snapshot issu de la production.

6.5 Tâche 4.5 — pruneYoutubeCache

  • Appeler la purge (désormais pruneSearchCache) dans un setInterval de 10 min au boot, avec unref() pour ne pas empêcher l'arrêt du process.

Implémentation : startSearchCacheJanitor() appelé dans le callback d'app.listen, plus une purge différée à 2 s (une base restée hors ligne des semaines accumule des lignes expirées en masse).

6.6 Critères d'acceptation

  • Deuxième recherche identique sur dm/od/pt < 50 ms et zéro appel amont (compteur vérifié via /api/providers/metrics). → mesuré : 1199 ms → 26 ms, calls reste à 1 après le 2ᵉ appel, hits passe à 1.
  • hit_count incrémente à chaque hit ; GET /api/providers/metrics expose un ratio.
  • npm run test:api + test:search-e2e verts après migration de table. (+ test:cache, 54 assertions)

7. Phase 5 — Table videos & fin de la dénormalisation

Objectif : créer un catalogue vidéo minimal, résoudre l'anomalie #13 (like sans watch_history → titre vide) et préparer les vues futures (« vidéos vues », tendances locales, recommandations).

7.1 Tâche 5.1 — Migration

-- db/migrations/20261001_add_videos_table.sql
CREATE TABLE IF NOT EXISTS videos (
  provider           TEXT NOT NULL,          -- nom long (convention existante)
  video_id           TEXT NOT NULL,
  title              TEXT,
  thumbnail          TEXT,
  duration_seconds   INTEGER,
  views              INTEGER,
  published_at       TEXT,
  url                TEXT,
  kind               TEXT,                   -- video | short | live | clip | channel
  channel_external_id TEXT,
  channel_name       TEXT,
  channel_avatar_url TEXT,
  width              INTEGER,
  height             INTEGER,
  raw_json           TEXT,                   -- debug, tronqué à 4 Ko
  captured_at        TEXT NOT NULL,          -- fraîcheur
  PRIMARY KEY (provider, video_id)
);
CREATE INDEX IF NOT EXISTS idx_videos_captured ON videos(captured_at DESC);
CREATE INDEX IF NOT EXISTS idx_videos_channel ON videos(provider, channel_external_id);
  • Volontairement mince : pas de table de streams, pas de formats, pas de commentaires.
  • captured_at permet d'afficher un indicateur de fraîcheur (§9.3).
  • Fichier réel : db/migrations/20260930_add_videos_table.sql (le plan annonçait 20261001).
  • created_at ajouté (distinct de captured_at) : captured_at est rafraîchi à chaque ré-observation, created_at reste la date de première insertion. Le plan ne les distinguait pas.
  • Le backfill normalise provider (nom court → nom long) dans les deux sources, sinon ('yt','id') et ('youtube','id') créent deux lignes et les lectures normalisées n'en voient qu'une.

7.2 Tâche 5.2 — Upsert depuis les 3 points d'écriture existants

  • upsertWatchHistory
  • likeVideo — c'est le point qui comblait le trou : likeVideo n'appelait upsertWatchHistory que si un titre ou une vignette était fourni, donc un like « à l'aveugle » ne laissait aucune trace.
  • addPlaylistVideo
  • Helper upsertVideoRow(dto), appel best-effort (entrée invalide → false, jamais d'exception).
  • COALESCE sur tous les champs : un appel minimal (sans titre) n'efface pas les métadonnées déjà connues.
  • 0, négatif et chaîne vide ne sont jamais persistés (NULL) — cohérent avec la règle « métadonnée absente = undefined, jamais 0 ».

7.3 Tâche 5.3 — Résoudre le trou des likes

  • listLikedVideos : LEFT JOIN videos en source primaire, repli watch_history puis playlist_items.
  • Les deux côtés du JOIN normalisent le provider : video_tags.provider est stocké tel quel par likeVideo (court ou long selon le front), videos est clé en nom long. Sans ça, un like émis avec yt ne trouvait aucune ligne youtube — c'est-à-dire le bug d'origine.
  • Le repli playlist_items est une sous-requête corrélée, pas une jointure : UNIQUE(playlist_id, provider, video_id) autorise la même vidéo dans N playlists, une jointure aurait dupliqué les likes.
  • Backfill INSERT OR IGNORE depuis watch_history puis playlist_items, rejouable (2ᵉ passage = 0 insertion), et refait au boot par la migration.
  • captured_at exposé dans le résultat des likes (alimente l'indicateur de fraîcheur §9.3).

7.4 Tâche 5.4 — Déréplication progressive (optionnelle, phase suivante)

  • Les tables watch_history / playlist_items gardent title/thumbnail en lecture (dénormalisation tolérée) mais cessent d'écrire ces colonnes une fois la phase stable — permet un revert sans perte.
  • Non fait volontairement : le repli de 5.3 s'appuie encore sur ces colonnes, et watch_history sert aussi à l'affichage « vu récemment » hors ligne. À traiter avec la 6.3 et le §9.3.

7.5 Critères d'acceptation

  • Un like sans visionnage antérieur affiche un titre et une vignette corrects (videos: le like sans historique est liste / titre retrouve via videos malgre l'absence d'historique).
  • SELECT COUNT(*) FROM videos > 0 après une session de lecture normale.
  • captured_at est mis à jour à chaque ré-observation d'une même vidéo, created_at ne l'est pas.
  • npm run test:api, test:playlists, test:history verts, plus npm run test:videos (56 assertions).
  • Isolation des tests garantie : db.mjs refuse d'ouvrir la base de développement quand le processus est un *.test.mjs sans NEWTUBE_DB_FILE. Une faute de frappe sur le nom de variable avait déjà écrit des fixtures (dm|k1, k) dans db/newtube.db ; lignes supprimées, youtube_search_cache (16 lignes réelles) intacte.

7.6 Rollback

La table est additive. Le Phase 5.3 (LEFT JOIN) est le seul changement de lecture → réversible en une ligne de SQL, les colonnes dénormalisées restant en place.

Défaut latent du runner de migrations (corrigé en phase 7.7). Un fichier BEGIN TRANSACTION; … COMMIT; qui échoue au milieu (typiquement un ALTER TABLE … ADD COLUMN sur une colonne déjà présente) laisse la transaction ouverte dans better-sqlite3. Toutes les migrations suivantes s'y exécutent « avec succès », journalisent Migration applied, puis sont annulées à la fermeture du process : leurs tables disparaissent sans trace. Pire, db/schema.sql est réappliqué à chaque boot, donc un ALTER en migration duplique systématiquement la colonne sur une base neuve. Corrigé : (a) ROLLBACK de sécurité dans le finally du runner, (b) les colonnes de la phase 7.7 sont ajoutées en JS gardé par PRAGMA table_info (comme les upgrades playlists), pas par un fichier SQL.


8. Phase 6 — Identité de chaîne normalisée

Objectif : today's 3 conventions divergentes (UC…, "instance|channel", claim LBRY brut) deviennent un objet explicite.

8.1 Tâches

  • 6.1 Ajouter au contrat Suggestion v2 :
    channelRef?: { provider: ProviderId; scheme: 'yt-uc' | 'dm-user' | 'tw-login'
                  | 'pt-composite' | 'od-claim' | 'ru-slug'; value: string }
    
    → ChannelRef / ChannelRefScheme dans src/app/shared/providers/channel-ref.ts, channelRef ajouté à SUGGESTION_V2_FIELDS (registry.mjs) et à SuggestionItemV1.
  • 6.2 Renseigner channelRef dans les 6 adaptateurs serveur, sans supprimer les champs legacy (channelExternalId etc.) → transition non cassante.
  • 6.3 Utilitaire de résolution channelRefFrom(legacy) côté front pour les anciennes entrées en cache (localStorage 5 min, base 6 h, cache SQLite 30 min → pas de problème de cohérence).
  • 6.4 Utiliser scheme dans channel-registry.mjs pour unifier parsePeerTubeExternalId (:141-145) et l'normalisation @ d'Odysee (:340 vs :353).

Écart assumé : l'annotation est appliquée dans registry.mjs (une boucle sur les 6 providers) et non dans les 6 adaptateurs. Même raisonnement que le cache de la phase 4 : un seul point d'entrée, donc aucun fournisseur ne peut l'oublier. Elle est posée au-dessus de la couche cache, pour que la réponse soit identique que les items viennent du réseau ou d'une entrée de cache écrite avant la phase 6 (prouvé par test:cache : channelRef annote aussi les items issus du cache).

Trois bugs trouvés en faisant la phase 6 :

  1. buildPeerTubeComposite sans instance produisait un composite bancal (toto seul), que fetchPeerTubeChannel lisait comme un nom d'hôte → https://toto. L'instance est maintenant obligatoire, comme l'exigeait déjà le front.
  2. ru.ts jetait channelExternalId : le slug /c/<slug> extrait côté serveur (phase 3.5) n'arrivait jamais à l'UI, donc le nom de chaîne Rumble n'était pas cliquable. Récupéré.
  3. Les imports from 'src/app/shared/…' des adaptateurs n'ont jamais été résolus à l'exécution — TypeScript les élimine quand ils ne servent qu'aux types (import { VideoItem } utilisé uniquement en as VideoItem). Ils masquaient donc le fait que ce style de specifier ne résout pas sous ts-node/esm ; le premier import de valeur a fait tomber test:search. Corrigés en chemins relatifs (../../shared/providers/channel-ref).

8.2 Critères d'acceptation

  • Un test unitaire vérifie que les 3 conventions historiques se reconvertissent en channelRef sans perte.
  • pt et od produisent un channelRef.value strictement identique à ce que produit le front aujourd'hui (comparaison sur fixtures). → npm run test:channelref rejoue dans le test les règles historiques des 6 adaptateurs front et compare champ à champ sur 33 fixtures, cas dégradés compris. La parité des listes de schemes serveur ↔ front est vérifiée sur la source TypeScript, pas sur une copie.
  • Un seul jeu de règles pour Odysee : normalizeOdyseeClaim / odyseeClaimToResolveArg / odyseeClaimToSlug remplace les deux normalisations divergentes de channel-registry.mjs.

9. Phase 7 — Présentation

Objectif : la reflects enfin ce que la collecte sait déjà.

9.1 Tâche 7.1 — Badges enrichis (video-card.component.html)

Badge Règle Prérequis
🔴 LIVE · 12,4 k afficher viewer_count quand isLive Phase 3.8 (champ viewers)
CLIP kind === 'clip' Phase 2.1 (propagation)
SHORT isShortVideo() avec signal fiable (règles 2/3) Phase 2.1
CHAÎNE type === 'channel' Phase 2.1
durée afficher — au lieu de 0:00 quand duration absent Phase 2.2
vues afficher « vues indisponibles » si absent, jamais un vide Phase 1.1
  • Badges génériques (les 6 lignes ci-dessus) : plus de conditionnement à provider === 'twitch'. LIVE / SHORT / CLIP / CHAÎNE dérivés de isLiveVideoItem() / isShortVideoItem() / type.
  • durationSec absent → — au lieu de 0:00.
  • viewCount absent → « vues indisponibles » + infobulle explicative.
  • viewers affiché uniquement si isLive (sinon c'est un compteur de vues déguisé).

9.2 Tâche 7.2 — Grille adaptative à l'orientation

  • video-card : width/height connus ⇒ classe CSS is-vertical ; CSS Grid grid-auto-rows pour éviter le letterbox des verticales en 16:9. (fait — la frame portait aspect-video en dur : toute verticale était rognée par object-cover, donc une partie du sujet disparaissait, alors que la donnée est disponible depuis la phase 2.1.)
    • Orientation : source unique video-kind.isVerticalVideoItem, même seuil que isShortVideo (VERTICAL_MAX_RATIO = 0.8) — pas de copie, sinon un 4:5 serait « vertical » pour la mise en page et « horizontal » pour la classification, et la carte se contredirait. Dimensions absentes ⇒ 16:9 (jamais d'orientation devinée).
    • Écart assumé vs grid-auto-rows : le row-span exige de connaître la hauteur en pixels de chaque carte (texte, badges, hauteur de vignette) — un span estimé déborde ou se recouvre, et l'erreur est visible à l'écran. On borne donc la hauteur : une verticale est limitée à 1,8 × la hauteur d'une frame 16:9 (VERTICAL_FRAME_HEIGHT_FACTOR), largeur déduite du ratio puis recentrée, et items-start sur la grille empêche la carte haute d'étirer ses voisines. Ratio préservé, letterbox supprimé, aucun débordement. Vrai masonry = mesure JS, hors périmètre.
    • Tests : test:kind étendu (ratio, bornes, plancher de lisibilité à 38 %, cohérence du seuil partagé avec la classification).

9.3 Tâche 7.3 — Indicateur de fraîcheur & source

  • Tooltip sur la carte : captured_at (« capturé il y a 2 min ») + source (InnerTube / API / scrape / REST), Issue #5 de l'analyse. (fait — capturedAt (epoch ms de la collecte RÉELLE) et source sont posés au registre, dans les 6 adaptateurs et via un module de mapping commun adapters/provenance.ts. Trois règles d'honnêteté, chacune couverte par un test : un champ absent reste undefined (jamais de Date.now() côté front, qui ferait passer du cache de 4 min pour du neuf) ; un hit de cache conserve l'instant du vrai appel amont (search_cache.created_at en repli pour les entrées sans provenance) ; une valeur absurde (0, négatif, timestamp en secondes) est rejetée plutôt qu'affichée.)
  • Utilisable en mode debug via ?debug=1 (retourner aussi le raw JSON tronqué). (fait — ?debug=1 sur /api/search renvoie la provenance par provider + 2 items d'échantillon, dans server/search-debug.mjs : troncature à 600 car., redaction des clés d'habilitation (y compris imbriquées), dégradation à [unserialisable] sur un raw circulaire. Les noms des champs mappés sont exposés (c'est ce qui distingue « views absent » de « views nul »). Réserve assumée : aucun adaptateur ne conserve aujourd'hui la charge utile amont dans un champ raw — les 6 fetcher appellent fetch directement, il n'y a pas de couche HTTP commune où l'accrocher ; la branche raw est donc prête mais inactive.)

9.4 Tâche 7.4 — États vides et erreurs partielles

  • Distinguer visuellement errors[p] = 'échec' (bandeau ambre, via reflectProviderErrors search.component.ts:805-824) de groups[p] = [] (état vide neutre « aucun résultat »). → providerErrorList structuré ({ provider, reason }) consommé par app-search-result-grid.
  • Alimentation : Phase 3.4 (Rumble) + Phase 4.3 (/api/providers/health) → badge discret « source dégradée » dans le header. (fait — ProviderHealthService (TTL client 60 s aligné sur le cache serveur, shareReplay pour éviter un second aller-retour) + normalizeHealth en module pur testé. Le badge n'apparaît que pour l'état degraded : un provider éteint par FF_<PROVIDER> est disabled, pas en panne — sinon un ribbon d'incident s'afficherait en permanence sur une recherche parfaitement saine. Rafraîchissement déclenché APRÈS une recherche, jamais au chargement de la page, pour ne pas sonder les upstreams avant que l'utilisateur ait tapé quoi que ce soit. L'échec de l'endpoint de santé fait disparaître le badge plutôt qu'afficher un « tout va bien » faux.)

9.5 Tâche 7.5 — Filtres par capacité réelle

  • Ne proposer l'onglet « Shorts » / « Playlists » / « Live » sur une page chaîne que si la capacité du provider l'est (source unique = Phase 0.2) → supprime les onglets qui renvoient toujours [] (dm/shorts, tw/playlists, od/, ru/). → canShowPill() dans search.component.ts, alimenté par PROVIDER_CAPABILITIES et par les résultats réels (typeCounts()) : une pastille reste affichée si elle est sélectionnée, même vide, pour permettre de revenir en arrière.

9.6 Tâche 7.6 — Squelettons par provider

  • Un squelette par provider qui répond, plutôt qu'un flash blanc quand 5/6 sources sont lentes. *(fait — deux volets. Côté visuel : video-card-skeleton est désormais shape-aware (aspect + largeur bornée, comme la carte réelle depuis 7.2 — un squelette 16:9 devant un Short 9:16 provoquait un saut de page à l'arrivée), et la grille nomme chaque source en attente (pendingProviders, une section + 2 cartes par source, excluant celles déjà en erreur). Le ratio des squelettes suit le filtre « Shorts » actif. Côté transport : /api/search ne répond plus seulement de façon atomique — avec Accept: application/x-ndjson (ou ?stream=1) il écrit une ligne JSON par provider dès qu'elle est résolue, puis une ligne done portant le contrat v2 (même filtrage, mêmes erreurs, UN seul fan-out partagé entre les deux modes, dans search-transport.mjs). Côté client, SearchService.request$ émet un snapshot progressif par provider (partial: true puis false) au lieu du Promise.all qui ne montrait rien tant que la source la plus lente répondait. Le front dégrade proprement si le serveur ne parle pas NDJSON (réponse atomique lue telle quelle). (réserve assumée : le voyage du raw amont pour ?debug=1 et le vrai streaming « serveur → front » ne se découpent pas exactement comme prévu en phase : le front interroge déjà /api/search par provider, donc c'est l'agrégateur CLIENT qui détermine l'arrivée au fil de l'eau ; le NDJSON serveur reste le transport déterministe pour les consommateurs API/CLI. Les deux sont testés hors-ligne — npm run test:stream.)

9.7 Tâche 7.7 — Bandeau & description de chaîne

  • Étendre les 6 fetchers de channel-registry.mjs avec bannerUrl + description (déjà déclarés dans ChannelDetail mais valorisés null).
    • YouTube : brandingSettings (déjà récupéré dans fetchYoutubeChannel pour l'URL, il contient aussi image) — source la plus facile.
    • Dailymotion : cover_url / description.
    • PeerTube : banners[].path (plus grande) / description.
    • Odysee : value.thumbnail élargi via ?size=1200x600 (pas de bandeau LBRY distinct) / value.description.
    • Twitch : banner_image_url dans /users.
    • Rumble : une seule requête HTML, og:description (deux ordres d'attributs tolérés) ; pas de bandeau dédié, on ne duplique pas og:image.
    • (fait — bannerUrl passe par un assainissement http(s) double (fetch + persistance), description normalisée et bornée à 600 caractères. Persistance : colonnes banner_url/description sur channels (schéma + migration 20260929_*), channelRowToMeta/upsertChannelRow/ensureChannelFresh mis à jour, front inchangé (le header affichait déjà banner/description). npm run test:banners = 12 tests hors-ligne.)

9.8 Critères d'acceptation

  • Une page chaîne PeerTube affiche la pastille SHORT sur une verticale de 60 s et pas sur une horizontale de 60 s. (test:kind)
  • Un provider en échec affiche un bandeau ; un provider réellement vide affiche un état vide neutre.
  • Aucun onglet d'onglet vide n'est proposé sur les pages chaîne. (canShowPill())

9.9 Shorts-catalogue — la page /shorts alimentée par le catalogue (hors plan initial)

  • SearchService.fetchCatalogShorts(providerLong, q, { page, pageSize }) : un provider, filtre type: 'shorts' (serveur quand il sait, matchesFilters sinon), via searchAdapter (cache 60 s, timeout 8 s, 1 retry, erreurs avalées) — le flux ne casse jamais sur un provider lent ou éteint (providerError → pastille « Source dégradée »).
  • WatchShortComponent : catalogue d'abord (feed, pagination par pages, mode « Pour toi » via catalogOrLegacy), repli historique direct sous SHORTS_CATALOG_MIN_ITEMS (= 3) ou en erreur ; Twitch garde ses clips (pas d'équivalent catalogue) ; enrichissement des durées YouTube et portes accepts() inchangés.
  • Pont pur src/app/search/shorts-catalog.ts (catalogShortToVideo, formatShortMeta, formatCapturedAgo) + spec hors-ligne npm run test:shorts-catalog (27 assertions : pas d'id fantôme, pas de fraîcheur inventée, pas de « 0 vue »).
  • Avatars paresseux via channelMeta (GET /channels/:provider/:externalId, une requête par vidéo, silence en échec) + initiale de repli — plus d'image cassée quand la recherche ne fournit pas d'avatar.
  • Refonte pro façon YouTube + mobile : scène plein écran (100dvh, fond d'ambiance flouté), rail d'actions overlay dans le cadre (J'aime / Commentaires / Partager / Son, cibles 48 px), méta sous le titre (« 12 k vues • il y a 2 j » + « capturé il y a 4 min • api »), pastille provider, safe-area, touch-action: pan-y, cadre sans radius sur mobile.
  • Son et lecture par provider : son par défaut (YT/OD/DM/PT), clips Twitch en muet forcé (pas d'API de volume sur l'embed) ; Odysee démarre muet (pas de rectangle « tap to unmute ») et ne passe au son que sur clic explicite du bouton Son (le geste autorise l'autoplay sonore) ; nudge playVideo YouTube si l'autoplay a calé.
  • Avance auto en fin de vidéo tous providers : playerState YouTube (0 = fin → suivant, 2 = pause → minuteur désarmé, 1 = reprise → réarmé) + minuteur générique sur durée connue ou estimée (clip Twitch = 60 s, sinon plafond Short 90 s) + 2,5 s de grâce, ignoré si l'onglet est caché.
  • Anti-Forbidden Dailymotion : allow_embed demandé côté serveur → embeddable → raw.allowEmbed, le garde isPlayable existant exclut ces vidéos avant affichage (même garde que le chemin historique).
  • Chrome auto-hide au hover (desktop, 2,5 s d'immobilité) ; capteur tactile mobile (l'iframe mange les touchers) : swipe = navigation garantie, tap = play/pause YouTube (API iframe) ou 6 s d'accès direct au lecteur sinon (les boutons du fournisseur restent atteignables) ; flèche retour accueil en haut à gauche sur mobile ; cadre quasi plein écran (--shorts-chrome: 5,5rem).
  • Avance auto fiabilisée : handshake listening YouTube SANS LEQUEL aucun événement player n'arrive (progression + fin mortes), onStateChange canonique, réessais bornés quand next() est absorbé par un pré-chargement en cours.
  • Pastilles icon-only (aucun texte : disque aux couleurs de la marque + glyphe — play YouTube, bulle Twitch, initiale sinon ; étincelles pour « Pour toi » ; globe cyclique Profil → FR → EN → FR+EN → Toutes pour les langues, anneau ambré quand un filtre actif, état dans le tooltip/aria-label) et filtre de langues préférées : chaque bouton — chaque provider + « Pour toi », catalogue comme historique — passe par effectiveShortsLangs() (resolveShortsLangs pur, spec).

10. Phase 8 — Architecture de fond

Objectif : faire du provider une extension plutôt qu'un câblage en 6 points.

10.1 Tâches

  • 8.1 Contrat unique ProviderAdapter { id, label, search, suggest?, channelContent, channelMeta, capabilities } — un seul fichier par provider devient l'unique point de contact. (fait — côté serveur, server/providers/registry.mjs construit providerAdapters[pid] : la recherche est la version déjà enveloppée (cache → channelRef → provenance), enrichie des deux volets chaîne (channelContent depuis channel-content.mjs, channelMeta depuis channel-registry.mjs — tous deux réorganisés en tables par id, fetchChannelContent/channelRegistry restant des façades de compat) et d'une déclaration capabilities (suggest, live, channelMeta, channelContent[]). Les routes (/channels/:provider/…/content, resolve, repli watch) passent toutes par getProviderAdapter(provider) — plus aucune table parallèle. Côté front, la fabrique de stratégie de chaîne devient une table CHANNEL_STRATEGIES: Record<ProviderId, ctor> au lieu d'un switch : un provider = une entrée, le reste est dérivé. Un test de contrat hors-ligne (npm run test:adapter) vérifie la forme des 6 adaptateurs, la fidélité capabilities ⟺ comportement et que le search du contrat EST la version enveloppée.)
  • 8.2 Suggestion v: 2 strict : les adaptateurs front exigent v === 2, sinon chemin de compatibilité v1 déprécié avec avertissement de logs.
  • 8.3 Feature flags par provider : FF_<PROVIDER> dans .env — désactiver ru sans redéployer (crucial vu la fragilité Cloudflare). Le fournisseur désactivé est exclu du fan-out et signalé dans la réponse (errors.ru = 'disabled_by_ff').
  • 8.4 Tests de contrat générés : chaque adaptateur passe le même test de forme Suggestion sur fixtures gelées, Lancé en CI sur les 6.
  • 8.5 Documentation générée depuis les tests : ce plan et le document de référence se régénèrent (champ ↔ fournisseur) pour ne pas devenir faux.

10.2 Notes d'implémentation (8.2, 8.3)

8.2 — contrat v2 strict. src/app/search/search-contract.ts centralise la lecture du groupe d'un provider : contrôle de v === 2, extraction de errors[<id>].message, et dégradation explicite. Les 6 adaptateurs passent tous par readSearchGroup() au lieu de relire res.groups[<id>] à la main (6 copies de la même logique, donc 6 occasions de diverger).

  • Le chemin v1 reste lisible : un déploiement partiel (front plus récent que le serveur) ne doit pas casser la recherche. Il est en revanche averti une seule fois par process — un avertissement par frappe clavier serait ignoré.
  • errors[<id>].code === 'disabled_by_ff' n'est pas remonté comme providerError : un arrêt volontaire ne doit pas s'afficher comme une panne.

8.3 — FF_<PROVIDER>. server/providers/feature-flags.mjs, ids courts (FF_YT, FF_RU, …). Trois états, pas deux : non configuré = actif (comportement historique), 0/false/off/no/disabled = éteint. Une variable vide est traitée comme « non configuré » : un FF_RU= oublié dans un .env n'éteint pas le provider par surprise.

  • Flags appliqués avant le fan-out (/api/search) et avant la clé de cache (/api/search/suggest), sinon un résultat calculé provider éteint serait resservi après réactivation.
  • Un provider éteint garde sa colonne vide + errors[<id>].code = 'disabled_by_ff' : une colonne vide sans explication se lit comme « aucune correspondance » et envoie l'utilisateur chercher au mauvais endroit.
  • /api/providers/health ne sonde pas un provider éteint et renvoie disabled: true avec lastError: 'disabled_by_ff' : sondé, il répondrait ok: false, indiscernable d'une panne.
  • Tests : npm run test:flags (16 assertions d'unités + 4 d'intégration HTTP, dont la preuve qu'aucun upstream n'est contacté quand les 6 flags sont à 0).

8.4 / 8.5 — fixtures gelées et doc générée. npm run fixtures:record appelle les 6 adaptateurs réels et fige leur sortie ; npm run test:shapes rejoue ces fixtures au même test de forme que test:contract (validateur partagé : server/tests/lib/suggestion-shape.mjs) ; npm run doc:providers régénère la matrice « champ ↔ provider » du document de référence.

  • Le validateur est partagé entre les deux tests : deux définitions du contrat finiraient par diverger, et c'est la divergence qu'on cherche à détecter.
  • Le gel utilise une base jetable : search_cache étant persistant, un gel sur la base de dev rejouait d'anciens résultats et figeait une forme périmée (constaté : un correctif PeerTube n'apparaissait pas dans les fixtures).
  • tw (sans identifiants) et ru (Cloudflare) n'ont pas pu être gelés. Le trou est assumé dans le fichier (note) et signalé par ? dans la matrice : une couverture partielle ne doit pas pouvoir se lire comme complète.

Ce que le gel a réellement trouvé (bugs de production, pas de style) :

  1. Vues PeerTube jamais affichées : le serveur émettait viewCount, le front lisait views avec viewCount: undefined en dur dans l'adaptateur. views devient canonique, viewCount reste en alias.
  2. language PeerTube : objet au lieu de chaîne — l'API renvoie {id,label}, la garde cherchait .code et laissait passer l'objet entier.
  3. Contrat des zéros affine : PeerTube renvoie légitimement likes: 0. Refuser tout 0 confondait deux choses — un 0 inventé (interdit : « absent » doit rester absent) et un 0 rapporté par l'API (donnée vraie : « personne n'a aimé »). duration/width/height restent > 0 ; views/likes/dislikes/viewers acceptent >= 0.
  4. Odysee views/publishedAt : le code est juste, la source ne livre rien. Vérifié en direct : claim_search ne renvoie ni release_time ni l'objet video (donc pas de view_count), même demandés dans include. Le critère « ≥ 80 % des champs sur Odysee » n'est donc pas atteignable sur cet endpoint : il faut un autre endpoint (résolution par claim), pas un correctif de mapping. Consigné en §12/3b du document de référence.

11. Matrice de traçabilité anomalie → phase

# Anomalie (document de référence §12) Phase Tâche
1 Rumble échoue silencieusement 3 3.3, 3.4
2 Rumble sans identité de chaîne 1 / 3 1.5, 3.5
3 Odysee jette views/publishedAt, statut HTTP non vérifié 1 1.1, 1.2, 1.3
4 Pagination Twitch cassée 3 3.7
5 Item live Twitch mal taggé 3 3.8
6 pageToken YT process-local 3 3.12
7 toVideoItem perd 6 champs 2 2.1
8 Dérive de capacités (dm.playlists) 0 0.2
9 3 conventions d'identité divergentes 6 6.1-6.4
10 Branche morte channel-content.mjs:155 2 2.4
11 Code mort adapters/base.ts 2 2.4
12 Listes de providers en dur 0 0.1, 0.3
13 video_tags sans titre/vignette 5 5.1-5.3
14 pruneYoutubeCache jamais appelé 4 4.5
15 Cache serveur YouTube-only 4 4.1
Élément de l'analyse d'optimisation Phase
§1.1 — 7 champs disponibles mais jetés 1
§1.2 — champs structurellement absents (width/height YT-TW-RU, kind dm/pt/od, game, langue, hasSubtitles, viewCountRaw) 1 (déclarés en v2, remplis au fur et à mesure) + 7
§1.2 — champ raw en mode debug 5 (raw_json) + 7 (?debug=1)
§1.3 — fiabiliser Rumble / Twitch / cache générique 3, 4
§1.4 — bascule dynamique InnerTube 4
§2.1 — channelRef normalisé 6
§2.2 — table videos 5
§2.3 — centraliser la conversion provider 0
§2.4 — cache générique 4
§2.5 — pruneYoutubeCache 4
§3.1 — corriger toVideoItem 2
§3.2 / §3.3 — badges et grille 7
§3.4 — distinguer erreur et vide 3 + 7
§3.5 — capacités et cohérence chaîne 0 + 3 + 7
§4.1 / §4.2 / §4.3 — règles de classification + parité serveur/front 2
§5 — plugin provider, v2, métriques, tests de contrat, FF, doc générée 8

12. Jalons & charge

Jalon Contenu Charge cumulée
J1 — Socle + parité Phases 0 + 1 ~2,5 j
J2 — Classification visible Phase 2 + 7.1/7.4/7.5 ~4,5 j
J3 — Fiabilité Phase 3 ~7,5 j
J4 — Cache & observabilité Phase 4 ~10 j
J5 — Catalogue Phase 5 ~13 j
J6 — Identité Phase 6 ~15 j
J7 — Présentation Phase 7 complet ~18 j
J8 — Fond Phase 8 ~2 sem.

~ = demi-journée, + = 1-2 jours, ++ = 1 semaine+. Charge indicative hors revue et hors tests de bout en bout sur providers réels (qui nécessitent du réseau).


13. Risques, rollback & Feature flags

Risque Phase Parade
Régression silencieuse sur un provider peu testé toutes npm run test:contract en CI sur les 6 + test:search-e2e
Odysee : views / publishedAt après changement du include Lighthouse 1 Feature flag ODYSEE_INCLUDE_VIDEOMETA (défaut off) pendant 1 semaine
YouTube : nouvelle bascule dynamique oscillation 4.4 Cooldown 15 min + seuil haut (20 %) + journalisation ; YT_INNERTUBE_AUTO_FAILOVER désactivable
Table videos : migration cassée sur base existante 5 Migration additive + double-lecture (videos → watch_history → playlist_items)
toVideoItem plus strict : cartes qui perdent la vignette 2 thumbnailUrl ?? '' conservé ; test sur fixture sans vignette
Classification : divergence serveur/front après modification 2 Les deux implémentations sont modifiées par le même ticket, avec les mêmes cas de test
Augmentation du volume DB (table videos) 5 Plafond de lignes + TTL de purge sur les lignes non observées depuis 90 j
Rumble bloqué par Cloudflare en prod 3 Cache négatif + FF_RUMBLE (Phase 8.3) + bandeau « source dégradée »

Ordre de déploiement recommandé : SQL (migrations) d'abord — elles sont toutes CREATE TABLE IF NOT EXISTS ou ALTER TABLE ADD COLUMN, donc sûres — puis code applicatif. Aucune phase ne contient de DROP.


14. Annexe — Commandes de test

npm run test:contract      # Phase 0.6 — forme Suggestion des 6 providers (offline)   [à créer]
npm run test:filters       # Phase 2   — filtres serveur + classification (offline)
npm run test:kind          # Phase 2   — classification Short/Live front (offline)
npm run test:section       # Phase 2   — politique de section + isPlayable (offline)
npm run test:ytinnertube   # Phase 1.9 — mappers InnerTube + parseRelativeDate (offline)
npm run test:ytscrape      # Phase 3   — parsers yt-dlp + classification d'erreurs
npm run test:search        #          — SearchService, @, clavier/focus, filtres
npm run test:search-e2e    # toutes   — fan-out réel sur serveur isolé
npm run test:api           # Phase 5   — couverture routes API
npm run test:history       # Phase 5   — filtres d'historique
npm run test:playlists     # Phase 5   — visibilité + items
npm run test:downloads     # Phase 3   — jobs de téléchargement
npm run test:transcript    # Phase 1   — parsers json3/vtt + contrat
npm run test:cache         # Phase 4   — cache, métriques, bascule YT (64 assertions)
npm run test:videos        # Phase 5   — catalogue `videos` + trou des likes (56 assertions)
npm run test:channelref    # Phase 6   — identité de chaîne, parité serveur <-> front (12 assertions)

Règle CI : test:contract, test:filters, test:kind sont bloquants dès la Phase 2 (classification = comportement visible). test:search-e2e reste bloquant mais ne dépend pas du contenu réel des providers (forme du fan-out uniquement).


Plan établi le 2026-09-29, à partir de docs/ingestion-catalogue-video-par-fournisseur.md §12 et de l'analyse d'optimisation associée.