Files
NewTube/README.md
T
bruno 41a28dfccc
CI / build-and-test (push) Successful in 14m57s
feat(api): production-ready P0/P1 — secrets hors navigateur, clés d'API, métriques
P0
- Aucun secret servi au navigateur : /assets/config.local.js est généré par le
  serveur AVANT les montages statiques (il l'emporte sur le fichier local) et
  ne contient plus YOUTUBE_API_KEY(S) ; le fichier local n'est plus copié dans
  l'image Docker ni embarqué dans dist (angular ignore config.local.js) ;
  youtube-api.service n'appelle plus googleapis directement (fetchYouTube =
  /api/yt -> /proxy/api/yt), gardes « pas de clé => écran vide », rotation et
  carte de bans côté client supprimées ; readiness YouTube via /healthz
  (youtube.keys.count) ; messages d'erreur orientés configuration serveur.
- CORS : origines pilotées par API_ALLOWED_ORIGINS (CSV), méthode PATCH
  ajoutée (requis par /user/preferences), header X-API-Key accepté.
- Clés d'API longue durée : table api_keys (empreinte SHA-256 + préfixe
  affichable), routes GET/POST /api/keys et DELETE /api/keys/:id, jeton
  ntk_… affiché une seule fois, last_used_at à chaque usage ; X-API-Key
  accepté par authMiddleware ET authMiddlewareCookieAware.

P1
- X-Request-Id renvoyé sur chaque réponse + journal JSON structuré en prod
  (ts, reqId, method, route, status, ms).
- GET /metrics : exposition Prometheus sans dépendance (http_requests_total
  par route/code, somme+nombre de durées, uptime/mémoire ; METRICS_TOKEN
  verrouille l'accès si défini).
- Rate-limit sur /api/details (DETAILS_RATE_LIMIT, 60/min, réponse JSON) —
  /transcript avait déjà le sien.
- Version unique package.json : menu du compte, info.version de l'OpenAPI
  (+ schéma apiKeyAuth et chemins /keys / /metrics documentés).

Tests : api_coverage +7 cas « production-ready » (sentinel de fuite de clé,
X-Request-Id, /metrics, CORS PATCH, contrat/version, cycle complet des clés
d'API), suggest, transcript, flags, filters, kind — verts. Build OK.
Vérifs instance locale : config servie sans secret, /api/yt 200 avec la clé
serveur, /metrics alimenté, menu 1.0.59 et 40 cartes rendues sans clé côté
client.
2026-10-02 23:24:50 -04:00

396 lines
33 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# NewTube — Votre hub vidéo multi-plateformes 🎬🌐
Agrégez, explorez et regardez des vidéos depuis
- **YouTube** 🔴
- **Dailymotion** 🔵
- **Twitch** 🟣
- **PeerTube** 🟢 (multi-instances)
- **Odysee** 🟡
- **Rumble** 🟠
---
## ✨ Pourquoi NewTube ? (Objectifs clés)
* 🔎 **Recherche unifiée & tendances** : un seul champ de recherche, des sections par fournisseur, filtres cohérents.
* 🧭 **Navigation claire** : thèmes (Trending, Live, Gaming, News…), onglets Shorts, pages Playlists/History/Liked.
* 👥 **Multi-utilisateur** : playlists **publiques/privées**, préférences, région/qualité par défaut.
* ⚙️ **Prod-ready** : API Node/Express, SQLite intégré, rate-limit, logs, tests ciblés.
* 📦 **Ops friendly** : image Docker, script `maj.sh`, variables d’env, **astuces `daemon.json`** pour registres HTTP.
---
## 🧩 Stack
* 🅰️ **Angular 20** (standalone), **RxJS**, **TailwindCSS**
* 🟩 **Node/Express** (sert `dist/` + API)
* 🐳 **Docker/Compose** (déploiement)
* 💾 **SQLite** (par défaut) – simple, portable
---
## 🎥 Fournisseurs supportés
| Plateforme | Recherche | Lecture | Shorts | Live | Playlists\* |
| ----------------------------- | :-------: | :-----: | :----: | :--: | :---------: |
| YouTube 🔴 | ✅ | ✅ | ✅ | ✅ | ✅ |
| Dailymotion 🔵 | ✅ | ✅ | ✅ | ✅ | ⏳ |
| Twitch 🟣 | ✅ | ✅ | Clips ✅ | ✅ | N/A |
| PeerTube 🟢 (multi-instances) | ✅ | ✅ | ✅ | ✅ | ⏳ |
| Odysee 🟡 | ✅ | ✅ | ✅ | N/A | N/A |
| Rumble 🟠 | ✅ | ✅ | ✅ | ✅ | N/A |
\* Playlists = intégration locale NewTube (création/gestion) ; la synchro native (ajout/suppression côté fournisseur) exige l’API d’écriture du fournisseur :
* **YouTube ✅** (Data API) ; **Dailymotion / PeerTube ⏳** : APIs d’écriture existantes mais coûteuses (inscription d’app OAuth pour l’un, JWT par instance pour l’autre).
* **Twitch N/A** — API playlists **supprimée** : probe 2026-10 avec token app valide, `GET/POST /helix/playlists` et `/helix/playlists/{id}/videos` répondent tous **404** (contrôle `/helix/users` → 400 = route existante).
* **Odysee N/A** — proxy `na-backend` en lecture seule (méthodes d’écriture : « authentication token missing ») ; **Rumble N/A** — aucune API publique.
Notes sur les colonnes :
* **Shorts** : le flux `/#/shorts` tourne sur les 6 fournisseurs — catalogue `/api/search?type=shorts` (lot affiché s’il compte ≥ 3 résultats), sinon repli recherche avec requêtes dédiées (`SHORTS_QUERIES`). Filtre de durée côté serveur : YouTube (`videoDuration=short`), Dailymotion (`shorter_than`), PeerTube (`durationMax`) ; filtre client pour Odysee et Rumble ; **Rumble** : flux natif `rumble.com/shorts` ; **Twitch** : pas de format vertical natif, le flux sert les **clips**.
* **Live** : sources câblées — YouTube (`search.list eventType=live`), Twitch (top streams Helix), PeerTube (API `?isLive=true` : thème « En direct » + onglet Live de la chaîne), Rumble (`rumble.com/browse/live` : thème + onglet chaîne filtré par `channelId`). **Dailymotion** : lecture possible mais `live:false` côté capacités (le thème y affiche « Ce fournisseur ne propose pas de directs »). **Odysee N/A** — aucune API publique de lives (proxy na-backend sous jeton, probe 2026-10).
* **Rumble 🟠** : scraping maison derrière Cloudflare (fetch navigateur + helper `curl_cffi`). Si le réseau du serveur est challengé, l’API répond `503 { error: "rumble_cloudflare_challenge" }` (« bloqué temporairement ») au lieu d’un résultat vide : Rumble peut donc être temporairement absent selon le réseau — voir `server/providers/rumble.mjs`.
---
## 🔎 Recherche unifiée (multi-providers)
Un seul champ, tous les fournisseurs — avec filtres, raccourcis et deep-links :
* **Panneau de filtres** : bouton **Filtres** (ou **Ctrl/⌘+Maj+F**) — un seul écran pour *toutes* les dimensions : **sources** (multi), **type de contenu** (vidéos / shorts-clips / en direct / chaînes), **période** (dernière heure, aujourd’hui, semaine, mois, année), **durée** (courte < 4 min, moyenne 4-20 min, longue > 20 min) et **tri**. Pastilles, roving tabindex (←/→ dans un groupe, ↑/↓ entre groupes, Entrée pour choisir, Échap pour fermer), focus trap, « Réinitialiser », « Mémoriser comme sources par défaut » et les **recherches récentes** en raccourci
* **Filtres appliqués côté serveur** : `?type=…&duration=…&period=…&sort=…` voyagent dans l’URL et dans `/api/search` — YouTube filtre nativement (InnerTube `upload_date`/`type`/`duration`/`features`, Data API `type`/`videoDuration`/`publishedAfter`, yt-dlp `--dateafter`), les autres providers sont affinés en post-traitement (`server/search-filters.mjs`)
* **Opérateurs en clair** : `linux live:`, `tuto today: long:`, `concert shorts:`… tapés dans la barre sont convertis en filtres et retirés de la requête (autocomplétion après le `:`)
* **Autocomplete `@`** : tapez `@yt` dans le champ pour cocher/décocher une source (↑/↓/Entrée, Échap pour fermer) — raccourcis **Alt+1..6**
* **Deep-links** : `/#/search?q=…&providers=yt,ru&period=week&type=live` relance la recherche filtrée — partageable
* **Fallback préférence** : URL sans `providers` → préférence `defaultProviders` de l’utilisateur → provider actif
* **Accessibilité** : focus trap dans le panneau, Esc pour fermer, aria-combobox + `aria-activedescendant` sur le champ, focus restauré à la fermeture
* **Panneau de suggestions** : sous l’input, ligne « Rechercher `<q>` » puis suggestions (recherches récentes 🕘 + groupes par provider `YT/DM/…`, sous-chaîne surlignée) — `GET /api/search/suggest?q=…&providers=…&limit=…` (min 2 caractères, debounce 250 ms, cache 5 min, dégradation `[]` par provider) ; ↑/↓ (bouclants), `Home`/`End`, `PageUp`/`PageDown`, `Entrée` (valide la ligne surlignée, sinon lance la recherche), `Tab` (complète sans chercher), `Échap` (ferme puis vide), priorité au popover `@`
* **Focus** : le panneau se referme dès que le focus quitte la barre (`focusout` + `relatedTarget`, clic neutralisé sur les lignes pour garder le focus dans l’input, pas de scintillement au re-clic)
<!-- TODO: add docs/search-ux.gif (capture du panneau de filtres, du panneau de suggestions et du deep-link) -->
### Endpoints API concernés
* `GET /api/search?q=…&providers=yt,dm&type=live&duration=short&period=week&sort=date` — fan-out parallèle, réponse groupée par provider (`page`/`pageSize`/`sort` + `filters` ; YT sans quota via InnerTube + continuations)
* `GET /api/search/suggest?q=…&providers=yt,dm&limit=10` — typeahead `{ q, groups: { yt: string[], … } }` (cache 5 min, rate-limit)
* `GET /api/details/youtube/:videoId` — métadonnées + `related[]` (watch-next InnerTube, `?related=0` pour désactiver)
* `GET /api/trending?provider=yt&limit=…` — tendances YT sans clé
* `GET /healthz` (alias `/api/healthz`) — mode YT, binaire yt-dlp `binOk`, cache, métriques quota/jour, clés
* `GET /api/transcript/:provider/:videoId?lang=&instance=&slug=&sourceUrl=` — transcript `{ lang, available, languages, lines: [{ t, dur, text }] }` (cache 24 h, rate-limit 10/min ; absent → 200 `{ available: false }`, échec → 502 ; YouTube : découverte des pistes via InnerTube, `YT_TRANSCRIPT_SOURCE`)
* `GET/PATCH /api/user/preferences` — `defaultProviders` (tableau JSON, sanitizé serveur)
* `POST /api/telemetry/events` — événements UX anonymes (whitelist : `search_submit`, `provider_picker_open`, `provider_apply`, `at_autocomplete_use`, `quick_menu_open`, `filter_panel_open`, `filter_apply`, `suggest_shown`, `suggest_used`)
### Tests
```bash
npm run test:search # unitaires SearchService + clavier/focus du panneau de suggestions + panneau de filtres
npm run test:search-e2e # scénarios e2e (serveur réel isolé)
npm run test:filters # filtres de recherche : normalisation, bornes, mapping providers (offline)
npm run test:suggest # typeahead : parsing/dédup + contrat /api/search/suggest
npm run test:transcript # transcripts : parseurs json3/vtt + contrat /api/transcript
npm run test:preferences # persistance defaultProviders
npm run test:telemetry # télémétrie minimale
```
---
## 🖥️ Fonctionnalités (vue d’ensemble)
* 🧭 **Accueil “Tendances & Viral”** par fournisseurs
* 🧩 **Thèmes** : Trending, Live, Gaming, Sports, News, Finance, Tech, Science, Health, Music, Podcasts, Movies/TV, Education, Travel, Food, DIY, Auto…
* 🎯 **Shorts** : affichage dédié (séparé des vidéos longues)
* ❤️ **Liked videos** avec recherche serveur
* ⏰ **À regarder plus tard** (Watch later) : bouton horloge à côté du cœur sur toutes les cartes (accueil, thèmes, recherche), liste **Vous → À regarder plus tard** (`/#/library/watch-later`), API `/api/user/watch-later`
* 🕘 **History** (recherches + visionnage)
* 📚 **Playlists** publiques/privées (CRUD, compteurs, dates MAJ)
* 🔐 **Auth légère** (JWT), **rate-limit** API
* ⚙️ **Préférences** : langue, thème (système / dark / light / blue / black), région, qualité par défaut
* 🧩 **PeerTube multi-instances** : activer/désactiver, choisir l’instance active
---
## 🗂️ Structure du repo
* `docker-compose/`
* `docker-compose.yml` — service `newtube` (image, ports, env, volumes, restart)
* `.env.example` — modèle d’environnement
* `maj.sh` — **pull** image + **restart** stack
* `init.sh` — init des dossiers/volumes & `.env`
* `docker/`
* `Dockerfile` — build Node/Express (sert `dist/` + API)
* `scripts/env-dump.sh` — génère `assets/config.js` depuis l’env (option NGINX)
* `config/nginx.conf` — exemple (si variante NGINX)
* `server/`
* `index.mjs` — routes API, statiques `dist/`, downloads
* `db.mjs` — SQLite + migrations légères
* `tests/playlist_visibility.test.mjs` — tests playlists
* `db/` — `schema.sql` + `migrations/`
* `assets/` — `config.local.example.js`
* `src/`, `app/`, `public/` — front Angular
* `package.json` — scripts (`dev`, `build`, `api`, `api:watch`, `test:playlists`)
---
## 🧱 Prérequis
* 🐧 Linux (recommandé) / macOS / Windows (WSL2 ok)
* 🐳 Docker Engine ≥ 24, Compose v2
* 🟩 Node.js ≥ 20 (dev local)
* 🔐 Accès au registre d’images (ex. `docker-registry.dev.home:5000`)
---
## 🚀 Installation
### Option A — Docker (recommandé)
```bash
cp docker-compose/.env.example docker-compose/.env
# Éditez docker-compose/.env (hostnames, clés API, secrets…)
cd docker-compose
docker compose up -d
```
Application : [http://localhost:8080](http://localhost:8080) (mappage `8080:4000`)
### Option B — Dev local
```bash
npm install
cp assets/config.local.example.js assets/config.local.js # (optionnel — valeurs SANS SECRET uniquement)
npm run dev # front + api dev proxy
# API seule :
npm run api
npm run api:watch
# Build prod :
npm run build
```
---
## 🔁 Déploiement & mises à jour (`maj.sh`)
```bash
chmod +x docker-compose/maj.sh
./docker-compose/maj.sh
```
Ce script fait :
1. `docker image pull <registre>/newtube-angular:latest`
2. `docker compose down`
3. `docker compose up -d`
**Vérifications** : `docker compose ps`, `docker logs newtube --tail=200`, `curl -I http://localhost:8080`
---
## 🧰 Astuce Ops — `Docker /etc/docker/daemon.json` (registres HTTP/insecure)
> À configurer **sur la machine qui exécute** `docker compose` / `maj.sh`.
```bash
sudo mkdir -p /etc/docker
sudo tee /etc/docker/daemon.json >/dev/null <<'JSON'
{
"insecure-registries": ["docker-registry.dev.home:5000"],
"registry-mirrors": [],
"debug": false
}
JSON
# debian linux
sudo systemctl daemon-reload
sudo systemctl restart docker
# Alpine linux
sudo rc-service docker restart
sudo rc-update add docker
docker info | grep -i registry
```
Ajoutez au besoin `log-driver`, `log-opts`, `default-address-pools`, etc.
---
## 🔐 Variables d’environnement (exemples)
**Service / compose**
* `NGINX_HOSTNAME`, `DIR_NEWTUBE`, `TZ`
**App / serveur**
* `PORT` (4000), `NODE_ENV`, `JWT_SECRET`
* `ACCESS_TTL_MIN`, `REFRESH_TTL_DAYS`, `REMEMBER_TTL_DAYS`
* `YT_CACHE_TTL_MS`
* **Clés API** (toutes **côté serveur**, aucune n'est envoyée au navigateur) : `GEMINI_API_KEY`, `YOUTUBE_API_KEY` ou `YOUTUBE_API_KEYS` (CSV), `VIMEO_ACCESS_TOKEN`, `RUMBLE_API_KEY`, `TWITCH_CLIENT_ID`, `TWITCH_CLIENT_SECRET`
* **API ouverte aux clients** : `API_ALLOWED_ORIGINS` (CSV des origines autorisées en CORS, défaut `localhost:4200,4000,3000` — y ajouter l'URL publique de l'app pour des clients tiers), `METRICS_TOKEN` (si défini, `/metrics` exige `Authorization: Bearer …`), `DETAILS_RATE_LIMIT` (appels `/api/details` par minute, défaut 60)
* `NEWTUBE_DB_FILE` (chemin SQLite alternatif)
> Voir `docker-compose/.env.example` pour un point de départ.
---
## 🩺 Troubleshooting (rapide)
* 🧾 **Certif/registre** : `x509… unknown authority` → configurez `daemon.json` (ci-dessus) puis `systemctl restart docker`
* 🔌 **Port 8080 occupé** : changez le mappage dans `docker-compose.yml`
* 🗄️ **Permissions volumes** : vérifiez que `DIR_NEWTUBE` existe et est accessible par Docker
* 🧩 **Variables manquantes** : complétez `.env` (clés API, `JWT_SECRET`, etc.)
* 🌐 **CORS en dev** : utilisez le `proxy.conf.json` et `npm run dev`
---
## 🗺️ Roadmap
Légende : **✅ livré** (câblé de bout en bout) · **🟡 partiellement livré** (détail sous chaque item) · **⏳ pas encore démarré**
### ✅ Livré
* ✅ Dist statique + API Node/Express
* ✅ Playlists **publiques/privées** (tests de visibilité)
* ✅ Barre de recherche **Liked videos** (filtrage serveur)
* ✅ Liste **« À regarder plus tard »** (watch later) — même stockage « tags » que les likes (aucune migration), bouton horloge à côté du cœur sur toutes les cartes, entrée « Vous » de la barre latérale, page `/#/library/watch-later` avec recherche serveur
* ✅ PeerTube **multi-instances** (activer/désactiver, set active)
* ✅ Thème (système / dark / light / blue / black)
* ✅ **Harmonisation visuelle** — tous les utilitaires neutres (`slate`/`zinc`/`gray`, fonds, textes, bordures, anneaux, placeholders, états `hover:`/`disabled:`) sont mappés sur les variables du thème (**75 jetons, 36 fichiers**) : barre de recherche, menus, panneaux et cartes suivent enfin le thème appliqué ; panneau **ⓘ des Shorts** re-colorié dans son périmètre (le chrome au-dessus de la vidéo reste blanc) ; marqueur `border-l-4` des titres de page sur l'accent du thème ; cartes vidéo harmonisées (`text-sm` / `font-semibold` / méta `text-xs` / rayon `rounded-lg`, pastille de durée sans `font-mono`) ; **contour fournisseur 1 px** à la couleur de la pastille autour de chaque cadre vidéo (grilles, recherche, channel, watch — lecteur et « à suivre » —, shorts, historique, aimés, playlist)
* ✅ Préférences (langue, thème, région, qualité par défaut)
* ✅ Auth légère (JWT), rate-limit API
* ✅ **Recherche unifiée multi-providers** — panneau de filtres (sources / type / période / durée / tri, Ctrl/⌘+Maj+F), autocomplete `@` + opérateurs `live:`/`today:`, deep-links `?providers=…&type=…&period=…`, préférence `defaultProviders` persistée, filtres serveur, télémétrie minimale, a11y (focus trap, Esc, clavier complet sur le panneau de suggestions)
* ✅ **Filtres & tri unifiés** — barre partagée **[Filtres] [Tri]** sur la page de recherche ET sur les pages thèmes (pastilles de type, ligne Sources **Toutes / Aucune**, période / durée / langue, filtres actifs en pastilles) ; bouton « filtres rapides » supprimé, tout est sous [Filtres]. Tri : Pertinence, Date de mise en ligne, Vues, **Note (likes)**, **Durée** (les deux derniers réordonnent côté client)
* ✅ Navigation par thèmes (Trending, Live, Gaming, News, Finance, Tech, Science, Health, Music, Podcasts, Movies/TV, Education, Travel, Food, DIY, Auto…)
* ✅ **Abonnements (routes + DB)** — `GET/POST/DELETE /api/subscriptions` (+ `POST /api/subscriptions/batch`), groupes façon PocketTube (`/api/subscription-groups` CRUD + affectation de membres), tables SQLite `subscriptions` / groupes, page `/#/library/subscriptions`, bouton « S'abonner » partagé (`subscribe-button`) + entrée barre latérale ; alimentés par l'import OAuth (YouTube/Twitch). Tests : `npm run test:subscriptions`
* ✅ **Page « Shorts » unifiée (tous fournisseurs)** — flux immersif `/#/shorts` sur les 6 fournisseurs (catalogue `/api/search?type=shorts` sinon repli recherche, dédup centralisée), durées : filtres côté serveur (YouTube `videoDuration=short`, Dailymotion `shorter_than`, PeerTube `durationMax`) + filtre client ailleurs, **pastille de durée** sur les cartes de la recherche filtrée et durée affichée dans le panneau ⓘ, clips Twitch, mode « Pour toi »
* ✅ **UI/UX des Shorts** — le rail d'actions quitte les bords (les flèches latérales sont supprimées) et s'ancre au bord **droit** de la vidéo : **informations**, flèches ↑/↓, j'aime, **enregistrer** (liste « À regarder plus tard »), s'abonner (pastille = avatar), commentaires, partager, **lecture auto** (interrupteur persisté, respecté par le minuteur ET la fin de vidéo YouTube) ; panneau **ⓘ** (titre, vues, engagement, chaîne, **description et vues demandées à la source à l'ouverture** via `/api/details` — squelette de chargement, échec silencieux —, renvoi vers la page complète, aucune source de commentaires dans l'app) en colonne à droite au bureau et en tiroir plein écran sur mobile, la vidéo rétrécissant sans jamais être masquée ; **transition glissée** d'un short à l'autre (le cadre suit le doigt, résistance aux extrémités, ressort sous 25 % de la hauteur puis éjection/emboîtement, `prefers-reduced-motion` respecté) ; FAB menu conservé (déplaçable, au-dessus de la vidéo). Le tap sur un contrôle du rail ne déclenche plus play/pause
* ✅ **Métadonnées Odysee sans yt-dlp** — `resolve` de l'API LBRY (**~0,2 s** contre **~45 s** mesurées avec l'extracteur `lbry` de yt-dlp, qui finissait sur « No video formats found! ») : titre, description, vignette, durée et date arrivent enfin sur `/watch` ; `views` volontairement omis (LBRY n'expose aucun compteur pour un claim isolé). Au passage : **cache `/api/details` 6 h** (LRU 500, `DETAILS_CACHE_TTL_MS`) et `views`/`duration` ne sont plus émis quand ils valent 0 (un 0 écrasait la valeur du flux : « 0 vue » sur les fournisseurs muets). Tests : `npm run test:odysee`
* ✅ **Téléchargements** intégrés — file d'attente **persistée en SQLite** (survit aux redémarrages API), jobs **par utilisateur** (répertoires isolés, ownership sur status/fichier/cancel), **quota de stockage** configurable (`DOWNLOAD_STORAGE_QUOTA_BYTES`, fenêtre `DOWNLOAD_QUOTA_WINDOW_MS`), **reprise** des jobs échoués/interrrompus (bouton Réessayer), **page Bibliothèque > Téléchargements** (filtres par état, progression live, quota), nettoyage auto des fichiers orphelins au boot. Variables : `DOWNLOAD_MAX_CONCURRENT` (2), `DOWNLOAD_STORAGE_QUOTA_BYTES` (5 GiB), `DOWNLOAD_QUOTA_WINDOW_MS` (30 j), `DOWNLOAD_PROVIDERS` (peertube,odysee)
* ✅ **Sous-titres & transcripts** — `GET /api/transcript/:provider/:videoId` (yt-dlp `subtitles`/`automatic_captions`, parsing `json3`/`vtt`, cache 24 h, rate-limit), panneau **Transcript** sur la page Watch (sélecteur de langue, dégradation propre si indisponible)
* ✅ **YouTube sans quota (InnerTube façon SmartTube)** — `youtubei.js` pinné, chaîne `innertube → scrape yt-dlp → API officielle` (`YT_SEARCH_MODE`, défaut `innertube-first`), **pagination illimitée** via continuations (scroll infini, `page=2,3…`), **vidéos connexes** watch-next dans `GET /api/details/youtube/:videoId` → sidebar Watch, `GET /api/trending?provider=yt`, cache mémoire + SQLite, anti-ban (`YT_COOKIES_FILE`, `YT_PO_TOKEN`, `YT_EGRESS_PROXY`), observabilité `/healthz` (mode, binaire `binOk`, cache, quota jour, clés)
* ✅ **OAuth** (Google/Twitch) pour import favoris/abonnements — `/library/import` : abonnements + likes YouTube (lecture seule), follows Twitch → abonnements, preview + import, `GET /api/oauth/status|connections|preview`, `POST /api/oauth/:provider/import`
* ✅ **Live sur PeerTube & Rumble** — thème « En direct » + onglet Live de la chaîne : PeerTube via l'API native `?isLive=true` (recherche sepiasearch + vidéos de chaîne, items `type:'live'` + spectateurs), Rumble via la page SSR `rumble.com/browse/live` (route `/api/rumble/live`, items typés live ; onglet chaîne filtré par `channelId` — plafond : page 1 bornée à 50 directs). Odysee : **N/A** (aucune API publique de lives, probe 2026-10). Tests : `npm run test:live`
* ✅ **Import/Export playlists (JSON)** — `GET /playlists/export` (dump complet + items, format `newtube-playlists-v1`), `POST /playlists/import` (création des listes manquantes, dédoublonnage par titre au réimport, compteurs détaillés ; bornes 200 listes / 5 000 vidéos par liste) ; boutons **[Exporter] [Importer]** sur `/#/library/playlists`.
* ✅ **Page Administration** (`/#/admin`, entrée « Administration » de la section Informations dans la barre latérale) — `/healthz` + `/api/providers/metrics` rendus lisibles : statut API, mode YouTube, version et santé yt-dlp, clés actives / bannies (+ suffixes), anti-ban (cookies / PO token / proxy), cache de recherche par provider, compteurs 1 h par provider, quota YouTube du jour. Au passage : `healthz` servi aussi sous `/proxy/api/healthz` (le chemin que le front utilise en prod). *Journaux et exposition Prometheus livrés — voir Observabilité.*
* ✅ **Cache TTL par provider** — `server/env-ttl.mjs` (`<BASE>_<PROVIDER>` → `<BASE>` → défaut, valeurs invalides ignorées, jamais de TTL nul) branché sur les caches **details** (`DETAILS_CACHE_TTL_MS_<P>`) et **transcripts** (`TRANSCRIPT_CACHE_TTL_<P>`) en plus de la recherche (`SEARCH_CACHE_TTL_MS_<P>`). Le cache **suggest** reste global par conception : sa clé agrégée couvre plusieurs providers d'un coup, un TTL par provider demanderait de scinder le cache. Tests : `npm run test:cache`
* ✅ **Menu du compte refondu** (bouton avatar, haut à droite du header) — panneau sombre à lignes « icône + titre gras + sous-titre gris » : **Thème** (thème réellement appliqué en sous-titre, pastilles dépliées au clic), **Administration** (`/#/admin`), **API** (`/proxy/api/openapi.json` dans un nouvel onglet), **Préférences** (`/#/account/preferences`), **Guide d'utilisation** (`/#/info/utilisation`), **À propos** (version, tagline, lien code source), **Sessions** (`/#/account/sessions`), **Déconnexion** ; pied de page **Version** = version officielle de l'application (`src/app/version.ts`, alignée sur le tag semver publié par `docker/deploy-img.sh`). Clés i18n déclarées des deux côtés (FR + EN)
* ✅ **Observabilité** — `GET /healthz` (mode `YT_SEARCH_MODE`, binaire yt-dlp, cache, quota du jour, clés masquées — testé par `test:api`), `GET /api/providers/metrics` et la **page Admin** qui les rend, **journal JSON structuré en prod** (une ligne par requête : `ts`, `reqId`, `method`, `route`, `status`, `ms`, adossé au header `X-Request-Id` renvoyé sur chaque réponse) et **`GET /metrics`** — exposition Prometheus sans dépendance (`http_requests_total` par route et code, somme/nombre de durées, démarrage + mémoire du processus ; `METRICS_TOKEN` verrouille l'accès si défini)
* ✅ **API production-ready (P0 + P1)** — **plus aucun secret servi au navigateur** : `/assets/config.local.js` est généré par le serveur **avant** les montages statiques (il l'emporte donc sur le fichier local) et ne contient plus `YOUTUBE_API_KEY(S)`, le fichier local n'est plus copié dans l'image Docker ni embarqué dans `dist`, et les appels YouTube passent par `/api/yt` uniquement (clé, rotation, quota et cache côté serveur — suppression des appels directs à googleapis et des gardes « pas de clé ⇒ écran vide ») ; **CORS piloté par `API_ALLOWED_ORIGINS`** (CSV) + méthode `PATCH` (requis par `/user/preferences`) ; **clés d'API longue durée** : `GET/POST /api/keys` + `DELETE /api/keys/:id`, jeton `ntk_…` affiché une seule fois, stocké en SHA-256 avec préfixe affichable, header `X-API-Key` accepté par les **deux** middlewares d'auth (toutes les routes protégées deviennent scriptables), `last_used_at` renseigné à chaque usage ; **`X-Request-Id`** en réponse sur chaque requête ; **`/api/details` rate-limité** (`DETAILS_RATE_LIMIT`, 60/min, réponse JSON — `/api/transcript` avait déjà le sien) ; **version unique `package.json`** (menu du compte + `info.version` de l'OpenAPI). Tests : `npm run test:api`
### 🟡 Partiellement livré
* 🟡 **Tags** — ✅ tables `tags` / `video_tags` (stockage des listes « Aimés » et « À regarder plus tard », recherche serveur dans ces listes) ; ⏳ tags **libres** créés par l'utilisateur sur n'importe quelle vidéo + recherche de vidéos par tag
* 🟡 **Qualité vidéo** — ✅ sélecteur de qualité sur `/#/watch` (formats yt-dlp, tri décroissant `1080p → 144p`, reprise de la lecture sur le flux choisi) ; ⏳ mode **« auto » intelligent** (choix automatique du flux selon la bande passante)
* 🟡 **Traduction UI (i18n)** — ✅ socle FR/EN (`src/services/i18n.service.ts`, chaque clé déclarée des deux côtés) couvrant barre de recherche, barre de filtres, pages thèmes ; ⏳ couverture **élargie** aux écrans encore en texte FR hardcodé (`/watch`, `/shorts`, bibliothèque)
### ⏳ Pas encore démarré
* ⏳ **PWA** (installable, offline cache des métadonnées)
* ⏳ **Chromecast / AirPlay**
* ⏳ Mode **« TV »**
* ⏳ **Theming avancé** (polices, densité, accents)
* ⏳ **Playlists natives Dailymotion & PeerTube** (les 3 autres fournisseurs sont en **N/A** — voir la matrice : API Twitch supprimée, Odysee/Rumble sans API d'écriture) : inscription d'app OAuth Dailymotion, JWT par instance PeerTube
* ⏳ **Nouveaux fournisseurs** — plan détaillé ci-dessous
### 🆕 Nouveaux fournisseurs — plan d'implémentation
**Candidats** (par rapport / effort ; endpoints et quotas à re-valider au démarrage de chacun) :
1. **Kick** — API JSON publique (sans quota clé à confirmer), modèle identique à Twitch (live / vod / clip) : recherche + métadonnées via l'API, lecture via l'embed officiel, feature-flag `FF_KICK`. Priorité haute : seul candidat avec un vrai live concurrent de Twitch.
2. **Vimeo** — recherche et détails via l'API Data (clé de développeur), formats/téléchargement via yt-dlp (extracteur Vimeo mature), lecture via l'embed officiel `player.vimeo.com`.
3. **SoundCloud** — recherche via yt-dlp, lecture via le widget iframe officiel ; audio seul → carte avec pastille « audio », **hors flux Shorts**.
4. **TikTok** — recherche via yt-dlp + embed officiel ; **risque élevé** (anti-bot de la même classe que Cloudflare/Rumble) : ne commencer qu'après les 1-3, avec contrat d'erreur strict (`503` = challenge, `404` = introuvable prouvé — jamais l'inverse).
**Chemin d'implémentation standard** (identique pour chaque fournisseur, c'est celui des 6 existants ; la procédure détaillée côté recherche/filtres est aussi dans « Ajouter un provider en 5 minutes » en fin de README) :
1. **IDs & URLs** — id court dans `src/app/shared/providers/provider-ids.ts` + couleur du badge fournisseur ; brancher `providerUrlFrom()` (`server/index.mjs`) — jamais d'URL provider écrite à la main ; transmettre tels quels `instance` / `slug` / `sourceUrl`.
2. **Métadonnées** — privilégier l'API native du provider quand elle existe (modèle Odysee : `resolve` LBRY, ~0,2 s), sinon tuyau yt-dlp `youtubedl(url, {dumpSingleJson, skipDownload})` qui alimente gratuitement `/api/details/:provider/:videoId` et `/api/download/:provider/:videoId/formats`.
3. **Recherche** — `server/providers/<p>.mjs` = **un seul cœur** fetch + parse ; enregistrer `search()` dans `registry.mjs` (fan-out), les chaînes dans `channel-registry.mjs` ; rate-limit + cache dans un routeur fin dédié si besoin (modèle `server/rumble.mjs`). Ne jamais créer un second parseur.
4. **Capacités & robustesse** — déclarer `suggest / live / channelMeta / channelContent` dans le bloc capacités de `registry.mjs` ; feature-flag `FF_<ID>` automatique via `feature-flags.mjs` ; dégradation **toujours** `200 + available: false` + état « pas de données » dans l'UI, jamais une erreur ; contrat d'erreur net : `404` = introuvable **prouvé** (HTML reçu), `503` = blocage/rate-limit.
5. **Shorts** — requêtes dédiées dans `SHORTS_QUERIES` + filtre de durée (serveur si l'API le supporte, client sinon), branche catalogue `type=shorts` ; tout chemin d'alimentation du flux passe par `dedup()` (sinon feed vide en silence) ; cadre vidéo avec attribut `data-video-provider` (contour 1 px).
6. **UI** — cartes, `/watch` et `/shorts` réutilisent les composants existants, **aucun composant par fournisseur** ; texte FR hardcodé sur `/watch` et `/shorts` (pas de clés i18n), ailleurs `| t: 'repli FR'` déclaré des **deux côtés** de `src/services/i18n.service.ts` (dictionnaire EN et dictionnaire FR).
7. **Tests** — `server/tests/<p>*.test.mjs` hors ligne (fixtures HTML/JSON, zéro spawn yt-dlp, zéro appel réseau) + script `test:<p>` dans `package.json` ; passer aussi `test:contract` et `test:shapes`.
8. **Livraison** — tests verts **avant** commit ; matrice fournisseurs + ce roadmap mis à jour (✅ uniquement une fois câblé de bout en bout) ; vérif navigateur desktop **1280×900** ET mobile **390×844** (dont `/watch` et `/shorts`, FAB menu visible sur tous les états) ; instance locale rebuildée, puis image prod `docker/build-img.ps1` + déploiement `docker/deploy-img.ps1`.
---
## 🔒 Notes de sécurité
* ❌ Ne **jamais** versionner de secrets réels
* 🔑 Utiliser `.env` hors VCS + restrictions par referer côté fournisseurs
* 🚧 Limiter l’exposition publique de l’API, activer **rate-limit** (déjà en place)
* 🧪 Ajouter des tests e2e/units pour les zones critiques (auth, playlists, downloads)
---
## 🤝 Contribuer
Issues & PR bienvenues ! Proposez des connecteurs, des règles de parsing plus robustes ou des idées d’UX.
Astuce : ouvrez une PR “Draft” tôt pour discussion/feedback.
---
## 📜 Licence
MIT (voir `LICENSE`)
---
> 💡 **Tip produit** : gardez l’UX simple — **Thèmes fixes sous le header**, **Shorts séparés**, **CTA clairs** (“View All”, “Add to playlist”), et utilisez les **états vides** (empty states) sur History/Liked/Playlists pour guider l’utilisateur.
---
## 🧩 Ajouter un provider en 5 minutes
> Complète le plan général « 🆕 Nouveaux fournisseurs » du [roadmap](#️-roadmap) : cette section détaille le côté **recherche / filtres / deep-link**, le plan du roadmap couvre IDs, URLs, métadonnées, shorts, capacités et livraison.
1) Front — Registry
- Éditez `src/app/core/providers/provider-registry.ts` et ajoutez une entrée `ProviderSpec` avec:
- `id` (ex. `"yt"`), `displayName`, `shortLabel`, `icon`, `colorClass`
- `supports` (search/shorts/live/playlists)
- `buildSearchUrl(q)` (facultatif côté front)
2) Back — Handler
- Créez un fichier `server/providers/<provider>.mjs` qui exporte `default` avec:
- `id`, `label`
- `async search(q, { limit, page })` → `Promise<Suggestion[]>`
- `async search(q, { limit, page, sort, filters })` → `Promise<Suggestion[]>` (`filters` = `{ type, duration, period, sort }` normalisé par `server/search-filters.mjs` ; ignoré par défaut, affiné en post‑traitement si besoin).
- Enregistrez-le dans `server/providers/registry.mjs` pour être éligible au fan‑out `/api/search`.
3) API — Recherche multi‑providers
- L’endpoint `GET /api/search?q=...&providers=yt,dm,ru&limit=...` valide les providers et déclenche les recherches en parallèle (timeouts), puis retourne:
```json
{
"q": "documentary",
"providers": ["yt","dm","ru"],
"groups": {
"yt": [{ "title": "...", "id": "...", "duration": 192, "isShort": false }],
"dm": [],
"ru": [{ "title": "...", "id": "...", "isShort": true }]
}
}
```
4) UI — Panneau de filtres
- Le `SearchBoxComponent` (standalone) rend le champ + le bouton **Filtres** (`SearchFilterPanelComponent` : sources, type, période, durée, tri, recherches récentes) et les pastilles des filtres actifs.
- Le composant émet `(submitted)` avec `{ q, providers, filters }` et `(filtersChange)` avec les filtres seuls ; `SearchService` (RxJS state) porte `filters$` et l’envoie à chaque adapter.
- Le modèle de filtres est partagé : `src/app/search/filters.ts` (front) et `server/search-filters.mjs` (API) implémentent les mêmes règles.
5) Deep‑link
- Les URLs du type `?q=…&providers=yt,ru` relancent la même recherche. Assurez-vous de propager le paramètre `providers` lors des navigations.
6) Tests
- Unit (`npm run test:search`) : parsing `@yt`, clavier/focus du panneau de suggestions, opérateurs `live:`, brouillon + apply du panneau de filtres, composition et fan-out de `SearchService. `npm run test:filters` : normalisation/bornes/post-filtrage/mapping providers (offline).
- e2e (`npm run test:search-e2e`) : `providers=yt,dm` → réponse ciblée sur [yt,dm]; deep-link `providers=pt`; fallback registry complet; préférence persistée; route SPA.
Astuce: l’ajout d’un provider ne nécessite pas de modifier les composants — il suffit d’ajouter une entrée dans le registry front + un handler API.