- 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)
59 KiB
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
- Principes directeurs
- Vue d'ensemble des phases
- Phase 0 — Socle : une seule source de vérité
- Phase 1 — Parité de champs (quick wins)
- Phase 2 — Propagation front & classification
- Phase 3 — Fiabilité des sources fragiles
- Phase 4 — Cache générique & observabilité
- Phase 5 — Table
videos& fin de la dénormalisation - Phase 6 — Identité de chaîne normalisée
- Phase 7 — Présentation
- Phase 8 — Architecture de fond
- Matrice de traçabilité anomalie → phase
- Jalons & charge
- Risques, rollback & Feature flags
- Annexe — Commandes de test
0. Principes directeurs
- Ne rien casser au collecting. Toute phase qui change le contrat
Suggestionpasse parv: 2(Phase 0) et reste rétrocompatible : les adaptateurs front ignorent les champs inconnus, le serveur tolère l'absence des nouveaux champs. - 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. - Aucune donnée inventée. Si un fournisseur ne donne pas
publishedAt, on ne le devine pas — on exposenullet l'UI affiche « — ». - 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).
- 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— exportePROVIDER_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éconcilieprovider-registry.ts:13-38etchannel-detail.model.ts:42-49(actuellement en contradiction surdm.playlists). Règle de réconciliation : la capacité vaut ce que le serveur sait réellement servir (channel-content.mjsfait foi), doncdm.playlists = true. - 0.3 Faire importer
ALL_PROVIDER_IDSpar :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: 2au JSDocSuggestion(server/providers/registry.mjs:5-16) et l'exposer dans la réponse/api/search(server/index.mjs:3120).vabsent = 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, unsuggest()mocké doit produire des objets dont tous les champs obligatoires (title,id,url,thumbnail,type) sont présents et typés, et dontdurationest unnumber ≥ 0ou absent. Test offline (fixtures JSON figées par provider). - 0.7 Ajouter
npm run test:contractet 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 dansprovider-ids.ts.providerSupportsLive()etHttpChannelProvider.supports()lisent la même table de capacités ;dm.playlistsesttruepartout.npm run test:contractpasse ; 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) faitDate.parse(raw)→NaN→ renvoie0;matchesFilters()(:254-258) n'écarte donc jamais un résultat sur le critère période ;sort=datecôté front (search.component.ts:732-751) trie donc surNaN→ 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=odrenvoie des items avecviewsetpublishedAtnon nuls sur ≥ 80 % des résultats. (nécessite le réseau — non vérifié)GET /api/search?q=x&providers=rurenvoiepublishedAt+channelExternalIdnon 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 partest:filters)- Une erreur HTTP Odysee (4xx/5xx) remonte dans
errors.odau lieu de produire un groupe vide silencieux. npm run test:filters,test:ytinnertube,test:search-e2e,test:contractverts.- 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
gameetlanguage(déjà dans le contrat, ignorés partout). - Ajouter un test unitaire : une fixture
channel-contentTwitch live →VideoItemavecisLive === true; une fixture PeerTube verticale 60 s avecwidth/height→ la règle d'orientation s'applique. (test:kind+test:sectioncouvrent les règles d'orientation ;toVideoItemn'est pas exporté, la propagation est couverte par le typecheck strict + le build AOTstrictTemplates)
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
|| 0parundefinedquand la valeur amont est absente (les 3 lectures deitemDurationSec()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,HttpAdapternon étendu, formeSearchItemobsolète). - Supprimer la branche morte
channel-content.mjs:155(retour identique ligne 157). - Supprimer
pruneYoutubeCacheou 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:kindetnpm run test:sectionverts avec les 3 nouvelles règles.npm run test:filtersvert, avec les mêmes cas quetest:kind(garantie de parité).- Page chaîne Twitch : la carte du live affiche le badge LIVE. (
video-card: badge générique viaisLiveVideoItem(); 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"]contenantVideoObject→datePublished,uploadDate,thumbnailUrl,interactionStatistic(vues). Plus stable que le DOMli.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' }danserrors.ruau lieu d'un groupe[]silencieux, pour que l'UI puisse afficher un bandeau (§9.4). - 3.5 Extraire
channelExternalIddu slug/c/…— débloqueruContentqui ne peut aujourd'hui filtrer que suruploaderName/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àChannelContentQueryet le renvoyer par le front au lieu d'unpageincrémental. Ou, si on gardepage, stocker le curseur dans un cache court keyé(user_id, type, page, sort)comme le fait déjà YouTube pour sespageToken. - 3.8 Corriger le tag du live :
type: 'live',isLive: true,kind: 'live',viewers: viewer_count(décision prise en phase 0 :viewers, pasviews— unviewer_countn'est pas un compteur de vues ; déclaré dansSUGGESTION_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.alldes deux, gains ~1 appel de latence. (fait —Promise.allSettledsur 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'existantmax(4, ceil(perPage/3))). (fait — une valeur, lisible ; défaut historique strictement inchangé, etfirstpar 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 enveloppesearch()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
pageTokende contenu de chaîne dans la tablesearch_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é + L2search_cachesous le namespaceyt_tokens, TTL 5 min. Corrige au passage un défaut silencieux : au-delà de 500 clés, l'ancienne Map faisaitclear()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
pageTokende contenu de chaîne dans la tablesearch_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é + L2search_cachesous le namespaceyt_tokens, TTL 5 min. Corrige au passage un défaut silencieux : au-delà de 500 clés, l'ancienne Map faisaitclear()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=ruconsécutives en moins de 60 s → au plus 2 appels sortants (cache négatif). (resetRumbleNegativeCache()exporté ; comportement couvert partest:rumble) - Un challenge Cloudflare simulé produit
errors.ru = 'rumble_cloudflare_challenge'et non un groupe vide. (search()lève ; propagé par le fan-outallSettledde/api/search) GET /api/channels/tw/<login>/content?type=videos&page=1puispage=2→ 0 doublon d'id. (curseur Helix persisté de bout en bout)GET /api/channels/tw/<login>/content?type=live→ items avecisLive === trueettype === 'live'.npm run test:downloads,test:subscriptions,test:search-e2everts.
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 DESCcomme aujourd'huidb.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_countincrémenté à chaque lecture.
Implémentation :
getCachedSearch/setCachedSearch/pruneSearchCache/searchCacheStatsdansserver/db.mjs. Le cache est appliqué dansserver/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'unesourcepar 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 laisseryoutube_search_cacheen lecture de repli pendant 1 version (fallback si la tablesearch_cacheest absente d'une base ancienne).
Implémentation :
migrateYoutubeCacheToSearchCache()(INSERT OR IGNOREsur les lignes non expirées, donc n'écrase jamais une entrée plus fraîche), appelée une fois au boot.getCachedYoutubeSearch/setCachedYoutubeSearchdeviennent des surcouches : primairesearch_cache, repliyoutube_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 recherchelimit=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 queyoutube_metrics, généralisé).
Implémentation :
incProviderMetrics/providerMetricsSnapshot/purgeProviderMetricsdansdb.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(seuilYT_INNERTUBE_AUTO_FAILOVER=0.2) → bascule temporaire surscrape-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()dansyoutube-common.mjs.youtube.mjsbranche 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-onlyresteapi-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()lisaitproviderMetricsSnapshot()mais n'écrivait jamais dansprovider_metrics, et le registre qui mesure les appels n'enregistre pas YouTube (il le sert depuis son propre chemin). La ligneytétait donc absente du snapshot,row.calls < 3sortait 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ésormaisincProviderMetrics('yt', …)avant de lire le snapshot (sinon la tentative courante n'est pas comptée). Test de régression danstest: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 unsetIntervalde 10 min au boot, avecunref()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,callsreste à 1 après le 2ᵉ appel,hitspasse à 1. hit_countincrémente à chaque hit ;GET /api/providers/metricsexpose un ratio.npm run test:api+test:search-e2everts 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_atpermet d'afficher un indicateur de fraîcheur (§9.3).- Fichier réel :
db/migrations/20260930_add_videos_table.sql(le plan annonçait20261001). created_atajouté (distinct decaptured_at) :captured_atest rafraîchi à chaque ré-observation,created_atreste 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
upsertWatchHistorylikeVideo— c'est le point qui comblait le trou :likeVideon'appelaitupsertWatchHistoryque 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). COALESCEsur 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 videosen source primaire, repliwatch_historypuisplaylist_items.- Les deux côtés du JOIN normalisent le provider :
video_tags.providerest stocké tel quel parlikeVideo(court ou long selon le front),videosest clé en nom long. Sans ça, un like émis avecytne trouvait aucune ligneyoutube— c'est-à-dire le bug d'origine. - Le repli
playlist_itemsest 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 IGNOREdepuiswatch_historypuisplaylist_items, rejouable (2ᵉ passage = 0 insertion), et refait au boot par la migration. captured_atexposé 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_itemsgardenttitle/thumbnailen 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_historysert 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_atest mis à jour à chaque ré-observation d'une même vidéo,created_atne l'est pas.npm run test:api,test:playlists,test:historyverts, plusnpm run test:videos(56 assertions).- Isolation des tests garantie :
db.mjsrefuse d'ouvrir la base de développement quand le processus est un*.test.mjssansNEWTUBE_DB_FILE. Une faute de frappe sur le nom de variable avait déjà écrit des fixtures (dm|k1,k) dansdb/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 unALTER TABLE … ADD COLUMNsur une colonne déjà présente) laisse la transaction ouverte dansbetter-sqlite3. Toutes les migrations suivantes s'y exécutent « avec succès », journalisentMigration applied, puis sont annulées à la fermeture du process : leurs tables disparaissent sans trace. Pire,db/schema.sqlest réappliqué à chaque boot, donc unALTERen migration duplique systématiquement la colonne sur une base neuve. Corrigé : (a)ROLLBACKde sécurité dans lefinallydu runner, (b) les colonnes de la phase 7.7 sont ajoutées en JS gardé parPRAGMA 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/ChannelRefSchemedanssrc/app/shared/providers/channel-ref.ts,channelRefajouté àSUGGESTION_V2_FIELDS(registry.mjs) et àSuggestionItemV1. - 6.2 Renseigner
channelRefdans les 6 adaptateurs serveur, sans supprimer les champs legacy (channelExternalIdetc.) → 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
schemedanschannel-registry.mjspour unifierparsePeerTubeExternalId(:141-145) et l'normalisation@d'Odysee (:340vs: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é partest:cache:channelRef annote aussi les items issus du cache).
Trois bugs trouvés en faisant la phase 6 :
buildPeerTubeCompositesans instance produisait un composite bancal (totoseul), quefetchPeerTubeChannellisait comme un nom d'hôte →https://toto. L'instance est maintenant obligatoire, comme l'exigeait déjà le front.ru.tsjetaitchannelExternalId: 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é.- 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 enas VideoItem). Ils masquaient donc le fait que ce style de specifier ne résout pas sousts-node/esm; le premier import de valeur a fait tombertest: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
channelRefsans perte. ptetodproduisent unchannelRef.valuestrictement identique à ce que produit le front aujourd'hui (comparaison sur fixtures). →npm run test:channelrefrejoue 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/odyseeClaimToSlugremplace les deux normalisations divergentes dechannel-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ÎNEdérivés deisLiveVideoItem()/isShortVideoItem()/type. durationSecabsent →—au lieu de0:00.viewCountabsent → « vues indisponibles » + infobulle explicative.viewersaffiché uniquement siisLive(sinon c'est un compteur de vues déguisé).
9.2 Tâche 7.2 — Grille adaptative à l'orientation
video-card:width/heightconnus ⇒ classe CSSis-vertical; CSS Gridgrid-auto-rowspour éviter le letterbox des verticales en 16:9. (fait — la frame portaitaspect-videoen dur : toute verticale était rognée parobject-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 queisShortVideo(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: lerow-spanexige de connaître la hauteur en pixels de chaque carte (texte, badges, hauteur de vignette) — unspanestimé 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, etitems-startsur 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).
- Orientation : source unique
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) etsourcesont posés au registre, dans les 6 adaptateurs et via un module de mapping communadapters/provenance.ts. Trois règles d'honnêteté, chacune couverte par un test : un champ absent resteundefined(jamais deDate.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_aten 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 lerawJSON tronqué). (fait —?debug=1sur/api/searchrenvoie la provenance par provider + 2 items d'échantillon, dansserver/search-debug.mjs: troncature à 600 car., redaction des clés d'habilitation (y compris imbriquées), dégradation à[unserialisable]sur unrawcirculaire. Les noms des champs mappés sont exposés (c'est ce qui distingue «viewsabsent » de «viewsnul »). Réserve assumée : aucun adaptateur ne conserve aujourd'hui la charge utile amont dans un champraw— les 6 fetcher appellentfetchdirectement, il n'y a pas de couche HTTP commune où l'accrocher ; la brancherawest donc prête mais inactive.)
9.4 Tâche 7.4 — États vides et erreurs partielles
- Distinguer visuellement
errors[p] = 'échec'(bandeau ambre, viareflectProviderErrorssearch.component.ts:805-824) degroups[p] = [](état vide neutre « aucun résultat »). →providerErrorListstructuré ({ provider, reason }) consommé parapp-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,shareReplaypour éviter un second aller-retour) +normalizeHealthen module pur testé. Le badge n'apparaît que pour l'étatdegraded: un provider éteint parFF_<PROVIDER>estdisabled, 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()danssearch.component.ts, alimenté parPROVIDER_CAPABILITIESet 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-skeletonest 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/searchne répond plus seulement de façon atomique — avecAccept: application/x-ndjson(ou?stream=1) il écrit une ligne JSON par provider dès qu'elle est résolue, puis une lignedoneportant le contrat v2 (même filtrage, mêmes erreurs, UN seul fan-out partagé entre les deux modes, danssearch-transport.mjs). Côté client,SearchService.request$émet un snapshot progressif par provider (partial: truepuisfalse) au lieu duPromise.allqui 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 durawamont pour?debug=1et le vrai streaming « serveur → front » ne se découpent pas exactement comme prévu en phase : le front interroge déjà/api/searchpar 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.mjsavecbannerUrl+description(déjà déclarés dansChannelDetailmais valorisésnull).- YouTube :
brandingSettings(déjà récupéré dansfetchYoutubeChannelpour l'URL, il contient aussiimage) — 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_urldans/users. - Rumble : une seule requête HTML,
og:description(deux ordres d'attributs tolérés) ; pas de bandeau dédié, on ne duplique pasog:image. - (fait —
bannerUrlpasse par un assainissement http(s) double (fetch + persistance),descriptionnormalisée et bornée à 600 caractères. Persistance : colonnesbanner_url/descriptionsurchannels(schéma + migration20260929_*),channelRowToMeta/upsertChannelRow/ensureChannelFreshmis à jour, front inchangé (le header affichait déjà banner/description).npm run test:banners= 12 tests hors-ligne.)
- YouTube :
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, filtretype: 'shorts'(serveur quand il sait,matchesFilterssinon), viasearchAdapter(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 » viacatalogOrLegacy), repli historique direct sousSHORTS_CATALOG_MIN_ITEMS(= 3) ou en erreur ; Twitch garde ses clips (pas d'équivalent catalogue) ; enrichissement des durées YouTube et portesaccepts()inchangés.- Pont pur
src/app/search/shorts-catalog.ts(catalogShortToVideo,formatShortMeta,formatCapturedAgo) + spec hors-lignenpm 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
playVideoYouTube si l'autoplay a calé. - Avance auto en fin de vidéo tous providers :
playerStateYouTube (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_embeddemandé côté serveur →embeddable→raw.allowEmbed, le gardeisPlayableexistant 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
listeningYouTube SANS LEQUEL aucun événement player n'arrive (progression + fin mortes),onStateChangecanonique, réessais bornés quandnext()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 pareffectiveShortsLangs()(resolveShortsLangspur, 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.mjsconstruitproviderAdapters[pid]: la recherche est la version déjà enveloppée (cache →channelRef→ provenance), enrichie des deux volets chaîne (channelContentdepuischannel-content.mjs,channelMetadepuischannel-registry.mjs— tous deux réorganisés en tables par id,fetchChannelContent/channelRegistryrestant des façades de compat) et d'une déclarationcapabilities(suggest,live,channelMeta,channelContent[]). Les routes (/channels/:provider/…/content,resolve, repli watch) passent toutes pargetProviderAdapter(provider)— plus aucune table parallèle. Côté front, la fabrique de stratégie de chaîne devient une tableCHANNEL_STRATEGIES: Record<ProviderId, ctor>au lieu d'unswitch: 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 ⟺ comportementet que lesearchdu contrat EST la version enveloppée.) - 8.2
Suggestion v: 2strict : les adaptateurs front exigentv === 2, sinon chemin de compatibilité v1 déprécié avec avertissement de logs. - 8.3 Feature flags par provider :
FF_<PROVIDER>dans.env— désactiverrusans 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
Suggestionsur 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é commeproviderError: 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/healthne sonde pas un provider éteint et renvoiedisabled: trueaveclastError: 'disabled_by_ff': sondé, il répondraitok: 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) etru(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) :
- Vues PeerTube jamais affichées : le serveur émettait
viewCount, le front lisaitviewsavecviewCount: undefineden dur dans l'adaptateur.viewsdevient canonique,viewCountreste en alias. languagePeerTube : objet au lieu de chaîne — l'API renvoie{id,label}, la garde cherchait.codeet laissait passer l'objet entier.- 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/heightrestent> 0;views/likes/dislikes/viewersacceptent>= 0. - Odysee
views/publishedAt: le code est juste, la source ne livre rien. Vérifié en direct :claim_searchne renvoie nirelease_timeni l'objetvideo(donc pas deview_count), même demandés dansinclude. 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.