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:
@@ -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 ----
|
||||
|
||||
@@ -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}` };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user