From 21f2296c416db0861338a88a050f621c046a5355 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 26 Sep 2026 18:46:55 -0400 Subject: [PATCH] fix(themes): pages themes Twitch vides -> requetes Helix tokenisees + fallbacks game_id/top-streams --- server/providers/twitch.mjs | 124 ++++++- .../themes/provider-theme-page.component.ts | 13 +- src/components/themes/theme-page.component.ts | 4 +- src/services/youtube-api.service.ts | 335 ++++++++++++++---- 4 files changed, 389 insertions(+), 87 deletions(-) diff --git a/server/providers/twitch.mjs b/server/providers/twitch.mjs index 75e5bb9..2f82345 100644 --- a/server/providers/twitch.mjs +++ b/server/providers/twitch.mjs @@ -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) { - try { - const data = await helixGet('/search/categories', new URLSearchParams({ query: q, first: '5' }), auth, { retries: 1 }); - const list = Array.isArray(data?.data) ? data.data : []; - if (!list.length) return null; - const needle = String(q || '').trim().toLowerCase(); - const exact = list.find((c) => String(c?.name || '').toLowerCase() === needle); - return (exact || list[0])?.id || null; - } catch { - return null; + 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: attempt, first: '5' }), auth, { retries: 1 }); + const list = Array.isArray(data?.data) ? data.data : []; + if (!list.length) continue; + const needle = String(attempt || '').trim().toLowerCase(); + const exact = list.find((c) => String(c?.name || '').toLowerCase() === needle); + 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); diff --git a/src/components/themes/provider-theme-page.component.ts b/src/components/themes/provider-theme-page.component.ts index a9651c8..0ea53a6 100644 --- a/src/components/themes/provider-theme-page.component.ts +++ b/src/components/themes/provider-theme-page.component.ts @@ -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(); diff --git a/src/components/themes/theme-page.component.ts b/src/components/themes/theme-page.component.ts index 8a26e40..d123c6e 100644 --- a/src/components/themes/theme-page.component.ts +++ b/src/components/themes/theme-page.component.ts @@ -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 => { diff --git a/src/services/youtube-api.service.ts b/src/services/youtube-api.service.ts index e14f6f7..d08cf04 100644 --- a/src/services/youtube-api.service.ts +++ b/src/services/youtube-api.service.ts @@ -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(); + 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 { + 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(q, { headers }).pipe( + catchError(() => this.http.get(f, { headers })) + ); + } + + private fetchTwitchCategoryForKeyword(token: string, headers: HttpHeaders, keyword: string): Observable { + 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(q, { headers }).pipe( + catchError(() => this.http.get(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> { 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(primary, { headers }).pipe( - catchError(() => this.http.get(fallback, { headers })), - map(res => ({ - items: (Array.isArray(res?.data) ? res.data : []).map((i: any) => this.mapTwitchChannelToVideo(i)), - nextCursor: res?.pagination?.cursor || null, - } as PagedResult