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/<username>
- 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
This commit is contained in:
2026-09-25 10:55:27 -04:00
parent 579354cdb3
commit 442c6f12f3
3 changed files with 55 additions and 12 deletions
+43 -6
View File
@@ -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 ----
+8 -5
View File
@@ -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}` };
}
}
+4 -1
View File
@@ -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(() => {