feat(channel): page details chaine multi-providers avec onglets et infinite scroll
CI / build-and-test (push) Successful in 13m34s

- Strategy Pattern front: ChannelProviderInterface + 6 providers (yt/dm/tw/pt/od/ru) + factory (ids longs/courts)
- ChannelContentService: cache par onglet/tri/recherche (TTL 5min), pagination nextPage
- ChannelPage: header (banniere/avatar/subs/description ...plus), tabs dynamiques par capabilities, grilles videos/shorts 9:16/playlists/live, recherche debounced, tri recent/populaire, infinite scroll IntersectionObserver, skeletons/empty/error
- Backend: GET /api/channels/:provider/:id/content (videos/shorts/playlists/live) natif par provider
This commit is contained in:
2026-09-25 10:15:12 -04:00
parent fe35340948
commit 97e5de74f5
147 changed files with 7954 additions and 77497 deletions
+222 -44
View File
@@ -14,6 +14,8 @@ export interface PagedResult<T> {
@Injectable({ providedIn: 'root' })
export class YoutubeApiService {
private twitchAuthToken$ = new BehaviorSubject<string | null>(null);
// Client ID renvoyé par le serveur avec le token (source de vérité côté back)
private twitchServerClientId: string | null = null;
// Quota exceeded flag to surface to UI components
public quotaExceeded$ = new BehaviorSubject<boolean>(false);
@@ -284,11 +286,15 @@ export class YoutubeApiService {
return out;
}),
catchError((e) => {
// Surface quotaExceeded if present
// Surface quotaExceeded if present + ban dead keys (quota ou clé expirée/invalide)
try {
if (e?.status === 403 && (e?.error?.error?.errors?.[0]?.reason === 'quotaExceeded' || /quota/i.test(String(e?.error?.error?.message || e?.message || '')))) {
const reason = e?.error?.error?.errors?.[0]?.reason || '';
const msg = String(e?.error?.error?.message || e?.message || '');
if (e?.status === 403 && (reason === 'quotaExceeded' || /quota/i.test(msg))) {
this.quotaExceeded$.next(true);
this.banYouTubeKey(key);
} else if (e?.status === 400 && (reason === 'API_KEY_INVALID' || /api key (expired|invalid)/i.test(msg))) {
this.banYouTubeKey(key);
}
} catch {}
console.error('YouTube trending error', e);
@@ -329,7 +335,7 @@ export class YoutubeApiService {
limit: String(limit),
page: String(page),
country: String(region).toLowerCase(),
fields: 'id,title,thumbnail_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,owner.screenname,owner.avatar_80_url,created_time',
fields: 'id,title,thumbnail_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,owner.id,owner.screenname,owner.avatar_80_url,created_time',
});
return this.fetchDailymotion('/videos', params).pipe(
map(res => {
@@ -346,23 +352,7 @@ export class YoutubeApiService {
}
case 'twitch': {
// Use Helix Streams for a "trending" like experience (popular live streams)
return this.getTwitchAuthToken().pipe(
switchMap(token => {
if (!token) return of({ items: [], nextCursor: null });
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
const after = cursor ? `&after=${encodeURIComponent(cursor)}` : '';
const primary = `/api/twitch-api/helix/streams?first=24${after}`;
const fallback = `/proxy/twitch-api/helix/streams?first=24${after}`;
return this.http.get<any>(primary, { headers }).pipe(
catchError(() => this.http.get<any>(fallback, { headers })),
map(res => ({
items: (res.data || []).map((i: any) => this.mapTwitchStreamToVideo(i)),
nextCursor: res.pagination?.cursor || null,
} as PagedResult<Video>)),
catchError((e) => { console.error('Twitch trending error', e); return of({ items: [], nextCursor: null }); })
);
})
);
return this.getTwitchTopStreamsPage(cursor);
}
case 'rumble': {
// Rumble utilise maintenant le web scraping côté serveur, pas d'API key requise
@@ -423,6 +413,8 @@ export class YoutubeApiService {
regionCode: String(region),
// Add safeSearch for better content filtering
safeSearch: 'moderate',
// Only return videos playable in an iframe (avoids player error 153).
videoEmbeddable: 'true',
key: String(key),
});
// Prefer results matching user's language when available
@@ -452,7 +444,11 @@ export class YoutubeApiService {
map((stats) => {
const enriched = out.items.map(v => {
const s = stats[v.videoId];
return s ? { ...v, duration: s.duration, views: s.views } : v;
if (!s) return v;
// duration -1 = non-embeddable (erreur 153) : on garde la
// vidéo en recherche (avec durée 0) mais les Shorts la filtrent.
const duration = typeof s.duration === 'number' && s.duration >= 0 ? s.duration : 0;
return { ...v, duration, views: s.views };
});
const next = { ...out, items: enriched } as PagedResult<Video>;
this.searchCache.set(cacheKey, { ts: now, data: next });
@@ -461,11 +457,15 @@ export class YoutubeApiService {
);
}),
catchError((e) => {
// Surface quotaExceeded if present
// Surface quotaExceeded if present + ban dead keys (quota ou clé expirée/invalide)
try {
if (e?.status === 403 && (e?.error?.error?.errors?.[0]?.reason === 'quotaExceeded' || /quota/i.test(String(e?.error?.error?.message || e?.message || '')))) {
const reason = e?.error?.error?.errors?.[0]?.reason || '';
const msg = String(e?.error?.error?.message || e?.message || '');
if (e?.status === 403 && (reason === 'quotaExceeded' || /quota/i.test(msg))) {
this.quotaExceeded$.next(true);
this.banYouTubeKey(key);
} else if (e?.status === 400 && (reason === 'API_KEY_INVALID' || /api key (expired|invalid)/i.test(msg))) {
this.banYouTubeKey(key);
}
} catch {}
console.error('YouTube search error', e);
@@ -551,10 +551,27 @@ export class YoutubeApiService {
nextCursor: next,
} as PagedResult<Video>;
}),
// Si la recherche est vide (scraper challengé / page changée),
// repli sur le flux trending pour ne jamais afficher un bloc vide.
switchMap(out => {
if ((out?.items?.length || 0) > 0) return of(out);
const page = 1;
const bParams = new URLSearchParams({ sort: 'trending', limit: '24', page: String(page) });
return this.fetchRumble('/browse', bParams).pipe(
map(res => {
const list = (res?.items || res?.result || res?.data || []) as any[];
return { items: list.map(i => this.mapRumbleItemToVideo(i)), nextCursor: null } as PagedResult<Video>;
}),
catchError(() => of(out))
);
}),
catchError((e) => { console.error('Rumble search error', e); return of({ items: [], nextCursor: null }); })
);
}
case 'twitch': {
// Recherche thématique robuste : chaînes live d'abord, puis VODs +
// clips, avec fallback top streams (évite les blocs vides sur les
// pages de thèmes où la recherche Helix pure ne matche rien).
return this.getTwitchAuthToken().pipe(
switchMap(token => {
if (!token) return of({ items: [], nextCursor: null });
@@ -571,6 +588,18 @@ export class YoutubeApiService {
nextCursor: res.pagination?.cursor || null,
} as PagedResult<Video>;
}),
// Si la recherche live est vide/maigre, compléter avec VODs+clips puis top streams.
switchMap(liveRes => {
if ((liveRes?.items?.length || 0) >= 4) return of(liveRes);
return this.searchTwitchThemePage(query, cursor).pipe(
map(themeRes => {
const seen = new Set((liveRes.items || []).map(v => v.videoId));
const extra = (themeRes?.items || []).filter(v => !seen.has(v.videoId));
return { items: [...(liveRes.items || []), ...extra], nextCursor: themeRes?.nextCursor || liveRes.nextCursor } as PagedResult<Video>;
}),
catchError(() => of(liveRes))
);
}),
catchError((e) => { console.error('Twitch search error', e); return of({ items: [], nextCursor: null }); })
);
})
@@ -603,6 +632,7 @@ export class YoutubeApiService {
const cd = i.contentDetails || {};
const thumb = sn.thumbnails?.high?.url || sn.thumbnails?.medium?.url || sn.thumbnails?.default?.url || '';
const publishedAt = sn.publishedAt || '';
const channelId = sn.channelId || '';
return {
url: `/watch/${id}`,
videoId: id,
@@ -611,8 +641,13 @@ export class YoutubeApiService {
title: sn.title || '',
thumbnail: thumb,
uploaderName: sn.channelTitle || '',
uploaderUrl: '',
uploaderAvatar: thumb, // placeholder; channel avatar would need another call
uploaderUrl: channelId ? `https://www.youtube.com/channel/${channelId}` : '',
// Ne jamais utiliser la miniature vidéo comme avatar chaîne : la page /watch
// résout le vrai avatar via /api/details puis /api/channels, avec fallback initiales.
uploaderAvatar: '',
channelId: channelId || undefined,
channelExternalId: channelId || undefined,
channelAvatarUrl: undefined,
uploadedDate: publishedAt,
duration: this.parseISODurationToSeconds(cd.duration || 'PT0S'),
views: Number(stats.viewCount || 0),
@@ -625,6 +660,7 @@ export class YoutubeApiService {
const sn = i.snippet || {};
const thumb = sn.thumbnails?.high?.url || sn.thumbnails?.medium?.url || sn.thumbnails?.default?.url || '';
const publishedAt = sn.publishedAt || '';
const channelId = sn.channelId || '';
return {
url: `/watch/${id}`,
videoId: id,
@@ -633,8 +669,11 @@ export class YoutubeApiService {
title: sn.title || '',
thumbnail: thumb,
uploaderName: sn.channelTitle || '',
uploaderUrl: '',
uploaderAvatar: thumb,
uploaderUrl: channelId ? `https://www.youtube.com/channel/${channelId}` : '',
uploaderAvatar: '',
channelId: channelId || undefined,
channelExternalId: channelId || undefined,
channelAvatarUrl: undefined,
uploadedDate: publishedAt,
duration: 0,
views: 0,
@@ -653,6 +692,9 @@ export class YoutubeApiService {
uploaderName: i['owner.screenname'] || '',
uploaderUrl: '',
uploaderAvatar: i['owner.avatar_80_url'] || i.thumbnail_480_url || i.thumbnail_url || '',
channelId: i['owner.id'] || undefined,
channelExternalId: i['owner.id'] || undefined,
channelAvatarUrl: i['owner.avatar_80_url'] || undefined,
uploadedDate: i.created_time ? new Date(i.created_time * 1000).toISOString() : '',
duration: Number(i.duration || 0),
views: Number(i.views_total || 0),
@@ -681,6 +723,9 @@ export class YoutubeApiService {
uploaderName,
uploaderUrl,
uploaderAvatar: uploaderAvatar || thumbnail,
channelId: i.account?.name || i.channel?.name || undefined,
channelExternalId: i.account?.name || i.channel?.name || undefined,
channelAvatarUrl: uploaderAvatar || undefined,
uploadedDate: i.publishedAt || '',
duration: Number(i.duration || 0),
views: Number(i.views || 0),
@@ -706,6 +751,9 @@ export class YoutubeApiService {
const duration = this.normalizeDuration(rawDuration);
const views = (i?.meta?.view_count != null) ? Number(i.meta.view_count) : 0;
const uploaderName = channelVal?.title || channel?.name || '';
const channelClaimId = channel?.claim_id || channel?.claimId || '';
const channelHandle = channel?.name || '';
const channelExternalId = channelClaimId || channelHandle.replace(/^@/, '') || '';
return {
url: webUrl,
@@ -717,6 +765,10 @@ export class YoutubeApiService {
uploaderName,
uploaderUrl: channel?.permanent_url ? this.lbryToOdyseeUrl(channel.permanent_url) : '',
uploaderAvatar: this.proxiedOdyseeThumb(channelVal?.thumbnail?.url) || thumb,
channelId: channelClaimId || undefined,
channelHandle: channelHandle || undefined,
channelExternalId: channelExternalId || undefined,
channelAvatarUrl: this.proxiedOdyseeThumb(channelVal?.thumbnail?.url) || undefined,
uploadedDate: publishedAt,
duration,
views: views,
@@ -731,6 +783,8 @@ export class YoutubeApiService {
let thumb = i?.thumbnail || i?.thumbnail_url || i?.image || '';
const uploaderName = i?.author || i?.uploader || i?.channel?.name || i?.uploaderName || '';
const uploaderAvatar = i?.author_avatar || i?.uploader_avatar || i?.channel?.avatar || i?.uploaderAvatar || thumb || '';
const channelSlug = i?.channel?.slug || i?.channel_slug || (i?.channel?.url ? i.channel.url.replace(/^https?:\/\/rumble.com\//, '').replace(/\/$/, '') : '') || '';
const channelExternalId = channelSlug || (uploaderName ? uploaderName.replace(/\s+/g, '').toLowerCase() : '');
const uploadedDate = i?.published_at || i?.created_at || i?.uploadedDate || '';
const views = Number(i?.views || i?.view_count || 0);
const duration = this.normalizeDuration(
@@ -748,6 +802,7 @@ export class YoutubeApiService {
// Best-effort: construct a thumbnail CDN path if present in URL pattern (fallback to uploader avatar)
thumb = uploaderAvatar || '';
}
const nativeShort = (i as any)?.isShort === true;
return {
url,
videoId: id,
@@ -758,10 +813,15 @@ export class YoutubeApiService {
uploaderName,
uploaderUrl: i?.channel_url || '',
uploaderAvatar,
channelId: channelExternalId || undefined,
channelExternalId: channelExternalId || undefined,
channelAvatarUrl: uploaderAvatar || undefined,
uploadedDate,
duration,
views,
uploaded: uploadedDate ? Date.parse(uploadedDate) : 0,
// Flag natif propagé du scraper serveur ; sinon durée courte ⇒ Short.
isShort: nativeShort || (duration > 0 && duration <= 75),
} as Video;
};
@@ -776,6 +836,9 @@ export class YoutubeApiService {
uploaderName: i.user_name || '',
uploaderUrl: `https://www.twitch.tv/${i.user_login}`,
uploaderAvatar: '', // Requires another API call
channelId: i.user_id || undefined,
channelHandle: i.user_login || undefined,
channelExternalId: i.user_login || undefined,
uploadedDate: i.created_at || '',
duration: this.parseTwitchDurationToSeconds(i.duration || '0s'),
views: i.view_count || 0,
@@ -793,6 +856,10 @@ export class YoutubeApiService {
uploaderName: i.user_name || '',
uploaderUrl: `https://www.twitch.tv/${i.user_login}`,
uploaderAvatar: (i.thumbnail_url || '').replace('{width}', '70').replace('{height}', '70'),
channelId: i.user_id || undefined,
channelHandle: i.user_login || undefined,
channelExternalId: i.user_login || undefined,
channelAvatarUrl: (i.thumbnail_url || '').replace('{width}', '70').replace('{height}', '70') || undefined,
uploadedDate: i.started_at || '',
duration: 0,
views: Number(i.viewer_count || 0),
@@ -877,6 +944,107 @@ export class YoutubeApiService {
);
}
/**
* Top live streams Twitch (Helix /streams) — utilisé comme "trending" Twitch
* et comme filet de sécurité quand une recherche thématique ne retourne rien
* (la recherche de chaînes Helix exige des noms proches de la requête et
* filtre souvent tout pour des thèmes génériques comme "sports" ou "tech").
*/
public getTwitchTopStreamsPage(cursor?: string | null, limit = 24): Observable<PagedResult<Video>> {
return this.getTwitchAuthToken().pipe(
switchMap(token => {
if (!token) return of({ items: [], nextCursor: null });
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
const after = cursor ? `&after=${encodeURIComponent(cursor)}` : '';
const primary = `/api/twitch-api/helix/streams?first=${limit}${after}`;
const fallback = `/proxy/twitch-api/helix/streams?first=${limit}${after}`;
return this.http.get<any>(primary, { headers }).pipe(
catchError(() => this.http.get<any>(fallback, { headers })),
map(res => ({
items: (res.data || []).map((i: any) => this.mapTwitchStreamToVideo(i)),
nextCursor: res.pagination?.cursor || null,
} as PagedResult<Video>)),
catchError((e) => { console.error('Twitch top streams error', e); return of({ items: [], nextCursor: null }); })
);
})
);
}
/**
* Recherche thématique Twitch robuste : VODs puis clips, avec fallback vers
* les top streams live si les deux sont quasi vides (évite les blocs vides
* sur les pages de thèmes). `minResults` = seuil sous lequel on complète.
*/
public searchTwitchThemePage(query: string, cursor?: string | null, minResults = 4): Observable<PagedResult<Video>> {
return this.searchTwitchVodsPage(query, cursor).pipe(
switchMap(vods => {
if ((vods?.items?.length || 0) >= minResults) return of(vods);
return this.searchTwitchClipsPage(query, cursor).pipe(
map(clips => ({
items: [...(vods?.items || []), ...(clips?.items || [])],
nextCursor: clips?.nextCursor || vods?.nextCursor || null,
} as PagedResult<Video>)),
switchMap(merged => {
if ((merged?.items?.length || 0) >= minResults) return of(merged);
// Filet de sécurité : top streams live (jamais vide quand Twitch est configuré)
return this.getTwitchTopStreamsPage(cursor).pipe(
map(top => {
if (!top.items.length) return merged;
const seen = new Set((merged.items || []).map(v => v.videoId));
const extra = top.items.filter(v => !seen.has(v.videoId));
return { items: [...(merged.items || []), ...extra], nextCursor: top.nextCursor } as PagedResult<Video>;
}),
catchError(() => of(merged))
);
})
);
})
);
}
/**
* Clips Twitch sans requête (page Shorts) : la recherche de chaînes Helix
* rejette les requêtes vides, on part donc des top streams pour obtenir des
* broadcaster_id valides puis on récupère leurs clips récents.
*/
public getTwitchClipsFeed(cursor?: string | null): Observable<PagedResult<Video>> {
return this.getTwitchAuthToken().pipe(
switchMap(token => {
if (!token) return of({ items: [], nextCursor: null });
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
const after = cursor ? `&after=${encodeURIComponent(cursor)}` : '';
const sPrimary = `/api/twitch-api/helix/streams?first=10${after}`;
const sFallback = `/proxy/twitch-api/helix/streams?first=10${after}`;
return this.http.get<any>(sPrimary, { headers }).pipe(
catchError(() => this.http.get<any>(sFallback, { headers })),
switchMap(sRes => {
const streams = Array.isArray(sRes?.data) ? sRes.data : [];
const userIds: string[] = streams.map((s: any) => s?.user_id).filter(Boolean).slice(0, 10);
if (!userIds.length) return of({ items: [], nextCursor: null } as PagedResult<Video>);
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const perUser = userIds.map(uid => {
const cPrimary = `/api/twitch-api/helix/clips?broadcaster_id=${encodeURIComponent(uid)}&first=5&started_at=${encodeURIComponent(since)}`;
const cFallback = `/proxy/twitch-api/helix/clips?broadcaster_id=${encodeURIComponent(uid)}&first=5&started_at=${encodeURIComponent(since)}`;
return this.http.get<any>(cPrimary, { headers }).pipe(
catchError(() => this.http.get<any>(cFallback, { headers })),
map(res => Array.isArray(res?.data) ? res.data : [])
);
});
return forkJoin(perUser).pipe(
map((lists: any[][]) => lists.flat().slice(0, 24)),
map((clips: any[]) => ({
items: clips.map((i: any) => this.mapTwitchClipToVideo(i)),
nextCursor: sRes?.pagination?.cursor || null,
} as PagedResult<Video>)),
catchError(() => of({ items: [], nextCursor: null } as PagedResult<Video>))
);
}),
catchError((e) => { console.error('Twitch clips feed error', e); return of({ items: [], nextCursor: null }); })
);
})
);
}
private parseISODurationToSeconds(iso: string): number {
// ... (rest of the code remains the same)
const m = /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/i.exec(iso || '');
@@ -960,23 +1128,20 @@ export class YoutubeApiService {
private resolveTwitchClientId(): string | undefined {
const g: any = globalThis as any;
return g?.TWITCH_CLIENT_ID || (typeof import.meta !== 'undefined' && (import.meta as any).env?.TWITCH_CLIENT_ID) || g?.process?.env?.TWITCH_CLIENT_ID;
return this.twitchServerClientId || g?.TWITCH_CLIENT_ID || (typeof import.meta !== 'undefined' && (import.meta as any).env?.TWITCH_CLIENT_ID) || g?.process?.env?.TWITCH_CLIENT_ID;
}
private getTwitchAuthToken(): Observable<string | null> {
const clientId = this.resolveTwitchClientId();
const clientSecret = (globalThis as any)?.TWITCH_CLIENT_SECRET || (typeof import.meta !== 'undefined' && (import.meta as any).env?.TWITCH_CLIENT_SECRET) || (globalThis as any)?.process?.env?.TWITCH_CLIENT_SECRET;
if (!clientId || !clientSecret) {
console.warn('Twitch Client ID or Secret not found.');
return of(null);
}
const body = `client_id=${clientId}&client_secret=${clientSecret}&grant_type=client_credentials`;
const headers = new HttpHeaders({ 'Content-Type': 'application/x-www-form-urlencoded' });
return this.http.post<any>('/proxy/twitch-auth/oauth2/token', body, { headers }).pipe(
map(res => res.access_token),
// Le token applicatif est obtenu via notre serveur (/api/twitch-token) :
// TWITCH_CLIENT_SECRET ne quitte jamais le backend.
return this.http.get<any>('/api/twitch-token').pipe(
map(res => {
if (res?.accessToken) {
if (res?.clientId) this.twitchServerClientId = String(res.clientId);
return String(res.accessToken);
}
return null;
}),
shareReplay(1),
catchError(() => of(null))
);
@@ -1041,6 +1206,8 @@ export class YoutubeApiService {
/**
* Fetch durations (in seconds) for a list of YouTube video IDs.
* Non-embeddable videos (status.embeddable === false) are excluded via
* a -1 marker so Shorts/feed callers can drop them (player error 153).
*/
public getYouTubeDurations(ids: string[]): Observable<Record<string, number>> {
const apiKey = this.getActiveYouTubeKey();
@@ -1050,7 +1217,7 @@ export class YoutubeApiService {
const params = new URLSearchParams({
key: apiKey,
part: 'contentDetails',
part: 'contentDetails,status',
id: ids.join(','),
});
@@ -1059,7 +1226,13 @@ export class YoutubeApiService {
map((items: any[]) => items.reduce((acc: Record<string, number>, item: any) => {
const duration = item?.contentDetails?.duration || 'PT0S';
const id = String(item?.id || '');
if (id) acc[id] = this.parseISODurationToSeconds(duration);
if (!id) return acc;
// -1 = non-embeddable (à exclure côté Shorts pour éviter l'erreur 153)
if (item?.status && item.status.embeddable === false) {
acc[id] = -1;
return acc;
}
acc[id] = this.parseISODurationToSeconds(duration);
return acc;
}, {} as Record<string, number>)),
catchError(() => of({}))
@@ -1068,13 +1241,14 @@ export class YoutubeApiService {
/**
* Fetch minimal details (duration seconds + viewCount) for a list of YouTube IDs.
* Non-embeddable entries get duration -1 so callers can drop them (error 153).
*/
private getYouTubeContentAndStats(ids: string[]): Observable<Record<string, { duration: number; views: number }>> {
const apiKey = this.getActiveYouTubeKey();
if (!apiKey || !ids.length) return of({});
const params = new URLSearchParams({
key: apiKey,
part: 'contentDetails,statistics',
part: 'contentDetails,statistics,status',
id: ids.join(','),
maxResults: String(Math.min(50, ids.length)),
// Add safeSearch for consistency
@@ -1087,6 +1261,10 @@ export class YoutubeApiService {
for (const it of items) {
const id = String(it?.id || '');
if (!id) continue;
if (it?.status && it.status.embeddable === false) {
out[id] = { duration: -1, views: Number(it?.statistics?.viewCount || 0) };
continue;
}
const duration = this.parseISODurationToSeconds(it?.contentDetails?.duration || 'PT0S');
const views = Number(it?.statistics?.viewCount || 0);
out[id] = { duration, views };