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, tap, timeout } from 'rxjs/operators'; import { Video, VideoDetail, Comment } from '../models/video.model'; import { InstanceService, Provider } from './instance.service'; import { I18nService } from './i18n.service'; import { UserService } from './user.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, private users: UserService) { this.getTwitchAuthToken().subscribe(this.twitchAuthToken$); this.ytKeys = this.loadYouTubeKeys(); } /** * Langues acceptées pour les Shorts : langue d'interface + langue par * défaut des préférences + langues de transcripts (`downloadLanguages`). * Ex. UI en anglais + transcripts fr/en ⇒ ['en', 'fr'] : les Shorts sont * cherchés dans ces langues, jamais dans d'autres quand l'API le permet * (YouTube relevanceLanguage, PeerTube languageOneOf, Twitch language). */ public getShortsLanguages(): string[] { const out: string[] = []; const push = (v: unknown) => { const base = String(v || '').trim().toLowerCase().split('-')[0]; if (/^[a-z]{2,3}$/.test(base) && !out.includes(base)) out.push(base); }; // 1) Préférences d'abord (l'UI guide la langue primaire des recherches). try { push(this.i18n.lang()); } catch {} try { const prefs = this.users.preferences() as { language?: unknown; downloadLanguages?: unknown } | null; push((prefs as any)?.language); const dl = (prefs as any)?.downloadLanguages; if (Array.isArray(dl)) for (const l of dl) push(l); } catch {} // 2) L'app ne propose que FR/EN : ces deux langues sont TOUJOURS acceptées // (on filtre les autres uniquement quand le provider expose `language`). push('en'); push('fr'); return out; } /** Langue primaire (2 lettres) pour les APIs qui n'acceptent qu'un code ISO 639-1. */ private primaryShortsLang2(): string | null { try { const langs = this.getShortsLanguages(); const two = langs.find(l => l.length === 2); return two || null; } catch { return null; } } 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 ===================== /** * 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(); 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 { 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(q, { headers }).pipe( catchError(() => this.http.get(f, { headers })) ); } private fetchTwitchCategoryForKeyword(token: string, headers: HttpHeaders, keyword: string): Observable { 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(q, { headers }).pipe( catchError(() => this.http.get(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> { 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}` }); const after = cursor ? `&after=${encodeURIComponent(cursor)}` : ''; 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