Files
NewTube/docs/ingestion-catalogue-video-par-fournisseur.md
bruno 665a0f0ebd
CI / build-and-test (push) Successful in 14m43s
feat(providers): phases 7.3/7.4/7.6/8.1 — provenance, health, NDJSON, contrat unique
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.
2026-09-30 07:57:11 -04:00

51 KiB
Raw Permalink Blame History

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
  2. Le contrat commun Suggestion
  3. Saisie — source amont par fournisseur
  4. Catalogue — champs réellement capturés
  5. Catalogue du contenu de chaîne
  6. Métadonnées de chaîne
  7. Classification Vidéo / Short / Live
  8. Normalisation front
  9. Persistance SQLite
  10. Caches
  11. Configuration par fournisseur
  12. Points d'attention et écarts constatés
  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.

┌─ 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} <title> (nettoyé du suffixe « on Rumble »), og:image

Note : aucune de ces 6 sources ne renvoie de banner ni de description de chaîne — le modèle ChannelDetail les déclare mais les valorise à null (channel-detail.model.ts:51-62).


7. Classification Vidéo / Short / Live

Les règles sont dupliquées à l'identique côté serveur et front :

  • Serveur : server/search-filters.mjs:182-224 (isShortItem)
  • Front : src/app/shared/utils/video-kind.ts:64-102 (isShortVideo)

7.1 Algorithme de détection Short (ordre strict)

Ordre Règle Seuils / constantes
1 kind === 'clip' → toucourt short les clips Twitch sont ≤ 60 s par construction ; une durée > 75 s sur un clip est du bruit de métadonnées
2 Flag natif : isShort === true ou type === 'short' ou URL contenant /shorts/ → short, annulé si durée connue > 75 s SHORT_MAX_SECONDS = 75
3 L'orientation fait autorité dès que width et height sont connus : verticale = h > w && w/h ≤ 0.8. Verticale + durée connue ≤ 90 s → short. Verticale sans durée connue → pas short. Horizontale ou carrée → jamais short (même à 50 s) VERTICAL_SHORT_MAX_SECONDS = 90
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
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.

É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.

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.