feat(library): liste « À regarder plus tard » (watch later)
CI / build-and-test (push) Successful in 14m55s

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).
This commit is contained in:
2026-10-01 14:13:43 -04:00
parent 88a2d21133
commit 735255b7d8
16 changed files with 246 additions and 114 deletions
+26 -1
View File
@@ -134,7 +134,7 @@ describe('auth + espace utilisateur', () => {
assert.equal(r2.status, 401);
});
it('protégées sans token -> 401', async () => {
for (const u of ['/api/user/me', '/api/playlists', '/api/user/likes', '/api/subscriptions', '/api/download/jobs']) {
for (const u of ['/api/user/me', '/api/playlists', '/api/user/likes', '/api/user/watch-later', '/api/subscriptions', '/api/download/jobs']) {
const r = await J(`${base}${u}`);
assert.equal(r.status, 401, u);
}
@@ -180,6 +180,31 @@ describe('auth + espace utilisateur', () => {
const lw = await J(`${base}/api/user/history/watch`, { headers: auth(token) });
assert.equal(lw.status, 200);
});
it('watch-later : ajout, statut, liste et retrait (indépendant des likes)', async () => {
const h = { ...auth(token), 'content-type': 'application/json' };
const add = await J(`${base}/api/user/watch-later`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube', videoId: 'wl1', title: 'WL', thumbnail: 'http://x/wl.jpg' }) });
assert.equal(add.status, 201, JSON.stringify(add.body));
const st = await J(`${base}/api/user/watch-later/status?provider=youtube&videoId=wl1`, { headers: auth(token) });
assert.equal(st.status, 200);
assert.equal(st.body.liked, true, 'wl1 doit etre dans la liste watch-later');
// La meme video ajoutee aux likes ne doit PAS apparaitre dans watch-later
const like = await J(`${base}/api/user/likes`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube', videoId: 'wl2', title: 'L', thumbnail: 'http://x/l.jpg' }) });
assert.ok([200, 201].includes(like.status));
const st2 = await J(`${base}/api/user/watch-later/status?provider=youtube&videoId=wl2`, { headers: auth(token) });
assert.equal(st2.body.liked, false, 'un like ne doit pas se retrouver dans watch-later');
const list = await J(`${base}/api/user/watch-later?limit=500`, { headers: auth(token) });
assert.equal(list.status, 200);
assert.ok(Array.isArray(list.body), 'watch-later repond une liste');
assert.ok(list.body.some((r) => r.video_id === 'wl1'), 'wl1 present dans la liste');
assert.ok(!list.body.some((r) => r.video_id === 'wl2'), 'wl2 absent de la liste watch-later');
const rm = await J(`${base}/api/user/watch-later?provider=youtube&videoId=wl1`, { method: 'DELETE', headers: auth(token) });
assert.equal(rm.status, 200);
assert.equal(rm.body.removed, true);
const st3 = await J(`${base}/api/user/watch-later/status?provider=youtube&videoId=wl1`, { headers: auth(token) });
assert.equal(st3.body.liked, false, 'wl1 retire de la liste');
const bad = await J(`${base}/api/user/watch-later`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube' }) });
assert.equal(bad.status, 400);
});
it('subscriptions + telemetry + download jobs', async () => {
const h = { ...auth(token), 'content-type': 'application/json' };
const sub = await J(`${base}/api/subscriptions`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'yt', externalId: 'ch1', title: 'C' }) });