# 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.*