From 442c6f12f34559fefd6a094a60afea4285262668 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Fri, 25 Sep 2026 10:55:27 -0400 Subject: [PATCH] fix(channel): titre/URL Dailymotion + playlists et pagination YouTube - channel-registry DM: retire le champ invalide avatar_url (400 sur toute la requete -> titre null), URL officielle /user/ - channel-content YT: ne plus envoyer pageToken=undefined (400 -> playlists vides); cache de pageTokens opaques pour la pagination videos/shorts/live/playlists - channel-content.service: stoppe l'infinite scroll quand une page n'apporte rien de nouveau --- server/providers/channel-content.mjs | 49 ++++++++++++++++++++++--- server/providers/channel-registry.mjs | 13 ++++--- src/services/channel-content.service.ts | 5 ++- 3 files changed, 55 insertions(+), 12 deletions(-) diff --git a/server/providers/channel-content.mjs b/server/providers/channel-content.mjs index da709ad..664b725 100644 --- a/server/providers/channel-content.mjs +++ b/server/providers/channel-content.mjs @@ -109,14 +109,46 @@ function ytSuggestion(item, details, channelId) { }; } +// L'API YouTube pagine avec des pageTokens opaques, pas des numéros de page. +// Cache process-wide des tokens : clé requête -> tokens[page] (tokens[1] = token +// pour charger la page 2). Sans token connu pour page > 1, on s'arrête (nextPage null) +// au lieu de re-servir la page 1 en boucle. +const ytTokenCache = new Map(); +function ytTokenStore(key, page, nextToken) { + if (!nextToken) return; + let arr = ytTokenCache.get(key); + if (!arr) { + if (ytTokenCache.size > 500) ytTokenCache.clear(); + arr = []; + ytTokenCache.set(key, arr); + } + arr[page] = nextToken; +} +function ytTokenFor(key, page) { + if (page <= 1) return ''; + const arr = ytTokenCache.get(key); + return arr ? arr[page - 1] : undefined; +} + async function ytContent(externalId, { type, page, limit, sort, q }) { const channelId = await resolveYouTubeChannelId(externalId); const perPage = Math.min(Math.max(1, Number(limit || 24)), 50); + const pageNum = Math.max(1, Number(page || 1)); if (type === 'playlists') { - const data = await ytGet('playlists', { - part: 'snippet,contentDetails', channelId, maxResults: String(perPage), - pageToken: page > 1 ? undefined : undefined, - }).catch(() => ({ items: [] })); + const key = ['pl', channelId, perPage].join('|'); + const token = ytTokenFor(key, pageNum); + if (token === undefined) return { items: [], nextPage: null }; + const params = { part: 'snippet,contentDetails', channelId, maxResults: String(perPage) }; + // Ne jamais envoyer pageToken=undefined (sérialisé en "undefined" -> 400). + if (token) params.pageToken = token; + let data; + try { + data = await ytGet('playlists', params); + } catch (e) { + console.warn('[channel-content/yt] playlists failed:', e?.message || e); + return { items: [], nextPage: null }; + } + ytTokenStore(key, pageNum, data?.nextPageToken); const items = (data?.items || []).map((pl) => ({ id: pl?.id, title: pl?.snippet?.title || '', @@ -124,17 +156,22 @@ async function ytContent(externalId, { type, page, limit, sort, q }) { videoCount: typeof pl?.contentDetails?.itemCount === 'number' ? pl.contentDetails.itemCount : null, updatedAt: pl?.snippet?.publishedAt || null, })); - return { items, nextPage: data?.nextPageToken ? (page || 1) + 1 : null, total: data?.pageInfo?.totalResults ?? null }; + return { items, nextPage: data?.nextPageToken ? pageNum + 1 : null, total: data?.pageInfo?.totalResults ?? null }; } const order = sort === 'popular' ? 'viewCount' : sort === 'recent' ? 'date' : 'relevance'; + const key = ['search', channelId, type, order, q || '', perPage].join('|'); + const token = ytTokenFor(key, pageNum); + if (token === undefined) return { items: [], nextPage: null }; const params = { part: 'snippet', channelId, type: 'video', maxResults: String(perPage), order, videoEmbeddable: 'true', safeSearch: 'moderate', }; + if (token) params.pageToken = token; if (type === 'live') params.eventType = 'live'; if (type === 'shorts') params.videoDuration = 'short'; if (q) params.q = q; const data = await ytGet('search', params); + ytTokenStore(key, pageNum, data?.nextPageToken); const ids = (data?.items || []).map((i) => i?.id?.videoId).filter(Boolean); const details = await ytVideoDetails(ids); let items = (data?.items || []) @@ -142,7 +179,7 @@ async function ytContent(externalId, { type, page, limit, sort, q }) { .map((i) => ytSuggestion(i, details, channelId)); if (type === 'shorts') items = items.filter((s) => !s.duration || s.duration <= 70); if (sort === 'popular') items = [...items].sort((a, b) => (b.views || 0) - (a.views || 0)); - return { items, nextPage: data?.nextPageToken ? (page || 1) + 1 : null, total: data?.pageInfo?.totalResults ?? null }; + return { items, nextPage: data?.nextPageToken ? pageNum + 1 : null, total: data?.pageInfo?.totalResults ?? null }; } // ---- Dailymotion ---- diff --git a/server/providers/channel-registry.mjs b/server/providers/channel-registry.mjs index baa7dfc..1686800 100644 --- a/server/providers/channel-registry.mjs +++ b/server/providers/channel-registry.mjs @@ -86,20 +86,23 @@ async function fetchYoutubeChannel(externalId) { async function fetchDailymotionChannel(externalId) { try { + // NOTE : `avatar_url` n'est pas un champ valide de l'API user (400 sur + // toute la requête) — seuls avatar_720_url / avatar_medium_url le sont. const params = new URLSearchParams({ - fields: 'id,username,screenname,avatar_720_url,avatar_medium_url,avatar_url,url,followers_total,verified', + fields: 'id,username,screenname,avatar_720_url,avatar_medium_url,url,followers_total,verified', }); const data = await fetchWithTimeout(`https://api.dailymotion.com/user/${externalId}?${params.toString()}`); + const username = data.username || externalId; return safeMeta({ title: data.screenname || data.username, handle: data.username ? `@${data.username}` : undefined, - avatarUrl: data.avatar_720_url || data.avatar_medium_url || data.avatar_url, - url: data.url, + avatarUrl: data.avatar_720_url || data.avatar_medium_url, + url: `https://www.dailymotion.com/user/${username}`, subsCount: typeof data.followers_total === 'number' ? data.followers_total : undefined, verified: Boolean(data.verified), - }, { provider: 'dm', externalId, url: `https://www.dailymotion.com/${externalId}` }); + }, { provider: 'dm', externalId, url: `https://www.dailymotion.com/user/${externalId}` }); } catch { - return { provider: 'dm', externalId, url: `https://www.dailymotion.com/${externalId}` }; + return { provider: 'dm', externalId, url: `https://www.dailymotion.com/user/${externalId}` }; } } diff --git a/src/services/channel-content.service.ts b/src/services/channel-content.service.ts index 09ceac6..56f718b 100644 --- a/src/services/channel-content.service.ts +++ b/src/services/channel-content.service.ts @@ -85,7 +85,10 @@ export class ChannelContentService { const id = (item as any)?.id ?? (item as any)?.title; if (!seen.has(id)) { seen.add(id); merged.push(item); } } - this.patch(key, { items: merged, nextPage: page.nextPage, total: page.total ?? current.total, fetchedAt: Date.now() }); + // Si la page ne rapporte rien de nouveau (ex. provider sans vrai + // curseur), on stoppe la pagination au lieu de boucler à l'infini. + const nextPage = merged.length === current.items.length ? null : page.nextPage; + this.patch(key, { items: merged, nextPage, total: page.total ?? current.total, fetchedAt: Date.now() }); }), map(() => true), catchError(() => {