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
+53 -50
View File
@@ -2713,64 +2713,63 @@ r.delete('/user/history/transcripts', authMiddleware, (req, res) => {
}
});
// --- Likes ---
// --- Listes vidéo par tag : « vidéos aimées » + « à regarder plus tard » ---
// NOTE : cookie-aware (et non Bearer seul) : le front s'authentifie via
// cookies httpOnly + Bearer en mémoire (perdu au F5) ; exiger le Bearer
// seul renvoyait 401 « Unauthorized » même connecté.
r.get('/user/likes', authMiddlewareCookieAware, (req, res) => {
const limit = Math.min(500, Number(req.query.limit || 100));
const q = typeof req.query.q === 'string' ? req.query.q : undefined;
const rows = listLikedVideos({ userId: req.user.id, limit, q });
return res.json(rows);
});
// `tagName` = nom de ligne dans `tags` ; c'est le SEUL paramètre qui change
// d'une liste à l'autre, les 4 routes sont donc enregistrées par une fabrique.
function registerVideoTagRoutes(prefix, tagName) {
r.get(prefix, authMiddlewareCookieAware, (req, res) => {
const limit = Math.min(500, Number(req.query.limit || 100));
const q = typeof req.query.q === 'string' ? req.query.q : undefined;
const rows = listLikedVideos({ userId: req.user.id, limit, q, tag: tagName });
return res.json(rows);
});
r.post('/user/likes', authMiddlewareCookieAware, async (req, res) => {
let { provider, videoId, title, thumbnail } = req.body || {};
try {
console.log('[POST /user/likes] payload:', {
provider,
videoId,
titlePreview: typeof title === 'string' ? title.slice(0, 80) : title,
hasThumbnail: Boolean(thumbnail)
});
} catch {}
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
r.post(prefix, authMiddlewareCookieAware, async (req, res) => {
let { provider, videoId, title, thumbnail } = req.body || {};
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
// Server-side enrichment: if title or thumbnail is missing, fetch minimal details via yt-dlp
try {
const needTitle = !(typeof title === 'string' && title.trim().length > 0);
const needThumb = !(typeof thumbnail === 'string' && thumbnail.trim().length > 0);
if (needTitle || needThumb) {
const url = providerUrlFrom(provider, videoId, { instance: req.query.instance, slug: req.query.slug, sourceUrl: req.query.sourceUrl });
try {
const raw = await youtubedl(url, { dumpSingleJson: true, noWarnings: true, noCheckCertificates: true, skipDownload: true });
const meta = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {});
if (needTitle) title = meta?.title || title || '';
if (needThumb) thumbnail = meta?.thumbnail || (Array.isArray(meta?.thumbnails) && meta.thumbnails.length ? meta.thumbnails[0].url : thumbnail || '');
} catch {}
}
} catch {}
// Server-side enrichment: if title or thumbnail is missing, fetch minimal details via yt-dlp
try {
const needTitle = !(typeof title === 'string' && title.trim().length > 0);
const needThumb = !(typeof thumbnail === 'string' && thumbnail.trim().length > 0);
if (needTitle || needThumb) {
const url = providerUrlFrom(provider, videoId, { instance: req.query.instance, slug: req.query.slug, sourceUrl: req.query.sourceUrl });
try {
const raw = await youtubedl(url, { dumpSingleJson: true, noWarnings: true, noCheckCertificates: true, skipDownload: true });
const meta = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {});
if (needTitle) title = meta?.title || title || '';
if (needThumb) thumbnail = meta?.thumbnail || (Array.isArray(meta?.thumbnails) && meta.thumbnails.length ? meta.thumbnails[0].url : thumbnail || '');
} catch {}
}
} catch {}
const row = likeVideo({ userId: req.user.id, provider, videoId, title, thumbnail });
return res.status(201).json(row);
});
const row = likeVideo({ userId: req.user.id, provider, videoId, title, thumbnail, tag: tagName });
return res.status(201).json(row);
});
r.delete('/user/likes', authMiddlewareCookieAware, (req, res) => {
const provider = req.query.provider ? String(req.query.provider) : '';
const videoId = req.query.videoId ? String(req.query.videoId) : '';
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
const result = unlikeVideo({ userId: req.user.id, provider, videoId });
return res.json(result);
});
r.delete(prefix, authMiddlewareCookieAware, (req, res) => {
const provider = req.query.provider ? String(req.query.provider) : '';
const videoId = req.query.videoId ? String(req.query.videoId) : '';
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
const result = unlikeVideo({ userId: req.user.id, provider, videoId, tag: tagName });
return res.json(result);
});
// Like status for a specific video
r.get('/user/likes/status', authMiddlewareCookieAware, (req, res) => {
const provider = req.query.provider ? String(req.query.provider) : '';
const videoId = req.query.videoId ? String(req.query.videoId) : '';
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
const liked = isVideoLiked({ userId: req.user.id, provider, videoId });
return res.json({ liked });
});
// Status for a specific video
r.get(`${prefix}/status`, authMiddlewareCookieAware, (req, res) => {
const provider = req.query.provider ? String(req.query.provider) : '';
const videoId = req.query.videoId ? String(req.query.videoId) : '';
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
const liked = isVideoLiked({ userId: req.user.id, provider, videoId, tag: tagName });
return res.json({ liked });
});
}
registerVideoTagRoutes('/user/likes', 'like');
registerVideoTagRoutes('/user/watch-later', 'watch-later');
// Odysee image proxy to avoid CORS/ORB issues
r.get('/img/odysee', async (req, res) => {
@@ -4164,6 +4163,10 @@ r.get('/openapi.json', (_req, res) => {
get: { summary: 'Vidéos likées', security: bearer, responses: { 200: { description: 'likes[]' } } },
post: { summary: 'Liker', security: bearer, responses: { 201: { description: 'like' } } },
},
'/user/watch-later': {
get: { summary: 'Liste « à regarder plus tard »', security: bearer, responses: { 200: { description: 'videos[]' } } },
post: { summary: 'Ajouter à la liste', security: bearer, responses: { 201: { description: '{ provider, video_id }' } } },
},
'/subscriptions': {
get: { summary: 'Abonnements { items, ttl }', security: bearer, responses: { 200: { description: 'subs' } } },
post: { summary: 'S abonner (résolution chaîne)', security: bearer, responses: { 201: { description: 'sub' } } },