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
@@ -14,6 +14,7 @@ import { ChannelIdentityComponent } from '../../app/shared/components/channel-id
import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component';
import { classifyVideo, isLiveVideo, isShortVideo } from '../../app/shared/utils/video-kind';
import { map, of, switchMap, catchError, forkJoin, Observable } from 'rxjs';
import { timeout } from 'rxjs/operators';
import { PagedResult } from '../../services/youtube-api.service';
const TWITCH_THEME_KEYWORDS: Record<string, string[]> = {
@@ -316,17 +317,27 @@ export class ProviderThemePageComponent implements OnDestroy {
this.cdr.markForCheck();
};
// Une section lente ne doit jamais bloquer les autres (forkJoin
// échoue dès qu'un appel erreur : chaque section est bornée à 25 s
// et retombe sur une page vide, le filet top-streams suit).
const bounded = (call: Observable<PagedResult<Video>>): Observable<PagedResult<Video>> =>
call.pipe(
timeout(25000),
catchError(() => of({ items: [], nextCursor: null } as PagedResult<Video>))
);
const fetchAll = (gameId: string | null) => {
const calls: Record<string, Observable<PagedResult<Video>>> = {};
if (wantLive) {
calls['live'] = gameId
const liveCall = gameId
? this.api.getTwitchGameStreamsPage(gameId, this.twitchCursors.live || undefined, 12).pipe(
catchError(() => this.api.getTwitchTopStreamsPage(this.twitchCursors.live || undefined)))
: this.api.getTwitchTopStreamsPage(this.twitchCursors.live || undefined);
calls['live'] = bounded(liveCall);
}
if (wantVod) calls['vod'] = this.api.searchTwitchVodsPage(query, this.twitchCursors.vod || undefined);
if (wantClip) calls['clip'] = this.api.searchTwitchClipsPage(query, this.twitchCursors.clip || undefined);
if (wantChannel) calls['channel'] = this.api.searchTwitchAllChannelsPage(query, this.twitchCursors.channel || undefined, 20);
if (wantVod) calls['vod'] = bounded(this.api.searchTwitchVodsPage(query, this.twitchCursors.vod || undefined));
if (wantClip) calls['clip'] = bounded(this.api.searchTwitchClipsPage(query, this.twitchCursors.clip || undefined));
if (wantChannel) calls['channel'] = bounded(this.api.searchTwitchAllChannelsPage(query, this.twitchCursors.channel || undefined, 20));
if (Object.keys(calls).length === 0) {
this.loading.set(false);
this.loadingMore.set(false);
@@ -359,8 +370,13 @@ export class ProviderThemePageComponent implements OnDestroy {
};
if (firstPage) {
// Première page : résoudre la catégorie Twitch pour des lives thématiques.
this.api.searchTwitchCategory(query).subscribe({
// Première page : résoudre la catégorie Twitch pour des lives
// thématiques, SANS bloquer l'affichage (4 s max, la recherche de
// catégorie séquentielle prenait plusieurs secondes à elle seule).
this.api.searchTwitchCategory(query).pipe(
timeout(4000),
catchError(() => of(null))
).subscribe({
next: (game) => {
if (snapshotVersion !== this.version) return;
if (game) this.twitchGame.set(game);
+23 -5
View File
@@ -219,15 +219,33 @@ export class ThemePageComponent {
if (block.provider === 'twitch') {
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 => {
block.items = (res.items || []).slice(0, max);
const show = (items: Video[]) => {
block.items = (items || []).slice(0, max);
block.loading = false;
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, query: q });
this.blocks.update(a => [...a]);
};
const fail = () => { block.error = 'Erreur de chargement'; block.loading = false; this.blocks.update(a => [...a]); };
const call = q ? this.api.searchTwitchThemePage(q, null) : this.api.getTwitchTopStreamsPage(null);
call.subscribe({
next: res => {
// Thème vide malgré les replis : dernière chance top streams live.
if (!(res.items || []).length && q) {
this.api.getTwitchTopStreamsPage(null).subscribe({
next: top => show(top.items || []),
error: () => fail(),
});
return;
}
show(res.items || []);
},
error: () => { block.error = 'Erreur de chargement'; block.loading = false; this.blocks.update(a => [...a]); }
error: () => {
// Erreur (timeout 30 s…) : une tentative top streams avant l'erreur.
this.api.getTwitchTopStreamsPage(null).subscribe({
next: top => { if ((top.items || []).length) show(top.items || []); else fail(); },
error: () => fail(),
});
}
});
return;
}
+68 -24
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,21 +1822,53 @@ 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(
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);
return String(res.accessToken);
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(() => of(null))
catchError(() => {
this.twitchTokenShared$ = null;
this.twitchTokenValue = null;
return of(null);
})
);
}
return this.twitchTokenShared$;
}
private parseTwitchDurationToSeconds(duration: string): number {
let totalSeconds = 0;