Files
NewTube/src/services/youtube-api.service.ts
T
bruno d4895bae92
CI / build-and-test (push) Successful in 14m9s
fix(shorts): autoplay muet, langues FR/EN, proxy prod et Dailymotion embeds
- 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
2026-09-29 14:22:45 -04:00

2191 lines
102 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }>))
);
}
}