CI / build-and-test (push) Successful in 14m9s
- autoplay: les Shorts demarrent muets (comme Watch) pour garantir l'autoplay des iframes; le son se debloque a la premiere interaction (unlockSound) et reste controlable
- proxy prod: ajoute les alias /proxy/twitch-api, /proxy/twitch-auth, /proxy/dm, /proxy/odysee (evite le fallback SPA -> parse JSON 'status 200 ok:false'); forwardJson accepte les deux prefixes. Corrige aussi la validation des clips Twitch morts
- Dailymotion: allow_embed demande dans les flux shorts/trending -> les videos non-embeddables ('Forbidden') sont exclues
- langues: mapping language (Dailymotion/PeerTube/Odysee) + shortsLangOk applique a tous les fournisseurs; en+fr toujours acceptes
- PeerTube shorts: durationMin/Max cote serveur (plus de videos longues au hasard)
- Twitch: validation helix/clips de nouveau fonctionnelle + autoplay muet
2191 lines
102 KiB
TypeScript
2191 lines
102 KiB
TypeScript
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<T> {
|
||
items: T[];
|
||
nextCursor?: string | null;
|
||
}
|
||
|
||
@Injectable({ providedIn: 'root' })
|
||
export class YoutubeApiService {
|
||
private twitchAuthToken$ = new BehaviorSubject<string | null>(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<boolean>(false);
|
||
|
||
// Lightweight in-memory caches (session scope)
|
||
private readonly CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||
private searchCache = new Map<string, { ts: number; data: PagedResult<Video> }>();
|
||
private trendingCache = new Map<string, { ts: number; data: PagedResult<Video> }>();
|
||
|
||
// ---- YouTube multi-key rotation ----
|
||
private ytKeys: string[] = [];
|
||
private ytKeyIndex = 0;
|
||
private ytKeyBans = new Map<string, number>(); // 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<string>();
|
||
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<any> {
|
||
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<any>(q, { headers }).pipe(
|
||
catchError(() => this.http.get<any>(f, { headers }))
|
||
);
|
||
}
|
||
|
||
private fetchTwitchCategoryForKeyword(token: string, headers: HttpHeaders, keyword: string): Observable<any> {
|
||
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<any>(q, { headers }).pipe(
|
||
catchError(() => this.http.get<any>(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<PagedResult<Video>> {
|
||
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<Video>);
|
||
}
|
||
// 2) Repli mot-à-mot : chaque mot-clé testé séparément, résultats fusionnés.
|
||
const keywords = this.twitchQueryKeywords(q, 3);
|
||
if (!keywords.length) return of({ items: [], nextCursor: null } as PagedResult<Video>);
|
||
const calls = keywords.map(k =>
|
||
this.fetchTwitchChannelsForKeyword(token, headers, k, Math.min(10, limit)).pipe(
|
||
catchError(() => of({ data: [], pagination: {} }))
|
||
)
|
||
);
|
||
return forkJoin(calls).pipe(
|
||
map((responses: any[]) => {
|
||
const seen = new Set<string>();
|
||
const merged: any[] = [];
|
||
let next: string | null = null;
|
||
for (const r of responses) {
|
||
const arr = Array.isArray(r?.data) ? r.data : [];
|
||
if (!next && r?.pagination?.cursor) next = r.pagination.cursor;
|
||
for (const c of arr) {
|
||
const key = String(c?.id || c?.broadcaster_login || c?.display_name || '');
|
||
if (!key || seen.has(key)) continue;
|
||
seen.add(key);
|
||
merged.push(c);
|
||
}
|
||
}
|
||
return {
|
||
items: merged.slice(0, limit).map((i: any) => this.mapTwitchChannelToVideo(i)),
|
||
nextCursor: next,
|
||
} as PagedResult<Video>;
|
||
})
|
||
);
|
||
}),
|
||
catchError((e) => { console.error('Twitch search channels error', e); return of({ items: [], nextCursor: null }); })
|
||
);
|
||
})
|
||
);
|
||
}
|
||
|
||
/** 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(timeout(30000),
|
||
switchMap(token => {
|
||
if (!token) return of(null);
|
||
const q = String(query || '').trim();
|
||
if (!q) return of(null);
|
||
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
|
||
// Essaie la requête complète puis chaque mot-clé (une longue phrase ne matche jamais une catégorie).
|
||
const attempts = [q, ...this.twitchQueryKeywords(q, 3).filter(k => k !== q.toLowerCase())];
|
||
const calls = attempts.slice(0, 4).map(a =>
|
||
this.fetchTwitchCategoryForKeyword(token, headers, a).pipe(
|
||
map(res => ({ attempt: a, list: Array.isArray(res?.data) ? res.data : [] })),
|
||
catchError(() => of({ attempt: a, list: [] }))
|
||
)
|
||
);
|
||
// Séquentiel : premier match exact, sinon premier non-vide.
|
||
const tryNext = (idx: number): Observable<{ id: string; name: string; boxArtUrl?: string } | null> => {
|
||
if (idx >= calls.length) return of(null);
|
||
return calls[idx].pipe(
|
||
switchMap(({ attempt, list }: any) => {
|
||
if (list.length) {
|
||
const needle = String(attempt || '').toLowerCase();
|
||
const exact = list.find((c: any) => String(c?.name || '').toLowerCase() === needle);
|
||
const best = exact || list[0];
|
||
if (best?.id) return of({ id: String(best.id), name: String(best.name || ''), boxArtUrl: best.box_art_url || undefined });
|
||
}
|
||
return tryNext(idx + 1);
|
||
})
|
||
);
|
||
};
|
||
return tryNext(0).pipe(catchError(() => of(null)));
|
||
})
|
||
);
|
||
}
|
||
|
||
/** 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(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 langParam = this.primaryShortsLang2() ? `&language=${encodeURIComponent(this.primaryShortsLang2() as string)}` : '';
|
||
const primary = `/api/twitch-api/helix/streams?game_id=${encodeURIComponent(gameId)}&first=${limit}${after}${langParam}`;
|
||
const fallback = `/proxy/twitch-api/helix/streams?game_id=${encodeURIComponent(gameId)}&first=${limit}${after}${langParam}`;
|
||
return this.http.get<any>(primary, { headers }).pipe(
|
||
catchError(() => this.http.get<any>(fallback, { headers })),
|
||
map(res => ({
|
||
items: (res.data || []).map((i: any) => this.mapTwitchStreamToVideo(i)),
|
||
nextCursor: res.pagination?.cursor || null,
|
||
} as PagedResult<Video>)),
|
||
catchError((e) => { console.error('Twitch game streams error', e); return of({ items: [], nextCursor: null }); })
|
||
);
|
||
})
|
||
);
|
||
}
|
||
|
||
/** Search live channels matching query (around 20), with pagination cursor. */
|
||
public searchTwitchChannelsPage(query: string, cursor?: string | null): Observable<PagedResult<Video>> {
|
||
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 primary = `/api/twitch-api/helix/search/channels?query=${encodeURIComponent(query)}&first=20&live_only=true${after}`;
|
||
const fallback = `/proxy/twitch-api/helix/search/channels?query=${encodeURIComponent(query)}&first=20&live_only=true${after}`;
|
||
return this.http.get<any>(primary, { headers }).pipe(
|
||
catchError(() => this.http.get<any>(fallback, { headers })),
|
||
switchMap(res => {
|
||
const data = Array.isArray(res?.data) ? res.data : [];
|
||
const live = data.filter((i: any) => i?.is_live === true).slice(0, 20);
|
||
const baseItems = live.map((i: any) => this.mapTwitchChannelToVideo(i));
|
||
const logins: string[] = live.map((i: any) => i?.broadcaster_login || i?.user_login).filter(Boolean);
|
||
if (logins.length === 0) {
|
||
return of({ items: baseItems, nextCursor: res?.pagination?.cursor || null } as PagedResult<Video>);
|
||
}
|
||
// Fetch viewer counts for these channels
|
||
const queryLogins = logins.map(l => `user_login=${encodeURIComponent(l)}`).join('&');
|
||
const sPrimary = `/api/twitch-api/helix/streams?${queryLogins}`;
|
||
const sFallback = `/proxy/twitch-api/helix/streams?${queryLogins}`;
|
||
return this.http.get<any>(sPrimary, { headers }).pipe(
|
||
catchError(() => this.http.get<any>(sFallback, { headers })),
|
||
map((sres: any) => {
|
||
const streams: any[] = Array.isArray(sres?.data) ? sres.data : [];
|
||
const vc = new Map<string, number>();
|
||
for (const s of streams) {
|
||
const login = s?.user_login || s?.broadcaster_login;
|
||
if (login) vc.set(String(login).toLowerCase(), Number(s?.viewer_count || 0));
|
||
}
|
||
const items = baseItems.map((v: Video) => {
|
||
const key = String(v.videoId || '').toLowerCase();
|
||
const views = vc.get(key);
|
||
return typeof views === 'number' ? { ...v, views } : v;
|
||
});
|
||
return { items, nextCursor: res?.pagination?.cursor || null } as PagedResult<Video>;
|
||
}),
|
||
catchError(() => of({ items: baseItems, nextCursor: res?.pagination?.cursor || null } as PagedResult<Video>))
|
||
);
|
||
}),
|
||
catchError((e) => { console.error('Twitch search channels error', e); return of({ items: [], nextCursor: null }); })
|
||
);
|
||
})
|
||
);
|
||
}
|
||
|
||
/** 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(timeout(30000),
|
||
switchMap(token => {
|
||
if (!token) return of({ items: [], nextCursor: null });
|
||
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
|
||
const q = String(query || '').trim();
|
||
if (!q) return of({ items: [], nextCursor: null });
|
||
const after = cursor ? `&after=${encodeURIComponent(cursor)}` : '';
|
||
// Chaînes candidates : requête complète + mots-clés unitaires (Helix ne matche pas les phrases).
|
||
const keywords = [q, ...this.twitchQueryKeywords(q, 3).filter(k => k !== q.toLowerCase())].slice(0, 4);
|
||
const channelCalls = keywords.map((k, idx) =>
|
||
this.fetchTwitchChannelsForKeyword(token, headers, k, 10, idx === 0 ? after : '').pipe(
|
||
catchError(() => of({ data: [], pagination: {} }))
|
||
)
|
||
);
|
||
return forkJoin(channelCalls).pipe(
|
||
switchMap((responses: any[]) => {
|
||
const seenCh = new Set<string>();
|
||
const channels: any[] = [];
|
||
let next: string | null = null;
|
||
for (const r of responses) {
|
||
const arr = Array.isArray(r?.data) ? r.data : [];
|
||
if (!next && r?.pagination?.cursor) next = r.pagination.cursor;
|
||
for (const c of arr) {
|
||
const key = String(c?.id || '');
|
||
if (!key || seenCh.has(key)) continue;
|
||
seenCh.add(key);
|
||
channels.push(c);
|
||
}
|
||
}
|
||
const userIds: string[] = channels.map((c: any) => c?.id).filter(Boolean).slice(0, 6);
|
||
// Langue de l'utilisateur (filtre natif Helix /videos).
|
||
const vodLang = this.primaryShortsLang2() ? `&language=${encodeURIComponent(this.primaryShortsLang2() as string)}` : '';
|
||
const fetchVodsForUsers = (ids: string[]) => {
|
||
if (!ids.length) return of([] as any[]);
|
||
// 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=6&type=all${vodLang}`;
|
||
const vFallback = `/proxy/twitch-api/helix/videos?user_id=${encodeURIComponent(uid)}&first=6&type=all${vodLang}`;
|
||
return this.http.get<any>(vPrimary, { headers }).pipe(
|
||
catchError(() => this.http.get<any>(vFallback, { headers })),
|
||
map(vres => Array.isArray(vres?.data) ? vres.data : []),
|
||
catchError(() => of([] as any[]))
|
||
);
|
||
});
|
||
return forkJoin(perUserRequests).pipe(map((lists: any[][]) => lists.flat()));
|
||
};
|
||
// Complément par catégorie (game_id) quand les chaînes ne suffisent pas (thèmes génériques).
|
||
const fetchVodsByCategory = (): Observable<any[]> => {
|
||
const cats = this.twitchQueryKeywords(q, 2);
|
||
if (!cats.length) return of([]);
|
||
const catCalls = cats.slice(0, 2).map(k =>
|
||
this.fetchTwitchCategoryForKeyword(token, headers, k).pipe(catchError(() => of({ data: [] })))
|
||
);
|
||
return forkJoin(catCalls).pipe(
|
||
switchMap((catRes: any[]) => {
|
||
const gameIds: string[] = [];
|
||
for (const cr of catRes) {
|
||
const list = Array.isArray(cr?.data) ? cr.data : [];
|
||
if (list[0]?.id) gameIds.push(String(list[0].id));
|
||
}
|
||
if (!gameIds.length) return of([]);
|
||
const gameCalls = gameIds.slice(0, 2).map(gid => {
|
||
const gPrimary = `/api/twitch-api/helix/videos?game_id=${encodeURIComponent(gid)}&first=10&sort=trending&type=all${vodLang}`;
|
||
const gFallback = `/proxy/twitch-api/helix/videos?game_id=${encodeURIComponent(gid)}&first=10&sort=trending&type=all${vodLang}`;
|
||
return this.http.get<any>(gPrimary, { headers }).pipe(
|
||
catchError(() => this.http.get<any>(gFallback, { headers })),
|
||
map(vres => Array.isArray(vres?.data) ? vres.data : []),
|
||
catchError(() => of([] as any[]))
|
||
);
|
||
});
|
||
return forkJoin(gameCalls).pipe(map((lists: any[][]) => lists.flat()));
|
||
})
|
||
);
|
||
};
|
||
return fetchVodsForUsers(userIds).pipe(
|
||
switchMap(vodsByUser => {
|
||
if (vodsByUser.length >= 4) {
|
||
return of({
|
||
items: vodsByUser.slice(0, 20).map((i: any) => this.mapTwitchItemToVideo(i)),
|
||
nextCursor: next,
|
||
} as PagedResult<Video>);
|
||
}
|
||
return fetchVodsByCategory().pipe(
|
||
map(vodsByGame => ({
|
||
items: [...vodsByUser, ...vodsByGame].slice(0, 20).map((i: any) => this.mapTwitchItemToVideo(i)),
|
||
nextCursor: next,
|
||
} as PagedResult<Video>))
|
||
);
|
||
})
|
||
);
|
||
}),
|
||
catchError((e) => { console.error('Twitch search VODs error', e); return of({ items: [], nextCursor: null }); })
|
||
);
|
||
})
|
||
);
|
||
}
|
||
|
||
private fetchOdysee(body: any) {
|
||
const prod = `/api/odysee/api/v1/proxy`;
|
||
const dev = `/proxy/odysee/api/v1/proxy`;
|
||
return this.http.post<any>(prod, body).pipe(
|
||
catchError(() => this.http.post<any>(dev, body))
|
||
);
|
||
}
|
||
|
||
private lbryToOdyseeUrl(lbryUrl: string): string {
|
||
if (!lbryUrl || typeof lbryUrl !== 'string') return '';
|
||
// Convert lbry://@Channel#id/Video#id => https://odysee.com/@Channel:id/Video:id
|
||
try {
|
||
const noScheme = lbryUrl.replace('lbry://', '');
|
||
const parts = noScheme.split('/');
|
||
const segs = parts.map(seg => seg.replace('#', ':'));
|
||
return `https://odysee.com/${segs.join('/')}`;
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
getTrendingVideos(): Observable<Video[]> {
|
||
return this.getTrendingPage().pipe(map(r => r.items));
|
||
}
|
||
|
||
searchVideos(query: string): Observable<Video[]> {
|
||
return this.searchVideosPage(query).pipe(map(r => r.items));
|
||
}
|
||
|
||
getVideoDetails(id: string): Observable<VideoDetail | null> {
|
||
// Not implemented yet per-provider; return null so Watch page shows fallback.
|
||
return of(null);
|
||
}
|
||
|
||
// Pagination-enabled APIs
|
||
getTrendingPage(cursor?: string | null, providerOverride?: Provider | null): Observable<PagedResult<Video>> {
|
||
const provider = providerOverride || this.instances.selectedProvider();
|
||
const region = this.instances.region();
|
||
switch (provider) {
|
||
case 'youtube': {
|
||
const key = this.getActiveYouTubeKey();
|
||
if (!key) {
|
||
console.warn('YOUTUBE_API_KEY not found. Set it to enable YouTube trending.');
|
||
return of({ items: [], nextCursor: null });
|
||
}
|
||
const params = new URLSearchParams({
|
||
chart: 'mostPopular',
|
||
regionCode: String(region),
|
||
maxResults: '24',
|
||
// Optimize quota usage: only get essential parts
|
||
part: 'snippet,contentDetails,statistics',
|
||
// Add safeSearch for better content filtering
|
||
safeSearch: 'moderate',
|
||
// Video category for trending (Music = 10, Gaming = 20, etc.)
|
||
videoCategoryId: '0', // All categories
|
||
key: String(key),
|
||
});
|
||
|
||
if (cursor) params.set('pageToken', cursor);
|
||
// Cache key: provider|chart|region|cursor
|
||
const cacheKey = `yt:trending|${region}|${cursor || ''}`;
|
||
const cached = this.trendingCache.get(cacheKey);
|
||
const now = Date.now();
|
||
if (cached && (now - cached.ts) < this.CACHE_TTL_MS) {
|
||
return of(cached.data);
|
||
}
|
||
const path = `/youtube/v3/videos`;
|
||
return this.fetchYouTube(path, params, key).pipe(
|
||
map(res => ({
|
||
items: (res.items || []).map((i: any) => this.mapYouTubeItemToVideo(i)),
|
||
nextCursor: res.nextPageToken || null,
|
||
} as PagedResult<Video>)),
|
||
map((out) => {
|
||
this.trendingCache.set(cacheKey, { ts: now, data: out });
|
||
return out;
|
||
}),
|
||
catchError((e) => {
|
||
// Surface quotaExceeded if present + ban dead keys (quota ou clé expirée/invalide)
|
||
try {
|
||
const reason = e?.error?.error?.errors?.[0]?.reason || '';
|
||
const msg = String(e?.error?.error?.message || e?.message || '');
|
||
if (e?.status === 403 && (reason === 'quotaExceeded' || /quota/i.test(msg))) {
|
||
this.quotaExceeded$.next(true);
|
||
this.banYouTubeKey(key);
|
||
} else if (e?.status === 400 && (reason === 'API_KEY_INVALID' || /api key (expired|invalid)/i.test(msg))) {
|
||
this.banYouTubeKey(key);
|
||
}
|
||
} catch {}
|
||
console.error('YouTube trending error', e);
|
||
return of({ items: [], nextCursor: null });
|
||
})
|
||
);
|
||
}
|
||
case 'odysee': {
|
||
// Use LBRY/Odysee claim_search with trending order
|
||
const page = cursor ? Number(cursor) : 1;
|
||
const body = {
|
||
method: 'claim_search',
|
||
params: {
|
||
page: page,
|
||
page_size: 24,
|
||
no_totals: true,
|
||
claim_type: 'stream',
|
||
stream_types: ['video'],
|
||
order_by: ['trending_group', 'trending_mixed']
|
||
}
|
||
};
|
||
return this.fetchOdysee(body).pipe(
|
||
map(res => {
|
||
const items = (res?.result?.items || res?.result?.claims || res?.items || []) as any[];
|
||
return {
|
||
items: items.map(i => this.mapOdyseeItemToVideo(i)),
|
||
nextCursor: items.length > 0 ? String(page + 1) : null,
|
||
} as PagedResult<Video>;
|
||
}),
|
||
catchError((e) => { console.error('Odysee trending error', e); return of({ items: [], nextCursor: null }); })
|
||
);
|
||
}
|
||
case 'dailymotion': {
|
||
const page = cursor ? String(cursor) : '1';
|
||
const limit = 24;
|
||
const params = new URLSearchParams({
|
||
sort: 'trending',
|
||
limit: String(limit),
|
||
page: String(page),
|
||
country: String(region).toLowerCase(),
|
||
fields: 'id,title,thumbnail_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,language,allow_embed,owner.id,owner.screenname,owner.avatar_80_url,created_time',
|
||
});
|
||
return this.fetchDailymotion('/videos', params).pipe(
|
||
map(res => {
|
||
const list = (res.list || res.result || res.items || res.data || []) as any[];
|
||
const currentPage = Number(page) || 1;
|
||
const hasMore = typeof res.has_more === 'boolean' ? res.has_more : (list.length === limit);
|
||
return {
|
||
items: list.map((i: any) => this.mapDailymotionItemToVideo(i)),
|
||
nextCursor: hasMore ? String(currentPage + 1) : null,
|
||
} as PagedResult<Video>;
|
||
}),
|
||
catchError((e) => { console.error('Dailymotion trending error', e); return of({ items: [], nextCursor: null }); })
|
||
);
|
||
}
|
||
case 'twitch': {
|
||
// Use Helix Streams for a "trending" like experience (popular live streams)
|
||
return this.getTwitchTopStreamsPage(cursor);
|
||
}
|
||
case 'rumble': {
|
||
// Rumble utilise maintenant le web scraping côté serveur, pas d'API key requise
|
||
const page = cursor ? Number(cursor) : 1;
|
||
const params = new URLSearchParams({
|
||
sort: 'trending',
|
||
limit: '24',
|
||
page: String(page),
|
||
});
|
||
return this.fetchRumble('/browse', params).pipe(
|
||
map(res => {
|
||
const list = (res?.items || res?.result || res?.data || []) as any[];
|
||
const backendCursor = res?.nextCursor != null ? String(res.nextCursor) : null;
|
||
const next = backendCursor ?? (list.length > 0 ? String(page + 1) : null);
|
||
return {
|
||
items: list.map(i => this.mapRumbleItemToVideo(i)),
|
||
nextCursor: next,
|
||
} as PagedResult<Video>;
|
||
}),
|
||
catchError((e) => { console.error('Rumble trending error', e); return of({ items: [], nextCursor: null }); })
|
||
);
|
||
}
|
||
case 'peertube': {
|
||
const instance = this.instances.activePeerTubeInstance();
|
||
const start = cursor ? Number(cursor) : 0;
|
||
const primary = `/api/peertube/${instance}/api/v1/videos?sort=-trending&count=24&start=${start}`;
|
||
const fallback = `/proxy/api/peertube/${instance}/api/v1/videos?sort=-trending&count=24&start=${start}`;
|
||
return this.http.get<any>(primary).pipe(
|
||
catchError(() => this.http.get<any>(fallback)),
|
||
map(res => ({
|
||
items: (res.data || []).map((i: any) => this.mapPeerTubeItemToVideo(i, instance)),
|
||
nextCursor: res.data?.length > 0 ? String(start + 24) : null,
|
||
} as PagedResult<Video>)),
|
||
catchError((e) => { console.error(`PeerTube (${instance}) trending error`, e); return of({ items: [], nextCursor: null }); })
|
||
);
|
||
}
|
||
default:
|
||
console.warn('Provider not yet implemented for trending:', provider);
|
||
return of({ items: [], nextCursor: null });
|
||
}
|
||
}
|
||
|
||
searchVideosPage(query: string, cursor?: string | null, providerOverride?: Provider | null): Observable<PagedResult<Video>> {
|
||
const provider = providerOverride || this.instances.selectedProvider();
|
||
const region = this.instances.region();
|
||
if (!query) return of({ items: [], nextCursor: null });
|
||
switch (provider) {
|
||
case 'youtube': {
|
||
const key = this.getActiveYouTubeKey();
|
||
if (!key) return of({ items: [], nextCursor: null });
|
||
const params = new URLSearchParams({
|
||
type: 'video',
|
||
// search.list only supports 'snippet'
|
||
part: 'snippet',
|
||
// Optimize for quota usage: reduce initial results
|
||
maxResults: '25',
|
||
q: String(query),
|
||
regionCode: String(region),
|
||
// Add safeSearch for better content filtering
|
||
safeSearch: 'moderate',
|
||
// Only return videos playable in an iframe (avoids player error 153).
|
||
videoEmbeddable: 'true',
|
||
key: String(key),
|
||
});
|
||
// Prefer results matching user's language when available
|
||
try {
|
||
const lang = String(this.i18n.lang()).toLowerCase();
|
||
if (lang) params.set('relevanceLanguage', lang);
|
||
} catch {}
|
||
if (cursor) params.set('pageToken', cursor);
|
||
// Cache key: provider|search|region|query|cursor
|
||
const cacheKey = `yt:search|${region}|${query}|${cursor || ''}`;
|
||
const cached = this.searchCache.get(cacheKey);
|
||
const now = Date.now();
|
||
if (cached && (now - cached.ts) < this.CACHE_TTL_MS) {
|
||
return of(cached.data);
|
||
}
|
||
const path = `/youtube/v3/search`;
|
||
return this.fetchYouTube(path, params, key).pipe(
|
||
map(res => ({
|
||
items: (res.items || []).map((i: any) => this.mapYouTubeSearchItemToVideo(i)),
|
||
nextCursor: res.nextPageToken || null,
|
||
} as PagedResult<Video>)),
|
||
// Enrich with durations and view counts
|
||
switchMap((out) => {
|
||
const ids = out.items.map(v => v.videoId).filter(Boolean);
|
||
if (!ids.length) return of(out);
|
||
return this.getYouTubeContentAndStats(ids).pipe(
|
||
map((stats) => {
|
||
const enriched = out.items.map(v => {
|
||
const s = stats[v.videoId];
|
||
if (!s) return v;
|
||
// duration -1 = non-embeddable (erreur 153) : on garde la
|
||
// vidéo en recherche (avec durée 0) mais les Shorts la filtrent.
|
||
const duration = typeof s.duration === 'number' && s.duration >= 0 ? s.duration : 0;
|
||
return { ...v, duration, views: s.views };
|
||
});
|
||
const next = { ...out, items: enriched } as PagedResult<Video>;
|
||
this.searchCache.set(cacheKey, { ts: now, data: next });
|
||
return next;
|
||
})
|
||
);
|
||
}),
|
||
catchError((e) => {
|
||
// Surface quotaExceeded if present + ban dead keys (quota ou clé expirée/invalide)
|
||
try {
|
||
const reason = e?.error?.error?.errors?.[0]?.reason || '';
|
||
const msg = String(e?.error?.error?.message || e?.message || '');
|
||
if (e?.status === 403 && (reason === 'quotaExceeded' || /quota/i.test(msg))) {
|
||
this.quotaExceeded$.next(true);
|
||
this.banYouTubeKey(key);
|
||
} else if (e?.status === 400 && (reason === 'API_KEY_INVALID' || /api key (expired|invalid)/i.test(msg))) {
|
||
this.banYouTubeKey(key);
|
||
}
|
||
} catch {}
|
||
console.error('YouTube search error', e);
|
||
return of({ items: [], nextCursor: null });
|
||
})
|
||
);
|
||
}
|
||
case 'odysee': {
|
||
const page = cursor ? Number(cursor) : 1;
|
||
const body = {
|
||
method: 'claim_search',
|
||
params: {
|
||
page: page,
|
||
page_size: 24,
|
||
no_totals: true,
|
||
claim_type: 'stream',
|
||
stream_types: ['video'],
|
||
text: String(query),
|
||
order_by: ['trending_group', 'trending_mixed']
|
||
}
|
||
};
|
||
return this.fetchOdysee(body).pipe(
|
||
map(res => {
|
||
const items = (res?.result?.items || res?.result?.claims || res?.items || []) as any[];
|
||
return {
|
||
items: items.map(i => this.mapOdyseeItemToVideo(i)),
|
||
nextCursor: items.length > 0 ? String(page + 1) : null,
|
||
} as PagedResult<Video>;
|
||
}),
|
||
catchError((e) => { console.error('Odysee search error', e); return of({ items: [], nextCursor: null }); })
|
||
);
|
||
}
|
||
case 'dailymotion': {
|
||
const page = cursor ? String(cursor) : '1';
|
||
const limit = 24;
|
||
const params = new URLSearchParams({
|
||
limit: String(limit),
|
||
page: String(page),
|
||
country: String(region).toLowerCase(),
|
||
fields: 'id,title,thumbnail_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,owner.screenname,owner.avatar_80_url,created_time',
|
||
search: String(query),
|
||
});
|
||
return this.fetchDailymotion('/videos', params).pipe(
|
||
map(res => {
|
||
const list = (res.list || res.result || res.items || res.data || []) as any[];
|
||
const currentPage = Number(page) || 1;
|
||
const hasMore = typeof res.has_more === 'boolean' ? res.has_more : (list.length === limit);
|
||
return {
|
||
items: list.map((i: any) => this.mapDailymotionItemToVideo(i)),
|
||
nextCursor: hasMore ? String(currentPage + 1) : null,
|
||
} as PagedResult<Video>;
|
||
}),
|
||
catchError((e) => { console.error('Dailymotion search error', e); return of({ items: [], nextCursor: null }); })
|
||
);
|
||
}
|
||
case 'rumble': {
|
||
// Rumble utilise maintenant le web scraping côté serveur, pas d'API key requise
|
||
const limit = 24;
|
||
const offset = cursor ? Number(cursor) : 0;
|
||
const params = new URLSearchParams({
|
||
q: String(query),
|
||
limit: String(limit),
|
||
offset: String(offset),
|
||
});
|
||
return this.fetchRumble('/search', params).pipe(
|
||
map(res => {
|
||
const list = (res?.items || res?.result || res?.data || []) as any[];
|
||
// Prefer backend-provided nextCursor if present (page-based). Translate it to offset-based cursor for the frontend.
|
||
let next: string | null = null;
|
||
if (res?.nextCursor != null && res?.page != null && res?.limit != null) {
|
||
try {
|
||
const nextPage = Number(res.nextCursor);
|
||
const perPage = Number(res.limit);
|
||
if (!isNaN(nextPage) && !isNaN(perPage)) next = String((nextPage - 1) * perPage);
|
||
} catch {}
|
||
}
|
||
if (next == null) {
|
||
const hasMore = list.length === limit;
|
||
next = hasMore ? String(offset + limit) : null;
|
||
}
|
||
return {
|
||
items: list.map(i => this.mapRumbleItemToVideo(i)),
|
||
nextCursor: next,
|
||
} as PagedResult<Video>;
|
||
}),
|
||
// Si la recherche est vide (scraper challengé / page changée),
|
||
// repli sur le flux trending pour ne jamais afficher un bloc vide.
|
||
switchMap(out => {
|
||
if ((out?.items?.length || 0) > 0) return of(out);
|
||
const page = 1;
|
||
const bParams = new URLSearchParams({ sort: 'trending', limit: '24', page: String(page) });
|
||
return this.fetchRumble('/browse', bParams).pipe(
|
||
map(res => {
|
||
const list = (res?.items || res?.result || res?.data || []) as any[];
|
||
return { items: list.map(i => this.mapRumbleItemToVideo(i)), nextCursor: null } as PagedResult<Video>;
|
||
}),
|
||
catchError(() => of(out))
|
||
);
|
||
}),
|
||
catchError((e) => { console.error('Rumble search error', e); return of({ items: [], nextCursor: null }); })
|
||
);
|
||
}
|
||
case 'twitch': {
|
||
// 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(timeout(30000),
|
||
switchMap(token => {
|
||
if (!token) return of({ items: [], nextCursor: null });
|
||
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
|
||
const primary = `/api/twitch-api/helix/search/channels?query=${encodeURIComponent(query)}&first=24&after=${cursor || ''}&live_only=true`;
|
||
const fallback = `/proxy/twitch-api/helix/search/channels?query=${encodeURIComponent(query)}&first=24&after=${cursor || ''}&live_only=true`;
|
||
return this.http.get<any>(primary, { headers }).pipe(
|
||
catchError(() => this.http.get<any>(fallback, { headers })),
|
||
map(res => {
|
||
const data = Array.isArray(res?.data) ? res.data : [];
|
||
const live = data.filter((i: any) => i?.is_live === true);
|
||
return {
|
||
items: live.map((i: any) => this.mapTwitchChannelToVideo(i)),
|
||
nextCursor: res.pagination?.cursor || null,
|
||
} as PagedResult<Video>;
|
||
}),
|
||
// Si la recherche live est vide/maigre, compléter avec VODs+clips puis top streams.
|
||
switchMap(liveRes => {
|
||
if ((liveRes?.items?.length || 0) >= 4) return of(liveRes);
|
||
return this.searchTwitchThemePage(query, cursor).pipe(
|
||
map(themeRes => {
|
||
const seen = new Set((liveRes.items || []).map(v => v.videoId));
|
||
const extra = (themeRes?.items || []).filter(v => !seen.has(v.videoId));
|
||
return { items: [...(liveRes.items || []), ...extra], nextCursor: themeRes?.nextCursor || liveRes.nextCursor } as PagedResult<Video>;
|
||
}),
|
||
catchError(() => of(liveRes))
|
||
);
|
||
}),
|
||
catchError((e) => { console.error('Twitch search error', e); return of({ items: [], nextCursor: null }); })
|
||
);
|
||
})
|
||
);
|
||
}
|
||
case 'peertube': {
|
||
const instance = this.instances.activePeerTubeInstance();
|
||
const start = cursor ? Number(cursor) : 0;
|
||
const primary = `/api/peertube/${instance}/api/v1/search/videos?search=${encodeURIComponent(query)}&count=24&start=${start}`;
|
||
const fallback = `/proxy/api/peertube/${instance}/api/v1/search/videos?search=${encodeURIComponent(query)}&count=24&start=${start}`;
|
||
return this.http.get<any>(primary).pipe(
|
||
catchError(() => this.http.get<any>(fallback)),
|
||
map(res => ({
|
||
items: (res.data || []).map((i: any) => this.mapPeerTubeItemToVideo(i, instance)),
|
||
nextCursor: res.data?.length > 0 ? String(start + 24) : null,
|
||
} as PagedResult<Video>)),
|
||
catchError((e) => { console.error(`PeerTube (${instance}) search error`, e); return of({ items: [], nextCursor: null }); })
|
||
);
|
||
}
|
||
default:
|
||
return of({ items: [], nextCursor: null });
|
||
}
|
||
}
|
||
|
||
// YouTube mappers
|
||
private mapYouTubeItemToVideo = (i: any): Video => {
|
||
const id = i.id;
|
||
const sn = i.snippet || {};
|
||
const stats = i.statistics || {};
|
||
const cd = i.contentDetails || {};
|
||
const thumb = sn.thumbnails?.high?.url || sn.thumbnails?.medium?.url || sn.thumbnails?.default?.url || '';
|
||
const publishedAt = sn.publishedAt || '';
|
||
const channelId = sn.channelId || '';
|
||
return {
|
||
url: `/watch/${id}`,
|
||
videoId: id,
|
||
type: 'video',
|
||
provider: 'youtube',
|
||
title: sn.title || '',
|
||
thumbnail: thumb,
|
||
uploaderName: sn.channelTitle || '',
|
||
uploaderUrl: channelId ? `https://www.youtube.com/channel/${channelId}` : '',
|
||
// Ne jamais utiliser la miniature vidéo comme avatar chaîne : la page /watch
|
||
// résout le vrai avatar via /api/details puis /api/channels, avec fallback initiales.
|
||
uploaderAvatar: '',
|
||
channelId: channelId || undefined,
|
||
channelExternalId: channelId || undefined,
|
||
channelAvatarUrl: undefined,
|
||
uploadedDate: publishedAt,
|
||
duration: this.parseISODurationToSeconds(cd.duration || 'PT0S'),
|
||
views: Number(stats.viewCount || 0),
|
||
uploaded: publishedAt ? Date.parse(publishedAt) : 0,
|
||
} as Video;
|
||
};
|
||
|
||
private mapYouTubeSearchItemToVideo = (i: any, live = false): Video => {
|
||
const id = i.id?.videoId || i.id;
|
||
const sn = i.snippet || {};
|
||
const thumb = sn.thumbnails?.high?.url || sn.thumbnails?.medium?.url || sn.thumbnails?.default?.url || '';
|
||
const publishedAt = sn.publishedAt || '';
|
||
const channelId = sn.channelId || '';
|
||
// liveBroadcastContent === 'live' => réellement en direct ; 'upcoming' => à venir
|
||
const broadcast = String(sn?.liveBroadcastContent || '').toLowerCase();
|
||
const isLive = live || broadcast === 'live';
|
||
return {
|
||
url: `/watch/${id}`,
|
||
videoId: id,
|
||
type: isLive ? 'live' : 'video',
|
||
provider: 'youtube',
|
||
title: sn.title || '',
|
||
thumbnail: thumb,
|
||
uploaderName: sn.channelTitle || '',
|
||
uploaderUrl: channelId ? `https://www.youtube.com/channel/${channelId}` : '',
|
||
uploaderAvatar: '',
|
||
channelId: channelId || undefined,
|
||
channelExternalId: channelId || undefined,
|
||
channelAvatarUrl: undefined,
|
||
uploadedDate: publishedAt,
|
||
duration: 0,
|
||
views: 0,
|
||
uploaded: publishedAt ? Date.parse(publishedAt) : 0,
|
||
isLive,
|
||
} as Video;
|
||
};
|
||
|
||
/**
|
||
* Recherche de directs YouTube (search.list eventType=live + type=video).
|
||
* Contrairement à une recherche textuelle "live", ne retourne que des
|
||
* diffusions réellement en direct. `query` optionnel : sans requête on
|
||
* cherche "live" avec le filtre live pour garantir des résultats.
|
||
*/
|
||
public searchYouTubeLivePage(query?: string, cursor?: string | null): Observable<PagedResult<Video>> {
|
||
const region = this.instances.region();
|
||
const key = this.getActiveYouTubeKey();
|
||
if (!key) return of({ items: [], nextCursor: null });
|
||
const q = String(query || '').trim() || 'live';
|
||
const params = new URLSearchParams({
|
||
type: 'video',
|
||
part: 'snippet',
|
||
maxResults: '25',
|
||
q,
|
||
regionCode: String(region),
|
||
safeSearch: 'moderate',
|
||
videoEmbeddable: 'true',
|
||
eventType: 'live',
|
||
order: 'viewCount',
|
||
key: String(key),
|
||
});
|
||
if (cursor) params.set('pageToken', cursor);
|
||
const cacheKey = `yt:live|${region}|${q}|${cursor || ''}`;
|
||
const cached = this.searchCache.get(cacheKey);
|
||
const now = Date.now();
|
||
if (cached && (now - cached.ts) < this.CACHE_TTL_MS) {
|
||
return of(cached.data);
|
||
}
|
||
const path = `/youtube/v3/search`;
|
||
return this.fetchYouTube(path, params, key).pipe(
|
||
map(res => ({
|
||
items: (res.items || []).map((i: any) => this.mapYouTubeSearchItemToVideo(i, true)),
|
||
nextCursor: res.nextPageToken || null,
|
||
} as PagedResult<Video>)),
|
||
switchMap((out) => {
|
||
const ids = out.items.map(v => v.videoId).filter(Boolean);
|
||
if (!ids.length) return of(out);
|
||
return this.getYouTubeContentAndStats(ids).pipe(
|
||
map((stats) => {
|
||
const enriched = out.items.map(v => {
|
||
const s = stats[v.videoId];
|
||
if (!s) return v;
|
||
const duration = typeof s.duration === 'number' && s.duration >= 0 ? s.duration : 0;
|
||
return { ...v, duration, views: s.views, type: 'live', isLive: true };
|
||
});
|
||
const next = { ...out, items: enriched } as PagedResult<Video>;
|
||
this.searchCache.set(cacheKey, { ts: now, data: next });
|
||
return next;
|
||
})
|
||
);
|
||
}),
|
||
catchError((e) => {
|
||
try {
|
||
const reason = e?.error?.error?.errors?.[0]?.reason || '';
|
||
const msg = String(e?.error?.error?.message || e?.message || '');
|
||
if (e?.status === 403 && (reason === 'quotaExceeded' || /quota/i.test(msg))) {
|
||
this.quotaExceeded$.next(true);
|
||
this.banYouTubeKey(key);
|
||
} else if (e?.status === 400 && (reason === 'API_KEY_INVALID' || /api key (expired|invalid)/i.test(msg))) {
|
||
this.banYouTubeKey(key);
|
||
}
|
||
} catch {}
|
||
console.error('YouTube live search error', e);
|
||
return of({ items: [], nextCursor: null });
|
||
})
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Flux Shorts par provider (page #/shorts).
|
||
* Contrairement à une recherche plein-texte "shorts" (qui retourne surtout
|
||
* des vidéos longues ensuite éliminées par le filtre client), on filtre la
|
||
* durée côté serveur quand l'API le permet :
|
||
* - YouTube : search.list `videoDuration=short` (< 4 min), resserré à ≤70 s côté client.
|
||
* - Dailymotion : `shorter_than=2` (en minutes), resserré à ≤75 s côté client.
|
||
* - PeerTube : `durationMin=1&durationMax=75` (en secondes).
|
||
* - Autres (odysee/rumble/...) : recherche générique + filtre client uniquement.
|
||
* Les durées YouTube sont enrichies ici (comme searchVideosPage) pour éviter
|
||
* un second appel videos.list côté composant (économie de quota).
|
||
*/
|
||
public searchShortsPage(query = 'shorts', cursor?: string | null, providerOverride?: Provider | null): Observable<PagedResult<Video>> {
|
||
const provider = providerOverride || this.instances.selectedProvider();
|
||
const region = this.instances.region();
|
||
const q = String(query || '').trim() || 'shorts';
|
||
switch (provider) {
|
||
case 'youtube': {
|
||
const key = this.getActiveYouTubeKey();
|
||
if (!key) return of({ items: [], nextCursor: null });
|
||
const params = new URLSearchParams({
|
||
type: 'video',
|
||
part: 'snippet',
|
||
maxResults: '25',
|
||
q,
|
||
regionCode: String(region),
|
||
safeSearch: 'moderate',
|
||
videoEmbeddable: 'true',
|
||
// Le filtre clé : sans lui, "shorts" ne retourne que des vidéos longues.
|
||
videoDuration: 'short',
|
||
order: 'viewCount',
|
||
key: String(key),
|
||
});
|
||
try {
|
||
// Langue de l'utilisateur en priorité (relevanceLanguage YouTube).
|
||
const lang = this.getShortsLanguages()[0];
|
||
if (lang) params.set('relevanceLanguage', lang);
|
||
} catch {}
|
||
if (cursor) params.set('pageToken', cursor);
|
||
const cacheKey = `yt:shorts|${region}|${q}|${this.getShortsLanguages().join(',')}|${cursor || ''}`;
|
||
const cached = this.searchCache.get(cacheKey);
|
||
const now = Date.now();
|
||
if (cached && (now - cached.ts) < this.CACHE_TTL_MS) {
|
||
return of(cached.data);
|
||
}
|
||
const path = `/youtube/v3/search`;
|
||
return this.fetchYouTube(path, params, key).pipe(
|
||
map(res => ({
|
||
items: (res.items || []).map((i: any) => this.mapYouTubeSearchItemToVideo(i)),
|
||
nextCursor: res.nextPageToken || null,
|
||
} as PagedResult<Video>)),
|
||
switchMap((out) => {
|
||
const ids = out.items.map(v => v.videoId).filter(Boolean);
|
||
if (!ids.length) return of(out);
|
||
return this.getYouTubeContentAndStats(ids).pipe(
|
||
map((stats) => {
|
||
const enriched = out.items.map(v => {
|
||
const s = stats[v.videoId];
|
||
if (!s) return v;
|
||
// duration -1 = non-embeddable (erreur 153) : on garde la
|
||
// vidéo en recherche (avec durée 0) mais les Shorts la filtrent.
|
||
const duration = typeof s.duration === 'number' && s.duration >= 0 ? s.duration : 0;
|
||
return { ...v, duration, views: s.views };
|
||
});
|
||
const next = { ...out, items: enriched } as PagedResult<Video>;
|
||
this.searchCache.set(cacheKey, { ts: now, data: next });
|
||
return next;
|
||
})
|
||
);
|
||
}),
|
||
catchError((e) => {
|
||
try {
|
||
const reason = e?.error?.error?.errors?.[0]?.reason || '';
|
||
const msg = String(e?.error?.error?.message || e?.message || '');
|
||
if (e?.status === 403 && (reason === 'quotaExceeded' || /quota/i.test(msg))) {
|
||
this.quotaExceeded$.next(true);
|
||
this.banYouTubeKey(key);
|
||
} else if (e?.status === 400 && (reason === 'API_KEY_INVALID' || /api key (expired|invalid)/i.test(msg))) {
|
||
this.banYouTubeKey(key);
|
||
}
|
||
} catch {}
|
||
console.error('YouTube shorts search error', e);
|
||
return of({ items: [], nextCursor: null });
|
||
})
|
||
);
|
||
}
|
||
case 'dailymotion': {
|
||
const page = cursor ? String(cursor) : '1';
|
||
const limit = 24;
|
||
const params = new URLSearchParams({
|
||
limit: String(limit),
|
||
page: String(page),
|
||
// Dailymotion compte en minutes : 2 = ≤ 2 min, resserré à ≤75 s côté client.
|
||
shorter_than: '2',
|
||
country: String(region).toLowerCase(),
|
||
// `allow_embed` indispensable : sans lui, les vidéos non-embeddables
|
||
// passent le filtre et le player répond "Forbidden".
|
||
fields: 'id,title,thumbnail_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,language,allow_embed,owner.screenname,owner.avatar_80_url,created_time',
|
||
search: q,
|
||
});
|
||
return this.fetchDailymotion('/videos', params).pipe(
|
||
map(res => {
|
||
const list = (res.list || res.result || res.items || res.data || []) as any[];
|
||
const currentPage = Number(page) || 1;
|
||
const hasMore = typeof res.has_more === 'boolean' ? res.has_more : (list.length === limit);
|
||
return {
|
||
items: list.map((i: any) => this.mapDailymotionItemToVideo(i)),
|
||
nextCursor: hasMore ? String(currentPage + 1) : null,
|
||
} as PagedResult<Video>;
|
||
}),
|
||
catchError((e) => { console.error('Dailymotion shorts search error', e); return of({ items: [], nextCursor: null }); })
|
||
);
|
||
}
|
||
case 'peertube': {
|
||
const instance = this.instances.activePeerTubeInstance();
|
||
const start = cursor ? Number(cursor) : 0;
|
||
const dmParams = `search=${encodeURIComponent(q)}&count=24&start=${start}&durationMin=1&durationMax=75&sort=-views`;
|
||
const primary = `/api/peertube/${instance}/api/v1/search/videos?${dmParams}`;
|
||
const fallback = `/proxy/api/peertube/${instance}/api/v1/search/videos?${dmParams}`;
|
||
return this.http.get<any>(primary).pipe(
|
||
catchError(() => this.http.get<any>(fallback)),
|
||
map(res => ({
|
||
items: (res.data || []).map((i: any) => this.mapPeerTubeItemToVideo(i, instance)),
|
||
nextCursor: res.data?.length > 0 ? String(start + 24) : null,
|
||
} as PagedResult<Video>)),
|
||
catchError((e) => { console.error(`PeerTube (${instance}) shorts search error`, e); return of({ items: [], nextCursor: null }); })
|
||
);
|
||
}
|
||
default:
|
||
// Odysee/Rumble/... : pas de filtre durée côté serveur → recherche générique + filtre client.
|
||
return this.searchVideosPage(q, cursor, providerOverride);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Shorts PeerTube : vidéos LOCALES uniquement (`isLocal=true`).
|
||
* Les résultats fédérés (distants) échouent à l'embed avec "Cannot get
|
||
* this video regarding follow constraints" / "This video does not exist"
|
||
* quand l'instance active ne suit pas l'instance d'origine. En local,
|
||
* l'embed `/videos/embed/<uuid>` fonctionne toujours.
|
||
*/
|
||
public searchPeerTubeShortsPage(query = 'shorts', cursor?: string | null): Observable<PagedResult<Video>> {
|
||
const instance = this.instances.activePeerTubeInstance();
|
||
const start = cursor ? Number(cursor) : 0;
|
||
const q = String(query || '').trim();
|
||
const params = new URLSearchParams({
|
||
count: '24',
|
||
start: String(start),
|
||
sort: '-views',
|
||
isLocal: 'true',
|
||
nsfw: 'false',
|
||
// Bornes de durée côté serveur : sans elles, le feed rendait des vidéos
|
||
// longues (rejetées ensuite) et pouvait se retrouver quasi vide.
|
||
durationMin: '1',
|
||
durationMax: '90',
|
||
});
|
||
// Langues de l'utilisateur (filtre natif PeerTube, ignoré si inconnu).
|
||
try {
|
||
for (const l of this.getShortsLanguages()) params.append('languageOneOf', l);
|
||
} catch {}
|
||
if (q) params.set('search', q);
|
||
const primary = `/api/peertube/${instance}/api/v1/videos?${params.toString()}`;
|
||
const fallback = `/proxy/api/peertube/${instance}/api/v1/videos?${params.toString()}`;
|
||
return this.http.get<any>(primary).pipe(
|
||
catchError(() => this.http.get<any>(fallback)),
|
||
map(res => {
|
||
const list = (res?.data || res?.result || []) as any[];
|
||
return {
|
||
items: (list || []).map((i: any) => this.mapPeerTubeItemToVideo(i, instance)),
|
||
nextCursor: list.length > 0 ? String(start + 24) : null,
|
||
} as PagedResult<Video>;
|
||
}),
|
||
catchError((e) => { console.error(`PeerTube (${instance}) shorts error`, e); return of({ items: [], nextCursor: null }); })
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Shorts Rumble natifs (rumble.com/shorts, vertical ≤ 90 s) + recherche
|
||
* plein-texte, fusionnés. Le flux natif garantit des IDs /vXXXX propres,
|
||
* compatibles avec l'embed officiel (fini "Video not found").
|
||
* Curseur opaque : numéro de page.
|
||
*/
|
||
public searchRumbleShortsPage(query = 'shorts', cursor?: string | null): Observable<PagedResult<Video>> {
|
||
const page = cursor ? Math.max(1, Number(cursor) || 1) : 1;
|
||
const q = String(query || '').trim();
|
||
const shortsParams = new URLSearchParams({ limit: '24', page: String(page) });
|
||
const shorts$ = this.fetchRumble('/shorts', shortsParams).pipe(
|
||
catchError(() => of({ items: [] }))
|
||
);
|
||
const search$ = q
|
||
? this.fetchRumble('/search', new URLSearchParams({ q, limit: '24', offset: String((page - 1) * 24) })).pipe(
|
||
catchError(() => of({ items: [] }))
|
||
)
|
||
: of({ items: [] });
|
||
return forkJoin([shorts$, search$]).pipe(
|
||
map(([a, b]: any[]) => {
|
||
const seen = new Set<string>();
|
||
const merged: any[] = [];
|
||
for (const it of [...(a?.items || []), ...(b?.items || [])]) {
|
||
const id = String(it?.videoId || it?.id || '');
|
||
if (!id || seen.has(id)) continue;
|
||
seen.add(id);
|
||
merged.push(it);
|
||
}
|
||
return {
|
||
items: merged.map(i => this.mapRumbleItemToVideo(i)),
|
||
nextCursor: merged.length > 0 ? String(page + 1) : null,
|
||
} as PagedResult<Video>;
|
||
}),
|
||
catchError((e) => { console.error('Rumble shorts error', e); return of({ items: [], nextCursor: null }); })
|
||
);
|
||
}
|
||
|
||
// Dailymotion mapper.
|
||
// allow_embed=false (embed désactivé par le propriétaire) => le player
|
||
// répond "Forbidden" : propagé via `raw.allowEmbed` pour que les Shorts
|
||
// (et toute page d'embed) puissent exclure ces vidéos.
|
||
private mapDailymotionItemToVideo = (i: any): Video => ({
|
||
url: `/watch/${i.id}`,
|
||
videoId: i.id || '',
|
||
type: 'video',
|
||
provider: 'dailymotion',
|
||
title: i.title || '',
|
||
thumbnail: i.thumbnail_720_url || i.thumbnail_480_url || i.thumbnail_url || '',
|
||
uploaderName: i['owner.screenname'] || '',
|
||
uploaderUrl: '',
|
||
uploaderAvatar: i['owner.avatar_80_url'] || i.thumbnail_480_url || i.thumbnail_url || '',
|
||
channelId: i['owner.id'] || undefined,
|
||
channelExternalId: i['owner.id'] || undefined,
|
||
channelAvatarUrl: i['owner.avatar_80_url'] || undefined,
|
||
uploadedDate: i.created_time ? new Date(i.created_time * 1000).toISOString() : '',
|
||
duration: Number(i.duration || 0),
|
||
views: Number(i.views_total || 0),
|
||
uploaded: i.created_time ? i.created_time * 1000 : 0,
|
||
language: i?.language ? String(i.language).toLowerCase().split('-')[0] : undefined,
|
||
raw: { allowEmbed: i?.allow_embed !== false },
|
||
});
|
||
|
||
// PeerTube mapper (robust handling for thumbnail/avatar fields across instances)
|
||
private mapPeerTubeItemToVideo = (i: any, instance: string): Video => {
|
||
const abs = (p?: string) => {
|
||
if (!p) return '';
|
||
const s = String(p);
|
||
return s.startsWith('http') ? s : `https://${instance}${s.startsWith('/') ? s : '/' + s}`;
|
||
};
|
||
const thumbnail = abs(i.thumbnailUrl) || abs(i.thumbnailPath) || abs(i.previewPath) || '';
|
||
const uploaderAvatar = abs(i.account?.avatar?.path) || abs(i.account?.avatars?.[0]?.path) || abs(i.channel?.avatar?.path) || '';
|
||
const uploaderName = i.account?.displayName || i.account?.name || i.channel?.displayName || '';
|
||
const uploaderUrl = i.account?.url || i.channel?.url || '';
|
||
|
||
return {
|
||
url: `https://${instance}/w/${i.uuid}`,
|
||
videoId: i.uuid || '',
|
||
type: 'video',
|
||
provider: 'peertube',
|
||
title: i.name || '',
|
||
thumbnail,
|
||
uploaderName,
|
||
uploaderUrl,
|
||
uploaderAvatar: uploaderAvatar || thumbnail,
|
||
channelId: i.account?.name || i.channel?.name || undefined,
|
||
channelExternalId: i.account?.name || i.channel?.name || undefined,
|
||
channelAvatarUrl: uploaderAvatar || undefined,
|
||
uploadedDate: i.publishedAt || '',
|
||
duration: Number(i.duration || 0),
|
||
views: Number(i.views || 0),
|
||
uploaded: i.publishedAt ? Date.parse(i.publishedAt) : 0,
|
||
// PeerTube expose `language` sous forme { id, label } ou chaîne ISO.
|
||
language: (i?.language && String(i.language.id || i.language).toLowerCase().split('-')[0]) || undefined,
|
||
} as Video;
|
||
};
|
||
|
||
// Odysee mapper
|
||
private mapOdyseeItemToVideo = (i: any): Video => {
|
||
const val = i?.value || {};
|
||
const channel = i?.signing_channel || {};
|
||
const channelVal = channel?.value || {};
|
||
const lbryUrl = i?.permanent_url || i?.canonical_url || '';
|
||
const webUrl = this.lbryToOdyseeUrl(lbryUrl) || (i?.name ? `https://odysee.com/${i.name}` : '');
|
||
const thumbRaw = val?.thumbnail?.url || '';
|
||
const thumb = this.proxiedOdyseeThumb(thumbRaw);
|
||
const publishedAt = val?.release_time ? new Date(val.release_time * 1000).toISOString() : '';
|
||
const rawDuration = val?.video?.duration
|
||
?? i?.duration
|
||
?? val?.duration
|
||
?? i?.meta?.duration
|
||
?? (typeof i?.value?.duration !== 'undefined' ? i.value.duration : undefined);
|
||
const duration = this.normalizeDuration(rawDuration);
|
||
const views = (i?.meta?.view_count != null) ? Number(i.meta.view_count) : 0;
|
||
// Odysee : `value.languages` (tableau ISO) ou `value.language` (chaîne).
|
||
const langRaw = Array.isArray(val?.languages) ? val.languages[0] : (val?.language ?? i?.language);
|
||
const language = langRaw ? String(langRaw).toLowerCase().split('-')[0] : undefined;
|
||
const uploaderName = channelVal?.title || channel?.name || '';
|
||
const channelClaimId = channel?.claim_id || channel?.claimId || '';
|
||
const channelHandle = channel?.name || '';
|
||
const channelExternalId = channelClaimId || channelHandle.replace(/^@/, '') || '';
|
||
|
||
return {
|
||
url: webUrl,
|
||
videoId: i?.claim_id || i?.txid || i?.claimId || i?.id || '',
|
||
type: 'video',
|
||
provider: 'odysee',
|
||
title: val?.title || i?.name || '',
|
||
thumbnail: thumb,
|
||
uploaderName,
|
||
uploaderUrl: channel?.permanent_url ? this.lbryToOdyseeUrl(channel.permanent_url) : '',
|
||
uploaderAvatar: this.proxiedOdyseeThumb(channelVal?.thumbnail?.url) || thumb,
|
||
channelId: channelClaimId || undefined,
|
||
channelHandle: channelHandle || undefined,
|
||
channelExternalId: channelExternalId || undefined,
|
||
channelAvatarUrl: this.proxiedOdyseeThumb(channelVal?.thumbnail?.url) || undefined,
|
||
uploadedDate: publishedAt,
|
||
duration,
|
||
views: views,
|
||
uploaded: publishedAt ? Date.parse(publishedAt) : 0,
|
||
language,
|
||
} as Video;
|
||
};
|
||
|
||
// Rumble mapper (best-effort over common fields)
|
||
private mapRumbleItemToVideo = (i: any): Video => {
|
||
let id = i?.id || i?.video_id || i?.videoId || i?.hash || '';
|
||
const title = i?.title || i?.name || '';
|
||
let thumb = i?.thumbnail || i?.thumbnail_url || i?.image || '';
|
||
const uploaderName = i?.author || i?.uploader || i?.channel?.name || i?.uploaderName || '';
|
||
const uploaderAvatar = i?.author_avatar || i?.uploader_avatar || i?.channel?.avatar || i?.uploaderAvatar || thumb || '';
|
||
const channelSlug = i?.channel?.slug || i?.channel_slug || (i?.channel?.url ? i.channel.url.replace(/^https?:\/\/rumble.com\//, '').replace(/\/$/, '') : '') || '';
|
||
const channelExternalId = channelSlug || (uploaderName ? uploaderName.replace(/\s+/g, '').toLowerCase() : '');
|
||
const uploadedDate = i?.published_at || i?.created_at || i?.uploadedDate || '';
|
||
const views = Number(i?.views || i?.view_count || 0);
|
||
const duration = this.normalizeDuration(
|
||
i?.duration ?? i?.durationSeconds ?? i?.duration_seconds ?? i?.video_duration ?? i?.length ?? i?.meta?.duration
|
||
);
|
||
const url = i?.url || (id ? `https://rumble.com/${id}` : '');
|
||
if (!id && url) {
|
||
try {
|
||
const u = new URL(url);
|
||
const m = u.pathname.match(/\/v[\w]+/) || u.pathname.match(/\/video\/([\w]+)/);
|
||
if (m) id = Array.isArray(m) ? (m[1] ? `v${m[1]}` : m[0].replace(/^\//, '')) : id;
|
||
} catch {}
|
||
}
|
||
if (!thumb && url) {
|
||
// Best-effort: construct a thumbnail CDN path if present in URL pattern (fallback to uploader avatar)
|
||
thumb = uploaderAvatar || '';
|
||
}
|
||
const nativeShort = (i as any)?.isShort === true;
|
||
return {
|
||
url,
|
||
videoId: id,
|
||
type: 'video',
|
||
provider: 'rumble',
|
||
title,
|
||
thumbnail: thumb,
|
||
uploaderName,
|
||
uploaderUrl: i?.channel_url || '',
|
||
uploaderAvatar,
|
||
channelId: channelExternalId || undefined,
|
||
channelExternalId: channelExternalId || undefined,
|
||
channelAvatarUrl: uploaderAvatar || undefined,
|
||
uploadedDate,
|
||
duration,
|
||
views,
|
||
uploaded: uploadedDate ? Date.parse(uploadedDate) : 0,
|
||
// Flag natif propagé du scraper serveur ; sinon durée courte ⇒ Short.
|
||
isShort: nativeShort || (duration > 0 && duration <= 75),
|
||
} as Video;
|
||
};
|
||
|
||
// Twitch Mappers (la langue Helix est conservée dans `raw` pour le
|
||
// filtre langue des Shorts : l'endpoint /clips n'a pas de param langue).
|
||
private mapTwitchItemToVideo = (i: any): Video => ({
|
||
url: `/watch/${i.id}`,
|
||
videoId: i.id || '',
|
||
type: 'video',
|
||
provider: 'twitch',
|
||
kind: 'vod',
|
||
title: i.title || '',
|
||
thumbnail: i.thumbnail_url?.replace('%{width}', '640').replace('%{height}', '360') || '',
|
||
uploaderName: i.user_name || i.broadcaster_name || '',
|
||
uploaderUrl: i.user_login ? `https://www.twitch.tv/${i.user_login}` : '',
|
||
uploaderAvatar: '', // Requires another API call
|
||
channelId: i.user_id || i.broadcaster_id || undefined,
|
||
channelHandle: i.user_login || undefined,
|
||
channelExternalId: i.user_login || undefined,
|
||
channelAvatarUrl: undefined,
|
||
uploadedDate: i.created_at || '',
|
||
duration: this.parseTwitchDurationToSeconds(i.duration || '0s'),
|
||
views: Number(i.view_count || i.viewer_count || 0),
|
||
uploaded: i.created_at ? Date.parse(i.created_at) : 0,
|
||
isLive: false,
|
||
raw: { language: i?.language || undefined },
|
||
});
|
||
|
||
// Twitch Streams mapper (for trending popular live streams)
|
||
// Les streams Helix sont TOUJOURS en direct : type normalisé 'live' + isLive.
|
||
// Le thumbnail Helix utilise le motif "{width}x{height}" (ex. ...-{width}x{height}.jpg).
|
||
private mapTwitchStreamToVideo = (i: any): Video => {
|
||
const rawThumb: string = String(i.thumbnail_url || '');
|
||
const resolvedThumb = rawThumb
|
||
.replace('{width}x{height}', '640x360')
|
||
.replace('{width}', '640')
|
||
.replace('{height}', '360')
|
||
|| 'https://static-cdn.jtvnw.net/ttv-static/404_preview-640x360.jpg';
|
||
const avatar = rawThumb
|
||
.replace('{width}x{height}', '70x70')
|
||
.replace('{width}', '70')
|
||
.replace('{height}', '70');
|
||
return {
|
||
url: `https://www.twitch.tv/${i.user_login}`,
|
||
videoId: i.user_login || i.id || '',
|
||
type: 'live',
|
||
provider: 'twitch',
|
||
title: i.title || '',
|
||
thumbnail: resolvedThumb,
|
||
uploaderName: i.user_name || '',
|
||
uploaderUrl: `https://www.twitch.tv/${i.user_login}`,
|
||
uploaderAvatar: avatar,
|
||
channelId: i.user_id || undefined,
|
||
channelHandle: i.user_login || undefined,
|
||
channelExternalId: i.user_login || undefined,
|
||
channelAvatarUrl: avatar || undefined,
|
||
uploadedDate: i.started_at || '',
|
||
duration: 0,
|
||
views: Number(i.viewer_count || 0),
|
||
uploaded: i.started_at ? Date.parse(i.started_at) : 0,
|
||
isLive: true,
|
||
} as Video;
|
||
};
|
||
|
||
private mapTwitchChannelToVideo = (i: any): Video => {
|
||
// /search/channels : thumbnail_url = image de profil (carrée), PAS une miniature 16:9.
|
||
// On la garde comme avatar + miniature (object-cover côté template) et on
|
||
// distingue bien les chaînes hors-ligne (type 'channel') des directs.
|
||
const rawThumb: string = String(i.thumbnail_url || '');
|
||
const login: string = String(i.broadcaster_login || i.user_login || '');
|
||
const display: string = String(i.display_name || i.broadcaster_name || i.user_name || login || '');
|
||
const live = i?.is_live === true;
|
||
const thumb = rawThumb || 'https://static-cdn.jtvnw.net/ttv-static/404_preview-640x360.jpg';
|
||
return {
|
||
url: `https://www.twitch.tv/${login}`,
|
||
videoId: login || String(i.id || ''),
|
||
type: live ? 'live' : 'channel',
|
||
provider: 'twitch',
|
||
title: (live && i.title) ? String(i.title) : display,
|
||
thumbnail: thumb,
|
||
uploaderName: display,
|
||
uploaderUrl: login ? `https://www.twitch.tv/${login}` : '',
|
||
uploaderAvatar: rawThumb || '',
|
||
channelId: i.id || undefined,
|
||
channelHandle: login || undefined,
|
||
channelExternalId: login || undefined,
|
||
channelAvatarUrl: rawThumb || undefined,
|
||
uploadedDate: live ? (i.started_at || '') : '',
|
||
duration: 0,
|
||
views: 0,
|
||
uploaded: live && i.started_at ? Date.parse(i.started_at) : 0,
|
||
isLive: live,
|
||
} as Video;
|
||
};
|
||
|
||
// Twitch Clips mapper (Shorts-equivalent)
|
||
private mapTwitchClipToVideo = (i: any): Video => ({
|
||
url: i.url || `https://clips.twitch.tv/${i.id}`,
|
||
videoId: i.id || '',
|
||
type: 'video',
|
||
provider: 'twitch',
|
||
kind: 'clip',
|
||
title: i.title || '',
|
||
// Clip thumbnails often have width/height placeholders or fixed preview sizes
|
||
thumbnail: (i.thumbnail_url || '').replace('%{width}', '640').replace('%{height}', '360'),
|
||
uploaderName: i.broadcaster_name || i.creator_name || '',
|
||
uploaderUrl: i.broadcaster_name ? `https://www.twitch.tv/${i.broadcaster_name}` : '',
|
||
uploaderAvatar: '',
|
||
channelId: i.broadcaster_id || undefined,
|
||
channelHandle: undefined,
|
||
channelExternalId: undefined,
|
||
channelAvatarUrl: undefined,
|
||
uploadedDate: i.created_at || '',
|
||
duration: Number(i.duration || 0),
|
||
views: Number(i.view_count || 0),
|
||
uploaded: i.created_at ? Date.parse(i.created_at) : 0,
|
||
isLive: false,
|
||
raw: { language: i?.language || undefined },
|
||
});
|
||
|
||
/**
|
||
* Search Twitch Clips by first finding channels for a query (or recent popular channels when empty),
|
||
* 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(timeout(30000),
|
||
switchMap(token => {
|
||
if (!token) return of({ items: [], nextCursor: null });
|
||
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
|
||
const q = String(query || '').trim();
|
||
if (!q) return of({ items: [], nextCursor: null });
|
||
const after = cursor ? `&after=${encodeURIComponent(cursor)}` : '';
|
||
const keywords = [q, ...this.twitchQueryKeywords(q, 3).filter(k => k !== q.toLowerCase())].slice(0, 4);
|
||
const channelCalls = keywords.map((k, idx) =>
|
||
this.fetchTwitchChannelsForKeyword(token, headers, k, 10, idx === 0 ? after : '').pipe(
|
||
catchError(() => of({ data: [], pagination: {} }))
|
||
)
|
||
);
|
||
// Fenêtre large (30j -> 90j) : beaucoup de petites chaînes n'ont pas de clips sur 7j.
|
||
const since = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString();
|
||
const fetchClipsForUsers = (ids: string[]) => {
|
||
if (!ids.length) return of([] as any[]);
|
||
// 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 : []),
|
||
catchError(() => of([] as any[]))
|
||
);
|
||
});
|
||
return forkJoin(perUser).pipe(map((lists: any[][]) => lists.flat()));
|
||
};
|
||
return forkJoin(channelCalls).pipe(
|
||
switchMap((responses: any[]) => {
|
||
const seen = new Set<string>();
|
||
const channels: any[] = [];
|
||
let next: string | null = null;
|
||
for (const r of responses) {
|
||
const arr = Array.isArray(r?.data) ? r.data : [];
|
||
if (!next && r?.pagination?.cursor) next = r.pagination.cursor;
|
||
for (const c of arr) {
|
||
const key = String(c?.id || '');
|
||
if (!key || seen.has(key)) continue;
|
||
seen.add(key);
|
||
channels.push(c);
|
||
}
|
||
}
|
||
const userIds: string[] = channels.map((c: any) => c?.id).filter(Boolean).slice(0, 10);
|
||
return fetchClipsForUsers(userIds).pipe(
|
||
switchMap(clipsByUser => {
|
||
if (clipsByUser.length >= 3) {
|
||
return of({
|
||
items: clipsByUser.slice(0, 24).map((i: any) => this.mapTwitchClipToVideo(i)),
|
||
nextCursor: next,
|
||
} as PagedResult<Video>);
|
||
}
|
||
// Repli catégorie (game_id) : clips thématiques même sans chaîne matchée.
|
||
const cats = this.twitchQueryKeywords(q, 2);
|
||
if (!cats.length) return of({ items: clipsByUser.slice(0, 24).map((i: any) => this.mapTwitchClipToVideo(i)), nextCursor: next } as PagedResult<Video>);
|
||
const catCalls = cats.slice(0, 2).map(k =>
|
||
this.fetchTwitchCategoryForKeyword(token, headers, k).pipe(catchError(() => of({ data: [] })))
|
||
);
|
||
return forkJoin(catCalls).pipe(
|
||
switchMap((catRes: any[]) => {
|
||
const gameIds: string[] = [];
|
||
for (const cr of catRes) {
|
||
const list = Array.isArray(cr?.data) ? cr.data : [];
|
||
if (list[0]?.id) gameIds.push(String(list[0].id));
|
||
}
|
||
if (!gameIds.length) {
|
||
return of({ items: clipsByUser.slice(0, 24).map((i: any) => this.mapTwitchClipToVideo(i)), nextCursor: next } as PagedResult<Video>);
|
||
}
|
||
const gameCalls = gameIds.slice(0, 2).map(gid => {
|
||
const gPrimary = `/api/twitch-api/helix/clips?game_id=${encodeURIComponent(gid)}&first=10&started_at=${encodeURIComponent(since)}`;
|
||
const gFallback = `/proxy/twitch-api/helix/clips?game_id=${encodeURIComponent(gid)}&first=10&started_at=${encodeURIComponent(since)}`;
|
||
return this.http.get<any>(gPrimary, { headers }).pipe(
|
||
catchError(() => this.http.get<any>(gFallback, { headers })),
|
||
map(res => Array.isArray(res?.data) ? res.data : []),
|
||
catchError(() => of([] as any[]))
|
||
);
|
||
});
|
||
return forkJoin(gameCalls).pipe(
|
||
map((lists: any[][]) => ({
|
||
items: [...clipsByUser, ...lists.flat()].slice(0, 24).map((i: any) => this.mapTwitchClipToVideo(i)),
|
||
nextCursor: next,
|
||
} as PagedResult<Video>))
|
||
);
|
||
})
|
||
);
|
||
})
|
||
);
|
||
}),
|
||
catchError((e) => { console.error('Twitch clips search error', e); return of({ items: [], nextCursor: null }); })
|
||
);
|
||
})
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Top live streams Twitch (Helix /streams) — utilisé comme "trending" Twitch
|
||
* et comme filet de sécurité quand une recherche thématique ne retourne rien
|
||
* (la recherche de chaînes Helix exige des noms proches de la requête et
|
||
* 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(timeout(30000),
|
||
switchMap(token => {
|
||
if (!token) return this.getTwitchServerTopStreams(limit);
|
||
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
|
||
const after = cursor ? `&after=${encodeURIComponent(cursor)}` : '';
|
||
// Langue de l'utilisateur (filtre natif Helix /streams).
|
||
const langParam = this.primaryShortsLang2() ? `&language=${encodeURIComponent(this.primaryShortsLang2() as string)}` : '';
|
||
const primary = `/api/twitch-api/helix/streams?first=${limit}${after}${langParam}`;
|
||
const fallback = `/proxy/twitch-api/helix/streams?first=${limit}${after}${langParam}`;
|
||
return this.http.get<any>(primary, { headers }).pipe(
|
||
catchError(() => this.http.get<any>(fallback, { headers })),
|
||
map(res => ({
|
||
items: (res.data || []).map((i: any) => this.mapTwitchStreamToVideo(i)),
|
||
nextCursor: res.pagination?.cursor || null,
|
||
} as PagedResult<Video>)),
|
||
// Helix direct vide/en erreur (proxy, 429, CORS…) : repli serveur.
|
||
switchMap(res => (res?.items?.length ? of(res) : this.getTwitchServerTopStreams(limit))),
|
||
catchError(() => this.getTwitchServerTopStreams(limit))
|
||
);
|
||
}),
|
||
catchError(() => this.getTwitchServerTopStreams(limit))
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Top streams via le serveur (`GET /api/twitch/top-streams`, token +
|
||
* retries côté back). Repli fiable pour Home/Explore quand les appels
|
||
* Helix directs du navigateur échouent.
|
||
*/
|
||
public getTwitchServerTopStreams(limit = 24): Observable<PagedResult<Video>> {
|
||
const n = Math.min(50, Math.max(1, Number(limit) || 24));
|
||
return this.http.get<any>(`/api/twitch/top-streams?first=${n}`).pipe(
|
||
timeout(20000),
|
||
map(res => ({
|
||
items: ((res?.items || []) as any[]).map(i => this.mapTwitchServerItemToVideo(i)),
|
||
nextCursor: null,
|
||
} as PagedResult<Video>)),
|
||
catchError((e) => { console.error('Twitch server top streams error', e); return of({ items: [], nextCursor: null }); })
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Recherche Twitch via le serveur unifié (`/api/search?providers=tw`,
|
||
* token + retries + filets côté back). Dernier repli des pages Explore.
|
||
*/
|
||
public searchTwitchViaServer(query: string, limit = 24): Observable<PagedResult<Video>> {
|
||
const q = String(query || '').trim();
|
||
if (!q) return this.getTwitchServerTopStreams(limit);
|
||
const n = Math.min(50, Math.max(1, Number(limit) || 24));
|
||
const url = `/api/search?q=${encodeURIComponent(q)}&providers=tw&pageSize=${n}`;
|
||
return this.http.get<any>(url).pipe(
|
||
timeout(25000),
|
||
map(res => {
|
||
const list = (res?.groups?.tw || res?.items || []) as any[];
|
||
return {
|
||
items: list.map(i => this.mapTwitchServerItemToVideo(i)).filter(v => !!v.videoId),
|
||
nextCursor: null,
|
||
} as PagedResult<Video>;
|
||
}),
|
||
catchError((e) => { console.error('Twitch server search error', e); return of({ items: [], nextCursor: null }); })
|
||
);
|
||
}
|
||
|
||
/** Mappe un item Twitch "forme serveur" (twitch.mjs) vers Video. */
|
||
private mapTwitchServerItemToVideo = (i: any): Video => {
|
||
const kind = String(i?.kind || '').toLowerCase();
|
||
const type = String(i?.type || 'video');
|
||
const login: string | undefined = i?.channelHandle || i?.channelExternalId || undefined;
|
||
const thumb: string = String(i?.thumbnail || i?.uploaderAvatar || '') ||
|
||
'https://static-cdn.jtvnw.net/ttv-static/404_preview-640x360.jpg';
|
||
return {
|
||
url: i?.url || (login ? `https://www.twitch.tv/${login}` : `/watch/${i?.id || ''}`),
|
||
videoId: String(i?.id || ''),
|
||
type,
|
||
provider: 'twitch',
|
||
kind: kind === 'clip' ? 'clip' : kind === 'vod' ? 'vod' : undefined,
|
||
title: i?.title || '',
|
||
thumbnail: thumb,
|
||
uploaderName: i?.uploaderName || '',
|
||
uploaderUrl: login ? `https://www.twitch.tv/${login}` : '',
|
||
uploaderAvatar: i?.uploaderAvatar || '',
|
||
channelId: i?.channelId || undefined,
|
||
channelHandle: login,
|
||
channelExternalId: login || undefined,
|
||
channelAvatarUrl: i?.uploaderAvatar || undefined,
|
||
uploadedDate: i?.publishedAt || '',
|
||
duration: Number(i?.duration || 0),
|
||
views: Number(i?.views || 0),
|
||
uploaded: i?.publishedAt ? Date.parse(i.publishedAt) : 0,
|
||
isLive: i?.isLive === true,
|
||
} as Video;
|
||
};
|
||
|
||
/**
|
||
* Recherche thématique Twitch robuste : VODs puis clips, avec fallback vers
|
||
* les top streams live si les deux sont quasi vides (évite les blocs vides
|
||
* sur les pages de thèmes). `minResults` = seuil sous lequel on complète.
|
||
*/
|
||
public searchTwitchThemePage(query: string, cursor?: string | null, minResults = 4): Observable<PagedResult<Video>> {
|
||
return this.searchTwitchVodsPage(query, cursor).pipe(
|
||
switchMap(vods => {
|
||
if ((vods?.items?.length || 0) >= minResults) return of(vods);
|
||
return this.searchTwitchClipsPage(query, cursor).pipe(
|
||
map(clips => ({
|
||
items: [...(vods?.items || []), ...(clips?.items || [])],
|
||
nextCursor: clips?.nextCursor || vods?.nextCursor || null,
|
||
} as PagedResult<Video>)),
|
||
switchMap(merged => {
|
||
if ((merged?.items?.length || 0) >= minResults) return of(merged);
|
||
// Filet de sécurité : top streams live (jamais vide quand Twitch est configuré)
|
||
return this.getTwitchTopStreamsPage(cursor).pipe(
|
||
map(top => {
|
||
if (!top.items.length) return merged;
|
||
const seen = new Set((merged.items || []).map(v => v.videoId));
|
||
const extra = top.items.filter(v => !seen.has(v.videoId));
|
||
return { items: [...(merged.items || []), ...extra], nextCursor: top.nextCursor } as PagedResult<Video>;
|
||
}),
|
||
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>))
|
||
))
|
||
);
|
||
}
|
||
|
||
// ---- Twitch Shorts : clips via les jeux les plus suivis -----------------
|
||
// L'ancienne chaîne (top streams -> broadcaster_id -> clips 7 j) rendait
|
||
// souvent un flux vide ("Aucun Short trouvé"). L'endpoint /helix/clips
|
||
// accepte directement game_id (trié par vues, sans broadcaster) : on
|
||
// tourne sur les top games, ce qui donne toujours des clips récents.
|
||
// Curseur opaque : "<gameId>::<paginationCursor>" (pagination vide = page 1).
|
||
private twitchTopGameIds: string[] | null = null;
|
||
private twitchGameIdx = 0;
|
||
private twitchClipsSinceDays = 30;
|
||
|
||
private getTwitchTopGameIds(): Observable<string[]> {
|
||
if (this.twitchTopGameIds?.length) return of(this.twitchTopGameIds);
|
||
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}` });
|
||
const primary = `/api/twitch-api/helix/games/top?first=10`;
|
||
const fallback = `/proxy/twitch-api/helix/games/top?first=10`;
|
||
return this.http.get<any>(primary, { headers }).pipe(
|
||
catchError(() => this.http.get<any>(fallback, { headers })),
|
||
map(res => {
|
||
const ids: string[] = (Array.isArray(res?.data) ? res.data : []).map((g: any) => String(g?.id || '')).filter(Boolean);
|
||
if (ids.length) this.twitchTopGameIds = ids;
|
||
return ids;
|
||
}),
|
||
catchError(() => of([] as string[]))
|
||
);
|
||
})
|
||
);
|
||
}
|
||
|
||
private fetchTwitchGameClipsPage(gameId: string, pageCursor?: string | null): Observable<PagedResult<Video>> {
|
||
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}` });
|
||
const since = new Date(Date.now() - this.twitchClipsSinceDays * 24 * 60 * 60 * 1000).toISOString();
|
||
const after = pageCursor ? `&after=${encodeURIComponent(pageCursor)}` : '';
|
||
const primary = `/api/twitch-api/helix/clips?game_id=${encodeURIComponent(gameId)}&first=20&started_at=${encodeURIComponent(since)}${after}`;
|
||
const fallback = `/proxy/twitch-api/helix/clips?game_id=${encodeURIComponent(gameId)}&first=20&started_at=${encodeURIComponent(since)}${after}`;
|
||
return this.http.get<any>(primary, { headers }).pipe(
|
||
catchError(() => this.http.get<any>(fallback, { headers })),
|
||
map(res => ({
|
||
clips: Array.isArray(res?.data) ? res.data : [],
|
||
page: (res?.pagination?.cursor || null) as string | null,
|
||
})),
|
||
// Ne garde que les clips existants (les morts affichent
|
||
// "time machine" dans le player).
|
||
switchMap(({ clips, page }) => {
|
||
const ids = clips.map((c: any) => String(c?.id || '')).filter(Boolean);
|
||
if (!ids.length) {
|
||
return of({
|
||
items: [],
|
||
nextCursor: page ? `${gameId}::${page}` : this.nextTwitchGameCursor(gameId),
|
||
} as PagedResult<Video>);
|
||
}
|
||
return this.validateTwitchClipIds(ids).pipe(
|
||
map(valid => ({
|
||
items: clips.filter((c: any) => valid.has(String(c?.id || ''))).map((i: any) => this.mapTwitchClipToVideo(i)),
|
||
// Jeu suivant quand la page est épuisée (rotation infinie).
|
||
nextCursor: page ? `${gameId}::${page}` : this.nextTwitchGameCursor(gameId),
|
||
} as PagedResult<Video>)),
|
||
catchError(() => of({
|
||
items: clips.map((i: any) => this.mapTwitchClipToVideo(i)),
|
||
nextCursor: page ? `${gameId}::${page}` : this.nextTwitchGameCursor(gameId),
|
||
} as PagedResult<Video>))
|
||
);
|
||
}),
|
||
catchError(() => of({
|
||
items: [],
|
||
nextCursor: this.nextTwitchGameCursor(gameId),
|
||
} as PagedResult<Video>))
|
||
);
|
||
})
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Valide l'existence des clips par lot (`GET /helix/clips?id=…`, 100 max).
|
||
* Les clips supprimés/expirés affichent "Sorry. Unless you've got a time
|
||
* machine, that content is unavailable." dans le player : on les exclut
|
||
* AVANT affichage. En cas d'échec de validation, le doute profite aux
|
||
* clips (on garde tout) pour ne jamais vider le flux.
|
||
*/
|
||
public validateTwitchClipIds(ids: string[]): Observable<Set<string>> {
|
||
const clean = Array.from(new Set((ids || []).map(s => String(s || '').trim()).filter(Boolean))).slice(0, 100);
|
||
if (!clean.length) return of(new Set<string>());
|
||
return this.getTwitchAuthToken().pipe(timeout(15000),
|
||
switchMap(token => {
|
||
if (!token) return of(new Set<string>(clean));
|
||
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
|
||
const params = clean.map(s => `id=${encodeURIComponent(s)}`).join('&');
|
||
const primary = `/api/twitch-api/helix/clips?${params}`;
|
||
const fallback = `/proxy/twitch-api/helix/clips?${params}`;
|
||
return this.http.get<any>(primary, { headers }).pipe(
|
||
catchError(() => this.http.get<any>(fallback, { headers })),
|
||
map(res => new Set<string>((Array.isArray(res?.data) ? res.data : []).map((c: any) => String(c?.id || '')).filter(Boolean))),
|
||
catchError(() => of(new Set<string>(clean)))
|
||
);
|
||
}),
|
||
catchError(() => of(new Set<string>(clean)))
|
||
);
|
||
}
|
||
|
||
/** Curseur vers le jeu suivant (boucle infinie sur les top games). */
|
||
private nextTwitchGameCursor(currentGameId: string): string | null {
|
||
const games = this.twitchTopGameIds || [];
|
||
if (!games.length) return null;
|
||
const idx = games.indexOf(currentGameId);
|
||
const next = games[(idx + 1 + games.length) % games.length];
|
||
return next ? `${next}::` : null;
|
||
}
|
||
|
||
/**
|
||
* Clips Twitch sans requête (page Shorts) : rotation sur les top games
|
||
* (clips triés par vues, 30 j). Repli historique broadcaster si les jeux
|
||
* sont indisponibles.
|
||
*/
|
||
public getTwitchClipsFeed(cursor?: string | null): Observable<PagedResult<Video>> {
|
||
const parsed = String(cursor || '');
|
||
const sep = parsed.indexOf('::');
|
||
if (sep > 0) {
|
||
const gameId = parsed.slice(0, sep);
|
||
const pageCursor = parsed.slice(sep + 2) || null;
|
||
return this.fetchTwitchGameClipsPage(gameId, pageCursor);
|
||
}
|
||
return this.getTwitchTopGameIds().pipe(
|
||
switchMap(games => {
|
||
if (!games.length) return this.getTwitchBroadcasterClipsFeed(null);
|
||
const gameId = games[this.twitchGameIdx++ % games.length];
|
||
return this.fetchTwitchGameClipsPage(gameId, null);
|
||
})
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Ancien flux broadcaster (top streams -> clips) : conservé en repli quand
|
||
* /helix/games/top ne répond pas.
|
||
*/
|
||
private getTwitchBroadcasterClipsFeed(cursor?: string | null): Observable<PagedResult<Video>> {
|
||
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 sPrimary = `/api/twitch-api/helix/streams?first=20${after}`;
|
||
const sFallback = `/proxy/twitch-api/helix/streams?first=20${after}`;
|
||
return this.http.get<any>(sPrimary, { headers }).pipe(
|
||
catchError(() => this.http.get<any>(sFallback, { headers })),
|
||
switchMap(sRes => {
|
||
const streams = Array.isArray(sRes?.data) ? sRes.data : [];
|
||
const userIds: string[] = streams.map((s: any) => s?.user_id).filter(Boolean).slice(0, 20);
|
||
if (!userIds.length) return of({ items: [], nextCursor: null } as PagedResult<Video>);
|
||
// Fenêtre large (90 j) : beaucoup de chaînes n'ont pas de clips sur 7 j,
|
||
// ce qui vidait la page Shorts Twitch ("Aucun Short trouvé").
|
||
const since = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString();
|
||
const perUser = userIds.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)}`;
|
||
return this.http.get<any>(cPrimary, { headers }).pipe(
|
||
catchError(() => this.http.get<any>(cFallback, { headers })),
|
||
map(res => Array.isArray(res?.data) ? res.data : [])
|
||
);
|
||
});
|
||
return forkJoin(perUser).pipe(
|
||
map((lists: any[][]) => lists.flat().slice(0, 24)),
|
||
// Exclut les clips morts ("time machine") avant affichage.
|
||
switchMap((clips: any[]) => {
|
||
const ids = clips.map((c: any) => String(c?.id || '')).filter(Boolean);
|
||
if (!ids.length) {
|
||
return of({ items: [], nextCursor: sRes?.pagination?.cursor || null } as PagedResult<Video>);
|
||
}
|
||
return this.validateTwitchClipIds(ids).pipe(
|
||
map(valid => ({
|
||
items: clips.filter((c: any) => valid.has(String(c?.id || ''))).map((i: any) => this.mapTwitchClipToVideo(i)),
|
||
nextCursor: sRes?.pagination?.cursor || null,
|
||
} as PagedResult<Video>))
|
||
);
|
||
}),
|
||
catchError(() => of({ items: [], nextCursor: null } as PagedResult<Video>))
|
||
);
|
||
}),
|
||
catchError((e) => { console.error('Twitch clips feed error', e); return of({ items: [], nextCursor: null }); })
|
||
);
|
||
})
|
||
);
|
||
}
|
||
|
||
private parseISODurationToSeconds(iso: string): number {
|
||
// ... (rest of the code remains the same)
|
||
const m = /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/i.exec(iso || '');
|
||
if (!m) return 0;
|
||
const h = parseInt(m[1] || '0', 10);
|
||
const mn = parseInt(m[2] || '0', 10);
|
||
const s = parseInt(m[3] || '0', 10);
|
||
return h * 3600 + mn * 60 + s;
|
||
}
|
||
|
||
private resolveYouTubeApiKey(): string | undefined {
|
||
const g: any = globalThis as any;
|
||
const fromGlobal = g?.YOUTUBE_API_KEY;
|
||
const fromImportMeta = (typeof import.meta !== 'undefined' && (import.meta as any).env)
|
||
? ((import.meta as any).env.YOUTUBE_API_KEY || (import.meta as any).env.NG_APP_YOUTUBE_API_KEY)
|
||
: undefined;
|
||
const fromProcess = (g?.process?.env) ? (g.process.env.YOUTUBE_API_KEY || g.process.env.NG_APP_YOUTUBE_API_KEY) : undefined;
|
||
return fromGlobal || fromImportMeta || fromProcess || undefined;
|
||
}
|
||
|
||
// ---- Multi-key helpers ----
|
||
private loadYouTubeKeys(): string[] {
|
||
const g: any = globalThis as any;
|
||
const keys: string[] = [];
|
||
try {
|
||
// Prefer explicit array window.YOUTUBE_API_KEYS
|
||
const arr = Array.isArray(g?.YOUTUBE_API_KEYS) ? g.YOUTUBE_API_KEYS : null;
|
||
if (arr && arr.length) keys.push(...arr.map((s: any) => String(s).trim()).filter(Boolean));
|
||
} catch {}
|
||
try {
|
||
// Comma-separated string support
|
||
const csv = g?.YOUTUBE_API_KEYS;
|
||
if (!Array.isArray(csv) && typeof csv === 'string' && csv.includes(',')) {
|
||
keys.push(...csv.split(',').map((s: string) => s.trim()).filter(Boolean));
|
||
}
|
||
} catch {}
|
||
try {
|
||
// Env-based multi-keys
|
||
const im: any = (typeof import.meta !== 'undefined' && (import.meta as any).env) ? (import.meta as any).env : undefined;
|
||
const fromEnvCsv = im?.YOUTUBE_API_KEYS || im?.NG_APP_YOUTUBE_API_KEYS || (globalThis as any)?.process?.env?.YOUTUBE_API_KEYS || (globalThis as any)?.process?.env?.NG_APP_YOUTUBE_API_KEYS;
|
||
if (typeof fromEnvCsv === 'string' && fromEnvCsv.includes(',')) {
|
||
keys.push(...fromEnvCsv.split(',').map((s: string) => s.trim()).filter(Boolean));
|
||
}
|
||
} catch {}
|
||
// Fallback single key
|
||
const single = this.resolveYouTubeApiKey();
|
||
if (single) keys.push(String(single));
|
||
// De-duplicate and return
|
||
const uniq = Array.from(new Set(keys.filter(Boolean)));
|
||
return uniq;
|
||
}
|
||
|
||
private isKeyBanned(k: string): boolean {
|
||
const until = this.ytKeyBans.get(k) || 0;
|
||
return Date.now() < until;
|
||
}
|
||
|
||
private banYouTubeKey(k: string) {
|
||
if (!k) return;
|
||
const until = Date.now() + this.YT_KEY_BAN_MS;
|
||
this.ytKeyBans.set(k, until);
|
||
}
|
||
|
||
private getActiveYouTubeKey(): string | undefined {
|
||
// If no keys loaded, attempt to load once
|
||
if (!this.ytKeys || this.ytKeys.length === 0) this.ytKeys = this.loadYouTubeKeys();
|
||
const n = this.ytKeys.length;
|
||
if (!n) return undefined;
|
||
// Round-robin until we find a non-banned key
|
||
for (let i = 0; i < n; i++) {
|
||
const idx = (this.ytKeyIndex + i) % n;
|
||
const candidate = this.ytKeys[idx];
|
||
if (!this.isKeyBanned(candidate)) {
|
||
this.ytKeyIndex = (idx + 1) % n; // advance pointer for next call
|
||
return candidate;
|
||
}
|
||
}
|
||
// All keys currently banned
|
||
return undefined;
|
||
}
|
||
|
||
private resolveTwitchClientId(): string | undefined {
|
||
const g: any = globalThis as any;
|
||
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.
|
||
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 {
|
||
let totalSeconds = 0;
|
||
if (duration.includes('h')) {
|
||
totalSeconds += parseInt(duration.split('h')[0]) * 3600;
|
||
duration = duration.split('h')[1];
|
||
}
|
||
if (duration.includes('m')) {
|
||
totalSeconds += parseInt(duration.split('m')[0]) * 60;
|
||
duration = duration.split('m')[1];
|
||
}
|
||
if (duration.includes('s')) {
|
||
totalSeconds += parseInt(duration.split('s')[0]);
|
||
}
|
||
return totalSeconds;
|
||
}
|
||
|
||
// Dev proxy helpers with fallback to direct URLs (returns Observable<any>)
|
||
private fetchYouTube(path: string, params: URLSearchParams, _activeKey?: string) {
|
||
const qs = params.toString();
|
||
const cachedProd = `/api/yt${path}?${qs}`; // server cache in prod
|
||
const cachedDev = `/proxy/api/yt${path}?${qs}`; // dev proxy to server cache
|
||
const direct = `https://www.googleapis.com${path}?${qs}`;
|
||
const proxied = `/proxy/yt${path}?${qs}`;
|
||
// Try prod server cache → dev server cache → direct → dev proxy
|
||
return this.http.get<any>(cachedProd).pipe(
|
||
catchError(() => this.http.get<any>(cachedDev).pipe(
|
||
catchError((_e1) => this.http.get<any>(direct).pipe(
|
||
catchError((err) => {
|
||
console.warn('YouTube direct call failed, trying /proxy/yt...', err);
|
||
return this.http.get<any>(proxied);
|
||
})
|
||
))
|
||
))
|
||
);
|
||
}
|
||
|
||
private fetchDailymotion(path: string, params: URLSearchParams) {
|
||
const prod = `/api/dm${path}?${params.toString()}`;
|
||
const proxied = `/proxy/dm${path}?${params.toString()}`;
|
||
const direct = `https://api.dailymotion.com${path}?${params.toString()}`;
|
||
return this.http.get<any>(prod).pipe(
|
||
catchError(() => this.http.get<any>(proxied).pipe(
|
||
catchError(() => this.http.get<any>(direct))
|
||
))
|
||
);
|
||
}
|
||
|
||
private fetchRumble(path: string, params: URLSearchParams) {
|
||
const url = `/api/rumble${path}?${params.toString()}`;
|
||
return this.http.get<any>(url).pipe(
|
||
catchError((err) => {
|
||
console.error('Rumble API call via proxy failed', err);
|
||
return of({ items: [], total: 0 });
|
||
})
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Fetch durations (in seconds) for a list of YouTube video IDs.
|
||
* Non-embeddable videos (status.embeddable === false) are excluded via
|
||
* a -1 marker so Shorts/feed callers can drop them (player error 153).
|
||
*/
|
||
public getYouTubeDurations(ids: string[]): Observable<Record<string, number>> {
|
||
const apiKey = this.getActiveYouTubeKey();
|
||
if (!apiKey || !ids.length) {
|
||
return of({});
|
||
}
|
||
|
||
const params = new URLSearchParams({
|
||
key: apiKey,
|
||
part: 'contentDetails,status',
|
||
id: ids.join(','),
|
||
});
|
||
|
||
return this.fetchYouTube('/youtube/v3/videos', params, apiKey).pipe(
|
||
map((res: any) => Array.isArray(res?.items) ? res.items : []),
|
||
map((items: any[]) => items.reduce((acc: Record<string, number>, item: any) => {
|
||
const duration = item?.contentDetails?.duration || 'PT0S';
|
||
const id = String(item?.id || '');
|
||
if (!id) return acc;
|
||
// -1 = non-embeddable (à exclure côté Shorts pour éviter l'erreur 153)
|
||
if (item?.status && item.status.embeddable === false) {
|
||
acc[id] = -1;
|
||
return acc;
|
||
}
|
||
acc[id] = this.parseISODurationToSeconds(duration);
|
||
return acc;
|
||
}, {} as Record<string, number>)),
|
||
catchError(() => of({}))
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Fetch minimal details (duration seconds + viewCount) for a list of YouTube IDs.
|
||
* Non-embeddable entries get duration -1 so callers can drop them (error 153).
|
||
*/
|
||
private getYouTubeContentAndStats(ids: string[]): Observable<Record<string, { duration: number; views: number }>> {
|
||
const apiKey = this.getActiveYouTubeKey();
|
||
if (!apiKey || !ids.length) return of({});
|
||
const params = new URLSearchParams({
|
||
key: apiKey,
|
||
part: 'contentDetails,statistics,status',
|
||
id: ids.join(','),
|
||
maxResults: String(Math.min(50, ids.length)),
|
||
// Add safeSearch for consistency
|
||
safeSearch: 'moderate',
|
||
});
|
||
return this.fetchYouTube('/youtube/v3/videos', params, apiKey).pipe(
|
||
map((res: any) => Array.isArray(res?.items) ? res.items : []),
|
||
map((items: any[]) => {
|
||
const out: Record<string, { duration: number; views: number }> = {};
|
||
for (const it of items) {
|
||
const id = String(it?.id || '');
|
||
if (!id) continue;
|
||
if (it?.status && it.status.embeddable === false) {
|
||
out[id] = { duration: -1, views: Number(it?.statistics?.viewCount || 0) };
|
||
continue;
|
||
}
|
||
const duration = this.parseISODurationToSeconds(it?.contentDetails?.duration || 'PT0S');
|
||
const views = Number(it?.statistics?.viewCount || 0);
|
||
out[id] = { duration, views };
|
||
}
|
||
return out;
|
||
}),
|
||
catchError(() => of({} as Record<string, { duration: number; views: number }>))
|
||
);
|
||
}
|
||
} |