fix(twitch): token partagé + timeouts + fan-out réduit sur Home/Explore
CI / build-and-test (push) Successful in 14m10s

This commit is contained in:
2026-09-29 09:56:15 -04:00
parent 5994841bda
commit 3fe05b3c30
3 changed files with 121 additions and 43 deletions
+76 -32
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable, of, BehaviorSubject, forkJoin } from 'rxjs';
import { catchError, map, switchMap, shareReplay, first } from 'rxjs/operators';
import { catchError, map, switchMap, shareReplay, first, tap, timeout } from 'rxjs/operators';
import { Video, VideoDetail, Comment } from '../models/video.model';
import { InstanceService, Provider } from './instance.service';
import { I18nService } from './i18n.service';
@@ -167,7 +167,7 @@ export class YoutubeApiService {
/** 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(
return this.getTwitchAuthToken().pipe(timeout(30000),
switchMap(token => {
if (!token) return of({ items: [], nextCursor: null });
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
@@ -222,7 +222,7 @@ export class YoutubeApiService {
/** Catégorie Twitch la plus pertinente (ex. "sports" -> Sports + box-art). */
public searchTwitchCategory(query: string): Observable<{ id: string; name: string; boxArtUrl?: string } | null> {
return this.getTwitchAuthToken().pipe(
return this.getTwitchAuthToken().pipe(timeout(30000),
switchMap(token => {
if (!token) return of(null);
const q = String(query || '').trim();
@@ -258,7 +258,7 @@ export class YoutubeApiService {
/** Top streams d'une catégorie Twitch (directory/category façon twitch.tv). */
public getTwitchGameStreamsPage(gameId: string, cursor?: string | null, limit = 24): Observable<PagedResult<Video>> {
return this.getTwitchAuthToken().pipe(
return this.getTwitchAuthToken().pipe(timeout(30000),
switchMap(token => {
if (!token) return of({ items: [], nextCursor: null });
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
@@ -279,7 +279,7 @@ export class YoutubeApiService {
/** Search live channels matching query (around 20), with pagination cursor. */
public searchTwitchChannelsPage(query: string, cursor?: string | null): Observable<PagedResult<Video>> {
return this.getTwitchAuthToken().pipe(
return this.getTwitchAuthToken().pipe(timeout(30000),
switchMap(token => {
if (!token) return of({ items: [], nextCursor: null });
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
@@ -327,7 +327,7 @@ export class YoutubeApiService {
/** Search VODs: find channels by query then fetch recent videos per channel; flatten to ~20. */
public searchTwitchVodsPage(query: string, cursor?: string | null): Observable<PagedResult<Video>> {
return this.getTwitchAuthToken().pipe(
return this.getTwitchAuthToken().pipe(timeout(30000),
switchMap(token => {
if (!token) return of({ items: [], nextCursor: null });
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
@@ -356,13 +356,16 @@ export class YoutubeApiService {
channels.push(c);
}
}
const userIds: string[] = channels.map((c: any) => c?.id).filter(Boolean).slice(0, 10);
const userIds: string[] = channels.map((c: any) => c?.id).filter(Boolean).slice(0, 6);
const fetchVodsForUsers = (ids: string[]) => {
if (!ids.length) return of([] as any[]);
const perUserRequests = ids.map(uid => {
// Fan-out réduit : 6 chaînes × 6 VODs suffisent, le budget
// taux/quota Helix et la limite de 6 connexions navigateur
// remercient (une page Explore en lance déjà 4 sections).
const perUserRequests = ids.slice(0, 6).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`;
const vPrimary = `/api/twitch-api/helix/videos?user_id=${encodeURIComponent(uid)}&first=6&type=all`;
const vFallback = `/proxy/twitch-api/helix/videos?user_id=${encodeURIComponent(uid)}&first=6&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 : []),
@@ -785,7 +788,7 @@ export class YoutubeApiService {
// 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(
return this.getTwitchAuthToken().pipe(timeout(30000),
switchMap(token => {
if (!token) return of({ items: [], nextCursor: null });
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
@@ -1437,7 +1440,7 @@ export class YoutubeApiService {
* then fetching recent clips per broadcaster. Returns up to ~24 clips with pagination via channel search cursor.
*/
public searchTwitchClipsPage(query: string, cursor?: string | null): Observable<PagedResult<Video>> {
return this.getTwitchAuthToken().pipe(
return this.getTwitchAuthToken().pipe(timeout(30000),
switchMap(token => {
if (!token) return of({ items: [], nextCursor: null });
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
@@ -1454,9 +1457,10 @@ export class YoutubeApiService {
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)}`;
// Fan-out réduit (voir VODs ci-dessus).
const perUser = ids.slice(0, 6).map(uid => {
const cPrimary = `/api/twitch-api/helix/clips?broadcaster_id=${encodeURIComponent(uid)}&first=4&started_at=${encodeURIComponent(since)}`;
const cFallback = `/proxy/twitch-api/helix/clips?broadcaster_id=${encodeURIComponent(uid)}&first=4&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 : []),
@@ -1538,7 +1542,7 @@ export class YoutubeApiService {
* 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(
return this.getTwitchAuthToken().pipe(timeout(30000),
switchMap(token => {
if (!token) return of({ items: [], nextCursor: null });
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
@@ -1583,9 +1587,17 @@ export class YoutubeApiService {
}),
catchError(() => of(merged))
);
})
}),
// Un timeout/échec des VODs ou clips ne doit jamais vider la page :
// repli direct sur les top streams live.
catchError(() => this.getTwitchTopStreamsPage(cursor).pipe(
catchError(() => of({ items: [], nextCursor: null } as PagedResult<Video>))
))
);
})
}),
catchError(() => this.getTwitchTopStreamsPage(cursor).pipe(
catchError(() => of({ items: [], nextCursor: null } as PagedResult<Video>))
))
);
}
@@ -1601,7 +1613,7 @@ export class YoutubeApiService {
private getTwitchTopGameIds(): Observable<string[]> {
if (this.twitchTopGameIds?.length) return of(this.twitchTopGameIds);
return this.getTwitchAuthToken().pipe(
return this.getTwitchAuthToken().pipe(timeout(30000),
switchMap(token => {
if (!token) return of([] as string[]);
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
@@ -1621,7 +1633,7 @@ export class YoutubeApiService {
}
private fetchTwitchGameClipsPage(gameId: string, pageCursor?: string | null): Observable<PagedResult<Video>> {
return this.getTwitchAuthToken().pipe(
return this.getTwitchAuthToken().pipe(timeout(30000),
switchMap(token => {
if (!token || !gameId) return of({ items: [], nextCursor: null });
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
@@ -1685,7 +1697,7 @@ export class YoutubeApiService {
* /helix/games/top ne répond pas.
*/
private getTwitchBroadcasterClipsFeed(cursor?: string | null): Observable<PagedResult<Video>> {
return this.getTwitchAuthToken().pipe(
return this.getTwitchAuthToken().pipe(timeout(30000),
switchMap(token => {
if (!token) return of({ items: [], nextCursor: null });
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
@@ -1810,20 +1822,52 @@ export class YoutubeApiService {
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;
}
// ---- Cache client du token Twitch (single-flight + TTL) ----
// Avant : CHAQUE appel Twitch (token + sections + fan-out) refaisait un
// HTTP /api/twitch-token, soit ~10 requêtes token par page Explore, en
// plus des ~40 appels Helix parallèles. Le moindre hoquet réseau vidait
// toute la page. Maintenant : 1 seul vol partagé, réutilisé 30 min.
private twitchTokenValue: string | null = null;
private twitchTokenShared$: Observable<string | null> | null = null;
private twitchTokenAt = 0;
private readonly TWITCH_TOKEN_TTL_MS = 30 * 60 * 1000;
private getTwitchAuthToken(): Observable<string | null> {
// 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))
);
const now = Date.now();
if (this.twitchTokenValue && this.twitchTokenShared$ && (now - this.twitchTokenAt) < this.TWITCH_TOKEN_TTL_MS) {
return of(this.twitchTokenValue);
}
if (!this.twitchTokenShared$ || (now - this.twitchTokenAt) >= this.TWITCH_TOKEN_TTL_MS) {
this.twitchTokenAt = now;
this.twitchTokenShared$ = this.http.get<any>('/api/twitch-token').pipe(
timeout(10000),
map(res => {
if (res?.accessToken) {
if (res?.clientId) this.twitchServerClientId = String(res.clientId);
this.twitchTokenValue = String(res.accessToken);
return this.twitchTokenValue as string;
}
return null;
}),
tap(t => {
// Échec : on invalide le partage pour que le prochain appel
// retente un HTTP au lieu de rejouer `null` jusqu'au TTL.
if (!t) {
this.twitchTokenShared$ = null;
this.twitchTokenValue = null;
}
}),
shareReplay(1),
catchError(() => {
this.twitchTokenShared$ = null;
this.twitchTokenValue = null;
return of(null);
})
);
}
return this.twitchTokenShared$;
}
private parseTwitchDurationToSeconds(duration: string): number {