diff --git a/README.md b/README.md index 28d0eb6..ef5abdc 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ npm run test:telemetry # télémétrie minimale * 🧩 **Thèmes** : Trending, Live, Gaming, Sports, News, Finance, Tech, Science, Health, Music, Podcasts, Movies/TV, Education, Travel, Food, DIY, Auto… * 🎯 **Shorts** : affichage dédié (séparé des vidéos longues) * ❤️ **Liked videos** avec recherche serveur +* ⏰ **À regarder plus tard** (Watch later) : bouton horloge à côté du cœur sur toutes les cartes (accueil, thèmes, recherche), liste **Vous → À regarder plus tard** (`/#/library/watch-later`), API `/api/user/watch-later` * 🕘 **History** (recherches + visionnage) * 📚 **Playlists** publiques/privées (CRUD, compteurs, dates MAJ) * 🔐 **Auth légère** (JWT), **rate-limit** API @@ -239,6 +240,7 @@ Ajoutez au besoin `log-driver`, `log-opts`, `default-address-pools`, etc. * ✅ Dist statique + API Node/Express * ✅ Playlists **publiques/privées** (tests de visibilité) * ✅ Barre de recherche **Liked videos** (filtrage serveur) +* ✅ Liste **« À regarder plus tard »** (watch later) — même stockage « tags » que les likes (aucune migration), bouton horloge à côté du cœur sur toutes les cartes, entrée « Vous » de la barre latérale, page `/#/library/watch-later` avec recherche serveur * ✅ PeerTube **multi-instances** (activer/désactiver, set active) * ✅ Thème (système / dark / light / blue / black) * ✅ Préférences (langue, thème, région, qualité par défaut) diff --git a/server/db.mjs b/server/db.mjs index 266c379..d77cf4c 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -903,8 +903,11 @@ function ensureTag(userId, name) { return id; } -export function likeVideo({ userId, provider, videoId, title, thumbnail }) { - const tagId = ensureTag(userId, 'like'); +// Les listes « par tag » (like, watch-later) partagent ces 4 fonctions : +// `tag` est le nom de ligne dans `tags` — la table sert déjà de mot-clés +// génériques, donc une nouvelle liste ne demande aucune migration. +export function likeVideo({ userId, provider, videoId, title, thumbnail, tag = 'like' }) { + const tagId = ensureTag(userId, tag); // Upsert-like behavior; ignore if exists db.prepare(`INSERT OR IGNORE INTO video_tags (user_id, provider, video_id, tag_id, created_at) VALUES (?, ?, ?, ?, ?)`) .run(userId, provider, videoId, tagId, nowIso()); @@ -922,25 +925,25 @@ export function likeVideo({ userId, provider, videoId, title, thumbnail }) { return { provider, video_id: videoId }; } -export function unlikeVideo({ userId, provider, videoId }) { - const tag = db.prepare(`SELECT id FROM tags WHERE user_id = ? AND name = ?`).get(userId, 'like'); - if (!tag) return { removed: false }; +export function unlikeVideo({ userId, provider, videoId, tag = 'like' }) { + const t = db.prepare(`SELECT id FROM tags WHERE user_id = ? AND name = ?`).get(userId, tag); + if (!t) return { removed: false }; const info = db.prepare(`DELETE FROM video_tags WHERE user_id = ? AND provider = ? AND video_id = ? AND tag_id = ?`) - .run(userId, provider, videoId, tag.id); + .run(userId, provider, videoId, t.id); return { removed: (info.changes || 0) > 0 }; } -export function isVideoLiked({ userId, provider, videoId }) { - const tag = db.prepare(`SELECT id FROM tags WHERE user_id = ? AND name = ?`).get(userId, 'like'); - if (!tag) return false; +export function isVideoLiked({ userId, provider, videoId, tag = 'like' }) { + const t = db.prepare(`SELECT id FROM tags WHERE user_id = ? AND name = ?`).get(userId, tag); + if (!t) return false; const row = db.prepare(`SELECT 1 FROM video_tags WHERE user_id = ? AND provider = ? AND video_id = ? AND tag_id = ?`) - .get(userId, provider, videoId, tag.id); + .get(userId, provider, videoId, t.id); return Boolean(row); } -export function listLikedVideos({ userId, limit = 100, q }) { +export function listLikedVideos({ userId, limit = 100, q, tag = 'like' }) { try { - console.log(`[listLikedVideos] Récupération des vidéos aimées pour l'utilisateur ${userId}`); + console.log(`[listLikedVideos] Récupération des vidéos "${tag}" pour l'utilisateur ${userId}`); // Vérifier que la table tags existe const tableExists = db.prepare(` @@ -953,18 +956,18 @@ export function listLikedVideos({ userId, limit = 100, q }) { return []; } - // Récupérer le tag 'like' de l'utilisateur - const tag = db.prepare(` + // Récupérer le tag de la liste demandee + const tagRow = db.prepare(` SELECT id FROM tags WHERE user_id = ? AND name = ? - `).get(userId, 'like'); + `).get(userId, tag); - if (!tag) { - console.log(`[listLikedVideos] Aucun tag 'like' trouvé pour l'utilisateur ${userId}`); + if (!tagRow) { + console.log(`[listLikedVideos] Aucun tag "${tag}" trouvé pour l'utilisateur ${userId}`); return []; } - console.log(`[listLikedVideos] Tag ID: ${tag.id}`); + console.log(`[listLikedVideos] Tag ID: ${tagRow.id}`); // Vérifier que la table video_tags existe const videoTagsExists = db.prepare(` @@ -1032,11 +1035,11 @@ export function listLikedVideos({ userId, limit = 100, q }) { // La requete est devenue longue (normalisation provider x 3, sous-requetes // de repli) : la logger en entier poluait la sortie a chaque appel. - console.log(`[listLikedVideos] ${hasQ ? 'recherche' : 'liste'} pour ${userId} (tag ${tag.id})`); + console.log(`[listLikedVideos] ${hasQ ? 'recherche' : 'liste'} pour ${userId} (tag ${tagRow.id})`); const rows = hasQ - ? db.prepare(query).all(userId, tag.id, like, like, like, limit) - : db.prepare(query).all(userId, tag.id, limit); + ? db.prepare(query).all(userId, tagRow.id, like, like, like, limit) + : db.prepare(query).all(userId, tagRow.id, limit); console.log(`[listLikedVideos] ${rows.length} vidéos trouvées`); return rows; diff --git a/server/index.mjs b/server/index.mjs index e293964..564f9e3 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -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' } } }, diff --git a/server/tests/api_coverage.test.mjs b/server/tests/api_coverage.test.mjs index d410d1b..26a6278 100644 --- a/server/tests/api_coverage.test.mjs +++ b/server/tests/api_coverage.test.mjs @@ -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' }) }); diff --git a/src/app.routes.ts b/src/app.routes.ts index d4740a2..f977d43 100644 --- a/src/app.routes.ts +++ b/src/app.routes.ts @@ -70,6 +70,12 @@ export const APP_ROUTES: Routes = [ loadComponent: () => import('./components/library/liked/liked.component').then(m => m.LikedComponent), title: 'NewTube - Liked' }, + { + path: 'library/watch-later', + loadComponent: () => import('./components/library/liked/liked.component').then(m => m.LikedComponent), + data: { list: 'watch-later' }, + title: 'NewTube - Watch later' + }, { path: 'library/subscriptions', loadComponent: () => import('./components/library/subscriptions/subscriptions.component').then(m => m.SubscriptionsComponent), diff --git a/src/app/shared/components/video-card/video-card.component.html b/src/app/shared/components/video-card/video-card.component.html index 7c0ee25..a61e21e 100644 --- a/src/app/shared/components/video-card/video-card.component.html +++ b/src/app/shared/components/video-card/video-card.component.html @@ -42,6 +42,13 @@ CLIP CHAÎNE + +