/index.css n'est pas fingerprinté (index.html le référence en dur) mais
héritait de `maxAge: '1h'` : après un redéploiement F5 continuait de servir
l'ANCIEN CSS jusqu'à une heure — correctif présent sur le serveur, invisible
au navigateur (« je ne vois toujours pas la ligne fournisseur »). Il rejoint
index.html dans le `setHeaders` no-cache (ETag → 304, coût nul).
Test : `index.html et index.css : jamais mis en cache` dans
server/tests/api_coverage.test.mjs (29 pass / 0 fail).
- Mobile : le panneau d'informations démarre à `var(--header-offset)` (bas du
header fixe z-50) au lieu de `top: 0` — ses 56 premiers px passaient
DERRIÈRE le header (bouton Fermer compris). Même motif que le drawer latéral
de app.component.html ; `md:static` neutralise ce top au bureau.
- Le cadre vidéo est arrondi partout (`rounded-2xl`, plus `rounded-none` sous
768 px).
- La ligne de couleur du fournisseur était invisible sur /shorts : l'iframe
positionnée peint par-dessus l'outline du cadre. Redessinée en overlay
`.shorts-frame[data-video-provider]::after` (bord 1 px `var(--prov)`,
`border-radius: inherit`, z-index 5 sous le chrome, pointer-events: none).
Vérifié au navigateur (harnais Playwright local) en mobile 390x844 ET desktop
1280x900 : gap tiroir/header, coins, couleur calculée du ::after, et avant/après
sur l'instance de test locale.
Le lien « API » du menu ouvre /api/docs (et /proxy/api/docs) : Swagger UI
auto-hébergé (swagger-ui-dist, aucun CDN, thème sombre), spécification chargée
depuis `${base}/openapi.json`.
/openapi.json devient exhaustif :
- inventaire généré depuis l'arbre Express (collectApiRoutes) : `:param` →
`{param}`, `*` → `{path}`, miroirs /proxy/* et métadonnées écartés, préfixe
/api retiré (le document porte les serveurs /api et /proxy/api) ;
- `app.all()` réduit à GET — ses 35 méthodes HTTP (acl, propfind, m-search…)
entraient dans le document et figaient Swagger au bout de 2 groupes ;
- authentification marquée automatiquement (middlewares de route ET de préfixe,
ex. `r.use('/download', …)`), route publique laissée publique ;
- tags + descriptions de groupe, résumés FR pour les routes courantes ;
- 3 schémas d'auth : bearerAuth (JWT), apiKeyAuth (X-API-Key), cookieAuth ;
- description : méthodes d'auth avec exemples, codes d'erreur et seuils de
débit, section « Serveur MCP » avec snippet `mcpServers` et la liste des
outils LUE dans mcp/server.mjs au démarrage (aucune liste à maintenir).
Dockerfile : `mcp/` copié dans l'image (cette lecture doit exister en prod).
package.json → 1.0.61 (tag de cette livraison).
Tests : api_coverage +3 cas (page /docs + assets, exhaustivité/méthodes/tags/
sécurité, 3 schémas + outils MCP dans la description) → 51/51 verts.
Vérifs instance locale : lien menu = /proxy/api/docs, 114 opérations rendues
sur 12 groupes, bouton Authorize + section MCP visibles, zéro erreur console.
package.json est la source unique de version (menu du compte +
info.version OpenAPI) ; le registre incrémente le patch à chaque déploiement,
donc le tag de cette livraison sera 1.0.60. Bumper package.json AVANT
docker/deploy-img.sh maintient les trois surfaces cohérentes.
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.
Refonte du panneau du bouton avatar (haut à droite du header) : lignes
« icône + titre gras + sous-titre gris » pour Thème (thème appliqué, pastilles
dépliées au clic), Administration, API (openapi.json), Préférences, Guide
d'utilisation, À propos (version + tagline + code source) et Sessions ;
Déconnexion isolée après séparateur et pied de page « Version ».
Ajout de src/app/version.ts (APP_VERSION, alignée sur le tag semver du
registre : 1.0.59 pour cette livraison) affichée dans le menu.
Clés i18n FR + EN ajoutées des deux côtés, ligne README « Menu du compte
refondu » dans le roadmap.
text-white est thémé globalement (sinon mot-marque et boutons disparaissent
en thème clair) : les pastilles de durée, les badges LIVE/SHORT/CLIP et le
chrome Shorts en prenaient aussi — noir sur noir en thème clair. Exemption
scopée aux cadres [data-video-provider] et à .shorts-stage ; le panneau ⓘ
garde son scope plus spécifique (reste thémé).
Re-vérifié : 245 assertions UI (4 thèmes, desktop + mobile) + 13 couleurs de
contour au vert.
Balayage de la couche d'overrides de index.css : les 75 jetons neutres
slate/zinc/gray qui n'étaient pas couverts (36 fichiers) sont mappés sur les
variables du thème — fonds, textes, bordures, anneaux, divide, placeholders,
dégradés + variants hover/focus/focus-within/disabled/group-hover, alpha via
color-mix. Barre de recherche, menus, panneaux, cartes et résultats de
recherche suivent donc le thème appliqué (dark/light/blue/black) au lieu de la
palette slate figée. Volontairement exclus : bg-white/text-black (boutons
inversés), pastilles « inversées » conditionnelles au thème clair, chrome
au-dessus des vidéos, rouges sémantiques.
Panneau d'informations ⓘ des Shorts re-colorié dans son propre scope
(fond/bordures/textes/pastilles d'action → panel/text/muted), les mêmes tokens
restant blancs ailleurs (rail, méta). Marqueur border-l-4 des titres de page
sur --accent.
Cartes vidéo harmonisées : titre text-sm font-semibold leading-snug
(résultats de recherche, chaîne, thèmes), méta text-xs, rayon rounded-lg sur les
cadres, pastille de durée sans font-mono, titre « Abonnements » avec le
marqueur d'accent.
Contour fournisseur 1 px autour de chaque cadre vidéo, à la couleur du premier
stop du dégradé de la pastille (table unique en CSS, ids longs + courts) :
résultats de recherche, grille chaîne, pages thèmes, watch (lecteur + « à
suivre »), shorts, historique, aimés/à regarder plus tard, détail de playlist.
Tests verts (search, filters, kind, section, highlight, shorts-catalog,
api 41/41) ; vérif UI Playwright desktop 1280x900 + mobile 390x844 sur 4
thèmes : 250 assertions + 13 couleurs de contour au vert.
Le flux Shorts ne porte pas de description (et pas toujours de vues) : le
panneau affichait « le fournisseur ne transmet pas de description » alors que
`/api/details` la connaît. Il l'interroge maintenant à l'OUVERTURE, une fois
par vidéo (chargement squelette, échec silencieux + retentative au clic
suivant), et le serveur sert le tout depuis son cache 6 h.
- `detailsEnriched()` + `detailsLoading()` : description et vues réelles,
`panelDescription()`/`panelMeta()` préfèrent le flux puis complètent.
- Paramètres par fournisseur réutilisés (`instance` PeerTube via
`peerTubeHostOf`, `slug` Odysee via `odyseeSlugOf`) : mêmes règles que le
reste du composant, pas de second jeu de conventions.
- Rafraîchi si le panneau reste ouvert pendant un changement de short.
- Rail + menu FAB : « Enregistrer » branché sur la liste réelle
« À regarder plus tard » (LikesService, tag `watch-later`, page
/library/watch-later) — état hydraté par vidéo comme J'aime, désactivé
hors connexion (la liste est serveur). C'est le bouton « Enregistrer » de la
description du rail, en vrai plutôt qu'en décoration.
- Panneau ⓘ : plus de promesse de commentaires — l'app n'en a aucune source
(le champ du modèle est un tableau vide sur /watch) ; le bloc renvoie
explicitement vers la page complète (description, téléchargements, transcript).
Mesuré avant : `/api/details/odysee/…` = 44,7 s puis « No video formats
found! » — la page Watch restait une minute sans titre, vues, description ni
vignette. L'extracteur `lbry` de yt-dlp n'est pas utilisable ici.
- Nouvelle branche Odysee dans /api/details : `resolve` de l'API LBRY
(`server/providers/odysee.mjs` → `resolveOdyseeVideo`) = 229 ms mesurés,
titre/description/vignette/durée/date/canal + avatar. Claim introuvable →
404 `odysee_claim_not_found` (le client garde les données du flux), panne →
502 `odysee_details_failed`. Jamais de repli yt-dlp (45 s pour échouer).
- `views` volontairement OMIS pour Odysee : ni LBRY ni lighthouse n'exposent
de compteur pour un claim isolé (l'`effective_amount` est un montant LBC).
- Cache mémoire de /api/details : 6 h, LRU 500 (`DETAILS_CACHE_TTL_MS`,
`DETAILS_CACHE_MAX_ENTRIES`) — métadonnées immuables, chaque /watch
relançait un dump complet.
- `views`/`duration` ne sont plus émis quand ils valent 0 : un 0 écrasait la
valeur du flux côté client (merge sur `typeof === 'number'`), d'où les
« 0 vue » sur les fournisseurs muets — même doctrine que
`parseRumbleViews` (« jamais de compteur à 0 »).
- Mapping pur `odyseeClaimToVideo` testé hors ligne : 21 assertions,
`npm run test:odysee`.
- Supprime les flèches Préc./Suiv. aux bords gauche/droite (troubles
d'affichage récurrents) : la navigation passe au rail vertical ancré au
bord DROIT de la vidéo, centré au bureau et ancré au-dessus de la méta
sur mobile, avec des flèches ↑/↓ plus grosses (44 px).
- Même rail : informations, j'aime, s'abonner (pastille = avatar), commentaires,
partager, lecture auto. Toujours monté (comme le FAB), désactivé sans vidéo.
- Bouton ⓘ : panneau d'informations (titre, vues, engagement, chaîne,
description, commentaires → Watch). Colonne à droite au bureau (la vidéo
rétrécit, jamais masquée : --shorts-panel-w retiré du calcul de taille),
tiroir plein écran sur mobile. Échap ferme.
- Transition de short : le doigt fait glisser le cadre (chrome solidaire),
résistance aux extrémités, seuil de bascule 25 % de la hauteur, ressort
sous le seuil, éjection + emboîtement au-delà (Web Animations, respecte
prefers-reduced-motion). Aucun état de lecture modifié.
- Lecture auto : interrupteur réel (rail + menu), persisté, respecté par le
minuteur et par la fin de vidéo YouTube.
- Tap sur un contrôle du rail = plus de play/pause parasite (garde
isChromeTarget dans onTouchStart/onTouchEnd).
- FAB menu conservé (haut droit, déplaçable) ; son touchmove ne déplace plus
le cadre en même temps.
Retour utilisateur : les flèches Préc./Suiv. n'étaient toujours pas
visibles et la position attendue est précisée — une à GAUCHE et une à
DROITE de la vidéo, centrées verticalement.
- Flèches remises aux bords gauche/droite du cadre (top-1/2,
-translate-y-1/2) avec contraste renforcé : ring-2 white/50, fond
black/55 — lisibles sur toute image.
- Sorties de @if (current()) comme le menu : TOUJOURS montées, donc
présentes même sur l'état de flux mort (désactivées) — on ne cherche
plus où naviguer.
- Chaque changement de vidéo réveille les contrôles 2,5 s (poke sous
matchMedia hover : sur tactile le chrome reste visible en permanence,
un poke y aurait armé le minuteur et masqué le chrome — régression
détectée par la recette mobile puis corrigée).
- Recette Playwright desktop+mobile : 17/17 (bords à 6 px, centrage
vertical ±1 px, fondu repos 0.18, plein au mouvement, mobile plein,
pas de chevauchement 2D avec la flèche retour, flèches + menu
présents sur feed mort). Build + test:section + test:shorts-catalog
verts avant commit.
Demandes (page /shorts) : retrouver les flèches Préc./Suiv. là où se
trouve le menu, et des contrôles presque invisibles au repos pour
laisser la vidéo respirer (ton pro).
- Flèches déplacées du milieu des côtés vers la rangée HAUTE du cadre,
centrées : même niveau que le FAB menu, sans chevaucher la flèche
retour mobile (gauche) ni le FAB (droite). Le toast « Lien copié »
passe en z-30 pour rester lisible sous la rangée.
- Fondu au repos : nouvelles règles index.css — flèches (.shorts-ctrl)
et FAB à 0.18 d'opacité quand le chrome est au repos (2,5 s sans
souris), pleins au moindre mouvement (chromeVisible) ou au survol
direct (:hover), jamais atténués quand le panneau est ouvert
(pm-fab-open). Sur tactile, le chrome ne se masque jamais (pas de
mousemove : comportement voulu, mobile inchangé).
- Le minuteur d'immobilité est armé au chargement (pokeChrome sous
matchMedia('(hover: hover)')) : sans un premier mousemove,
chromeVisible restait true et le fondu ne s'armait jamais.
- mouseenter sur l'iframe : en entrant sur la vidéo, le document
parent ne reçoit pas les mouvements internes à l'iframe — l'entrée
réveille les contrôles.
Recette Playwright desktop+mobile, 14/14 : position haute centrée,
absence de chevauchements, repos 0.18 (flèches + FAB), retour plein
après mouvement réel, mobile plein en permanence. Build + test:section
verts avant commit.
Trois symptômes, deux racines — diagnostiqués au navigateur (Playwright
desktop + mobile) et hors navigateur (vraies réponses API + fonctions
front réelles importées via ts-node) :
1. Double dedup() dans handleShortsResponse : show() re-dédupliquait une
liste déjà dédupliquée par son appelant → le second passage marquait
tout comme « déjà vu » et posait items=[] en silence → écran
« Aucun short à afficher » sans erreur ni requête suivante, dès que
le catalogue type=shorts rendait moins de 3 items (aléa de la
requête tirée au sort). Racine des écrans morts, présente depuis le
commit 983c6cb.
2. Menu FAB et flèches Préc./Suiv. vivaient dans @if (current()) :
absents de TOUS les états vides, donc impossibles à utiliser
exactement quand le flux meurt (impossible de changer de
fournisseur). Le menu est désormais toujours monté (z-30 au-dessus
des placeholders), et la flèche désactivée passe en opacity 0.4
(au 1er item, Préc. était quasi invisible à 0.3).
3. Format : Rumble n'expose AUCUNE dimension — vérifié : vignettes og:image
toutes en crop fixe 16:9 (9/9 échantillons, 1280x720 et 480x270) et
aucun width/height dans les pages vidéo → un filtre d'orientation
par vignette est impossible, la classification reste durée-seule.
En revanche le flux natif rumble.com/shorts (les vrais shorts
officiels) devient la voie primaire : loadFeed va droit au chemin
historique au lieu du catalogue qui ne rendait que des brives 16:9.
Tests verts avant commit : npm run build, test:section,
test:shorts-catalog, test:filters.
Le scan générique acceptait tout href /^\/v/ dont le regex d'id
matchait : `/videos` donnait `videoId: "videos"` — une fausse carte en
tête du flux /shorts (pré-validée puis retirée, mais visible le temps
du résolve). Probe live : 32/32 vrais ids rumble portent au moins un
chiffre, seul /videos n'en a pas. Garde « >=1 chiffre » dans l'id,
plafond documenté (un id sans chiffre ferait sauter sa carte).
Effet de bord heureux : la branche /video/123 (isLegacy) était morte
depuis toujours — le regex capturait « video » avant elle — elle
redevient accessible.
Tests : lien /videos ajouté au fixture du flux, 64 assertions vertes.
Probe live depuis le conteneur : rumble.com renvoie 404 (vraie page,
non-challenge) pour un id inexistant. fetchHtml les classait en échec
total, d'où deux régressions : 503 sur /video (les vidéos mortes ne
sont plus retirées des Shorts) et armement du cooldown 60 s pour toute
la maison au premier id bogus.
- fetchHtml retourne toute réponse nette non-challenge ; le statut est
jugé par les appelants (tous dans le même fichier).
- rumbleFailureForStatus() exporté : 403 -> 503
rumble_cloudflare_challenge (bandeau identique à la recherche),
404/410 -> 404 rumble_not_found, 5xx -> 503 rumble_upstream_error.
/video, /browse et /shorts passent par ce mapping.
- handler.search ne parse plus que du HTTP 200 : les pages 404 de
Rumble embarquent des cartes de recommandation qui seraient
autrement prises pour des résultats de recherche.
- Tests : 7 assertions sur le mapping (sans réseau), 64 au total.
- server/providers/rumble.mjs devient l'unique implémentation : fetch
(cookie jar __cf_bm + curl_cffi + cooldown global 60 s après un échec
total), parsing liste/vidéo avec surcouche JSON-LD partagée, vues via
parseRumbleViews partout (« 1,2K » n'est plus lu en 12 sur /api/rumble).
- server/rumble.mjs = couche HTTP mince : limiter 20/min, cache 60 s,
5 routes qui délèguent au cœur ; /search passe par le registre de
providers (cache SQLite, negative cache et channelRef partagés avec
/api/search).
- Contrat d'erreur : un blocage Cloudflare est un 503
rumble_cloudflare_challenge, plus jamais un 404 (qui faisait retirer
les vidéos du flux Shorts lors d'un challenge) ; page reçue sans
identité = 404 rumble_video_not_found.
- Code mort supprimé : scrapeRumbleVideo d'index.mjs (191 l., 3e
implémentation jamais appelée), rumbleLimiter 10/min jamais monté,
import cheerio devenu inutile. Net -238 lignes.
- Tests : parseur générique (vues, durées, ids /shorts/), contrat 503
sans réseau via le cooldown, durées absentes -> undefined.
- Docs : API_MCP_GUIDE §5.5 (routes, rate-limit réel, erreurs) + note
README « Rumble peut être temporairement absent selon le réseau ».
- Flèches : elles vivaient EN DEHORS du cadre (et se fadaient avec le
chrome auto-hide). Elles sont désormais posées dans le cadre, à mi-hauteur
sur les bords, toujours visibles (classe `shorts-chrome` retirée), sur
mobile comme sur bureau, et le stopPropagation protège le geste tactile.
- Rumble : la plateforme est16:9 mais n'expose ni largeur ni hauteur →
l'embed tombait dans un cadre 9:16 (étiré / réduit). `isWideEmbed()`
(pur + testé) décide de la boîte : dimensions connues => orientation
réelle, sinon repli « rumble = paysage » ; la CSS `.embed-wide` rend une
iframe 16:9 centrée (434x244 dans un cadre 434x771, mesuré). Les vidéos
verticales (dimensions connues) gardent le cadre pleine hauteur.
- État vide : un fournisseur sans résultat laissait un cadre NOIR muet
(ni squelette, ni message) — il affiche désormais « Aucun short à afficher
pour ce fournisseur » + Réessayer.
Tests : test:shorts-catalog (3 cas isWideEmbed), test:kind, test:section,
test:search verts ; build ng OK ; sondage Playwright local : flèches dans
le cadre + navigation suivant/précédent réelle, boîte embed-wide 16:9
centrée, short vertical inchangé (iframe pleine hauteur), état vide affiché.
- PeerTube : l'adaptateur pt.ts reconstruisait l'item SANS `url` et le pont
catalogue ne conservait l'URL que pour Odysee → /shorts reconstruisait
l'embed sur l'instance ACTIVE → « Sorry this video does not exist » sur
toute vidéo fédérée. `url` est désormais miroitée dans VideoItem (contrat
v2), propagée par PtAdapter et gardée par catalogShortToVideo :
peerTubeHostOf() dérive enfin le host d'origine. Vérifié de bout en bout :
embed tube.blueben.net → 200 + vrai titre, cas de test dédié dans
shorts-catalog.spec.
- FAB du menu : déplaçable au pointeur (seuil 6 px, position en % du cadre,
persistée en localStorage). Le clic qui suit un glissement n'ouvre pas le
menu, et le panneau s'ouvre TOUJOURS vers l'intérieur du cadre (classes
pm-opens-left / pm-opens-right / pm-opens-up posées par applyAnchor) :
sinon overflow:hidden le rognait dès qu'on déplaçait le bouton.
- Bureau : --shorts-chrome 9rem → 4rem (la vidéo passe de 691 à 771 px de
haut en 1280x900, 389 → 434 de large) et menu compact au-delà de 768 px
(FAB 36 → 30 px, panneau 233 → 202 px). Mobile inchangé (36 / 636 px).
Tests : test:shorts-catalog (nouveau cas PeerTube), test:kind,
test:section, test:search verts ; build ng OK ; sondage Playwright 19/19
sur l'instance locale (layout, glissement + restauration, embed PeerTube
200 sans erreur).
La barre partagée avait perdu la marge basse des blocs qu'elle a remplacés :
sur /p/:provider/t/:theme la 1re carte collait aux boutons (0 px, l'ancien
panneau portait mb-8), et /t/:theme ne gardait que mt-6.
- les deux pages thèmes enveloppent la barre dans un div mb-8 ;
- /t/:theme : le conteneur de sections perd son mt-6 (compensé par mb-8),
plus aucun double espace.
Mesuré au navigateur (Playwright, desktop 1280 + mobile 390) : 32 px sous
la barre sur /p/:…, 74 px jusqu'à la 1re carte sur /t/:…, build ng OK,
test:search vert. Instance locale 4200 rebuildée.
Nouveau composant partage app-filter-toolbar, utilise tel quel par la page
de recherche ET les deux pages de themes (meme visuel, seul le cablage
differe : URL pour la recherche, signaux locaux pour les themes).
- Boutons : [Filtres] (pastilles de type, ligne Sources, selects periode /
duree / langue, filtres actifs) et [Tri] (Pertinence, Date de mise en
ligne, Vues, Note (likes), Duree).
- Page de recherche : le bouton oeil « filtres rapides » et le panneau de
selects disparaissent, tout est sous [Filtres] ; le tri a son bouton.
- Sources : boutons Toutes / Aucune. « Aucune » masque tout (la garde
« derniere source masquable » est retiree) et un bandeau vide dit de
reactiver au moins un fournisseur.
- /t/:theme : les 3 pastilles maison sont remplacees par la barre ; les
sources masquent des blocs entiers ; filtres et tri appliques sur les
blocs deja charges.
- /p/:provider/t/:theme : les 5 selects et les pastilles sont remplaces par
la barre ; signaux maison unifies sur le modele SearchFilters (defaut
relevance) ; les onglets Twitch lisent/ecrivent filters().type.
- Tri etendu : FilterSort + rating + duration. Les providers ne connaissent
que relevance/date/views (serveur inchange, repli relevance) : Note et
Duree sont reordonnes cote client via sortItems(), partage avec les pages
themes.
- Communs : sortItems / itemViews / itemRating / matchesLanguage /
formatCount extraits de filters.ts (3 copies d'heuristiques supprimees) ;
cles i18n fr+en (filters.sort.rating, filters.sort.duration,
filters.sortHint, filters.localHint, « Date de mise en ligne »).
Tests : test:search, test:filters, test:suggest, test:kind, test:section
verts + test:api 41/41, build ng OK.
Nouvelle liste dans la section Vous de la barre latérale, avec un bouton
horloge a cote du coeur sur toutes les cartes (accueil, themes, provider-
themes, recherche).
- db : les 4 fonctions de listes (like/unlike/isLiked/list) prennent un
paramètre `tag` — la table `tags` sert déjà de mot-clés génériques, donc
zéro migration.
- api : les 4 routes likes sont générées par registerVideoTagRoutes(prefix,
tagName) ; /api/user/watch-later = même fabrique, même enrichissement
yt-dlp, même middleware cookie-aware. Entrée OpenAPI ajoutée.
- front : LikesService prend un VideoTag, like-button un @Input() list
(icône horloge, ambre quand enregistré, libellés a11y dédiés) — un seul
composant pour les deux boutons, pas de copie.
- cartes de recherche : la grille partagée n'avait AUCUN bouton ; cœur +
horloge ajoutés, masqués sur les lives et cartes chaîne (coin bas-gauche
déjà occupé par le compteur de spectateurs / pas des vidéos).
- route /library/watch-later = LikedComponent via data.list (titre, icône,
placeholder et état vide pilotés par la route), clés nav.watchLater FR/EN.
Tests : npm run test:api 41/41 (nouveau test watch-later : ajout, statut,
indépendance vis-à-vis des likes, liste, retrait, 400, 401) + 21 autres
suites vertes. Build OK, instance locale 4200 rebuildée. Sondage Playwright
desktop+mobile : 12/14 (2 faux positifs du sondage : accordéon replié sur
une route hors /library, 3×401 console pendant la phase déconnectée).
Trois causes du « toujours les mêmes vidéos » (peu importe le fournisseur) :
- watch-short : queryIndex repartait à 0 a chaque loadFeed() -> toujours la
même requête (« shorts ») et donc les mêmes résultats du fournisseur.
Départ désormais aléatoire dans SHORTS_QUERIES (la rotation par page reste).
- watch-short : dedup() ne triait rien -> le 1er lot arrivait toujours dans
l'ordre du fournisseur. Le lot neuf est maintenant mélangé (Fisher-Yates),
comme le chemin « Pour toi » le fait déjà. Un seul point de passage, donc
catalogue, historique, repli trending et Twitch sont couverts.
- youtube-api : la rotation des top games Twitch démarrait à l'index 0 ->
mêmes clips à chaque session. Départ aléatoire, rotation conservée.
Le cache serveur (5 min / 30 min YT) n'est pas en cause.
Vérif instance locale : YouTube 3/3 premières vidéos distinctes sur 3
rechargements, Twitch 2 distinctes sur 3. tests verts.
- /watch : muted=true supprime — son actif dès le démarrage (le chargement
de l'embed a lieu dans un geste, donc l'autoplay sonore est autorisé).
- /shorts : muted piloté par le bouton Son du menu (défaut : son ON) — ce
bouton était jusqu'ici sans effet sur Twitch.
- /shorts : plus d'avance auto pour Twitch. L'embed clips est cross-origin et
n'expose AUCUNE API d'état : impossible de savoir si la vidéo est en pause,
donc on ne change jamais de vidéo tout seul (swipe / flèches / molette).
- Verifs instance locale : 4/4 (URLs + aucune avance après 75 s d'un clip de
49 s) + 6/6 (nudge) + 8/8 (chargement au premier geste).
- Methode « touch listener b4 loading the iframe » : l'URL de l'embed Twitch
n'est produite qu'apres un premier geste — navigator.userActivation.
hasBeenActive a l'arrivee (clic sur un lien = fenetre d'activation de 5 s),
sinon pointerdown / keydown / wheel. Le premier chargement de l'iframe
tombe donc dans un contexte d'activation utilisateur.
- Invite « Touchez pour charger la vidéo » sur /watch et /shorts pendant
l'attente (sinon spinner infini sur /watch, cadre noir sur /shorts).
- /shorts : pas d'avance auto tant que l'embed n'est pas charge — le flux
ne switchait plus d'une video a l'autre sans jamais jouer.
- Le nudge (recharge &r=1 au premier geste) est conserve pour les videos
montees sans geste (avance auto, arrivee avec activation deja consommee).
- Verifs instance locale : 8/8 (arrivee a froid : invite + montage au geste)
et 6/6 (arrivee avec activation : montage immediat + recharge unique).
- /watch + /shorts : le player Twitch refuse de demarrer sans geste
utilisateur (anti-bot) et l'iframe etant cross-origin on ne peut pas
cliquer dedans. On ecoute document:pointerdown (+ le keydown existant) et
on ajoute &r=1 a l'URL de l'embed de la video courante : l'iframe se
recharge dans le contexte d'activation utilisateur.
- Une seule fois par video (twitchNudgedFor) : pas de boucle de
rechargement, les videos suivantes sont re-armees automatiquement.
- Verifie sur l'instance de test : 6/6 (URL initiale sans r=1, r=1 apres
le geste, aucune recharge au 2e geste) sur /watch et /shorts.
- /watch : les embeds Twitch etaient en autoplay=false depuis le 1er commit
(clips clips.twitch.tv + channel + VOD player.twitch.tv) alors que tous les
autres fournisseurs de la page demarrent automatiquement en muet
(YouTube mute=1, Dailymotion mute=1, Odysee muted=1, PeerTube autoplay=1).
- Passe a autoplay=true&muted=true : le lecteur demarre tout seul, le son se
reactive via les commandes du player (meme convention que /shorts).
- /shorts : inchange, l'embed demandait deja autoplay=true&muted=true.
- app-player-menu : stopPropagation wheel/touchstart/touchend sur le host —
les handlers window:wheel et window:touch* de /shorts ne volaient plus les
gestes sur le panneau (impossible de scroller jusqu'en bas du menu, tap =
play/pause, swipe = changement de short)
- /shorts : ArrowUp/Down ignore quand le menu est ouvert
- /shorts : le FAB passe en haut a DROITE du cadre (la fleche retour mobile
z-20 passait par-dessus le FAB piege dans la scene z-10)
- Look/taille : FAB 40->36px, panneau 288->240px, items compacts,
max-height 70vh/480 -> 65vh/430 (plus de rognage par overflow:hidden),
pastille en degrade + relief, panneau rounded-xl + highlight interne
- PlayerMenuComponent : FAB circulaire translucide (haut-gauche) + panneau
deroulant, commun aux deux pages (position, style, open/close, clic exterieur)
- /shorts : consolidation de tous les boutons (fournisseurs, langue, actions)
dans le menu FAB ; suppression du rail a droite et de la rangee du haut
- /watch : actions (cinema, qualite, j'aime, playlist, transcription,
telechargement, partage, ouvrir) deplacees dans le menu FAB
- AddToPlaylistComponent : variante menuItem pour s'integrer au menu
- styles .pm-item/.pm-sep/.pm-section-label dans index.css
Dailymotion exige désormais un Player ID (fév. 2026) : player.html?video=...
sans Player ID répond HTTP 403 'Forbidden'. L'URL correcte est
geo.dailymotion.com/player/{PLAYER_ID}.html?video=...
- helper dailymotionPlayerId()/dailymotionEmbedBase() (config window.DAILYMOTION_PLAYER_ID, repli demo x8lr5)
- /watch et /shorts utilisent le nouvel endpoint avec Player ID
- config.local.example.js documente la config
L'iframe Shorts utilisait referrerpolicy=strict-origin-when-cross-origin
alors que /watch utilise 'origin'. C'est la seule difference restante entre
les deux pages pour Dailymotion (URL identique, allow_embed=true) et elle
fait repondre 'Forbidden' au player. On aligne sur 'origin'.
Revert du referrerPolicy helmet (no-op pour les iframes qui ont leur propre
attribut, risque de side-effects inutile sur les CDN de vignettes).
Boutons fournisseurs avec disque aux couleurs de la marque + glyphe SVG (play YT, bulle Twitch, initiale sinon) ; icone etincelles pour Pour toi. Selecteur de langues Profil/FR/EN/FR+EN/Toutes (persiste newtube.shorts.langFilter, invite compris) : chaque flux passe par effectiveShortsLangs (resolveShortsLangs pur + spec). Docs 9.9.
Chrome auto-hide au hover desktop (2,5 s). Capteur tactile mobile : swipe garanti (l'iframe mangeait les touchers), tap = play/pause YT ou 6 s d'acces direct au lecteur sinon. Fleche retour accueil mobile, cadre quasi plein ecran. Avance auto : handshake YT listening (les evenements player n'arrivaient jamais), onStateChange canonique, retry borne anti-prefetch. Docs 9.9.
Odysee demarre muet (pas de rectangle tap-to-unmute) et ne passe au son que sur clic explicite du bouton Son (le geste autorise l'autoplay sonore). Avance auto : playerState YT (fin -> suivant, pause -> minuteur desarme, reprise -> rearme) + minuteur generique sur duree connue ou estimee (clip Twitch 60 s, sinon plafond Short 90 s). Docs 9.9.
Son active par defaut (YT/OD/DM/PT avec son ; clips Twitch en muet force, sans API de volume). Avance auto en fin de lecture (infoDelivery YT + minuteur duree generique garde par index/videoId, ignore si onglet cache). Dailymotion : allow_embed demande cote serveur, mappe en embeddable, propage en raw.allowEmbed pour le garde isPlayable (fini les Forbidden). Rumble autoplay=1/2 selon muet + nudge playVideo YT.
- format: des que largeur/hauteur sont connues, l'orientation fait autorite (horizontale/carree => jamais short, meme <75 s). Repli duree reserve aux providers muets
- dimensions mappees: Dailymotion (width,height), PeerTube (aspectRatio), Odysee (value.video.width/height) => validation verticale reelle
- YouTube: langue via snippet.defaultAudioLanguage (filtre FR/EN)
- autoplay: demarrage muet, suppression du deblocage automatique au scroll/selection (reload d'embed hors geste => ecran noir); son via le bouton dedie
- PeerTube embed: muted=1 pour l'autoplay
- server/search-filters.mjs: miroir de la regle d'orientation + tests mis a jour
- autoplay: les Shorts demarrent muets (comme Watch) pour garantir l'autoplay des iframes; le son se debloque a la premiere interaction (unlockSound) et reste controlable
- proxy prod: ajoute les alias /proxy/twitch-api, /proxy/twitch-auth, /proxy/dm, /proxy/odysee (evite le fallback SPA -> parse JSON 'status 200 ok:false'); forwardJson accepte les deux prefixes. Corrige aussi la validation des clips Twitch morts
- Dailymotion: allow_embed demande dans les flux shorts/trending -> les videos non-embeddables ('Forbidden') sont exclues
- langues: mapping language (Dailymotion/PeerTube/Odysee) + shortsLangOk applique a tous les fournisseurs; en+fr toujours acceptes
- PeerTube shorts: durationMin/Max cote serveur (plus de videos longues au hasard)
- Twitch: validation helix/clips de nouveau fonctionnelle + autoplay muet
Source unique de classification (video-kind.ts) + politique de section centralisee (section-policy.ts). Les composants ne portent plus de seuils ni de regles de type maison.
- section-policy.ts: sectionAccepts / routeForVideo / shortsFeedAccepts /
isPlayable + classifyOnce (enrichissement _kind a l'ingestion)
- watch-short: separe classification (shortsFeedAccepts) et jouabilite
(isPlayable: allowEmbed DM, live/channel Twitch, Rumble 404); supprime le
seuil Rumble 90 s et l'import YOUTUBE_SHORT_MAX_SECONDS du composant
- home: filtre sectionAccepts(...,'home') (plus de shorts dans le trending),
routeForVideo, pagination bouclee (min 20, max 5 iterations)
- themes: _kind pre-calcule une fois, sectionAccepts, capacites live lues
depuis le registre providers (fin de LIVE_PROVIDERS codes en dur)
- video-kind: helpers isNativeVerticalProvider / hasKnownDuration
- routes: alias /shorts/:id et /live/:id pour routeForVideo
- tests: section-policy.spec.ts + video-kind.spec.ts etendus; npm run test:section