fix(twitch): token partagé + timeouts + fan-out réduit sur Home/Explore
CI / build-and-test (push) Successful in 14m10s
CI / build-and-test (push) Successful in 14m10s
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user