feat(library): liste « À regarder plus tard » (watch later)
CI / build-and-test (push) Successful in 14m55s
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:
+24
-21
@@ -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;
|
||||
|
||||
+53
-50
@@ -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' } } },
|
||||
|
||||
@@ -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' }) });
|
||||
|
||||
Reference in New Issue
Block a user