first commit
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { map, catchError, tap } from 'rxjs/operators';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
username: string;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
id: string;
|
||||
userAgent?: string;
|
||||
deviceInfo?: string;
|
||||
ip?: string;
|
||||
isRemember?: number | boolean;
|
||||
createdAt?: string;
|
||||
lastSeenAt?: string;
|
||||
expiresAt?: string;
|
||||
revokedAt?: string | null;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private _accessToken = signal<string | null>(null);
|
||||
private _currentUser = signal<AuthUser | null>(null);
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
getAccessToken(): string | null {
|
||||
return this._accessToken();
|
||||
}
|
||||
|
||||
currentUser() {
|
||||
return this._currentUser();
|
||||
}
|
||||
|
||||
setCurrentUser(user: AuthUser | null) {
|
||||
this._currentUser.set(user);
|
||||
}
|
||||
|
||||
setAccessToken(token: string | null) {
|
||||
this._accessToken.set(token);
|
||||
}
|
||||
|
||||
login(username: string, password: string, rememberMe = false): Observable<AuthUser> {
|
||||
const body = { username, password, rememberMe };
|
||||
return this.http.post<any>('/api/auth/login', body, { withCredentials: true }).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
// Only fallback in dev when /api is missing (404) or on network error (status 0)
|
||||
if (!err || err.status === 0 || err.status === 404) {
|
||||
return this.http.post<any>('/proxy/api/auth/login', body, { withCredentials: true });
|
||||
}
|
||||
throw err;
|
||||
}),
|
||||
tap(res => {
|
||||
this._accessToken.set(res?.accessToken || null);
|
||||
this._currentUser.set(res?.user || null);
|
||||
}),
|
||||
map(res => res.user as AuthUser)
|
||||
);
|
||||
}
|
||||
|
||||
register(username: string, password: string, email?: string): Observable<AuthUser> {
|
||||
const body = { username, password, email };
|
||||
return this.http.post<any>('/api/auth/register', body, { withCredentials: true }).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
if (!err || err.status === 0 || err.status === 404) {
|
||||
return this.http.post<any>('/proxy/api/auth/register', body, { withCredentials: true });
|
||||
}
|
||||
throw err;
|
||||
}),
|
||||
tap(res => {
|
||||
this._accessToken.set(res?.accessToken || null);
|
||||
this._currentUser.set(res?.user || null);
|
||||
}),
|
||||
map(res => res.user as AuthUser)
|
||||
);
|
||||
}
|
||||
|
||||
refresh(): Observable<boolean> {
|
||||
return this.http.post<any>('/api/auth/refresh', {}, { withCredentials: true }).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
if (!err || err.status === 0 || err.status === 404) {
|
||||
return this.http.post<any>('/proxy/api/auth/refresh', {}, { withCredentials: true });
|
||||
}
|
||||
throw err;
|
||||
}),
|
||||
tap(res => {
|
||||
const token = res?.accessToken || null;
|
||||
this._accessToken.set(token);
|
||||
}),
|
||||
map(() => true),
|
||||
catchError(() => {
|
||||
this._accessToken.set(null);
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
logout(allDevices = false): Observable<void> {
|
||||
const body = { allDevices };
|
||||
return this.http.post<void>('/api/auth/logout', body, { withCredentials: true }).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
if (!err || err.status === 0 || err.status === 404) {
|
||||
return this.http.post<void>('/proxy/api/auth/logout', body, { withCredentials: true });
|
||||
}
|
||||
throw err;
|
||||
}),
|
||||
tap(() => {
|
||||
this._accessToken.set(null);
|
||||
this._currentUser.set(null);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
listSessions(): Observable<SessionInfo[]> {
|
||||
return this.http.get<SessionInfo[]>('/api/auth/sessions', { withCredentials: true }).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
if (!err || err.status === 0 || err.status === 404) {
|
||||
return this.http.get<SessionInfo[]>('/proxy/api/auth/sessions', { withCredentials: true });
|
||||
}
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
revokeSession(id: string): Observable<void> {
|
||||
const url = `/api/auth/sessions/${encodeURIComponent(id)}`;
|
||||
return this.http.delete<void>(url, { withCredentials: true }).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
if (!err || err.status === 0 || err.status === 404) {
|
||||
return this.http.delete<void>(`/proxy/api/auth/sessions/${encodeURIComponent(id)}`, { withCredentials: true });
|
||||
}
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export interface DownloadFormat {
|
||||
id: string;
|
||||
resolution?: string;
|
||||
fps?: number;
|
||||
ext?: string;
|
||||
vcodec?: string;
|
||||
acodec?: string;
|
||||
sizeEstimate?: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type DownloadState = 'queued' | 'running' | 'merging' | 'completed' | 'failed';
|
||||
|
||||
export interface DownloadJob {
|
||||
id: string;
|
||||
userId: string;
|
||||
provider: string;
|
||||
videoId: string;
|
||||
state: DownloadState;
|
||||
progress: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
filePath?: string | null;
|
||||
fileExt?: string | null;
|
||||
fileSize?: number | null;
|
||||
error?: string | null;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DownloadService {
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
listFormats(
|
||||
provider: string,
|
||||
videoId: string,
|
||||
opts?: { instance?: string; slug?: string; sourceUrl?: string }
|
||||
): Observable<{ url: string; formats: DownloadFormat[]; title: string; duration: number }> {
|
||||
let params = new HttpParams();
|
||||
if (opts?.instance) params = params.set('instance', opts.instance);
|
||||
if (opts?.slug) params = params.set('slug', opts.slug);
|
||||
if (opts?.sourceUrl) params = params.set('sourceUrl', opts.sourceUrl);
|
||||
return this.http.get<{ url: string; formats: DownloadFormat[]; title: string; duration: number }>(
|
||||
`/proxy/api/download/${encodeURIComponent(provider)}/${encodeURIComponent(videoId)}/formats`,
|
||||
{ params, withCredentials: true }
|
||||
);
|
||||
}
|
||||
|
||||
startDownload(
|
||||
provider: string,
|
||||
videoId: string,
|
||||
body?: { formatId?: string; audioOnly?: boolean; sourceUrl?: string },
|
||||
opts?: { instance?: string; slug?: string }
|
||||
): Observable<{ jobId: string }> {
|
||||
let params = new HttpParams();
|
||||
if (opts?.instance) params = params.set('instance', opts.instance);
|
||||
if (opts?.slug) params = params.set('slug', opts.slug);
|
||||
return this.http.post<{ jobId: string }>(
|
||||
`/proxy/api/download/${encodeURIComponent(provider)}/${encodeURIComponent(videoId)}`,
|
||||
body || {},
|
||||
{ params, withCredentials: true }
|
||||
);
|
||||
}
|
||||
|
||||
getJob(id: string): Observable<DownloadJob> {
|
||||
return this.http.get<DownloadJob>(`/proxy/api/download/jobs/${encodeURIComponent(id)}`, { withCredentials: true });
|
||||
}
|
||||
|
||||
cancelJob(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`/proxy/api/download/jobs/${encodeURIComponent(id)}`, { withCredentials: true });
|
||||
}
|
||||
|
||||
fileUrl(id: string): string {
|
||||
return `/proxy/api/download/jobs/${encodeURIComponent(id)}/file`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { GoogleGenAI } from '@google/genai';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class GeminiService {
|
||||
private ai: GoogleGenAI;
|
||||
private readyFlag = false;
|
||||
private readinessMsg: string | undefined;
|
||||
|
||||
constructor() {
|
||||
const apiKey = this.resolveApiKey();
|
||||
if (!apiKey) {
|
||||
// This error will be caught in the console if the API key is missing.
|
||||
// The app continues to work – only comment summarization is disabled.
|
||||
console.warn('Gemini API key not found. Set GEMINI_API_KEY to enable summarization.');
|
||||
// Create a dummy object to avoid crashing if key is not set.
|
||||
this.ai = {} as GoogleGenAI;
|
||||
this.readyFlag = false;
|
||||
this.readinessMsg = 'La fonctionnalité de résumé IA est désactivée : configurez GEMINI_API_KEY dans assets/config.local.js puis rechargez la page.';
|
||||
return;
|
||||
}
|
||||
this.ai = new GoogleGenAI({ apiKey });
|
||||
this.readyFlag = true;
|
||||
this.readinessMsg = undefined;
|
||||
}
|
||||
|
||||
async summarizeComments(comments: string[]): Promise<string> {
|
||||
// If API key was not supplied, the client will be a dummy object.
|
||||
if (!(this.ai as any).models) {
|
||||
throw new Error(this.readinessMsg || 'Gemini API client is not initialized. Is the API key missing?');
|
||||
}
|
||||
|
||||
if (comments.length === 0) {
|
||||
return 'There are no comments to summarize.';
|
||||
}
|
||||
|
||||
const commentsText = comments.join('\n- ');
|
||||
const prompt = `Please summarize the following YouTube comments. Provide a concise, neutral overview of the general sentiment and the main topics discussed. Do not add any preamble or sign-off. Just provide the summary. Here are the comments:\n\n- ${commentsText}`;
|
||||
|
||||
try {
|
||||
const response = await (this.ai as any).models.generateContent({
|
||||
model: 'gemini-2.5-flash',
|
||||
contents: prompt,
|
||||
});
|
||||
return response.text;
|
||||
} catch (error) {
|
||||
console.error('Error summarizing comments:', error);
|
||||
throw new Error('Failed to generate summary from comments.');
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt to resolve the API key from multiple sources so the app works across tools/builds.
|
||||
private resolveApiKey(): string | undefined {
|
||||
// 1) Expose a global variable via <script> on window or globalThis
|
||||
const fromGlobal = (globalThis as any)?.GEMINI_API_KEY || (window as any)?.GEMINI_API_KEY;
|
||||
|
||||
// 2) Vite-style env (if present in a different toolchain)
|
||||
const fromImportMeta = (typeof import.meta !== 'undefined' && (import.meta as any).env)
|
||||
? ((import.meta as any).env.GEMINI_API_KEY || (import.meta as any).env.VITE_GEMINI_API_KEY)
|
||||
: undefined;
|
||||
|
||||
// 3) Node/bundler style replacement (rare in browser-only setup)
|
||||
const fromProcess = (globalThis as any)?.process?.env
|
||||
? ((globalThis as any).process.env.GEMINI_API_KEY || (globalThis as any).process.env.API_KEY)
|
||||
: undefined;
|
||||
|
||||
return fromGlobal || fromImportMeta || fromProcess || undefined;
|
||||
}
|
||||
|
||||
// Public readiness helpers for UI
|
||||
isReady(): boolean { return this.readyFlag; }
|
||||
readinessReason(): string | undefined { return this.readinessMsg; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export interface SearchHistoryItem {
|
||||
id: string;
|
||||
query: string;
|
||||
filters_json?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface WatchHistoryItem {
|
||||
id: string;
|
||||
provider: string;
|
||||
video_id: string;
|
||||
title?: string | null;
|
||||
watched_at: string;
|
||||
progress_seconds: number;
|
||||
duration_seconds: number;
|
||||
last_position_seconds: number;
|
||||
last_watched_at?: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class HistoryService {
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
// --- Search ---
|
||||
recordSearch(query: string, filters?: Record<string, any>): Observable<{ id: string; created_at: string }> {
|
||||
return this.http.post<{ id: string; created_at: string }>(
|
||||
'/proxy/api/user/history/search',
|
||||
{ query, filters },
|
||||
{ withCredentials: true }
|
||||
);
|
||||
}
|
||||
|
||||
getSearchHistory(limit = 50, before?: string): Observable<SearchHistoryItem[]> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', String(limit));
|
||||
if (before) params.set('before', before);
|
||||
return this.http.get<SearchHistoryItem[]>(`/proxy/api/user/history/search?${params.toString()}`, { withCredentials: true });
|
||||
}
|
||||
|
||||
// --- Watch ---
|
||||
recordWatchStart(provider: string, videoId: string, title?: string | null, watchedAt?: string): Observable<WatchHistoryItem> {
|
||||
return this.http.post<WatchHistoryItem>(
|
||||
'/proxy/api/user/history/watch',
|
||||
{ provider, videoId, title: title ?? null, watchedAt },
|
||||
{ withCredentials: true }
|
||||
);
|
||||
}
|
||||
|
||||
updateWatchProgress(id: string, progressSeconds: number, lastPositionSeconds?: number): Observable<WatchHistoryItem> {
|
||||
return this.http.patch<WatchHistoryItem>(
|
||||
`/proxy/api/user/history/watch/${encodeURIComponent(id)}`,
|
||||
{ progressSeconds, lastPositionSeconds },
|
||||
{ withCredentials: true }
|
||||
);
|
||||
}
|
||||
|
||||
getWatchHistory(limit = 50, before?: string): Observable<WatchHistoryItem[]> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', String(limit));
|
||||
if (before) params.set('before', before);
|
||||
return this.http.get<WatchHistoryItem[]>(`/proxy/api/user/history/watch?${params.toString()}`, { withCredentials: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
|
||||
export type LangCode = 'en' | 'fr';
|
||||
|
||||
// Simple key-value dictionaries. Extend as needed.
|
||||
const EN: Record<string, string> = {
|
||||
'app.name': 'NewTube',
|
||||
// Header
|
||||
'search.placeholder': 'Search...',
|
||||
'label.provider': 'Provider',
|
||||
'label.region': 'Region',
|
||||
// Regions
|
||||
'region.US': 'United States',
|
||||
'region.FR': 'France',
|
||||
'region.BR': 'Brazil',
|
||||
'region.DE': 'Germany',
|
||||
'region.GB': 'United Kingdom',
|
||||
'region.CA': 'Canada',
|
||||
'btn.edit': 'Edit',
|
||||
'btn.login': 'Login',
|
||||
'btn.register': 'Register',
|
||||
'menu.account': 'Account',
|
||||
'menu.preferences': 'Preferences',
|
||||
'menu.appearance': 'Appearance',
|
||||
'menu.logout': 'Logout',
|
||||
'theme.black': 'Black',
|
||||
'theme.dark': 'Dark',
|
||||
'theme.blue': 'Blue',
|
||||
'theme.light': 'Light',
|
||||
// Preferences
|
||||
'preferences.title': 'Preferences',
|
||||
'preferences.general': 'General',
|
||||
'preferences.general_hint': 'Customize your experience',
|
||||
'preferences.peertube_hint': 'Manage your PeerTube instances',
|
||||
'preferences.add_instance_placeholder': 'e.g. example.org',
|
||||
'preferences.no_instances': 'No instances yet.',
|
||||
'preferences.active': 'Active',
|
||||
'preferences.set_active': 'Set Active',
|
||||
'label.language': 'Language',
|
||||
'label.defaultProvider': 'Default Provider',
|
||||
'label.theme': 'Theme',
|
||||
'label.quality': 'Default Quality',
|
||||
'btn.save': 'Save',
|
||||
'btn.add': 'Add',
|
||||
'btn.remove': 'Remove',
|
||||
// Sidebar
|
||||
'nav.home': 'Home',
|
||||
'nav.shorts': 'Shorts',
|
||||
'nav.subscriptions': 'Subscriptions',
|
||||
'nav.you': 'You',
|
||||
'nav.history': 'History',
|
||||
'nav.playlists': 'Playlists',
|
||||
'nav.liked': 'Liked videos',
|
||||
'nav.explore': 'Explore',
|
||||
'explore.music': 'Music',
|
||||
'explore.gaming': 'Gaming',
|
||||
'explore.news': 'News',
|
||||
'explore.sports': 'Sports',
|
||||
'explore.learning': 'Learning',
|
||||
// Home
|
||||
'home.trending': 'Trending',
|
||||
'loading.more': 'Loading more...',
|
||||
'search.hint': 'Type something in the search bar to get started.',
|
||||
'search.noResults': 'No results found.',
|
||||
// Themes + Provider/Theme pages
|
||||
'themes.viewAll': 'View All',
|
||||
'themes.live': 'Live Streams',
|
||||
'themes.recorded': 'Recorded Streams',
|
||||
'themes.videos': 'Videos',
|
||||
'themes.categories': 'Categories',
|
||||
'themes.backToThemes': 'Back to themes',
|
||||
'themes.changeProvider': 'Change provider',
|
||||
// Common
|
||||
'common.error': 'An error occurred.',
|
||||
'empty.noItems': 'No items.',
|
||||
'common.more': 'More',
|
||||
'common.less': 'Less',
|
||||
// Filters
|
||||
'filter.sort': 'Sort by',
|
||||
'filter.sort.recent': 'Most recent',
|
||||
'filter.sort.viewed': 'Most viewed',
|
||||
'filter.sort.longest': 'Longest',
|
||||
'filter.duration': 'Duration',
|
||||
'filter.duration.any': 'Any',
|
||||
'filter.duration.short': 'Short (<10m)',
|
||||
'filter.duration.medium': 'Medium (10–30m)',
|
||||
'filter.duration.long': 'Long (30m+)',
|
||||
'filter.language': 'Language',
|
||||
'filter.date': 'Date',
|
||||
// Optional per-theme labels
|
||||
'theme.finance-crypto': 'Finance & Crypto',
|
||||
'theme.gaming': 'Gaming',
|
||||
'theme.news': 'News',
|
||||
'theme.sports': 'Sports',
|
||||
};
|
||||
|
||||
const FR: Record<string, string> = {
|
||||
'app.name': 'NewTube',
|
||||
// Header
|
||||
'search.placeholder': 'Rechercher...',
|
||||
'label.provider': 'Fournisseur',
|
||||
'label.region': 'Région',
|
||||
// Régions
|
||||
'region.US': 'États-Unis',
|
||||
'region.FR': 'France',
|
||||
'region.BR': 'Brésil',
|
||||
'region.DE': 'Allemagne',
|
||||
'region.GB': 'Royaume-Uni',
|
||||
'region.CA': 'Canada',
|
||||
'btn.edit': 'Modifier',
|
||||
'btn.login': 'Connexion',
|
||||
'btn.register': 'Inscription',
|
||||
'menu.account': 'Compte',
|
||||
'menu.preferences': 'Préférences',
|
||||
'menu.appearance': 'Apparence',
|
||||
'menu.logout': 'Déconnexion',
|
||||
'theme.black': 'Noir',
|
||||
'theme.dark': 'Sombre',
|
||||
'theme.blue': 'Bleu',
|
||||
'theme.light': 'Clair',
|
||||
// Preferences
|
||||
'preferences.title': 'Préférences',
|
||||
'preferences.general': 'Préférences générales',
|
||||
'preferences.general_hint': 'Personnalisez votre expérience',
|
||||
'preferences.peertube_hint': 'Gérez vos instances PeerTube',
|
||||
'preferences.add_instance_placeholder': 'ex: example.org',
|
||||
'preferences.no_instances': 'Aucune instance pour le moment.',
|
||||
'preferences.active': 'Actif',
|
||||
'preferences.set_active': 'Définir actif',
|
||||
'label.language': 'Langue',
|
||||
'label.defaultProvider': 'Fournisseur par défaut',
|
||||
'label.theme': 'Thème',
|
||||
'label.quality': 'Qualité par défaut',
|
||||
'btn.save': 'Enregistrer',
|
||||
'btn.add': 'Ajouter',
|
||||
'btn.remove': 'Supprimer',
|
||||
// Sidebar
|
||||
'nav.home': 'Accueil',
|
||||
'nav.shorts': 'Shorts',
|
||||
'nav.subscriptions': 'Abonnements',
|
||||
'nav.you': 'Vous',
|
||||
'nav.history': 'Historique',
|
||||
'nav.playlists': 'Listes de lecture',
|
||||
'nav.liked': 'Vidéos que vous aimez',
|
||||
'nav.explore': 'Explorer',
|
||||
'explore.music': 'Musique',
|
||||
'explore.gaming': 'Jeux vidéo',
|
||||
'explore.news': 'Actualités',
|
||||
'explore.sports': 'Sports',
|
||||
'explore.learning': 'Apprentissage',
|
||||
// Home
|
||||
'home.trending': 'Tendances',
|
||||
'loading.more': 'Chargement...',
|
||||
'search.hint': 'Tapez quelque chose dans la barre de recherche pour commencer.',
|
||||
'search.noResults': 'Aucun résultat.',
|
||||
// Themes + Provider/Theme pages
|
||||
'themes.viewAll': 'Voir tout',
|
||||
'themes.live': 'En direct',
|
||||
'themes.recorded': 'Rediffusions',
|
||||
'themes.videos': 'Vidéos',
|
||||
'themes.categories': 'Catégories',
|
||||
'themes.backToThemes': 'Retour aux thèmes',
|
||||
'themes.changeProvider': 'Changer de provider',
|
||||
// Common
|
||||
'common.error': 'Une erreur est survenue.',
|
||||
'empty.noItems': 'Aucun élément.',
|
||||
'common.more': 'Plus',
|
||||
'common.less': 'Moins',
|
||||
// Filters
|
||||
'filter.sort': 'Tri',
|
||||
'filter.sort.recent': 'Plus récents',
|
||||
'filter.sort.viewed': 'Plus vus',
|
||||
'filter.sort.longest': 'Plus longs',
|
||||
'filter.duration': 'Durée',
|
||||
'filter.duration.any': 'Toute',
|
||||
'filter.duration.short': 'Courte (<10m)',
|
||||
'filter.duration.medium': 'Moyenne (10–30m)',
|
||||
'filter.duration.long': 'Longue (30m+)',
|
||||
'filter.language': 'Langue',
|
||||
'filter.date': 'Date',
|
||||
// Optional per-theme labels
|
||||
'theme.finance-crypto': 'Finance & Crypto',
|
||||
'theme.gaming': 'Jeux vidéo',
|
||||
'theme.news': 'Actualités',
|
||||
'theme.sports': 'Sports',
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class I18nService {
|
||||
private readonly LS_KEY = 'newtube.lang';
|
||||
private readonly dictionaries: Record<string, Record<string, string>> = { en: EN, fr: FR };
|
||||
private readonly defaultLang: LangCode = 'en';
|
||||
|
||||
// initialize from localStorage, fallback 'en'
|
||||
lang = signal<LangCode>((() => {
|
||||
try {
|
||||
const ls = (localStorage.getItem(this.LS_KEY) || '').toLowerCase();
|
||||
return (ls === 'fr' ? 'fr' : 'en') as LangCode;
|
||||
} catch {
|
||||
return 'en';
|
||||
}
|
||||
})());
|
||||
|
||||
readonly currentDict = computed(() => this.dictionaries[this.lang()] || this.dictionaries[this.defaultLang]);
|
||||
|
||||
constructor() {
|
||||
try {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.setAttribute('lang', this.lang());
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
setLanguage(l: string | null | undefined) {
|
||||
const v = (String(l || '').toLowerCase() === 'fr') ? 'fr' : 'en';
|
||||
this.lang.set(v as LangCode);
|
||||
try {
|
||||
localStorage.setItem(this.LS_KEY, v);
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.setAttribute('lang', v);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
t(key: string, params?: Record<string, any>): string {
|
||||
const dict = this.currentDict();
|
||||
const base = dict[key] ?? this.dictionaries[this.defaultLang][key] ?? key;
|
||||
if (params) {
|
||||
return Object.keys(params).reduce((acc, k) => acc.replace(new RegExp('{' + k + '}', 'g'), String(params[k])), base);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
|
||||
const LS_REGION_KEY = 'newtube.region';
|
||||
const LS_PROVIDER_KEY = 'newtube.provider';
|
||||
const LS_PEERTUBE_INSTANCES_KEY = 'newtube.peertube.instances';
|
||||
const LS_PEERTUBE_ACTIVE_INSTANCE_KEY = 'newtube.peertube.active_instance';
|
||||
|
||||
export type Provider = 'youtube' | 'dailymotion' | 'twitch' | 'peertube' | 'rumble' | 'odysee';
|
||||
|
||||
export interface ProviderInfo {
|
||||
id: Provider;
|
||||
label: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class InstanceService {
|
||||
readonly providers = signal<ProviderInfo[]>([
|
||||
{ id: 'youtube', label: 'YouTube' },
|
||||
{ id: 'dailymotion', label: 'Dailymotion' },
|
||||
{ id: 'twitch', label: 'Twitch' },
|
||||
{ id: 'peertube', label: 'PeerTube' },
|
||||
{ id: 'rumble', label: 'Rumble' },
|
||||
{ id: 'odysee', label: 'Odysee' },
|
||||
]);
|
||||
|
||||
private storedRegion = ((): string | null => {
|
||||
try { return localStorage.getItem(LS_REGION_KEY); } catch { return null; }
|
||||
})();
|
||||
private storedProvider = ((): Provider | null => {
|
||||
try { return (localStorage.getItem(LS_PROVIDER_KEY) as Provider) || null; } catch { return null; }
|
||||
})();
|
||||
|
||||
readonly region = signal<string>(this.storedRegion || 'US');
|
||||
readonly selectedProvider = signal<Provider>(this.storedProvider || 'youtube');
|
||||
|
||||
readonly selectedProviderLabel = computed(() => {
|
||||
const cur = this.selectedProvider();
|
||||
return this.providers().find(p => p.id === cur)?.label || cur;
|
||||
});
|
||||
|
||||
// Centralized provider readiness check to avoid unnecessary requests
|
||||
// and provide user-facing reasons when configuration is missing.
|
||||
getProviderReadiness(provider?: Provider): { ready: boolean; reason?: string } {
|
||||
const p = provider || this.selectedProvider();
|
||||
const g: any = globalThis as any;
|
||||
|
||||
if (p === 'youtube') {
|
||||
const hasKey = !!(
|
||||
g?.YOUTUBE_API_KEY ||
|
||||
g?.process?.env?.YOUTUBE_API_KEY ||
|
||||
((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)
|
||||
);
|
||||
if (!hasKey) {
|
||||
return { ready: false, reason: 'YouTube is selected but YOUTUBE_API_KEY is missing. Create assets/config.local.js with window.YOUTUBE_API_KEY = "..." and reload.' };
|
||||
}
|
||||
return { ready: true };
|
||||
}
|
||||
|
||||
if (p === 'twitch') {
|
||||
const im: any = (typeof import.meta !== 'undefined' && (import.meta as any).env) ? (import.meta as any).env : undefined;
|
||||
const hasClientId = !!(g?.TWITCH_CLIENT_ID || im?.TWITCH_CLIENT_ID || g?.process?.env?.TWITCH_CLIENT_ID);
|
||||
const hasSecret = !!(g?.TWITCH_CLIENT_SECRET || im?.TWITCH_CLIENT_SECRET || g?.process?.env?.TWITCH_CLIENT_SECRET);
|
||||
const hasCreds = hasClientId && hasSecret;
|
||||
if (!hasCreds) {
|
||||
return { ready: false, reason: 'Twitch requires TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET in assets/config.local.js.' };
|
||||
}
|
||||
return { ready: true };
|
||||
}
|
||||
|
||||
if (p === 'peertube') {
|
||||
const inst = this.activePeerTubeInstance();
|
||||
if (!inst) {
|
||||
return { ready: false, reason: 'No active PeerTube instance selected. Choose one in the header.' };
|
||||
}
|
||||
return { ready: true };
|
||||
}
|
||||
|
||||
if (p === 'rumble') {
|
||||
// Rumble utilise maintenant le web scraping, pas d'API key requise
|
||||
return { ready: true };
|
||||
}
|
||||
|
||||
if (p === 'odysee') {
|
||||
// Pas de clé requise pour Odysee via l’API proxy.
|
||||
return { ready: true };
|
||||
}
|
||||
|
||||
// Dailymotion or others: assume ready
|
||||
return { ready: true };
|
||||
}
|
||||
|
||||
setRegion(region: string): void {
|
||||
this.region.set(region);
|
||||
try { localStorage.setItem(LS_REGION_KEY, region); } catch {}
|
||||
}
|
||||
|
||||
setSelectedProvider(provider: Provider): void {
|
||||
this.selectedProvider.set(provider);
|
||||
try { localStorage.setItem(LS_PROVIDER_KEY, provider); } catch {}
|
||||
}
|
||||
|
||||
// PeerTube specific instance management
|
||||
private defaultPeerTubeInstances = ['video.manu.quebec', 'peerate.fr', 'mytube.pyramix.ca'];
|
||||
private storedPeerTubeInstances = ((): string[] => {
|
||||
try { return JSON.parse(localStorage.getItem(LS_PEERTUBE_INSTANCES_KEY) || 'null') || this.defaultPeerTubeInstances; } catch { return this.defaultPeerTubeInstances; }
|
||||
})();
|
||||
private storedActivePeerTubeInstance = ((): string | null => {
|
||||
try { return localStorage.getItem(LS_PEERTUBE_ACTIVE_INSTANCE_KEY); } catch { return null; }
|
||||
})();
|
||||
|
||||
readonly peerTubeInstances = signal<string[]>(this.storedPeerTubeInstances);
|
||||
readonly activePeerTubeInstance = signal<string>(this.storedActivePeerTubeInstance || this.peerTubeInstances()[0]);
|
||||
|
||||
setPeerTubeInstances(instances: string[]): void {
|
||||
this.peerTubeInstances.set(instances);
|
||||
try { localStorage.setItem(LS_PEERTUBE_INSTANCES_KEY, JSON.stringify(instances)); } catch {}
|
||||
}
|
||||
|
||||
setActivePeerTubeInstance(instance: string): void {
|
||||
this.activePeerTubeInstance.set(instance);
|
||||
try { localStorage.setItem(LS_PEERTUBE_ACTIVE_INSTANCE_KEY, instance); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
import { ThemeDef, THEMES } from '../models/theme.model';
|
||||
import { I18nService } from './i18n.service';
|
||||
|
||||
export interface ThemeCategory {
|
||||
key: string;
|
||||
label: string;
|
||||
emoji?: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ThemesService {
|
||||
private readonly list = signal<ThemeDef[]>(THEMES);
|
||||
// Active theme slug derived from router (set externally by the theme bar)
|
||||
readonly activeSlug = signal<string>('trending');
|
||||
// Controls whether the themes nav should show all themes or only a subset (e.g., 8)
|
||||
readonly showAll = signal<boolean>(false);
|
||||
|
||||
constructor(private i18n: I18nService) {}
|
||||
|
||||
themes() { return this.list(); }
|
||||
|
||||
setShowAll(v: boolean) { this.showAll.set(!!v); }
|
||||
toggleShowAll() { this.showAll.update(x => !x); }
|
||||
|
||||
bySlug(slug: string | null | undefined): ThemeDef | undefined {
|
||||
if (!slug) return undefined;
|
||||
const s = String(slug).toLowerCase();
|
||||
return this.list().find(t => t.slug === s);
|
||||
}
|
||||
|
||||
label(t: ThemeDef): string {
|
||||
// Full label including emoji
|
||||
const v = this.i18nLabel(t);
|
||||
return `${t.emoji} ${v}`;
|
||||
}
|
||||
|
||||
// Label without emoji, i18n-aware
|
||||
i18nLabel(t: ThemeDef): string {
|
||||
const key = `theme.${t.slug}`;
|
||||
const v = this.i18n.t(key);
|
||||
return v && v !== key ? v : t.label;
|
||||
}
|
||||
|
||||
tokensFor(slug: string): string[] {
|
||||
const t = this.bySlug(slug);
|
||||
if (!t) return [];
|
||||
const base = t.tokens && t.tokens.length ? t.tokens : [t.label];
|
||||
return base;
|
||||
}
|
||||
|
||||
// Best-effort suggested categories per theme (static for now)
|
||||
categoriesFor(slug: string): ThemeCategory[] {
|
||||
switch (slug) {
|
||||
case 'finance-crypto':
|
||||
return [
|
||||
{ key: 'stock-market', label: 'Stock Market', emoji: '📈' },
|
||||
{ key: 'crypto', label: 'Crypto', emoji: '🪙' },
|
||||
{ key: 'economy', label: 'Economy', emoji: '🏦' },
|
||||
];
|
||||
case 'gaming':
|
||||
return [
|
||||
{ key: 'esports', label: 'Esports', emoji: '🎮' },
|
||||
{ key: 'retro', label: 'Retro', emoji: '🕹️' },
|
||||
{ key: 'fps', label: 'FPS', emoji: '🎯' },
|
||||
];
|
||||
case 'news':
|
||||
return [
|
||||
{ key: 'politics', label: 'Politics', emoji: '🏛️' },
|
||||
{ key: 'world', label: 'World', emoji: '🌍' },
|
||||
{ key: 'tech', label: 'Tech', emoji: '🧠' },
|
||||
];
|
||||
default:
|
||||
return [
|
||||
{ key: 'featured', label: 'Featured', emoji: '⭐' },
|
||||
{ key: 'popular', label: 'Popular', emoji: '🔥' },
|
||||
{ key: 'recent', label: 'Recent', emoji: '🕒' },
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import type { AuthUser } from './auth.service';
|
||||
|
||||
export interface UserPreferences {
|
||||
language?: string | null;
|
||||
defaultProvider?: string | null;
|
||||
theme?: 'light' | 'dark' | 'black' | 'system' | string | null;
|
||||
videoQuality?: string | null;
|
||||
region?: string | null;
|
||||
version?: number | null;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UserService {
|
||||
private _preferences = signal<UserPreferences | null>(null);
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
preferences() {
|
||||
return this._preferences();
|
||||
}
|
||||
|
||||
loadPreferences(): Observable<UserPreferences> {
|
||||
return this.http.get<UserPreferences>('/proxy/api/user/preferences', { withCredentials: true }).pipe(
|
||||
tap(p => this._preferences.set(p || {}))
|
||||
);
|
||||
}
|
||||
|
||||
updatePreferences(patch: Partial<UserPreferences>): Observable<UserPreferences> {
|
||||
return this.http.patch<UserPreferences>('/proxy/api/user/preferences', patch, { withCredentials: true }).pipe(
|
||||
tap(p => this._preferences.set(p || {}))
|
||||
);
|
||||
}
|
||||
|
||||
loadMe(): Observable<AuthUser> {
|
||||
return this.http.get<AuthUser>('/proxy/api/user/me', { withCredentials: true });
|
||||
}
|
||||
}
|
||||
@@ -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 }>))
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user