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 }) {
|
async function ytContent(externalId, { type, page, limit, sort, q }) {
|
||||||
const channelId = await resolveYouTubeChannelId(externalId);
|
const channelId = await resolveYouTubeChannelId(externalId);
|
||||||
const perPage = Math.min(Math.max(1, Number(limit || 24)), 50);
|
const perPage = Math.min(Math.max(1, Number(limit || 24)), 50);
|
||||||
|
const pageNum = Math.max(1, Number(page || 1));
|
||||||
if (type === 'playlists') {
|
if (type === 'playlists') {
|
||||||
const data = await ytGet('playlists', {
|
const key = ['pl', channelId, perPage].join('|');
|
||||||
part: 'snippet,contentDetails', channelId, maxResults: String(perPage),
|
const token = ytTokenFor(key, pageNum);
|
||||||
pageToken: page > 1 ? undefined : undefined,
|
if (token === undefined) return { items: [], nextPage: null };
|
||||||
}).catch(() => ({ items: [] }));
|
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) => ({
|
const items = (data?.items || []).map((pl) => ({
|
||||||
id: pl?.id,
|
id: pl?.id,
|
||||||
title: pl?.snippet?.title || '',
|
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,
|
videoCount: typeof pl?.contentDetails?.itemCount === 'number' ? pl.contentDetails.itemCount : null,
|
||||||
updatedAt: pl?.snippet?.publishedAt || 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 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 = {
|
const params = {
|
||||||
part: 'snippet', channelId, type: 'video', maxResults: String(perPage), order,
|
part: 'snippet', channelId, type: 'video', maxResults: String(perPage), order,
|
||||||
videoEmbeddable: 'true', safeSearch: 'moderate',
|
videoEmbeddable: 'true', safeSearch: 'moderate',
|
||||||
};
|
};
|
||||||
|
if (token) params.pageToken = token;
|
||||||
if (type === 'live') params.eventType = 'live';
|
if (type === 'live') params.eventType = 'live';
|
||||||
if (type === 'shorts') params.videoDuration = 'short';
|
if (type === 'shorts') params.videoDuration = 'short';
|
||||||
if (q) params.q = q;
|
if (q) params.q = q;
|
||||||
const data = await ytGet('search', params);
|
const data = await ytGet('search', params);
|
||||||
|
ytTokenStore(key, pageNum, data?.nextPageToken);
|
||||||
const ids = (data?.items || []).map((i) => i?.id?.videoId).filter(Boolean);
|
const ids = (data?.items || []).map((i) => i?.id?.videoId).filter(Boolean);
|
||||||
const details = await ytVideoDetails(ids);
|
const details = await ytVideoDetails(ids);
|
||||||
let items = (data?.items || [])
|
let items = (data?.items || [])
|
||||||
@@ -142,7 +179,7 @@ async function ytContent(externalId, { type, page, limit, sort, q }) {
|
|||||||
.map((i) => ytSuggestion(i, details, channelId));
|
.map((i) => ytSuggestion(i, details, channelId));
|
||||||
if (type === 'shorts') items = items.filter((s) => !s.duration || s.duration <= 70);
|
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));
|
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 ----
|
// ---- Dailymotion ----
|
||||||
|
|||||||
@@ -86,20 +86,23 @@ async function fetchYoutubeChannel(externalId) {
|
|||||||
|
|
||||||
async function fetchDailymotionChannel(externalId) {
|
async function fetchDailymotionChannel(externalId) {
|
||||||
try {
|
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({
|
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 data = await fetchWithTimeout(`https://api.dailymotion.com/user/${externalId}?${params.toString()}`);
|
||||||
|
const username = data.username || externalId;
|
||||||
return safeMeta({
|
return safeMeta({
|
||||||
title: data.screenname || data.username,
|
title: data.screenname || data.username,
|
||||||
handle: data.username ? `@${data.username}` : undefined,
|
handle: data.username ? `@${data.username}` : undefined,
|
||||||
avatarUrl: data.avatar_720_url || data.avatar_medium_url || data.avatar_url,
|
avatarUrl: data.avatar_720_url || data.avatar_medium_url,
|
||||||
url: data.url,
|
url: `https://www.dailymotion.com/user/${username}`,
|
||||||
subsCount: typeof data.followers_total === 'number' ? data.followers_total : undefined,
|
subsCount: typeof data.followers_total === 'number' ? data.followers_total : undefined,
|
||||||
verified: Boolean(data.verified),
|
verified: Boolean(data.verified),
|
||||||
}, { provider: 'dm', externalId, url: `https://www.dailymotion.com/${externalId}` });
|
}, { provider: 'dm', externalId, url: `https://www.dailymotion.com/user/${externalId}` });
|
||||||
} catch {
|
} 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;
|
const id = (item as any)?.id ?? (item as any)?.title;
|
||||||
if (!seen.has(id)) { seen.add(id); merged.push(item); }
|
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),
|
map(() => true),
|
||||||
catchError(() => {
|
catchError(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user