fix(themes): pages themes Twitch vides -> requetes Helix tokenisees + fallbacks game_id/top-streams

This commit is contained in:
2026-09-26 18:46:55 -04:00
parent db03b70854
commit 21f2296c41
4 changed files with 389 additions and 87 deletions
+105 -9
View File
@@ -224,18 +224,79 @@ function mapClip(c) {
};
}
/**
* Mots-clés unitaires exploitables par Helix : /search/channels et
* /search/categories ne matchent que des noms proches, jamais de longues
* phrases multi-mots (ex. "cooking food Food Cooking Recipe").
*/
function twitchKeywords(q, max = 3) {
const words = String(q || '')
.toLowerCase()
.split(/[^a-z0-9à-ÿ]+/i)
.map((w) => w.trim())
.filter((w) => w.length >= 3);
const seen = new Set();
const out = [];
for (const w of words) {
if (seen.has(w)) continue;
seen.add(w);
out.push(w);
if (out.length >= max) break;
}
return out;
}
/** Catégorie Twitch la plus pertinente pour une requête (ex. "sports" -> Sports). */
async function findCategoryId(q, auth) {
const attempts = [String(q || '').trim(), ...twitchKeywords(q, 3)].filter(Boolean).slice(0, 4);
// Déduplique en conservant l'ordre (requête complète d'abord).
const seen = new Set();
const ordered = attempts.filter((a) => {
const k = String(a).toLowerCase();
if (seen.has(k)) return false;
seen.add(k);
return true;
});
for (const attempt of ordered) {
try {
const data = await helixGet('/search/categories', new URLSearchParams({ query: q, first: '5' }), auth, { retries: 1 });
const data = await helixGet('/search/categories', new URLSearchParams({ query: attempt, first: '5' }), auth, { retries: 1 });
const list = Array.isArray(data?.data) ? data.data : [];
if (!list.length) return null;
const needle = String(q || '').trim().toLowerCase();
if (!list.length) continue;
const needle = String(attempt || '').trim().toLowerCase();
const exact = list.find((c) => String(c?.name || '').toLowerCase() === needle);
return (exact || list[0])?.id || null;
const best = exact || list[0];
if (best?.id) return best.id;
} catch {
// Essaie le mot-clé suivant.
}
}
return null;
}
/** Recherche de chaînes sur plusieurs mots-clés (fusion dédupliquée). */
async function searchChannelsMulti(queries, first, auth, after = '') {
const seen = new Set();
const merged = [];
let cursor = null;
for (let i = 0; i < queries.length; i++) {
try {
const params = new URLSearchParams({ query: queries[i], first: String(first) });
if (i === 0 && after) params.set('after', after);
const data = await helixGet('/search/channels', params, auth, { retries: 1 });
const list = Array.isArray(data?.data) ? data.data : [];
if (i === 0) cursor = data?.pagination?.cursor || null;
else if (!cursor && data?.pagination?.cursor) cursor = data.pagination.cursor;
for (const c of list) {
const key = String(c?.id || '');
if (!key || seen.has(key)) continue;
seen.add(key);
merged.push(c);
}
// Assez de candidats : inutile de spammer Helix.
if (merged.length >= 10) break;
} catch {}
}
return { channels: merged, cursor };
}
const handler = {
@@ -258,16 +319,42 @@ const handler = {
const targetPage = Math.max(1, Number(page || 1));
// 1) Chaînes (paginées : la pagination repose sur ce curseur).
// Helix ne matche pas les longues phrases : on cherche la requête
// complète puis chaque mot-clé (ex. "cooking" pour "cooking food...").
const channelQueries = [query, ...twitchKeywords(query, 3).filter((k) => k !== query.toLowerCase())].slice(0, 4);
let after = '';
let currentPage = 1;
let channelsData = [];
let channelsCursor = null;
while (currentPage <= targetPage) {
const params = new URLSearchParams({ query, first: String(Math.min(20, perPage)) });
if (after) params.set('after', after);
const data = await helixGet('/search/channels', params, auth);
const list = Array.isArray(data?.data) ? data.data : [];
channelsCursor = data?.pagination?.cursor || null;
const { channels, cursor } = await searchChannelsMulti(
channelQueries,
Math.min(20, perPage),
auth,
after,
);
// Alternative brute si Helix a tout filtré : top streams comme base de chaînes.
let list = channels;
let cur = cursor;
if (!list.length && currentPage === 1 && targetPage === 1) {
try {
const top = await helixGet('/streams', new URLSearchParams({ first: '20' }), auth, { retries: 1 });
const streams = Array.isArray(top?.data) ? top.data : [];
list = streams.map((s) => ({
id: s.user_id,
broadcaster_login: s.user_login,
user_login: s.user_login,
display_name: s.user_name,
is_live: true,
title: s.title,
thumbnail_url: s.thumbnail_url,
started_at: s.started_at,
game_name: s.game_name,
}));
cur = top?.pagination?.cursor || null;
} catch {}
}
channelsCursor = cur;
if (currentPage === targetPage) {
channelsData = list;
break;
@@ -445,6 +532,15 @@ const handler = {
seen.add(key);
out.push(item);
}
// Filet de sécurité : ne jamais renvoyer une page thème vide quand
// Twitch est configuré (thèmes sans chaîne ni catégorie correspondante).
if (!out.length && targetPage === 1) {
try {
const top = await helixGet('/streams', new URLSearchParams({ first: String(Math.min(20, perPage)) }), auth, { retries: 1 });
const fallback = (Array.isArray(top?.data) ? top.data : []).map(mapStream);
return fallback;
} catch {}
}
return out;
} catch (error) {
console.error('Twitch search error:', error?.message || error);
@@ -193,12 +193,15 @@ export class ProviderThemePageComponent implements OnDestroy {
const baseTokens = this.themes.tokensFor(slug);
if (provider === 'twitch') {
// Helix /search/channels et /search/categories ne matchent que des noms
// proches : une longue phrase multi-mots ("cooking food Food Cooking
// Recipe") ne retourne jamais rien. On envoie UN mot-clé court ; le
// service tente ensuite les variantes + game_id + top streams.
const twitchTokens = TWITCH_THEME_KEYWORDS[slug] || [];
const merged = [...new Set([...twitchTokens, ...baseTokens])];
const value = merged.join(' ').trim();
if (value) return value;
if (twitchTokens.length) return twitchTokens.join(' ');
return slug.replace(/-/g, ' ');
const first = (twitchTokens[0] || baseTokens[0] || slug.replace(/-/g, ' ') || '').trim();
// Garde le premier mot pour maximiser le taux de match Helix.
const short = String(first).split(/[^A-Za-z0-9à-ÿ]+/).filter(Boolean)[0] || first;
return (short || first || slug.replace(/-/g, ' ')).trim();
}
const value = baseTokens.join(' ').trim();
@@ -215,8 +215,10 @@ export class ThemePageComponent {
return;
}
// Twitch hors live/trending : VODs + clips + fallback top streams.
// Helix ne matche pas les longues phrases : un seul mot-clé court.
if (block.provider === 'twitch') {
const q = tokens.join(' ');
const raw = tokens.join(' ').trim();
const q = (raw.split(/[^A-Za-z0-9à-ÿ]+/).filter(w => w.length >= 3)[0] || raw).trim();
const call = q ? this.api.searchTwitchThemePage(q, null) : this.api.getTwitchTopStreamsPage(null);
call.subscribe({
next: res => {
+255 -54
View File
@@ -121,6 +121,50 @@ export class YoutubeApiService {
// ... (rest of the code remains the same)
// ===================== Twitch Search Helpers =====================
/**
* Découpe une requête thème (ex. "cooking food Food Cooking Recipe") en
* mots-clés unitaires exploitables par Helix : /search/channels et
* /search/categories ne matchent que des noms proches, jamais de longues
* phrases multi-mots. On retourne jusqu'à 3 mots distincts >= 3 lettres.
*/
private twitchQueryKeywords(query: string, max = 3): string[] {
const words = String(query || '')
.toLowerCase()
.split(/[^a-z0-9à-ÿ]+/i)
.map(w => w.trim())
.filter(w => w.length >= 3);
const seen = new Set<string>();
const out: string[] = [];
for (const w of words) {
if (seen.has(w)) continue;
seen.add(w);
out.push(w);
if (out.length >= max) break;
}
// Repli : requête brute courte si aucun mot exploitable.
if (!out.length) {
const raw = String(query || '').trim();
if (raw) out.push(raw.slice(0, 40));
}
return out;
}
private fetchTwitchChannelsForKeyword(token: string, headers: HttpHeaders, keyword: string, first: number, after = ''): Observable<any> {
const q = `/api/twitch-api/helix/search/channels?query=${encodeURIComponent(keyword)}&first=${first}${after}`;
const f = `/proxy/twitch-api/helix/search/channels?query=${encodeURIComponent(keyword)}&first=${first}${after}`;
return this.http.get<any>(q, { headers }).pipe(
catchError(() => this.http.get<any>(f, { headers }))
);
}
private fetchTwitchCategoryForKeyword(token: string, headers: HttpHeaders, keyword: string): Observable<any> {
const q = `/api/twitch-api/helix/search/categories?query=${encodeURIComponent(keyword)}&first=5`;
const f = `/proxy/twitch-api/helix/search/categories?query=${encodeURIComponent(keyword)}&first=5`;
return this.http.get<any>(q, { headers }).pipe(
catchError(() => this.http.get<any>(f, { headers }))
);
}
/** Chaînes Twitch (lives + hors-ligne) pour une requête, façon directory. */
public searchTwitchAllChannelsPage(query: string, cursor?: string | null, limit = 20): Observable<PagedResult<Video>> {
return this.getTwitchAuthToken().pipe(
@@ -128,14 +172,48 @@ export class YoutubeApiService {
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/search/channels?query=${encodeURIComponent(query)}&first=${limit}${after}`;
const fallback = `/proxy/twitch-api/helix/search/channels?query=${encodeURIComponent(query)}&first=${limit}${after}`;
return this.http.get<any>(primary, { headers }).pipe(
catchError(() => this.http.get<any>(fallback, { headers })),
map(res => ({
items: (Array.isArray(res?.data) ? res.data : []).map((i: any) => this.mapTwitchChannelToVideo(i)),
const q = String(query || '').trim();
if (!q) return of({ items: [], nextCursor: null });
// 1) Essai requête complète d'abord (pertinent pour "just chatting", noms de jeux...).
return this.fetchTwitchChannelsForKeyword(token, headers, q, limit, after).pipe(
switchMap(res => {
const list = Array.isArray(res?.data) ? res.data : [];
if (list.length > 0 || cursor) {
return of({
items: list.map((i: any) => this.mapTwitchChannelToVideo(i)),
nextCursor: res?.pagination?.cursor || null,
} as PagedResult<Video>)),
} as PagedResult<Video>);
}
// 2) Repli mot-à-mot : chaque mot-clé testé séparément, résultats fusionnés.
const keywords = this.twitchQueryKeywords(q, 3);
if (!keywords.length) return of({ items: [], nextCursor: null } as PagedResult<Video>);
const calls = keywords.map(k =>
this.fetchTwitchChannelsForKeyword(token, headers, k, Math.min(10, limit)).pipe(
catchError(() => of({ data: [], pagination: {} }))
)
);
return forkJoin(calls).pipe(
map((responses: any[]) => {
const seen = new Set<string>();
const merged: any[] = [];
let next: string | null = null;
for (const r of responses) {
const arr = Array.isArray(r?.data) ? r.data : [];
if (!next && r?.pagination?.cursor) next = r.pagination.cursor;
for (const c of arr) {
const key = String(c?.id || c?.broadcaster_login || c?.display_name || '');
if (!key || seen.has(key)) continue;
seen.add(key);
merged.push(c);
}
}
return {
items: merged.slice(0, limit).map((i: any) => this.mapTwitchChannelToVideo(i)),
nextCursor: next,
} as PagedResult<Video>;
})
);
}),
catchError((e) => { console.error('Twitch search channels error', e); return of({ items: [], nextCursor: null }); })
);
})
@@ -150,20 +228,30 @@ export class YoutubeApiService {
const q = String(query || '').trim();
if (!q) return of(null);
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
const primary = `/api/twitch-api/helix/search/categories?query=${encodeURIComponent(q)}&first=5`;
const fallback = `/proxy/twitch-api/helix/search/categories?query=${encodeURIComponent(q)}&first=5`;
return this.http.get<any>(primary, { headers }).pipe(
catchError(() => this.http.get<any>(fallback, { headers })),
map(res => {
const list: any[] = Array.isArray(res?.data) ? res.data : [];
if (!list.length) return null;
const needle = q.toLowerCase();
const exact = list.find(c => String(c?.name || '').toLowerCase() === needle);
const best = exact || list[0];
return best?.id ? { id: String(best.id), name: String(best.name || ''), boxArtUrl: best.box_art_url || undefined } : null;
}),
catchError(() => of(null))
// Essaie la requête complète puis chaque mot-clé (une longue phrase ne matche jamais une catégorie).
const attempts = [q, ...this.twitchQueryKeywords(q, 3).filter(k => k !== q.toLowerCase())];
const calls = attempts.slice(0, 4).map(a =>
this.fetchTwitchCategoryForKeyword(token, headers, a).pipe(
map(res => ({ attempt: a, list: Array.isArray(res?.data) ? res.data : [] })),
catchError(() => of({ attempt: a, list: [] }))
)
);
// Séquentiel : premier match exact, sinon premier non-vide.
const tryNext = (idx: number): Observable<{ id: string; name: string; boxArtUrl?: string } | null> => {
if (idx >= calls.length) return of(null);
return calls[idx].pipe(
switchMap(({ attempt, list }: any) => {
if (list.length) {
const needle = String(attempt || '').toLowerCase();
const exact = list.find((c: any) => String(c?.name || '').toLowerCase() === needle);
const best = exact || list[0];
if (best?.id) return of({ id: String(best.id), name: String(best.name || ''), boxArtUrl: best.box_art_url || undefined });
}
return tryNext(idx + 1);
})
);
};
return tryNext(0).pipe(catchError(() => of(null)));
})
);
}
@@ -243,33 +331,90 @@ export class YoutubeApiService {
switchMap(token => {
if (!token) return of({ items: [], nextCursor: null });
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
const q = String(query || '').trim();
if (!q) return of({ items: [], nextCursor: null });
const after = cursor ? `&after=${encodeURIComponent(cursor)}` : '';
const chPrimary = `/api/twitch-api/helix/search/channels?query=${encodeURIComponent(query)}&first=10${after}`;
const chFallback = `/proxy/twitch-api/helix/search/channels?query=${encodeURIComponent(query)}&first=10${after}`;
const fetchChannels$ = this.http.get<any>(chPrimary, { headers }).pipe(
catchError(() => this.http.get<any>(chFallback, { headers }))
// Chaînes candidates : requête complète + mots-clés unitaires (Helix ne matche pas les phrases).
const keywords = [q, ...this.twitchQueryKeywords(q, 3).filter(k => k !== q.toLowerCase())].slice(0, 4);
const channelCalls = keywords.map((k, idx) =>
this.fetchTwitchChannelsForKeyword(token, headers, k, 10, idx === 0 ? after : '').pipe(
catchError(() => of({ data: [], pagination: {} }))
)
);
return fetchChannels$.pipe(
switchMap((chRes: any) => {
const channels = Array.isArray(chRes?.data) ? chRes.data : [];
return forkJoin(channelCalls).pipe(
switchMap((responses: any[]) => {
const seenCh = new Set<string>();
const channels: any[] = [];
let next: string | null = null;
for (const r of responses) {
const arr = Array.isArray(r?.data) ? r.data : [];
if (!next && r?.pagination?.cursor) next = r.pagination.cursor;
for (const c of arr) {
const key = String(c?.id || '');
if (!key || seenCh.has(key)) continue;
seenCh.add(key);
channels.push(c);
}
}
const userIds: string[] = channels.map((c: any) => c?.id).filter(Boolean).slice(0, 10);
if (userIds.length === 0) return of({ items: [], nextCursor: null } as PagedResult<Video>);
const perUserRequests = userIds.map(uid => {
const fetchVodsForUsers = (ids: string[]) => {
if (!ids.length) return of([] as any[]);
const perUserRequests = ids.map(uid => {
// type=all to include archive, upload, highlight; fetch a few more to increase hit rate
const vPrimary = `/api/twitch-api/helix/videos?user_id=${encodeURIComponent(uid)}&first=10&type=all`;
const vFallback = `/proxy/twitch-api/helix/videos?user_id=${encodeURIComponent(uid)}&first=10&type=all`;
return this.http.get<any>(vPrimary, { headers }).pipe(
catchError(() => this.http.get<any>(vFallback, { headers })),
map(vres => Array.isArray(vres?.data) ? vres.data : [])
map(vres => Array.isArray(vres?.data) ? vres.data : []),
catchError(() => of([] as any[]))
);
});
return forkJoin(perUserRequests).pipe(
map((lists: any[][]) => lists.flat().slice(0, 20)),
map((videos: any[]) => ({
items: videos.map((i: any) => this.mapTwitchItemToVideo(i)),
nextCursor: chRes?.pagination?.cursor || null,
return forkJoin(perUserRequests).pipe(map((lists: any[][]) => lists.flat()));
};
// Complément par catégorie (game_id) quand les chaînes ne suffisent pas (thèmes génériques).
const fetchVodsByCategory = (): Observable<any[]> => {
const cats = this.twitchQueryKeywords(q, 2);
if (!cats.length) return of([]);
const catCalls = cats.slice(0, 2).map(k =>
this.fetchTwitchCategoryForKeyword(token, headers, k).pipe(catchError(() => of({ data: [] })))
);
return forkJoin(catCalls).pipe(
switchMap((catRes: any[]) => {
const gameIds: string[] = [];
for (const cr of catRes) {
const list = Array.isArray(cr?.data) ? cr.data : [];
if (list[0]?.id) gameIds.push(String(list[0].id));
}
if (!gameIds.length) return of([]);
const gameCalls = gameIds.slice(0, 2).map(gid => {
const gPrimary = `/api/twitch-api/helix/videos?game_id=${encodeURIComponent(gid)}&first=10&sort=trending&type=all`;
const gFallback = `/proxy/twitch-api/helix/videos?game_id=${encodeURIComponent(gid)}&first=10&sort=trending&type=all`;
return this.http.get<any>(gPrimary, { headers }).pipe(
catchError(() => this.http.get<any>(gFallback, { headers })),
map(vres => Array.isArray(vres?.data) ? vres.data : []),
catchError(() => of([] as any[]))
);
});
return forkJoin(gameCalls).pipe(map((lists: any[][]) => lists.flat()));
})
);
};
return fetchVodsForUsers(userIds).pipe(
switchMap(vodsByUser => {
if (vodsByUser.length >= 4) {
return of({
items: vodsByUser.slice(0, 20).map((i: any) => this.mapTwitchItemToVideo(i)),
nextCursor: next,
} as PagedResult<Video>);
}
return fetchVodsByCategory().pipe(
map(vodsByGame => ({
items: [...vodsByUser, ...vodsByGame].slice(0, 20).map((i: any) => this.mapTwitchItemToVideo(i)),
nextCursor: next,
} as PagedResult<Video>))
);
})
);
}),
catchError((e) => { console.error('Twitch search VODs error', e); return of({ items: [], nextCursor: null }); })
);
@@ -1090,32 +1235,88 @@ export class YoutubeApiService {
switchMap(token => {
if (!token) return of({ items: [], nextCursor: null });
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
const q = String(query || '').trim();
if (!q) return of({ items: [], nextCursor: null });
const after = cursor ? `&after=${encodeURIComponent(cursor)}` : '';
const q = String(query || '');
const chPrimary = `/api/twitch-api/helix/search/channels?query=${encodeURIComponent(q)}&first=10${after}`;
const chFallback = `/proxy/twitch-api/helix/search/channels?query=${encodeURIComponent(q)}&first=10${after}`;
return this.http.get<any>(chPrimary, { headers }).pipe(
catchError(() => this.http.get<any>(chFallback, { headers })),
switchMap(chRes => {
const channels = Array.isArray(chRes?.data) ? chRes.data : [];
const userIds: string[] = channels.map((c: any) => c?.id).filter(Boolean).slice(0, 10);
if (userIds.length === 0) return of({ items: [], nextCursor: null } as PagedResult<Video>);
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const perUserRequests = userIds.map(uid => {
const keywords = [q, ...this.twitchQueryKeywords(q, 3).filter(k => k !== q.toLowerCase())].slice(0, 4);
const channelCalls = keywords.map((k, idx) =>
this.fetchTwitchChannelsForKeyword(token, headers, k, 10, idx === 0 ? after : '').pipe(
catchError(() => of({ data: [], pagination: {} }))
)
);
// Fenêtre large (30j -> 90j) : beaucoup de petites chaînes n'ont pas de clips sur 7j.
const since = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString();
const fetchClipsForUsers = (ids: string[]) => {
if (!ids.length) return of([] as any[]);
const perUser = ids.slice(0, 8).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 : [])
map(res => Array.isArray(res?.data) ? res.data : []),
catchError(() => of([] as any[]))
);
});
return forkJoin(perUserRequests).pipe(
map((lists: any[][]) => lists.flat().slice(0, 24)),
map((clips: any[]) => ({
items: clips.map((i: any) => this.mapTwitchClipToVideo(i)),
nextCursor: chRes?.pagination?.cursor || null,
} as PagedResult<Video>)),
catchError(() => of({ items: [], nextCursor: null } as PagedResult<Video>))
return forkJoin(perUser).pipe(map((lists: any[][]) => lists.flat()));
};
return forkJoin(channelCalls).pipe(
switchMap((responses: any[]) => {
const seen = new Set<string>();
const channels: any[] = [];
let next: string | null = null;
for (const r of responses) {
const arr = Array.isArray(r?.data) ? r.data : [];
if (!next && r?.pagination?.cursor) next = r.pagination.cursor;
for (const c of arr) {
const key = String(c?.id || '');
if (!key || seen.has(key)) continue;
seen.add(key);
channels.push(c);
}
}
const userIds: string[] = channels.map((c: any) => c?.id).filter(Boolean).slice(0, 10);
return fetchClipsForUsers(userIds).pipe(
switchMap(clipsByUser => {
if (clipsByUser.length >= 3) {
return of({
items: clipsByUser.slice(0, 24).map((i: any) => this.mapTwitchClipToVideo(i)),
nextCursor: next,
} as PagedResult<Video>);
}
// Repli catégorie (game_id) : clips thématiques même sans chaîne matchée.
const cats = this.twitchQueryKeywords(q, 2);
if (!cats.length) return of({ items: clipsByUser.slice(0, 24).map((i: any) => this.mapTwitchClipToVideo(i)), nextCursor: next } as PagedResult<Video>);
const catCalls = cats.slice(0, 2).map(k =>
this.fetchTwitchCategoryForKeyword(token, headers, k).pipe(catchError(() => of({ data: [] })))
);
return forkJoin(catCalls).pipe(
switchMap((catRes: any[]) => {
const gameIds: string[] = [];
for (const cr of catRes) {
const list = Array.isArray(cr?.data) ? cr.data : [];
if (list[0]?.id) gameIds.push(String(list[0].id));
}
if (!gameIds.length) {
return of({ items: clipsByUser.slice(0, 24).map((i: any) => this.mapTwitchClipToVideo(i)), nextCursor: next } as PagedResult<Video>);
}
const gameCalls = gameIds.slice(0, 2).map(gid => {
const gPrimary = `/api/twitch-api/helix/clips?game_id=${encodeURIComponent(gid)}&first=10&started_at=${encodeURIComponent(since)}`;
const gFallback = `/proxy/twitch-api/helix/clips?game_id=${encodeURIComponent(gid)}&first=10&started_at=${encodeURIComponent(since)}`;
return this.http.get<any>(gPrimary, { headers }).pipe(
catchError(() => this.http.get<any>(gFallback, { headers })),
map(res => Array.isArray(res?.data) ? res.data : []),
catchError(() => of([] as any[]))
);
});
return forkJoin(gameCalls).pipe(
map((lists: any[][]) => ({
items: [...clipsByUser, ...lists.flat()].slice(0, 24).map((i: any) => this.mapTwitchClipToVideo(i)),
nextCursor: next,
} as PagedResult<Video>))
);
})
);
})
);
}),
catchError((e) => { console.error('Twitch clips search error', e); return of({ items: [], nextCursor: null }); })