first commit

This commit is contained in:
2025-09-14 23:05:30 -04:00
commit 2866d74b32
122 changed files with 95474 additions and 0 deletions
+930
View File
@@ -0,0 +1,930 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable, of, BehaviorSubject, forkJoin } from 'rxjs';
import { catchError, map, switchMap, shareReplay, first } from 'rxjs/operators';
import { Video, VideoDetail, Comment } from '../models/video.model';
import { InstanceService, Provider } from './instance.service';
import { I18nService } from './i18n.service';
export interface PagedResult<T> {
items: T[];
nextCursor?: string | null;
}
@Injectable({ providedIn: 'root' })
export class YoutubeApiService {
private twitchAuthToken$ = new BehaviorSubject<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) {
this.getTwitchAuthToken().subscribe(this.twitchAuthToken$);
this.ytKeys = this.loadYouTubeKeys();
}
// ... (rest of the code remains the same)
// ===================== Twitch Search Helpers =====================
/** Search live channels matching query (around 20), with pagination cursor. */
public searchTwitchChannelsPage(query: string, cursor?: string | null): Observable<PagedResult<Video>> {
return this.getTwitchAuthToken().pipe(
switchMap(token => {
if (!token) return of({ items: [], nextCursor: null });
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
const after = cursor ? `&after=${encodeURIComponent(cursor)}` : '';
const primary = `/api/twitch-api/helix/search/channels?query=${encodeURIComponent(query)}&first=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(
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 chPrimary = `/api/twitch-api/helix/search/channels?query=${encodeURIComponent(query)}&first=10${after}`;
const chFallback = `/proxy/twitch-api/helix/search/channels?query=${encodeURIComponent(query)}&first=10${after}`;
const fetchChannels$ = this.http.get<any>(chPrimary, { headers }).pipe(
catchError(() => this.http.get<any>(chFallback, { headers }))
);
return fetchChannels$.pipe(
switchMap((chRes: any) => {
const channels = Array.isArray(chRes?.data) ? chRes.data : [];
const userIds: string[] = channels.map((c: any) => c?.id).filter(Boolean).slice(0, 10);
if (userIds.length === 0) return of({ items: [], nextCursor: null } as PagedResult<Video>);
const perUserRequests = userIds.map(uid => {
// type=all to include archive, upload, highlight; fetch a few more to increase hit rate
const vPrimary = `/api/twitch-api/helix/videos?user_id=${encodeURIComponent(uid)}&first=10&type=all`;
const vFallback = `/proxy/twitch-api/helix/videos?user_id=${encodeURIComponent(uid)}&first=10&type=all`;
return this.http.get<any>(vPrimary, { headers }).pipe(
catchError(() => this.http.get<any>(vFallback, { headers })),
map(vres => Array.isArray(vres?.data) ? vres.data : [])
);
});
return forkJoin(perUserRequests).pipe(
map((lists: any[][]) => lists.flat().slice(0, 20)),
map((videos: any[]) => ({
items: videos.map((i: any) => this.mapTwitchItemToVideo(i)),
nextCursor: chRes?.pagination?.cursor || null,
} 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',
part: 'snippet,contentDetails,statistics',
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
try {
if (e?.status === 403 && (e?.error?.error?.errors?.[0]?.reason === 'quotaExceeded' || /quota/i.test(String(e?.error?.error?.message || e?.message || '')))) {
this.quotaExceeded$.next(true);
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,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.getTwitchAuthToken().pipe(
switchMap(token => {
if (!token) return of({ items: [], nextCursor: null });
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
const after = cursor ? `&after=${encodeURIComponent(cursor)}` : '';
const primary = `/api/twitch-api/helix/streams?first=24${after}`;
const fallback = `/proxy/twitch-api/helix/streams?first=24${after}`;
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 trending error', e); return of({ items: [], nextCursor: null }); })
);
})
);
}
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',
part: 'snippet',
// Reduce initial page size to 12 to limit payload; unit cost per call remains but fewer follow-up calls
maxResults: '12',
q: String(query),
regionCode: String(region),
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];
return s ? { ...v, duration: s.duration, views: s.views } : v;
});
const next = { ...out, items: enriched } as PagedResult<Video>;
this.searchCache.set(cacheKey, { ts: now, data: next });
return next;
})
);
}),
catchError((e) => {
// Surface quotaExceeded if present
try {
if (e?.status === 403 && (e?.error?.error?.errors?.[0]?.reason === 'quotaExceeded' || /quota/i.test(String(e?.error?.error?.message || e?.message || '')))) {
this.quotaExceeded$.next(true);
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>;
}),
catchError((e) => { console.error('Rumble search error', e); return of({ items: [], nextCursor: null }); })
);
}
case 'twitch': {
return this.getTwitchAuthToken().pipe(
switchMap(token => {
if (!token) return of({ items: [], nextCursor: null });
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
const 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>;
}),
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 || '';
return {
url: `/watch/${id}`,
videoId: id,
type: 'video',
title: sn.title || '',
thumbnail: thumb,
uploaderName: sn.channelTitle || '',
uploaderUrl: '',
uploaderAvatar: thumb, // placeholder; channel avatar would need another call
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): 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 || '';
return {
url: `/watch/${id}`,
videoId: id,
type: 'video',
title: sn.title || '',
thumbnail: thumb,
uploaderName: sn.channelTitle || '',
uploaderUrl: '',
uploaderAvatar: thumb,
uploadedDate: publishedAt,
duration: 0,
views: 0,
uploaded: publishedAt ? Date.parse(publishedAt) : 0,
} as Video;
};
// Dailymotion mapper
private mapDailymotionItemToVideo = (i: any): Video => ({
url: `/watch/${i.id}`,
videoId: i.id || '',
type: 'video',
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 || '',
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,
});
// 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',
title: i.name || '',
thumbnail,
uploaderName,
uploaderUrl,
uploaderAvatar: uploaderAvatar || thumbnail,
uploadedDate: i.publishedAt || '',
duration: Number(i.duration || 0),
views: Number(i.views || 0),
uploaded: i.publishedAt ? Date.parse(i.publishedAt) : 0,
} 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 thumb = val?.thumbnail?.url || '';
const publishedAt = val?.release_time ? new Date(val.release_time * 1000).toISOString() : '';
const duration = val?.video?.duration || 0;
const views = (i?.meta?.view_count != null) ? Number(i.meta.view_count) : 0;
const uploaderName = channelVal?.title || channel?.name || '';
return {
url: webUrl,
videoId: i?.claim_id || i?.txid || i?.claimId || i?.id || '',
type: 'video',
title: val?.title || i?.name || '',
thumbnail: thumb,
uploaderName,
uploaderUrl: channel?.permanent_url ? this.lbryToOdyseeUrl(channel.permanent_url) : '',
uploaderAvatar: channelVal?.thumbnail?.url || thumb,
uploadedDate: publishedAt,
duration: Number(duration || 0),
views: views,
uploaded: publishedAt ? Date.parse(publishedAt) : 0,
} 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 uploadedDate = i?.published_at || i?.created_at || i?.uploadedDate || '';
const views = Number(i?.views || i?.view_count || 0);
const duration = Number(i?.duration || 0);
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 || '';
}
return {
url,
videoId: id,
type: 'video',
title,
thumbnail: thumb,
uploaderName,
uploaderUrl: i?.channel_url || '',
uploaderAvatar,
uploadedDate,
duration,
views,
uploaded: uploadedDate ? Date.parse(uploadedDate) : 0,
} as Video;
};
// Twitch Mappers
private mapTwitchItemToVideo = (i: any): Video => ({
url: `/watch/${i.id}`,
videoId: i.id || '',
type: 'video',
title: i.title || '',
thumbnail: i.thumbnail_url?.replace('%{width}', '640').replace('%{height}', '360') || '',
uploaderName: i.user_name || '',
uploaderUrl: `https://www.twitch.tv/${i.user_login}`,
uploaderAvatar: '', // Requires another API call
uploadedDate: i.created_at || '',
duration: this.parseTwitchDurationToSeconds(i.duration || '0s'),
views: i.view_count || 0,
uploaded: i.created_at ? Date.parse(i.created_at) : 0,
});
// Twitch Streams mapper (for trending popular live streams)
private mapTwitchStreamToVideo = (i: any): Video => ({
url: `https://www.twitch.tv/${i.user_login}`,
videoId: i.user_login || i.id || '',
type: 'channel',
title: i.title || '',
thumbnail: (i.thumbnail_url || '').replace('{width}', '640').replace('{height}', '360'),
uploaderName: i.user_name || '',
uploaderUrl: `https://www.twitch.tv/${i.user_login}`,
uploaderAvatar: (i.thumbnail_url || '').replace('{width}', '70').replace('{height}', '70'),
uploadedDate: i.started_at || '',
duration: 0,
views: Number(i.viewer_count || 0),
uploaded: i.started_at ? Date.parse(i.started_at) : 0,
});
private mapTwitchChannelToVideo = (i: any): Video => ({
url: `https://www.twitch.tv/${i.broadcaster_login || i.user_login || ''}`,
videoId: i.broadcaster_login || i.user_login || '',
type: 'channel',
title: i.display_name || i.title || '',
thumbnail: (i.thumbnail_url || '').replace('{width}', '640').replace('{height}', '360'),
uploaderName: i.display_name || i.broadcaster_login || i.user_login || '',
uploaderUrl: `https://www.twitch.tv/${i.broadcaster_login || i.user_login || ''}`,
uploaderAvatar: (i.thumbnail_url || '').replace('{width}', '70').replace('{height}', '70'),
uploadedDate: i.started_at || '',
duration: 0, // Not a VOD
views: 0, // Not applicable
uploaded: i.started_at ? Date.parse(i.started_at) : 0,
});
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 g?.TWITCH_CLIENT_ID || (typeof import.meta !== 'undefined' && (import.meta as any).env?.TWITCH_CLIENT_ID) || g?.process?.env?.TWITCH_CLIENT_ID;
}
private getTwitchAuthToken(): Observable<string | null> {
const clientId = this.resolveTwitchClientId();
const clientSecret = (globalThis as any)?.TWITCH_CLIENT_SECRET || (typeof import.meta !== 'undefined' && (import.meta as any).env?.TWITCH_CLIENT_SECRET) || (globalThis as any)?.process?.env?.TWITCH_CLIENT_SECRET;
if (!clientId || !clientSecret) {
console.warn('Twitch Client ID or Secret not found.');
return of(null);
}
const body = `client_id=${clientId}&client_secret=${clientSecret}&grant_type=client_credentials`;
const headers = new HttpHeaders({ 'Content-Type': 'application/x-www-form-urlencoded' });
return this.http.post<any>('/proxy/twitch-auth/oauth2/token', body, { headers }).pipe(
map(res => res.access_token),
shareReplay(1),
catchError(() => of(null))
);
}
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.
*/
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',
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) 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.
*/
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',
id: ids.join(','),
maxResults: String(Math.min(50, ids.length)),
});
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;
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 }>))
);
}
}