feat(providers): phases 7.3/7.4/7.6/8.1 — provenance, health, NDJSON, contrat unique
CI / build-and-test (push) Successful in 14m43s

7.3: capturedAt/source au registre + 6 adaptateurs + module provenance.ts + ?debug=1 (search-transport.mjs). 7.4: ProviderHealthService + badge source degradee. 7.6: squelettes par provider + snapshots progressifs + transport NDJSON /api/search. 8.1: ProviderAdapter unifie (search enveloppe + channelContent/channelMeta/capabilities) via getProviderAdapter + test de contrat offline.
This commit is contained in:
2026-09-30 07:57:11 -04:00
parent 37681c4f53
commit 665a0f0ebd
87 changed files with 8850 additions and 550 deletions
@@ -0,0 +1,643 @@
# Saisie et catalogage des informations vidéo par fournisseur — NewTube
**Version :** 1.0
**Date :** 2026-09-29
**Statut :** Document de référence technique (lecture de code)
**Périmètre :** comment chacun des 6 fournisseurs supportés est **saisi** (source amont, endpoint, auth, parsing) puis **catalogué** (modèle normalisé, classification, persistance SQLite).
> Ce document est un **compte rendu de lecture du code**, pas une spécification normative. Chaque affirmation renvoie à un fichier et une ligne.
---
## Table des matières
1. [Vue d'ensemble du pipeline](#1-vue-densemble-du-pipeline)
2. [Le contrat commun `Suggestion`](#2-le-contrat-commun-suggestion)
3. [Saisie — source amont par fournisseur](#3-saisie--source-amont-par-fournisseur)
4. [Catalogue — champs réellement capturés](#4-catalogue--champs-réellement-capturés)
5. [Catalogue du contenu de chaîne](#5-catalogue-du-contenu-de-chaîne)
6. [Métadonnées de chaîne](#6-métadonnées-de-chaîne)
7. [Classification Vidéo / Short / Live](#7-classification-vidéo--short--live)
8. [Normalisation front](#8-normalisation-front)
9. [Persistance SQLite](#9-persistance-sqlite)
10. [Caches](#10-caches)
11. [Configuration par fournisseur](#11-configuration-par-fournisseur)
12. [Points d'attention et écarts constatés](#12-points-dattention-et-écarts-constatés)
13. [Annexe — Référence des fichiers](#13-annexe--référence-des-fichiers)
---
## 1. Vue d'ensemble du pipeline
Tous les fournisseurs convergent vers un contrat unique, puis passent par trois étapes de normalisation successives.
```text
┌─ FRONT ────────────────────────────────────────────────────────────────┐
│ SearchBox → SearchService (RxJS) │
│ state : q$ providers$ page$ pageSize$ sort$ filters$ │
│ src/app/search/search.service.ts:27-40 │
└──────────────────────────────┬────────────────────────────────────────┘
│ GET /api/search?q=…&providers=yt,dm,…
│ &type=&duration=&period=&sort=
▼
┌─ SERVEUR ────────────────────────────────────────────────────────────┐
│ server/index.mjs:3120 (validation + normalisation filtres) │
│ server/search-filters.mjs:34-42 parseSearchFilters() │
│ │
│ fan-out parallèle → server/providers/registry.mjs:32-39 │
│ yt → providers/youtube.mjs (3 niveaux de source) │
│ dm → providers/dailymotion.mjs (REST Graph) │
│ tw → providers/twitch.mjs (Helix, token app) │
│ pt → providers/peertube.mjs (SepiaSearch) │
│ od → providers/odysee.mjs (Lighthouse / LBRY) │
│ ru → providers/rumble.mjs (scraping HTML + CF) │
│ │
│ post-traitement commun → search-filters.mjs:278-288 │
│ applySearchFilters(providerId, items, filters) │
└──────────────────────────────┬────────────────────────────────────────┘
│ { q, providers, groups: {yt:[], dm:[]…},
│ page, pageSize, sort, filters, errors }
▼
┌─ FRONT ────────────────────────────────────────────────────────────────┐
│ 6 adaptateurs src/app/search/adapters/{yt,dm,tw,pt,od,ru}.ts │
│ → modèle unique `VideoItem` (src/app/shared/models/video-item.model.ts)
│ → classification (src/app/shared/utils/video-kind.ts) │
│ → rendu (search-result-grid → video-card) │
│ │
│ Écritures SQLite déclenchées par le front : │
│ history.service.ts → /user/history/watch │
│ likes.service.ts → /user/likes │
│ → watch_history, playlist_items, video_tags, download_jobs │
└───────────────────────────────────────────────────────────────────────┘
```
**Trois chemins d'ingestion cohabitent :**
| Chemin | Fournisseurs | Point d'entrée |
|---|---|---|
| **Recherche unifiée** (fan-out) | les 6 | `server/providers/registry.mjs` |
| **Contenu de chaîne** (onglets vidéos/shorts/playlists/live) | les 6 | `server/providers/channel-content.mjs:378-389` |
| **Transcripts / détails / téléchargements** | tous, via `yt-dlp` | `server/index.mjs:1271-1295` (`providerUrlFrom`) |
---
## 2. Le contrat commun `Suggestion`
Défini en JSDoc dans `server/providers/registry.mjs:5-16`. C'est la **forme de sortie unique** de tous les adaptateurs serveur.
| Champ | Type | Rôle |
|---|---|---|
| `title` | `string` | obligatoire |
| `id` | `string` | identifiant canonique provider |
| `url` | `string?` | URL de lecture |
| `thumbnail` | `string?` | vignette |
| `uploaderName` | `string?` | nom de la chaîne / auteur |
| `type` | `string?` | `video` \| `live` \| `channel` \| `short` |
| `duration` | `number?` | **secondes** |
| `isShort` | `boolean?` | flag natif fournisseur |
| `width` / `height` | `number?` | px — **signal d'orientation** |
| `views` | `number?` | (enrichi hors contrat JSDoc) |
| `publishedAt` / `uploadedDate` | `string?` | ISO 8601 |
| `channelId` / `channelExternalId` / `channelHandle` / `channelUrl` | | identité chaîne |
| `uploaderAvatar` | `string?` | avatar |
| `kind` | `string?` | `vod` \| `clip` (Twitch) |
| `isLive` | `boolean?` | en cours de diffusion |
| `game` | `string?` | catégorie Twitch |
**Doctrine de sûreté** appliquée partout (`search-filters.mjs:9-11`) : *un filtre inconnu est ignoré (jamais d'erreur 4xx), et un champ métadonnée absent ne fait jamais disparaître un résultat.*
---
## 3. Saisie — source amont par fournisseur
### 3.1 Tableau récapitulatif
| Fournisseur | Source d'ingestion | Endpoint(s) amont | Auth | Pagination | Normalisation / parsing |
|---|---|---|---|---|---|
| **YouTube** `yt` | **Chaîne de 3 niveaux** : InnerTube (youtubei.js) → scrape yt-dlp → Data API v3. Dispatcher selon `YT_SEARCH_MODE`, défaut `innertube-first` (`youtube.mjs:236-330`) | • InnerTube `youtubei/v1/search`<br>• binaire `yt-dlp --dump-single-json --flat-playlist`<br>• `googleapis.com/youtube/v3/search` + `/videos` | Aucune pour InnerTube/scrape.<br>**Clés API** `YOUTUBE_API_KEY(S)` avec rotation sur échec quota (`youtube-common.mjs:9-40`) | **Continuations InnerTube illimitées** ; `pageToken` Data API ; `--playlist-start/--playlist-end` yt-dlp | `mapVideoNode()` / `mapLockupView()` — 10 renderers YT (`youtube-innertube.mjs:92-215`) ; `parseViewsText()` (« 1,2 M ») ; `parseDurationLabel()` (labels a11y) ; `parseISODurationToSeconds()` PT#H#M#S ; `mapFlatEntry()` yt-dlp (`youtube-scrape.mjs:14-39`) |
| **Dailymotion** `dm` | API Graph publique (REST) | `GET https://api.dailymotion.com/videos?search=&fields=…`<br>`dailymotion.mjs:15-24` | Aucune | `page` + `limit` (plafond 100) | Mapping à plat (`owner.screenname`, `owner.avatar_80_url`) ; `created_time` epoch → ISO ; `duration`/`views_total` → `Number()` ; **dimensions conservées** (`width`, `height`) |
| **Twitch** `tw` | Helix — recherche **mixte façon annuaire** : chaînes + lives + VODs + clips | `/helix/search/channels`, `/helix/search/categories`, `/helix/streams`, `/helix/videos`, `/helix/clips`<br>`twitch.mjs:302-567` | **App Access Token** `client_credentials`, cache process-wide, 3 retries + **refresh automatique sur 401** (`twitch.mjs:30-107`) | Curseur Helix (`pagination.cursor`) ; requêtes multi-mots-clés fusionnées et dédupliquées (`twitchKeywords`) | `parseTwitchDurationToSeconds()` (« 2h13m5s ») ; gabarits de vignettes `{width}x{height}` et `%{width}` ; 4 mappeurs dédiés `mapChannel`/`mapStream`/`mapVod`/`mapClip` (`twitch.mjs:137-225`) ; budgets par section |
| **PeerTube** `pt` | **SepiaSearch** (index fédéré des instances) | `GET https://sepiasearch.org/api/v1/search/videos?search=&count=&start=`<br>`peertube.mjs:16-23` | Aucune | Offset `start` + `count` (plafond 50) | `uuid` → `id`, `name` → `title` ; vignette absolutisée (`//`, chemin relatif) ; `duration` déjà en secondes ; **dimensions = plus grande surface de `files[]`** (signal d'orientation, lecture défensive) |
| **Odysee** `od` | Lighthouse (index LBRY) | `GET https://lighthouse.odysee.tv/search?s=&size=&from=&include=…&mediaType=video`<br>`odysee.mjs:17-25` | Aucune | Offset `from` + `size` (plafond 50) | `claimId` → `id` ; construction du segment `nom:claimId` ; vignette reconstruite via `thumbnails.odycdn.com/optimize/…` ; durée en cascade `duration` → `video.duration` ; dims `video.width/height` |
| **Rumble** `ru` | **Scraping HTML** derrière Cloudflare — 2 tentatives | ① `GET rumble.com/search/video?q=`<br>② `GET rumble.com/search/all?search-videos=1&q=`<br>`rumble.mjs:257-277` | Aucune. Contournement CF : headers Chrome complets + **cookie jar `__cf_bm` partagé (TTL 25 min)**, puis repli `python3 + curl_cffi` (`rumble_fetch.py`, impersonation Chrome) | `page` HTML | **cheerio** sur `li.video-listing-entry` ; `parseDurationToSeconds()` robuste (ISO 8601, `h m s`, `H:MM:SS`, heuristique ms > 100 000, **garde-fou anti-datetime**) ; `id` depuis `data-id` ou slug d'URL ; params de tracking (`?e9s=`, `?sci=`) supprimés (`rumble.mjs:184-238`) |
### 3.2 Détail YouTube — la chaîne de fallback
`server/providers/youtube.mjs:236-330` implémente 6 modes pilotés par `YT_SEARCH_MODE`
(`youtube-common.mjs:42-48`) :
| Mode | Ordre d'essai |
|---|---|
| `innertube-first` *(défaut)* | InnerTube → scrape → API |
| `scrape-first` | scrape → API |
| `api-first` | API → scrape |
| `innertube-only` / `scrape-only` / `api-only` | source unique |
- Chaque couche **ne fait jamais échouer la recherche à elle seule** : un échec déclenche le repli et incrémente `ytMetrics.fallbacks`.
- Si aucune source n'est disponible → erreur `503` avec le code `youtube_no_source` (`youtube.mjs:320-324`).
- Le dispatcher logue systématiquement la source gagnante, le mode et la latence (`youtube.mjs:234`).
**Anti-ban yt-dlp** (`youtube-common.mjs:130-147`) : `--cookies` (si `YT_COOKIES_FILE` existe), `--extractor-args youtube:po_token=…`, `--proxy` (si `YT_EGRESS_PROXY`). Les valeurs ne sont jamais loguées.
**Rotation des clés YouTube** (`youtube-common.mjs:32-40`) : une clé n'est abandonnée que sur échec *clé* (400 `API_KEY_INVALID` / clé expirée, ou 403 quota/rateLimit). Les métriques de quota sont estimées : `search × 100` unités, `videos × 1` (`youtube.mjs:44-47`).
### 3.3 Détail Twitch — recherche multi-sections
`twitch.mjs:328-567`, dans l'ordre :
1. **Chaînes** — `/search/channels` sur la requête complète puis chaque mot-clé (Helix ne matche pas les longues phrases : `twitch.mjs:229-247`). Pagination par curseur. Repli : `/streams` top si Helix a tout filtré.
2. **Catégorie** — `/search/categories` pour les thèmes génériques (`sports`, `music`) ; matching exact prioritaire.
3. **Lives** — `/streams` enrichis par `user_login` (viewer_count, game_name, started_at) + top streams de la catégorie ; dédupliqués.
4. **VODs** — `/videos?type=archive` par broadcaster (6 max) + par `game_id` si catégorie trouvée. `sort` mappé : `views`→`views`, `date`→`time`, sinon `trending`.
5. **Clips** — `/clips` par broadcaster (fenêtre 30 jours) + par `game_id`.
6. **Assemblage** façon annuaire avec **budgets par section** pour ne pas noyer les lives (`twitch.mjs:537-543`), tri optionnel, déduplication par `type:id`.
7. **Filet de sécurité** : si la page 1 est vide alors que Twitch est configuré → top streams (`twitch.mjs:555-561`).
### 3.4 Détail Rumble — stratégie anti-Cloudflare
`rumble.mjs:121-133` (`fetchHtml`) :
```
1. Node fetch + headers Chrome + cookie jar __cf_bm → si 200 et pas de challenge
2. python3 + curl_cffi (rumble_fetch.py, impersonation Chrome)
3. sinon → []
```
Détection de challenge : `/Just a moment|challenge-platform|cf-chl/i` sur les 4 000 premiers caractères. La dégradation est **silencieuse** — le fan-out `/api/search` renvoie simplement un groupe vide pour `ru`.
---
## 4. Catalogue — champs réellement capturés
<!-- GENERATED:provider-matrix (npm run doc:providers) -->
> Section **générée** par `npm run doc:providers` depuis `server/tests/fixtures/provider-suggestions.json`
> (gel du 2026-09-30, requête `tutorial`). Ne pas éditer à la main.
Legende : `x` = émis dans le gel, `·` = absent (donnée inconnue, donc `undefined` côté front).
| Champ | Type | YouTube | Dailymotion | Twitch | PeerTube | Odysee | Rumble |
|---|---|---|---|---|---|---|---|
| `duration` (durée) | secondes | x | x | ? | x | x | ? |
| `views` (vues) | nombre | x | x | ? | x | · | ? |
| `likes` (likes) | nombre | · | · | ? | x | · | ? |
| `publishedAt` (publication) | date ISO | · | · | ? | x | · | ? |
| `thumbnail` (vignette) | URL | x | x | ? | x | x | ? |
| `uploaderName` (chaîne) | texte | x | x | ? | x | x | ? |
| `channelRef` (identité chaîne) | scheme + value | x | x | ? | x | x | ? |
| `type` (type) | video / live / short | x | x | ? | x | x | ? |
| `kind` (kind) | vod / live / clip / channel | · | · | ? | x | · | ? |
| `isLive` (direct) | booléen | · | · | ? | · | · | ? |
| `width` (largeur) | px | · | x | ? | · | · | ? |
| `height` (hauteur) | px | · | x | ? | · | · | ? |
| `language` (langue) | code | · | · | ? | x | · | ? |
Couverture du gel :
- **YouTube** : 3 item(s) vérifié(s).
- **Dailymotion** : 3 item(s) vérifié(s).
- **Twitch** : *non couvert* — aucun résultat au moment du gel — couverture non testée pour ce provider.
- **PeerTube** : 3 item(s) vérifié(s).
- **Odysee** : 3 item(s) vérifié(s).
- **Rumble** : *non couvert* — erreur au gel : rumble_unavailable.
(`?` = provider sans fixture au gel : la matrice ne prétend rien sur lui.)
<!-- /GENERATED:provider-matrix -->
Matrice de couverture : ✅ capturé · ⚠️ partiel / dégradé · ❌ non capturé malgré la disponibilité en amont.
| Champ catalogué | YouTube | Dailymotion | Twitch | PeerTube | Odysee | Rumble |
|---|:---:|:---:|:---:|:---:|:---:|:---:|
| `id` (canonique) | ✅ `videoId` / `UC…` | ✅ `x8…` | ⚠️ `login` (live) ou `id` (vod/clip) | ✅ `uuid` | ✅ `claimId` | ✅ `data-id` ou slug URL |
| `title` | ✅ | ✅ | ✅ | ✅ `name` | ✅ | ✅ |
| `url` de lecture | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ canonicalisée |
| `thumbnail` | ✅ meilleure dispo | ✅ 720→480→360→url | ✅ gabarit 640×360 (+70×70 avatar) | ✅ absolutisée | ✅ via proxy optimize | ✅ `//`→`https:` |
| `duration` | ✅ ISO / label a11y / yt-dlp | ✅ | ✅ parsé `2h13m5s` (vod+clip) / ❌ live | ✅ (secondes) | ✅ cascade | ✅ multi-format |
| `views` | ✅ `statistics.viewCount` ou texte | ✅ `views_total` | ✅ `view_count` / `viewer_count` | ✅ | ❌ **jeté** | ✅ texte gratté |
| `publishedAt` | ✅ | ✅ epoch → ISO | ✅ `created_at`/`started_at` | ✅ | ❌ **jeté** | ❌ **jamais** |
| `uploaderName` | ✅ `channelTitle` | ✅ `owner.screenname` | ✅ `display_name` | ✅ `account.displayName` | ✅ `channel` | ✅ sélecteur `.ellipsis-1` |
| `channelExternalId` | ✅ `UC…` | ✅ `owner.id` | ✅ `user_login` | ✅ `instance\|channel` | ✅ claim LBRY | ❌ **jamais** |
| `uploaderAvatar` | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| `width` / `height` | ❌ | ✅ | ❌ | ✅ (`files[]`) | ✅ | ❌ |
| `type` | ✅ | `video` | ✅ `live`/`channel`/`video` | `video` | `video` | `video` |
| `kind` (`vod`/`clip`) | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| `isLive` | ✅ (InnerTube) | ❌ | ✅ | ❌ | ❌ | ❌ |
| `isShort` (flag natif) | ✅ | ❌ | clips uniquement | ❌ | ❌ | ❌ |
| `game` | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
**Modèle cible front** : `src/app/shared/models/video-item.model.ts:3-26`.
Champs obligatoires : `id`, `provider`, `title`, `thumbnailUrl`. Le reste est optionnel car Dailymotion, PeerTube et Odysee ne renvoient pas toutes les métadonnées en recherche.
> ⚠️ **Derive d'identifiants** : `VideoItem.provider` utilise les **noms longs** (`youtube`, `dailymotion`…) alors que le registry et les adaptateurs utilisent les **ids courts** (`yt`, `dm`…). La table de conversion est dupliquée dans 4 endroits (`http-channel.provider.ts:16-23`, `provider-badge.component.ts:61-68`, `search.component.ts:133-140`, `video-card.component.ts:108-115`).
---
## 5. Catalogue du contenu de chaîne
Route : `GET /api/channels/:provider/:externalId/content?type=&page=&limit=&sort=&q=`
Dispatcher : `server/providers/channel-content.mjs:378-389`.
`type` ∈ `videos | shorts | playlists | live` · `sort` ∈ `recent | popular` · `limit` plafonné à 50 (`server/index.mjs:315-334`).
### 5.1 Matrice de couverture
| Type de contenu | YouTube | Dailymotion | Twitch | PeerTube | Odysee | Rumble |
|---|---|---|---|---|---|---|
| **vidéos** | ✅ yt-dlp `/videos` + `search.list`<br>`:143-215` | ✅ `/user/{u}/videos`<br>`:219-248` | ✅ `/videos?type=archive`<br>`:273-303` | ✅ `/video-channels/{c}/videos`<br>`:307-335` | ✅ `claim_search` JSON-RPC<br>`:338-359` | ⚠️ recherche globale + filtre client sur `uploaderName`+`url`<br>`:363-376` |
| **shorts** | ✅ onglet `/shorts` + `videoDuration=short` + filtre ≤ 70 s (`:203-213`) | ❌ vide (`:231`) | ❌ vide (`:293`) | ❌ vide (`:324`) | ❌ vide (`:339`) | ❌ vide (`:364`) |
| **playlists** | ✅ `playlists.list` + onglet `/playlists`<br>`:170-193` | ✅ `/user/{u}/playlists`<br>`:222-230` | ❌ vide | ✅ `/video-playlists?sort=-updatedAt`<br>`:312-323` | ❌ vide | ❌ vide |
| **live** | ✅ onglet `/streams` + `eventType=live`<br>`:203` | ❌ vide | ⚠️ `/streams?user_login&first=1`<br>`nextPage:null` codé en dur<br>`:281-292` | ❌ vide | ❌ vide | ❌ vide |
Les absences sont des `return { items: [], nextPage: null }` explicites, en miroir des flags de capacités front (`src/app/shared/models/channel-detail.model.ts:42-49`).
### 5.2 Mapping par fournisseur (contenu de chaîne)
| Fournisseur | Pagination | Points notables du mapping |
|---|---|---|
| **YouTube** | `pageToken` Data API mis en cache **process-local et vers-l'avant uniquement** (`channel-content.mjs:122-141`) ; onglets yt-dlp en parallèle | `type:'video'` systématique (y compris pour le live) ; `channelId`/`channelExternalId` = `UC…` résolu ; résolution d'id chaîne en 4 étapes : regex `UC` → `resolveChannelIdViaScrape` → `channels?forHandle=` → `search?type=channel` (`:62-89`) ; tri `popular` refait en mémoire |
| **Dailymotion** | `page` + `has_more` | `duration` et `views` forcés à `0` (pas `undefined`) ; `q` envoyé **et** re-filtré côté serveur ; pas de `channelUrl`/`channelHandle` |
| **Twitch** | curseur Helix lu **puis jeté** → `page=2` renvoie `page=1` (`:302-303`) | item live taggé `kind:'vod'` et `views=viewer_count` ; `duration: undefined` (Helix `/videos` ne la fournit pas) ; vignette de live avec expansion `.replace()` inline (première occurrence seulement) |
| **PeerTube** | offset `start` + `count` | `externalId` au format composite **`"instance\|channel"`** ; `channelId` = l'`externalId` complet ; `thumbnail` = `https://` + `thumbnailPath` ; `q` envoyé en amont |
| **Odysee** | `page`/`page_size` + `total_pages` ; **`total` jamais retourné** | `claim` = `@` + externalId ; `channelId` = externalId **sans** la normalisation `@` (incohérence interne) ; `views` et `slug` disponibles mais non catalogués pour les vues ; **statut HTTP non vérifié** (`:349` contourne `readJson`) |
| **Rumble** | `page` toujours forcée à 1 → `nextPage:null` | aucun appel direct : délègue à `searchRegistry.ru.search(q, { limit: 50, page: 1 })` puis filtre client ; `sort` non déstructuré → **ignoré** ; reposant sur `uploaderName`/`url` puisque les résultats Rumble ne portent pas de `channelId` |
---
## 6. Métadonnées de chaîne
`server/providers/channel-registry.mjs` — un `fetchChannelById` par fournisseur, fusionné dans le registry de recherche (`registry.mjs:41-46`). Persisté dans la table `channels` avec TTL 6 h.
| Fournisseur | Endpoint | Champs récupérés |
|---|---|---|
| **YouTube** | `youtube/v3/channels?part=snippet,statistics,brandingSettings` | `title`, `customUrl` → `@handle`, `avatarUrl`, `subscribers`, `verified` (badges), URL custom |
| **Dailymotion** | `api.dailymotion.com/user/{id}` | `screenname`, `@username`, `avatar_720_url`, `followers_total`, `verified` |
| **Twitch** | `helix/users?id=` ou `?login=` (selon format de l'id) | `display_name`, `@login`, `profile_image_url` (avatar), `view_count` |
| **PeerTube** | `{instance}/api/v1/video-channels/{channel}` | `displayName`, handle `@name@host`, avatar via `avatar.path`, `followersCount`, `ownerAccount.verified` |
| **Odysee** | JSON-RPC `resolve` via `api.na-backend.odysee.com/api/v1/proxy` | `title`, `short_url`, `thumbnail.url`, `effective_amount` (abonnés) |
| **Rumble** | scraping `rumble.com/{handle}` | `<title>` (nettoyé du suffixe « on Rumble »), `og:image` |
**Note :** aucune de ces 6 sources ne renvoie de *banner* ni de *description de chaîne* — le modèle `ChannelDetail` les déclare mais les valorise à `null` (`channel-detail.model.ts:51-62`).
---
## 7. Classification Vidéo / Short / Live
Les règles sont **dupliquées à l'identique** côté serveur et front :
- Serveur : `server/search-filters.mjs:182-224` (`isShortItem`)
- Front : `src/app/shared/utils/video-kind.ts:64-102` (`isShortVideo`)
### 7.1 Algorithme de détection Short (ordre strict)
| Ordre | Règle | Seuils / constantes |
|---|---|---|
| 1 | `kind === 'clip'` → **toucourt short** | les clips Twitch sont ≤ 60 s par construction ; une durée > 75 s sur un clip est du bruit de métadonnées |
| 2 | Flag natif : `isShort === true` **ou** `type === 'short'` **ou** URL contenant `/shorts/` → short, **annulé** si durée connue > 75 s | `SHORT_MAX_SECONDS = 75` |
| 3 | **L'orientation fait autorité** dès que `width` et `height` sont connus : verticale = `h > w && w/h ≤ 0.8`. Verticale + durée connue ≤ 90 s → short. Verticale **sans** durée connue → **pas** short. Horizontale ou carrée → **jamais** short (même à 50 s) | `VERTICAL_SHORT_MAX_SECONDS = 90`<br>`VERTICAL_MAX_RATIO = 0.8` |
| 4 | Repli **durée seule**, uniquement si l'orientation est inconnue : ≤ 70 s pour YouTube, ≤ 75 s sinon. Durée inconnue → **pas** short | `YOUTUBE_SHORT_MAX_SECONDS = 70`<br>`SHORT_MAX_SECONDS = 75` |
> L'ancien code excluait les titres contenant « short », ce qui **excluait** les vrais Shorts titlés `#shorts`. Cette condition a été supprimée (`search-filters.mjs:213-218`).
### 7.2 Live et chaîne
| Fonction | Règle | Emplacement |
|---|---|---|
| `isLiveItem` | `isLive === true` **ou** `type ∈ {live, stream, channel}` | `search-filters.mjs:148-152` |
| `isChannelItem` | `type === 'channel'` **et** `isLive !== true` | `search-filters.mjs:226-228` |
| `classifyVideo` | short ⇒ `short` ; sinon live ⇒ `live` ; sinon `video` (**short prioritaire sur live**) | `video-kind.ts:111-115` |
### 7.3 Post-filtrage par fournisseur
`search-filters.mjs:99-106` déclare les dimensions affinables en post-traitement :
| Provider | Dimensions post-filtrables | Raison documentée |
|---|---|---|
| `yt` | period, duration, type | tout est natif, le post-filtrage est un filet de sécurité |
| `dm` | period, duration, type | — |
| `tw` | period, **type** | chaînes sans durée exploitable ; `live` seul type fiable (les VOD portent `type:'video'`) |
| `pt` | period, duration, type | — |
| `od` | period, **type** | pas de durée fiable en recherche |
| `ru` | period, duration, type | — |
Exception : le filtre `type=channel` n'est appliqué **qu'à YouTube** — ailleurs il viderait le résultat au lieu de le restreindre (`search-filters.mjs:285-286`).
### 7.4 Traduction des filtres vers le natif
| Cible | Mapping |
|---|---|
| InnerTube (`innertubeSearchFilters`, `:299-315`) | `period` → `upload_date` ; `shorts` → `type:'shorts'` ; `live` → `features:['live']` (pas un SearchType) ; `duration` → `UNDER_THREE_MINS` / `THREE_TO_TWENTY_MINS` / `OVER_TWENTY_MINS` |
| Data API v3 (`apiSearchParams`, `:321-328`) | `duration` → `videoDuration` ; `period` → `publishedAfter` ; `shorts`/`live` **inexistants** → post-filtrage |
| yt-dlp (`youtube-scrape.mjs`) | seule la **période** est native (`--dateafter`) |
| PeerTube / Odysee / Rumble / Dailymotion / Twitch | **aucun filtre natif** → 100 % post-traitement |
---
## 8. Normalisation front
### 8.1 Adaptateurs de recherche
Un adaptateur par fournisseur dans `src/app/search/adapters/`. Tous appellent `GET /api/search` avec `providers=<id court>`, puis mappent `Suggestion` → `VideoItem`.
| Adaptateur | Renommages clés | Enrichissements spécifiques | Lignes |
|---|---|---|---|
| `yt.ts` | `uploaderName→channelName`, `duration→durationSec`, `thumbnail→thumbnailUrl` | `viewCount` accepte `views` **ou** `viewCount` ; `channelExternalId: channelExternalId \|\| channelId` | `:21-39` |
| `dm.ts` | idem | `viewCount`/`publishedAt` forcements `undefined` ; **seul adaptateur à mapper `channelAvatarUrl`** (`uploaderAvatar`) | `:22-37` |
| `tw.ts` | idem | dérive `login` ; **filtre les lignes sans id ni vignette** ; `channel` = embed `?channel=<login>` (jamais pour VOD/clip) ; `kind`, `game`, `type`, `isLive` préservés | `:24-55` |
| `pt.ts` | idem | **reconstruit** `channelExternalId` = `${hostname(url)}\|${channelId}` (try/catch silencieux) | `:23-27` |
| `od.ts` | idem | **extrait le `slug`** de l'URL si domaine `odysee.com` ; `channelExternalId = uploaderName` (nom LBRY) | `:21-40` |
| `ru.ts` | idem | le plus simple : aucun `channelExternalId`, aucun `isLive` | `:23-34` |
`withFilterParams` (`filter-params.ts:9-17`) n'ajoute que les filtres **non par défaut**, pour que les URLs et le cache serveur restent partagés.
> `src/app/search/adapters/base.ts` est du **code mort** : `HttpAdapter` n'est étendu par aucun adaptateur et utilise une forme `SearchItem` obsolète.
### 8.2 Adaptateur de contenu de chaîne
`src/app/core/providers/channel/http-channel.provider.ts` — une implémentation générique pour les 6 fournisseurs.
`toVideoItem()` (`:25-40`) porte la chaîne de repli la plus riche du code :
```
id ← raw.id
provider ← LONG_PROVIDER[provider] (court → long)
title ← raw.title
thumbnailUrl ← raw.thumbnail || raw.thumbnailUrl
durationSec ← raw.duration (number) || raw.durationSec
channelName ← raw.uploaderName || raw.channelName
channelExternalId ← raw.channelExternalId || raw.channelId || fallbackChannelId
channelAvatarUrl ← raw.uploaderAvatar || raw.channelAvatarUrl
viewCount ← raw.views (number) || raw.viewCount
publishedAt ← raw.publishedAt || raw.uploadedDate
slug, channel ← raw.slug, raw.channel
```
⚠️ **Elle ne propage pas** `type`, `isLive`, `isShort`, `kind`, `width`, `height` — les grilles de chaîne retombent donc sur la **règle 4** (durée seule) de `isShortVideo`, alors que les résultats de recherche profitent de la logique d'orientation complète.
`ChannelProviderFactory` (`channel-provider.factory.ts:29-50`) mémoïse une instance par fournisseur ; repli par défaut sur YouTube.
### 8.3 Assemblage pour l'affichage
`src/components/search/search.component.ts` :
1. **Fusion par page** (`:423-436`) — `mergeGroups()` déduplique **par provider** sur `String(id ‖ videoId ‖ url)` ; `endReached` quand un lot n'apporte rien de nouveau.
2. **Entrelacement par provider** (`:763-794`) — en tri `relevance`, un **round-robin** sur l'ordre des sources plutôt que des blocs, choisi car stable en cas d'ajout incrémental de l'infinite scroll.
3. **Puits de erreurs** (`:805-824`) — un provider en échec produit un bandeau ambre « Résultats partiels », jamais une erreur bloquante.
4. **Cartes** — `search-result-grid.component.ts` (grille 1/2/3/4 colonnes, `trackById`) → `video-card.component.ts` (badges provider, pastilles LIVE/CLIP/CHAÎNE, durée, vues, jeu, date, identité chaîne + bouton d'abonnement).
---
## 9. Persistance SQLite
> **Il n'existe pas de table `videos`.** Chaque métadonnée est *dénormalisée* dans des tables par fonctionnalité, toutes clés par le couple `(provider, video_id)`.
Fichier : `db/newtube.db` (surchargeable via `NEWTUBE_DB_FILE`). Schéma : `db/schema.sql` + `db/migrations/*.sql`, appliqués au boot par `server/db.mjs:47-89` (table `migrations` de suivi).
### 9.1 Inventaire des tables vidéo
| Table | Colonnes vidéo | Écriture (route → helper) | Providers |
|---|---|---|---|
| `watch_history` | `provider`, `video_id`, `title`, `thumbnail`, `progress_seconds`, `duration_seconds`, `last_position_seconds` | `POST /user/history/watch` → `upsertWatchHistory` (`db.mjs:478`) | tous |
| `playlist_items` | `provider`, `video_id`, `title`, `thumbnail`, `position` | `POST /playlists/:id/videos` → `addPlaylistVideo` (`db.mjs:926`) — **titre/vignette complétés par `yt-dlp --dump-single-json` si absents** (`index.mjs:3850-3862`) | tous |
| `video_tags` (+ `tags`) | likes modélisés comme un tag nommé `like` — **aucun titre/vignette** | `POST /user/likes` → `likeVideo` (`db.mjs:698`) ; listing réhydraté par LEFT JOIN `watch_history` (`db.mjs:771-789`) | tous |
| `transcript_history` | `lines_json`, `languages_json`, `lang`, `line_count`, `char_count` | `POST /user/history/transcripts` → `upsertTranscriptHistory` (`db.mjs:593`) ; plafonds 2 000 lignes, 2 000 car./ligne, 32 langues | **yt, dm, pt** seulement — `index.mjs:3319` exclut `twitch`, `odysee`, `rumble` |
| `download_jobs` | `url` résolue, `format_id`, `file_name`, `file_ext`, `file_size`, `file_path`, `state`, `progress`, `audio_only` | `POST /download/:p/:videoId` → `insertDownloadJob` (`db.mjs:981`) ; au boot, les jobs `queued/running/merging` passent à `interrupted` (`db.mjs:1040`) | tous (`DOWNLOAD_PROVIDERS`) |
| `channels` | `title`, `handle`, `avatar_url`, `url`, `subs_count`, `verified`, `last_refreshed_at` — **seule vraie table-catalogue** | `ensureChannelFresh` (`db.mjs:1123`), TTL 6 h, upsert même en cas d'échec (ligne stub) | tous |
| `youtube_search_cache` | `payload_json` (Suggestion[]), `source` (`innertube`/`scrape`/`api`), `expires_at` | `setCachedYoutubeSearch` (`db.mjs:1399`) | **yt uniquement** |
| `youtube_metrics` | `scrape_calls`, `api_calls`, `quota_units` par jour | `incYoutubeMetrics` (`db.mjs:1417`) | yt |
| `search_history` | `query`, `filters_json` | `POST /user/history/search` → `insertSearchHistory` (`db.mjs:425`) | tous |
Tables non-vidéo mais liées : `users`, `user_preferences` (dont `default_providers`), `sessions`, `login_audit`, `playlists`, `playlist_metrics`, `subscription_groups`, `oauth_connections` (avec les **seules colonnes provider-spécifiques** du schéma : `yt_channel_id`, `yt_page_id`), `telemetry_events`.
### 9.2 Conventions d'identité
| Élément | Convention |
|---|---|
| Provider en base | **nom long** (`youtube`, `dailymotion`…) via `normalizeHistoryProvider` (`db.mjs:405-418`) |
| Provider en cache / préférences | **id court** (`yt`, `dm`…) — `KNOWN_PROVIDER_IDS` (`db.mjs:168`) |
| Clé vidéo | toujours composite `(user_id, provider, video_id)` ou `(playlist_id, provider, video_id)` |
| Timestamps | ISO-8601 texte partout **sauf** `download_jobs`, `youtube_search_cache`, `oauth_connections` (epoch ms entier) |
#### Identité de chaîne : `channelRef` (phase 6)
| Provider | Identifiant de chaîne | `channelRef.scheme` |
|---|---|---|
| YouTube | `UC…` | `yt-uc` |
| Dailymotion | id numérique d'utilisateur (`owner.id`) | `dm-user` |
| Twitch | `login` | `tw-login` |
| PeerTube | `instance\|channel` (instance déduite de l'URL vidéo) | `pt-composite` |
| Odysee | claim LBRY, **un seul `@` en tête** | `od-claim` |
| Rumble | slug `/c/<slug>` | `ru-slug` |
Source unique : `server/providers/channel-ref.mjs` (serveur) et
`src/app/shared/providers/channel-ref.ts` (front), parité vérifiée par
`npm run test:channelref`. `channelRef` est **redondant** avec
`channelExternalId` — c'est une transition non cassante, pas un remplacement :
le champ legacy reste lu partout, `channelRef` en priorité quand il est présent.
Deux règles méritent note :
- **PeerTube** : l'instance n'est pas optionnelle. Sans elle,
`fetchPeerTubeChannel` produirait `https://<channel>`, une URL fausse — donc
pas de `channelRef` plutôt qu'un composite bancal.
- **Odysee** : le claim LBRY arrivait avec et sans `@` selon le chemin
(`claim_search`, `short_url`, base). La forme canonique porte le `@`, et les
deux usages (paramètre de `resolve`, slug d'URL) passent par
`odyseeClaimToResolveArg()` / `odyseeClaimToSlug()`.
### 9.3 Cache YouTube (2 niveaux)
| Niveau | Emplacement | Caractéristiques |
|---|---|---|
| L1 — mémoire | `youtube.mjs:94-107` | LRU, `YT_SCRAPE_MEM_MAX` (300) ; lecture avec rafraîchissement de récence ; réinjecté depuis SQLite |
| L2 — SQLite | `youtube_search_cache` | clé `yt\|sha256(q\|perPage\|page\|sort\|mode\|filtersCacheKey)` tronquée à 32 car. ; TTL `YT_SCRAPE_TTL_MS` (30 min) ; plafond 2 000 lignes (purge par `expires_at DESC`) ; **jamais de résultat vide persisté** (`youtube.mjs:213-217`) ; expiration **paresseuse** à la lecture — `pruneYoutubeCache` existe mais n'est jamais appelé |
La signature des filtres entre dans la clé : deux recherches identiques avec des filtres différents ne partagent jamais leur cache (`youtube.mjs:197-199`).
Le cache InnerTube est **memoire seule** (`youtube-innertube.mjs:280-286`), clé `it|<hash>`.
### 9.4 Ce qui n'est **pas** persisté
`/api/details/*`, `/api/transcript/*` (hors `transcript_history` déclenché par le front), `/api/search/suggest`, `/api/trending`, `/api/download/*/formats`, `/api/yt/*`, `/oauth/google/watchlater`, `/oauth/google/yt-history` — caches mémoire de processus uniquement.
### 9.5 Catalogue `videos` (phase 5)
`title` / `thumbnail` étaient **dupliqués** dans `watch_history`, `playlist_items` et les tables de tags, et les tags n'en portaient pas. Conséquence concrète : un like posé depuis l'UI **sans titre disponible** n'écrivait nulle part (le front n'a pas toujours la fiche), et `listLikedVideos` — qui lisait `title` via `LEFT JOIN watch_history` — renvoyait une ligne vide, invisible ou sans titre.
La table `videos` (`PRIMARY KEY (provider, video_id)`, provider en **nom long**) centralise ces métadonnées. Elle est alimentée par les trois points d'écriture existants :
| Point d'écriture | Effet |
|---|---|
| `upsertWatchHistory` | observation réelle d'une vidéo |
| `likeVideo` | comble le trou : appelé même **sans** titre ni vignette |
| `addPlaylistVideo` | ajout à une playlist = observation |
- `upsertVideoRow()` est **best-effort** : entrée invalide → `false`, jamais d'exception, jamais d'échec de l'écriture fonctionnelle.
- Chaque champ est écrit par `COALESCE` : un appel sans titre **n'efface pas** les métadonnées déjà connues.
- `0`, les valeurs négatives et les chaînes vides ne sont jamais persistés (`NULL`) — cohérent avec la règle « métadonnée absente = `undefined`, jamais 0 ».
- `captured_at` est rafraîchi à **chaque** ré-observation (indicateur de fraîcheur), `created_at` conserve la première observation.
- Lecture : `videos` en source primaire, repli `watch_history` puis `playlist_items`. Le repli playlist est une **sous-requête corrélée** — `UNIQUE(playlist_id, provider, video_id)` autorise la même vidéo dans N playlists, une jointure aurait dupliqué les likes.
- `video_tags.provider` est stocké **tel quel** par `likeVideo` (forme courte ou longue selon le front) : les JOIN normalisent donc les deux côtés.
- `watch_history` et `playlist_items` **conservent** leurs colonnes `title`/`thumbnail` (déréplication = phase suivante) : le repli de lecture en dépend encore.
---
## 10. Caches
| Cache | Emplacement | Clé | TTL |
|---|---|---|---|
| Résultats de recherche (front) | `search.service.ts:39-40, 99-117` | `pid\|q\|page\|sort\|type.duration.period.sort` | **60 s**, par provider, mémoire |
| Suggestions (front) | `suggest.service.ts:27-28` | `sortedProviders\|q\|limit` | **5 min**, purge globale à 200 entrées |
| Suggestions (serveur) | `index.mjs:3190-3213` | `suggest:{ids}:{q}:{limit}` | 5 min, LRU 500, rate-limit 60/min |
| Contenu de chaîne | `channel-content.service.ts:27-41` | `provider::channelId::type::sort::q` | **5 min** ; « frais » exige `items.length > 0` |
| Métadonnées de chaîne | `channels.service.ts:16-20` | `provider::externalId` | **6 h**, servi périmé en cas d'erreur |
| Recherche YouTube (serveur) | `youtube.mjs:94-224` | cf. §9.3 | 30 min |
| Transcript | `index.mjs:3283-3305` | `provider:videoId:lang` | **24 h**, rate-limit 10/min |
| Vidéos connexes YT | `index.mjs:1717-1723` | `related:{videoId}` | mémoire |
| Formats de téléchargement | `index.mjs:1115` | — | mémoire |
| Préférences d'affichage | `search.component.ts:31-33` | `newtube:search.hiddenProviders` etc. | localStorage |
---
## 11. Configuration par fournisseur
| Variable | Fournisseur(s) | Défaut | Effet |
|---|---|---|---|
| `YT_SEARCH_MODE` | YouTube | `innertube-first` | ordre d'essai des 3 sources |
| `YOUTUBE_API_KEY` / `YOUTUBE_API_KEYS` | YouTube | — | Data API v3 ; CSV ou tableau JSON ; rotation automatique |
| `YT_DLP_PATH` | YouTube | `yt-dlp[.exe]` sur le PATH | résolution du binaire : `YT_DLP_PATH` → PATH → binaire bundled `youtube-dl-exec` |
| `YT_SCRAPE_TTL_MS` | YouTube | 30 min | TTL du cache de recherche |
| `YT_SCRAPE_MEM_MAX` | YouTube | 300 | taille du LRU mémoire |
| `YT_DLP_TIMEOUT_MS` | YouTube | 20 s | timeout du binaire |
| `YT_COOKIES_FILE` / `YT_PO_TOKEN` / `YT_EGRESS_PROXY` | YouTube | — | anti-ban yt-dlp (jamais logués) |
| `YT_INNERTUBE_GL` / `YT_INNERTUBE_HL` | YouTube | `FR` / `fr` | localisation de la session InnerTube |
| `TWITCH_CLIENT_ID` / `TWITCH_CLIENT_SECRET` | Twitch | — | App Access Token ; **requis**, sinon le fournisseur renvoie `[]` |
| `CHANNEL_CONTENT_TIMEOUT_MS` | tous | 9 s | timeout des appels contenu de chaîne |
| `CHANNEL_FETCH_TIMEOUT_MS` | tous | 6 s | timeout des appels métadonnées de chaîne |
| `CHANNEL_TTL_MS` | tous | 6 h | fraîcheur de la table `channels` |
| `DOWNLOAD_PROVIDERS` | tous | les 6 | restreint les téléchargements (`index.mjs:1258`) |
| `SUPPORTED_DL_LANGS` / `DEFAULT_DL_LANGS` | tous | `fr,en` | langues de transcript autorisées |
| `NEWTUBE_DB_FILE` | tous | `db/newtube.db` | chemin SQLite alternatif |
**Instances PeerTube** : gérées **uniquement côté front** (`src/services/instance.service.ts:115-124`, défauts `video.manu.quebec`, `peerate.fr`, `mytube.pyramix.ca`, persistées en localStorage). La recherche serveur est figée sur `sepiasearch.org` ; le multi-instances n'a d'effet que sur la lecture et les téléchargements.
---
## 12. Points d'attention et écarts constatés
| # | Anomalie | Impact | Emplacement |
|---|---|---|---|
| 1 | **Rumble n'a aucune API** — tout dépend du scraping HTML ; en cas de challenge Cloudflare persistant le fournisseur renvoie `[]` silencieusement | Groupe de résultats vide, sans message | `rumble.mjs:121-133, 279` |
| 2 | **Rumble sans identité de chaîne** : `channelId`, `channelExternalId`, `publishedAt` jamais renseignés | La page chaîne filtre sur `uploaderName`/`url` et `nextPage` est toujours `null` | `rumble.mjs:224-235`, `channel-content.mjs:370-374` |
| 3 | **Odysee jette `views` et `publishedAt`** alors qu'ils sont disponibles en amont ; statut HTTP jamais vérifié | Cartes sans compteurs ; une erreur 4xx/5xx passe pour un résultat vide | `odysee.mjs:48-60`, `channel-content.mjs:349` |
| 3b | **Odysee (anomalie 3) : le mapping est correct, la SOURCE ne livre pas les données.** Vérifié en direct (30/09/2026) : `claim_search` ne renvoie ni `release_time` ni l'objet `video` — donc ni `view_count` — même quand ils sont demandés dans `include`. Le gel de la phase 8.4 le confirme (`views`/`publishedAt` absents pour `od`) | `views`/`publishedAt` restent absents sur la recherche Odysee ; le filtre `period=` n'y est pas inopérant par erreur de code mais par absence de donnée. Remplir ces champs demande un **autre endpoint** (résolution par claim), pas un correctif de mapping | `odysee.mjs:50-69` |
| 4 | **Pagination Twitch (page chaîne) cassée** : le curseur Helix est lu puis jeté → `page=2` renvoie `page=1` | Infinite scroll dupliqué | `channel-content.mjs:302-303` |
| 5 | **Item live Twitch (page chaîne) mal taggé** : `kind:'vod'`, `type:'video'`, `views=viewer_count`, `isLive` jamais posé | `isLiveItem()` ne le classera pas comme live | `channel-content.mjs:286-290` |
| 6 | **`pageToken` YouTube en cache process-local, vers-l'avant uniquement** | Changer `limit`/`sort`/`q` entre deux pages, un redémarrage ou un scale horizontal tronquent à la page 1 | `channel-content.mjs:122-141` |
| 7 | **`toVideoItem` perd `type`/`isLive`/`isShort`/`kind`/`width`/`height`** | Les grilles de chaîne retombent sur la règle « durée seule » : une verticale de 60 s y est bien short, une horizontale de 50 s aussi (faux positif) | `http-channel.provider.ts:25-40` |
| 8 | **Dérive de capacités** : `provider-registry.ts:19` déclare `dm.playlists = false`, `channel-detail.model.ts:44` déclare `true` | Incohérence d'affichage selon le code appelant | — |
| 9 | **3 conventions d'identité de chaîne divergentes** : `UC…` (yt), `"instance\|channel"` (pt), externalId sans `@` (od) | Toute jointure future multi-provider sera fragile | `channel-content.mjs:116-117, 331, 353` |
| 10 | **Branche morte** dans `channel-content.mjs:155` (retour identique ligne 157) | Sans effet, mais signale un refactor incomplet | — |
| 11 | **Code mort front** : `src/app/search/adapters/base.ts` | Maintenance inutile | — |
| 12 | **Liste de providers `'all'` codée en dur** dans `search.service.ts:93` et 4× dans `search.component.ts` | L'ajout d'un provider exige de toucher ces endroits, contredisant la promesse « 5 minutes » du README | — |
| 13 | **`video_tags` sans titre/vignette** | Un like sans ligne `watch_history` s'affiche avec un titre vide | `db.mjs:771-789` |
| 14 | **`pruneYoutubeCache` jamais appelé** | Les entrées expirées ne sont supprimées qu'à la lecture de la même clé | `db.mjs:1413` |
| 15 | **Brèche sur le quota YouTube** : `youtube_search_cache` ne stocke que YT, aucun cache persistant pour les 5 autres fournisseurs | Chaque recherche Dailymotion/PeerTube/Odysee/Rumble/Twitch refait un appel amont | `db/schema.sql` |
| 16 | **Compteurs PeerTube perdus** : le serveur émettait `viewCount`, le front lisait `views` (et `viewCount: undefined` en dur dans l'adaptateur) | Les vues PeerTube n'affichaient jamais. Corrigé en phase 8.4 : `views` est désormais canonique, `viewCount` reste en alias | `peertube.mjs:67-72`, `adapters/pt.ts:36` |
| 17 | **`language` PeerTube : objet au lieu de chaîne** — l'API renvoie `{id,label}`, l'ancienne garde cherchait `.code` et laissait passer l'objet entier | Le front recevait `{id:null,label:'Unknown'}` là où le contrat déclare `string`. Corrigé en phase 8.4 (`label` volontairement exclu : « Unknown » n'est pas un code de langue) | `peertube.mjs:71-79` |
---
## 13. Annexe — Référence des fichiers
> **Suite** : le plan d'exécution des corrections (parité de champs, classification, cache, table `videos`) se trouve dans [`plan-phases-catalogue-classification.md`](./plan-phases-catalogue-classification.md).
>
> **État d'avancement** : les phases 0, 1, 2, 3, 4 et 7 sont implémentées et testées — voir [`rapport-execution-phases-0-1-2-3-7.md`](./rapport-execution-phases-0-1-2-3-7.md).
### Backend
| Fichier | Rôle |
|---|---|
| `server/index.mjs` | routes API, fan-out `/api/search`, `providerUrlFrom()`, téléchargements, transcripts |
| `server/db.mjs` | schéma, migrations, tous les helpers d'écriture SQL |
| `server/search-filters.mjs` | modèle de filtres partagé, classification Short/Live, mapping natif |
| `server/transcript.mjs` | transcription multi-provider via yt-dlp + InnerTube |
| `server/providers/registry.mjs` | contrat `Suggestion`, registre des 6 adaptateurs, cache générique, pose de `channelRef` |
| `server/providers/channel-ref.mjs` | source unique des schemes d'identité de chaîne + normalisations Odysee / PeerTube (phase 6) |
| `server/providers/feature-flags.mjs` | `FF_<PROVIDER>` : extinction d'un provider sans redéploiement (phase 8.3) |
| `server/providers/youtube.mjs` | dispatcher 3 niveaux, cache 2 niveaux, métriques |
| `server/providers/youtube-common.mjs` | clés API, modes, résolution yt-dlp, anti-ban, métriques |
| `server/providers/youtube-innertube.mjs` | InnerTube (search, continuations, watch-next, caption tracks) |
| `server/providers/youtube-scrape.mjs` | scraping yt-dlp, flat-playlist, onglets de chaîne |
| `server/providers/dailymotion.mjs` | adaptateur REST Dailymotion |
| `server/providers/twitch.mjs` | adaptateur Helix (token, 4 mappeurs, recherche multi-sections) |
| `server/providers/peertube.mjs` | adaptateur SepiaSearch |
| `server/providers/odysee.mjs` | adaptateur Lighthouse |
| `server/providers/rumble.mjs` + `rumble_fetch.py` | scraping HTML + contournement Cloudflare |
| `server/providers/channel-content.mjs` | contenu de chaîne (4 types × 6 providers) |
| `server/providers/channel-registry.mjs` | métadonnées de chaîne (6 fetchers) |
### Frontend
| Fichier | Rôle |
|---|---|
| `src/app/core/providers/provider-registry.ts` | specs providers (id, libellé, icône, couleur, capacités) |
| `src/app/shared/models/video-item.model.ts` | modèle `VideoItem` |
| `src/app/shared/models/channel-detail.model.ts` | `ChannelMeta`, `ChannelDetail`, `DEFAULT_CAPABILITIES` |
| `src/app/shared/utils/video-kind.ts` | classification Short/Live/Vidéo (miroir serveur) |
| `src/app/shared/providers/channel-ref.ts` | identité de chaîne normalisée, `channelRefFrom()` / `channelUrlFromRef()` (phase 6) |
| `src/app/shared/utils/section-policy.ts` | politique d'acceptation par section + `isPlayable()` |
| `src/app/search/search.service.ts` | fan-out RxJS, timeout 8 s/provider, 1 retry, cache 60 s |
| `src/app/search/filters.ts` | modèle de filtres front + opérateurs (`live:`, `today:`, `long:`) |
| `src/app/search/search-contract.ts` | lecture stricte du contrat `v: 2` + extraction d'erreur par provider (phase 8.2) |
| `src/app/search/adapters/*.ts` | 6 adaptateurs HTTP → `VideoItem` |
| `src/app/core/providers/channel/*` | adaptateurs de contenu de chaîne |
| `src/services/channel-content.service.ts` | cache 5 min + infinite scroll avec dédup |
| `src/services/youtube-api.service.ts` | couche legacy : mappers détaillés par provider, normalisation durées/vignettes |
### Base de données
| Fichier | Rôle |
|---|---|
| `db/schema.sql` | 15 tables de base |
| `db/migrations/20240915_add_thumbnail_to_watch_history.sql` | `watch_history.thumbnail` |
| `db/migrations/20250923_add_subscriptions_tables.sql` | `channels`, `subscriptions` |
| `db/migrations/20250924_add_default_providers.sql` | `user_preferences.default_providers` |
| `db/migrations/20250924_add_download_jobs.sql` | file de téléchargement persistante |
| `db/migrations/20250924_add_telemetry_events.sql` | télémétrie UX |
| `db/migrations/20250926_add_download_languages.sql` | `user_preferences.download_languages` |
| `db/migrations/20250926_add_youtube_scrape_cache.sql` | `youtube_search_cache`, `youtube_metrics` |
| `db/migrations/20260926_add_subscription_groups.sql` | groupes d'abonnements |
| `db/migrations/20260926_add_transcript_history.sql` | `transcript_history` |
| `db/migrations/20260927_add_oauth_connections.sql` | `oauth_connections` |
| `db/migrations/20260927_add_oauth_yt_channel.sql` | colonnes `yt_channel_id`, `yt_page_id` |
| `db/migrations/20260927_backfill_youtube_thumbnails.sql` | backfill vignettes YT depuis `video_id` |
| `db/migrations/20260930_add_videos_table.sql` | `videos` (catalogue partagé) + backfill depuis `watch_history` / `playlist_items` (cf. §9.5) |
### Tests associés
| Test | Couverture |
|---|---|
| `server/tests/search-filters.test.mjs` (`npm run test:filters`) | normalisation, bornes, mapping providers, classification Short — **offline** |
| `server/tests/youtube-innertube.test.mjs` | `mapVideoNode`, `mapLockupView`, parsing |
| `server/tests/youtube-scrape.test.mjs` | `mapFlatEntry`, `parseFlatPlaylistJson`, classification d'erreurs |
| `server/tests/search.e2e.test.mjs` (`npm run test:search-e2e`) | fan-out réel, deep-links, préférences |
| `src/app/shared/utils/video-kind.spec.ts` (`npm run test:kind`) | classification Short front |
| `server/tests/transcript.test.mjs` (`npm run test:transcript`) | parseurs `json3`/`vtt` + contrat API |
| `server/tests/provider-contract.test.mjs` (`npm run test:contract`) | contrat `Suggestion v2` des 6 fournisseurs — **offline** |
| `server/tests/rumble-ld.test.mjs` (`npm run test:rumble`) | `parseRumbleViews()` (« 1,2 K »), JSON-LD — **offline** |
| `server/tests/search-cache.test.mjs` (`npm run test:cache`) | cache générique, TTL, plafond, `provider_metrics`, bascule InnerTube (61 assertions) — **offline** |
| `server/tests/videos_catalog.test.mjs` (`npm run test:videos`) | catalogue `videos`, upsert non destructif, trou des likes, replis, backfill (56 assertions) — **offline** |
| `server/tests/channel_ref.test.mjs` (`npm run test:channelref`) | `channelRef` : reconversion des 3 conventions historiques, parité stricte avec le front d'avant, parité des schemes serveur ↔ front, aucune valeur inventée (12 assertions) — **offline** |
> `server/db.mjs` **refuse** d'ouvrir `db/newtube.db` lorsqu'il est chargé depuis un `*.test.mjs` sans `NEWTUBE_DB_FILE` : une faute de frappe sur le nom de variable avait déjà écrit des fixtures de test dans la base de développement. Chaque test doit pointer vers un fichier temporaire avant l'import.
---
## Synthèse en une phrase
NewTube **saisit** les métadonnées vidéo par six chemins/upstream très hétérogènes — API REST structurée (Dailymotion, PeerTube via SepiaSearch), API authentifiée multi-sections (Twitch Helix), protocole interne sans clé avec 3 niveaux de repli (YouTube InnerTube/scrape/Data API), index JSON-RPC (Odysee), scraping HTML avec contournement anti-bot (Rumble) — puis les **catalogue** en les ramenant à un contrat unique (`Suggestion`), en les re-normalisant en `VideoItem` côté front, en les classant Vidéo/Short/Live par règles déterministes dupliquées serveur/front, et en ne persistant que les fragments liés à l'utilisateur (historique, playlists, likes, téléchargements) **plus** un catalogue `videos` partagé (phase 5) qui porte chaque métadonnée une seule fois, alimenté par les trois points d'écriture existants.
---
*Document généré le 2026-09-29 à partir de la lecture du code source du dépôt NewTube.*
@@ -0,0 +1,659 @@
# 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()`)*
---
## 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.*
+124
View File
@@ -0,0 +1,124 @@
# Rapport d'exécution — Phases 0, 1, 2, 3, 4 et 7
**Plan** : [`plan-phases-catalogue-classification.md`](./plan-phases-catalogue-classification.md)
**Analyse source** : [`ingestion-catalogue-video-par-fournisseur.md`](./ingestion-catalogue-video-par-fournisseur.md)
## Verdict
Ordre exécuté : **0 → 1 → 2 → 3 → 4 → 7** (le chemin à valeur rapide du plan).
Phases **5, 6, 8 non exécutées** — elles restent disponibles et n'ont pas été entamées.
| Vérification | Résultat |
|---|---|
| `npx tsc --noEmit` | exit 0 |
| `npm run build` (AOT, `strictTemplates`) | succès |
| Suites de tests | **21/21 vertes** |
| Assertions | 14 (contrat) + 41 (Rumble/JSON-LD) + 54 (cache/métriques/bascule) = **109** |
| Gain mesuré (phase 4) | 2ᵉ recherche identique : **1199 ms → 26 ms**, zéro appel amont |
## Bugs réels découverts pendant l'exécution
Ces anomalies n'étaient pas dans le document d'analyse : elles sont apparues en écrivant
les tests (phases 0-1) puis en auditant le diff (phases 2-3-7). Elles sont comptées dans les gains ci-dessous.
| # | Bug | Impact | Correctif |
|---|---|---|---|
| **B1** | `rumble.mjs` : `views` parse `"1,2K views"` → **12** (`.replace(/[^\d]/g,'')` supprimait le suffixe) | Le compteur de vues était **faux sur toutes les cartes Rumble > 999 vues** | `parseRumbleViews()` : compact FR/EN, milliers, séparateurs — 12 assertions |
| **B2** | `search-filters.mjs` : `itemDurationSec()` renvoyait `0` au lieu d'`undefined` | `0:00` affiché sur les cartes ; règle « verticale sans durée ⇒ pas short » inopérante | Retour `undefined` + 3 assertions de `test:filters` mises à jour |
| **B3** | `isShortItem()` serveur ignorait le marqueur URL `/shorts/` présent côté front | Un Short YouTube entrait dans la grille « vidéos » serveur mais sortait du filtre front — divergence silencieuse | Marqueur ajouté côté serveur |
| **B4** | `twitch.mjs` : `viewer_count` (spectateurs du live) exposé dans **`views`** | L'UI affichait « 1 234 **vues** » sur un live jamais regardé | Exposé dans `viewers` (champ v2 ajouté au contrat serveur + front) |
| **B5** | `toVideoItem()` ne propageait pas `type` | La page chaîne ne pouvait pas distinguer live/VOD sur la seule base de `kind` | `type: str('type')` ajouté |
## Phase 0 — Socle
### 0.1–0.3 Source unique
- **Créé** `src/app/shared/providers/provider-ids.ts` — `ProviderId`, `ProviderLongId`, `ALL_PROVIDER_IDS`, `ALL_PROVIDER_IDS_CSV`, `SHORT_TO_LONG`, `LONG_TO_SHORT`, `toShortProviderId`, `toLongProviderId`, `normalizeProviderList`, `parseProviderCsv`.
- **Créé** `src/app/shared/providers/provider-capabilities.ts` — `PROVIDER_CAPABILITIES` (matrice unique), `capabilitiesOf`, `providerSupportsLive`, `searchableProviders`, `toLegacySupports`.
- `provider-registry.ts` réduit à de la **présentation** (libellés, icônes, couleurs). `supports` est dérivé.
- `DEFAULT_CAPABILITIES` de `channel-detail.model.ts` est désormais **dérivé** de la matrice unique.
- **8 tables dupliquées supprimées** : `search.service.ts`, `suggest.util.ts`, `models.ts`, `search.component.ts` (×5), `header.component.ts`, `provider-badge`, `video-card`, `channel-provider.factory`, `watch.component`, `watch-short.component`, `subscriptions.component`, `http-channel.provider.ts`.
- Conflit `dm.playlists` (`false` dans le registre vs `true` dans `channel-detail`) **résolu** : le serveur fait foi ⇒ `true`.
### 0.4–0.5 Contrat versionné
- `server/providers/registry.mjs` : 22 champs optionnels documentés en JSDoc + `SUGGESTION_CONTRACT_VERSION = 2` + `SUGGESTION_V2_FIELDS`.
- `/api/search` renvoie désormais `v: 2`.
- `src/app/search/api.v1.ts` réécrit (`SuggestionItemV1` + champs v2), `VideoItem` étendu (v2 + `viewers`).
### 0.6 Test de contrat
- **Créé** `server/tests/provider-contract.test.mjs` (14 assertions) + `npm run test:contract`.
- Verrouille : `duration > 0` obligatoire, `publishedAt` doit être une vraie date (rejette « 2 months ago »), `views/likes/width/height > 0`, parité des règles (a)/(b)/(c) et du marqueur `/shorts/`.
## Phase 1 — Parité des champs
| Tâche | Fichier | Changement |
|---|---|---|
| 1.1 `views` | `odysee.mjs`, `channel-content.mjs` | `video.view_count` mappé. `effective_amount` **exclu** (montant LBC, pas des vues) |
| 1.2 `publishedAt` | `odysee.mjs` | `release_time` (déjà dans `include` !) converti en ISO. **Le filtre `period=` était inopérant sur Odysee** |
| 1.3 statut HTTP | `channel-content.mjs` | `resp.json().catch(()=>({}))` → `readJson()` + erreur 502 explicite |
| 1.4 `publishedAt` | `rumble.mjs` | `<time datetime>` au lieu du libellé « il y a 1 mois » |
| 1.5 `channelId` | `rumble.mjs` | Extrait de `/c/<username>/` |
| 1.6 `uploaderAvatar` | `rumble.mjs` | Image du by-line, `//` → `https` |
| 1.7 avatars | `youtube.mjs` | 1 appel `channels.list` par page (dégradation silencieuse sans clé) + `likeCount` + `liveBroadcastContent` |
| 1.8 avatars | `peertube.mjs` | `avatars[0].path` compte + chaîne, + `views`, `likes`, `publishedAt`, `language`, `hasSubtitles`, `kind` |
| 1.9 dates relatives | `youtube-innertube.mjs` | **`parseRelativeDate()`** FR/EN. `publishedAt` n'était jamais une date ⇒ `period=week` inopérant, `sort=date` sur `NaN` |
## Phase 2 — Propagation & classification
- **2.1** `toVideoItem()` réécrit : 12 champs ajoutés (`isLive`, `isShort`, `kind`, `game`, `width`, `height`, `likes`, `viewers`, `language`, `hasSubtitles`, `tags`, `description`). Lecture double nom long / nom court, `0 → undefined`.
- **2.2** `duration: 0 → undefined` dans `channel-content.mjs` (dm, pt, od), `search-filters.mjs`, et `toVideoItem`.
- **2.3** Règles (a)/(b)/(c) documentées et verrouillées dans les **deux** copies ; parité `/shorts/` rétablie.
- **2.4** `src/app/search/adapters/base.ts` **supprimé** ; branche morte `channel-content.mjs:155` supprimée.
## Phase 3 — Fiabilité
| Tâche | Changement |
|---|---|
| 3.1 Rumble JSON-LD | `parseJsonLd()` : `VideoObject` / `ItemList` / `@graph` → `datePublished`, `interactionStatistic`, `thumbnailUrl`, `duration`, `/c/<id>`. Surcouche additive, **n'écrase jamais** le DOM |
| 3.3 Cache négatif | 5 min, `search:vide:<q>:<page>`, borné à 200 entrées. Évite 4 requêtes (12-20 s) par frappe |
| 3.4 Échec ≠ vide | `search()` **lève** `rumble_cloudflare_challenge` ⇒ remonté dans `errors.ru` ⇒ bannière UI |
| 3.1b Curseur Twitch | `nextCursor` persisté de bout en bout (route → `ChannelContentPage` → `ChannelContentService` → `?cursor=`). **Avant, `page=2` renvoyait `page=1`** |
| 3.2 Item live Twitch | `type:'live'`, `isLive:true`, `kind:'live'`, **`viewers`** (et plus `views` — cf. B4), `game`, `language`, avatar. **Avant : marqué `kind:'vod'` (donc jamais classé live) et « vues » affichées à la place des spectateurs** |
| 3.5 `ruContent` | Filtre prioritaire sur `channelId` canonique |
| 3.6 `pageToken` YouTube | Cache de la chaîne de jetons (15 min, 200 entrées). La page 5 coûtait **5 appels Data API** ; désormais 1 en cache |
## Phase 7 — Présentation
- **7.1** Badges **génériques** (plus « Twitch-only ») : `LIVE` / `SHORT` / `CLIP` / `CHAÎNE` via `isShortVideoItem()` / `isLiveVideoItem()`.
- **7.3** `viewers` distinct de `viewCount` ; « joueurs en direct » réservé aux lives.
- **7.3** « **vues indisponibles** » avec infobulle au lieu d'un vide silencieux (qui se lisait comme « 0 vues »).
- **7.4** `providerErrors` structuré sur `search-result-grid` : bandeau ambre listant **source + cause traduite**, et message d'état vide distinct (« la recherche a échoué côté source ») de « aucun résultat ».
- **7.5** `canShowPill()` : une pastille n'apparaît que si un fournisseur actif sait **vraiment** servir ce type (source : `PROVIDER_CAPABILITIES`).
## Phase 4 — Cache générique & observabilité
- **4.1** Table `search_cache` (`cache_key`, `provider`, `q`, `payload_json`, `item_count`, `source`, `hit_count`, `created_at`, `expires_at`), TTL par provider, plafond 2 000/provider, jamais de vide persisté, `hit_count` à chaque lecture. Cache appliqué dans `server/providers/registry.mjs` (point d'entrée unique, 5 adaptateurs non-YT), YT exclu de par son cache à deux niveaux.
- **4.2** `migrateYoutubeCacheToSearchCache()` au boot (`INSERT OR IGNORE`), `youtube_search_cache` **conservée et lue en repli** → migration réversible par un simple `DROP TABLE search_cache`.
- **4.3** `GET /api/providers/health` (sonde `limit=1` + cache 60 s), `GET /api/providers/metrics`, table `provider_metrics` (par heure, rétention 24 h), `/healthz` étendu. Compteurs incrémentés **uniquement sur appels amont réels**.
- **4.4** Bascule automatique InnerTube → `scrape-first` sur taux d'échec ≥ 20 % sur 1 h, cooldown 15 min, seuil minimum de 3 appels, journalisée. `youtube.mjs` branche sur `getEffectiveSearchMode()` et inclut ce mode dans la clé de cache.
- **4.5** `startSearchCacheJanitor()` : `setInterval` 10 min + purge différée 2 s, tous deux `unref()`.
**Gain mesuré** (Dailymotion, 2ᵉ requête identique) : **1199 ms → 26 ms**, `calls` reste à 1, `hits` passe à 1.
## Tests ajoutés
| Fichier | Assertions |
|---|---|
| `server/tests/provider-contract.test.mjs` | 14 |
| `server/tests/rumble-ld.test.mjs` | 41 |
| `server/tests/search-cache.test.mjs` | 54 |
| `server/tests/search-filters.test.mjs` | +1 (3 assertions mises à jour en v2) |
Les 3 nouveaux fichiers de test sont câblés dans `.github/workflows/ci.yml`.
## Non exécuté
Phases **5** (table `videos`), **6** (`channelRef`), **8** (architecture). Aucune dépendance bloquante : les phases 0 et 4 les facilitent (contrat v2, source unique, cache générique).
## Points d'attention pour la suite
1. `provider-badge`, `video-card`, `channel-provider.factory` importent désormais la source unique : **ne plus réintroduire de table locale**.
2. `VideoItem.provider` est `ProviderLongId` : toute table doit venir de `SHORT_TO_LONG`.
3. Toute valeur absente reste `undefined`. `test:contract` échouera si un `0` ou une chaîne vide réapparaît.
4. `rumble.search()` lève désormais : ne pas l'appeler sans `try/catch` hors du fan-out `/api/search`.
5. Un nouveau fournisseur serverside hérite du cache et des métriques **gratuitement** en étant ajouté à `providerRegistry` — ne pas réimplémenter un cache dans un adaptateur.
6. `NEWTUBE_DB_FILE` doit être défini **avant** l'import de `db.mjs` : sinon un test écrit dans la base de dev (piège rencontré).