feat(channel): page details chaine multi-providers avec onglets et infinite scroll
CI / build-and-test (push) Successful in 13m34s
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:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user