fix(themes): pages themes Twitch vides -> requetes Helix tokenisees + fallbacks game_id/top-streams
This commit is contained in:
+110
-14
@@ -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);
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 }); })
|
||||
|
||||
Reference in New Issue
Block a user