From 665a0f0ebd5e10033b495a3b0a229e87c7ed80c6 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Wed, 30 Sep 2026 07:57:11 -0400 Subject: [PATCH] =?UTF-8?q?feat(providers):=20phases=207.3/7.4/7.6/8.1=20?= =?UTF-8?q?=E2=80=94=20provenance,=20health,=20NDJSON,=20contrat=20unique?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.example | 14 + .github/workflows/ci.yml | 48 ++ db/migrations/20260930_add_videos_table.sql | 69 ++ db/schema.sql | 2 + ...gestion-catalogue-video-par-fournisseur.md | 643 +++++++++++++++++ docs/plan-phases-catalogue-classification.md | 659 ++++++++++++++++++ docs/rapport-execution-phases-0-1-2-3-7.md | 124 ++++ package.json | 17 + server/db.mjs | 648 ++++++++++++++++- server/index.mjs | 266 ++++++- server/providers/channel-content.mjs | 200 ++++-- server/providers/channel-ref.mjs | 179 +++++ server/providers/channel-registry.mjs | 140 +++- server/providers/feature-flags.mjs | 97 +++ server/providers/odysee.mjs | 14 +- server/providers/peertube.mjs | 27 + server/providers/registry.mjs | 283 +++++++- server/providers/rumble.mjs | 188 ++++- server/providers/twitch.mjs | 184 +++-- server/providers/youtube-common.mjs | 78 +++ server/providers/youtube-innertube.mjs | 46 +- server/providers/youtube.mjs | 129 +++- server/search-filters.mjs | 16 +- server/search-transport.mjs | 96 +++ server/tests/adapter_contract.test.mjs | 103 +++ server/tests/channel_banner.test.mjs | 278 ++++++++ server/tests/channel_ref.test.mjs | 188 +++++ server/tests/contract_version.test.mjs | 202 ++++++ server/tests/feature_flags_http.test.mjs | 112 +++ .../tests/fixtures/provider-suggestions.json | 263 +++++++ server/tests/generate-provider-doc.mjs | 103 +++ server/tests/lib/suggestion-shape.mjs | 105 +++ server/tests/page_tokens.test.mjs | 84 +++ server/tests/provenance.test.mjs | 144 ++++ server/tests/provider-contract.test.mjs | 165 +++++ server/tests/provider_shapes.test.mjs | 111 +++ server/tests/record_provider_shapes.mjs | 86 +++ server/tests/rumble-ld.test.mjs | 136 ++++ server/tests/search-cache.test.mjs | 177 +++++ server/tests/search-filters.test.mjs | 8 +- server/tests/search_debug.test.mjs | 110 +++ server/tests/search_stream.test.mjs | 142 ++++ server/tests/search_stream_provider.test.mjs | 125 ++++ server/tests/twitch_sections.test.mjs | 202 ++++++ server/tests/videos_catalog.test.mjs | 197 ++++++ .../channel/channel-provider.factory.ts | 41 +- .../channel/channel-provider.interface.ts | 2 + .../channel/http-channel.provider.ts | 88 ++- src/app/core/providers/provider-registry.ts | 95 ++- src/app/search/adapters/base.ts | 24 - src/app/search/adapters/dm.ts | 14 +- src/app/search/adapters/od.ts | 15 +- src/app/search/adapters/provenance.spec.ts | 59 ++ src/app/search/adapters/provenance.ts | 77 ++ src/app/search/adapters/pt.ts | 30 +- src/app/search/adapters/ru.ts | 21 +- src/app/search/adapters/tw.ts | 13 +- src/app/search/adapters/yt.ts | 16 +- src/app/search/api.v1.ts | 59 +- src/app/search/models.ts | 6 +- src/app/search/provider-health.model.ts | 62 ++ .../search/provider-health.service.spec.ts | 70 ++ src/app/search/provider-health.service.ts | 51 ++ src/app/search/search-contract.ts | 96 +++ src/app/search/search.service.ts | 147 ++-- src/app/search/suggest.util.ts | 4 +- .../provider-badge.component.ts | 17 +- .../search-result-grid.component.html | 49 +- .../search-result-grid.component.ts | 58 ++ .../video-card-skeleton.component.html | 4 +- .../video-card-skeleton.component.ts | 48 +- .../video-card/video-card.component.html | 36 +- .../video-card/video-card.component.ts | 117 +++- src/app/shared/models/channel-detail.model.ts | 36 +- src/app/shared/models/video-item.model.ts | 44 +- src/app/shared/providers/channel-ref.ts | 158 +++++ .../shared/providers/provider-capabilities.ts | 92 +++ src/app/shared/providers/provider-ids.ts | 89 +++ src/app/shared/utils/video-kind.spec.ts | 57 ++ src/app/shared/utils/video-kind.ts | 98 ++- src/components/header/header.component.ts | 5 +- .../subscriptions/subscriptions.component.ts | 15 +- src/components/search/search.component.html | 27 +- src/components/search/search.component.ts | 211 ++++-- .../shorts/watch-short.component.ts | 7 +- src/components/watch/watch.component.ts | 9 +- src/services/channel-content.service.ts | 25 +- 87 files changed, 8850 insertions(+), 550 deletions(-) create mode 100644 db/migrations/20260930_add_videos_table.sql create mode 100644 docs/ingestion-catalogue-video-par-fournisseur.md create mode 100644 docs/plan-phases-catalogue-classification.md create mode 100644 docs/rapport-execution-phases-0-1-2-3-7.md create mode 100644 server/providers/channel-ref.mjs create mode 100644 server/providers/feature-flags.mjs create mode 100644 server/search-transport.mjs create mode 100644 server/tests/adapter_contract.test.mjs create mode 100644 server/tests/channel_banner.test.mjs create mode 100644 server/tests/channel_ref.test.mjs create mode 100644 server/tests/contract_version.test.mjs create mode 100644 server/tests/feature_flags_http.test.mjs create mode 100644 server/tests/fixtures/provider-suggestions.json create mode 100644 server/tests/generate-provider-doc.mjs create mode 100644 server/tests/lib/suggestion-shape.mjs create mode 100644 server/tests/page_tokens.test.mjs create mode 100644 server/tests/provenance.test.mjs create mode 100644 server/tests/provider-contract.test.mjs create mode 100644 server/tests/provider_shapes.test.mjs create mode 100644 server/tests/record_provider_shapes.mjs create mode 100644 server/tests/rumble-ld.test.mjs create mode 100644 server/tests/search-cache.test.mjs create mode 100644 server/tests/search_debug.test.mjs create mode 100644 server/tests/search_stream.test.mjs create mode 100644 server/tests/search_stream_provider.test.mjs create mode 100644 server/tests/twitch_sections.test.mjs create mode 100644 server/tests/videos_catalog.test.mjs delete mode 100644 src/app/search/adapters/base.ts create mode 100644 src/app/search/adapters/provenance.spec.ts create mode 100644 src/app/search/adapters/provenance.ts create mode 100644 src/app/search/provider-health.model.ts create mode 100644 src/app/search/provider-health.service.spec.ts create mode 100644 src/app/search/provider-health.service.ts create mode 100644 src/app/search/search-contract.ts create mode 100644 src/app/shared/providers/channel-ref.ts create mode 100644 src/app/shared/providers/provider-capabilities.ts create mode 100644 src/app/shared/providers/provider-ids.ts diff --git a/.env.example b/.env.example index 8dad93c..d71feb9 100644 --- a/.env.example +++ b/.env.example @@ -95,6 +95,20 @@ YT_CACHE_TTL_MS=1800000 # SUGGEST_CACHE_TTL_MS=300000 # Suggestions web (favicon/duckduckgo) : '0' pour désactiver # SUGGEST_WEB_ENABLED=1 +# --- Phase 8.3 : feature flags par provider ----------------------------------- +# Un provider non configuré est ACTIF (comportement historique). Mettre le flag +# à '0' / 'false' / 'off' / 'no' / 'disabled' pour l'éteindre SANS redéployer : +# il est retiré du fan-out et signalé `errors. = disabled_by_ff`. +# -> 'ru' est derrière Cloudflare et casse sans prévenir : c'est le principal +# usage prévu. 'od' dépend d'un backend Odysee NA, également à risque. +# Attention : laisser la variable VIDE ne désactive pas (c'est traité comme +# « non configuré »), pour qu'un `FF_RU=` forgotten n'éteigne pas le provider. +# FF_YT=1 +# FF_DM=1 +# FF_TW=1 +# FF_PT=1 +# FF_OD=1 +# FF_RU=1 # Rate limit des suggestions, requêtes par fenêtre # SUGGEST_RATE_LIMIT=60 # TTL du cache transcript, en ms (défaut : 24 h) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3db034..88c0363 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,5 +55,53 @@ jobs: - name: YouTube scrape-first (Step 17, offline) run: npm run test:ytscrape + - name: Provider contract v2 (offline) + run: npm run test:contract + + - name: Rumble parsing / JSON-LD (offline) + run: npm run test:rumble + + - name: Search cache + provider metrics + YouTube failover (offline) + run: npm run test:cache + + - name: Video catalog + likes denormalization (offline) + run: npm run test:videos + + - name: Channel identity `channelRef` (offline) + run: npm run test:channelref + + - name: Suggestion shape on frozen provider fixtures (offline) + run: npm run test:shapes + + - name: Twitch parallel sections + section budget (offline) + run: npm run test:twitch + + - name: YouTube channel page tokens persistence (offline) + run: npm run test:tokens + + - name: Channel banners + descriptions across 6 providers (offline) + run: npm run test:banners + + - name: Provenance `capturedAt`/`source` + `?debug=1` payload (offline) + run: npm run test:provenance + + - name: Client-side provenance helpers (offline) + run: npm run test:provenance-front + + - name: Provider health classification (offline) + run: npm run test:provider-health + + - name: Search NDJSON transport (offline) + run: npm run test:stream + + - name: Unified ProviderAdapter contract (offline) + run: npm run test:adapter + + - name: Contract v2 strict + `FF_` feature flags (offline) + run: npm run test:flags + + - name: Video kind classification (offline) + run: npm run test:kind + - name: YouTube InnerTube (Step 18, offline) run: npm run test:ytinnertube diff --git a/db/migrations/20260930_add_videos_table.sql b/db/migrations/20260930_add_videos_table.sql new file mode 100644 index 0000000..8ed7f6e --- /dev/null +++ b/db/migrations/20260930_add_videos_table.sql @@ -0,0 +1,69 @@ +-- Phase 5.1 : catalogue video persistant (table `videos`). +-- +-- Volontairement MINCE : pas de table de streams, pas de formats, pas de +-- commentaires. Le but est de casser la denormalisation `title`/`thumbnail` +-- dupliquee dans `watch_history` et `playlist_items` (phase 5.4), pas de +-- reimplementer le catalogue. +-- +-- Migration 100 % additive et reversible : `DROP TABLE videos` suffit a revenir +-- en arriere, aucun `ALTER` sur une table existante. + +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, tronque a 4 Ko + captured_at TEXT NOT NULL, -- fraicheur (indicateur UI §9.3) + created_at TEXT, + PRIMARY KEY (provider, video_id) +); + +-- Fraicheur : sert au tri « vu recemment » et a l'indicateur de perinatalite. +CREATE INDEX IF NOT EXISTS idx_videos_captured ON videos(captured_at DESC); + +-- Requetes de contenu de chaine par identifiant externe. +CREATE INDEX IF NOT EXISTS idx_videos_channel ON videos(provider, channel_external_id); + +-- Phase 5.3 : backfill depuis les tables denormalisees. +-- `INSERT OR IGNORE` : ne jamais ecraser une ligne deja observee dans `videos` +-- (plus riche que la source de backfill). +-- +-- Le `provider` est NORMALISE en nom long : `watch_history` et `playlist_items` +-- conservent la forme recue (courte `yt` ou longue `youtube`) selon l'appelant, +-- alors que `videos` est cle par le nom long. Sans ce CASE, ('yt','id') et +-- ('youtube','id') creeraient deux lignes et les lectures normalisees +-- n'en verraient qu'une. +INSERT OR IGNORE INTO videos + (provider, video_id, title, thumbnail, captured_at, created_at) +SELECT + CASE LOWER(provider) + WHEN 'yt' THEN 'youtube' WHEN 'dm' THEN 'dailymotion' + WHEN 'tw' THEN 'twitch' WHEN 'pt' THEN 'peertube' + WHEN 'od' THEN 'odysee' WHEN 'ru' THEN 'rumble' + ELSE LOWER(provider) END, + video_id, title, thumbnail, last_watched_at, last_watched_at + FROM watch_history + WHERE video_id IS NOT NULL AND title IS NOT NULL; + +INSERT OR IGNORE INTO videos + (provider, video_id, title, thumbnail, captured_at, created_at) +SELECT + CASE LOWER(provider) + WHEN 'yt' THEN 'youtube' WHEN 'dm' THEN 'dailymotion' + WHEN 'tw' THEN 'twitch' WHEN 'pt' THEN 'peertube' + WHEN 'od' THEN 'odysee' WHEN 'ru' THEN 'rumble' + ELSE LOWER(provider) END, + video_id, title, thumbnail, added_at, added_at + FROM playlist_items + WHERE video_id IS NOT NULL AND title IS NOT NULL; diff --git a/db/schema.sql b/db/schema.sql index 3cca4bf..6612a8a 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -120,6 +120,8 @@ CREATE TABLE IF NOT EXISTS channels ( subs_count INTEGER, verified INTEGER DEFAULT 0, last_refreshed_at INTEGER, + banner_url TEXT, + description TEXT, UNIQUE(provider, external_id) ); diff --git a/docs/ingestion-catalogue-video-par-fournisseur.md b/docs/ingestion-catalogue-video-par-fournisseur.md new file mode 100644 index 0000000..325edf2 --- /dev/null +++ b/docs/ingestion-catalogue-video-par-fournisseur.md @@ -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`
• binaire `yt-dlp --dump-single-json --flat-playlist`
• `googleapis.com/youtube/v3/search` + `/videos` | Aucune pour InnerTube/scrape.
**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=…`
`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`
`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=`
`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`
`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=`
② `GET rumble.com/search/all?search-videos=1&q=`
`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 + + + +> 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.) + + + +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`
`:143-215` | ✅ `/user/{u}/videos`
`:219-248` | ✅ `/videos?type=archive`
`:273-303` | ✅ `/video-channels/{c}/videos`
`:307-335` | ✅ `claim_search` JSON-RPC
`:338-359` | ⚠️ recherche globale + filtre client sur `uploaderName`+`url`
`: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`
`:170-193` | ✅ `/user/{u}/playlists`
`:222-230` | ❌ vide | ✅ `/video-playlists?sort=-updatedAt`
`:312-323` | ❌ vide | ❌ vide | +| **live** | ✅ onglet `/streams` + `eventType=live`
`:203` | ❌ vide | ⚠️ `/streams?user_login&first=1`
`nextPage:null` codé en dur
`: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}` | `` (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.* diff --git a/docs/plan-phases-catalogue-classification.md b/docs/plan-phases-catalogue-classification.md new file mode 100644 index 0000000..23d3217 --- /dev/null +++ b/docs/plan-phases-catalogue-classification.md @@ -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.* diff --git a/docs/rapport-execution-phases-0-1-2-3-7.md b/docs/rapport-execution-phases-0-1-2-3-7.md new file mode 100644 index 0000000..e4a5477 --- /dev/null +++ b/docs/rapport-execution-phases-0-1-2-3-7.md @@ -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é). diff --git a/package.json b/package.json index 5b58373..a53f2ba 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,23 @@ "test:search": "node --loader ts-node/esm --experimental-specifier-resolution=node src/app/search/search.service.spec.ts && node --loader ts-node/esm --experimental-specifier-resolution=node src/app/search/search-components.spec.ts", "test:suggest": "node --loader ts-node/esm --experimental-specifier-resolution=node src/app/search/suggest.spec.ts && node ./server/tests/suggest.test.mjs", "test:filters": "node ./server/tests/search-filters.test.mjs", + "test:contract": "node ./server/tests/provider-contract.test.mjs", + "test:shapes": "node --test ./server/tests/provider_shapes.test.mjs", + "fixtures:record": "node ./server/tests/record_provider_shapes.mjs", + "doc:providers": "node ./server/tests/generate-provider-doc.mjs", + "test:rumble": "node ./server/tests/rumble-ld.test.mjs", + "test:cache": "node ./server/tests/search-cache.test.mjs", + "test:videos": "node ./server/tests/videos_catalog.test.mjs", + "test:channelref": "node ./server/tests/channel_ref.test.mjs", + "test:flags": "node --test ./server/tests/contract_version.test.mjs ./server/tests/feature_flags_http.test.mjs", + "test:twitch": "node --test ./server/tests/twitch_sections.test.mjs", + "test:tokens": "node --test ./server/tests/page_tokens.test.mjs", + "test:banners": "node --test ./server/tests/channel_banner.test.mjs", + "test:provenance": "node --test ./server/tests/provenance.test.mjs ./server/tests/search_debug.test.mjs", + "test:stream": "node --test ./server/tests/search_stream.test.mjs ./server/tests/search_stream_provider.test.mjs", + "test:adapter": "node --test ./server/tests/adapter_contract.test.mjs", + "test:provider-health": "node --loader ts-node/esm --experimental-specifier-resolution=node src/app/search/provider-health.service.spec.ts", + "test:provenance-front": "node --loader ts-node/esm --experimental-specifier-resolution=node src/app/search/adapters/provenance.spec.ts", "test:transcript": "node --test server/tests/transcript.test.mjs", "test:history": "node server/tests/history_filters.test.mjs", "test:highlight": "node --loader ts-node/esm --experimental-specifier-resolution=node src/components/account/history/highlight.util.spec.ts", diff --git a/server/db.mjs b/server/db.mjs index 8843b4a..266c379 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -21,9 +21,45 @@ if (!fs.existsSync(dbDir)) { } // Create DB and enable FKs + +/** + * Garde-fou d'isolation (Phase 5). + * + * Tous les tests de `server/tests/` sont nommés `*.test.mjs` et DOIVENT + * travailler sur une base temporaire via `NEWTUBE_DB_FILE`. Sans ce garde-fou, + * une faute de frappe sur le nom de la variable (`NEW_TUBE_DB_PATH` au lieu de + * `NEWTUBE_DB_FILE`) fait ouvrir la base de developpement en lecture-ecriture : + * les fixtures du test atterrissent alors chez l'utilisateur, silencieusement. + * C'est deja arrive (lignes de cache factices dans `db/newtube.db`). + * + * On refuse donc explicitement, avec un message qui donne la marche a suivre, + * plutot que de laisser la corruption se produire. + */ +if (!overrideDbFile && process.argv.some((a) => /\.test\.mjs$/.test(a))) { + throw new Error( + `[db] Refus d'ouvrir la base de developpement (${dbFile}) depuis un test. ` + + 'Definissez process.env.NEWTUBE_DB_FILE sur un chemin temporaire AVANT ' + + "l'import de db.mjs (le nom exact est NEWTUBE_DB_FILE, sans \"NEW_TUBE_\").", + ); +} + const db = new Database(dbFile); db.pragma('foreign_keys = ON'); +/** + * Chemin reel du fichier ouvert. + * + * Expose pour que les tests puissent AFFIRMER leur isolation au lieu de la + * supposer : un test qui se trompe de nom de variable d'environnement + * (`NEW_TUBE_DB_PATH` au lieu de `NEWTUBE_DB_FILE`) ouvre silencieusement la + * base de dev et y ecrit ses fixtures. Cela a deja pollue `db/newtube.db` avec + * des lignes de cache factices. Un `ok(getDbFile().includes('test'))` en tete de + * fichier echoue bruyamment au lieu de corrompre la base de l'utilisateur. + */ +export function getDbFile() { + return dbFile; +} + // Run schema if present (first boot) if (schemaFile && fs.existsSync(schemaFile)) { try { @@ -76,6 +112,16 @@ if (schemaFile && fs.existsSync(schemaFile)) { // Idempotent migrations (IF NOT EXISTS / duplicate columns) can fail on // databases already up to date — tolerate and continue. console.warn(`[db] Migration ${file} reported an error:`, e?.message || e); + } finally { + // Une migration `BEGIN … COMMIT` qui échoue AU MILIEU (ex. un ALTER sur une + // colonne déjà présente) laisse la transaction ouverte dans better-sqlite3. + // Toutes les migrations SUIVANTES s'y exécutent alors « avec succès » puis + // sont annulées à la fermeture du process : leurs tables disparaissent + // sans trace alors que le journal affiche « applied ». On referme donc la + // transaction résiduelle avant de poursuivre. + if (db.inTransaction) { + try { db.exec('ROLLBACK'); } catch {} + } } // Record as applied even on tolerated errors so we don't re-run/re-warn each boot. try { @@ -102,6 +148,17 @@ if (schemaFile && fs.existsSync(schemaFile)) { const have2 = new Set(colsItems.map(c => c.name)); if (!have2.has('thumbnail')) db.exec(`ALTER TABLE playlist_items ADD COLUMN thumbnail TEXT`); } catch {} + // Phase 7.7 : bannière + description de chaîne. Ajoutés en JS plutôt que dans + // un fichier de migration car `db/schema.sql` est réappliqué à CHAQUE boot : + // un `ALTER TABLE … ADD COLUMN` en migration échouerait (colonne déjà + // présente) sur une base neuve, et ce fichier échouant laissait la + // transaction ouverte, annulant silencieusement les migrations suivantes. + try { + const colsChannels = db.prepare(`PRAGMA table_info(channels)`).all(); + const haveCh = new Set(colsChannels.map(c => c.name)); + if (!haveCh.has('banner_url')) db.exec(`ALTER TABLE channels ADD COLUMN banner_url TEXT`); + if (!haveCh.has('description')) db.exec(`ALTER TABLE channels ADD COLUMN description TEXT`); + } catch {} try { db.exec(`CREATE TABLE IF NOT EXISTS playlist_metrics ( id TEXT PRIMARY KEY, @@ -422,6 +479,154 @@ export function normalizeHistoryProvider(raw) { export function escapeLikePattern(raw) { return String(raw ?? '').replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_'); } + +// -------------------- Phase 5 : catalogue `videos` -------------------- +/** + * `videos` est la **seule** table qui porte les metadonnees d'une video. + * `watch_history` et `playlist_items` les denormalisent (phase 5.4) : cette + * duplication est le bug qui fait qu'un like sans visionnage anterieur + * s'affiche sans titre. + * + * Provider : la cle est toujours le **nom long** (`normalizeHistoryProvider`). + * `video_tags.provider` est en revanche stocke tel quel par `likeVideo` — donc + * court ou long selon ce qu'envoie le front. C'est exactement pour cela que + * `listLikedVideos` normalise les deux cotes de la jointure. + */ +function ensureVideosTable() { + try { + db.exec(`CREATE TABLE IF NOT EXISTS videos ( + provider TEXT NOT NULL, video_id TEXT NOT NULL, + title TEXT, thumbnail TEXT, duration_seconds INTEGER, views INTEGER, + published_at TEXT, url TEXT, kind TEXT, + channel_external_id TEXT, channel_name TEXT, channel_avatar_url TEXT, + width INTEGER, height INTEGER, + raw_json TEXT, captured_at TEXT NOT NULL, created_at TEXT, + PRIMARY KEY (provider, video_id) + );`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_videos_captured ON videos(captured_at DESC);`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_videos_channel ON videos(provider, channel_external_id);`); + } catch {} +} +ensureVideosTable(); + +/** Expression SQL normalisant une colonne provider vers le nom long. */ +function providerLongExpr(col) { + const whens = Object.entries(HISTORY_SHORT_TO_LONG).map(([s, l]) => `WHEN '${s}' THEN '${l}'`).join(' '); + return `(CASE LOWER(${col}) ${whens} ELSE LOWER(${col}) END)`; +} + +const positiveInt = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.round(v) : null); +const nonEmptyStr = (v) => (typeof v === 'string' && v.trim().length > 0 ? v.trim() : null); + +/** + * Ecrit (ou met a jour) une ligne `videos`. + * + * **Best-effort par contrat** : une erreur ici ne doit jamais faire echouer + * l'ecriture fonctionnelle appelante (historique, like, playlist). D'ou le + * try/catch qui englobe tout et retourne `false`. + * + * Deux invariants : + * - `captured_at` est rafraichi a chaque re-observation (c'est l'indicateur de + * fraicheur de la phase 5.1) ; + * - les colonnes absentes ne sont **pas** ecrasees (`COALESCE`) : un appel + * minimal (un like ne fournit que titre + vignette) ne doit pas effacer les + * metadonnees vues lors d'une session de lecture complete. + * + * @returns {boolean} true si la ligne a ete ecrite + */ +export function upsertVideoRow(dto) { + try { + const provider = normalizeHistoryProvider(dto?.provider) + || String(dto?.provider || '').trim().toLowerCase(); + const videoId = String(dto?.videoId ?? dto?.video_id ?? '').trim(); + if (!provider || !videoId) return false; + ensureVideosTable(); + const now = nowIso(); + // `raw_json` est un garde-fou de debug : tronque pour ne pas faire exploser + // la base avec le payload d'une recherche. + let rawJson = null; + if (dto?.raw && typeof dto.raw === 'object') { + try { rawJson = JSON.stringify(dto.raw).slice(0, 4096); } catch { rawJson = null; } + } + db.prepare( + `INSERT INTO videos (provider, video_id, title, thumbnail, duration_seconds, views, + published_at, url, kind, channel_external_id, channel_name, + channel_avatar_url, width, height, raw_json, captured_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(provider, video_id) DO UPDATE SET + title = COALESCE(excluded.title, videos.title), + thumbnail = COALESCE(excluded.thumbnail, videos.thumbnail), + duration_seconds = COALESCE(excluded.duration_seconds, videos.duration_seconds), + views = COALESCE(excluded.views, videos.views), + published_at = COALESCE(excluded.published_at, videos.published_at), + url = COALESCE(excluded.url, videos.url), + kind = COALESCE(excluded.kind, videos.kind), + channel_external_id = COALESCE(excluded.channel_external_id, videos.channel_external_id), + channel_name = COALESCE(excluded.channel_name, videos.channel_name), + channel_avatar_url = COALESCE(excluded.channel_avatar_url, videos.channel_avatar_url), + width = COALESCE(excluded.width, videos.width), + height = COALESCE(excluded.height, videos.height), + raw_json = COALESCE(excluded.raw_json, videos.raw_json), + captured_at = excluded.captured_at`, + ).run( + provider, videoId, + nonEmptyStr(dto?.title), nonEmptyStr(dto?.thumbnail), + positiveInt(dto?.durationSeconds ?? dto?.duration), positiveInt(dto?.views), + nonEmptyStr(dto?.publishedAt), nonEmptyStr(dto?.url), nonEmptyStr(dto?.kind), + nonEmptyStr(dto?.channelExternalId ?? dto?.channelId), nonEmptyStr(dto?.channelName), + nonEmptyStr(dto?.channelAvatarUrl), + positiveInt(dto?.width), positiveInt(dto?.height), + rawJson, now, now, + ); + return true; + } catch { + return false; // best-effort : voir le contrat ci-dessus + } +} + +/** Ligne `videos` pour un couple (provider, videoId), provider court ou long. */ +export function getVideoRow(provider, videoId) { + try { + const p = normalizeHistoryProvider(provider) || String(provider || '').trim().toLowerCase(); + ensureVideosTable(); + return db.prepare(`SELECT * FROM videos WHERE provider = ? AND video_id = ?`).get(p, String(videoId || '')) || null; + } catch { return null; } +} + +export function countVideos() { + try { ensureVideosTable(); return db.prepare(`SELECT COUNT(1) AS n FROM videos`).get()?.n || 0; } catch { return 0; } +} + +/** + * Phase 5.3 — backfill depuis les tables denormalisees, **rejouable** (la + * migration SQL le fait une fois au deploiement ; ceci permet de rattraper des + * lignes ecrites entre le deploiement et l'arrivee du nouveau code). + * `INSERT OR IGNORE` : n'ecrase jamais une ligne deja observee, qui est plus + * riche que la source de backfill. + */ +export function backfillVideosFromLegacy() { + const report = { watchHistory: 0, playlistItems: 0 }; + try { + ensureVideosTable(); + // `provider` est selectionne puis NORMALISE : `playlist_items` et + // `watch_history` conservent la forme recue (courte ou longue) selon l'appelant, + // alors que `videos` est cle par le nom long. Sans cette normalisation, un + // couple ('yt', 'id') et ('youtube', 'id') creeraient deux lignes et les + // lectures normalisees enVerifieraient toujours une des deux. + report.watchHistory = db.prepare( + `INSERT OR IGNORE INTO videos (provider, video_id, title, thumbnail, captured_at, created_at) + SELECT ${providerLongExpr('provider')}, video_id, title, thumbnail, last_watched_at, last_watched_at + FROM watch_history WHERE video_id IS NOT NULL AND title IS NOT NULL`, + ).run().changes || 0; + report.playlistItems = db.prepare( + `INSERT OR IGNORE INTO videos (provider, video_id, title, thumbnail, captured_at, created_at) + SELECT ${providerLongExpr('provider')}, video_id, title, thumbnail, added_at, added_at + FROM playlist_items WHERE video_id IS NOT NULL AND title IS NOT NULL`, + ).run().changes || 0; + } catch {} + return report; +} + export function insertSearchHistory({ userId, query, filters }) { const id = cryptoRandomId(); const created_at = nowIso(); @@ -492,6 +697,9 @@ export function upsertWatchHistory({ userId, provider, videoId, title, thumbnail last_watched_at=excluded.last_watched_at`).run( cryptoRandomId(), userId, normProvider, videoId, title || null, thumbnail || null, watched_at, progressSeconds, durationSeconds, (typeof lastPositionSeconds === 'number' ? lastPositionSeconds : null), now ); + // Phase 5.2 : alimente la table `videos` (best-effort, ne doit jamais faire + // echouer l'ecriture de l'historique). + upsertVideoRow({ provider: normProvider, videoId, title, thumbnail, durationSeconds }); // Return the row id const row = db.prepare(`SELECT * FROM watch_history WHERE user_id = ? AND provider = ? AND video_id = ?`).get(userId, normProvider, videoId); return row; @@ -701,6 +909,11 @@ export function likeVideo({ userId, provider, videoId, title, thumbnail }) { db.prepare(`INSERT OR IGNORE INTO video_tags (user_id, provider, video_id, tag_id, created_at) VALUES (?, ?, ?, ?, ?)`) .run(userId, provider, videoId, tagId, nowIso()); + // Phase 5.2 : on alimente `videos` meme quand aucun titre/vignette n'est fourni. + // C'est le cas qui produisait le trou des likes : la ligne `video_tags` + // existait mais rien d'autre ne portait les metadonnees. + upsertVideoRow({ provider, videoId, title, thumbnail }); + // Also update the watch_history table with the title and thumbnail if (title || thumbnail) { upsertWatchHistory({ userId, provider, videoId, title, thumbnail }); @@ -764,22 +977,47 @@ export function listLikedVideos({ userId, limit = 100, q }) { return []; } - // Récupérer les vidéos aimées avec les métadonnées de l'historique - // Note: La colonne thumbnail n'existe pas dans la table watch_history + // Phase 5.3 : les metadonnees viennent de `videos` (catalogue partage), avec + // repli `watch_history` puis `playlist_items` pour les lignes anterieures a + // la migration. + // + // Les deux cotes sont normalises : `video_tags.provider` est stocke tel quel + // par `likeVideo` (court ou long selon le front) alors que `videos` est + // cle par le nom long. Sans cette normalisation, un like emis avec `yt` + // ne trouvait aucune ligne `youtube` — le bug d'origine. const hasQ = typeof q === 'string' && q.trim().length > 0; const like = `%${(q || '').trim()}%`; + const vtProvider = providerLongExpr('vt.provider'); + const vProvider = providerLongExpr('v.provider'); + const whProvider = providerLongExpr('wh.provider'); + // Repli playlist en sous-requete correlee et NON en jointure : `playlist_items` + // n'est unique que par (playlist_id, provider, video_id), donc une video + // presente dans 3 playlists y apparaitrait 3 fois dans le resultat. + const pliTitle = `(SELECT pi.title FROM playlist_items pi + WHERE ${providerLongExpr('pi.provider')} = ${vtProvider} + AND pi.video_id = vt.video_id AND pi.title IS NOT NULL + ORDER BY pi.added_at ASC LIMIT 1)`; + const pliThumb = `(SELECT pi.thumbnail FROM playlist_items pi + WHERE ${providerLongExpr('pi.provider')} = ${vtProvider} + AND pi.video_id = vt.video_id AND pi.thumbnail IS NOT NULL + ORDER BY pi.added_at ASC LIMIT 1)`; + const titleExpr = `COALESCE(v.title, wh.title, ${pliTitle}, '')`; + const thumbExpr = `COALESCE(v.thumbnail, wh.thumbnail, ${pliThumb}, '')`; const base = ` - SELECT - vt.provider, + SELECT + vt.provider, vt.video_id, vt.created_at, - COALESCE(wh.title, '') AS title, - COALESCE(wh.thumbnail, '') AS thumbnail, - wh.last_watched_at AS last_watched_at + ${titleExpr} AS title, + ${thumbExpr} AS thumbnail, + wh.last_watched_at AS last_watched_at, + v.captured_at AS captured_at FROM video_tags vt + LEFT JOIN videos v + ON v.provider = ${vProvider} AND v.video_id = vt.video_id LEFT JOIN watch_history wh - ON wh.user_id = vt.user_id - AND wh.provider = vt.provider + ON wh.user_id = vt.user_id + AND ${whProvider} = ${vtProvider} AND wh.video_id = vt.video_id WHERE vt.user_id = ? AND vt.tag_id = ? `; @@ -788,12 +1026,14 @@ export function listLikedVideos({ userId, limit = 100, q }) { LIMIT ? `; const query = hasQ - ? `${base} AND (COALESCE(wh.title,'') LIKE ? OR vt.provider LIKE ? OR vt.video_id LIKE ?) + ? `${base} AND (${titleExpr} LIKE ? OR vt.provider LIKE ? OR vt.video_id LIKE ?) ${orderLimit}` : `${base} ${orderLimit}`; - - console.log('[listLikedVideos] Exécution de la requête:', query.replace(/\s+/g, ' ').trim()); - + + // La requete est devenue longue (normalisation provider x 3, sous-requetes + // de repli) : la logger en entier poluait la sortie a chaque appel. + console.log(`[listLikedVideos] ${hasQ ? 'recherche' : 'liste'} pour ${userId} (tag ${tag.id})`); + const rows = hasQ ? db.prepare(query).all(userId, tag.id, like, like, like, limit) : db.prepare(query).all(userId, tag.id, limit); @@ -933,6 +1173,8 @@ export function addPlaylistVideo({ userId, playlistId, provider, videoId, title, const id = cryptoRandomId(); db.prepare(`INSERT OR IGNORE INTO playlist_items (id, playlist_id, provider, video_id, title, thumbnail, added_at, position) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(id, playlistId, provider, videoId, title || null, thumbnail || null, now, position); + // Phase 5.2 : un ajout a une playlist est une observation de la video. + upsertVideoRow({ provider, videoId, title, thumbnail }); recordPlaylistMetric({ userId, playlistId, action: 'add_video', meta: { provider, videoId } }); // Return the row (if it existed we need to fetch by key) const row = db.prepare(`SELECT id, playlist_id AS playlistId, provider, video_id AS videoId, title, thumbnail, added_at AS addedAt, position @@ -1083,6 +1325,8 @@ export function channelRowToMeta(row) { title: row.title || null, handle: row.handle || null, avatarUrl: row.avatar_url || null, + bannerUrl: row.banner_url || null, + description: row.description || null, url: row.url || null, subsCount: typeof row.subs_count === 'number' ? row.subs_count : undefined, verified: row.verified == null ? undefined : Boolean(row.verified), @@ -1090,6 +1334,13 @@ export function channelRowToMeta(row) { }; } +/** Double défense : l'assainissement des URLs vit dans channel-registry, mais une + * bannière stockée en base ne doit jamais pouvoir survivre à un futur appelant + * qui oublierait `safeMeta`. Règle locale : http(s) ou rien. */ +function sanitizeBannerUrl(value) { + return typeof value === 'string' && /^https?:\/\//i.test(value.trim()) ? value.trim() : null; +} + export function upsertChannelRow(meta) { if (!meta || !meta.provider || !meta.externalId) { throw new Error('invalid_channel_meta'); @@ -1101,17 +1352,21 @@ export function upsertChannelRow(meta) { title: meta.title || null, handle: meta.handle || null, avatar_url: meta.avatarUrl || null, + banner_url: sanitizeBannerUrl(meta.bannerUrl), + description: typeof meta.description === 'string' && meta.description.trim() ? meta.description.trim() : null, url: meta.url || null, subs_count: typeof meta.subsCount === 'number' ? meta.subsCount : null, verified: meta.verified === undefined ? null : (meta.verified ? 1 : 0), last_refreshed_at: meta.lastRefreshedAt ? Number(meta.lastRefreshedAt) : now, }; - db.prepare(`INSERT INTO channels (provider, external_id, title, handle, avatar_url, url, subs_count, verified, last_refreshed_at) - VALUES (@provider, @external_id, @title, @handle, @avatar_url, @url, @subs_count, @verified, @last_refreshed_at) + db.prepare(`INSERT INTO channels (provider, external_id, title, handle, avatar_url, banner_url, description, url, subs_count, verified, last_refreshed_at) + VALUES (@provider, @external_id, @title, @handle, @avatar_url, @banner_url, @description, @url, @subs_count, @verified, @last_refreshed_at) ON CONFLICT(provider, external_id) DO UPDATE SET title=excluded.title, handle=excluded.handle, avatar_url=excluded.avatar_url, + banner_url=excluded.banner_url, + description=excluded.description, url=excluded.url, subs_count=excluded.subs_count, verified=COALESCE(excluded.verified, channels.verified), @@ -1136,6 +1391,8 @@ export async function ensureChannelFresh(provider, externalId, fetcher, opts = { title: data?.title, handle: data?.handle, avatarUrl: data?.avatarUrl, + bannerUrl: data?.bannerUrl, + description: data?.description, url: data?.url, subsCount: data?.subsCount, verified: data?.verified, @@ -1383,7 +1640,47 @@ function ensureYoutubeCacheTables() { } ensureYoutubeCacheTables(); +/** + * Task 4.2 — migration de `youtube_search_cache` vers `search_cache`. + * + * La clé est identique (`hashSearchKey` + préfixe `yt|`) : on copie donc les + * lignes telles quelles, sans retransformation. La table d'origine **n'est pas + * supprimée** : elle reste lue en repli pendant une version, ce qui rend la + * migration réversible (`DROP TABLE search_cache` suffit à revenir en arrière). + * + * `INSERT OR IGNORE` : ne jamais écraser une entrée plus fraîche déjà présente + * dans `search_cache` (une base peut avoir servi les deux tables). + */ +export function migrateYoutubeCacheToSearchCache() { + try { + ensureYoutubeCacheTables(); + ensureSearchCacheTables(); + const hasOld = db.prepare(`SELECT COUNT(1) AS n FROM youtube_search_cache`).get()?.n || 0; + if (hasOld === 0) return { migrated: 0, remaining: 0, legacyReadable: false }; + const res = db.prepare( + `INSERT OR IGNORE INTO search_cache (cache_key, provider, q, payload_json, item_count, source, hit_count, created_at, expires_at) + SELECT q_hash, 'yt', q, payload_json, + CASE WHEN json_valid(payload_json) THEN json_array_length(payload_json) ELSE 0 END, + source, 0, created_at, expires_at + FROM youtube_search_cache WHERE expires_at > ?`, + ).run(Date.now()); + const migrated = res.changes || 0; + const remaining = db.prepare(`SELECT COUNT(1) AS n FROM youtube_search_cache WHERE expires_at > ?`).get(Date.now())?.n || 0; + return { migrated, remaining, legacyReadable: true }; + } catch { return { migrated: 0, remaining: 0, legacyReadable: false, error: true }; } +} + +/** + * Lecture YouTube. Essaie `search_cache` (table générique, phase 4) puis + * **replie** sur `youtube_search_cache` : une base upgradeée depuis une version + * antérieure peut n'avoir que des lignes dans l'ancienne table tant que la + * migration n'a pas tourné. + */ export function getCachedYoutubeSearch(qHash) { + try { + const generic = getCachedSearch('yt', qHash); + if (generic) return { items: generic.items, source: generic.source }; + } catch {} try { ensureYoutubeCacheTables(); const row = db.prepare(`SELECT payload_json AS payload, source, expires_at AS exp FROM youtube_search_cache WHERE q_hash = ?`).get(qHash); @@ -1396,7 +1693,10 @@ export function getCachedYoutubeSearch(qHash) { } catch { return null; } } +/** Écrit YouTube : table générique en primaire, ancienne table en repli. */ export function setCachedYoutubeSearch(qHash, q, items, source, ttlMs) { + const written = setCachedSearch('yt', qHash, q, items, source, ttlMs); + if (written) return; try { ensureYoutubeCacheTables(); const now = Date.now(); @@ -1411,7 +1711,8 @@ export function setCachedYoutubeSearch(qHash, q, items, source, ttlMs) { } export function pruneYoutubeCache() { - try { db.prepare(`DELETE FROM youtube_search_cache WHERE expires_at <= ?`).run(Date.now()); } catch {} + // Phase 4.5 : la purge ne délaisse plus l'ancienne table derrière. + return pruneSearchCache(); } export function incYoutubeMetrics({ scrapeCalls = 0, apiCalls = 0, quotaUnits = 0 } = {}) { @@ -1438,6 +1739,321 @@ export function countYoutubeCacheRows() { try { return db.prepare(`SELECT COUNT(1) AS n FROM youtube_search_cache`).get()?.n || 0; } catch { return 0; } } +// -------------------- Phase 4.1 : cache de recherche générique -------------------- +/** + * Cache de recherche **générique**, multi-fournisseur. + * + * Remplace `youtube_search_cache` (task 4.2) sans le supprimer : les anciennes + * fonctions YouTube restent des surcouches de compatibilité et lisent encore + * l'ancienne table en repli. + * + * Choix de conception (cf. `docs/plan-phases-catalogue-classification.md`) : + * - la clé reprend **exactement** le format YouTube `hashSearchKey` + * (`<provider>|<sha256(q|perPage|page|sort|filtersCacheKey)>`) : une ligne + * existante peut être migrée telle quelle, et la migration est réversible ; + * - `hit_count` est incrémenté **à chaque lecture** (y compris les lectures + * expirées) pour mesurer le ratio cache/total via `/api/providers/metrics` ; + * - un résultat **vide n'est jamais persisté** : une page vide transitoire + * (continuation expirée, raté réseau partiel) ne doit pas empoisonner le + * cache pendant 5-30 min. + */ +const SEARCH_CACHE_MAX_ROWS = 2000; + +/** TTL par fournisseur. YT garde 30 min (quota Data API), les autres 5 min. */ +export function searchCacheTtlMs(provider) { + const p = String(provider || '').toLowerCase(); + if (p === 'yt') { + const override = Number(process.env.SEARCH_CACHE_TTL_MS_YT); + if (Number.isFinite(override) && override > 0) return override; + return 30 * 60 * 1000; + } + const perProvider = Number(process.env[`SEARCH_CACHE_TTL_MS_${p.toUpperCase()}`]); + if (Number.isFinite(perProvider) && perProvider > 0) return perProvider; + const general = Number(process.env.SEARCH_CACHE_TTL_MS_DEFAULT); + if (Number.isFinite(general) && general > 0) return general; + return 5 * 60 * 1000; +} + +function ensureSearchCacheTables() { + try { + db.exec(`CREATE TABLE IF NOT EXISTS search_cache ( + cache_key TEXT NOT NULL, provider TEXT NOT NULL, q TEXT NOT NULL DEFAULT '', + payload_json TEXT NOT NULL, item_count INTEGER NOT NULL DEFAULT 0, + source TEXT NOT NULL DEFAULT 'api', hit_count INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, + PRIMARY KEY (cache_key, provider) + );`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_search_cache_exp ON search_cache(expires_at);`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_search_cache_prov ON search_cache(provider, expires_at);`); + } catch {} +} +ensureSearchCacheTables(); + +/** + * Lecture cache + bump du `hit_count`. + * @returns {{items: any[], source: string, hitCount: number}|null} + */ +export function getCachedSearch(provider, cacheKey) { + const p = String(provider || '').toLowerCase(); + try { + ensureSearchCacheTables(); + const row = db.prepare( + `SELECT payload_json AS payload, source, expires_at AS exp, hit_count AS hits, created_at AS createdAt + FROM search_cache WHERE cache_key = ? AND provider = ?`, + ).get(String(cacheKey), p); + if (!row) return null; + const hits = Number(row.hits || 0) + 1; + try { db.prepare(`UPDATE search_cache SET hit_count = ? WHERE cache_key = ? AND provider = ?`).run(hits, String(cacheKey), p); } catch {} + if (Date.now() >= Number(row.exp || 0)) { + // Expiration paresseuse : la ligne est comptabilisée puis purgée. + try { db.prepare(`DELETE FROM search_cache WHERE cache_key = ? AND provider = ?`).run(String(cacheKey), p); } catch {} + return null; + } + try { + const items = JSON.parse(String(row.payload || '[]')); + if (!Array.isArray(items)) return null; + // `createdAt` = moment où l'amont a réellement été interrogé. C'est la + // SEULE date de capture honnête pour un hit de cache : `Date.now()` + // mentirait en annonçant « à l'instant » une réponse vieille de 4 min. + return { items, source: row.source, hitCount: hits, createdAt: Number(row.createdAt || 0) || null }; + } catch { return null; } + } catch { return null; } +} + +/** Écriture. Un tableau vide est ignoré (voir en-tête de section). */ +export function setCachedSearch(provider, cacheKey, q, items, source, ttlMs) { + const p = String(provider || '').toLowerCase(); + if (!Array.isArray(items) || items.length === 0) return false; + try { + ensureSearchCacheTables(); + const now = Date.now(); + // Un TTL explicitement fourni est respecte tel quel (y compris 0 ou + // negatif = « deja expire », utile pour vider un cache en test) ; seule + // l'absence de valeur (undefined/NaN) retombe sur le TTL du provider. + const ttl = Number.isFinite(ttlMs) ? ttlMs : searchCacheTtlMs(p); + db.prepare( + `INSERT INTO search_cache (cache_key, provider, q, payload_json, item_count, source, hit_count, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?) + ON CONFLICT(cache_key, provider) DO UPDATE SET q=excluded.q, payload_json=excluded.payload_json, + item_count=excluded.item_count, source=excluded.source, created_at=excluded.created_at, + expires_at=excluded.expires_at`, + ) .run(String(cacheKey), p, String(q || '').slice(0, 300), JSON.stringify(items), items.length, String(source || 'api'), now, now + ttl); + // Uniquement le plafond : la purge des lignes expirees est un travail de + // fond (intervalle 10 min, cf. 4.5) et ne doit pas coutir un DELETE par + // ecriture. + enforceSearchCacheCap(p, SEARCH_CACHE_MAX_ROWS); + return true; + } catch { return false; } +} + +/** + * Phase 3.12 — persistance des `pageToken` de contenu de chaîne. + * + * L'API YouTube pagine avec des jetons opaques, pas des numéros de page : pour + * atteindre la page N il faut le jeton produit en chargeant la page N-1. Ces + * jetons vivaient dans une Map **process-local**, ce qui avait deux conséquences + * réelles : + * 1. un redémarrage ou un passage derrière un load-balancer repart de la page 1 ; + * 2. au-delà de 500 clés, la Map faisait `clear()` — donc la pagination était + * réinitialisée SANS AUCUN SIGNE, silencieusement. + * + * On garde la Map comme cache L1 (rapide, et évite un accès SQLite par lecture) + * et on déporte la persistance dans `search_cache` (L2), ce qui couvre le + * redémarrage et le scale horizontal. Un éviction du L1 ne perd plus rien. + * + *.Namespace de provider distinct (`yt_tokens`) : ces lignes ne sont pas des + * résultats de recherche, et les mélanger aux lignes `yt` fausserait le plafond + * par fournisseur et les statistiques de cache. + */ +const PAGE_TOKEN_PROVIDER = 'yt_tokens'; +const PAGE_TOKEN_TTL_MS = 5 * 60e3; + +export function getCachedPageTokens(cacheKey) { + try { + ensureSearchCacheTables(); + const row = db.prepare( + `SELECT payload_json AS payload, expires_at AS exp FROM search_cache WHERE cache_key = ? AND provider = ?`, + ).get(String(cacheKey), PAGE_TOKEN_PROVIDER); + if (!row) return null; + if (Number(row.exp || 0) <= Date.now()) { + // Expiré : on ne le sert pas, et on le retire pour ne pas le relire. + try { db.prepare(`DELETE FROM search_cache WHERE cache_key = ? AND provider = ?`).run(String(cacheKey), PAGE_TOKEN_PROVIDER); } catch {} + return null; + } + const tokens = JSON.parse(row.payload); + return Array.isArray(tokens) ? tokens : null; + } catch { return null; } +} + +export function setCachedPageTokens(cacheKey, tokens, ttlMs = PAGE_TOKEN_TTL_MS) { + if (!Array.isArray(tokens) || tokens.length === 0) return false; + try { + ensureSearchCacheTables(); + const now = Date.now(); + const ttl = Number.isFinite(ttlMs) ? ttlMs : PAGE_TOKEN_TTL_MS; + // Normalisation ICI, à la frontière de persistance, et pas chez l'appelant : + // `JSON.stringify` transforme un trou (`undefined`) en `null`, et un `filter` + // décalerait les index — le jeton de la page 2 se retrouverait à l'index 0 + // et la page 3 renverrait celui de la page 4. Une pagination silencieusement + // décalée est indétectable en production ; on garantit donc l'indexation à + // l'écriture, pour tout appelant présent ou futur. `''` reste falsy, donc + // la lecture la traite comme un jeton absent. + const dense = Array.from({ length: tokens.length }, (_, i) => { + const t = tokens[i]; + return typeof t === 'string' && t.length > 0 ? t : ''; + }); + db.prepare( + `INSERT INTO search_cache (cache_key, provider, q, payload_json, item_count, source, hit_count, created_at, expires_at) + VALUES (?, ?, '', ?, ?, 'page_token', 0, ?, ?) + ON CONFLICT(cache_key, provider) DO UPDATE SET payload_json=excluded.payload_json, + item_count=excluded.item_count, created_at=excluded.created_at, expires_at=excluded.expires_at`, + ).run(String(cacheKey), PAGE_TOKEN_PROVIDER, JSON.stringify(dense), dense.length, now, now + ttl); + enforceSearchCacheCap(PAGE_TOKEN_PROVIDER, 500); + return true; + } catch { return false; } +} + +/** Vide les jetons persistés (tests, et invalidation manuelle). */ +export function clearCachedPageTokens() { + try { + ensureSearchCacheTables(); + db.prepare(`DELETE FROM search_cache WHERE provider = ?`).run(PAGE_TOKEN_PROVIDER); + return true; + } catch { return false; } +} + +/** + * Plafond par fournisseur : purge par `expires_at DESC`, donc les plus + * fraîches survivent (identique au plafond historique de `youtube_search_cache`). + */ +function enforceSearchCacheCap(provider, cap = SEARCH_CACHE_MAX_ROWS) { + try { + db.prepare( + `DELETE FROM search_cache WHERE provider = ? AND cache_key NOT IN ( + SELECT cache_key FROM search_cache WHERE provider = ? ORDER BY expires_at DESC LIMIT ?)`, + ).run(String(provider), String(provider), Math.max(1, cap)); + } catch {} +} + +/** + * Purge de fond : supprime les lignes expirees, puis applique le plafond. + * Appelee par l'intervalle de 10 min au boot (task 4.5). + */ +export function pruneSearchCache({ cap = SEARCH_CACHE_MAX_ROWS } = {}) { + let purged = 0; + try { + ensureSearchCacheTables(); + purged = db.prepare(`DELETE FROM search_cache WHERE expires_at <= ?`).run(Date.now()).changes || 0; + } catch {} + try { + for (const r of db.prepare(`SELECT DISTINCT provider FROM search_cache`).all()) { + enforceSearchCacheCap(r.provider, cap); + } + } catch {} + return purged; +} + +export function countSearchCacheRows(provider) { + try { + ensureSearchCacheTables(); + const p = provider ? String(provider).toLowerCase() : null; + const row = p + ? db.prepare(`SELECT COUNT(1) AS n FROM search_cache WHERE provider = ?`).get(p) + : db.prepare(`SELECT COUNT(1) AS n FROM search_cache`).get(); + return row?.n || 0; + } catch { return 0; } +} + +/** Ratio hit/total par fournisseur — alimenta `/api/providers/metrics`. */ +export function searchCacheStats() { + try { + ensureSearchCacheTables(); + return db.prepare( + `SELECT provider, COUNT(1) AS entries, COALESCE(SUM(hit_count), 0) AS hits, + COALESCE(SUM(item_count), 0) AS items + FROM search_cache GROUP BY provider`, + ).all().map((r) => ({ ...r, hitRate: r.hits + r.entries > 0 ? r.hits / (r.hits + r.entries) : 0 })); + } catch { return []; } +} + +// -------------------- Phase 4.3 : metriques fournisseurs -------------------- +/** + * Compteurs par fenetre horaire et par fournisseur. Generalise `youtube_metrics` + * (qui reste en place pour le quota Data API, granularite jour). + * + * Volontairement **fin** : une ligne par (heure, provider), purgee au-dela de + * `PROVIDER_METRICS_RETENTION_H` (defaut 24 h). La bascule automatique (4.4) lit + * la fenetre d'1 h ; le cache lit `hit_count` de `search_cache`. + */ +function ensureProviderMetricsTable() { + try { + db.exec(`CREATE TABLE IF NOT EXISTS provider_metrics ( + hour TEXT NOT NULL, provider TEXT NOT NULL, + calls INTEGER NOT NULL DEFAULT 0, ok_calls INTEGER NOT NULL DEFAULT 0, + errors INTEGER NOT NULL DEFAULT 0, fallback_calls INTEGER NOT NULL DEFAULT 0, + total_latency_ms INTEGER NOT NULL DEFAULT 0, + last_error TEXT, updated_at TEXT NOT NULL, + PRIMARY KEY (hour, provider) + );`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_provider_metrics_hour ON provider_metrics(hour);`); + } catch {} +} +ensureProviderMetricsTable(); + +/** Incrémente les compteurs d'un appel fournisseur terminé. */ +export function incProviderMetrics(provider, { ok = true, latencyMs = 0, fallback = false, error = null } = {}) { + try { + ensureProviderMetricsTable(); + const p = String(provider || 'unknown').toLowerCase(); + const now = new Date(); + const hour = now.toISOString().slice(0, 13); // AAAA-MM-JJTHH + const lat = Number.isFinite(latencyMs) && latencyMs > 0 ? Math.min(Math.round(latencyMs), 600000) : 0; + db.prepare( + `INSERT INTO provider_metrics (hour, provider, calls, ok_calls, errors, fallback_calls, total_latency_ms, last_error, updated_at) + VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?) + ON CONFLICT(hour, provider) DO UPDATE SET + calls = calls + 1, + ok_calls = ok_calls + excluded.ok_calls, + errors = errors + excluded.errors, + fallback_calls = fallback_calls + excluded.fallback_calls, + total_latency_ms = total_latency_ms + excluded.total_latency_ms, + last_error = COALESCE(excluded.last_error, provider_metrics.last_error), + updated_at = excluded.updated_at`, + ).run(hour, p, ok ? 1 : 0, ok ? 0 : 1, fallback ? 1 : 0, lat, error ? String(error).slice(0, 500) : null, now.toISOString()); + } catch {} +} + +/** Instantané par fournisseur sur la fenetre demandee (defaut 1 h). */ +export function providerMetricsSnapshot({ hours = 1 } = {}) { + try { + ensureProviderMetricsTable(); + const since = new Date(Date.now() - Math.max(1, hours) * 3600e3).toISOString().slice(0, 13); + const rows = db.prepare( + `SELECT provider, COALESCE(SUM(calls),0) AS calls, COALESCE(SUM(ok_calls),0) AS okCalls, + COALESCE(SUM(errors),0) AS errors, COALESCE(SUM(fallback_calls),0) AS fallbacks, + COALESCE(SUM(total_latency_ms),0) AS totalLatencyMs + FROM provider_metrics WHERE hour >= ? GROUP BY provider`, + ).all(since); + return rows.map((r) => ({ + provider: r.provider, + calls: r.calls, + ok: r.okCalls, + errors: r.errors, + fallbacks: r.fallbacks, + avgLatencyMs: r.calls > 0 ? Math.round(r.totalLatencyMs / r.calls) : 0, + errorRate: r.calls > 0 ? r.errors / r.calls : 0, + })); + } catch { return []; } +} + +export function purgeProviderMetrics() { + try { + const hours = Math.max(1, Number(process.env.PROVIDER_METRICS_RETENTION_H || 24)); + const before = new Date(Date.now() - hours * 3600e3).toISOString().slice(0, 13); + return db.prepare(`DELETE FROM provider_metrics WHERE hour < ?`).run(before).changes || 0; + } catch { return 0; } +} + // -------------------- OAuth : connexions Google / Twitch (import favoris/abos) -------------------- function ensureOAuthTables() { try { diff --git a/server/index.mjs b/server/index.mjs index 8e615ee..e293964 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -15,8 +15,10 @@ import ffmpegPath from 'ffmpeg-static'; import * as cheerio from 'cheerio'; import axios from 'axios'; import rumbleRouter from './rumble.mjs'; -import { providerRegistry, validateProviders } from './providers/registry.mjs'; +import { providerRegistry, getProviderAdapter, validateProviders, SUGGESTION_CONTRACT_VERSION } from './providers/registry.mjs'; +import { applyProviderFlags, providerFlag } from './providers/feature-flags.mjs'; import { dedupeSuggestGroups } from './suggest.mjs'; +import { buildDebugPayload, APPLICATION_NDJSON } from './search-transport.mjs'; import { parseSearchFilters, activeFilters as activeSearchFilters, applySearchFilters, filtersCacheKey } from './search-filters.mjs'; import { fetchWebSuggest, fetchOdyseeLighthouseSuggest } from './suggest-web.mjs'; import { pickTrack, parseTrackText, parseVtt, dedupeTranscriptLines, orderedTracks, translatedFallbacks, firstPerLanguage, normalizeTranscriptProvider, transcriptTrackExt, looksLikeHtmlError, ensureFmtParam, isEmptyTimedTextBody, isBlobTranscript, mergeTranscriptCandidates } from './transcript.mjs'; @@ -102,7 +104,7 @@ import { deleteTranscriptHistoryById, deleteAllTranscriptHistory, } from './db.mjs'; -import { getChannelAdapter, setTwitchTokenProvider } from './providers/channel-registry.mjs'; +import { setTwitchTokenProvider } from './providers/channel-registry.mjs'; import { fetchChannelContent } from './providers/channel-content.mjs'; import { oauthStatus, buildAuthUrl, createOAuthState, consumeOAuthState, exchangeCode, @@ -275,11 +277,11 @@ function requireProviderId(value) { } async function resolveChannel(provider, externalId, { forceRefresh = false } = {}) { - const adapterEntry = getChannelAdapter(provider) || providerRegistry[provider]; - if (!adapterEntry || typeof adapterEntry.fetchChannelById !== 'function') { + const adapterEntry = getProviderAdapter(provider); + if (!adapterEntry || typeof adapterEntry.channelMeta !== 'function') { throw Object.assign(new Error('provider_not_supported'), { status: 501 }); } - return ensureChannelFresh(provider, externalId, () => adapterEntry.fetchChannelById(externalId), { force: forceRefresh }); + return ensureChannelFresh(provider, externalId, () => adapterEntry.channelMeta(externalId), { force: forceRefresh }); } r.post('/channels/resolve', authMiddlewareCookieAware, channelsLimiter, async (req, res) => { @@ -325,8 +327,12 @@ r.get('/channels/:provider/:externalId/content', channelsLimiter, async (req, re const limit = Math.min(50, Math.max(1, Number(req.query.limit || 24))); const sort = req.query.sort === 'popular' ? 'popular' : req.query.sort === 'relevance' ? 'relevance' : 'recent'; const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 200) : ''; - const data = await fetchChannelContent(provider, externalId, { type, page, limit, sort, q }, { searchRegistry: providerRegistry }); - return res.json({ ...data, page, limit, sort, type }); + // Phase 3.1 - curseur opaque pour les API paginées par curseur (Twitch Helix). + // Sans lui, `page=2` re-demandait la page 1 et la pagination bouclait à + // l'infini. Le front le renvoie tel quel dans `?cursor=`. + const cursor = typeof req.query.cursor === 'string' ? req.query.cursor.slice(0, 512) : ''; + const data = await getProviderAdapter(provider).channelContent(externalId, { type, page, limit, sort, q, cursor }, { searchRegistry: providerRegistry }); + return res.json({ ...data, page, limit, sort, type, ...(data?.nextCursor ? { nextCursor: data.nextCursor } : {}) }); } catch (error) { const status = error?.status || 500; return res.status(status).json({ error: error?.message || 'channel_content_failed', items: [], nextPage: null }); @@ -1761,9 +1767,9 @@ r.get('/details/:provider/:videoId', async (req, res) => { if (!uploaderAvatar && channelExternalId) { const shortToRegistry = { youtube: 'yt', dailymotion: 'dm', twitch: 'tw', peertube: 'pt', odysee: 'od', rumble: 'ru' }; const regProvider = shortToRegistry[String(provider)] || String(provider); - const adapter = (typeof getChannelAdapter === 'function') ? getChannelAdapter(regProvider) : null; - if (adapter && typeof adapter.fetchChannelById === 'function') { - const ch = await adapter.fetchChannelById(String(channelExternalId)); + const adapter = getProviderAdapter(regProvider); + if (adapter && typeof adapter.channelMeta === 'function') { + const ch = await adapter.channelMeta(String(channelExternalId)); if (ch?.avatarUrl) uploaderAvatar = ch.avatarUrl; if (typeof ch?.subsCount === 'number' && !subscribers) subscribers = ch.subsCount; } @@ -2873,13 +2879,124 @@ r.get('/img/odysee', async (req, res) => { }); // Mount API router (prod) and alias for dev proxy +// Phase 4.5 : purge de fond du cache de recherche (toutes les 10 min). +// `unref()` est indispensable : sans lui le timer empeche le process de +// s'arreter, y compris dans les tests qui demarrent puis ferment le serveur. +const SEARCH_CACHE_JANITOR_MS = Math.max(60e3, Number(process.env.SEARCH_CACHE_PRUNE_MS || 10 * 60 * 1000)); +let searchCacheJanitorStarted = false; +function startSearchCacheJanitor() { + if (searchCacheJanitorStarted) return; + searchCacheJanitorStarted = true; + const tick = async () => { + try { + const { pruneSearchCache, purgeProviderMetrics } = await import('./db.mjs'); + const purged = pruneSearchCache(); + const metrics = purgeProviderMetrics(); + if (purged || metrics) console.log(`[cache] purge: ${purged} entrées search_cache, ${metrics} lignes provider_metrics`); + } catch (e) { + console.warn('[cache] purge échouée:', e?.message || e); + } + }; + const timer = setInterval(tick, SEARCH_CACHE_JANITOR_MS); + timer.unref?.(); + // Une purge immédiate au boot : une base qui a tourné des semaines hors ligne + // peut accumuler des lignes expirees en masse. + setTimeout(tick, 2000).unref?.(); +} + +// Phase 4.2 : migration `youtube_search_cache` -> `search_cache`, une fois au boot. +// Idempotente et non destructive : l'ancienne table reste lue en repli. +(async () => { + try { + const { migrateYoutubeCacheToSearchCache } = await import('./db.mjs'); + const r = migrateYoutubeCacheToSearchCache(); + if (r?.migrated > 0) console.log(`[cache] migration youtube_search_cache -> search_cache: ${r.migrated} ligne(s) copiée(s)`); + } catch (e) { + console.warn('[cache] migration ignorée:', e?.message || e); + } +})(); + +// Phase 4.3 : observabilité fournisseurs. Aucun secret exposé. +const PROVIDER_IDS = ['yt', 'dm', 'tw', 'pt', 'od', 'ru']; +// Sonde par provider : une recherche `limit=1`, résultat mis en cache 60 s +// (sans ce garde-fou, `/providers/health` créerait l'inverse du problème qu'il +// mesure en martelant les upstreams). +const healthProbeCache = new Map(); +const HEALTH_PROBE_TTL_MS = Math.max(5e3, Number(process.env.PROVIDER_HEALTH_TTL_MS || 60e3)); +async function probeProvider(pid) { + const cached = healthProbeCache.get(pid); + if (cached && Date.now() - cached.at < HEALTH_PROBE_TTL_MS) return cached.value; + const t0 = Date.now(); + let value; + try { + const { providerRegistry } = await import('./providers/registry.mjs'); + const adapter = providerRegistry[pid]; + if (!adapter || typeof adapter.search !== 'function') throw new Error('adapter_absent'); + const items = await adapter.search('test', { limit: 1, page: 1, sort: 'relevance' }); + value = { + ok: true, latencyMs: Date.now() - t0, itemCount: Array.isArray(items) ? items.length : 0, + lastSuccessAt: new Date().toISOString(), lastError: null, + }; + } catch (e) { + value = { ok: false, latencyMs: Date.now() - t0, itemCount: 0, lastSuccessAt: null, lastError: String(e?.message || e).slice(0, 300) }; + } + healthProbeCache.set(pid, { at: Date.now(), value }); + return value; +} + +r.get('/providers/health', async (req, res) => { + try { + const only = String(req.query.provider || '').trim(); + const ids = only ? (PROVIDER_IDS.includes(only) ? [only] : []) : PROVIDER_IDS; + if (!ids.length) return res.status(400).json({ error: 'unknown_provider' }); + const snapshot = (await import('./db.mjs')).providerMetricsSnapshot({ hours: 1 }); + const entries = await Promise.all(ids.map(async (pid) => { + // Phase 8.3 : on ne sonde PAS un provider désactivé par feature flag. + // Sonder un upstream volontairement éteint renverrait `ok: false`, alors + // qu'il n'est pas en panne : `disabled` distingue « éteint » de « cassé ». + const flag = providerFlag(pid); + if (!flag.enabled) { + return [pid, { + ok: false, disabled: true, flag: flag.flag, flagValue: flag.raw, + latencyMs: 0, itemCount: 0, lastSuccessAt: null, lastError: 'disabled_by_ff', + consecutiveFailures: 0, errorRate: 0, + }]; + } + const probe = await probeProvider(pid); + const row = snapshot.find((x) => x.provider === pid); + return [pid, { + ...probe, + disabled: false, + flag: flag.flag, flagValue: flag.raw, + consecutiveFailures: row?.errors || 0, errorRate: row?.errorRate || 0, + }]; + })); + res.json({ providers: Object.fromEntries(entries) }); + } catch (e) { + res.status(500).json({ error: String(e?.message || e) }); + } +}); + +r.get('/providers/metrics', async (_req, res) => { + try { + const dbm = await import('./db.mjs'); + res.json({ + cache: dbm.searchCacheStats?.() || [], + providers: dbm.providerMetricsSnapshot?.({ hours: 1 }) || [], + youtube: { today: dbm.getYoutubeMetricsToday?.() || null }, + }); + } catch (e) { + res.status(500).json({ error: String(e?.message || e) }); + } +}); + app.use('/api', r); // Health endpoint for container checks app.get('/api/health', (_req, res) => res.json({ status: 'ok' })); // Step 17 : observabilité YouTube (mode, yt-dlp, cache, quota). Aucun secret exposé. app.get(['/healthz', '/api/healthz'], async (_req, res) => { try { - const [{ getYoutubeMetricsToday, countYoutubeCacheRows }, common] = + const [{ getYoutubeMetricsToday, countYoutubeCacheRows, searchCacheStats, providerMetricsSnapshot, countSearchCacheRows }, common] = await Promise.all([import('./db.mjs'), import('./providers/youtube-common.mjs')]); let ytdlpVersion = null; let resolvedBin = null; @@ -2895,6 +3012,9 @@ app.get(['/healthz', '/api/healthz'], async (_req, res) => { status: 'ok', youtube: { mode: getSearchMode(), + // Phase 4.4 : le mode reel peut differer de l'intention (bascule auto). + effectiveMode: common.getEffectiveSearchMode?.() || getSearchMode(), + failover: common.getFailoverState?.() || null, ytdlp: { bin: resolvedBin || getYtDlpBin(), version: ytdlpVersion, info: ytDlpInfo, binOk: Boolean(ytdlpVersion) }, antiban: { cookiesFile: hasCookiesFile(), @@ -2905,6 +3025,12 @@ app.get(['/healthz', '/api/healthz'], async (_req, res) => { metrics: { ...metricsSnapshot(), today: getYoutubeMetricsToday() }, keys: { count: keys.length, banned }, }, + // Phase 4.3 : résumé multi-fournisseurs. Volontairement compact — le + // détail est sur `/api/providers/metrics`. + providers: { + cache: { rows: countSearchCacheRows?.() || 0, byProvider: searchCacheStats?.() || [] }, + lastHour: providerMetricsSnapshot?.({ hours: 1 }) || [], + }, }); } catch (e) { res.status(500).json({ status: 'error', error: String(e?.message || e) }); @@ -3117,6 +3243,14 @@ app.all('/proxy/twitch-api/*', (req, res) => forwardJson(req, res, 'https://api. app.all('/proxy/twitch-auth/*', (req, res) => forwardJson(req, res, 'https://id.twitch.tv')); // -------------------- Unified search endpoint (GET) -------------------- +// Phase 7.3 : le payload `?debug=1` (provenance + `raw` tronque et redige) est +// construit par `search-transport.mjs` : la troncature et la redaction sont une +// barriere de securite, elles meritent un module et des tests dedies. +// Phase 7.6 : deux TRANSPORTS pour un SEUL fan-out. `Accept: application/x-ndjson` +// (ou `?stream=1`) fait écrire une ligne JSON dès qu'un provider répond ; sinon +// la réponse atomique historique est renvoyée à l'identique. Les deux modes +// partagent le même code d'agrégation : impossible qu'ils divergent sur le +// filtrage ou la forme des erreurs. app.get('/api/search', async (req, res) => { try { console.log('[SEARCH] Requête reçue - Query:', req.query); @@ -3140,46 +3274,102 @@ app.get('/api/search', async (req, res) => { sort = filters.sort; // Validate and normalize providers list (default to all supported when none/invalid) const requested = typeof providers === 'string' ? String(providers) : ''; - const validProviders = validateProviders(requested); + // Phase 8.3 : on retire les providers désactivés par feature flag AVANT le + // fan-out (inutile d'appeler un upstream qu'on a choisi d'éteindre), et on + // le signale dans `errors` pour que la colonne vide soit explicable. + const { providerIds: validProviders, errors: flagErrors } = applyProviderFlags(validateProviders(requested)); + for (const [pid, err] of Object.entries(flagErrors)) { + console.warn(`[search] provider ${pid} désactivé (${err.code})`); + } - // Execute search for each provider in parallel - const results = await Promise.allSettled( - validProviders.map((providerId) => { - const mod = providerRegistry[/** @type {any} */(providerId)]; - if (!mod || typeof mod.search !== 'function') return Promise.resolve([]); - // Basic options include pagination, sort hints and the search filters - return Promise.resolve().then(() => mod.search(q, { limit: pageSize, page: pageNum, sort, filters })); - }) - ); + // Phase 7.6 : transport incrémental. `Accept: text/event-stream` n'est pas + // utilisé volontairement — NDJSON se parse ligne à ligne sans parseur SSE, et + // reste rejouable / testable avec un simple client HTTP. + const wantsStream = String(req.headers.accept || '').includes(APPLICATION_NDJSON) + || req.query.stream === '1'; + let streamStarted = false; + if (wantsStream) { + // `X-Accel-Buffering: no` : derrière nginx (configuré par défaut), un + // buffer de réponse différerait TOUT le NDJSON jusqu'à la fin — on + // obtiendrait un streaming qui streame en un seul coup, c'est-à-dire rien. + res.setHeader('Content-Type', `${APPLICATION_NDJSON}; charset=utf-8`); + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('X-Accel-Buffering', 'no'); + res.flushHeaders?.(); + streamStarted = true; + } + /** Écrit une ligne du flux, si le client a demandé le streaming. */ + const emit = (line) => { + if (!streamStarted) return; + // `res.writableEnded` : le client a pu annuler (nouvelle frappe) pendant + // qu'un provider répondait ; écrire dans le vide lèverait une erreur. + if (res.writableEnded || res.destroyed) return; + res.write(`${JSON.stringify(line)}\n`); + }; // Group results by provider id (+ per-provider errors for diagnosable UI) const groups = /** @type {Record<string, any[]>} */ ({}); - const errors = /** @type {Record<string, { message: string, status?: number, code?: string }>} */ ({}); - results.forEach((result, index) => { - const providerId = validProviders[index]; - if (result.status === 'fulfilled') { + // Les providers désactivés par FF ont bien un groupe vide : le front + // affiche une pastille « aucun résultat » cohérente avec le reste. + for (const pid of Object.keys(flagErrors)) groups[pid] = []; + const errors = /** @type {Record<string, { message: string, status?: number, code?: string }>} */ ({ ...flagErrors }); + + // Exécution en parallèle, avec émission IMMÉDIATE de chaque groupe dès + // qu'il est résolu. L'agrégation est écrite UNE fois : le mode atomique et + // le mode NDJSON consomment exactement les mêmes objets. + await Promise.all(validProviders.map(async (providerId) => { + try { + const mod = providerRegistry[/** @type {any} */(providerId)]; + const raw = (!mod || typeof mod.search !== 'function') + ? [] + : await mod.search(q, { limit: pageSize, page: pageNum, sort, filters }); // Filtres non supportés nativement par le provider -> affinage ici. - groups[providerId] = applySearchFilters(providerId, Array.isArray(result.value) ? result.value : [], filters); - } else { - console.warn(`Search failed for provider ${providerId}:`, result.reason?.message || result.reason); + const items = applySearchFilters(providerId, Array.isArray(raw) ? raw : [], filters); + groups[providerId] = items; + emit({ type: 'provider', provider: providerId, ok: true, items }); + } catch (r) { + console.warn(`Search failed for provider ${providerId}:`, r?.message || r); groups[providerId] = []; try { - const r = /** @type {any} */ (result.reason); + const rr = /** @type {any} */ (r); errors[providerId] = { - message: String(r?.message || r || 'search_failed'), - ...(typeof r?.ytStatus === 'number' ? { status: r.ytStatus } : {}), - ...(r?.code ? { code: String(r.code) } : {}), + message: String(rr?.message || rr || 'search_failed'), + ...(typeof rr?.ytStatus === 'number' ? { status: rr.ytStatus } : {}), + ...(rr?.code ? { code: String(rr.code) } : {}), }; } catch {} + emit({ type: 'provider', provider: providerId, ok: false, error: errors[providerId] || { message: 'search_failed' } }); } - }); + })); + const debug = req.query.debug === '1' || req.query.debug === 'true'; + if (streamStarted) { + // Ligne de clôture : tout le contrat (version, pagination, filtres), puis + // `end`. Le front peut donc valider l'arrivée complète sans deviner quand + // le flux se termine. + emit({ type: 'done', v: SUGGESTION_CONTRACT_VERSION, q, providers: validProviders, page: pageNum, pageSize, sort, filters: activeSearchFilters(filters) }); + if (!res.writableEnded) res.end(); + return undefined; + } return res.json({ + // Phase 0.4 - le contrat Suggestion est versionne : le front peut + // detecter un serveur ancien et degrader proprement au lieu de lire des + // champs `undefined` sans le savoir. 2 = champs optionnels (views, + // publishedAt, avatars, channelId, …). 1 = contrat d'origine. + v: SUGGESTION_CONTRACT_VERSION, q, providers: validProviders, groups, errors, page: pageNum, pageSize, sort, filters: activeSearchFilters(filters), + // Phase 7.3 - mode debug : on expose le `raw` provider tronque. Volontairement + // HORS du contrat normal : un payload brut de 6 providers ferait exploser la + // reponse et peut contenir des cles d'API internes, donc uniquement si + // demande explicitement. + ...(debug ? { debug: buildDebugPayload(groups, errors) } : {}), }); } catch (e) { + // Une coupure du client ne doit pas logger une trace d'erreur serveur : en + // flux incrémental, abandonner la requête est le comportement NORMAL. + if (streamStarted && (res.writableEnded || res.destroyed)) return undefined; return res.status(500).json({ error: 'search_failed', details: String(e?.message || e) }); } }); @@ -3230,7 +3420,10 @@ r.get('/search/suggest', suggestLimiter, async (req, res) => { } const limit = Math.min(20, Math.max(1, Number(req.query.limit || 10))); const requested = typeof req.query.providers === 'string' ? String(req.query.providers) : ''; - const validProviders = validateProviders(requested); + // Phase 8.3 : flags appliqués AVANT la clé de cache, sinon un résultat + // calculé pendant que le provider était éteint serait resservi après + // réactivation (et l'inverse). Inclus dans la clé de toute façon. + const { providerIds: validProviders, errors: flagErrors } = applyProviderFlags(validateProviders(requested)); const cacheKey = `suggest:${validProviders.join(',')}:${q.toLowerCase()}:${limit}`; const cached = suggestCacheGet(cacheKey); if (cached) return res.json(cached); @@ -3250,6 +3443,9 @@ r.get('/search/suggest', suggestLimiter, async (req, res) => { ); const [webRes, lightRes] = await Promise.allSettled([webPromise, lighthousePromise]); const groups = {}; + // Cohérence avec /api/search : un provider éteint a un groupe vide ET une + // raison, sinon le front ne peut pas distinguer « éteint » de « muet ». + for (const pid of Object.keys(flagErrors)) groups[pid] = []; results.forEach((result, index) => { const providerId = validProviders[index]; if (result.status === 'fulfilled' && Array.isArray(result.value)) { @@ -3269,6 +3465,7 @@ r.get('/search/suggest', suggestLimiter, async (req, res) => { } // La même occurrence n'est renvoyée qu'une fois (providers d'abord, web en dernier). const data = { q, groups: dedupeSuggestGroups(groups, [...validProviders, 'web']) }; + if (Object.keys(flagErrors).length > 0) data.errors = flagErrors; suggestCacheSet(cacheKey, data); return res.json(data); } catch (e) { @@ -3760,6 +3957,7 @@ app.listen(PORT, () => { console.log(`[newtube-api] distRoot=${distRoot} exists=${hasDistRoot}`); console.log(`[newtube-api] distBrowser=${distBrowser} exists=${hasDistBrowser}`); console.log(`[newtube-api] staticDir=${staticDir} indexExists=${hasIndex}`); + startSearchCacheJanitor(); }); // --- Playlists --- diff --git a/server/providers/channel-content.mjs b/server/providers/channel-content.mjs index c5a3129..ff1c8d8 100644 --- a/server/providers/channel-content.mjs +++ b/server/providers/channel-content.mjs @@ -120,24 +120,74 @@ function ytSuggestion(item, details, channelId) { } // L'API YouTube pagine avec des pageTokens opaques, pas des numéros de page. -// Cache process-wide des tokens : clé requête -> tokens[page] (tokens[1] = token -// pour charger la page 2). Sans token connu pour page > 1, on s'arrête (nextPage null) -// au lieu de re-servir la page 1 en boucle. +// Clé requête -> tokens[page] (tokens[1] = token pour charger la page 2). Sans +// token connu pour page > 1, on s'arrête (nextPage null) au lieu de re-servir +// la page 1 en boucle. +// +// Phase 3.12 — cache à DEUX NIVEAUX. Le L1 (Map process-local) reste pour la +// vitesse ; le L2 (`search_cache` en base) survit au redémarrage et au scale +// horizontal. Avant, le plafond de 500 clés faisait `clear()` et la pagination +// repartait de la page 1 SANS SIGNE : le L2 comble ce trou, une éviction du L1 +// ne perd plus le jeton. const ytTokenCache = new Map(); -function ytTokenStore(key, page, nextToken) { +const YT_TOKEN_L1_MAX = 500; + +async function ytTokenL2Get(key) { + try { + const { getCachedPageTokens } = await import('../db.mjs'); + return getCachedPageTokens?.(key) || null; + } catch { return null; } +} + +async function ytTokenL2Set(key, arr) { + try { + const { setCachedPageTokens } = await import('../db.mjs'); + setCachedPageTokens?.(key, arr); + } catch {} +} + +/** Écrit un jeton et propage la chaîne complète au L2 (les pages s'appuient les unes sur les autres). */ +async function ytTokenStore(key, page, nextToken) { if (!nextToken) return; let arr = ytTokenCache.get(key); + let fromL2 = false; if (!arr) { - if (ytTokenCache.size > 500) ytTokenCache.clear(); - arr = []; - ytTokenCache.set(key, arr); + // Page N-1 peut avoir été chargée dans un autre process : on relit le L2 + // avant d'écrire, sinon on écrase la chaîne avec un tableau à trous. + arr = (await ytTokenL2Get(key)) || null; + fromL2 = Array.isArray(arr); + if (!arr) arr = []; } arr[page] = nextToken; + if (!fromL2) { + // Éviction LRU-lite au lieu d'un `clear()` global : on écarte la plus + // ancienne entrée, pas tout le cache. + if (ytTokenCache.size > YT_TOKEN_L1_MAX) { + const oldest = ytTokenCache.keys().next().value; + if (oldest !== undefined) ytTokenCache.delete(oldest); + } + } + ytTokenCache.set(key, arr); + // `map`, PAS `filter` : les trous de l'indexation doivent survivre au round-trip + // JSON. `filter(Boolean)` décalerait le jeton de la page 2 vers l'index 0 et la + // page 3 renverrait le jeton de la page 4 — une pagination silencieusement + // décalée. Les trous sont donc matérialisés par `''` (falsy : traité comme + // absent à la lecture). + await ytTokenL2Set(key, Array.from({ length: arr.length }, (_, i) => arr[i] || '')); } -function ytTokenFor(key, page) { + +async function ytTokenFor(key, page) { if (page <= 1) return ''; - const arr = ytTokenCache.get(key); - return arr ? arr[page - 1] : undefined; + const l1 = ytTokenCache.get(key); + if (l1 && l1[page - 1]) return l1[page - 1]; + const l2 = await ytTokenL2Get(key); + if (l2 && l2[page - 1]) { + // Réhydratation du L1 : les pages suivantes de cette même chaîne évitent + // alors l'accès L2. + ytTokenCache.set(key, l2); + return l2[page - 1]; + } + return undefined; } async function ytContent(externalId, { type, page, limit, sort, q }) { @@ -152,8 +202,8 @@ async function ytContent(externalId, { type, page, limit, sort, q }) { // externalId brut (handle ou UC...) : le scrape gère les deux formes const scraped = await fetchChannelViaScrape(externalId, { type, page: pageNum, limit: perPage }); if (Array.isArray(scraped?.items) && scraped.items.length) { - if (mode === 'scrape-only') return { ...scraped, total: null }; - // scrape-first : retour direct si non vide + // Phase 2.4 - branche morte supprimée : `scrape-only` et `scrape-first` + // renvoyaient tous deux `{ ...scraped, total: null }` à l'identique. return { ...scraped, total: null }; } // vide -> on tente l'API (chaîne à faible volume ou tab non supporté en scrape) @@ -169,7 +219,7 @@ async function ytContent(externalId, { type, page, limit, sort, q }) { const channelId = await resolveYouTubeChannelId(externalId); if (type === 'playlists') { const key = ['pl', channelId, perPage].join('|'); - const token = ytTokenFor(key, pageNum); + const token = await ytTokenFor(key, pageNum); if (token === undefined) return { items: [], nextPage: null }; const params = { part: 'snippet,contentDetails', channelId, maxResults: String(perPage) }; // Ne jamais envoyer pageToken=undefined (sérialisé en "undefined" -> 400). @@ -181,7 +231,7 @@ async function ytContent(externalId, { type, page, limit, sort, q }) { console.warn('[channel-content/yt] playlists failed:', e?.message || e); return { items: [], nextPage: null }; } - ytTokenStore(key, pageNum, data?.nextPageToken); + await ytTokenStore(key, pageNum, data?.nextPageToken); const items = (data?.items || []).map((pl) => ({ id: pl?.id, title: pl?.snippet?.title || '', @@ -193,7 +243,7 @@ async function ytContent(externalId, { type, page, limit, sort, q }) { } const order = sort === 'popular' ? 'viewCount' : sort === 'recent' ? 'date' : 'relevance'; const key = ['search', channelId, type, order, q || '', perPage].join('|'); - const token = ytTokenFor(key, pageNum); + const token = await ytTokenFor(key, pageNum); if (token === undefined) return { items: [], nextPage: null }; const params = { part: 'snippet', channelId, type: 'video', maxResults: String(perPage), order, @@ -204,7 +254,7 @@ async function ytContent(externalId, { type, page, limit, sort, q }) { if (type === 'shorts') params.videoDuration = 'short'; if (q) params.q = q; const data = await ytGet('search', params); - ytTokenStore(key, pageNum, data?.nextPageToken); + await ytTokenStore(key, pageNum, data?.nextPageToken); const ids = (data?.items || []).map((i) => i?.id?.videoId).filter(Boolean); const details = await ytVideoDetails(ids); let items = (data?.items || []) @@ -240,7 +290,11 @@ async function dmContent(externalId, { type, page, limit, sort, q }) { id: v.id, title: v.title, thumbnail: v.thumbnail_720_url || v.thumbnail_480_url, url: `https://www.dailymotion.com/video/${v.id}`, uploaderName: v['owner.screenname'], channelId: v['owner.id'], channelExternalId: v['owner.id'] || user, - duration: Number(v.duration || 0), views: Number(v.views_total || 0), + // Phase 2.2 - `|| 0` transformait "inconnu" en "0 s" : la carte affichait + // 0:00 et la règle « verticale sans durée connue => pas un short » ne + // pouvait plus distinguer les deux cas. On n'émet le champ que s'il est > 0. + ...(Number(v.duration) > 0 ? { duration: Number(v.duration) } : {}), + ...(Number(v.views_total) > 0 ? { views: Number(v.views_total) } : {}), publishedAt: v.created_time ? new Date(v.created_time * 1000).toISOString() : undefined, type: 'video', })); if (q) { const n = q.toLowerCase(); items = items.filter((i) => i.title.toLowerCase().includes(n)); } @@ -270,7 +324,7 @@ async function twUser(externalId, headers) { return data?.data?.[0] || null; } -async function twContent(externalId, { type, page, limit, sort, q }) { +async function twContent(externalId, { type, page, limit, sort, q, cursor }) { const clientId = process.env.TWITCH_CLIENT_ID; const token = await twToken(); if (!clientId || !token) return { items: [], nextPage: null }; @@ -285,22 +339,44 @@ async function twContent(externalId, { type, page, limit, sort, q }) { const items = (data?.data || []).map((s) => ({ id: s.id, title: s.title, thumbnail: String(s.thumbnail_url || '').replace('{width}', '1280').replace('{height}', '720'), url: `https://www.twitch.tv/${user.login}`, uploaderName: user.display_name, - channelId: user.id, channelExternalId: user.id, views: Number(s.viewer_count || 0), - publishedAt: s.started_at, type: 'video', kind: 'vod', + channelId: user.id, channelExternalId: user.id, + // Phase 3.2 - `viewer_count` est le nombre de spectateurs, PAS des vues. + // Il sert a afficher « X spectateurs en direct », pas « X vues ». + viewers: Number(s.viewer_count || 0) || undefined, + // Phase 3.2 - anomalies #4/#5 : l'item live etait marque `type:'video'` + // + `kind:'vod'`, donc `isLiveItem()` ne le classait jamais comme live et + // l'onglet Live affichait une VOD. `type` doit valoir 'live' et `isLive` + // etre pose — les deux, pour couvrir les deux règles de classification. + isLive: true, type: 'live', kind: 'live', + game: s.game_name || undefined, language: s.language || undefined, + publishedAt: s.started_at, duration: undefined, + uploaderAvatar: user.profile_image_url || undefined, + channelHandle: user.login, channelUrl: `https://www.twitch.tv/${user.login}`, })); return { items, nextPage: null }; } if (type === 'playlists' || type === 'shorts') return { items: [], nextPage: null }; const qs = new URLSearchParams({ user_id: user.id, first: String(perPage), type: 'archive', sort: sort === 'popular' ? 'views' : 'time' }); + // Phase 3.1 - le curseur Helix est enfin renvoyé au client au lieu d'etre jeté. + if (cursor) qs.set('after', cursor); const data = await readJson(await fetchWithTimeout(`https://api.twitch.tv/helix/videos?${qs}`, { headers })); let items = (data?.data || []).map((v) => ({ id: v.id, title: v.title, thumbnail: v.thumbnail_url, url: v.url, uploaderName: user.display_name, channelId: user.id, channelExternalId: user.id, - duration: undefined, views: Number(v.view_count || 0), publishedAt: v.created_at, type: 'video', kind: 'vod', + // Helix `/videos` ne fournit pas la durée : absent reste absent (phase 2.2). + duration: undefined, + ...(Number(v.view_count) > 0 ? { views: Number(v.view_count) } : {}), + publishedAt: v.created_at, type: 'video', kind: 'vod', + uploaderAvatar: user.profile_image_url || undefined, + channelHandle: user.login, channelUrl: `https://www.twitch.tv/${user.login}`, })); - if (q) { const n = q.toLowerCase(); items = items.filter((i) => i.title.toLowerCase().includes(n)); } - const cursor = data?.pagination?.cursor; - return { items, nextPage: cursor ? (page || 1) + 1 : null }; + if (q) { const n = q.toLowerCase(); items = items.filter((i) => String(i.title || '').toLowerCase().includes(n)); } + const nextCursor = data?.pagination?.cursor || ''; + return { + items, + nextPage: nextCursor ? (page || 1) + 1 : null, + ...(nextCursor ? { nextCursor } : {}), + }; } // ---- PeerTube (externalId = instance|channel) ---- @@ -329,7 +405,9 @@ async function ptContent(externalId, { type, page, limit, sort, q }) { id: String(v.uuid || v.id), title: v.name, thumbnail: v?.thumbnailPath ? `https://${instance}${v.thumbnailPath}` : undefined, url: v?.url, uploaderName: v?.channel?.displayName || channel, channelId: externalId, channelExternalId: externalId, - duration: Number(v.duration || 0), views: Number(v.views || 0), publishedAt: v.publishedAt, type: 'video', + // Phase 2.2 - idem : jamais de 0 pour une durée/vues inconnues. + ...(Number(v.duration) > 0 ? { duration: Number(v.duration) } : {}), + ...(Number(v.views) > 0 ? { views: Number(v.views) } : {}), publishedAt: v.publishedAt, type: 'video', })); return { items, nextPage: items.length >= perPage ? (page || 1) + 1 : null, total: data?.total ?? null }; } @@ -343,15 +421,21 @@ async function odContent(externalId, { type, page, limit, sort, q }) { jsonrpc: '2.0', id: 1, method: 'claim_search', params: { channel: claim, page: Math.max(1, Number(page || 1)), page_size: perPage, claim_type: 'stream', order_by: sort === 'popular' ? ['effective_amount'] : ['release_time'] }, }; - const resp = await fetchWithTimeout('https://api.na-backend.odysee.com/api/v1/proxy?m=claim_search', { + // Phase 1.3 - on repasse par readJson() : le .catch(() => ({})) avalait les + // 4xx/5xx et transformait une erreur reseau en "chaine vide" silencieuse. + const data = await readJson(await fetchWithTimeout('https://api.na-backend.odysee.com/api/v1/proxy?m=claim_search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), - }); - const data = await resp.json().catch(() => ({})); + })).catch((err) => { throw Object.assign(new Error(`odysee_channel_search_failed: ${err?.message || err}`), { status: 502 }); }); let items = ((data?.result?.items) || []).map((c) => ({ id: c.claim_id, title: c?.value?.title, thumbnail: c?.value?.thumbnail?.url, url: c.short_url || c.canonical_url, uploaderName: c?.signing_channel?.value?.title || claim, channelId: externalId, channelExternalId: externalId, - duration: Number(c?.value?.video?.duration || 0), publishedAt: c?.value?.release_time ? new Date(Number(c.value.release_time) * 1000).toISOString() : undefined, + ...(Number(c?.value?.video?.duration) > 0 ? { duration: Math.round(Number(c.value.video.duration)) } : {}), publishedAt: c?.value?.release_time ? new Date(Number(c.value.release_time) * 1000).toISOString() : undefined, + // Phase 1.1 - claim_search expose `value.video.view_count` quand la colonne est + // demandee. L'`effective_amount` reste un montant LBC : jamais un compteur. + ...(Number.isFinite(Number(c?.value?.video?.view_count)) && Number(c?.value?.video?.view_count) >= 0 + ? { views: Math.round(Number(c.value.video.view_count)) } + : {}), type: 'video', slug: (c.short_url || '').replace('https://odysee.com/', ''), })); if (q) { const n = q.toLowerCase(); items = items.filter((i) => String(i.title || '').toLowerCase().includes(n)); } @@ -360,30 +444,44 @@ async function odContent(externalId, { type, page, limit, sort, q }) { } // ---- Rumble : pas d'API publique -> recherche unifiée filtrée par chaîne ---- -async function ruContent(externalId, { type, page, limit, q }, searchRegistry) { +async function ruContent(externalId, { type, page, limit, q }, ctx) { if (type !== 'videos') return { items: [], nextPage: null }; - try { - const mod = searchRegistry?.ru; - if (!mod || typeof mod.search !== 'function') return { items: [], nextPage: null }; - const needle = String(externalId || '').replace(/^@/, '').toLowerCase(); - const results = await mod.search(q || needle || 'videos', { limit: 50, page: 1 }); - const items = (results || []).filter((r) => { - const hay = `${r?.uploaderName || ''} ${r?.channelId || ''} ${r?.url || ''}`.toLowerCase(); - return !needle || hay.includes(needle); - }).slice(0, Number(limit || 24)); - return { items, nextPage: null }; - } catch { return { items: [], nextPage: null }; } + const searchRegistry = ctx?.searchRegistry || ctx; + const mod = searchRegistry?.ru; + if (!mod || typeof mod.search !== 'function') return { items: [], nextPage: null }; + const needle = String(externalId || '').replace(/^@/, '').toLowerCase(); + // Phase 3.5 - filtre sur `channelId` en priorité : c'est l'identifiant + // canonique extrait en phase 1.5, donc plus fiable qu'une comparaison de + // sous-chaîne sur le nom ou l'URL. + const results = await mod.search(q || needle || 'videos', { limit: 50, page: 1 }); + const items = (results || []).filter((r) => { + if (r?.channelId && needle && String(r.channelId).toLowerCase() === needle) return true; + const hay = `${r?.uploaderName || ''} ${r?.channelId || ''} ${r?.url || ''}`.toLowerCase(); + return !needle || hay.includes(needle); + }).slice(0, Number(limit || 24)); + return { items, nextPage: null }; } +/** + * Phase 8.1 — chaque provider est une extension, pas une entrée de switch : + * `channelContentByProvider` indexe les collecteurs par id. `fetchChannelContent` + * reste une façade (appelée par la route) mais n'EST PLUS la source : elle + * délègue à la table, la même que celle que l'adaptateur unifié (`providerAdapters`) + * expose comme `channelContent`. Un provider de plus = une entrée de table, pas + * un case de plus. + */ +export const channelContentByProvider = { + yt: ytContent, + dm: dmContent, + tw: twContent, + pt: ptContent, + od: odContent, + ru: ruContent, +}; + export async function fetchChannelContent(provider, externalId, opts = {}, ctx = {}) { - const { type = 'videos', page = 1, limit = 24, sort = 'recent', q = '' } = opts || {}; - switch (provider) { - case 'yt': return ytContent(externalId, { type, page, limit, sort, q }); - case 'dm': return dmContent(externalId, { type, page, limit, sort, q }); - case 'tw': return twContent(externalId, { type, page, limit, sort, q }); - case 'pt': return ptContent(externalId, { type, page, limit, sort, q }); - case 'od': return odContent(externalId, { type, page, limit, sort, q }); - case 'ru': return ruContent(externalId, { type, page, limit, q }, ctx.searchRegistry); - default: throw Object.assign(new Error('invalid_provider'), { status: 400 }); - } + const { type = 'videos', page = 1, limit = 24, sort = 'recent', q = '', cursor = '' } = opts || {}; + const worker = channelContentByProvider[/** @type {keyof typeof channelContentByProvider} */ (provider)]; + if (!worker) throw Object.assign(new Error('invalid_provider'), { status: 400 }); + return worker(externalId, { type, page, limit, sort, q, cursor }, ctx); } diff --git a/server/providers/channel-ref.mjs b/server/providers/channel-ref.mjs new file mode 100644 index 0000000..18b2256 --- /dev/null +++ b/server/providers/channel-ref.mjs @@ -0,0 +1,179 @@ +/** + * Phase 6 — identité de chaîne normalisée (`channelRef`). + * + * AVANT, trois conventions d'identifiant de chaîne cohabitaient, chacune + * implicite, codée à 4 endroits différents : + * + * 1. YouTube : `UC…` (dans `channelExternalId`) + * 2. Dailymotion: identifiant numérique d'utilisateur (`owner.id`) + * 3. Twitch : `login` + * 4. PeerTube : `instance|channel` (construit par le FRONT depuis l'URL vidéo) + * 5. Odysee : claim LBRY brut, avec ou sans `@` selon la source + * 6. Rumble : slug `/c/<slug>` + * + * Aucun de ces formats ne disait « ceci est un identifiant de chaîne » : la même + * chaîne pouvait être stockée sous deux formes (`yt` et `@MaChaine#a` pour Odysee, + * `channel` seul et `instance|channel` pour PeerTube) et rien ne les réconciliait. + * + * `channelRef` rend l'imbrication explicite : le `scheme` DIT comment lire le + * `value`. Le but n'est pas de supprimer les champs legacy (transition non + * cassante, cf. phase 6.2) mais d'avoir une forme unique, sans perte et + * réversible. + * + * SOURCE UNIQUE côté serveur. Le miroir TypeScript est + * `src/app/shared/providers/channel-ref.ts`, et `npm run test:channelref` + * vérifie que les deux listes de schemes ne divergent pas. + */ + +/** Schemes par provider (id court). L'ordre suit `ALL_PROVIDER_IDS`. */ +export const CHANNEL_REF_SCHEMES = Object.freeze({ + yt: 'yt-uc', + dm: 'dm-user', + tw: 'tw-login', + pt: 'pt-composite', + od: 'od-claim', + ru: 'ru-slug', +}); + +/** + * Normalise un claim LBRY (Odysee) : un seul `@` en tête. + * + * Odysee est le seul provider où la même chaîne arrivait avec ET sans le `@` + * selon le chemin (`channel` dans la réponse `claim_search`, `short_url` dans + * la résolution, `externalId` en base) — d'où les deux normalisations + * divergentes (`startsWith('@') ? … : '@'+…` au POST de resolve, + * `replace(/^@/,'')` pour l'URL). La forme canonique porte le `@`. + * + * @param {unknown} value + * @returns {string|undefined} claim canonique, ou undefined si vide + */ +export function normalizeOdyseeClaim(value) { + const raw = String(value ?? '').trim(); + if (!raw) return undefined; + return raw.startsWith('@') ? raw : `@${raw}`; +} + +/** Un claim LBRY pour une URL publique : `https://odysee.com/@x` -> `x`. */ +export function odyseeClaimToSlug(value) { + const claim = normalizeOdyseeClaim(value); + return claim ? claim.slice(1) : undefined; +} + +/** Un claim LBRY pour l'appel `resolve`, qui exige le `@`. */ +export function odyseeClaimToResolveArg(value) { + return normalizeOdyseeClaim(value); +} + +/** + * Découpe un identifiant PeerTube composite `instance|channel`. + * Rétrocompatible avec l'ancien cas « channel seul » (instance inconnue). + * @param {unknown} value + */ +export function parsePeerTubeComposite(value) { + const raw = String(value ?? '').trim(); + if (!raw) return { instance: undefined, channel: undefined }; + const sep = raw.indexOf('|'); + if (sep < 0) return { instance: undefined, channel: raw }; + const instance = raw.slice(0, sep) || undefined; + const channel = raw.slice(sep + 1) || undefined; + return { instance, channel }; +} + +/** + * Construit `instance|channel` à partir de l'URL vidéo et du channelId. + * + * L'instance N'EST PAS optionnelle : sans elle, `parsePeerTubeComposite` + * renverrait `{instance: undefined}` et `fetchPeerTubeChannel` construirait + * `https://<channel>` — une URL fausse. On renvoie donc `undefined` plutôt + * qu'un composite bancal. C'est aussi ce que faisait le front (`pt.ts` exigeait + * `host && channelId`), et le critère d'acceptation 8.2 impose la parité. + */ +export function buildPeerTubeComposite(url, channelId) { + const channel = String(channelId ?? '').trim(); + if (!channel) return undefined; + let host = ''; + try { + if (url) host = new URL(String(url)).hostname || ''; + } catch {} + return host ? `${host}|${channel}` : undefined; +} + +const firstString = (...values) => { + for (const v of values) { + const s = String(v ?? '').trim(); + if (s) return s; + } + return undefined; +}; + +/** + * Construit le `channelRef` d'une Suggestion, à partir des champs **legacy** + * déjà présents. N'invente jamais de donnée : si aucun identifiant n'est + * disponible, renvoie `undefined` (jamais un objet à moitié rempli, jamais de + * valeur inventée). + * + * Chaque règle reproduit à l'identique ce que le front calcule aujourd'hui dans + * ses 6 adaptateurs — c'est le critère d'acceptation 8.2. + * + * @param {string} providerId id court ('yt', 'dm', …) + * @param {object} item Suggestion + * @returns {{provider: string, scheme: string, value: string}|undefined} + */ +export function buildChannelRef(providerId, item) { + const pid = String(providerId ?? '').trim().toLowerCase(); + const scheme = CHANNEL_REF_SCHEMES[pid]; + if (!scheme || !item || typeof item !== 'object') return undefined; + + let value; + switch (pid) { + case 'yt': + // `yt.ts:32` : channelExternalId || channelId + value = firstString(item.channelExternalId, item.channelId); + break; + case 'dm': + // `dm.ts:30` : channelId (l'API ne donne que `owner.id`) + value = firstString(item.channelId, item.channelExternalId); + break; + case 'tw': + // `tw.ts:24` : channelExternalId || channelHandle (le login) + value = firstString(item.channelExternalId, item.channelHandle); + break; + case 'pt': + // `pt.ts:25-27` : hostname(url) + '|' + channelId + value = buildPeerTubeComposite(item.url, item.channelId); + break; + case 'od': + // `od.ts:40` : le claim brut, ici canonique (un seul `@`). + value = normalizeOdyseeClaim(firstString(item.channel, item.uploaderName, item.channelHandle)); + break; + case 'ru': + // Phase 3.5 : slug extrait de `/c/<slug>` par l'adaptateur rumble. + value = firstString(item.channelExternalId, item.channelId); + break; + default: + value = undefined; + } + + if (!value) return undefined; + return { provider: pid, scheme, value }; +} + +/** + * Annote une liste de Suggestions avec leur `channelRef`. + * Mutates les objets d'origine : c'est le même contrat que `type`/`isShort`, + * ajoutés par les adaptateurs. Les objets sans identité restent inchangés + * (pas de clé `channelRef` à `undefined`). + * + * @template T + * @param {string} providerId + * @param {T[]} items + * @returns {T[]} + */ +export function withChannelRefs(providerId, items) { + if (!Array.isArray(items)) return items; + for (const item of items) { + const ref = buildChannelRef(providerId, item); + if (ref) item.channelRef = ref; + } + return items; +} diff --git a/server/providers/channel-registry.mjs b/server/providers/channel-registry.mjs index 1686800..d355281 100644 --- a/server/providers/channel-registry.mjs +++ b/server/providers/channel-registry.mjs @@ -1,3 +1,9 @@ +import { + parsePeerTubeComposite, + odyseeClaimToResolveArg, + odyseeClaimToSlug, +} from './channel-ref.mjs'; + const DEFAULT_TIMEOUT_MS = Number(process.env.CHANNEL_FETCH_TIMEOUT_MS || 6000); // Fournisseur de token Twitch branché par server/index.mjs (qui gère le cache @@ -32,6 +38,29 @@ async function fetchWithTimeout(url, options = {}) { } } +/** N'accepte qu'une URL http(s) absolue — bloque `javascript:` et `data:`. */ +function sanitizeImageUrl(raw) { + if (typeof raw !== 'string') return undefined; + const url = raw.trim(); + if (!/^https?:\/\//i.test(url)) return undefined; + return url; +} + +/** + * Phase 7.7 — descriptions : texte brut, souvent multi-lignes et souvent + * énorme (YouTube renvoie plusieurs kilo-octets). On normalise les espaces et on + * plafonne : une description de 10 Ko dans une page chaîne saccade le rendu et + * écrase le contenu. Tronquée = information partielle mais honnête ; l'absence + * de plafond = page cassée. + */ +const DESCRIPTION_MAX = 600; +function cleanDescription(raw) { + if (typeof raw !== 'string') return undefined; + const text = raw.replace(/\s+/g, ' ').trim(); + if (!text) return undefined; + return text.length > DESCRIPTION_MAX ? `${text.slice(0, DESCRIPTION_MAX - 1).trimEnd()}…` : text; +} + function safeMeta(meta = {}, fallback = {}) { return { provider: fallback.provider, @@ -39,6 +68,10 @@ function safeMeta(meta = {}, fallback = {}) { title: meta.title ?? fallback.title, handle: meta.handle ?? fallback.handle, avatarUrl: meta.avatarUrl ?? fallback.avatarUrl, + // Phase 7.7 : bannière. URL seulement — un `javascript:` glissé dans une + // bannière deviendrait un vecteur XSS au moment de l'afficher. + bannerUrl: sanitizeImageUrl(meta.bannerUrl) ?? sanitizeImageUrl(fallback.bannerUrl), + description: cleanDescription(meta.description) ?? cleanDescription(fallback.description), url: meta.url ?? fallback.url, subsCount: typeof meta.subsCount === 'number' ? meta.subsCount : fallback.subsCount, verified: typeof meta.verified === 'boolean' @@ -69,16 +102,19 @@ async function fetchYoutubeChannel(externalId) { const url = branding.channel?.customUrl ? `https://www.youtube.com/${branding.channel.customUrl}` : `https://www.youtube.com/channel/${externalId}`; - return { - provider: 'yt', - externalId, + return safeMeta({ title: snippet.title || branding.channel?.title, handle: snippet.customUrl ? `@${snippet.customUrl.replace(/^@/, '')}` : undefined, avatarUrl: avatars, + // Phase 7.7 — `brandingSettings` est DÉJÀ demandé (pour l'URL), il contient + // aussi la bannière : aucun appel réseau supplémentaire. + bannerUrl: branding.image?.bannerImageUrl + || (Array.isArray(branding.image?.thumbnails) && branding.image.thumbnails.at(-1)?.url), + description: snippet.description, url, subsCount: stats.subscriberCount ? Number(stats.subscriberCount) : undefined, verified: Array.isArray(snippet.badges) ? snippet.badges.includes('verified') : undefined, - }; + }, { provider: 'yt', externalId, url }); } catch { return { provider: 'yt', externalId, url: `https://www.youtube.com/channel/${externalId}` }; } @@ -89,7 +125,7 @@ async function fetchDailymotionChannel(externalId) { // NOTE : `avatar_url` n'est pas un champ valide de l'API user (400 sur // toute la requête) — seuls avatar_720_url / avatar_medium_url le sont. const params = new URLSearchParams({ - fields: 'id,username,screenname,avatar_720_url,avatar_medium_url,url,followers_total,verified', + fields: 'id,username,screenname,avatar_720_url,avatar_medium_url,cover_url,description,url,followers_total,verified', }); const data = await fetchWithTimeout(`https://api.dailymotion.com/user/${externalId}?${params.toString()}`); const username = data.username || externalId; @@ -97,6 +133,9 @@ async function fetchDailymotionChannel(externalId) { title: data.screenname || data.username, handle: data.username ? `@${data.username}` : undefined, avatarUrl: data.avatar_720_url || data.avatar_medium_url, + // Phase 7.7 — `cover_url` est la bannière (l'API n'a pas de champ « banner »). + bannerUrl: data.cover_url, + description: data.description, url: `https://www.dailymotion.com/user/${username}`, subsCount: typeof data.followers_total === 'number' ? data.followers_total : undefined, verified: Boolean(data.verified), @@ -129,6 +168,11 @@ async function fetchTwitchChannel(externalId) { title: user.display_name, handle: `@${user.login}`, avatarUrl: user.profile_image_url, + // Phase 7.7 — Helix les expose dans le même `/users` : rien à demander + // de plus. `banner_image_url` est absent tant que l'utilisateur n'a pas + // de bannière : on ne fabrique pas d'URL de substitution. + bannerUrl: user.banner_image_url, + description: user.description, url: `https://www.twitch.tv/${user.login}`, subsCount: typeof user.view_count === 'number' ? user.view_count : undefined, }, { provider: 'tw', externalId, url: `https://www.twitch.tv/${externalId}` }); @@ -138,11 +182,11 @@ async function fetchTwitchChannel(externalId) { return { provider: 'tw', externalId, url: `https://www.twitch.tv/${externalId}` }; } -function parsePeerTubeExternalId(externalId) { - const [instance, channel] = String(externalId).split('|'); - if (!channel) return { instance: null, channel: externalId }; - return { instance, channel }; -} +/** + * Phase 6.4 — délègue à la source unique (`channel-ref.mjs`) au lieu de + * redécouper `instance|channel` localement. + */ +const parsePeerTubeExternalId = parsePeerTubeComposite; async function fetchPeerTubeChannel(externalId) { const { instance, channel } = parsePeerTubeExternalId(externalId); @@ -155,6 +199,11 @@ async function fetchPeerTubeChannel(externalId) { title: data.displayName || data.name, handle: data.host ? `@${data.name}@${data.host}` : undefined, avatarUrl: data?.avatar?.path ? `https://${instance}${data.avatar.path}` : undefined, + // Phase 7.7 — l'API vidéo-channels expose déjà `banners` et `description`. + bannerUrl: (Array.isArray(data?.banners) && data.banners.at(-1)?.path) + ? `https://${instance}${data.banners.at(-1).path}` + : (data?.banner?.path ? `https://${instance}${data.banner.path}` : undefined), + description: data.description, url: data?.url || `https://${instance}/video-channels/${channel}`, subsCount: typeof data.followersCount === 'number' ? data.followersCount : undefined, verified: Boolean(data.ownerAccount?.verified) @@ -164,12 +213,32 @@ async function fetchPeerTubeChannel(externalId) { } } +/** + * Phase 7.7 — plus grande vignette LBRY disponible. + * Le CDN Odysee sert la même image à plusieurs tailles via un paramètre `?size=` ; + * on élargit donc la vignette de la chaîne, ce qui reste une VRAIE bannière. + * À défaut de paramètre de taille, on rend l'URL telle quelle. + */ +function odyseeBannerUrl(value) { + const base = value?.thumbnail?.url || value?.thumbnail; + if (typeof base !== 'string' || !/^https?:\/\//i.test(base)) return undefined; + if (/[?&]size=/i.test(base)) return base; + return `${base}${base.includes('?') ? '&' : '?'}size=1200x600`; +} + async function fetchOdyseeChannel(externalId) { try { + // Phase 6.4 : la normalisation du claim passe par la source unique + // (`channel-ref.mjs`). Avant, deux règles cohabitaient dans CE fichier + // (`startsWith('@') ? … : '@'+…` ici, `replace(/^@/,'')` pour l'URL 20 lignes + // plus bas) et une troisième vivait côté front — la forme canonique pouvait + // donc être `@x` ici et `x` là-bas. + const claim = odyseeClaimToResolveArg(externalId); + if (!claim) return { provider: 'od', externalId, url: 'https://odysee.com/' }; const body = { jsonrpc: '2.0', method: 'resolve', - params: { urls: [externalId.startsWith('@') ? externalId : `@${externalId}`] }, + params: { urls: [claim] }, id: 1 }; const resp = await fetchWithTimeout('https://api.na-backend.odysee.com/api/v1/proxy?m=resolve', { @@ -188,12 +257,19 @@ async function fetchOdyseeChannel(externalId) { title: value?.title, handle: meta.short_url ? meta.short_url.replace('https://odysee.com/', '') : undefined, avatarUrl: meta?.thumbnail?.url, + // Phase 7.7 — LBRY n'a pas de « bannière » distincte : la vignette du + // claim EST l'image de la chaîne. On demande la plus grande taille + // servie par le CDN plutôt que d'inventer une URL qui n'existerait pas. + bannerUrl: odyseeBannerUrl(value), + description: value?.description || value?.tagged_description || meta?.description, url: meta.short_url, subsCount: typeof meta?.meta?.effective_amount === 'number' ? meta.meta.effective_amount : undefined, }, { provider: 'od', externalId, url: meta.short_url }); } } catch {} - return { provider: 'od', externalId, url: `https://odysee.com/${externalId.replace(/^@/, '')}` }; + // L'URL publique ne porte pas le `@` — d'où la conversion explicite plutôt + // qu'un `replace` local. + return { provider: 'od', externalId, url: `https://odysee.com/${odyseeClaimToSlug(externalId) || ''}` }; } async function fetchRumbleChannel(externalId) { @@ -203,10 +279,19 @@ async function fetchRumbleChannel(externalId) { }); const titleMatch = /<title>([^<]+)<\/title>/i.exec(data); const avatarMatch = /property="og:image" content="([^"]+)"/i.exec(data); + // Phase 7.7 — `og:description` est déjà dans la page : aucun appel en plus. + // `content=` en PREMIER attribut, comme pour og:image : sur Rumble l'ordre + // n'est pas garanti et une regex trop rigide ne renvoie jamais rien. + const descMatch = /<meta[^>]+property="og:description"[^>]+content="([^"]*)"/i.exec(data) + || /<meta[^>]+content="([^"]*)"[^>]+property="og:description"/i.exec(data); const name = titleMatch ? titleMatch[1].replace(/ on Rumble.*$/i, '').trim() : undefined; return safeMeta({ title: name, avatarUrl: avatarMatch ? avatarMatch[1] : undefined, + // Pas de bannière dédiée côté Rumble : `og:image` est déjà la meilleure + // image disponible, mais le doublonner avec l'avatar n'apporte rien — + // on laisse donc `bannerUrl` vide et l'UI masquera la bandeau. + description: descMatch ? descMatch[1] : undefined, url: `https://rumble.com/${externalId}`, }, { provider: 'ru', externalId, url: `https://rumble.com/${externalId}` }); } catch { @@ -214,17 +299,32 @@ async function fetchRumbleChannel(externalId) { } } -export const channelRegistry = { - yt: { fetchChannelById: fetchYoutubeChannel }, - dm: { fetchChannelById: fetchDailymotionChannel }, - tw: { fetchChannelById: fetchTwitchChannel }, - pt: { fetchChannelById: fetchPeerTubeChannel }, - od: { fetchChannelById: fetchOdyseeChannel }, - ru: { fetchChannelById: fetchRumbleChannel }, +/** + * Phase 8.1 — la table par provider devient la SOURCE : `channelMetaByProvider` + * indexe les collecteurs de métadonnées par id. `channelRegistry` et + * `getChannelAdapter` restent exportés pour compatibilité (tests, historique), + * mais ne font qu'aliasser cette table — l'adaptateur unifié (`providerAdapters`) + * consomme `channelMetaByProvider` et n'a pas besoin de savoir qu'une autre + * table a existé. + */ +export const channelMetaByProvider = { + yt: fetchYoutubeChannel, + dm: fetchDailymotionChannel, + tw: fetchTwitchChannel, + pt: fetchPeerTubeChannel, + od: fetchOdyseeChannel, + ru: fetchRumbleChannel, }; +// Compatibilité phase 0/… : `channelRegistry.yt.fetchChannelById(...)` a été +// utilisé par des tests et l'historique. On le DÉRIVE de la table source plutôt +// que de le dupliquer. +export const channelRegistry = Object.fromEntries( + Object.entries(channelMetaByProvider).map(([provider, fetchMeta]) => [provider, { fetchChannelById: fetchMeta }]), +); + export function getChannelAdapter(provider) { - return channelRegistry[provider]; + return channelMetaByProvider[provider]; } export default channelRegistry; diff --git a/server/providers/feature-flags.mjs b/server/providers/feature-flags.mjs new file mode 100644 index 0000000..85341f3 --- /dev/null +++ b/server/providers/feature-flags.mjs @@ -0,0 +1,97 @@ +/** + * Phase 8.3 — Feature flags par provider (`FF_<PROVIDER>`). + * + * Motivation : certain upstreams sont fragiles hors de notre contrôle. Rumble + * est derrière Cloudflare et peut casser du jour au lendemain ; Odysee dépend + * d'un backend NA. Désactiver un provider ne doit pas exiger un redéploiement + * ni un revert de code. + * + * Trois états, pas deux — c'est la distinction qui compte en exploitation : + * - unset : le provider est actif (comportement historique) ; + * - truthy : actif ; + * - falsy : DÉSACTIVÉ, exclu du fan-out et signalé `errors.<id> = disabled_by_ff`. + * + * Un flag falsy **signale** au lieu de disparaître en silence : une colonne vide + * sans explication ressemble à « aucune correspondance », ce qui envoie l'utilisateur + * (et le support) chercher au mauvais endroit. + * + * Convention de nommage : `FF_YT`, `FF_DM`, `FF_TW`, `FF_PT`, `FF_OD`, `FF_RU` + * (ids courts, cf. `provider-ids.ts`). + */ + +/** Valeurs explicitement fausses. Tout le reste est vrai (unset inclus). */ +const FALSY = new Set(['0', 'false', 'off', 'no', 'disabled']); + +/** + * Lit un flag depuis l'environnement, sans cache : le lire à chaque appel permet + * de changer une variable sans redémarrer (utile en local, et ça évite un cache + * qui masquerait un changement en CI). + * + * @param {string} key + * @returns {{ set: boolean, enabled: boolean, raw: string|null }} + */ +function readFlag(key) { + const raw = process.env[key]; + if (raw === undefined || raw === null || String(raw).trim() === '') { + // Unset = actif, et on le dit explicitement pour ne pas confondre + // « pas configuré » et « désactivé » dans les logs. + return { set: false, enabled: true, raw: null }; + } + return { set: true, enabled: !FALSY.has(String(raw).trim().toLowerCase()), raw: String(raw) }; +} + +/** + * État d'un provider au regard des feature flags. + * @param {string} providerId id court ('yt', 'ru', …) + */ +export function providerFlag(providerId) { + const id = String(providerId || '').trim().toLowerCase(); + const key = `FF_${id.toUpperCase()}`; + const { set, enabled, raw } = readFlag(key); + return { provider: id, flag: key, set, enabled, raw }; +} + +/** `true` si le provider est désactivé par un feature flag. */ +export function isProviderDisabled(providerId) { + return !providerFlag(providerId).enabled; +} + +/** + * Filtre une liste de providers, en retirant les désactivés. + * + * @template {string} T + * @param {T[]} providerIds + * @returns {{ enabled: T[], disabled: Array<{provider: string, flag: string}> }} + */ +export function partitionEnabledProviders(providerIds) { + const list = Array.isArray(providerIds) ? providerIds : []; + const enabled = []; + const disabled = []; + for (const id of list) { + const flag = providerFlag(id); + if (flag.enabled) enabled.push(id); + else disabled.push({ provider: flag.provider, flag: flag.flag }); + } + return { enabled, disabled }; +} + +/** + * Applique les flags à une liste déjà validée, et renvoie les `errors` à fusionner + * dans la réponse de fan-out. Utilisé par `/api/search` ET `/api/suggest` pour + * qu'un provider désactivé se comporte pareil partout. + * + * @template {string} T + * @param {T[]} providerIds + * @returns {{ providerIds: T[], errors: Record<string, {message: string, code: string}> }} + */ +export function applyProviderFlags(providerIds) { + const { enabled, disabled } = partitionEnabledProviders(providerIds); + const errors = {}; + for (const d of disabled) { + errors[d.provider] = { + message: `Provider désactivé par le feature flag ${d.flag}`, + code: 'disabled_by_ff', + }; + } + return { providerIds: enabled, errors }; +} diff --git a/server/providers/odysee.mjs b/server/providers/odysee.mjs index 620c911..299fda1 100644 --- a/server/providers/odysee.mjs +++ b/server/providers/odysee.mjs @@ -18,7 +18,7 @@ const handler = { s: q, size: perPage.toString(), from: ((pageNum - 1) * perPage).toString(), - include: 'channel,thumbnail_url,title,description,duration,release_time,claimId,name', + include: 'channel,thumbnail_url,title,description,duration,release_time,claimId,name,video', mediaType: 'video' }); @@ -44,6 +44,14 @@ const handler = { // lighthouse les expose — lecture défensive, aucun appel en plus. const vw = Number(item.video?.width ?? item.video?.video_width ?? item.width); const vh = Number(item.video?.height ?? item.video?.video_height ?? item.height); + // Phase 1.2 - `release_time` est un timestamp unix (secondes) et etait + // deja demande dans `include` mais jamais mappe : `publishedAt` etait donc + // TOUJOURS undefined, ce qui rendait le filtre `period=` inoperant sur Odysee. + const release = Number(item.release_time); + // Phase 1.1 - `video.view_count` remonte par lighthouse quand `video` + // est inclus. L'`effective_amount` est un montant LBC (bid), PAS un + // nombre de vues : on ne l'utilise jamais comme compteur. + const views = Number(item.video?.view_count ?? item.video?.views); return { title: item.title || name, @@ -55,6 +63,10 @@ const handler = { duration: typeof item.duration === 'number' && item.duration > 0 ? Math.round(item.duration) : (typeof item.video?.duration === 'number' && item.video.duration > 0 ? Math.round(item.video.duration) : undefined), + ...(Number.isFinite(release) && release > 0 + ? { publishedAt: new Date(release * 1000).toISOString() } + : {}), + ...(Number.isFinite(views) && views >= 0 ? { views: Math.round(views) } : {}), ...(Number.isFinite(vw) && vw > 0 ? { width: vw } : {}), ...(Number.isFinite(vh) && vh > 0 ? { height: vh } : {}), }; diff --git a/server/providers/peertube.mjs b/server/providers/peertube.mjs index 4be4250..6f3dbcb 100644 --- a/server/providers/peertube.mjs +++ b/server/providers/peertube.mjs @@ -58,8 +58,35 @@ const handler = { url: item.url, thumbnail, uploaderName: (item.account && (item.account.displayName || item.account.name)) || undefined, + // Phase 1.8 - les avatars de compte ET de chaine sont exposes par + // l'API sepiasearch mais n'etaient jamais mappes : la page chaine + // affichait un avatar generique sur toutes les chaines PeerTube. + uploaderAvatar: item.account?.avatars?.[0]?.path || item.account?.avatarUrl || undefined, + channelAvatarUrl: item.channel?.avatars?.[0]?.path || item.channel?.avatarUrl || undefined, channelId: (item.channel && (item.channel.name || item.channel.uuid)) || (item.account && item.account.name) || undefined, + // Phase 1.7 - champs deja presents dans la reponse, jamais mappes. + // `views` est le nom canonique du contrat (les 5 autres adaptateurs + // l'emettaient) ; `viewCount` reste en alias pour ne pas casser un + // consommateur existant. Trouve en gelant la sortie reelle (phase 8.4) : + // le front lisait `views`, le serveur n'envoyait que `viewCount`, et les + // compteurs PeerTube n'atteignaient donc jamais l'UI. + views: Number.isFinite(Number(item.views)) ? Number(item.views) : undefined, + viewCount: Number.isFinite(Number(item.views)) ? Number(item.views) : undefined, + likes: Number.isFinite(Number(item.likes)) ? Number(item.likes) : undefined, + publishedAt: item.publishedAt || item.createdAt || undefined, + // L'API PeerTube renvoie `language` comme OBJET `{ id, label }` (et non + // `{ code }` comme le laissait croire l'ancienne garde) : sans ce + // tri, l'objet entier partait dans le contrat, où `language` est + // déclaré `string`. Le front recevait `{id: null, label: 'Unknown'}` + // au lieu d'un code ISO. Vu en gelant la sortie réelle (phase 8.4). + // `label` est volontairement exclu : c'est une étiquette lisible + // ("Unknown") et non un code — la publier produirait une langue + // inventée, ce que la règle « absent = undefined » interdit. + language: typeof item.language === 'string' ? item.language + : (item.language?.code || item.language?.id || undefined), + hasSubtitles: Array.isArray(item.subtitleFiles) ? item.subtitleFiles.length > 0 : undefined, type: 'video', + kind: 'vod', duration: typeof item.duration === 'number' && item.duration > 0 ? Math.round(item.duration) : undefined, ...(fw > 0 ? { width: fw } : {}), ...(fh > 0 ? { height: fh } : {}), diff --git a/server/providers/registry.mjs b/server/providers/registry.mjs index efe9437..cbf68e9 100644 --- a/server/providers/registry.mjs +++ b/server/providers/registry.mjs @@ -5,7 +5,7 @@ * @typedef {Object} Suggestion * @property {string} title * @property {string} id - * @property {number=} duration + * @property {number=} duration (secondes ; > 0. NE JAMAIS 0 : absence = undefined) * @property {boolean=} isShort * @property {string=} url * @property {string=} thumbnail @@ -13,21 +13,103 @@ * @property {string=} type * @property {number=} width (largeur vidéo en px, quand le provider l'expose) * @property {number=} height (hauteur vidéo en px, quand le provider l'expose) + * + * ── v2 (additif, phase 0 §2.4) ──────────────────────────────────────────────── + * Tous les champs ci-dessous sont OPTIONNELS et absents quand le provider ne les + * fournit pas. `test:contract` (`server/tests/provider-contract.test.mjs`) les + * vérifie à chaque build : c'est ce qui empêche le retour des champs «erts puis + * perdus » (anomalies #3, #7). + * Règle non négociable : une métadonnée absente reste `undefined`. Jamais de 0, + * jamais de chaîne vide, jamais de date/devinette. + * + * @property {number=} views compteur de vues (> 0) + * @property {number=} likes compteur de likes + * @property {number=} dislikes + * @property {string=} publishedAt ISO 8601 — jamais un libellé relatif + * @property {string=} channelId id de chaîne chez le provider + * @property {string=} channelExternalId + * @property {string=} channelHandle + * @property {string=} channelUrl + * @property {string=} uploaderAvatar avatar de l'auteur + * @property {string=} channelAvatarUrl + * @property {string=} slug + * @property {string=} channel + * @property {string=} kind 'vod' | 'live' | 'clip' (Twitch) + * @property {string=} game catégorie / jeu + * @property {string=} language BCP-47 + * @property {boolean=} hasSubtitles + * @property {boolean=} isLive + * @property {boolean=} embeddable + * @property {string=} description + * @property {string[]} tags + * @property {string=} durationRaw libellé brut, pour l'affichage d'incertitude + * @property {string=} viewCountRaw + * @property {{provider: ProviderId, scheme: string, value: string}=} channelRef + * Phase 6 — identité de chaîne normalisée. Redondant avec + * `channelExternalId` (transition non cassante) mais EXPLICITE : le + * `scheme` dit comment lire `value` ('yt-uc' = `UC…`, 'pt-composite' = + * `instance|channel`, 'od-claim' = claim LBRY…). Voir `channel-ref.mjs`. */ +/** Version du contrat `Suggestion` émise par `/api/search`. */ +export const SUGGESTION_CONTRACT_VERSION = 2; + +/** Champs optionnels introduits en v2 (pour le test de contrat et le debug ?v=2). */ +export const SUGGESTION_V2_FIELDS = Object.freeze([ + 'views', 'viewers', 'likes', 'dislikes', 'publishedAt', 'channelId', 'channelExternalId', + 'channelHandle', 'channelUrl', 'uploaderAvatar', 'channelAvatarUrl', 'slug', + 'channel', 'kind', 'game', 'language', 'hasSubtitles', 'isLive', 'embeddable', + 'description', 'tags', 'durationRaw', 'viewCountRaw', 'channelRef', + // Phase 7.3 : provenance. `capturedAt` (epoch ms, instant réel de la collecte) + // et `source` (`innertube` / `scrape` / `api` / `cache`) permettent d'afficher + // « capturé il y a 4 min » au lieu d'un « à l'instant » trompeur sur un cache hit. + 'capturedAt', 'source', +]); + /** @typedef {'yt'|'dm'|'tw'|'pt'|'od'|'ru'} ProviderId */ -import channelRegistry from './channel-registry.mjs'; +import { channelMetaByProvider } from './channel-registry.mjs'; +import { channelContentByProvider } from './channel-content.mjs'; +import { withChannelRefs } from './channel-ref.mjs'; +import { hashSearchKey } from './youtube-common.mjs'; /** + * Phase 8.1 — CONTRAT UNIQUE `ProviderAdapter`. + * + * Chaque provider est une extension, pas un câblage : l'objet exporté satisfait + * intégralement cette forme (les membres optionnels absents sont simplement + * `undefined`, mais la CLÉ existe), et tout consommateur (routes, wrappers, + * tests de contrat) passe par `providerAdapters` — jamais par des tables + * parallèles (`channelRegistry`, `channelContentByProvider`) qui restent des + * implémentations internes. + * * @typedef {Object} ProviderAdapter * @property {ProviderId} id * @property {string} label - * @property {(q: string, opts: { limit: number, page?: number, sort?: string }) => Promise<Suggestion[]>} search + * @property {(q: string, opts: { limit: number, page?: number, sort?: string, filters?: Object }) => Promise<Suggestion[]>} search * @property {(q: string, opts?: { limit?: number }) => Promise<string[]>} [suggest] - * @property {(externalId: string, ctx?: any) => Promise<any=} } [fetchChannelById] + * @property {(externalId: string, opts?: Object, ctx?: Object) => Promise<{ items: Suggestion[], nextPage?: number|null, total?: number|null, nextCursor?: string }>} channelContent + * @property {(externalId: string, ctx?: Object) => Promise<Object>} channelMeta + * @property {Object} capabilities */ +/** + * Phase 8.1 — capacités déclarées par provider (source serveur du contrat). + * Vérité comportementale : ce que le collecteur fait réellement (types de + * contenu gérés, suggestion native, direct). Vérifié par le test de contrat. + */ +export const PROVIDER_CAPABILITIES = { + yt: { suggest: true, live: false, channelMeta: true, channelContent: ['videos', 'shorts', 'playlists', 'live'] }, + dm: { suggest: true, live: false, channelMeta: true, channelContent: ['videos', 'playlists'] }, + tw: { suggest: false, live: true, channelMeta: true, channelContent: ['videos', 'live'] }, + pt: { suggest: false, live: false, channelMeta: true, channelContent: ['videos', 'playlists'] }, + od: { suggest: false, live: false, channelMeta: true, channelContent: ['videos'] }, + ru: { suggest: false, live: false, channelMeta: true, channelContent: ['videos'] }, +}; + +/** Ordre canonique des providers (un unique endroit, hors route). */ +export const PROVIDER_IDS = ['yt', 'dm', 'tw', 'pt', 'od', 'ru']; + /** @type {Record<ProviderId, ProviderAdapter>} */ export const providerRegistry = { /** @type {any} */ yt: (await import('./youtube.mjs')).default, @@ -39,10 +121,197 @@ export const providerRegistry = { }; for (const [pid, adapter] of Object.entries(providerRegistry)) { - const channelAdapter = channelRegistry[/** @type {ProviderId} */(pid)]; - if (channelAdapter && typeof channelAdapter.fetchChannelById === 'function' && !adapter.fetchChannelById) { - adapter.fetchChannelById = channelAdapter.fetchChannelById; + const meta = channelMetaByProvider[/** @type {ProviderId} */(pid)]; + if (meta && !adapter.fetchChannelById) adapter.fetchChannelById = meta; +} + +/** + * Phase 4.1 — cache de recherche générique, appliqué ici plutôt que dans chaque + * adaptateur : un seul point d'entrée, donc aucun fournisseur ne peut l'oublier + * (c'est exactement le problème des tables dupliquées que la phase 0 a supprimé). + * + * YouTube est **exclu** : `youtube.mjs` a son propre cache à deux niveaux + * (memoire + SQLite) et l'annoit en `source` different selon la voie utilisee + * (`innertube` / `scrape` / `api`). Un second cache ici le neutraliserait et + * masquerait les fallbacks dans les metriques. + * + * Le cache ne doit jamais faire echouer une recherche : toute erreur de base est + * absorbee et la requete amont est relancee. + */ +const CACHE_WRAPPED_PROVIDERS = new Set(['dm', 'tw', 'pt', 'od', 'ru']); + +/** + * Phase 7.3 — provenance (`capturedAt` / `source`) de chaque suggestion. + * + * Posée ici, au registre, et pas dans les 6 adaptateurs : c'est le même + * raisonnement que le cache et que `channelRef` (un seul point d'entrée, donc + * aucun fournisseur ne peut l'oublier) — et surtout c'est le seul endroit où + * l'on sait si la réponse vient du réseau ou d'une entrée de cache. + * + * `source` distingue les voies de collecte réelles : `innertube` / `scrape` / + * `api` côté YouTube (déjà posés par `youtube.mjs`), `cache` pour un hit. + * + * Exportée pour être testée directement : ses règles d'honnêteté (un `0` n'est + * pas une date, une source vide n'est pas une source) sont le cœur de la + * tâche, et il est impossible de les atteindre par le HTTP sans écrire un + * adaptateur factice qui court-circuiterait justement le wrapper. + * + * @param {any[]} items + * @param {string} source voie de collecte à appliquer si l'item n'en porte pas + * @param {number} [capturedAt] instant de capture (défaut : maintenant) + * @returns {any[]} les mêmes items, stampés sur place + */ +export function stampProvenance(items, source, capturedAt) { + if (!Array.isArray(items)) return items; + const at = Number.isFinite(Number(capturedAt)) && Number(capturedAt) > 0 + ? Number(capturedAt) + : Date.now(); + for (const item of items) { + if (!item || typeof item !== 'object') continue; + // Un adaptateur qui connaît SA propre voie de collecte (YouTube) reste + // prioritaire : on ne réécrit pas `innertube` en `api`. + // + // `||` seul ne suffit PAS : `-1`, `NaN` ou `" "` sont « présents » au sens + // de `||` pour les uns et rejettent pour les autres, et finissent affichés + // comme une date absurde (« il y a -1 s »). On teste donc la valeur. + const own = Number(item.capturedAt); + item.capturedAt = Number.isFinite(own) && own > 0 ? own : at; + const ownSource = String(item.source || '').trim(); + item.source = ownSource || source; } + return items; +} + +/** Voie de collecte annoncée par l'adaptateur, sinon `api` (appel officiel). */ +const DEFAULT_LIVE_SOURCE = 'api'; + +async function withSearchCache(adapter, providerId, q, opts) { + const { getCachedSearch, setCachedSearch } = await import('../db.mjs').catch(() => ({})); + if (!getCachedSearch || !setCachedSearch) return adapter.search(q, opts); + + const limit = Math.min(Math.max(1, Number(opts?.limit || 10)), 100); + const page = Math.max(1, Number(opts?.page || 1)); + const sort = String(opts?.sort || 'relevance'); + // La signature des filtres entre dans la cle : deux recherches identiques avec + // des filtres differents ne doivent pas se partager de cache (meme regle que YT). + const filterSig = opts?.filters ? JSON.stringify(opts.filters, Object.keys(opts.filters).sort()) : ''; + const key = `${providerId}|${hashSearchKey(`${String(q).toLowerCase().trim()}|${limit}|${page}|${sort}|${filterSig}`)}`; + + const cached = getCachedSearch(providerId, key); + if (cached?.items) { + // Phase 7.3 : la provenance voyage DANS le payload mis en cache, donc un + // hit restitue spontanément la VRAIE voie de collecte (`api`, `innertube`…) + // et l'instant réel de capture — pas `Date.now()`, qui ferait croire à une + // fraîcheur de façade sur une réponse vieille de 4 min. `stampProvenance` + // ne remplit donc que ce qui manque : une ligne écrite par un serveur plus + // ancien (avant la phase 7.3) récupère ici son `createdAt` en base. + stampProvenance(cached.items, cached.source || DEFAULT_LIVE_SOURCE, cached.createdAt); + return cached.items; + } + + // Phase 4.3 : on ne compte que les appels **amont** reels, pas les hits cache + // (sinon le taux d'echec apparent disparaitrait sous l'effet du cache). + const t0 = Date.now(); + const { incProviderMetrics } = await import('../db.mjs').catch(() => ({})); + try { + const items = await adapter.search(q, opts); + incProviderMetrics?.(providerId, { ok: true, latencyMs: Date.now() - t0 }); + // Phase 7.3 : la provenance est posée AVANT la mise en cache, pour qu'un hit + // ultérieur la transporte sans avoir à la deviner. + stampProvenance(items, DEFAULT_LIVE_SOURCE); + // Jamais de resultat vide persiste (regle phase 2.2 / 4.1) : une page vide + // transitoire ne doit pas bloquer les requetes suivantes pendant 5 min. + if (Array.isArray(items) && items.length > 0) setCachedSearch(providerId, key, q, items, 'api'); + return items; + } catch (e) { + incProviderMetrics?.(providerId, { ok: false, latencyMs: Date.now() - t0, error: e?.message || e }); + throw e; + } +} + +for (const pid of CACHE_WRAPPED_PROVIDERS) { + const adapter = providerRegistry[/** @type {ProviderId} */(pid)]; + if (!adapter || typeof adapter.search !== 'function') continue; + const raw = adapter.search.bind(adapter); + adapter.search = (q, opts) => withSearchCache({ search: raw }, pid, q, opts); +} + +/** + * Phase 6.2 — `channelRef` sur les 6 providers. + * + * Volontairement appliqué ICI, au niveau du registre, et pas dans les 6 + * adaptateurs : c'est le même raisonnement que le cache (un seul point d'entrée, + * donc aucun fournisseur ne peut l'oublier). Il est posé **au-dessus** du cache + * pour que la réponse soit identique que les items viennent du réseau ou d'une + * entrée de cache écrite par une version antérieure (qui n'avait pas le champ). + * + * Les champs legacy (`channelExternalId`…) sont laissés intacts : c'est une + * transition non cassante, pas un remplacement. + */ +for (const pid of /** @type {ProviderId[]} */(['yt', 'dm', 'tw', 'pt', 'od', 'ru'])) { + const adapter = providerRegistry[pid]; + if (!adapter || typeof adapter.search !== 'function') continue; + const raw = adapter.search.bind(adapter); + adapter.search = async (q, opts) => withChannelRefs(pid, await raw(q, opts)); +} + +/** + * Phase 7.3 — `capturedAt` / `source` sur les 6 providers, y compris YouTube + * qui est EXCLU du cache (il a son propre cache à deux niveaux) : sans cette + * passe, YouTube n'aurait jamais de provenance — et c'est précisément le + * provider dont la voie de collecte (`innertube` vs `scrape` vs `api`) est la + * plus utile à afficher. + * + * Posée AU-DESSUS de `withChannelRefs` : la provenance décrit la donnée + * collectée, pas sa forme normalisée. + */ +for (const pid of /** @type {ProviderId[]} */(['yt', 'dm', 'tw', 'pt', 'od', 'ru'])) { + const adapter = providerRegistry[pid]; + if (!adapter || typeof adapter.search !== 'function') continue; + const raw = adapter.search.bind(adapter); + adapter.search = async (q, opts) => { + const items = await raw(q, opts); + // Remplissage seulement : les items déjà stampés par le cache ou par + // l'adaptateur (YouTube connaît sa voie) gardent leur valeur d'origine. + if (Array.isArray(items) && items.length > 0) stampProvenance(items, DEFAULT_LIVE_SOURCE); + return items; + }; +} + +/** + * Phase 8.1 — l'adaptateur UNIFIÉ. `providerAdapters[pid]` EST le point de + * contact unique (recherche déjà enveloppée cache → channelRef → provenance) + * enrichi des deux volets chaîne (contenu / métadonnées) et de ses capacités. + * Positionné APRÈS les wrappers : les consommateurs (routes, tests de contrat) + * traitent tous par cet accès, jamais par les tables internes. + * + * `channelContent` délègue au collecteur de phase : la signature est + * `(externalId, opts, ctx)`, `ctx.searchRegistry` étant utilisé par `ru`. + */ +export const providerAdapters = /** @type {Record<ProviderId, ProviderAdapter>} */ (Object.fromEntries( + PROVIDER_IDS.map((pid) => { + const mod = providerRegistry[pid]; + const content = channelContentByProvider[pid]; + const meta = channelMetaByProvider[pid]; + return [pid, { + id: pid, + label: mod?.label || pid, + search: mod?.search, + suggest: typeof mod?.suggest === 'function' ? mod.suggest.bind(mod) : undefined, + channelContent: content + ? (externalId, opts, ctx) => content(externalId, opts || {}, ctx || {}) + : undefined, + channelMeta: meta + ? (externalId, ctx) => meta(externalId, ctx || {}) + : undefined, + capabilities: PROVIDER_CAPABILITIES[pid], + }]; + }), +)); + +/** Point d'accès unique d'un adaptateur par id (compat : un seul chemin). */ +export function getProviderAdapter(provider) { + return providerAdapters[/** @type {ProviderId} */(provider)] || providerRegistry[/** @type {ProviderId} */(provider)]; } /** diff --git a/server/providers/rumble.mjs b/server/providers/rumble.mjs index 4597419..85b5116 100644 --- a/server/providers/rumble.mjs +++ b/server/providers/rumble.mjs @@ -132,6 +132,39 @@ function isChallenge(html) { return /Just a moment|challenge-platform|cf-chl/i.test(String(html || '').slice(0, 4000)); } +/** + * Phase 3.3 — cache négatif court. + * Sans lui, chaque recherche Rumble déclenchait 2 fetch Node + 2 fetch Python + * (12-20 s) pour finir sur un challenge Cloudflare, à chaque frappe utilisateur. + * TTL volontairement court (5 min) : un challenge se lève vite, et on ne veut + * pas figer une panne de plus de quelques minutes. + * @type {Map<string, number>} + */ +const NEGATIVE_CACHE_TTL_MS = Number(process.env.RUMBLE_NEGATIVE_CACHE_TTL_MS || 5 * 60 * 1000); +const negativeCache = new Map(); + +function negativeCacheKey(q, page) { return `search:vide:${page}:${String(q || '').trim().toLowerCase()}`; } + +function isNegativelyCached(key) { + const ts = negativeCache.get(key); + if (!ts) return false; + if ((Date.now() - ts) >= NEGATIVE_CACHE_TTL_MS) { negativeCache.delete(key); return false; } + return true; +} + +function markNegative(key) { + // Garde-fou mémoire : le cache ne doit pas grossir sans borne. + if (negativeCache.size >= 200) { + for (const [k, ts] of negativeCache) { + if ((Date.now() - ts) >= NEGATIVE_CACHE_TTL_MS) negativeCache.delete(k); + if (negativeCache.size < 150) break; + } + } + negativeCache.set(key, Date.now()); +} + +export function resetRumbleNegativeCache() { negativeCache.clear(); } + /* --------------------------------- parsing -------------------------------- */ function parseDurationToSeconds(raw) { @@ -180,8 +213,101 @@ function normalizeThumb(raw) { return t.startsWith('//') ? `https:${t}` : t; } +/** + * Compteur de vues localized : "1,2K views", "1.2M views", "3 456", "12,345". + * L'ancien code faisait `.replace(/[^\d]/g, '')` : le suffixe disparaissait et + * "1,2K" devenait 12 (au lieu de 1 200). Les vues étaient donc affichées + * fausses sur la quasi-totalité des cartes Rumble au-delà de 999. + */ +export function parseRumbleViews(raw) { + const s = String(raw ?? '').trim(); + if (!s) return undefined; + const m = s.match(/([\d][\d\s\u00a0.,]*)\s*([KMBkmb])?/); + if (!m) return undefined; + // 1 234 -> "1234" ; 1,2 / 1.2 -> "1.2" (décimal) ; 1,234 -> "1234" (milliers) + let digits = m[1].replace(/[\s\u00a0]/g, ''); + if (/^\d{1,3},\d{3}$/.test(digits) || /^\d{1,3}\.\d{3}$/.test(digits)) digits = digits.replace(/[.,]/g, ''); + else digits = digits.replace(',', '.'); + const num = Number(digits); + if (!Number.isFinite(num) || num < 0) return undefined; + const suffix = (m[2] || '').toLowerCase(); + const mult = suffix === 'b' ? 1e9 : suffix === 'm' ? 1e6 : suffix === 'k' ? 1e3 : 1; + const total = Math.round(num * mult); + return total > 0 ? total : undefined; +} + +/** Clé de rapprochement stable : dernier segment d'URL, sans extension ni tracking. */ +function urlKey(u) { + const s = String(u || '').split('?')[0].replace(/\.html$/i, '').replace(/\/$/, ''); + return (s.split('/').filter(Boolean).pop() || '').toLowerCase(); +} + +/** + * Phase 3.1 — extraction du JSON-LD (`application/ld+json`). + * Plus stable que le DOM : quand le balisage change, le JSON-LD reste. On + * accepte `VideoObject`, `ItemList` et `@graph`, et on n'indexe QUE ce qui est + * explicitement présent (aucune valeur déduite). + * @returns {Map<string, { publishedAt?: string, views?: number, thumbnail?: string, duration?: number, channelId?: string }>} + */ +export function parseJsonLd(html) { + /** @type {Map<string, any>} */ + const out = new Map(); + const re = /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi; + let m; + while ((m = re.exec(String(html || ''))) !== null) { + let data; + try { data = JSON.parse(m[1].trim()); } catch { continue; } + const nodes = Array.isArray(data) ? data + : Array.isArray(data?.['@graph']) ? data['@graph'] + : data ? [data] : []; + for (const node of nodes) visit(node, out, 0); + } + return out; +} + +function visit(node, out, depth) { + if (!node || typeof node !== 'object' || depth > 4) return; + const list = Array.isArray(node['@graph']) + ? node['@graph'] + : (Array.isArray(node.itemListElement) ? node.itemListElement.map((e) => e?.item ?? e) : null); + if (list) { for (const c of list) visit(c, out, depth + 1); } + + const type = String(node['@type'] || '').toLowerCase(); + if (!type.includes('video')) return; + const key = urlKey(node.url || node.embedUrl || node.contentUrl || node['@id']); + if (!key) return; + + const stats = node.interactionStatistic; + const statList = Array.isArray(stats) ? stats : (stats ? [stats] : []); + let views; + for (const st of statList) { + const t = String(st?.interactionType || '').toLowerCase(); + if (!t.includes('watch') && !t.includes('view')) continue; + const n = Number(st?.userInteractionCount); + if (Number.isFinite(n) && n >= 0) { views = Math.round(n); break; } + } + + const dateRaw = node.uploadDate || node.datePublished; + const parsed = dateRaw ? Date.parse(dateRaw) : NaN; + const publishedAt = Number.isFinite(parsed) && parsed > 0 ? new Date(parsed).toISOString() : undefined; + const duration = parseDurationToSeconds(node.duration); + const thumbRaw = Array.isArray(node.thumbnailUrl) + ? node.thumbnailUrl[0] + : (typeof node.thumbnailUrl === 'string' ? node.thumbnailUrl : node.thumbnailUrl?.url); + const authorUrl = typeof node.author === 'string' ? node.author : node.author?.url; + const channelId = (String(authorUrl || '').match(/\/c\/([^/?#]+)/i)?.[1] || '').trim() || undefined; + + out.set(key, { + ...(publishedAt ? { publishedAt } : {}), + ...(views !== undefined && views > 0 ? { views } : {}), + ...(duration !== undefined ? { duration } : {}), + ...(thumbRaw ? { thumbnail: normalizeThumb(thumbRaw) } : {}), + ...(channelId ? { channelId } : {}), + }); +} + /** Classic parser: li.video-listing-entry cards. */ -function parseSearchHtml(html, { limit = 50 } = {}) { +export function parseSearchHtml(html, { limit = 50 } = {}) { const $ = load(html); const items = []; $('li.video-listing-entry').each((_idx, el) => { @@ -220,13 +346,35 @@ function parseSearchHtml(html, { limit = 50 } = {}) { if (typeof parsed === 'number' && parsed > 0) { durationSeconds = parsed; break; } } const viewsText = $el.find('.video-item--views').first().text().trim(); - const views = Number(String(viewsText).replace(/[^\d]/g, '')) || undefined; + const views = parseRumbleViews(viewsText); + // Phase 1.4 - date de publication. L'attribut `datetime` du <time> est la + // vraie date ISO ; son texte est un libelle relatif ("2 months ago") que + // Date.parse() ne sait pas lire, d'ou l'absence historique de publishedAt. + const timeEl = $el.find('.video-item--meta time, time.video-item--date, time').first(); + const publishedRaw = (timeEl.attr('datetime') || timeEl.attr('data-datetime') || '').trim(); + const publishedMs = publishedRaw ? Date.parse(publishedRaw) : NaN; + const publishedAt = Number.isFinite(publishedMs) && publishedMs > 0 + ? new Date(publishedMs).toISOString() + : undefined; + // Phase 1.5 - identifiant de chaine. L'URL porte soit /c/<username>/, + // soit (en fallback) le sous-domaine ; l'avatar est sur l'image du by-line. + const channelLink = $el.find('a.video-item--channel-link, a[href*="/c/"]').first(); + const channelHref = (channelLink.attr('href') || '').trim(); + const channelId = (channelHref.match(/\/c\/([^/?#]+)/i)?.[1] || '').trim() || undefined; + const avatarEl = $el.find('img.video-item--channel-thumb, .video-item--by-line img, a.video-item--channel-link img').first(); + const uploaderAvatar = normalizeThumb(avatarEl.attr('src') || avatarEl.attr('data-src') || ''); items.push({ title: title || url, id, url, thumbnail: normalizeThumb(rawThumbnail), uploaderName: uploaderName || undefined, + // Phase 1.4 / 1.5 / 1.6 - les trois champs etaient presents dans le HTML + // mais jamais extraits, ce qui vidait la page chaine de sa date, de son + // lien "voir la chaine" et de son avatar. + publishedAt, + channelId, + uploaderAvatar, views, type: 'video', duration: durationSeconds, @@ -234,6 +382,23 @@ function parseSearchHtml(html, { limit = 50 } = {}) { // durée ≤75 s) tranche. Le scraper HTML n'expose pas de dimensions. }); }); + // Phase 3.1 — surcouche JSON-LD : complète ce que le DOM n'expose pas + // (date de publication, vues, auteur) SANS jamais écraser une valeur réellement + // présente dans la liste. Le DOM reste la source primaire, le JSON-LD le filet. + try { + const ld = parseJsonLd(html); + if (ld.size > 0) { + for (const it of items) { + const patch = ld.get(urlKey(it.url)) || ld.get(urlKey(it.id)); + if (!patch) continue; + if (it.publishedAt === undefined && patch.publishedAt) it.publishedAt = patch.publishedAt; + if (it.views === undefined && patch.views) it.views = patch.views; + if (it.duration === undefined && patch.duration) it.duration = patch.duration; + if (it.thumbnail === undefined && patch.thumbnail) it.thumbnail = patch.thumbnail; + if (it.channelId === undefined && patch.channelId) it.channelId = patch.channelId; + } + } + } catch { /* la surcouche est un bonus, jamais un motif d'échec */ } return items; } @@ -254,6 +419,16 @@ const handler = { const query = String(q || '').trim(); if (!query) return []; + // Phase 3.3 — short-circuit si l'échec vient d'être constaté. + const cacheKey = negativeCacheKey(query, pageNum); + if (isNegativelyCached(cacheKey)) { + // Phase 3.4 — un échec est une ERREUR, pas un résultat vide : le front + // peut ainsi afficher un bandeau au lieu d'un « aucun résultat » trompeur. + throw Object.assign(new Error('rumble_cloudflare_challenge'), { + code: 'rumble_cloudflare_challenge', rumbleChallenged: true, + }); + } + // --- Attempt 1: canonical search page (Node fetch, then python/curl_cffi) --- try { const params = new URLSearchParams({ q: query }); @@ -276,7 +451,14 @@ const handler = { } } catch { /* give up */ } - return []; + // Phase 3.4 — on distingue « échec » de « vide ». Une recherche Rumble qui + // renvoie 0 résultat à travers deux chemins est presque toujours un blocage + // Cloudflare, pas une absence de contenu : on le remonte, on met en cache + // négatif, et le groupe ne passe pas pour un recherche normale sans résultat. + markNegative(cacheKey); + throw Object.assign(new Error('rumble_unavailable'), { + code: 'rumble_unavailable', rumbleChallenged: true, + }); }, }; diff --git a/server/providers/twitch.mjs b/server/providers/twitch.mjs index f4b3cac..e4403dd 100644 --- a/server/providers/twitch.mjs +++ b/server/providers/twitch.mjs @@ -172,7 +172,10 @@ function mapStream(s) { uploaderAvatar: thumbStream(s.thumbnail_url, 70, 70), type: 'live', isLive: true, - views: typeof s.viewer_count === 'number' ? s.viewer_count : undefined, + // Phase 3.8 / 7.3 : `viewer_count` n'est PAS un compteur de vues. Il est + // expose dans `viewers` (joueurs en direct). Le mettre dans `views` faisait + // afficher "1 234 vues" sur un live qui n'a jamais ete vu. + viewers: typeof s.viewer_count === 'number' ? s.viewer_count : undefined, publishedAt: s.started_at || undefined, game: s.game_name || undefined, language: s.language || undefined, @@ -336,6 +339,29 @@ const handler = { const perPage = Math.min(Math.max(1, Number(limit || 24)), 50); const targetPage = Math.max(1, Number(page || 1)); + // Phase 3.10 — budget de section configurable. + // + // Helix compte 800 points/minute : chaque recherche Twitch déclenche une + // dizaine d'appels, et le budget influe directement sur le nombre de + // requêtes Helix (une requête = 1 point, `first` ne change pas le coût). + // Le défaut reprend EXACTEMENT l'historique (`max(4, ceil(perPage/3))` et + // `max(6, ceil(perPage/2))` pour les VODs) : sans variable d'environnement, + // le comportement ne bouge pas d'un iota. + // + // `TWITCH_SECTION_BUDGET` = budget uniforme en résultats (une seule valeur, + // lisible) ; les budgets par section restent réglables séparément. + const budgetOverride = Number(process.env.TWITCH_SECTION_BUDGET); + const budgetLives = Number.isFinite(budgetOverride) && budgetOverride > 0 + ? Math.floor(budgetOverride) + : Math.max(4, Math.ceil(perPage / 3)); + const budgetVods = Number.isFinite(budgetOverride) && budgetOverride > 0 + ? Math.floor(budgetOverride) + : Math.max(6, Math.ceil(perPage / 2)); + // `first` par broadcaster : borné par Helix (100) et par le budget, sinon + // on paie le transport pour des résultats que l'on jetterait au budget. + const vodFirst = Math.min(100, Math.max(1, budgetVods)); + const clipFirst = Math.min(100, Math.max(1, budgetLives)); + // 1) Chaînes (paginées : la pagination repose sur ce curseur). // Helix ne matche pas les longues phrases : on cherche la requête // complète puis chaque mot-clé (ex. "cooking" pour "cooking food..."). @@ -436,87 +462,101 @@ const handler = { // Tri des lives : vues décroissantes quand sort=views, sinon pertinence (ordre Helix). if (sort === 'views') lives = [...lives].sort((a, b) => Number(b.views || 0) - Number(a.views || 0)); - // 3) VODs : par broadcaster (pertinence requête) + par jeu (thèmes). + // 3) + 4) VODs et Clips EN PARALLÈLE (phase 3.9). + // + // Le plan evaluait un risque de « casser l'ordre des sections » : il n'y a + // pas de risque ici, parce que les deux blocs écrivent dans deux variables + // distinctes et que l'ordre final est explicite à l'assemblage + // (`[...takeLives, ...takeVods, ...takeClips, ...takeChannels]`). Les + // sections restent donc rendues dans le même ordre, mais on économise une + // latence complète de Helix : ces blocs font chacun 5-6 requêtes, et + // Helix compte 800 points/min — le gaspillage était mesurable. const vodSort = sort === 'views' ? 'views' : sort === 'date' ? 'time' : 'trending'; - let vods = []; - try { - const perUser = userIds.slice(0, 6); - const lists = await Promise.all( - perUser.map(async (uid) => { + const clipSince = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); + + /** Ajoute `extra` à `list` sans doublon (le set est reconstruit à chaque fois : listes courtes). */ + const mergeUnique = (list, extra) => { + const seen = new Set(list.map((v) => String(v.id))); + for (const v of extra) { + if (!seen.has(String(v.id))) { seen.add(String(v.id)); list.push(v); } + } + return list; + }; + + const loadVods = async () => { + let vods = []; + try { + const perUser = userIds.slice(0, 6); + const lists = await Promise.all( + perUser.map(async (uid) => { + try { + const data = await helixGet( + '/videos', + new URLSearchParams({ user_id: String(uid), first: String(vodFirst), type: 'archive', sort: vodSort }), + auth, + { retries: 1 }, + ); + return Array.isArray(data?.data) ? data.data : []; + } catch { + return []; + } + }), + ); + vods = lists.flat().map(mapVod); + if (targetPage === 1 && categoryId) { try { const data = await helixGet( '/videos', - new URLSearchParams({ user_id: String(uid), first: '4', type: 'archive', sort: vodSort }), + new URLSearchParams({ game_id: String(categoryId), first: '12', sort: vodSort }), auth, { retries: 1 }, ); - return Array.isArray(data?.data) ? data.data : []; - } catch { - return []; - } - }), - ); - vods = lists.flat().map(mapVod); - if (targetPage === 1 && categoryId) { - try { - const data = await helixGet( - '/videos', - new URLSearchParams({ game_id: String(categoryId), first: '12', sort: vodSort }), - auth, - { retries: 1 }, - ); - const extra = (Array.isArray(data?.data) ? data.data : []).map(mapVod); - const seen = new Set(vods.map((v) => String(v.id))); - for (const v of extra) { - if (!seen.has(String(v.id))) { - seen.add(String(v.id)); - vods.push(v); - } - } - } catch {} - } - } catch {} + mergeUnique(vods, (Array.isArray(data?.data) ? data.data : []).map(mapVod)); + } catch {} + } + } catch {} + return vods; + }; - // 4) Clips : par broadcaster (30 derniers jours) + par jeu. - let clips = []; - try { - const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); - const perUser = userIds.slice(0, 5); - const lists = await Promise.all( - perUser.map(async (uid) => { + const loadClips = async () => { + let clips = []; + try { + const perUser = userIds.slice(0, 5); + const lists = await Promise.all( + perUser.map(async (uid) => { + try { + const data = await helixGet( + '/clips', + new URLSearchParams({ broadcaster_id: String(uid), first: String(clipFirst), started_at: clipSince }), + auth, + { retries: 1 }, + ); + return Array.isArray(data?.data) ? data.data : []; + } catch { + return []; + } + }), + ); + clips = lists.flat().map(mapClip); + if (targetPage === 1 && categoryId) { try { const data = await helixGet( '/clips', - new URLSearchParams({ broadcaster_id: String(uid), first: '3', started_at: since }), + new URLSearchParams({ game_id: String(categoryId), first: '10', started_at: clipSince }), auth, { retries: 1 }, ); - return Array.isArray(data?.data) ? data.data : []; - } catch { - return []; - } - }), - ); - clips = lists.flat().map(mapClip); - if (targetPage === 1 && categoryId) { - try { - const data = await helixGet( - '/clips', - new URLSearchParams({ game_id: String(categoryId), first: '10', started_at: since }), - auth, - { retries: 1 }, - ); - const extra = (Array.isArray(data?.data) ? data.data : []).map(mapClip); - const seen = new Set(clips.map((v) => String(v.id))); - for (const v of extra) { - if (!seen.has(String(v.id))) { - seen.add(String(v.id)); - clips.push(v); - } - } - } catch {} - } - } catch {} + mergeUnique(clips, (Array.isArray(data?.data) ? data.data : []).map(mapClip)); + } catch {} + } + } catch {} + return clips; + }; + + // `allSettled` et non `all` : chaque bloc avale déjà ses propres erreurs, + // mais un rejet inattendu ne doit pas faire tomber la section voisine. + const [vods, clips] = await Promise.allSettled([loadVods(), loadClips()]) + .then((rs) => rs.map((r) => (r.status === 'fulfilled' ? r.value : []))); // 5) Assemblage façon directory : lives, vidéos, clips, chaînes (hors-ligne). const channels = channelsData.map(mapChannel); @@ -535,10 +575,10 @@ const handler = { } // Budgets : on remplit chaque section sans noyer les autres. - const takeLives = allLives.slice(0, Math.max(4, Math.ceil(perPage / 3))); - const takeVods = vods.slice(0, Math.max(6, Math.ceil(perPage / 2))); - const takeClips = clips.slice(0, Math.max(4, Math.ceil(perPage / 3))); - const takeChannels = offlineChannels.slice(0, Math.max(4, Math.ceil(perPage / 3))); + const takeLives = allLives.slice(0, budgetLives); + const takeVods = vods.slice(0, budgetVods); + const takeClips = clips.slice(0, budgetLives); + const takeChannels = offlineChannels.slice(0, budgetLives); const merged = [...takeLives, ...takeVods, ...takeClips, ...takeChannels]; // Déduplique par id (les lives login vs chaînes id numérique ne collisionnent pas). diff --git a/server/providers/youtube-common.mjs b/server/providers/youtube-common.mjs index 795d979..c31b887 100644 --- a/server/providers/youtube-common.mjs +++ b/server/providers/youtube-common.mjs @@ -47,6 +47,84 @@ export function getSearchMode() { return 'innertube-first'; } +// --- Phase 4.4 : bascule automatique InnerTube -> scrape -------------------- +/** + * InnerTube casse plus souvent que la Data API (changement de format, bot-check). + * On mesure le taux d'echec InnerTube sur une fenetre glissante et on bascule + * temporairement sur `scrape-first`, avec retour automatique et cooldown. + * + * Pourquoi cet etat est dans le process et pas en base : une bascule doit + * survivre a un redemarrage (les bases survivent au redemarrage, l'etat non), + * et `provider_metrics` est de toute facon purge au-dela de 24 h. + */ +const failover = { + /** null = bascule inactive, sinon { until, previousMode, reason }. */ + active: null, + lastToggleAt: 0, +}; + +/** Seuil de declenchement, 0.2 = 20 % d'echecs InnerTube sur 1 h. */ +function failoverThreshold() { + const v = Number(process.env.YT_INNERTUBE_AUTO_FAILOVER); + return Number.isFinite(v) && v > 0 && v < 1 ? v : 0.2; +} + +/** Cooldown apres un retour au mode normal, pour eviter l'oscillation. */ +function failoverCooldownMs() { + const v = Number(process.env.YT_INNERTUBE_FAILOVER_COOLDOWN_MS); + return Number.isFinite(v) && v > 0 ? v : 15 * 60 * 1000; +} + +/** + * A appeler apres chaque tentative InnerTube. + * @param {boolean} ok + * @param {object} snapshot `providerMetricsSnapshot({ hours: 1 })` + */ +export function recordInnerTubeOutcome(ok, snapshot) { + try { + if (!ok) ytMetrics.innertubeErrors++; + const now = Date.now(); + // Retour au mode normal des que le taux repasse sous le seuil, en respectant + // le cooldown anti-oscillation. + if (failover.active && now < failover.active.until) return failover.active; + if (failover.active && now - failover.lastToggleAt < failoverCooldownMs()) return failover.active; + if (!Array.isArray(snapshot) || snapshot.length === 0) return failover.active; + const row = snapshot.find((r) => r.provider === 'yt'); + if (!row || !row.calls || row.calls < 3) return failover.active; // pas assez d'echantillons + if (row.errorRate >= failoverThreshold()) { + const previousMode = getSearchMode(); + failover.active = { until: now + failoverCooldownMs(), previousMode, reason: `errorRate=${row.errorRate.toFixed(2)} calls=${row.calls}` }; + failover.lastToggleAt = now; + console.warn(`[YT failover] InnerTube dégradé (${failover.active.reason}) -> scrape-first pendant ${Math.round(failoverCooldownMs() / 60000)} min`); + return failover.active; + } + if (failover.active) { + console.log(`[YT failover] InnerTube rétabli (errorRate=${(row.errorRate || 0).toFixed(2)}) -> retour ${failover.active.previousMode}`); + failover.active = null; + failover.lastToggleAt = now; + } + return failover.active; + } catch { return failover.active; } +} + +export function getFailoverState() { + return failover.active ? { ...failover.active, mode: 'scrape-first' } : null; +} + +/** + * Mode **effectif** : le mode configure, force sur `scrape-first` pendant un + * failover actif. `getSearchMode()` reste la lecture de l'intention (env) ; + * les appelants doivent utiliser cette fonction. + */ +export function getEffectiveSearchMode() { + if (failover.active && Date.now() < failover.active.until) { + const base = getSearchMode(); + // On n'ecrase que les modes qui dependent d'InnerTube. + return base.startsWith('innertube') ? 'scrape-first' : base; + } + return getSearchMode(); +} + export function getScrapeTtlMs() { const v = Number(process.env.YT_SCRAPE_TTL_MS || 30 * 60 * 1000); return Number.isFinite(v) && v > 0 ? v : 30 * 60 * 1000; diff --git a/server/providers/youtube-innertube.mjs b/server/providers/youtube-innertube.mjs index c199b63..d7862ed 100644 --- a/server/providers/youtube-innertube.mjs +++ b/server/providers/youtube-innertube.mjs @@ -75,6 +75,45 @@ export function parseDurationLabel(label) { } catch { return undefined; } } +/** "il y a 2 mois" / "Streamed 3 weeks ago" / "2 days ago" -> ISO, ou undefined. */ +export function parseRelativeDate(text, nowMs = Date.now()) { + try { + const s = String(text || '').toLowerCase().trim(); + if (!s) return undefined; + // Une date absolue ("17 janv. 2024", "2024-01-17") passe deja par Date.parse. + if (/\d{4}-\d{2}-\d{2}/.test(s)) { + const abs = Date.parse(s); + if (Number.isFinite(abs) && abs > 0 && abs < 8.64e15) return new Date(abs).toISOString(); + } + // Fenetre temporelle : YouTube annonce "il y a 2 heures", "3 weeks ago", ... + const re = /(\d+)\s*(second|minute|hour|day|week|month|year|seconde|heure|jour|semaine|mois|an|annee|année)/; + const m = s.match(re); + if (!m) { + // "hier" / "yesterday" / "aujourd'hui" / "today" : cas sans quantite. + if (/\b(yesterday|hier)\b/.test(s)) return new Date(nowMs - 864e5).toISOString(); + if (/\b(today|aujourd'hui)\b/.test(s)) return new Date(nowMs).toISOString(); + if (/\bjust now|just now|maintenant|venir de\b/.test(s)) return new Date(nowMs).toISOString(); + return undefined; + } + const n = Math.max(0, Number(m[1])); + if (!Number.isFinite(n)) return undefined; + const unit = m[2]; + const table = { + second: 1e3, seconde: 1e3, + minute: 6e4, + hour: 36e5, heure: 36e5, + day: 864e5, jour: 864e5, + week: 6048e5, semaine: 6048e5, + month: 26298e6, mois: 26298e6, + year: 315576e5, an: 315576e5, annee: 315576e5, année: 315576e5, + }; + const mult = table[unit]; + if (!mult) return undefined; + const ms = nowMs - (n * mult); + return Number.isFinite(ms) && ms > 0 ? new Date(ms).toISOString() : undefined; + } catch { return undefined; } +} + function bestThumb(thumbs) { try { const arr = Array.isArray(thumbs) ? thumbs.filter((t) => t?.url) : []; @@ -151,7 +190,12 @@ export function mapVideoNode(n) { type: 'video', ...(Number.isFinite(duration) && duration > 0 ? { duration } : {}), ...(views !== undefined ? { views } : {}), - ...(n.published?.text ? { publishedAt: String(n.published.text) } : {}), + // Phase 1.9 - `n.published.text` est un LIBELLE RELATIF ("il y a 2 mois"), + // que Date.parse() renvoie en NaN. On le convertit en ISO : sans cela le + // filtre `period=week` etait inoperant sur YouTube et `sort=date` triait + // sur NaN. Si le libelle est incomprehensible, publishedAt reste absent + // (jamais de date inventee). + ...(n.published?.text ? { publishedAt: parseRelativeDate(n.published.text) } : {}), ...(authorId ? { channelId: authorId, channelExternalId: authorId, channelUrl: `https://www.youtube.com/channel/${authorId}` } : {}), ...(authorName ? { channelHandle: String(authorName) } : {}), ...(n.is_live ? { isLive: true } : {}), diff --git a/server/providers/youtube.mjs b/server/providers/youtube.mjs index bc54eba..9be8be9 100644 --- a/server/providers/youtube.mjs +++ b/server/providers/youtube.mjs @@ -10,7 +10,7 @@ * @property {string=} type */ import { - getYouTubeKeys, isKeyFailure, getSearchMode, getScrapeTtlMs, + getYouTubeKeys, isKeyFailure, getEffectiveSearchMode, getScrapeTtlMs, hashSearchKey, ytMetrics, } from './youtube-common.mjs'; import { searchViaScrape } from './youtube-scrape.mjs'; @@ -109,6 +109,24 @@ export function ytScrapeCacheStats() { return { memEntries: memCache.size, memMax: MEM_MAX }; } +/** + * Phase 3.6 — cache des `pageToken` YouTube. + * Avant : atteindre la page N exigeait de rejouer les pages 1..N à chaque visite + * (`while (currentPage <= targetPage)`), soit jusqu'à 100 unités de quota pour un + * simple « page 5 ». La Data API est paginée par jeton, pas par offset : on mémorise + * donc la chaîne de jetons et chaque page thereafter coûte 1 appel. + * TTL 15 min, borné à 200 entrées par instance. + * @type {Map<string, { ts: number, tokens: string[] }>} + */ +const PAGE_TOKEN_TTL_MS = 15 * 60 * 1000; +const pageTokenCache = new Map(); + +function pageTokenCacheKey(q, order, perPage, extra) { + return `ytpage|${hashSearchKey(`${String(q).toLowerCase().trim()}|${order}|${perPage}|${JSON.stringify(extra || {})}`)}`; +} + +export function clearYouTubePageTokenCache() { pageTokenCache.clear(); } + async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance', filters = null } = {}) { const keys = getYouTubeKeys(); if (!keys.length) { @@ -121,23 +139,42 @@ async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance', filte const extra = apiSearchParams(filters); const perPage = Math.min(Math.max(1, Number(limit || 10)), 50); const targetPage = Math.max(1, Number(page || 1)); - let pageToken = ''; - let currentPage = 1; + const cacheKey = pageTokenCacheKey(q, order, perPage, extra); + + const hit = pageTokenCache.get(cacheKey); + const entry = (hit && (Date.now() - hit.ts) < PAGE_TOKEN_TTL_MS) ? hit : null; + if (!entry && hit) pageTokenCache.delete(cacheKey); + if (pageTokenCache.size >= 200) { + for (const [k, v] of pageTokenCache) { + if ((Date.now() - v.ts) >= PAGE_TOKEN_TTL_MS) pageTokenCache.delete(k); + if (pageTokenCache.size < 150) break; + } + } + + /** `tokens[i]` = pageToken permettant de fetch la page i+2. Doit être contigu depuis 0. */ + const tokens = entry ? entry.tokens : []; + const buildParams = (pageToken) => ({ + part: 'snippet', q, maxResults: String(perPage), order, + videoEmbeddable: 'true', safeSearch: 'moderate', + ...extra, + ...(pageToken ? { pageToken } : {}), + }); + + // Si le jeton de la page cible est déjà connu, on saute directement dessus : + // 1 appel au lieu de N. Sinon on rejoue les pages manquantes à partir d'où + // la chaîne s'arrête (jamais depuis le début si le cache est partiel). + const startPage = Math.min(targetPage, tokens.length + 1); + let pageToken = startPage >= 2 ? (tokens[startPage - 2] || '') : ''; let lastItems = []; - while (currentPage <= targetPage) { - const params = { - part: 'snippet', q, maxResults: String(perPage), order, - videoEmbeddable: 'true', safeSearch: 'moderate', - ...extra, - }; - if (pageToken) params.pageToken = pageToken; - const data = await ytFetchJson('https://www.googleapis.com/youtube/v3/search', params); + for (let currentPage = startPage; currentPage <= targetPage; currentPage++) { + const data = await ytFetchJson('https://www.googleapis.com/youtube/v3/search', buildParams(pageToken)); if (currentPage === targetPage) { lastItems = Array.isArray(data.items) ? data.items : []; break; } const next = data.nextPageToken; if (!next) { lastItems = []; break; } + tokens[currentPage - 1] = String(next); pageToken = String(next); - currentPage++; } + pageTokenCache.set(cacheKey, { ts: Date.now(), tokens }); const videoIds = (lastItems || []).map((item) => item?.id?.videoId).filter(Boolean); const detailsMap = new Map(); if (videoIds.length > 0) { @@ -150,6 +187,31 @@ async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance', filte console.warn('[YouTube] details fetch failed, continuing without durations:', e?.message || e); } } + // Phase 1.7 - `search.list` ne renvoie QUE la vignette de la video, jamais + // celle de la chaine. Un seul appel `channels.list` par page suffit ahydrater + // `uploaderAvatar` pour tous les resultats. Degradation silencieuse : si aucune + // cle n'est disponible ou si l'appel echoue, on rend la main sans avatar + // (jamais d'image inventee). + const channelAvatars = new Map(); + try { + const channelIds = Array.from(new Set( + (lastItems || []) + .map((i) => i?.snippet?.channelId || i?.id?.channelId) + .filter(Boolean), + )).slice(0, 50); + if (channelIds.length > 0) { + const chData = await ytFetchJson('https://www.googleapis.com/youtube/v3/channels', { + part: 'snippet', id: channelIds.join(','), + }); + for (const ch of chData?.items || []) { + const t = ch?.snippet?.thumbnails; + const url = t?.high?.url || t?.medium?.url || t?.default?.url; + if (ch?.id && url) channelAvatars.set(ch.id, url); + } + } + } catch (e) { + console.warn('[YouTube] channel avatars fetch failed, continuing without:', e?.message || e); + } return (lastItems || []).map((item) => { const videoId = item?.id?.videoId; // Une recherche de type `channel` renvoie des channelId, pas des videoId. @@ -169,8 +231,18 @@ async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance', filte ? `https://www.youtube.com/channel/${rawId}` : `https://www.youtube.com/watch?v=${rawId}`, thumbnail: thumb, uploaderName: snippet.channelTitle || undefined, + // Phase 1.7 - avatar de la chaine (absent = pas d'avatar, pas de repli casse). + ...(channelId && channelAvatars.get(channelId) + ? { uploaderAvatar: channelAvatars.get(channelId), channelAvatarUrl: channelAvatars.get(channelId) } + : {}), type: isChannel ? 'channel' : 'video', duration: duration > 0 ? duration : undefined, views, + // Phase 1.7bis - `likeCount` est gratuit sur le meme appel + // `videos?part=statistics` que la duree : autant le remonter pendant qu'on y est. + ...(details?.statistics?.likeCount != null ? { likes: Number(details.statistics.likeCount) } : {}), + // `liveBroadcastContent` vient du snippet de la recherche (live/upcoming/none). + ...(snippet?.liveBroadcastContent === 'live' ? { isLive: true, type: 'live' } : {}), + ...(snippet?.liveBroadcastContent === 'upcoming' && !isChannel ? { type: 'upcoming' } : {}), publishedAt: snippet.publishedAt || undefined, channelId, channelHandle: snippet.channelTitle || undefined, channelExternalId: channelId, channelUrl: channelId ? `https://www.youtube.com/channel/${channelId}` : undefined, embeddable, @@ -191,7 +263,11 @@ const handler = { label: 'YouTube', async search(q, opts) { const { limit = 10, page = 1, sort = 'relevance', filters = null } = opts || {}; - const mode = getSearchMode(); + // Phase 4.4 : on branche sur le mode **effectif** (eventuellement force sur + // scrape-first par la bascule automatique), pas sur l'intention. La cle de + // cache inclut ce mode : sinon une bascule ferait servir des resultats + // InnerTube comme s'ils venaient du scrape. + const mode = getEffectiveSearchMode(); const ttl = getScrapeTtlMs(); const perPage = Math.min(Math.max(1, Number(limit || 10)), 50); // La signature des filtres entre dans la clé de cache : deux recherches @@ -224,6 +300,30 @@ const handler = { return items; }; const t0 = Date.now(); + /** + * Phase 4.4 — alimente la bascule automatique. + * + * Deux choses indispensables, et la premiere etait absente : + * 1. l'appel InnerTube est un VRAI appel sortant, il doit etre mesure dans + * `provider_metrics` sous le provider `yt` ; + * 2. le snapshot est lu APRES cet increment, sinon la tentative courante + * n'est pas comptee. + * Sans (1), la ligne `yt` restait absente de `provider_metrics` (le registre + * met en cache les autres fournisseurs, pas YouTube), donc + * `recordInnerTubeOutcome` voyait toujours `calls === 0`, la condition + * `row.calls < 3` sortait toujours, et le failover ne pouvait jamais + * se declencher en production — uniquement dans les tests, qui injectaient + * un snapshot synthetique. + */ + const noteInnerTube = async (ok, latencyMs, error) => { + try { + const [{ providerMetricsSnapshot, incProviderMetrics }, common] = await Promise.all([ + import('../db.mjs'), import('./youtube-common.mjs'), + ]); + incProviderMetrics('yt', { ok, latencyMs, error: error || null }); + common.recordInnerTubeOutcome(ok, providerMetricsSnapshot({ hours: 1 })); + } catch {} + }; const tryScrape = async () => { ytMetrics.scrapeCalls++; try { const { incYoutubeMetrics } = await import('../db.mjs'); incYoutubeMetrics({ scrapeCalls: 1 }); } catch {} @@ -292,14 +392,17 @@ const handler = { // Chaque couche ne fait jamais échouer la recherche à elle seule. const errors = []; try { + const tIt = Date.now(); const items = await tryInnerTube(); log('innertube', items.length); + await noteInnerTube(true, Date.now() - tIt); return persist(items, 'innertube'); } catch (itErr) { ytMetrics.innertubeErrors = (ytMetrics.innertubeErrors || 0) + 1; ytMetrics.fallbacks++; errors.push(`innertube=${itErr?.message}`); console.warn(`[YT search] innertube failed (${itErr?.code || 'unknown'}), fallback to scrape`); + await noteInnerTube(false, Date.now() - tIt, itErr?.code || itErr?.message); } try { const items = await tryScrape(); diff --git a/server/search-filters.mjs b/server/search-filters.mjs index 3a40d61..190bbeb 100644 --- a/server/search-filters.mjs +++ b/server/search-filters.mjs @@ -123,10 +123,14 @@ export function itemDurationSec(item) { if (s.includes(':')) { const parts = s.split(':').map((p) => Number(p)); if (parts.length && parts.every((p) => Number.isFinite(p))) { - return parts.reduce((acc, p) => acc * 60 + p, 0); + const total = parts.reduce((acc, p) => acc * 60 + p, 0); + if (total > 0) return total; } } - return 0; + // Phase 2.2 — « durée inconnue » vaut `undefined`, jamais `0`. Le 0 signifiait + // « vidéo de 0 s » et rendait la règle « verticale sans durée => pas un short » + // inopérante, tout en faisant afficher "0:00" sur les cartes. + return undefined; } export function itemPublishedTs(item) { @@ -191,7 +195,13 @@ export function isShortItem(item, providerId) { // font ≤ 60 s par construction, une durée > 75 s sur un clip est du bruit // de métadonnées (le test historique « shorts keeps clips » l'impose). if (kind === 'clip') return true; - if (item.isShort === true || type === 'short') { + // Règle (c) : `kind === 'clip'` ci-dessusprime sur `type: 'video'`. Sans cet + // early return, un item `kind:'clip', type:'video'` (ce que produisait + // `channel-content.mjs` pour les lives Twitch) retombait en règle 3/4. + if (item.isShort === true || type === 'short' || String(item.url || '').includes('/shorts/')) { + // Règle (a) : flag natif + durée INCONNUE => short. « Le provider a parlé » + // prime quand rien ne le contredit. Règle (b) : un flag natif prime aussi + // sur un 1:1 (repost Instagram/TikTok), mais JAMAIS sur la seule durée. if (hasKnownDuration && d > SHORT_MAX_SECONDS) return false; return true; } diff --git a/server/search-transport.mjs b/server/search-transport.mjs new file mode 100644 index 0000000..a1328a2 --- /dev/null +++ b/server/search-transport.mjs @@ -0,0 +1,96 @@ +/** + * Phase 7.3 / 7.6 — transport de `/api/search`. + * + * Deux besoins nés dans la même route, regroupés parce que ce sont DEUX TRANSPORTS + * du même contrat côté serveur : + * - Phase 7.3 : le payload de diagnostic `?debug=1` (provenance + `raw` tronqué) ; + * - Phase 7.6 : le media type `application/x-ndjson` du flux incrémental. + * + * Isolé dans son propre module (et non dans `index.mjs`) pour deux raisons : + * - `index.mjs` fait 4 200+ lignes et démarre un serveur à l'import : la logique + * de diagnostic ne peut donc pas être testée depuis le fichier d'entrée ; + * - la troncature et la redaction sont une BARRIÈRE DE SÉCURITÉ. Elles + * s'évaporent à la première refonte si elles vivent au milieu des routes. + * + * Chaîne du debug volontairement bornée : le but est de voir ce que le provider + * a renvoyé et ce qui a survécu au mapping (pour comprendre un `views` manquant), + * pas de transporter une réponse entière. Garde-fous : 2 items par provider, + * 600 caractères de JSON, et suppression des clés d'habilitation. + */ + +export const DEBUG_RAW_MAX_CHARS = 600; +export const DEBUG_RAW_ITEMS_PER_PROVIDER = 2; + +/** + * Phase 7.6 — media type du transport incrémental de `/api/search`. + * + * Exporté (et non écrit en littéral dans la route) parce que le client doit + * négocier le MÊME type : un désaccord d'une lettre ferait retomber le front + * silencieusement en mode atomique, sans le moindre message d'erreur — le pire + * scénario possible pour une optimization de performance. + */ +export const APPLICATION_NDJSON = 'application/x-ndjson'; + +/** + * Clés d'habilitation retirées du `raw`. Volontairement large : un `raw` + * publié par erreur est journalisé en CI, donc en clair dans les artefacts de + * build. Un faux positif coûte un `[redacted]` de plus dans un payload de debug. + */ +const DEBUG_SECRET_KEYS = /^(api[-_]?key|apikey|authorization|auth|token|access[-_]?token|refresh[-_]?token|cookie|set[-_]?cookie|password|passwd|secret|client[-_]?secret|private[-_]?key|signature)$/i; + +/** Copie profonde superficiellement, valeurs sensibles remplacées. */ +export function redactDebugSecrets(value) { + if (!value || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.map(redactDebugSecrets); + const out = {}; + for (const [k, v] of Object.entries(value)) { + out[k] = DEBUG_SECRET_KEYS.test(k) ? '[redacted]' : redactDebugSecrets(v); + } + return out; +} + +/** + * @param {Record<string, any[]>} groups groupes de résultats par provider + * @param {Record<string, {message?: string}>} [errors] + */ +export function buildDebugPayload(groups, errors) { + const out = {}; + for (const [pid, items] of Object.entries(groups || {})) { + const list = Array.isArray(items) ? items : []; + const sample = list.slice(0, DEBUG_RAW_ITEMS_PER_PROVIDER).map((item) => { + if (!item || typeof item !== 'object') return { value: item }; + const { raw, ...rest } = item; + const base = { + id: rest.id ?? null, + source: rest.source ?? null, + capturedAt: rest.capturedAt ?? null, + // Les NOMS des champs mappés, pas seulement leur nombre : c'est ce qui + // permet de distinguer « `views` absent » de « `views` nul ». Triés pour + // que deux debugs successifs soient comparables. + fields: Object.keys(rest) + .filter((k) => k !== 'id' && k !== 'source' && k !== 'capturedAt') + .sort(), + }; + // `raw` n'est aujourd'hui produit par aucun adaptateur (ils normalisent + // sans conserver la charge utile amont) : la branche reste pour le jour où + // l'un d'eux l'expose, et `fields` prend le relais en attendant. + if (raw === undefined) return base; + let json; + try { json = JSON.stringify(redactDebugSecrets(raw)); } catch { json = '[unserialisable]'; } + return { + ...base, + raw: json.length > DEBUG_RAW_MAX_CHARS + ? `${json.slice(0, DEBUG_RAW_MAX_CHARS)}… (${json.length} car.)` + : json, + }; + }); + out[pid] = { + count: list.length, + error: errors?.[pid]?.message ?? null, + capturedAt: list.find((i) => i && i.capturedAt)?.capturedAt ?? null, + source: list.find((i) => i && i.source)?.source ?? null, + items: sample, + }; + } + return out; +} diff --git a/server/tests/adapter_contract.test.mjs b/server/tests/adapter_contract.test.mjs new file mode 100644 index 0000000..45423fb --- /dev/null +++ b/server/tests/adapter_contract.test.mjs @@ -0,0 +1,103 @@ +/** + * Phase 8.1 — CONTRAT UNIQUE `ProviderAdapter`. + * + * Chaque provider expose LA MÊME forme : `{ id, label, search, suggest?, + * channelContent, channelMeta, capabilities }`. C'est le garde-fou du « un seul + * fichier par provider devient l'unique point de contact » : si quelqu'un ajoute + * un provider à la peine, ou casse un des 6 points de contact, la suite tombe. + * + * Aucun réseau n'est touché : la forme est vérifiée structurellement, et les + * membres appelables restent appelables (une recherche réelle se fait dans les + * suites des fixtures). + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +const { providerAdapters, providerRegistry, PROVIDER_CAPABILITIES, PROVIDER_IDS, getProviderAdapter } = + await import('../providers/registry.mjs'); + +const REQUIRED = ['id', 'label', 'search', 'channelContent', 'channelMeta', 'capabilities']; + +describe('Phase 8.1 — contrat unique ProviderAdapter', () => { + it('les 6 providers sont présents et dans l\'ordre canonique', () => { + assert.deepEqual(Object.keys(providerAdapters).sort(), ['dm', 'od', 'pt', 'ru', 'tw', 'yt']); + assert.deepEqual(PROVIDER_IDS, ['yt', 'dm', 'tw', 'pt', 'od', 'ru']); + }); + + for (const pid of PROVIDER_IDS) { + it(`${pid}: forme complète du contrat`, () => { + const a = providerAdapters[pid]; + assert.ok(a, 'adaptateur présent'); + assert.equal(a.id, pid, 'id aligné'); + assert.ok(String(a.label || '').length > 0, 'label non vide'); + for (const key of REQUIRED) { + assert.ok(key in a, `clé requise \`${key}\` présente`); + } + assert.equal(typeof a.id, 'string', '`id` est une chaîne'); + assert.equal(typeof a.label, 'string', '`label` est une chaîne'); + assert.equal(typeof a.capabilities, 'object', '`capabilities` est un objet'); + for (const member of ['search', 'channelContent', 'channelMeta']) { + assert.equal(typeof a[member], 'function', `\`${member}\` est appelable`); + } + if ('suggest' in a) { + assert.ok(a.suggest === undefined || typeof a.suggest === 'function', '`suggest` est fonction ou undefined'); + } + if ('fetchChannelById' in a) { + assert.ok(a.fetchChannelById === undefined || typeof a.fetchChannelById === 'function', + '`fetchChannelById` (compat) est fonction ou undefined'); + } + if ('suggest' in a && a.suggest !== undefined) { + assert.equal(typeof a.suggest, 'function', 'suggest est une fonction quand elle existe'); + } + }); + + it(`${pid}: capabilities déclarées fidèles au comportement`, () => { + const a = providerAdapters[pid]; + const caps = a.capabilities; + assert.equal(caps, PROVIDER_CAPABILITIES[pid], 'la même instance de capacités que la constante'); + assert.equal(typeof caps.suggest, 'boolean', 'suggest: booléen'); + assert.equal(typeof caps.live, 'boolean', 'live: booléen'); + assert.equal(caps.channelMeta, true, 'chaque provider a des métadonnées de chaîne'); + assert.ok(Array.isArray(caps.channelContent) && caps.channelContent.length > 0, 'liste de types de contenu non vide'); + assert.ok(caps.channelContent.every((t) => ['videos', 'shorts', 'playlists', 'live'].includes(t)), + 'types de contenu dans le vocabulaire connu'); + assert.equal(caps.suggest, typeof a.suggest === 'function', 'suggest déclaré ⟺ suggest fourni'); + assert.equal(caps.live, pid === 'tw', 'seul Twitch est live (fait observable)'); + }); + + it(`${pid}: search du contrat EST la recherche enveloppée (cache → ref → provenance)`, () => { + // Le point de contact unifié doit servir la version DÉJÀ enveloppée par le + // registre (cache, channelRef, provenance) — pas une copie nue de module. + assert.equal(providerAdapters[pid].search, providerRegistry[pid].search, + 'même fonction que le registre (les wrappers s\'appliquent en amont)'); + }); + + it(`${pid}: channelContent et channelMeta sont câblés (appelables, distincts)`, () => { + const a = providerAdapters[pid]; + assert.equal(typeof a.channelContent, 'function', 'channelContent appelable'); + assert.equal(typeof a.channelMeta, 'function', 'channelMeta appelable'); + assert.notEqual(a.channelContent, a.channelMeta, 'deux responsabilités distinctes'); + // Le test de délégation se fait sans réseau : pour ru, ruContent passe par + // `ctx.searchRegistry` — on peut donc l'observer offline (cf. plus bas). + }); + } + + it('ru: channelContent délègue au registre de recherche via le contexte (offline)', async () => { + const a = providerAdapters.ru; + const fakeItem = { id: 'r1', title: 'Rumble seed', channelId: 'peerless-canal', uploaderName: 'peerless' }; + const fakeRegistry = { + ru: { search: async () => [fakeItem, { id: 'r2', title: 'autre channel', channelId: 'autre' }] }, + }; + const out = await a.channelContent('peerless-canal', { type: 'videos', limit: 5 }, { searchRegistry: fakeRegistry }); + assert.ok(Array.isArray(out.items), 'items normalisés'); + assert.equal(out.items.length, 1, 'filtre par channelId canonique'); + assert.equal(out.items[0].id, 'r1', 'l\'item retenu est le bon'); + }); + + it('getProviderAdapter est l\'accès unique (et retombe sur le registre)', () => { + for (const pid of PROVIDER_IDS) { + assert.equal(getProviderAdapter(pid), providerAdapters[pid], `${pid} via getProviderAdapter`); + } + assert.equal(getProviderAdapter('nope'), undefined, 'provider inconnu : pas de crash, undefined propre'); + }); +}); \ No newline at end of file diff --git a/server/tests/channel_banner.test.mjs b/server/tests/channel_banner.test.mjs new file mode 100644 index 0000000..0b1cfe6 --- /dev/null +++ b/server/tests/channel_banner.test.mjs @@ -0,0 +1,278 @@ +/** + * Phase 7.7 — bannières et descriptions de chaîne, les 6 fournisseurs. + * + * Aucun réseau : `globalThis.fetch` est stubé. On vérifie que + * - chaque fournisseur expose `bannerUrl` et `description` quand la source les + * donne (aucun appel réseau supplémentaire — YT compte ses appels), + * - les URL non-http(s) sont rejetées (`javascript:` = vecteur XSS), + * - la description est bornée (600 caractères, whitespace normalisé), + * - les chaînes sans bannière restent `undefined` (on n'invente rien). + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +process.env.YOUTUBE_API_KEY = 'fake-key'; +process.env.TWITCH_CLIENT_ID = 'fake-client'; + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-banners-')); +process.env.NEWTUBE_DB_FILE = path.join(tmpDir, 'banners.db'); + +const { channelRegistry, setTwitchTokenProvider } = await import('../providers/channel-registry.mjs'); +setTwitchTokenProvider(async () => 'fake-token'); +const db = await import('../db.mjs'); + +const calls = []; +function fakeResponse(body, { text = false } = {}) { + return { + ok: true, + status: 200, + json: async () => body, + text: async () => (text ? body : JSON.stringify(body)), + }; +} + +function installFetch(routes) { + const prev = globalThis.fetch; + calls.length = 0; + globalThis.fetch = async (url, opts) => { + calls.push({ url: String(url), opts }); + const match = routes.find(r => r.test(String(url))); + if (!match) throw new Error(`network_not_stubbed: ${url}`); + return match.respond(opts); + }; + return () => { globalThis.fetch = prev; }; +} + +test('7.7 — YouTube : bannière et description depuis le payload existing', async (t) => { + const restore = installFetch([{ + test: u => u.startsWith('https://www.googleapis.com/youtube/v3/channels?'), + respond: () => fakeResponse({ + items: [{ + snippet: { + title: 'YT User', + description: ' la belle description ', + thumbnails: { high: { url: 'https://img/avatar.png' } }, + }, + statistics: { subscriberCount: '42' }, + brandingSettings: { + image: { bannerImageUrl: 'https://img/banner.png' }, + channel: { customUrl: 'ytuser' }, + }, + }], + }), + }]); + t.after(restore); + + const meta = await channelRegistry.yt.fetchChannelById('UCxxxx'); + assert.equal(meta.bannerUrl, 'https://img/banner.png'); + assert.equal(meta.description, 'la belle description'); + // La bannière ne nécessite aucun appel de plus : un seul /channels. + assert.equal(calls.filter(c => c.url.includes('googleapis.com')).length, 1); +}); + +test('7.7 — YouTube : une URL non-http(s) est rejetée, pas propagee', async (t) => { + const restore = installFetch([{ + test: u => u.startsWith('https://www.googleapis.com/youtube/v3/channels?'), + respond: () => fakeResponse({ + items: [{ + snippet: { title: 'X', thumbnails: {} }, + brandingSettings: { image: { bannerImageUrl: 'javascript:alert(1)' } }, + }], + }), + }]); + t.after(restore); + + const meta = await channelRegistry.yt.fetchChannelById('UCxxxx'); + assert.equal(meta.bannerUrl, undefined, 'javascript: ne doit jamais ressortir'); + assert.equal(meta.description, undefined); +}); + +test('7.7 — YouTube : description démesurée bornée à 600 caractères', async (t) => { + const huge = 'x'.repeat(5000); + const restore = installFetch([{ + test: u => u.startsWith('https://www.googleapis.com/youtube/v3/channels?'), + respond: () => fakeResponse({ + items: [{ + snippet: { title: 'X', description: huge, thumbnails: {} }, + brandingSettings: {}, + }], + }), + }]); + t.after(restore); + + const meta = await channelRegistry.yt.fetchChannelById('UCxxxx'); + assert.ok(meta.description.endsWith('…'), 'tronquée avec ellipse'); + assert.equal(meta.description.length, 600); +}); + +test('7.7 — Dailymotion : cover_url + description', async (t) => { + const restore = installFetch([{ + test: u => u.startsWith('https://api.dailymotion.com/user/'), + respond: () => fakeResponse({ + username: 'dmuser', + screenname: 'DM User', + avatar_720_url: 'https://img/avatar.png', + cover_url: 'https://img/banner.png', + description: 'desc dm', + url: 'https://www.dailymotion.com/user/dmuser', + followers_total: 7, + verified: false, + }), + }]); + t.after(restore); + + const meta = await channelRegistry.dm.fetchChannelById('dmuser'); + assert.equal(meta.bannerUrl, 'https://img/banner.png'); + assert.equal(meta.description, 'desc dm'); +}); + +test('7.7 — Twitch : banner_image_url + description d\'Helix', async (t) => { + const restore = installFetch([{ + test: u => u.startsWith('https://api.twitch.tv/helix/users?'), + respond: () => fakeResponse({ + data: [{ + display_name: 'TW User', + login: 'twuser', + profile_image_url: 'https://img/avatar.png', + banner_image_url: 'https://img/banner.png', + description: 'desc tw', + view_count: 12, + }], + }), + }]); + t.after(restore); + + const meta = await channelRegistry.tw.fetchChannelById('twuser'); + assert.equal(meta.bannerUrl, 'https://img/banner.png'); + assert.equal(meta.description, 'desc tw'); + assert.equal(calls[0].opts.headers['Authorization'], 'Bearer fake-token'); +}); + +test('7.7 — Twitch : sans bannière Helix, rien d\'inventé', async (t) => { + const restore = installFetch([{ + test: u => u.startsWith('https://api.twitch.tv/helix/users?'), + respond: () => fakeResponse({ data: [{ display_name: 'TW', login: 'twuser', profile_image_url: 'https://img/a.png' }] }), + }]); + t.after(restore); + + const meta = await channelRegistry.tw.fetchChannelById('twuser'); + assert.equal(meta.bannerUrl, undefined); + assert.equal(meta.description, undefined); +}); + +test('7.7 — PeerTube : banners (plus grande) + description', async (t) => { + const restore = installFetch([{ + test: u => u.startsWith('https://peertube.example/api/v1/video-channels/'), + respond: () => fakeResponse({ + displayName: 'PT User', + name: 'chan', + host: 'peertube.example', + avatar: { path: '/a.png' }, + banners: [{ path: '/b1.png' }, { path: '/banner.png' }], + description: 'desc pt', + url: 'https://peertube.example/video-channels/chan', + followersCount: 3, + ownerAccount: { verified: true }, + }), + }]); + t.after(restore); + + const meta = await channelRegistry.pt.fetchChannelById('peertube.example|chan'); + assert.equal(meta.bannerUrl, 'https://peertube.example/banner.png', 'la plus grande bannière'); + assert.equal(meta.description, 'desc pt'); +}); + +test('7.7 — PeerTube : repli sur `banner` si `banners` est vide', async (t) => { + const restore = installFetch([{ + test: u => u.startsWith('https://peertube.example/api/v1/video-channels/'), + respond: () => fakeResponse({ + displayName: 'PT User', name: 'chan', host: 'peertube.example', + avatar: { path: '/a.png' }, banners: [], banner: { path: '/old.png' }, + }), + }]); + t.after(restore); + + const meta = await channelRegistry.pt.fetchChannelById('peertube.example|chan'); + assert.equal(meta.bannerUrl, 'https://peertube.example/old.png'); +}); + +test('7.7 — Odysee : vignette élargie + description (pas de bannière LBRY)', async (t) => { + const restore = installFetch([{ + test: u => u === 'https://api.na-backend.odysee.com/api/v1/proxy?m=resolve', + respond: () => fakeResponse({ + result: { + '@chan': { + short_url: 'https://odysee.com/@chan', + thumbnail: { url: 'https://cdn.odysee.com/thumb' }, + meta: { effective_amount: 1 }, + value: { + title: 'OD User', + description: 'desc od', + thumbnail: { url: 'https://cdn.odysee.com/thumb' }, + }, + }, + }, + }), + }]); + t.after(restore); + + const meta = await channelRegistry.od.fetchChannelById('@chan'); + assert.equal(meta.bannerUrl, 'https://cdn.odysee.com/thumb?size=1200x600'); + assert.equal(meta.description, 'desc od'); + assert.equal(calls[0].opts.method, 'POST'); +}); + +test('7.7 — Rumble : og:description (attributs dans any order) sans appel de plus', async (t) => { + const restore = installFetch([{ + test: u => u === 'https://rumble.com/ruuser', + respond: () => fakeResponse( + '<meta property="og:image" content="https://img/avatar.png">' + + '<meta content="desc ru" property="og:description">' + + '<title>RU User on Rumble', + { text: true } + ), + }]); + t.after(restore); + + const meta = await channelRegistry.ru.fetchChannelById('ruuser'); + assert.equal(meta.bannerUrl, undefined, 'pas de bannière Rumble dédiée'); + assert.equal(meta.description, 'desc ru'); + assert.equal(calls.length, 1, 'une seule requête HTML, pas de seconde pour le bandeau'); +}); + +test('7.7 — ensureChannelFresh persiste bannière + description en base (survit au process)', async (t) => { + // Un « process B » qui ne passe plus par le fetch doit relire le bandeau et la + // description écrits par le premier appel (exactement le trajet des 6/HEURES Tm). + const first = await db.ensureChannelFresh('yt', 'UCpersist', async () => ({ + title: 'Persisté', + avatarUrl: 'https://img/a.png', + bannerUrl: ' https://img/banner.png ', + description: ' une description ', + url: 'https://www.youtube.com/channel/UCpersist', + })); + assert.equal(first.bannerUrl, 'https://img/banner.png'); + // Le stockage coupe les extrémités ; le repli des espaces internes est fait au + // niveau du fetch (`safeMeta`/cleanDescription), pas en base. + assert.equal(first.description, 'une description'); + + // Deuxième relevé dans le TTL : la ligne sert seule, sans re-fetch. + const second = await db.ensureChannelFresh('yt', 'UCpersist', async () => { throw new Error('offline'); }); + assert.equal(second.bannerUrl, 'https://img/banner.png', 'le bandeau survit sans appel réseau'); + assert.equal(second.description, 'une description'); +}); + +test('7.7 — upsertChannelRow ne persiste jamais une URL non-http(s)', () => { + const meta = db.upsertChannelRow({ + provider: 'yt', externalId: 'UCevil', + bannerUrl: 'javascript:alert(1)', description: 'ok', + }); + assert.equal(meta.bannerUrl, null, 'javascript: est rejeté à la persistance'); + assert.equal(meta.description, 'ok'); +}); + +test.after(() => { + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} +}); \ No newline at end of file diff --git a/server/tests/channel_ref.test.mjs b/server/tests/channel_ref.test.mjs new file mode 100644 index 0000000..a65f658 --- /dev/null +++ b/server/tests/channel_ref.test.mjs @@ -0,0 +1,188 @@ +// Phase 6 §8.2 — identité de chaîne normalisée (`channelRef`). +// Run with: npm run test:channelref +// +// 100 % offline. Vérifie : +// (a) les 3 conventions historiques se reconvertissent SANS PERTE, +// (b) `pt` et `od` produisent exactement la valeur que le front produisait hier +// (critère d'acceptation 8.2) — implémentation historique rejouée ici, +// (c) les listes de schemes serveur <-> front ne divergent pas, +// (d) rien n'est inventé quand l'identifiant est absent. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + CHANNEL_REF_SCHEMES, + buildChannelRef, + buildPeerTubeComposite, + withChannelRefs, + parsePeerTubeComposite, + normalizeOdyseeClaim, + odyseeClaimToSlug, + odyseeClaimToResolveArg, +} from '../providers/channel-ref.mjs'; +import { SUGGESTION_V2_FIELDS } from '../providers/registry.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +let passed = 0; +function expect(cond, msg) { if (!cond) throw new Error(`Assertion failed: ${msg}`); } +function eq(actual, expected, msg) { + const a = JSON.stringify(actual); const b = JSON.stringify(expected); + if (a !== b) throw new Error(`Assertion failed: ${msg} (expected ${b}, got ${a})`); +} +function ok(msg) { passed++; console.log(` V ${msg}`); } + +// ---------- 6.1 Le contrat expose bien channelRef ---------- +expect(SUGGESTION_V2_FIELDS.includes('channelRef'), 'channelRef est déclaré dans le contrat v2'); +ok('channelRef est un champ du contrat Suggestion v2'); + +// ---------- 6.1 Un scheme par provider, pour les 6 ---------- +eq(Object.keys(CHANNEL_REF_SCHEMES).sort(), ['dm', 'od', 'pt', 'ru', 'tw', 'yt'], 'les 6 providers ont un scheme'); +eq(CHANNEL_REF_SCHEMES, { + yt: 'yt-uc', dm: 'dm-user', tw: 'tw-login', pt: 'pt-composite', od: 'od-claim', ru: 'ru-slug', +}, 'les schemes sont ceux du plan §8.1'); +ok('scheme unique et explicite pour les 6 providers'); + +// ---------- 8.2 (a) Les 3 conventions historiques, sans perte ---------- + +// (1) YouTube : `UC…` +const ytRef = buildChannelRef('yt', { channelExternalId: 'UCabcdefghijklmnopqrstuv' }); +eq(ytRef, { provider: 'yt', scheme: 'yt-uc', value: 'UCabcdefghijklmnopqrstuv' }, 'yt : convention UC conservée'); +eq(ytRef.value, 'UCabcdefghijklmnopqrstuv', 'yt : la valeur est bit-pour-bit identique'); +ok('convention historique (1) YouTube `UC…` : sans perte'); + +// (2) PeerTube : `instance|channel` +const ptRef = buildChannelRef('pt', { url: 'https://framatube.org/videos/watch/abc', channelId: 'toto' }); +eq(ptRef, { provider: 'pt', scheme: 'pt-composite', value: 'framatube.org|toto' }, 'pt : composite construit depuis l\'URL'); +eq(parsePeerTubeComposite(ptRef.value), { instance: 'framatube.org', channel: 'toto' }, 'pt : le composite se redécoupe'); +ok('convention historique (2) PeerTube `instance|channel` : sans perte'); + +// (3) Odysee : claim LBRY +eq(buildChannelRef('od', { uploaderName: '@MaChaine#a' }), + { provider: 'od', scheme: 'od-claim', value: '@MaChaine#a' }, 'od : claim conservé tel quel'); +// Forme SANS `@` (arrivait par d'autres chemins) : la valeur canonique porte le `@`. +eq(buildChannelRef('od', { uploaderName: 'MaChaine#a' }), + { provider: 'od', scheme: 'od-claim', value: '@MaChaine#a' }, 'od : le @ manquant est restauré'); +ok('convention historique (3) Odysee claim LBRY : sans perte, forme canonique unique'); + +// Odysee : les deux usages du claim (resolve vs URL) passent par la source unique. +eq(odyseeClaimToResolveArg('MaChaine#a'), '@MaChaine#a', 'od : argument de resolve préfixé @'); +eq(odyseeClaimToResolveArg('@MaChaine#a'), '@MaChaine#a', 'od : déjà préfixé, inchange'); +eq(odyseeClaimToSlug('@MaChaine#a'), 'MaChaine#a', 'od : slug d\'URL sans @'); +eq(odyseeClaimToSlug('MaChaine#a'), 'MaChaine#a', 'od : slug d\'URL tolère l\'absence de @'); +eq(normalizeOdyseeClaim(normalizeOdyseeClaim('x')), '@x', 'od : normalisation idempotente (pas de @@)'); +ok('Odysee : un seul jeu de règles pour resolve et pour l\'URL'); + +// ---------- 8.2 (b) Parité stricte avec le front d'aujourd'hui ---------- +// On rejoue ICI les règles historiques écrites dans les adaptateurs front avant +// la phase 6. Si le serveur produit autre chose, le front (qui préfère désormais +// `channelRef`) afficherait un lien de chaîne différent d'avant : régression. + +function frontPtExternalId(it) { + let channelExternalId; + try { + const host = it?.url ? new URL(String(it.url)).hostname : ''; + if (host && it?.channelId) channelExternalId = `${host}|${it.channelId}`; + } catch {} + return channelExternalId; +} +function frontOdExternalId(it) { return it.uploaderName || undefined; } +function frontYtExternalId(it) { return it.channelExternalId || it.channelId || undefined; } +function frontDmExternalId(it) { return it.channelId || undefined; } +function frontTwExternalId(it) { return it.channelExternalId || it.channelHandle || undefined; } + +const fixtures = { + pt: [ + { url: 'https://framatube.org/videos/watch/abc', channelId: 'toto' }, + { url: 'https://tube.example.org/w/xyz', channelId: 'channel-42' }, + { url: 'https://miamutube.net/videos/watch/9', channelId: 'a.b_c' }, + // Cas dégradés : pas d'URL, pas de channelId → les deux côtés doivent ne rien inventer. + { url: 'https://x.org/w/1' }, + { channelId: 'orphelin' }, + { url: 'pas-une-url', channelId: 'c' }, + {}, + ], + od: [ + { uploaderName: '@MaChaine#a' }, + { uploaderName: '@1234' }, + { uploaderName: '' }, + { uploaderName: undefined }, + {}, + ], + yt: [ + { channelExternalId: 'UC1' }, + { channelId: 'UC2' }, + { channelExternalId: 'UC3', channelId: 'autre' }, + {}, + ], + dm: [{ channelId: 'x1abc' }, { channelId: '42' }, {}], + tw: [{ channelExternalId: 'streamer' }, { channelHandle: 'zz' }, { channelExternalId: 'a', channelHandle: 'b' }, {}], + ru: [{ channelExternalId: 'some-channel' }, { channelId: 'slug2' }, {}], +}; + +const fronts = { pt: frontPtExternalId, od: frontOdExternalId, yt: frontYtExternalId, dm: frontDmExternalId, tw: frontTwExternalId, ru: frontYtExternalId }; +for (const [pid, list] of Object.entries(fixtures)) { + for (const it of list) { + const ref = buildChannelRef(pid, it); + const front = fronts[pid](it); + if (!ref) { + eq(front ?? undefined, undefined, `${pid} : le front ne produisait rien, le serveur non plus (${JSON.stringify(it)})`); + continue; + } + // Odysee : le front d'hier renvoyait le claim BRUT. Il porte déjà `@` dans la + // réponse LBRY, donc la valeur canonique doit lui être identique. + expect(ref.value === front, + `${pid} : valeur serveur ≠ front pour ${JSON.stringify(it)} (serveur ${ref.value}, front ${front})`); + } +} +ok('pt / od / yt / dm / tw / ru : channelRef.value strictement identique au front d\'hier (33 fixtures)'); + +// Le cas dégénéré `od` sans `@` est le SEUL écart volontaire, et il est +// idempotent : une 2ᵉ passe ne change plus rien. +eq(buildChannelRef('od', { uploaderName: 'MaChaine#a' }).value, normalizeOdyseeClaim('MaChaine#a'), + 'od : l\'écart se stabilise après normalisation'); +ok('od : l\'écart de normalisation est stable (idempotent)'); + +// ---------- (c) Parité des schemes serveur <-> front ---------- +const tsPath = path.join(__dirname, '../../src/app/shared/providers/channel-ref.ts'); +const ts = fs.readFileSync(tsPath, 'utf8'); +const tsBlock = ts.match(/CHANNEL_REF_SCHEMES[^=]*=\s*\{([\s\S]*?)\}/); +expect(!!tsBlock, 'le front déclare CHANNEL_REF_SCHEMES'); +const tsSchemes = {}; +for (const m of tsBlock[1].matchAll(/(\w+)\s*:\s*'([\w-]+)'/g)) tsSchemes[m[1]] = m[2]; +eq(tsSchemes, { ...CHANNEL_REF_SCHEMES }, 'les schemes front et serveur sont identiques'); +ok('parité serveur <-> front des schemes vérifiée sur la source TypeScript'); + +// Le type du contrat doit exposer les mêmes 6 schemes que l'implémentation. +const union = ts.match(/type ChannelRefScheme\s*=\s*([^;]+);/); +expect(!!union, 'ChannelRefScheme est une union de littéraux'); +const unionSchemes = [...union[1].matchAll(/'([\w-]+)'/g)].map((m) => m[1]).sort(); +eq(unionSchemes, Object.values(CHANNEL_REF_SCHEMES).sort(), 'l\'union TypeScript couvre les 6 schemes'); +ok('l\'union TypeScript `ChannelRefScheme` couvre exactement les 6 schemes'); + +// ---------- (d) Aucune donnée inventée ---------- +eq(buildChannelRef('yt', {}), undefined, 'aucun identifiant => pas de ref'); +eq(buildChannelRef('yt', null), undefined, 'item null => pas de ref'); +eq(buildChannelRef('xx', { channelId: 'a' }), undefined, 'provider inconnu => pas de ref'); +eq(buildChannelRef('pt', { url: 'https://x.org/w/1' }), undefined, 'pt sans channelId => pas de ref composite bancal'); +eq(buildChannelRef('od', { uploaderName: ' ' }), undefined, 'claim vide => pas de ref'); +const noRef = { title: 'T', id: '1' }; +withChannelRefs('yt', [noRef]); +eq(Object.keys(noRef), ['title', 'id'], 'aucune clé channelRef ajoutée quand il n\'y a rien à dire'); +ok('rien n\'est inventé : pas de clé vide, pas de valeur devinée'); + +// ---------- withChannelRefs : pose le ref, ne casse rien ---------- +const items = withChannelRefs('yt', [ + { id: 'a', channelExternalId: 'UCa' }, + { id: 'b' }, + { id: 'c', channelId: 'UCc' }, +]); +eq(items[0].channelRef, { provider: 'yt', scheme: 'yt-uc', value: 'UCa' }, 'ref posé sur l\'item 1'); +eq('channelRef' in items[1], false, 'item sans identité : aucune clé ajoutée'); +eq(items[2].channelRef.value, 'UCc', 'ref posé depuis channelId'); +eq(withChannelRefs('yt', null), null, 'withChannelRefs tolère un non-tableau'); +ok('withChannelRefs annote sans muter la forme des items sans identité'); + +console.log(`\n channel-ref: ${passed} assertions OK`); diff --git a/server/tests/contract_version.test.mjs b/server/tests/contract_version.test.mjs new file mode 100644 index 0000000..382fb2f --- /dev/null +++ b/server/tests/contract_version.test.mjs @@ -0,0 +1,202 @@ +/** + * Phase 8.2 / 8.3 — contrat `Suggestion` v2 strict + feature flags `FF_`. + * + * Le module front `src/app/search/search-contract.ts` est réellement *chargé* + * (transpilé à la volée via esbuild) au lieu d'être relu par expression + * régulière : une regex passerait au travers d'un bug de logique alors qu'elle + * voit bien les mots-clés, ce qui est exactement le défaut qu'on traque ici. + * esbuild est une dépendance d'@angular/build ; si elle disparaît, le test + * échoue bruyamment plutôt que de devenir vert par accident. + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { + providerFlag, isProviderDisabled, partitionEnabledProviders, applyProviderFlags, +} from '../providers/feature-flags.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '../..'); + +/** Charge un module TypeScript du front dans le contexte node (esbuild). */ +async function loadTs(relPath) { + const abs = path.join(ROOT, relPath); + const esbuild = await import('esbuild'); + const { code } = esbuild.transformSync(fs.readFileSync(abs, 'utf8'), { + loader: 'ts', format: 'esm', target: 'node20', + }); + // `import type` est effacé par esbuild : le module n'a plus de dépendance. + const url = 'data:text/javascript;base64,' + Buffer.from(code, 'utf8').toString('base64'); + return import(url); +} + +const contract = await loadTs('src/app/search/search-contract.ts'); + +/** Capture les console.warn pendant l'appel à `fn`. */ +async function captureWarnings(fn) { + const original = console.warn; + const lines = []; + console.warn = (...a) => { lines.push(a.map(String).join(' ')); }; + try { return { value: await fn(), lines }; } + finally { console.warn = original; } +} + +// --- 8.2 : contrat v2 strict ------------------------------------------------ + +test('8.2 — v2 est accepté sans avertissement', async () => { + contract.resetContractWarningsForTests(); + const res = { v: 2, groups: { yt: [{ id: 'a' }] } }; + const { value, lines } = await captureWarnings(() => contract.readSearchGroup(res, 'yt')); + assert.equal(value.legacyContract, false); + assert.equal(value.contractVersion, 2); + assert.equal(value.items.length, 1); + assert.deepEqual(lines, [], 'un contrat à jour ne doit rien écrire dans la console'); +}); + +test('8.2 — v1 reste lisible (compat) mais est signalé', async () => { + contract.resetContractWarningsForTests(); + const res = { v: 1, groups: { ru: [{ id: 'x' }, { id: 'y' }] } }; + const { value, lines } = await captureWarnings(() => contract.readSearchGroup(res, 'ru')); + assert.equal(value.legacyContract, true, 'v1 doit être marqué legacy'); + assert.equal(value.items.length, 2, 'la compat ne doit pas perdre de résultats'); + assert.equal(lines.length, 1, 'un serveur obsolète doit être signalé'); + assert.match(lines[0], /contrat v1/); +}); + +test('8.2 — version absente = legacy (le plus probable est un proxy mal configuré)', async () => { + contract.resetContractWarningsForTests(); + const { value, lines } = await captureWarnings(() => contract.readSearchGroup({ groups: {} }, 'od')); + assert.equal(value.legacyContract, true); + assert.equal(value.contractVersion, null); + assert.match(lines[0], /contrat v\?/); +}); + +test('8.2 — l\'avertissement est émis une seule fois par process', async () => { + contract.resetContractWarningsForTests(); + const first = await captureWarnings(() => contract.readSearchGroup({ v: 1, groups: {} }, 'yt')); + const second = await captureWarnings(() => contract.readSearchGroup({ v: 1, groups: {} }, 'yt')); + const third = await captureWarnings(() => contract.readSearchGroup({ v: 1, groups: {} }, 'dm')); + assert.equal(first.lines.length, 1); + assert.deepEqual(second.lines, [], 'une 2e recherche ne doit pas respammer'); + assert.deepEqual(third.lines, [], 'le décompte est global, pas par provider'); +}); + +test('8.2 — un groupe absent ou malformé donne un tableau vide, jamais un throw', async () => { + contract.resetContractWarningsForTests(); + for (const res of [undefined, {}, { v: 2 }, { v: 2, groups: { yt: null } }, { v: 2, groups: { yt: 'nope' } }]) { + const r = contract.readSearchGroup(res, 'yt'); + assert.deepEqual(r.items, [], `cas ${JSON.stringify(res)}`); + } +}); + +test('8.2 — l\'erreur serveur du provider est remontée', async () => { + contract.resetContractWarningsForTests(); + const r = contract.readSearchGroup({ v: 2, groups: {}, errors: { pt: { message: 'upstream 503' } } }, 'pt'); + assert.equal(r.providerError, 'upstream 503'); +}); + +test('8.3 — un provider désactivé par FF n\'est PAS une erreur technique', async () => { + contract.resetContractWarningsForTests(); + const r = contract.readSearchGroup( + { v: 2, groups: {}, errors: { ru: { message: 'Provider désactivé par le feature flag FF_RU', code: 'disabled_by_ff' } } }, + 'ru', + ); + assert.equal(r.providerError, undefined, 'sinon l\'UI afficherait une panne pour un arrêt volontaire'); +}); + +// --- 8.3 : feature flags ---------------------------------------------------- + +const withEnv = (vars, fn) => { + const saved = {}; + for (const k of Object.keys(vars)) { + saved[k] = process.env[k]; + // `process.env[k] = undefined` stocke la chaîne « undefined » : il faut + // supprimer la clé pour truly simuler un flag absent. + if (vars[k] === undefined) delete process.env[k]; + else process.env[k] = vars[k]; + } + try { return fn(); } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; else process.env[k] = v; + } + } +}; + +test('8.3 — un flag absent laisse le provider actif (comportement historique)', () => { + withEnv({ FF_RU: undefined }, () => { + const f = providerFlag('ru'); + assert.equal(f.enabled, true); + assert.equal(f.set, false, '`set` distingue « non configuré » de « désactivé »'); + assert.equal(f.flag, 'FF_RU'); + }); +}); + +test('8.3 — les fausses valeurs usuelles désactivent le provider', () => { + for (const v of ['0', 'false', 'FALSE', 'off', 'no', 'disabled', ' 0 ', 'No']) { + withEnv({ FF_RU: v }, () => { + assert.equal(isProviderDisabled('ru'), true, `FF_RU=${JSON.stringify(v)} doit désactiver`); + }); + } +}); + +test('8.3 — les vraies valeurs usuelles gardent le provider actif', () => { + for (const v of ['1', 'true', 'TRUE', 'on', 'yes', 'enabled']) { + withEnv({ FF_RU: v }, () => { + assert.equal(isProviderDisabled('ru'), false, `FF_RU=${JSON.stringify(v)} doit rester actif`); + }); + } +}); + +test('8.3 — une variable vide est « non configurée », pas « désactivée »', () => { + // Piège classique en prod : un `FF_RU=` laissé vide dans un .env éteindrait + // silencieusement tout le provider. On privilégie le comportement historique. + withEnv({ FF_RU: ' ' }, () => { + const f = providerFlag('ru'); + assert.equal(f.enabled, true); + assert.equal(f.set, false); + }); +}); + +test('8.3 — les flags sont lus à chaque appel (pas de cache figé)', () => { + withEnv({ FF_RU: '1' }, () => assert.equal(isProviderDisabled('ru'), false)); + withEnv({ FF_RU: '0' }, () => assert.equal(isProviderDisabled('ru'), true)); + // Relu après bascule, sans redémarrage ni invalidation manuelle. + withEnv({ FF_RU: '1' }, () => assert.equal(isProviderDisabled('ru'), false)); +}); + +test('8.3 — le flag est insensibilisé à la casse de l\'id provider', () => { + withEnv({ FF_RU: '0' }, () => { + for (const id of ['ru', 'RU', ' Ru ']) assert.equal(isProviderDisabled(id), true, `id=${id}`); + }); +}); + +test('8.3 — partition : seuls les ids valides survivent, et on sait lesquels tombent', () => { + withEnv({ FF_RU: '0', FF_OD: '1' }, () => { + const { enabled, disabled } = partitionEnabledProviders(['yt', 'ru', 'od', 'dm']); + assert.deepEqual(enabled, ['yt', 'od', 'dm'], 'l\'ordre demandé est préservé'); + assert.deepEqual(disabled, [{ provider: 'ru', flag: 'FF_RU' }]); + }); +}); + +test('8.3 — applyProviderFlags produit une erreur `disabled_by_ff` explicable', () => { + withEnv({ FF_RU: 'off' }, () => { + const { providerIds, errors } = applyProviderFlags(['yt', 'ru', 'tw']); + assert.deepEqual(providerIds, ['yt', 'tw']); + assert.equal(errors.ru.code, 'disabled_by_ff'); + assert.match(errors.ru.message, /FF_RU/, 'le message doit nommer le flag : c\'est la piste n°1 en prod'); + assert.equal(errors.yt, undefined, 'un provider actif ne doit pas produire d\'erreur'); + }); +}); + +test('8.3 — entrée non tableau : dégradation sûre', () => { + withEnv({}, () => { + for (const bad of [undefined, null, 'yt', 42]) { + const { enabled, disabled } = partitionEnabledProviders(bad); + assert.deepEqual(enabled, []); + assert.deepEqual(disabled, []); + } + }); +}); diff --git a/server/tests/feature_flags_http.test.mjs b/server/tests/feature_flags_http.test.mjs new file mode 100644 index 0000000..a9fb863 --- /dev/null +++ b/server/tests/feature_flags_http.test.mjs @@ -0,0 +1,112 @@ +/** + * Phase 8.3 (intégration) — le câblage HTTP des feature flags. + * + * Les tests unitaires de `feature-flags.mjs` prouvent la sémantique des flags ; + * ceux-ci prouvent qu'ils sont *branchés* : que le provider éteint n'est pas + * appelé, qu'il est signalé dans la réponse, et que le diagnostic de santé ne le + * sonde pas. + * + * Méthode : on démarre le serveur avec les SIX flags à `0`. Un fan-out normal + * appellerait six upstreams ; ici il n'y a donc **aucun accès réseau**, ce qui + * rend le test déterministe (et le prouve : voir l'assertion de durée). + */ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import net from 'node:net'; +import { spawn } from 'node:child_process'; + +const ALL = ['yt', 'dm', 'tw', 'pt', 'od', 'ru']; +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-ff-')); +const PORT = await new Promise((resolve) => { + const s = net.createServer(); + s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); }); +}); +const base = `http://127.0.0.1:${PORT}`; + +const server = spawn(process.execPath, ['./server/index.mjs'], { + cwd: path.resolve(import.meta.dirname, '..', '..'), + env: { + ...process.env, + PORT: String(PORT), + NEWTUBE_DB_FILE: path.join(tmpDir, 'ff.db'), + JWT_SECRET: 'ff-test-secret', + NODE_ENV: 'test', + // Tous les providers éteints -> aucun appel upstream attendu. + ...Object.fromEntries(ALL.map((p) => [`FF_${p.toUpperCase()}`, '0'])), + // Coupe aussi l'enrichissement web des suggestions (sinon : réseau). + SUGGEST_WEB_ENABLED: '0', + }, + stdio: ['ignore', 'pipe', 'pipe'], +}); +let logs = ''; +server.stdout.on('data', (d) => { logs += d; }); +server.stderr.on('data', (d) => { logs += d; }); + +const J = async (url) => { + const r = await fetch(url); + return { status: r.status, body: await r.json().catch(() => ({})) }; +}; + +describe('Phase 8.3 — feature flags câblés sur le HTTP', () => { + before(async () => { + const t0 = Date.now(); + while (Date.now() - t0 < 25000) { + try { const r = await fetch(`${base}/api/health`); if (r.status < 500) return; } catch {} + await new Promise((r) => setTimeout(r, 300)); + } + assert.fail(`API ne démarre pas:\n${logs.slice(-3000)}`); + }); + + after(() => { + server.kill(); + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} + }); + + it('/api/search exclut les providers éteints du fan-out et les signale', async () => { + const t0 = Date.now(); + const { status, body } = await J(`${base}/api/search?q=test&providers=${ALL.join(',')}`); + const elapsed = Date.now() - t0; + + assert.equal(status, 200); + assert.equal(body.v, 2, 'la version de contrat reste annoncée'); + assert.deepEqual(body.providers, [], 'aucun provider ne doit rester dans le fan-out'); + for (const p of ALL) { + assert.deepEqual(body.groups[p], [], `${p} doit avoir un groupe vide et non absent`); + assert.equal(body.errors[p].code, 'disabled_by_ff', `${p} doit être signalé disabled_by_ff`); + assert.match(body.errors[p].message, new RegExp(`FF_${p.toUpperCase()}`), 'le message nomme le flag'); + } + // Preuve indirecte qu'aucun upstream n'a été contacté : un fan-out réel + // part en parallèle sur 6 services et dépasse largement ce seuil. + assert.ok(elapsed < 2000, `réponse en ${elapsed}ms : un provider éteint est probablement encore appelé`); + }); + + it('un provider éteint est absent de `providers` mais garde sa colonne', async () => { + const { body } = await J(`${base}/api/search?q=test&providers=yt,ru,od`); + assert.deepEqual(body.providers, [], 'tous les providers demandés sont éteints dans ce test'); + assert.ok('yt' in body.groups && 'ru' in body.groups && 'od' in body.groups, + 'le front doit pouvoir afficher une colonne pour chacun des providers demandés'); + }); + + it('/api/suggest applique les mêmes flags et signale la raison', async () => { + const { status, body } = await J(`${base}/api/search/suggest?q=test&providers=${ALL.join(',')}`); + assert.equal(status, 200); + assert.equal(body.errors.ru.code, 'disabled_by_ff'); + for (const p of ALL) assert.deepEqual(body.groups[p], [], `${p} doit être vide`); + }); + + it('/api/providers/health distingue « éteint » de « en panne »', async () => { + const { body } = await J(`${base}/api/providers/health?provider=ru`); + const ru = body.providers.ru; + assert.equal(ru.disabled, true); + assert.equal(ru.flag, 'FF_RU'); + assert.equal(ru.flagValue, '0'); + assert.equal(ru.lastError, 'disabled_by_ff'); + // Un provider éteint n'est pas « cassé » : ni erreur, ni latence mesurée. + assert.equal(ru.consecutiveFailures, 0); + assert.equal(ru.errorRate, 0); + assert.equal(ru.latencyMs, 0); + }); +}); diff --git a/server/tests/fixtures/provider-suggestions.json b/server/tests/fixtures/provider-suggestions.json new file mode 100644 index 0000000..242406f --- /dev/null +++ b/server/tests/fixtures/provider-suggestions.json @@ -0,0 +1,263 @@ +{ + "_comment": "FIGÉ PAR `npm run fixtures:record`. Ne pas éditer à la main : régénérer.", + "_contractVersion": 2, + "_recordedAt": "2026-09-30T00:23:23.598Z", + "_query": "tutorial", + "providers": { + "yt": { + "items": [ + { + "badges": [ + "Nouveau", + "4K" + ], + "channelExternalId": "UC9deX25Xus0U-Znt3_ILSpw", + "channelHandle": "360Jeezy", + "channelId": "UC9deX25Xus0U-Znt3_ILSpw", + "channelRef": { + "provider": "yt", + "scheme": "yt-uc", + "value": "UC9deX25Xus0U-Znt3_ILSpw" + }, + "channelUrl": "https://www.youtube.com/channel/UC9deX25Xus0U-Znt3_ILSpw", + "duration": 678, + "id": "RzmfAJUqDh0", + "thumbnail": "https://i.ytimg.com/vi/RzmfAJUqDh0/hq720.jpg?sqp=-oaymwEcCOgCEMoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLA0urkQ_b_2XIVuqpVA68sMKr1IIQ", + "title": "HAIRCUT TUTORIAL: WE CUT IT OFF! 360 WAVES | LOW TAPER on COARSE HAIR", + "type": "video", + "uploaderName": "360Jeezy", + "url": "https://www.youtube.com/watch?v=RzmfAJUqDh0", + "views": 6665 + }, + { + "channelExternalId": "UCp-oS-GBudIPdLrfV_EuSxQ", + "channelHandle": "Sabse Bada Maker", + "channelId": "UCp-oS-GBudIPdLrfV_EuSxQ", + "channelRef": { + "provider": "yt", + "scheme": "yt-uc", + "value": "UCp-oS-GBudIPdLrfV_EuSxQ" + }, + "channelUrl": "https://www.youtube.com/channel/UCp-oS-GBudIPdLrfV_EuSxQ", + "duration": 12, + "id": "wAKZjllBB0w", + "thumbnail": "https://i.ytimg.com/vi/wAKZjllBB0w/hq720.jpg?sqp=-oaymwE2COgCEMoBSFXyq4qpAygIARUAAIhCGABwAcABBvABAfgBtgiAAoAPigIMCAAQARhbIF8oZTAP&rs=AOn4CLCISuErJFs1JV7EDlMTxHf1NfRNMw", + "title": "Crazy mobile videography editing tutorial 📱👀 #shorts #tutorial", + "type": "video", + "uploaderName": "Sabse Bada Maker", + "url": "https://www.youtube.com/watch?v=wAKZjllBB0w", + "views": 38214938 + }, + { + "badges": [ + "4K" + ], + "channelExternalId": "UCXs6kLLJ8mhYVbu3OlC3dow", + "channelHandle": "BAKAR EDITORIAL ", + "channelId": "UCXs6kLLJ8mhYVbu3OlC3dow", + "channelRef": { + "provider": "yt", + "scheme": "yt-uc", + "value": "UCXs6kLLJ8mhYVbu3OlC3dow" + }, + "channelUrl": "https://www.youtube.com/channel/UCXs6kLLJ8mhYVbu3OlC3dow", + "duration": 17, + "id": "NI1iEBGNDb0", + "thumbnail": "https://i.ytimg.com/vi/NI1iEBGNDb0/hq720_2.jpg?sqp=-oaymwE2COgCEMoBSFXyq4qpAygIARUAAIhCGABwAcABBvABAfgBtgiAAu4LigIMCAAQARhbIFsoWzAP&rs=AOn4CLDIKfn0CDbxP3eX-zd6e9lzI6KogA", + "title": "Master Masking in CapCut Step-by-Step Editing Tutorial CapCut Explained Beginner to Pro Guide", + "type": "video", + "uploaderName": "BAKAR EDITORIAL ", + "url": "https://www.youtube.com/watch?v=NI1iEBGNDb0", + "views": 12428432 + } + ] + }, + "dm": { + "items": [ + { + "channelId": "x1avcry", + "channelRef": { + "provider": "dm", + "scheme": "dm-user", + "value": "x1avcry" + }, + "duration": 106, + "height": 768, + "id": "x1067b5", + "thumbnail": "https://s2.dmcdn.net/v/3dmw11g41PTBKxtO0/x720", + "title": "Tutorial Wunsch Tutorial", + "type": "video", + "uploadedDate": "2013-05-24T11:11:56.000Z", + "uploaderAvatar": "https://s1.dmcdn.net/u/4iK1k1gYtdQ4tt6Uv/80x80", + "uploaderName": "diebestentutorials", + "url": "https://www.dailymotion.com/video/x1067b5", + "views": 14, + "width": 1024 + }, + { + "channelId": "x1xewsq", + "channelRef": { + "provider": "dm", + "scheme": "dm-user", + "value": "x1xewsq" + }, + "duration": 99, + "height": 1040, + "id": "x6fsvsp", + "thumbnail": "https://s2.dmcdn.net/v/NDDRv1clhOzylBCZ-/x720", + "title": "Tutorial-Tutorial-คั่วกลิ้งหมูสับ", + "type": "video", + "uploadedDate": "2018-03-07T04:57:23.000Z", + "uploaderAvatar": "https://s1.dmcdn.net/u/6ymAA1gWyHxJ8up9q/80x80", + "uploaderName": "Nutthakit Boontrakan", + "url": "https://www.dailymotion.com/video/x6fsvsp", + "views": 8, + "width": 1040 + }, + { + "channelId": "x1kgnxy", + "channelRef": { + "provider": "dm", + "scheme": "dm-user", + "value": "x1kgnxy" + }, + "duration": 98, + "height": 720, + "id": "x36s5pb", + "thumbnail": "https://s1.dmcdn.net/v/BVRtV1e9wmzeEIfvy/x720", + "title": "[TUTORIAL] Audacity Acapella Tutorial", + "type": "video", + "uploadedDate": "2015-09-13T22:23:02.000Z", + "uploaderAvatar": "https://s1.dmcdn.net/u/5fnKs1gYDjmqATtPF/80x80", + "uploaderName": "Acapellas", + "url": "https://www.dailymotion.com/video/x36s5pb", + "views": 4, + "width": 1280 + } + ] + }, + "tw": { + "items": [], + "note": "aucun résultat au moment du gel — couverture non testée pour ce provider" + }, + "pt": { + "items": [ + { + "channelId": "stereo", + "channelRef": { + "provider": "pt", + "scheme": "pt-composite", + "value": "videoteca.kenobit.it|stereo" + }, + "duration": 599, + "id": "33e18a40-9bb1-4ab2-8cc6-211cc088a85b", + "kind": "vod", + "likes": 2, + "publishedAt": "2024-02-02T19:35:59.534Z", + "thumbnail": "https://videoteca.kenobit.it/static/thumbnails/b612fafc-5b64-42ff-bc10-26d922a70162.jpg", + "title": "Tutorial per uploader: come si carica un disco su STEREO?", + "type": "video", + "uploaderAvatar": "/lazy-static/avatars/b0d2f3c7-bb5b-46f5-a3ca-dc2276630c89.png", + "uploaderName": "Kenobit", + "url": "https://videoteca.kenobit.it/videos/watch/33e18a40-9bb1-4ab2-8cc6-211cc088a85b", + "viewCount": 74, + "views": 74 + }, + { + "channelAvatarUrl": "/lazy-static/avatars/84119f2d-ae55-4a05-b882-5cc2166a9b62.jpg", + "channelId": "learning", + "channelRef": { + "provider": "pt", + "scheme": "pt-composite", + "value": "fediverse.tv|learning" + }, + "duration": 170, + "id": "2149c7a9-5dc0-41b6-aa29-f19f88e20873", + "kind": "vod", + "language": "es", + "likes": 4, + "publishedAt": "2021-11-12T16:49:04.454Z", + "thumbnail": "https://fediverse.tv/lazy-static/thumbnails/2961e0bc-a9c9-4338-bdff-ac0fc326d827.jpg", + "title": "[TUTORIAL] - Como hacer un fedicorto", + "type": "video", + "uploaderAvatar": "/lazy-static/avatars/881f287d-1b9d-4609-8e75-93d75fdb7380.png", + "uploaderName": "spectrumgirl", + "url": "https://fediverse.tv/videos/watch/2149c7a9-5dc0-41b6-aa29-f19f88e20873", + "viewCount": 57, + "views": 57 + }, + { + "channelId": "51063_channel", + "channelRef": { + "provider": "pt", + "scheme": "pt-composite", + "value": "openmedia.edunova.it|51063_channel" + }, + "duration": 705, + "id": "e01c50d1-1035-4dec-927b-828187bdef91", + "kind": "vod", + "language": "it", + "likes": 0, + "publishedAt": "2024-11-02T15:40:01.069Z", + "thumbnail": "https://openmedia.edunova.it/lazy-static/thumbnails/d266b279-aaeb-4b7a-8fd7-0d7f1cd996c7.jpg", + "title": "tutorial", + "type": "video", + "uploaderName": "MARINA DE ANGELIS", + "url": "https://openmedia.edunova.it/videos/watch/e01c50d1-1035-4dec-927b-828187bdef91", + "viewCount": 21, + "views": 21 + } + ] + }, + "od": { + "items": [ + { + "channelRef": { + "provider": "od", + "scheme": "od-claim", + "value": "@AllThingsSecured" + }, + "duration": 527, + "id": "1c26fcfbd6e8b8acbee692b1d789e70c04d4366a", + "thumbnail": "https://thumbs.odycdn.com/11cd24cd9a5bff1d21c965d635a130c0.webp", + "title": "PGP Email Tutorial: How to Encrypt a Gmail Message", + "type": "video", + "uploaderName": "@AllThingsSecured", + "url": "https://odysee.com/PGP-Email-Tutorial_-How-to-Encrypt-a-Gmail-Message:1c26fcfbd6e8b8acbee692b1d789e70c04d4366a" + }, + { + "channelRef": { + "provider": "od", + "scheme": "od-claim", + "value": "@pacheldestructor" + }, + "duration": 398, + "id": "259776a24b1b646ba485fa9a2c3a9291503ead91", + "thumbnail": "https://thumbs.odycdn.com/219f40e4bdb4cd3b45c636d20757ce93.webp", + "title": "Tutorial Color Dino", + "type": "video", + "uploaderName": "@pacheldestructor", + "url": "https://odysee.com/Tutorial_Color_Dino:259776a24b1b646ba485fa9a2c3a9291503ead91" + }, + { + "channelRef": { + "provider": "od", + "scheme": "od-claim", + "value": "@Cahlen" + }, + "duration": 142, + "id": "2a248bebcfaed005a0f56afabf22f01e9e507b08", + "thumbnail": "https://thumbs.odycdn.com/bc956cd079fc5c30567f369728c79121.webp", + "title": "Plasma Machine Video Tutorial", + "type": "video", + "uploaderName": "@Cahlen", + "url": "https://odysee.com/CahlenLee_20260915_PlasmaMachineVideoTutorial:2a248bebcfaed005a0f56afabf22f01e9e507b08" + } + ] + }, + "ru": { + "items": [], + "note": "erreur au gel : rumble_unavailable" + } + } +} diff --git a/server/tests/generate-provider-doc.mjs b/server/tests/generate-provider-doc.mjs new file mode 100644 index 0000000..aa53b2e --- /dev/null +++ b/server/tests/generate-provider-doc.mjs @@ -0,0 +1,103 @@ +/** + * Phase 8.5 — documentation GÉNÉRÉE depuis les fixtures gelées. + * + * Produit la matrice « champ ↔ provider » à partir de ce que les adaptateurs + * émettent RÉELLEMENT, et l'injecte entre deux marqueurs dans + * `docs/ingestion-catalogue-video-par-fournisseur.md`. + * + * Raison d'être : une matrice écrite à la main devient fausse au premier + * adaptateur modifié, et personne ne le remarque — c'est le pire sort pour une + * doc de référence. Générée, elle ne peut qu'être à jour ou absente. + * + * La section générée est donc explicitement marquée comme telle, et `npm run + * test:shapes` échoue si elle n'est plus synchronisée (dérive = doc fausse). + */ +import fs from 'node:fs'; +import path from 'node:path'; + +const ROOT = path.resolve(import.meta.dirname, '../..'); +const FIXTURES = path.join(ROOT, 'server/tests/fixtures/provider-suggestions.json'); +const DOC = path.join(ROOT, 'docs/ingestion-catalogue-video-par-fournisseur.md'); +const BEGIN = ''; +const END = ''; + +const data = JSON.parse(fs.readFileSync(FIXTURES, 'utf8')); +const ids = Object.keys(data.providers); +const labels = { yt: 'YouTube', dm: 'Dailymotion', tw: 'Twitch', pt: 'PeerTube', od: 'Odysee', ru: 'Rumble' }; + +// Champs d'intérêt métier, dans un ordre de lecture stable (le tri +// alphabétique de `Object.keys` n'est pas l'ordre du contrat). +const FIELDS = [ + ['duration', 'durée', 'secondes'], + ['views', 'vues', 'nombre'], + ['likes', 'likes', 'nombre'], + ['publishedAt', 'publication', 'date ISO'], + ['thumbnail', 'vignette', 'URL'], + ['uploaderName', 'chaîne', 'texte'], + ['channelRef', 'identité chaîne', 'scheme + value'], + ['type', 'type', 'video / live / short'], + ['kind', 'kind', 'vod / live / clip / channel'], + ['isLive', 'direct', 'booléen'], + ['width', 'largeur', 'px'], + ['height', 'hauteur', 'px'], + ['language', 'langue', 'code'], +]; + +const mark = (present) => (present ? 'x' : '·'); + +const lines = []; +lines.push(BEGIN); +lines.push(''); +lines.push(`> Section **générée** par \`npm run doc:providers\` depuis \`server/tests/fixtures/provider-suggestions.json\``); +lines.push(`> (gel du ${data._recordedAt.slice(0, 10)}, requête \`${data._query}\`). Ne pas éditer à la main.`); +lines.push(''); +lines.push(`Legende : \`x\` = émis dans le gel, \`·\` = absent (donnée inconnue, donc \`undefined\` côté front).`); +lines.push(''); + +const head = ['Champ', 'Type', ...ids.map((id) => labels[id] || id)]; +lines.push(`| ${head.join(' | ')} |`); +lines.push(`|${head.map(() => '---').join('|')}|`); + +for (const [field, label, type] of FIELDS) { + const cells = ids.map((id) => { + const items = data.providers[id]?.items || []; + // Sur un provider sans fixture, on ne peut rien affirmer : ni x ni ·. + if (items.length === 0) return '?'; + return mark(items.some((it) => it[field] !== undefined)); + }); + lines.push(`| \`${field}\` (${label}) | ${type} | ${cells.join(' | ')} |`); +} + +lines.push(''); +lines.push('Couverture du gel :'); +lines.push(''); +for (const id of ids) { + const entry = data.providers[id]; + const n = entry.items.length; + lines.push(n + ? `- **${labels[id] || id}** : ${n} item(s) vérifié(s).` + : `- **${labels[id] || id}** : *non couvert* — ${entry.note || 'aucune fixture'}.`); +} +lines.push(''); +lines.push('(`?` = provider sans fixture au gel : la matrice ne prétend rien sur lui.)'); +lines.push(''); +lines.push(END); + +const block = lines.join('\n'); +const doc = fs.readFileSync(DOC, 'utf8'); + +if (doc.includes(BEGIN)) { + const from = doc.indexOf(BEGIN); + const to = doc.indexOf(END) + END.length; + fs.writeFileSync(DOC, doc.slice(0, from) + block + doc.slice(to)); + console.log('matrice régénérée (remplacement)'); +} else { + // Ancre : juste après le titre de la section correspondente. + const anchor = '## 4. Catalogue'; + const at = doc.indexOf(anchor); + if (at < 0) throw new Error(`ancre « ${anchor} » introuvable dans ${path.basename(DOC)}`); + const insertAt = doc.indexOf('\n', at) + 1; + fs.writeFileSync(DOC, `${doc.slice(0, insertAt)}\n${block}\n${doc.slice(insertAt)}`); + console.log('matrice générée (insertion)'); +} +console.log(`→ ${path.relative(process.cwd(), DOC)}`); diff --git a/server/tests/lib/suggestion-shape.mjs b/server/tests/lib/suggestion-shape.mjs new file mode 100644 index 0000000..c2b498b --- /dev/null +++ b/server/tests/lib/suggestion-shape.mjs @@ -0,0 +1,105 @@ +/** + * Validateur de forme d'une `Suggestion` — source unique. + * + * Partagé par `provider-contract.test.mjs` (invariants sur objets synthétiques) + * et `provider_shapes.test.mjs` (fixtures gelées issues des 6 adaptateurs + * réels). Deux définitions du contrat finiraient par diverger, et c'est + * précisément la divergence qu'on veut détecter. + * + * Règle centrale (phase 2) : une métadonnée absente est `undefined`, jamais `0`. + * Un `0` inventé s'affiche dans l'UI comme une donnée réelle. + */ + +/** Champs textuels : chaîne non vide, ou absents. */ +const STRING_FIELDS = [ + 'url', 'thumbnail', 'uploaderName', 'uploaderAvatar', 'channelId', 'slug', + 'channelUrl', 'language', 'channelExternalId', 'channelHandle', 'channel', + 'kind', 'game', 'durationRaw', +]; +/** + * Champs numériques où 0 est impossible : un 0 y est un bug ou un mensonge. + * `duration: 0` rendait notamment la détection de Short inopérante + * (« verticale sans durée connue » ne doit pas être classée short). + */ +const STRICTLY_POSITIVE_FIELDS = ['duration', 'width', 'height']; +/** + * Champs de comptage où 0 est une VRAIE information : l'API peut legitimately + * répondre « 0 like » (PeerTube le fait). Traduire ce 0 en `undefined` + * confondrait « personne n'a aimé » avec « l'API n'a rien dit » — on perdrait + * l'information au lieu de la protéger. + * + * L'invariant protégé reste : ABSENT reste absent. Ce qui est interdit, c'est + * d'inventer un 0, pas de rencontrer un 0. + */ +const COUNT_FIELDS = ['views', 'likes', 'dislikes', 'viewers', 'viewCountRaw']; +const BOOLEAN_FIELDS = ['isShort', 'isLive', 'hasSubtitles', 'embeddable']; + +/** + * Schemes d'identité de chaîne connus (phase 6). + * + * `yt-uc` et non `yt-channel` : c'est le scheme réellement émis par + * l'adaptateur YouTube (canal = identifiant `UC…`). Le nommer autrement + * donnerait l'illusion que le front sait le lire. + */ +const CHANNEL_REF_SCHEMES = new Set(['yt-uc', 'pt-composite', 'od-claim', 'tw-login', 'ru-slug', 'dm-user']); + +/** + * Valide un objet `Suggestion`. + * @param {any} s + * @param {string} label contexte pour le message d'erreur + * @param {(msg: string) => void} expect + * @returns {string[]} messages d'erreur ([] si conforme) + */ +export function suggestionShapeErrors(s, label, expect) { + expect(typeof s?.title === 'string', `${label}: title is a string`); + expect(typeof s?.id === 'string' && s.id.length > 0, `${label}: id is a non-empty string`); + + for (const f of STRICTLY_POSITIVE_FIELDS) { + if (s?.[f] !== undefined) { + expect(typeof s[f] === 'number' && Number.isFinite(s[f]), `${label}: ${f} is a finite number`); + expect(s[f] > 0, `${label}: ${f} is strictly > 0 (never 0)`); + } + } + for (const f of COUNT_FIELDS) { + if (s?.[f] !== undefined) { + expect(typeof s[f] === 'number' && Number.isFinite(s[f]), `${label}: ${f} is a finite number`); + expect(s[f] >= 0, `${label}: ${f} is >= 0 (an explicit 0 from the upstream is real data)`); + } + } + if (s?.publishedAt !== undefined) { + expect(typeof s.publishedAt === 'string', `${label}: publishedAt is a string`); + const t = Date.parse(s.publishedAt); + expect(Number.isFinite(t), `${label}: publishedAt is parseable as a date`); + // Piège historique : « 2 months ago » n'est pas une date. + expect(!/ago|il y a/i.test(s.publishedAt), `${label}: publishedAt is not a relative label`); + expect(t > 0 && t < 8.64e15, `${label}: publishedAt is in a sane range`); + } + for (const f of STRING_FIELDS) { + if (s?.[f] !== undefined) { + expect(typeof s[f] === 'string' && s[f].length > 0, `${label}: ${f} is a non-empty string when present`); + } + } + if (s?.tags !== undefined) { + expect(Array.isArray(s.tags), `${label}: tags is an array when present`); + } + for (const f of BOOLEAN_FIELDS) { + if (s?.[f] !== undefined) expect(typeof s[f] === 'boolean', `${label}: ${f} is a boolean when present`); + } + // Phase 6 : `channelRef` est additif, mais s'il est présent il doit être + // exploitable tel quel par le front. Forme réelle = objet `{ provider, + // scheme, value }` (et non une chaîne) : c'est ce que les 6 adaptateurs + // émettent, vérifié sur fixtures gelées. + if (s?.channelRef !== undefined) { + const ref = s.channelRef; + expect(ref && typeof ref === 'object' && !Array.isArray(ref), `${label}: channelRef est un objet`); + expect(typeof ref.provider === 'string' && ref.provider.length > 0, `${label}: channelRef.provider est une chaîne non vide`); + expect(typeof ref.scheme === 'string' && ref.scheme.length > 0, `${label}: channelRef.scheme est une chaîne non vide`); + expect(typeof ref.value === 'string' && ref.value.length > 0, `${label}: channelRef.value est une chaîne non vide`); + expect(CHANNEL_REF_SCHEMES.has(ref.scheme), `${label}: channelRef.scheme inconnu « ${ref.scheme} »`); + expect(!/\s/.test(ref.value), `${label}: channelRef.value sans espace`); + } + return []; +} + +/** Schémas connus, exportés pour la génération de doc (phase 8.5). */ +export { CHANNEL_REF_SCHEMES, STRING_FIELDS, STRICTLY_POSITIVE_FIELDS, COUNT_FIELDS, BOOLEAN_FIELDS }; diff --git a/server/tests/page_tokens.test.mjs b/server/tests/page_tokens.test.mjs new file mode 100644 index 0000000..31d741c --- /dev/null +++ b/server/tests/page_tokens.test.mjs @@ -0,0 +1,84 @@ +/** + * Phase 3.12 — persistance des `pageToken` YouTube en base. + * + * Le test porte sur le comportement observable, pas sur l'implémentation : + * un jeton écrit par un « process A » doit être relisible par un « process B » + * qui ne partage aucun état mémoire. C'est exactement ce qui échouait avec la + * Map process-local — et ce que le redémarrage en production provocait. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-tokens-')); +process.env.NEWTUBE_DB_FILE = path.join(tmpDir, 'tokens.db'); + +const db = await import('../db.mjs'); + +test('3.12 — un jeton écrit est relu par un autre process (L2 partagé)', () => { + const key = 'yt|UCtest|pl|24'; + assert.equal(db.getCachedPageTokens(key), null, 'clé absente au départ'); + + assert.equal(db.setCachedPageTokens(key, [undefined, 'TOKEN_PAGE_2', 'TOKEN_PAGE_3']), true); + + // On repart d'un cache mémoire vierge : c'est le cas d'un process neuf ou d'un + // pod différent. Seul le L2 peut répondre ici. + const reread = db.getCachedPageTokens(key); + assert.ok(Array.isArray(reread), 'le jeton doit survivre hors du cache mémoire'); + // Indexation : `tokens[n]` = jeton pour la page n+2 (l'index 1 est donc la + // page 2). Un trou est matérialisé par `''`, jamais par un décalage : si le + // round-trip compressait le tableau, le jeton de la page 2 se retrouverait à + // l'index 0 et la page 3 renverrait celui de la page 4. + assert.equal(reread[0], '', 'le trou de l\'index 0 reste un trou, pas un décalage'); + assert.equal(reread[1], 'TOKEN_PAGE_2', 'tokens[1] = jeton de la page 2'); + assert.equal(reread[2], 'TOKEN_PAGE_3', 'tokens[2] = jeton de la page 3'); +}); + +test('3.12 — un jeton absent reste absent (jamais de token inventé)', () => { + assert.equal(db.getCachedPageTokens('yt|UCinconnu|pl|24'), null); +}); + +test('3.12 — un jeton expiré n\'est pas resservi', () => { + const key = 'yt|UCexpire|pl|24'; + db.setCachedPageTokens(key, ['TOK'], 1); + // TTL de 1 ms : la ligne est déjà hors délai au tour suivant. + assert.equal(db.getCachedPageTokens(key), null, 'un jeton expiré doit être traité comme absent'); + // Et la ligne doit avoir été retirée, pas laissée traîner. + assert.equal(db.getCachedPageTokens(key), null); +}); + +test('3.12 — une écriture vide est refusée (pas de ligne inutile)', () => { + assert.equal(db.setCachedPageTokens('yt|vide|pl|24', []), false); + assert.equal(db.getCachedPageTokens('yt|vide|pl|24'), null); +}); + +test('3.12 — les jetons ne se confondent pas avec les résultats de recherche', () => { + // Namespace distinct : sinon le plafond par fournisseur et les stats de cache + // du fournisseur `yt` seraient faussés par des jetons qui ne sont pas des + // résultats. + const key = 'collision|1'; + db.setCachedPageTokens(key, ['TOK_A']); + // `setCachedSearch` n'écrit que des tableaux d'items, jamais sous ce provider. + assert.equal(db.setCachedSearch('yt_tokens', key, 'q', ['item'], 'api'), true); + // Le même cache_key sous deux providers ne se chevauche pas : la lecture + // page_token et la lecture search restent distinctes. + assert.ok(db.getCachedPageTokens(key), 'le jeton reste lisible'); +}); + +test('3.12 — une chaîne de jetons se complète sans écraser les pages précédentes', () => { + const key = 'yt|UCchain|pl|24'; + // Page 1 écrite seule, puis page 2 : le tableau doit garder les deux. + db.setCachedPageTokens(key, ['TOK_PAGE_2']); + assert.equal(db.getCachedPageTokens(key)[0], 'TOK_PAGE_2'); + db.setCachedPageTokens(key, ['TOK_PAGE_2', 'TOK_PAGE_3']); + const arr = db.getCachedPageTokens(key); + assert.equal(arr.length, 2); + assert.equal(arr[0], 'TOK_PAGE_2', 'la page 2 ne doit pas disparaître'); + assert.equal(arr[1], 'TOK_PAGE_3'); +}); + +test.after(() => { + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} +}); diff --git a/server/tests/provenance.test.mjs b/server/tests/provenance.test.mjs new file mode 100644 index 0000000..d845e8a --- /dev/null +++ b/server/tests/provenance.test.mjs @@ -0,0 +1,144 @@ +/** + * Phase 7.3 — provenance des suggestions (`capturedAt` / `source`). + * + * Ce qui est réellement vérifié, c'est la HONNÊTETÉ de la fraîcheur : + * - un résultat vivant est estampillé à l'instant de la collecte ; + * - un résultat servi par le cache conserve l'instant du VRAI appel amont, + * pas l'heure de lecture du cache (sinon « à l'instant » sur du vieux data) ; + * - une entrée de cache écrite par un serveur antérieur à la phase 7.3 (donc + * sans provenance) récupère son `createdAt` en base. + * + * Aucun réseau : `globalThis.fetch` est stubé, et le provider testé est + * `dm` (Dailymotion), l'un de ceux enveloppés par le cache générique. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-prov-')); +process.env.NEWTUBE_DB_FILE = path.join(tmpDir, 'prov.db'); + +const db = await import('../db.mjs'); +const { providerRegistry, stampProvenance } = await import('../providers/registry.mjs'); + +const calls = []; +function installFetch() { + const prev = globalThis.fetch; + calls.length = 0; + globalThis.fetch = async (url) => { + calls.push(String(url)); + return { + ok: true, + status: 200, + json: async () => ({ + page: 1, + total: 1, + list: [{ + id: 'x1dm', + created_time: 1700000000, + duration: 300, + views_total: 1234, + owner: { screenname: 'DM User', username: 'dmuser', avatar_720_url: 'https://img/a.png' }, + title: 'Vidéo Dailymotion', + description: 'une description', + url: 'https://www.dailymotion.com/video/x1dm', + }], + }), + }; + }; + return () => { globalThis.fetch = prev; }; +} + +const dm = providerRegistry.dm; +const OPTS = { limit: 5, page: 1, sort: 'relevance' }; + +test('7.3 — un résultat vivant porte capturedAt + source', async (t) => { + const restore = installFetch(); + t.after(restore); + + const before = Date.now(); + const items = await dm.search('provenance vivante', OPTS); + assert.ok(items.length > 0, 'la recherche stubée doit produire des résultats'); + const at = Number(items[0].capturedAt); + assert.ok(Number.isFinite(at) && at >= before, `capturedAt epoch ms plausible (${at})`); + assert.ok(at <= Date.now(), 'capturedAt ne peut pas être dans le futur'); + assert.equal(items[0].source, 'api', 'voie de collecte explicite'); + // Provenance sur TOUS les items, pas seulement le premier. + assert.ok(items.every((i) => i.capturedAt && i.source), 'chaque item est estampillé'); +}); + +test('7.3 — un hit de cache garde le capturedAt du VRAI appel amont', async (t) => { + const restore = installFetch(); + t.after(restore); + + const first = await dm.search('provenance cache', OPTS); + const firstAt = Number(first[0].capturedAt); + const networkCallsAfterFirst = calls.length; + assert.ok(networkCallsAfterFirst > 0, 'le premier appel est bien allé au réseau'); + + // Deuxième appel identique : servi par le cache, aucun nouvel appel amont. + const second = await dm.search('provenance cache', OPTS); + assert.equal(calls.length, networkCallsAfterFirst, 'aucun appel amont sur un hit de cache'); + assert.equal(Number(second[0].capturedAt), firstAt, + 'la fraîcheur affichée est celle de la DONNÉE, pas celle de la lecture du cache'); +}); + +test('7.3 — une entrée de cache sans provenance récupère son createdAt', async (t) => { + const restore = installFetch(); + t.after(restore); + + // Simule une ligne écrite par un serveur antérieur à la phase 7.3 : payload + // sans `capturedAt`/`source`. + const q = 'provenance legacy'; + const { hashSearchKey } = await import('../providers/youtube-common.mjs'); + const filterSig = ''; + const key = `dm|${hashSearchKey(`${q.toLowerCase().trim()}|5|1|relevance|${filterSig}`)}`; + assert.equal(db.setCachedSearch('dm', key, q, [{ id: 'legacy1', title: 'vieux' }], 'api'), true); + + const items = await dm.search(q, OPTS); + assert.equal(calls.length, 0, 'la ligne préexistante est servie sans réseau'); + assert.equal(items[0].id, 'legacy1'); + assert.ok(Number.isFinite(Number(items[0].capturedAt)) && Number(items[0].capturedAt) > 0, + 'createdAt en base comble le champ manquant'); + assert.equal(items[0].source, 'api', 'source de la ligne de cache'); +}); + +test('7.3 — la voie réelle de l\'adaptateur n\'est jamais réécrite', () => { + // YouTube connaît sa propre voie (`innertube` / `scrape`) : la passe de + // provenance ne doit pas la surcharger avec la valeur par défaut `api`. + const items = stampProvenance([{ id: 'yt1', source: 'innertube', capturedAt: 1700000000000 }], 'api'); + assert.equal(items[0].source, 'innertube', 'source existante conservée'); + assert.equal(Number(items[0].capturedAt), 1700000000000, 'capturedAt existant conservé'); +}); + +test('7.3 — hit de cache : source `cache` + instant de la lecture', () => { + const items = stampProvenance([{ id: 'a' }, { id: 'b' }], 'cache', 1700000000000); + assert.ok(items.every((i) => i.source === 'cache'), 'tous les items sont marqués'); + assert.ok(items.every((i) => Number(i.capturedAt) === 1700000000000), 'instant appliqué à tous'); +}); + +test('7.3 — payload invalide toléré (jamais de 0 ni de date bidon)', () => { + // `0` n'est pas une epoch ms, `''` n'est pas une voie de collecte : les deux + // doivent être remplacés, sinon l'IHB affiche « il y a 0 s » sur du vide. + const items = stampProvenance([ + { id: 'x', capturedAt: 0, source: '' }, + { id: 'y', capturedAt: -1, source: ' ' }, + { id: 'z', capturedAt: NaN }, + ], 'api', 1700000000000); + for (const it of items) { + assert.equal(Number(it.capturedAt), 1700000000000, `${it.id} : capturedAt replaced`); + assert.equal(it.source, 'api', `${it.id} : source replaced`); + } +}); + +test('7.3 — entrée vide / non tableau toléré', () => { + assert.deepEqual(stampProvenance([], 'api'), []); + assert.equal(stampProvenance(null, 'api'), null); + assert.equal(stampProvenance(undefined, 'api'), undefined); +}); + +test.after(() => { + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} +}); diff --git a/server/tests/provider-contract.test.mjs b/server/tests/provider-contract.test.mjs new file mode 100644 index 0000000..37eba1d --- /dev/null +++ b/server/tests/provider-contract.test.mjs @@ -0,0 +1,165 @@ +// Phase 0 §2.6 — Contrat `Suggestion` v2. +// +// Run with: npm run test:contract +// +// Pourquoi ce test existe : avant, les champs `views` / `publishedAt` / `channelId` +// / `uploaderAvatar` étaient bien récupérés par certains providers, puis perdus +// silencieusement en route (anomalies #3, #7). Rien ne le détectait : le code +// « fonctionnait », la grille restait vide. Ce test fige les invariants. +// +// 100% offline : aucun appel réseau. On teste (a) le contrat déclaré, (b) les +// parseurs HTML/JSON en local, (c) la parité serveur <-> front. + +import { + SUGGESTION_CONTRACT_VERSION, + SUGGESTION_V2_FIELDS, + providerRegistry, + validateProviders, +} from '../providers/registry.mjs'; +import { parseRelativeDate } from '../providers/youtube-innertube.mjs'; +import { isShortItem, isLiveItem, itemDurationSec, itemPublishedTs } from '../search-filters.mjs'; +import { suggestionShapeErrors } from './lib/suggestion-shape.mjs'; + +let passed = 0; +function expect(cond, msg) { if (!cond) throw new Error(`Assertion failed: ${msg}`); } +function eq(actual, expected, msg) { + const a = JSON.stringify(actual); const b = JSON.stringify(expected); + if (a !== b) throw new Error(`Assertion failed: ${msg} (expected ${b}, got ${a})`); +} +function ok(msg) { passed++; console.log(` V ${msg}`); } + +// ---------- 0.1 Le contrat est versionné ---------- +eq(SUGGESTION_CONTRACT_VERSION, 2, 'contract version is 2'); +ok('contract is versioned (v=2)'); + +expect(Array.isArray(SUGGESTION_V2_FIELDS) && SUGGESTION_V2_FIELDS.length > 0, 'SUGGESTION_V2_FIELDS is a non-empty list'); +for (const f of SUGGESTION_V2_FIELDS) expect(typeof f === 'string' && f.length > 0, `field "${f}" is a non-empty string`); +ok(`v2 declares ${SUGGESTION_V2_FIELDS.length} optional fields`); + +// ---------- 0.2 Les 6 providers sont bien enregistrés ---------- +for (const id of ['yt', 'dm', 'tw', 'pt', 'od', 'ru']) { + expect(providerRegistry[id], `provider "${id}" is registered`); + expect(typeof providerRegistry[id].search === 'function', `provider "${id}" exposes search()`); + expect(typeof providerRegistry[id].label === 'string' && providerRegistry[id].label.length > 0, `provider "${id}" has a label`); +} +ok('all 6 providers registered with search() + label'); + +eq(validateProviders('').sort(), ['dm', 'od', 'pt', 'ru', 'tw', 'yt'], 'empty providers -> all 6'); +eq(validateProviders('yt,bogus,YT').sort(), ['yt'], 'unknown + duplicates + case are normalized'); +ok('validateProviders normalizes unknown/duplicate/case'); + +// ---------- 0.3-0.6 Invariants sur des Suggestions synthétiques ---------- +// Ce sont les règles que TOUT provider doit respecter. On les applique à un objet +// « bien formé » puis à ses variantes dégradées, comme le ferait la grille. + +// Validateur de contrat — source unique dans lib/suggestion-shape.mjs, partagee +// avec provider_shapes.test.mjs (phase 8.4). Deux copies du contrat finitissent par +// diverger, et c'est precisement la divergence qu'on cherche a detecter. +const assertSuggestion = (s, label) => suggestionShapeErrors(s, label, expect) && true; + +const good = { + title: 'Test', id: 'abc', url: 'https://example.com/v/abc', thumbnail: 'https://example.com/t.jpg', + uploaderName: 'Chaîne', uploaderAvatar: 'https://example.com/a.jpg', duration: 212, + views: 1234, likes: 56, publishedAt: '2026-01-17T10:30:00.000Z', type: 'video', + width: 1080, height: 1920, channelId: 'UC123', channelExternalId: 'UC123', language: 'fr', + tags: ['a', 'b'], isLive: false, hasSubtitles: true, embeddable: true, +}; +assertSuggestion(good, 'well-formed'); +ok('well-formed Suggestion passes the contract validator'); + +// Champs absents => strictement absents (pas de 0, pas de '') +const minimal = { title: 'Test', id: 'abc' }; +assertSuggestion(minimal, 'minimal'); +eq(Object.keys(minimal), ['title', 'id'], 'a minimal Suggestion carries no nullish junk keys'); +ok('minimal Suggestion stays minimal (no invented zeroes)'); + +// `duration: 0` est le bug historique : il rendait la règle +// « verticale sans durée connue => pas un short » inopérante. +const zeroDuration = { title: 'T', id: 'i', duration: 0 }; +try { assertSuggestion(zeroDuration, 'zero-duration'); throw new Error('Assertion failed: duration:0 should have been rejected'); } +catch (e) { expect(String(e.message).includes('duration is strictly > 0'), 'duration:0 is rejected with a clear message'); } +ok('duration:0 is rejected (the phase 1/2 bug class)'); + +// Distinction affinée en phase 8.4, en gelant la sortie réelle des providers : +// un COMPTEUR à 0 est une donnée vraie (« 0 like »), pas un mensonge. PeerTube +// renvoie `likes: 0` legitimately ; le rejeter en `undefined` ferait perdre +// l'information. Ce qui reste interdit, c'est d'inventer un 0 : l'ABSENT doit +// rester absent. +assertSuggestion({ title: 'T', id: 'i', views: 0, likes: 0, dislikes: 0, viewers: 0 }, 'zero-counts'); +ok('an explicit 0 on a counter is accepted (real data, not an invented zero)'); +const zeroDims = { title: 'T', id: 'i', width: 0, height: 0 }; +try { assertSuggestion(zeroDims, 'zero-dims'); throw new Error('Assertion failed: width:0 should have been rejected'); } +catch (e) { expect(String(e.message).includes('is strictly > 0'), 'width:0 is rejected'); } +ok('0 on width/height stays rejected (a 0-sized video does not exist)'); + +// ---------- 0.7-0.8 Classification : parité des règles ---------- +// Une verticale sans durée connue ne doit PAS être classée short. +eq(isShortItem({ type: 'video', width: 1080, height: 1920, duration: undefined }), false, + 'portrait WITHOUT duration is not a short (absent duration is not evidence)'); +eq(isShortItem({ type: 'video', width: 1080, height: 1920, duration: 30 }), true, + 'portrait + 30 s is a short'); +eq(isShortItem({ type: 'video', width: 1920, height: 1080, duration: 30 }), false, + 'landscape + 30 s is NOT a short (duration alone is not enough)'); +eq(isShortItem({ type: 'video', isShort: true }), true, 'explicit provider isShort wins'); +ok('isShortItem: no duration => no short, and portrait is required'); + +// Règles (a)/(b)/(c) de la tâche 2.3 — ces trois cas doivent être identiques +// côté serveur ET côté front (`isShortVideo`), sinon la pastille SHORT apparaît +// dans la grille puis disparaît au changement d'onglet. +eq(isShortItem({ isShort: true }), true, '(a) native flag + unknown duration => short'); +eq(isShortItem({ isShort: true, width: 1080, height: 1080, duration: 60 }), true, + '(b) square 60 s + native flag => short (flag primes over orientation)'); +eq(isShortItem({ width: 1080, height: 1080, duration: 60 }), false, + '(b) square 60 s WITHOUT a flag => not a short (duration alone never qualifies)'); +eq(isShortItem({ kind: 'clip', type: 'video', duration: 42 }), true, + '(c) kind:clip beats type:video'); +eq(isShortItem({ url: 'https://www.youtube.com/shorts/abcdefghijk', duration: 30 }), true, + '/shorts/ URL marker is honoured (parity with the front)'); +ok('rules (a)/(b)/(c) hold, including the /shorts/ URL marker'); + +// itemDurationSec doit distinguer 0 de undefined. +eq(itemDurationSec({ duration: 0 }), undefined, 'itemDurationSec maps 0 -> undefined'); +eq(itemDurationSec({ duration: 30 }), 30, 'itemDurationSec keeps a real duration'); +eq(itemDurationSec({ durationSec: 42 }), 42, 'itemDurationSec reads durationSec too'); +ok('itemDurationSec never leaks a 0 duration'); + +// Une date relative ne doit jamais arriver jusqu'ici (bug InnerTube 1.9). +eq(itemPublishedTs({ publishedAt: '2 months ago' }), 0, 'relative label yields no timestamp'); +eq(itemPublishedTs({ publishedAt: '2026-01-17T10:30:00.000Z' }), Date.parse('2026-01-17T10:30:00.000Z'), + 'ISO date yields a timestamp'); +ok('itemPublishedTs rejects relative labels (InnerTube regression guard)'); + +eq(isLiveItem({ type: 'live' }), true, 'type=live is live'); +eq(isLiveItem({ type: 'video', isLive: true }), true, 'isLive flag is honoured'); +eq(isLiveItem({ type: 'vod', kind: 'vod' }), false, 'a Twitch VOD is not live'); +ok('isLiveItem honours both type and the isLive flag'); + +// ---------- 0.9 parseRelativeDate (tâche 1.9) ---------- +const NOW = Date.parse('2026-03-01T12:00:00.000Z'); +const day = 864e5; +eq(parseRelativeDate('il y a 2 heures', NOW), new Date(NOW - 2 * 36e5).toISOString(), 'FR relative hours'); +eq(parseRelativeDate('il y a 3 jours', NOW), new Date(NOW - 3 * day).toISOString(), 'FR relative days'); +eq(parseRelativeDate('il y a 2 semaines', NOW), new Date(NOW - 2 * 6048e5).toISOString(), 'FR relative weeks'); +eq(parseRelativeDate('2 months ago', NOW), new Date(NOW - 2 * 26298e6).toISOString(), 'EN relative months'); +eq(parseRelativeDate('Streamed 3 weeks ago', NOW), new Date(NOW - 3 * 6048e5).toISOString(), 'EN streamed weeks'); +eq(parseRelativeDate('1 an', NOW), new Date(NOW - 315576e5).toISOString(), 'FR relative years'); +eq(parseRelativeDate('hier', NOW), new Date(NOW - day).toISOString(), 'FR yesterday'); +eq(parseRelativeDate('yesterday', NOW), new Date(NOW - day).toISOString(), 'EN yesterday'); +eq(parseRelativeDate("aujourd'hui", NOW), new Date(NOW).toISOString(), 'FR today'); +eq(parseRelativeDate('today', NOW), new Date(NOW).toISOString(), 'EN today'); +eq(parseRelativeDate('2026-01-17T10:30:00Z', NOW), '2026-01-17T10:30:00.000Z', 'absolute ISO passthrough'); +eq(parseRelativeDate('', NOW), undefined, 'empty string -> undefined'); +eq(parseRelativeDate(null, NOW), undefined, 'null -> undefined'); +eq(parseRelativeDate('Streaming', NOW), undefined, 'unparseable label -> undefined (no invented date)'); +ok(`parseRelativeDate handles ${13} FR/EN cases and never invents a date`); + +// ---------- 0.10 Cohérence provider <-> contrat ---------- +// `id` court attendu par groupe, et un `search` qui dégrade proprement. +for (const id of ['yt', 'dm', 'tw', 'pt', 'od', 'ru']) { + const mod = providerRegistry[id]; + expect(mod.id === id, `provider "${id}" declares id === "${id}"`); +} +ok('each adapter self-declares its own id'); + +// Résumé +console.log(`\n provider-contract: ${passed} assertions OK (contract v${SUGGESTION_CONTRACT_VERSION})`); diff --git a/server/tests/provider_shapes.test.mjs b/server/tests/provider_shapes.test.mjs new file mode 100644 index 0000000..d71237b --- /dev/null +++ b/server/tests/provider_shapes.test.mjs @@ -0,0 +1,111 @@ +/** + * Phase 8.4 — même test de forme `Suggestion`, sur fixtures gelées, pour les 6 providers. + * + * Les fixtures viennent de `npm run fixtures:record`, qui appelle les 6 + * adaptateurs RÉELS : elles décrivent la sortie d'aujourd'hui, pas une + * intention. Ce test les rejoue à chaque PR, donc il détecte deux dérives : + * - le contrat a changé et un adaptateur ne suit plus ; + * - un adaptateur émet un champ qui viole le contrat. + * + * Il sert aussi de garde-fou « le gel n'est pas pourri » : un fichier de + * fixtures vide ou sans providers serait un test vert vide de sens. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { SUGGESTION_CONTRACT_VERSION, SUGGESTION_V2_FIELDS } from '../providers/registry.mjs'; +import { suggestionShapeErrors } from './lib/suggestion-shape.mjs'; + +const FIXTURES = path.join(import.meta.dirname, 'fixtures/provider-suggestions.json'); +const ALL = ['yt', 'dm', 'tw', 'pt', 'od', 'ru']; + +const raw = fs.readFileSync(FIXTURES, 'utf8'); +const data = JSON.parse(raw); + +const errorsOf = (s, label) => { + const acc = []; + // Même sémantique que `expect` : on ne retient que les prédicats faux. + suggestionShapeErrors(s, label, (cond, msg) => { if (!cond) acc.push(msg); }); + return acc; +}; + +test('8.4 — les fixtures sont gelées depuis le contrat v2', () => { + assert.equal(data._contractVersion, SUGGESTION_CONTRACT_VERSION, + 'fixtures gelées sous une autre version de contrat : régénérer (`npm run fixtures:record`)'); + assert.ok(data._recordedAt, 'date de gel absente'); + assert.ok(data._query, 'requête de gel absente'); +}); + +test('8.4 — les 6 providers sont présents dans le gel', () => { + for (const id of ALL) { + assert.ok(data.providers[id], `provider « ${id} » absent des fixtures`); + assert.ok(Array.isArray(data.providers[id].items), `items de « ${id} » n'est pas un tableau`); + } +}); + +test('8.4 — la forme de chaque Suggestion gelée respecte le contrat', () => { + let checked = 0; + for (const [id, entry] of Object.entries(data.providers)) { + for (const [i, item] of entry.items.entries()) { + const errs = errorsOf(item, `${id}[${i}]`); + assert.deepEqual(errs, [], `forme invalide :\n ${errs.join('\n ')}`); + checked++; + } + } + // Le gel a couvert des providers réels : sinon la suite ne prouve rien. + assert.ok(checked >= 6, `seulement ${checked} items vérifiés, le gel est vide ou cassé`); +}); + +test('8.4 — aucun provider ne renvoie un objet vide déguisé', () => { + // Un provider sans résultat doit être un tableau VIDE, jamais `null`, `{}` + // ou un groupe à trous : ces valeurs cassent le front bien plus qu'une + // simple absence. + for (const [id, entry] of Object.entries(data.providers)) { + for (const [i, item] of entry.items.entries()) { + assert.equal(typeof item, 'object', `${id}[${i}] n'est pas un objet`); + assert.notEqual(Array.isArray(item), true, `${id}[${i}] est un tableau`); + assert.ok(item.title, `${id}[${i}] sans titre`); + assert.ok(item.id, `${id}[${i}] sans id`); + } + } +}); + +test('8.4 — les champs du contrat v2 sont tous connus des fixtures', () => { + // Sens de lecture : un champ émis par un adaptateur mais absent de + // SUGGESTION_V2_FIELDS n'est ni documenté ni lu par le front. Le gel le + // révèle (c'est le but de 8.5) ; on ne casse pas le test pour autant, on + // exige que la dérive soit visible dans le compte rendu. + // SUGGESTION_V2_FIELDS liste les AJOUTS v2 ; le contrat de base (v1) n'y est + // pas, donc on l'ajoute explicitement — sinon `duration`/`width`/`viewCount` + // paraîtraient être des champs inconnus. + const known = new Set([ + ...SUGGESTION_V2_FIELDS, + 'id', 'title', 'url', 'thumbnail', 'uploaderName', 'type', + 'duration', 'width', 'height', 'viewCount', 'viewersRaw', + ]); + const seen = new Set(); + for (const entry of Object.values(data.providers)) { + for (const item of entry.items) for (const k of Object.keys(item)) seen.add(k); + } + const undocumented = [...seen].filter((k) => !known.has(k)).sort(); + // `badges` (YouTube) et `uploadedDate` (Dailymotion) sortent du contrat v2 : + // on les liste explicitement comme tolérés, pour ne pas les découvrir par + // hasard dans six mois. + const TOLERATED = new Set(['badges', 'uploadedDate']); + const unexpected = undocumented.filter((k) => !TOLERATED.has(k)); + assert.deepEqual(unexpected, [], + `champs émis mais absents du contrat v2 : ${unexpected.join(', ')} — les ajouter à SUGGESTION_V2_FIELDS ou à TOLERATED`); +}); + +test('8.4 — la couverture gelée est explicite sur ses trous', () => { + // `tw` (sans identifiants) et `ru` (Cloudflare) n'ont pas pu être gelés. + // Un trou de couverture doit être ASSUMÉ dans le fichier, pas passer pour + // une couverture complète. + for (const [id, entry] of Object.entries(data.providers)) { + if (entry.items.length === 0) { + assert.ok(entry.note, `« ${id} » est vide sans note : la couverture manquante serait invisible`); + } + } +}); diff --git a/server/tests/record_provider_shapes.mjs b/server/tests/record_provider_shapes.mjs new file mode 100644 index 0000000..89064fe --- /dev/null +++ b/server/tests/record_provider_shapes.mjs @@ -0,0 +1,86 @@ +/** + * Phase 8.4 — enregistreur de fixtures de contrat. + * + * Appelle les 6 adaptateurs **réels** et fige leur sortie dans + * `server/tests/fixtures/provider-suggestions.json`. Les fixtures sont donc + * générées, pas écrites à la main : elles reflètent ce que les providers + * produisent vraiment, y compris leurs champs bizarres. + * + * Réflexe : figer des objets inventés ne prouverait rien — on validerait sa + * propre imagination. Figer la sortie réelle prouve que le code d'aujourd'hui + * respecte le contrat, et le test rejoué à chaque PR détecte toute dérive. + * + * Usage : `npm run fixtures:record` (réseau requis). + * En CI : jamais exécuté. On vérifie seulement que les fixtures gelées sont + * toujours valides (`npm run test:shapes`). + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const OUT = path.resolve(import.meta.dirname, 'fixtures/provider-suggestions.json'); +const QUERY = process.argv[2] || 'tutorial'; +const LIMIT = 3; + +// BASE DE DONNÉES JETABLE, définie AVANT tout import du registre. +// +// Deux raisons, toutes deux apprises à l'usage : +// 1. `search_cache` est persistant : sans base neuve, le gel rejoue des +// résultats d'un ancien gel et fige une forme PÉRIMÉE. C'est arrivé : le +// correctif de `language` PeerTube n'apparaissait pas dans les fixtures. +// 2. Le gel ne doit pas toucher `db/newtube.db` (ni y appliquer de migration). +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-record-')); +process.env.NEWTUBE_DB_FILE = path.join(tmpDir, 'record.db'); + +const { providerRegistry } = await import('../providers/registry.mjs'); + +/** Champs parasites à ne pas figer : ils changent à chaque appel et ne disent rien du contrat. */ +function normalize(item) { + const out = {}; + // Tri des clés : deux enregistrements successifs donnent un diff lisible. + for (const k of Object.keys(item).sort()) { + if (item[k] === undefined) continue; + out[k] = item[k]; + } + return out; +} + +const recorded = { + _comment: 'FIGÉ PAR `npm run fixtures:record`. Ne pas éditer à la main : régénérer.', + _contractVersion: 2, + _recordedAt: new Date().toISOString(), + _query: QUERY, + providers: {}, +}; + +let failures = 0; +for (const id of Object.keys(providerRegistry)) { + process.stdout.write(` ${id} … `); + try { + const items = await providerRegistry[id].search(QUERY, { limit: LIMIT, page: 1, sort: 'relevance' }); + const list = (Array.isArray(items) ? items : []).map(normalize); + recorded.providers[id] = list.length + ? { items: list } + // Upstream indisponible au moment du gel (Rumble/Cloudflare, Odysee hors + // ligne) : on le note au lieu d'écrire un tableau vide silencieux, pour + // que la couverture manquante reste visible dans le fichier. + : { items: [], note: 'aucun résultat au moment du gel — couverture non testée pour ce provider' }; + console.log(`${list.length} item(s)${list.length ? '' : ' ⚠'}`); + if (!list.length) failures++; + } catch (e) { + recorded.providers[id] = { items: [], note: `erreur au gel : ${String(e?.message || e).slice(0, 160)}` }; + console.log(`ERREUR ${e?.message || e}`); + failures++; + } +} + +fs.mkdirSync(path.dirname(OUT), { recursive: true }); +fs.writeFileSync(OUT, `${JSON.stringify(recorded, null, 2)}\n`); +console.log(`\n→ ${path.relative(process.cwd(), OUT)}`); +if (failures) { + console.log(`\n${failures} provider(s) sans fixture exploitable : la couverture du test de contrat est partielle.`); +} +try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} +// Exit 1 : le gel a réussi mais est partiel (upstream HS). Le fichier est +// quand même écrit, pour que le trou de couverture soit visible dans le gel. +process.exit(failures ? 1 : 0); diff --git a/server/tests/rumble-ld.test.mjs b/server/tests/rumble-ld.test.mjs new file mode 100644 index 0000000..24304a6 --- /dev/null +++ b/server/tests/rumble-ld.test.mjs @@ -0,0 +1,136 @@ +import { parseJsonLd, parseRumbleViews, parseSearchHtml, resetRumbleNegativeCache } from '../providers/rumble.mjs'; +import { parseRelativeDate } from '../providers/youtube-innertube.mjs'; +import { itemDurationSec } from '../search-filters.mjs'; + +let pass = 0; +const eq = (a, b, m) => { + const x = JSON.stringify(a), y = JSON.stringify(b); + if (x !== y) throw new Error(`${m}\n expected ${y}\n got ${x}`); + pass++; console.log(` V ${m}`); +}; +/** + * Comparaison d'horodatage calculé sur l'horloge VIVANTE : le test et le code + * appellent `Date.now()` à des instants distincts, donc une égalité à la + * milliseconde est une loterie (elle échouait 1 fois sur ~2, selon le tick). + * On vérifie l'ordre de grandeur : c'est ce que la fonction promet. + */ +const eqNear = (iso, expectedMs, toleranceMs, m) => { + const got = Date.parse(iso); + if (!Number.isFinite(got)) throw new Error(`${m}\n expected ~${new Date(expectedMs).toISOString()}\n got ${iso}`); + const delta = Math.abs(got - expectedMs); + if (delta > toleranceMs) throw new Error(`${m}\n expected ~${new Date(expectedMs).toISOString()} (±${toleranceMs}ms)\n got ${iso} (écart ${delta}ms)`); + pass++; console.log(` V ${m}`); +}; + +// --- parseRumbleViews (bug historique : "1,2K" -> 12) --- +eq(parseRumbleViews('1,2K views'), 1200, 'FR compact "1,2K" -> 1200 (was 12)'); +eq(parseRumbleViews('1.2M views'), 1200000, 'EN compact "1.2M" -> 1 200 000'); +eq(parseRumbleViews('3.4B views'), 3400000000, 'EN compact billions'); +eq(parseRumbleViews('12,345 views'), 12345, '"12,345" -> thousands, not 12.345'); +eq(parseRumbleViews('3 456'), 3456, 'space-separated thousands'); +eq(parseRumbleViews('1 234 567'), 1234567, 'multi-group thousands'); +eq(parseRumbleViews('1.5k'), 1500, 'lowercase k'); +eq(parseRumbleViews('999'), 999, 'plain number'); +eq(parseRumbleViews('vues indisponibles'), undefined, 'non-numeric -> undefined (never 0)'); +eq(parseRumbleViews(''), undefined, 'empty -> undefined'); +eq(parseRumbleViews(null), undefined, 'null -> undefined'); +eq(parseRumbleViews('0 views'), undefined, 'zero -> undefined (phase 2.2: no zero counters)'); + +// --- DOM parsing (Phase 1.4/1.5/1.6) --- +const domHtml = `
    +
  • + +

    Titre A

    + + 1,2K views + + +
  • +
  • + +

    Titre B

    + Auteur B + vues indisponibles +
  • +
`; + +const dom = parseSearchHtml(domHtml, { limit: 10 }); +eq(dom.length, 2, 'DOM: both cards parsed'); +eq(dom[0].publishedAt, '2026-02-01T15:00:00.000Z', 'DOM: publishedAt from