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
@@ -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 => {
+268 -67
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)),
nextCursor: res?.pagination?.cursor || null,
} as PagedResult<Video>)),
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>);
}
// 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,32 +331,89 @@ 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 => {
// 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 : [])
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 : []),
catchError(() => of([] as any[]))
);
});
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(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,
} as PagedResult<Video>))
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 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 : []),
catchError(() => of([] as any[]))
);
});
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);
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 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(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 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 }); })