Files
NewTube/docs/plan-phases-catalogue-classification.md
T
bruno 49859f4e81
CI / build-and-test (push) Successful in 14m49s
feat(shorts): pastilles a logos + filtre de langues preferees
Boutons fournisseurs avec disque aux couleurs de la marque + glyphe SVG (play YT, bulle Twitch, initiale sinon) ; icone etincelles pour Pour toi. Selecteur de langues Profil/FR/EN/FR+EN/Toutes (persiste newtube.shorts.langFilter, invite compris) : chaque flux passe par effectiveShortsLangs (resolveShortsLangs pur + spec). Docs 9.9.
2026-09-30 12:57:31 -04:00

674 lines
59 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`](./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`](./ingestion-catalogue-video-par-fournisseur.md) (analyse de référence) et l'analyse d'optimisation qui en découle.
---
## Table des matières
0. [Principes directeurs](#0-principes-directeurs)
1. [Vue d'ensemble des phases](#1-vue-densemble-des-phases)
2. [Phase 0 — Socle : une seule source de vérité](#2-phase-0--socle--une-seule-source-de-vérité)
3. [Phase 1 — Parité de champs (quick wins)](#3-phase-1--parité-de-champs-quick-wins)
4. [Phase 2 — Propagation front & classification](#4-phase-2--propagation-front--classification)
5. [Phase 3 — Fiabilité des sources fragiles](#5-phase-3--fiabilité-des-sources-fragiles)
6. [Phase 4 — Cache générique & observabilité](#6-phase-4--cache-générique--observabilité)
7. [Phase 5 — Table `videos` & fin de la dénormalisation](#7-phase-5--table-videos--fin-de-la-dénormalisation)
8. [Phase 6 — Identité de chaîne normalisée](#8-phase-6--identité-de-chaîne-normalisée)
9. [Phase 7 — Présentation](#9-phase-7--présentation)
10. [Phase 8 — Architecture de fond](#10-phase-8--architecture-de-fond)
11. [Matrice de traçabilité anomalie → phase](#11-matrice-de-traçabilité-anomalie--phase)
12. [Jalons & charge](#12-jalons--charge)
13. [Risques, rollback & Feature flags](#13-risques-rollback--feature-flags)
14. [Annexe — Commandes de test](#14-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
- [x] **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).
- [x] **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`.
- [x] **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)
- [x] **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).
- [x] **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).
- [x] **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).
- [x] **0.7** Ajouter `npm run test:contract` et le câbler dans `.github/workflows/ci.yml`.
### 2.2 Critères d'acceptation
- [x] `grep -rn "'yt','dm','tw','pt','od','ru'" src/` ne retourne plus que la définition dans `provider-ids.ts`.
- [x] `providerSupportsLive()` et `HttpChannelProvider.supports()` lisent **la même** table de capacités ; `dm.playlists` est `true` partout.
- [x] `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` :
```js
...(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é)*
- [x] `GET /api/search?q=x&providers=ru` renvoie `publishedAt` + `channelExternalId` non nuls. *(vérifié offline : `test:rumble`, 41 assertions)*
- [x] `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`)*
- [x] Une erreur HTTP Odysee (4xx/5xx) remonte dans `errors.od` au lieu de produire un groupe vide silencieux.
- [x] `npm run test:filters`, `test:ytinnertube`, `test:search-e2e`, `test:contract` verts.
- [x] 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`.
- [x] Propager les 6 champs avec la même defensive typing que les adaptateurs de recherche (`typeof x === 'number'` / `=== 'boolean'`).
- [x] Propager aussi `game` et `language` (déjà dans le contrat, ignorés partout).
- [x] 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 ».
- [x] Remplacer `|| 0` par `undefined` quand la valeur amont est absente (les 3 lectures de `itemDurationSec()` gèrent déjà l'absence).
- [x] 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 |
- [x] Écrire d'abord les tests des 2 implémentations **avant** de modifier (parité mirror).
- [x] 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
- [x] Supprimer `src/app/search/adapters/base.ts` (24 lignes, `HttpAdapter` non étendu, forme `SearchItem` obsolète).
- [x] 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
- [x] `npm run test:kind` et `npm run test:section` verts avec les 3 nouvelles règles.
- [x] `npm run test:filters` vert, avec **les mêmes cas** que `test:kind` (garantie de parité).
- [x] Page chaîne Twitch : la carte du live affiche le badge LIVE. *(`video-card` : badge générique via `isLiveVideoItem()` ; typecheck AOT vert)*
- [x] 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)
- [x] **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`.
- [x] **3.2** Repli `<link rel="canonical">` pour l'URL propre quand l'ancre contient des paramètres de tracking.
- [x] **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.
- [x] **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).
- [x] **3.5** Extraire `channelExternalId` du slug `/c/…` — débloque `ruContent` qui ne peut aujourd'hui filtrer que sur `uploaderName`/`url` (Phase 5).
- [x] **3.6** Rendre la taille du `limit`/budget adaptative : `RUMBLE_MAX_CARDS` (défaut 50).
### 5.2 Twitch (anomalies #4, #5)
- [x] **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`.
- [x] **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).
- [x] **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)*
- [x] **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)*
- [x] **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)*
- [x] **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)
- [x] **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
- [x] 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`)*
- [x] 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`)*
- [x] `GET /api/channels/tw/<login>/content?type=videos&page=1` puis `page=2` → **0 doublon** d'id. *(curseur Helix persisté de bout en bout)*
- [x] `GET /api/channels/tw/<login>/content?type=live` → items avec `isLive === true` et `type === 'live'`.
- [x] `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
```sql
-- 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);
```
- [x] Clé : `<provider>|<sha256(q|perPage|page|sort|filtersCacheKey)>` — identique au format YouTube actuel (`hashSearchKey`, `youtube-common.mjs:156`), donc **migration réversible**.
- [x] TTL par provider : `SEARCH_CACHE_TTL_MS_YT` (30 min, quota) / `SEARCH_CACHE_TTL_MS_DEFAULT` (5 min) / surcharge par provider.
- [x] Plafond par provider (2 000 lignes, purge par `expires_at DESC` comme aujourd'hui `db.mjs:1409`).
- [x] **Ne jamais persister un résultat vide** (règle déjà appliquée pour YT, `youtube.mjs:213-217`) → à généraliser.
- [x] `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`
- [x] 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é
- [x] `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).
- [x] `GET /api/providers/metrics` → hit/miss cache, nombre d'appels, fallbacks, erreurs par provider et par source.
- [x] É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).
- [x] 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
- [x] 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).
- [x] 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`
- [x] 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
- [x] 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.*
- [x] `hit_count` incrémente à chaque hit ; `GET /api/providers/metrics` expose un ratio.
- [x] `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
```sql
-- 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);
```
- [x] Volontairement **mince** : pas de table de streams, pas de formats, pas de commentaires.
- [x] `captured_at` permet d'afficher un indicateur de fraîcheur (§9.3).
- [x] Fichier réel : `db/migrations/20260930_add_videos_table.sql` (le plan annonçait `20261001`).
- [x] `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.
- [x] 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
- [x] `upsertWatchHistory`
- [x] `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.
- [x] `addPlaylistVideo`
- [x] Helper `upsertVideoRow(dto)`, appel **best-effort** (entrée invalide → `false`, jamais d'exception).
- [x] `COALESCE` sur tous les champs : un appel minimal (sans titre) **n'efface pas** les métadonnées déjà connues.
- [x] `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
- [x] `listLikedVideos` : `LEFT JOIN videos` en source primaire, repli `watch_history` puis `playlist_items`.
- [x] 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.
- [x] 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.
- [x] Backfill `INSERT OR IGNORE` depuis `watch_history` puis `playlist_items`, rejouable (2ᵉ passage = 0 insertion), et refait au boot par la migration.
- [x] `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
- [x] 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`).
- [x] `SELECT COUNT(*) FROM videos` > 0 après une session de lecture normale.
- [x] `captured_at` est mis à jour à chaque ré-observation d'une même vidéo, `created_at` ne l'est pas.
- [x] `npm run test:api`, `test:playlists`, `test:history` verts, plus `npm run test:videos` (56 assertions).
- [x] 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
- [x] **6.1** Ajouter au contrat `Suggestion v2` :
```ts
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`.
- [x] **6.2** Renseigner `channelRef` dans les 6 adaptateurs serveur, **sans supprimer** les champs legacy (`channelExternalId` etc.) → transition non cassante.
- [x] **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).
- [x] **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
- [x] Un test unitaire vérifie que les 3 conventions historiques se reconvertissent en `channelRef` sans perte.
- [x] `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.
- [x] 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 |
- [x] 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`.
- [x] `durationSec` absent → `—` au lieu de `0:00`.
- [x] `viewCount` absent → « vues indisponibles » + infobulle explicative.
- [x] `viewers` affiché **uniquement** si `isLive` (sinon c'est un compteur de vues déguisé).
### 9.2 Tâche 7.2 — Grille adaptative à l'orientation
- [x] `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
- [x] 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.)*
- [x] 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
- [x] 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`.
- [x] 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
- [x] 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
- [x] 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
- [x] É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
- [x] 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`)*
- [x] Un provider en échec affiche un bandeau ; un provider réellement vide affiche un état vide neutre.
- [x] 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)
- [x] `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 »).
- [x] `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.
- [x] 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 »).
- [x] 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.
- [x] 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.
- [x] 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é.
- [x] 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é.
- [x] 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).
- [x] 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`).
- [x] 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.
- [x] Pastilles à logos (disque aux couleurs de la marque + glyphe : play YouTube, bulle Twitch, initiale sinon ; icône étincelles pour « Pour toi ») et filtre de langues préférées visible (`Profil / Français / Anglais / FR+EN / Toutes`, persisté `newtube.shorts.langFilter`, invité compris) : 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
- [x] **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.)*
- [x] **8.2** `Suggestion v: 2` **strict** : les adaptateurs front exigent `v === 2`, sinon chemin de compatibilité v1 déprécié avec avertissement de logs.
- [x] **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'`).
- [x] **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.
- [x] **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
```bash
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.*