Cliquer sur « Pour toi » lançait un fan-out d'environ 22 appels (4 sujets ×
5 fournisseurs + Twitch) et attendait le plus lent : 4 à 8 s mesuré, 13 s à
froid.
- Dès la première vidéo affichée, le lot est préparé EN ARRIÈRE-PLAN
(`warmForYou`) : mêmes tuyaux (`topics$()` puis `forYouFeed$()`), aucune
écriture de signal — l'écran ne bouge pas. Réchauffage périodique toutes
les 5 min (onglet visible), lot réputé frais pendant 5 min.
- `toggleForYou` consomme le lot : prêt → application immédiate ; en cours →
le flux partagé (shareReplay) est rejoint au lieu de déclencher un second
fan-out ; périmé ou absent → chemin réseau comme avant.
- Refactor : `loadForYou` = `topics$()` + `fetchForYou`, le fan-out devient
`forYouFeed$()` (pur, retourne le lot) et un seul `applyForYouFeed()` écrit
les signaux, `seenIds` compris.
- Journal `[forYou]` en console : préchauffage lancé, lot préparé, branche
prise au clic — pour diagnostiquer un clic lent sans rejouer la scénario.
Mesures (Playwright, localhost:4200) : clic avec lot prêt 34-74 ms
(4,3 à 7,6 s avant), clic pendant la préparation 1,3-1,8 s avec UN seul
fan-out, UI intacte pendant la préparation. Harnais :
$LOCALAPPDATA/Temp/pw/foryou-warm-check.mjs (7 checks).
/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
Bug : le @if (hasQuery()) englobant toute la section basse avait ete passe en
@if (hasQuery() && railsOpen()), donc replier le rail masquait aussi la
grille, le loader et le sentinel de scroll infini. Seul le panneau
' types + sources ' est desormais imbrique dans @if (railsOpen()).
Logos : les formes geometriques approximees sont remplacees par les six
marques officielles (traces et couleurs Simple Icons, CC0-1.0), stockees en
local pour eviter toute dependance CDN a l'execution. Dailymotion garde le
bleu de la marque (#0066DC) plutot que le #0A0A0A de Simple Icons, invisible
sur fond sombre.
L'etat actif/masque n'est plus porte par la couleur de l'icone (fixe par
marque) mais par le bouton : fond plein + logo couleur, ou fond transparent +
logo grayscale 40%.
- nouveau bouton oeil a droite de 'Filtres' pour replier le bloc
types + sources (railsOpen, persiste en localStorage) ; pastille d'accent
quand un filtre de type est actif alors que le rail est replie
- nouveau composant app-provider-icon : marques des 6 sources en trait,
currentColor pour que l'icone herite de l'etat actif/masque du bouton
- pastilles sources en icone seule (h-8 w-8), libelle et compte passes par
aria-label / title ; plus de point de couleur ni de nom visible
- suppression de PROVIDER_DOTS et providerDotStyle
Les pastilles fournisseurs portaient des couleurs de marque en dur ([ngStyle])
qui ignoraient le systeme de themes : rendu illisible en thème clair. Tout
l'en-tete rebati sur les classes que le CSS remappe (bg/text/border-slate-*).
- sources : point de couleur plein (active) / creux (masquee) a la place des
fonds de marque, nom complet + compte compact tabular-nums
- actions (Tout afficher / Toutes les sources) en style contour, plus de
bordure pointillee qui ressemblait a un placeholder
- pastilles de type : bg-slate-200 pour l'etat actif, border-transparent
pour eviter le saut de 2px au changement d'etat
- rails regroupes dans un panneau + filet, fade de droite (scroll-rail-fade)
- selects : appearance-none + chevron SVG, libelles visibles remplaces par
sr-only (5 lignes de hauteur en moins)
- bannieres notice/error refaites en neutres themables (illisibles en clair)
- anneau focus-visible en var(--accent) sur tous les controles
- suppression de getProviderColors et formatViews (morts)
- rail 1 : pills de type resserrees (h-6/text-xs), role=group coherent
- rail 2 : les pastilles fournisseur deviennent des boutons d'affichage
(couleur de marque = visible, gris + opacite = masque), avec le compte de
resultats par source, aria-pressed, hover/focus
- filtre d'affichage local (aucun refetch, aucun squelette) persiste en
localStorage ; impossible de masquer la derniere source visible
- panneau de filtres repliable par defaut, selects h-8 inline labels sr-only,
badge du nombre de filtres actifs sur le bouton
- nouveau computed scopedVideos : compteurs de types et tri inter-provider
alignes sur les sources visibles
- suppression d'activeProvidersInfo devenu inutilise
- video-card: etat de chargement de la miniature (shimmer + spinner),
fallback 'Apertu indisponible' avec retry, referrerpolicy no-referrer
- grid: eager sur les premieres cartes, bandeau de rafraichissement quand
la grille est deja peuplee
- search: garde MIN_QUERY_LENGTH=2 (le loading partait pour une requete que
request$ filtrait, grille figee sur des skeletons) + filet de securite 20s
- skeleton: ligne chaine pour coller a la hauteur reelle de la carte
Barre de recherche
- Panneau de filtres unifié (remplace le ProviderPicker) : sources, type
(vidéos/shorts/direct/chaînes), période (heure/jour/semaine/mois/année),
durée (<4 / 4-20 / >20 min) et tri. Pastilles + roving tabindex
(←/→ dans un groupe, ↑/↓ entre groupes), focus trap, Échap, Ctrl/⌘+Maj+F,
recherches récentes, « mémoriser par défaut », « Réinitialiser ».
- Pastilles des filtres actifs sous la barre (retrait en un clic) + compteur.
- Typeahead : un seul keydown (fin du double Enter), ↑/↓ bouclants,
Home/End, PageUp/PageDown, Entrée valide la ligne surlignée (sinon
recherche brute), Tab complète sans chercher, Échap ferme puis vide,
ligne « Rechercher <q> », défilement de l'option active, aria à jour.
- Focus : le panneau se referme dès que le focus quitte la barre
(focusout + relatedTarget + activeElement), pointerdown neutralisé sur les
lignes pour garder le focus dans l'input (plus de scintillement).
- Opérateurs en clair : `linux live:`, `tuto today: long:` deviennent des
filtres et sont retirés de la requête (autocomplétion après le `:`).
Filtres côté serveur
- Modèle partagé : src/app/search/filters.ts + server/search-filters.mjs.
- /api/search?type=&duration=&period=&sort= : InnerTube (upload_date, type,
duration, features), Data API v3 (type, videoDuration, publishedAfter),
yt-dlp --dateafter ; post-filtrage pour les providers sans filtre natif.
Un champ manquant ne fait jamais disparaître un résultat.
- Filtres dans l'URL (partageables) + adapters + SearchService (clé de cache).
- Correction : la recherche Shorts renvoyait 0 (ShortsLockupView/GridShelfView
non mappés, id/titre dans l'endpoint) -> mappage + shelf + vignette/vues.
Tests / docs
- 23 tests unitaires (clavier, focus, opérateurs, panneau de filtres),
server/tests/search-filters.test.mjs (npm run test:filters) + CI,
scénarios e2e des filtres, README/api/README/todo/MCP à jour.
Cinq correctifs signalés sur le mode mobile et le mode desktop.
1. La modale « @ » était confinée dans le header (desktop + mobile)
<header> portait `backdrop-blur-sm`, et `backdrop-filter` fait de l'élément
le containing block de ses descendants en position:fixed. Les overlays du
provider-picker (et du menu Ctrl+K) se résolvaient donc contre le header :
mesuré 1440x64 au lieu de 1440x900, dialogue réduit à 32px de haut pour 466px
de contenu, haut de la fenêtre inatteignable.
Le flou passe sur une couche enfant (`-z-10`) qui n'est pas un ancêtre des
overlays : `fixed` redevient relatif au viewport.
Desktop : dialogue 512x468 centré. Mobile : 358x686, entièrement visible.
2. La barre de thèmes disparaissait sous le header (desktop + mobile)
`--header-offset` vaut 56/64px, mais le header mesure 57/65px (bordure) et
118px quand la feuille de recherche mobile est ouverte. La barre était donc
déjà glissée dessous, et totalement invisible dans ce dernier cas
(hauteur visible mesurée : 0px).
HeaderComponent publie maintenant sa hauteur réelle dans `--header-measured`
via ResizeObserver ; `--header-offset` l'utilise quand elle existe et garde
la valeur statique en repli. Barre visible dans tous les cas, y compris
feuille de recherche ouverte.
3. Barre de thèmes : icônes seules sur mobile
Les libellés sont masqués sous md (`aria-label` conservé pour
l'accessibilité). 7 pastilles visibles au lieu de 3, sans scroll.
4. Avatar en pastille
`.avatar-circle` est renforcé : `rounded-full` dans le markup plus
`border-radius: 9999px !important`, `aspect-ratio: 1/1`, min-width/min-height
et `line-height: 1`, pour qu'aucun style de thème ne puisse le dégrader en
rectangle.
Vérifié : audit navigateur sur 17 routes x 320/390/768/1440 px — aucun
débordement horizontal, barre de thèmes visible partout ; tests du dépôt verts ;
build production OK.
Le push réussissait puis le script sortait en 22 :
curl: (22) The requested URL returned error: 404
Write-Error: ... code d'erreur: 22
Deux causes cumulées, visibles seulement une fois MAX_VERSIONS dépassé.
1. Accept trop restrictif
`docker build` via buildx produit un OCI image index
(application/vnd.oci.image.index.v1+json), pas un manifest v2 plat.
delete_by_tag demandait le digest avec
`Accept: application/vnd.docker.distribution.manifest.v2+json` : le registre
répond 404 — et non 406 — quand l'Accept ne couvre pas le type stocké.
Le HEAD échouait donc sur TOUS les tags, y compris celui venait d'être
poussé. On liste maintenant les quatre media types possibles.
2. La rétention pouvait tuer un déploiement réussi
Sous `set -euo pipefail`, le curl de delete_by_tag n'était protégé par
aucun `|| true` : son code de sortie 22 se propageait et tuait le script,
transformant un push réussi en échec. Le ménage des anciennes versions est
du best-effort et ne doit jamais faire échouer le push.
Symptôme d'origine : la rétention ne s'est jamais déclenchée tant que le
nombre de tags semver est resté <= MAX_VERSIONS (5). Au 7e tag, elle a
commencé à tourner et a fait échouer le script.
Vérifié : deploy-img.ps1 publie 1.0.20, `latest` aligné sur le digest de
l'image locale (sha256:25555d9ee4bf…), rétention purgée sans erreur.
Le header était en grid-cols-[auto_1fr_auto] avec une barre de recherche
non-wrappable (7 chips + input + 2 boutons, aucun min-w-0) : ~1000px de
largeur intrinsèque dans un viewport de 375px. Toutes les routes
débordaient horizontalement et les boutons Connexion/Inscription partaient
hors écran.
Socle
- --header-h / --header-offset / --app-vh centralisent la métrique du
header : supprime les top-16 codés en dur (channel-tabs était en top-0 et
glissait sous le header au scroll).
- Safe-area iOS, viewport-fit=cover, color-scheme/theme-color.
- dvh partout (100vh > hauteur visible sur iOS), overflow-x: clip au lieu de
hidden (hidden cassait silencieusement tous les sticky).
- Cible tactile 44px sur pointeur grossier, skip link, focus-ring clavier,
scroll-rail pour les tab strips.
Header
- Barre mobile (burger, logo, recherche, menu ⋮) + barre desktop inchangée.
- Le menu ⋮ expose ce qui disparaissait : bande des thèmes, préférences,
historique, favoris, téléchargements, thème, connexion/inscription.
- Recherche en feuille sous le header avec focus automatique.
- Échap / clic extérieur / navigation ferment les panneaux transitoires.
Drawer
- Partait à inset-y-0 : les 56 premiers px du menu étaient derrière le
header. Descend sous le header, bouton fermer intégré, verrouille le
scroll, se ferme à Échap.
Barre de recherche
- Chips masquées sous lg, remplacées par un bouton résumé qui ouvre le
sélecteur de fournisseurs. Suggestions en dvh, menu Ctrl+K plein écran
sur mobile.
Pages
- Shorts : cadre 9:16 contraint par dvh ET vw (il débordait en paysage),
Préc./Suiv. qui étaient hors du conteneur height-capped.
- Watch : barre d'actions qui wrappe, mode cinéma en opacity-0 group-hover
donc invisible au doigt, thumbnails fluides, playsinline sur les iframes.
- Historique, thèmes, chaîne, playlists, préférences, sessions : rails
swipables, min-w-0, truncate, en-têtes qui wrappent.
Vérifié : audit navigateur (CDP) sur 17 routes x 7 largeurs (320→1440),
zéro débordement horizontal ; 19 tests d'interaction mobile ; suites de
tests du dépôt vertes (api 40/40, transcript 27/27, search, suggest,
playlists, preferences, subscriptions, highlight, telemetry).
- Expand .env.example with full runtime, cache, Docker, and AI keys
- Add deployment secrets to .gitignore (docker-compose/.env,
docker/.registry.env)
- Remove VIMEO_ACCESS_TOKEN from compose files; add OAuth redirect URIs
- Load registry credentials from .registry.env in deploy-img.sh
- Fail fast if protected registry lacks auth during pre-checks
- Pass DOCKER_USERNAME/PASSWORD explicitly to WSL in deploy-img.ps1
- Remove inline collapse buttons from themes-nav.component.html
Pipe histHighlight + utilitaire pur (HTML source echappe, matchs
insensibles a la casse, multi-mots, chevauchements fusionnes) :
titres/queries/apercus des 3 onglets + lignes des transcripts
deplies, avec badge de comptage d occurrences. test:highlight.
- distinctUntilChanged() sur Subject<void> avalait toutes les frappes
apres la premiere : barre de recherche morte sur les 3 onglets.
- Filtre provider recherches : ids courts stockes (yt, dm) vs filtre
long (youtube) -> zero resultat. Match court+long cote serveur.
- LIKE echappes (%, _, \\) sur q des 3 onglets.
- Watch/transcripts : provider normalise (court+long), lang
insensible a la casse avec prefixe (fr -> fr, fr-ca).
- Ecriture watch canonique (nom long). Test test:history.
Le fallback &tlang= peut renvoyer la piste source en un seul bloc
de plusieurs Ko (throttle YouTube) : il passait devant les pistes
directes et son blob anglais etait mis en cache comme 'fr'.
tlang desormais etape 3 (apres directes signees), isBlobTranscript()
refuse les cues uniques > 2000 chars (fetch, yt-dlp et post-dedupe).
Tests 27/27.
Les URLs timedtext InnerTube repondent 200-vide (throttle YouTube) :
sonde rapide max 2 sans retry, puis dump yt-dlp -> URLs signees en
direct (max 4), puis --write-sub. 200-vide traite comme 429
(retryAfterSec), fmt= plus jamais duplique (signature), cookies
YT_COOKIES_FILE reutilises par le fetch direct. Tests 26/26.
- InnerTube 403: la YOUTUBE_API_KEY restreinte par referrer etait
rejetee sans Referer envoye; ajout headers navigateur + repli cle
publique du client web (probe live: 401 propre = transport OK)
- Watch Later: repli lecture via InnerTube (browseId WL, sans ID) quand
l'API Data ne renvoie pas d'ID; repli push via editPlaylist; note
'via' affichee dans l'UI; debug cles relatedPlaylists conserve
- Watch Later: youtube_no_channel / watchlater_not_found avec hint
actionnable + log cles relatedPlaylists (sans PII)
- Preview Google: totaux compte (subscriptionTotal/likeTotal), UI
'X / N, l'import prend tout'; import-all paginé (subs<=1000,
likes<=500) au lieu de 50/25
- Sync auto historique: GET /oauth/google/yt-history (browse FEhistory
authentifie, continuations, <=500), bouton Sync auto dans carte
Takeout, import via lots takeout existants
- Backend: GET /api/auth/google/{url,callback} (state login distinct du
link import), echange code, profil userinfo, find-or-create (OAuth sub,
puis email), session + cookies comme login interne, connexion stockee
dans oauth_connections pour l'import direct
- Front: bouton 'Se connecter avec Google' sur /auth/login (visible si
configure), page /auth/google/callback (initSession + prefs + redirect
/library/import), cles i18n nav.import deja en place
- Comptes Google-only: password_hash aleatoire (login mot de passe impossible)