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 { Video, VideoDetail, Comment } from '../models/video.model'; import { InstanceService, Provider } from './instance.service'; import { I18nService } from './i18n.service'; export interface PagedResult { items: T[]; nextCursor?: string | null; } @Injectable({ providedIn: 'root' }) export class YoutubeApiService { private twitchAuthToken$ = new BehaviorSubject(null); // Client ID renvoyé par le serveur avec le token (source de vérité côté back) private twitchServerClientId: string | null = null; // Quota exceeded flag to surface to UI components public quotaExceeded$ = new BehaviorSubject(false); // Lightweight in-memory caches (session scope) private readonly CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes private searchCache = new Map }>(); private trendingCache = new Map }>(); // ---- YouTube multi-key rotation ---- private ytKeys: string[] = []; private ytKeyIndex = 0; private ytKeyBans = new Map(); // key -> bannedUntil epoch ms // Ban duration (default 6h) can be overridden via window.YT_KEY_BAN_MS private readonly YT_KEY_BAN_MS = (() => { try { const v = Number((globalThis as any)?.YT_KEY_BAN_MS); return Number.isFinite(v) && v > 0 ? v : 6 * 60 * 60 * 1000; } catch { return 6 * 60 * 60 * 1000; } })(); constructor(private http: HttpClient, private instances: InstanceService, private i18n: I18nService) { this.getTwitchAuthToken().subscribe(this.twitchAuthToken$); this.ytKeys = this.loadYouTubeKeys(); } private normalizeDuration(raw: any): number { if (typeof raw === 'number' && Number.isFinite(raw)) { return raw > 0 ? Math.round(raw) : 0; } if (typeof raw === 'string') { const value = raw.trim(); if (!value) return 0; const numeric = Number(value); if (Number.isFinite(numeric)) { return numeric > 0 ? Math.round(numeric) : 0; } const spaced = value.replace(/[,]/g, '').trim(); const spaceMatch = spaced.match(/^(\d+)[\s]+(\d{1,2}):(\d{2})$/); if (spaceMatch) { const hours = Number(spaceMatch[1]); const minutes = Number(spaceMatch[2]); const seconds = Number(spaceMatch[3]); const total = hours * 3600 + minutes * 60 + seconds; if (total > 0) return total; } const colonized = value.replace(/[^0-9:]/g, ''); if (colonized.includes(':')) { const segments = colonized.split(':').filter(Boolean).map(part => Number(part)); if (segments.length >= 2 && segments.every(segment => Number.isFinite(segment))) { while (segments.length < 3) segments.unshift(0); const [hours, minutes, seconds] = segments.slice(-3); const total = hours * 3600 + minutes * 60 + seconds; if (total > 0) return total; } } } return 0; } // Some Odysee API responses return thumbnail URLs on hosts like "thumbs.odycdn.com" // that can be flaky or blocked by the browser. Prefer the canonical // "thumbnails.odycdn.com" host and ensure https scheme. private normalizeOdyseeThumb(url?: string): string { if (!url) return ''; try { let u = String(url).trim(); if (!u) return ''; // Ensure protocol if (u.startsWith('//')) u = 'https:' + u; if (!/^https?:\/\//i.test(u)) u = 'https://' + u.replace(/^\/*/, ''); // Replace non-canonical host variants try { const parsed = new URL(u); const host = parsed.hostname.toLowerCase(); if (host === 'thumbs.odycdn.com' || host === 'thumb.odycdn.com') { parsed.hostname = 'thumbnails.odycdn.com'; return parsed.toString(); } // Already canonical return parsed.toString(); } catch { // Fallback simple replace return u.replace('thumbs.odycdn.com', 'thumbnails.odycdn.com'); } } catch { return url || ''; } } // Build a proxied URL through our backend to avoid ORB/CORS on Odysee CDN private proxiedOdyseeThumb(url?: string): string { const norm = this.normalizeOdyseeThumb(url); if (!norm) return ''; try { return `/proxy/api/img/odysee?u=${encodeURIComponent(norm)}`; } catch { return `/proxy/api/img/odysee?u=${encodeURIComponent(String(norm))}`; } } // ... (rest of the code remains the same) // ===================== Twitch Search Helpers ===================== /** 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( switchMap(token => { 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