feat(channel): page details chaine multi-providers avec onglets et infinite scroll
CI / build-and-test (push) Successful in 13m34s

- Strategy Pattern front: ChannelProviderInterface + 6 providers (yt/dm/tw/pt/od/ru) + factory (ids longs/courts)
- ChannelContentService: cache par onglet/tri/recherche (TTL 5min), pagination nextPage
- ChannelPage: header (banniere/avatar/subs/description ...plus), tabs dynamiques par capabilities, grilles videos/shorts 9:16/playlists/live, recherche debounced, tri recent/populaire, infinite scroll IntersectionObserver, skeletons/empty/error
- Backend: GET /api/channels/:provider/:id/content (videos/shorts/playlists/live) natif par provider
This commit is contained in:
2026-09-25 10:15:12 -04:00
parent fe35340948
commit 97e5de74f5
147 changed files with 7954 additions and 77497 deletions
+40
View File
@@ -44,6 +44,46 @@ export class AuthService {
this._accessToken.set(token);
}
/**
* Restaure la session au démarrage de l'app (F5) : refresh via cookies
* httpOnly puis chargement du profil. Échoue silencieusement si aucune
* session (cookies absents/expirés) : l'utilisateur reste déconnecté.
*/
initSession(): Observable<boolean> {
if (this._currentUser() && this._accessToken()) return of(true);
return this.refresh().pipe(
map((ok) => {
if (!ok || !this._accessToken()) {
this._currentUser.set(null);
return false;
}
return true;
}),
catchError(() => {
this._currentUser.set(null);
return of(false);
})
);
}
/** Charge /user/me avec fallback dev/prod, sans jamais lever. */
fetchMe(): Observable<AuthUser | null> {
return this.http.get<AuthUser>('/api/user/me', { withCredentials: true }).pipe(
catchError((err: HttpErrorResponse) => {
if (!err || err.status === 0 || err.status === 404) {
return this.http.get<AuthUser>('/proxy/api/user/me', { withCredentials: true });
}
throw err;
}),
map((u) => u || null),
tap((u) => this._currentUser.set(u)),
catchError(() => {
this._currentUser.set(null);
return of(null);
})
);
}
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(
+149
View File
@@ -0,0 +1,149 @@
import { Injectable, inject, signal } from '@angular/core';
import { Observable, catchError, finalize, map, of, tap } from 'rxjs';
import type { ProviderId } from '../app/shared/models/channel.model';
import type {
ChannelContentPage,
ChannelContentType,
ChannelPlaylist,
ChannelSort,
} from '../app/shared/models/channel-detail.model';
import type { VideoItem } from '../app/shared/models/video-item.model';
import { ChannelProviderFactory } from '../app/core/providers/channel/channel-provider.factory';
export interface ChannelListState<T> {
items: T[];
nextPage: number | null;
total: number | null;
loading: boolean;
loadingMore: boolean;
error: string | null;
fetchedAt: number | null;
}
function emptyState<T>(): ChannelListState<T> {
return { items: [], nextPage: null, total: null, loading: false, loadingMore: false, error: null, fetchedAt: null };
}
const TTL_MS = 5 * 60 * 1000; // 5 min pour les listes (meta reste 6h dans ChannelsService)
const PAGE_SIZE = 24;
/**
* Cache mémoire par (provider, channelId, type, sort, q).
* Évite de re-fetcher à chaque retour sur la page / changement d'onglet.
*/
@Injectable({ providedIn: 'root' })
export class ChannelContentService {
private factory = inject(ChannelProviderFactory);
private states = signal(new Map<string, ChannelListState<any>>());
private key(provider: ProviderId, channelId: string, type: ChannelContentType, sort: ChannelSort, q: string): string {
return `${provider}::${channelId}::${type}::${sort}::${q.trim().toLowerCase()}`;
}
snapshot<T>(provider: ProviderId, channelId: string, type: ChannelContentType, sort: ChannelSort, q = ''): ChannelListState<T> {
return (this.states().get(this.key(provider, channelId, type, sort, q)) as ChannelListState<T>) ?? emptyState<T>();
}
private patch(key: string, partial: Partial<ChannelListState<any>>): void {
const next = new Map(this.states());
next.set(key, { ...(next.get(key) ?? emptyState()), ...partial });
this.states.set(next);
}
private isFresh<T>(s: ChannelListState<T>): boolean {
return Boolean(s.fetchedAt && Date.now() - s.fetchedAt < TTL_MS && s.items.length > 0);
}
loadVideos(provider: ProviderId, channelId: string, opts?: { sort?: ChannelSort; q?: string; refresh?: boolean }): Observable<VideoItem[]> {
return this.loadList<VideoItem>(provider, channelId, 'videos', opts);
}
loadShorts(provider: ProviderId, channelId: string, opts?: { sort?: ChannelSort; q?: string; refresh?: boolean }): Observable<VideoItem[]> {
return this.loadList<VideoItem>(provider, channelId, 'shorts', opts);
}
loadStreams(provider: ProviderId, channelId: string, opts?: { q?: string; refresh?: boolean }): Observable<VideoItem[]> {
return this.loadList<VideoItem>(provider, channelId, 'live', { ...opts, sort: 'recent' });
}
loadPlaylists(provider: ProviderId, channelId: string, opts?: { q?: string; refresh?: boolean }): Observable<ChannelPlaylist[]> {
return this.loadList<ChannelPlaylist>(provider, channelId, 'playlists', { ...opts, sort: 'recent' });
}
loadMore(provider: ProviderId, channelId: string, type: ChannelContentType, opts?: { sort?: ChannelSort; q?: string }): Observable<boolean> {
const sort = opts?.sort ?? 'recent';
const q = opts?.q ?? '';
const key = this.key(provider, channelId, type, sort, q);
const current = (this.states().get(key) as ChannelListState<any>) ?? emptyState();
if (current.loadingMore || current.loading || current.nextPage == null) return of(false);
this.patch(key, { loadingMore: true, error: null });
return this.fetchPage(provider, channelId, type, current.nextPage, sort, q).pipe(
tap((page) => {
const seen = new Set(current.items.map((i: any) => i?.id ?? i?.title));
const merged = [...current.items];
for (const item of page.items) {
const id = (item as any)?.id ?? (item as any)?.title;
if (!seen.has(id)) { seen.add(id); merged.push(item); }
}
this.patch(key, { items: merged, nextPage: page.nextPage, total: page.total ?? current.total, fetchedAt: Date.now() });
}),
map(() => true),
catchError(() => {
this.patch(key, { error: 'channel_content_failed' });
return of(false);
}),
finalize(() => this.patch(key, { loadingMore: false })),
);
}
clear(provider: ProviderId, channelId: string): void {
const prefix = `${provider}::${channelId}::`;
const next = new Map(this.states());
for (const k of [...next.keys()]) if (k.startsWith(prefix)) next.delete(k);
this.states.set(next);
}
private loadList<T>(provider: ProviderId, channelId: string, type: ChannelContentType, opts?: { sort?: ChannelSort; q?: string; refresh?: boolean }): Observable<T[]> {
const sort = opts?.sort ?? 'recent';
const q = opts?.q ?? '';
const key = this.key(provider, channelId, type, sort, q);
const current = (this.states().get(key) as ChannelListState<T>) ?? emptyState<T>();
if (!opts?.refresh && (current.loading || this.isFresh(current))) return of(current.items);
this.patch(key, { loading: true, error: null });
return this.fetchPage(provider, channelId, type, 1, sort, q).pipe(
tap((page) => this.patch(key, { items: page.items as T[], nextPage: page.nextPage, total: page.total ?? null, fetchedAt: Date.now() })),
map((page) => page.items as T[]),
catchError(() => {
this.patch(key, { error: 'channel_content_failed' });
return of(current.items);
}),
finalize(() => this.patch(key, { loading: false })),
);
}
private fetchPage(
provider: ProviderId,
channelId: string,
type: ChannelContentType,
page: number,
sort: ChannelSort,
q: string,
): Observable<ChannelContentPage<any>> {
const p = this.factory.get(provider);
const query = { page, limit: PAGE_SIZE, sort, q: q || undefined };
if (type === 'videos') {
if (q && p.searchInChannel) return p.searchInChannel(channelId, q, query);
return p.getChannelVideos(channelId, query);
}
if (type === 'shorts') {
const fn = p.getChannelShorts?.bind(p);
return fn ? fn(channelId, query) : of({ items: [], nextPage: null });
}
if (type === 'playlists') {
const fn = p.getChannelPlaylists?.bind(p);
return fn ? fn(channelId, query) : of({ items: [], nextPage: null });
}
const fn = p.getChannelStreams?.bind(p);
return fn ? fn(channelId, query) : of({ items: [], nextPage: null });
}
}
+94
View File
@@ -0,0 +1,94 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { catchError, map, shareReplay, tap } from 'rxjs/operators';
import type { ChannelMeta, ProviderId } from '../app/shared/models/channel.model';
interface CacheEntry {
meta: ChannelMeta;
fetchedAt: number;
}
@Injectable({ providedIn: 'root' })
export class ChannelsService {
private http = inject(HttpClient);
private cache = signal(new Map<string, CacheEntry>());
private readonly ttlMs = 6 * 60 * 60 * 1000; // 6h
private key(provider: ProviderId, externalId: string): string {
return `${provider}::${externalId}`;
}
private upsertCache(provider: ProviderId, externalId: string, meta: ChannelMeta): void {
const next = new Map(this.cache());
next.set(this.key(provider, externalId), { meta, fetchedAt: Date.now() });
this.cache.set(next);
}
private getFreshMeta(provider: ProviderId, externalId: string): ChannelMeta | null {
const key = this.key(provider, externalId);
const entry = this.cache().get(key);
if (!entry) return null;
if ((Date.now() - entry.fetchedAt) > this.ttlMs) {
const next = new Map(this.cache());
next.delete(key);
this.cache.set(next);
return null;
}
return entry.meta;
}
readonly snapshot = computed(() => this.cache());
get(provider: ProviderId, externalId: string, opts?: { refresh?: boolean }): Observable<ChannelMeta | null> {
const normalizedExtId = String(externalId || '').trim();
if (!normalizedExtId) return of(null);
const cachedMeta = this.getFreshMeta(provider, normalizedExtId);
if (cachedMeta && !opts?.refresh) {
return of(cachedMeta);
}
let params = new HttpParams();
if (opts?.refresh) params = params.set('refresh', '1');
return this.http
.get<ChannelMeta>(`/api/channels/${provider}/${encodeURIComponent(normalizedExtId)}`, { params })
.pipe(
tap((meta) => {
if (meta) this.upsertCache(provider, normalizedExtId, meta);
}),
map((meta) => meta || cachedMeta || null),
catchError((err) => {
console.error('Failed to fetch channel meta', provider, normalizedExtId, err);
return cachedMeta ? of(cachedMeta) : of(null);
}),
shareReplay(1)
);
}
resolve(provider: ProviderId, externalId: string, opts?: { refresh?: boolean }): Observable<ChannelMeta | null> {
const normalizedExtId = String(externalId || '').trim();
if (!normalizedExtId) return of(null);
const body: any = { provider, externalId: normalizedExtId };
if (opts?.refresh) body.refresh = true;
return this.http.post<ChannelMeta>('/api/channels/resolve', body).pipe(
tap((meta) => {
if (meta) this.upsertCache(provider, normalizedExtId, meta);
}),
map((meta) => meta || this.getFreshMeta(provider, normalizedExtId)),
catchError((err) => {
console.error('Failed to resolve channel meta', provider, normalizedExtId, err);
const cachedMeta = this.getFreshMeta(provider, normalizedExtId);
return cachedMeta ? of(cachedMeta) : of(null);
})
);
}
channelSignal(provider: ProviderId, externalId: string) {
const key = this.key(provider, externalId);
return computed(() => this.cache().get(key)?.meta || null);
}
}
+59 -7
View File
@@ -1,6 +1,6 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
import { Observable, map } from 'rxjs';
export interface DownloadFormat {
id: string;
@@ -13,22 +13,36 @@ export interface DownloadFormat {
label: string;
}
export type DownloadState = 'queued' | 'running' | 'merging' | 'completed' | 'failed';
export type DownloadState = 'queued' | 'running' | 'merging' | 'completed' | 'failed' | 'interrupted';
export interface DownloadQuota {
usedBytes: number;
quotaBytes: number;
}
export interface DownloadJob {
id: string;
userId: string;
provider: string;
videoId: string;
title?: string | null;
state: DownloadState;
progress: number;
createdAt: string;
updatedAt: string;
filePath?: string | null;
formatId?: string | null;
audioOnly?: number | boolean;
url?: string | null;
fileName?: string | null;
fileExt?: string | null;
fileSize?: number | null;
error?: string | null;
url?: string;
createdAt: number | string;
updatedAt: number | string;
completedAt?: number | string | null;
}
export interface DownloadJobsResponse {
items: DownloadJob[];
quota?: DownloadQuota | null;
}
@Injectable({ providedIn: 'root' })
@@ -66,6 +80,22 @@ export class DownloadService {
);
}
listJobs(opts?: { limit?: number; offset?: number; state?: string }): Observable<DownloadJobsResponse> {
let params = new HttpParams();
if (opts?.limit != null) params = params.set('limit', String(opts.limit));
if (opts?.offset != null) params = params.set('offset', String(opts.offset));
if (opts?.state) params = params.set('state', opts.state);
return this.http.get<DownloadJobsResponse>('/proxy/api/download/jobs', { params, withCredentials: true });
}
retryJob(id: string): Observable<{ jobId: string }> {
return this.http.post<{ jobId: string }>(
`/proxy/api/download/jobs/${encodeURIComponent(id)}/retry`,
{},
{ withCredentials: true }
);
}
getJob(id: string): Observable<DownloadJob> {
return this.http.get<DownloadJob>(`/proxy/api/download/jobs/${encodeURIComponent(id)}`, { withCredentials: true });
}
@@ -77,4 +107,26 @@ export class DownloadService {
fileUrl(id: string): string {
return `/proxy/api/download/jobs/${encodeURIComponent(id)}/file`;
}
/**
* Télécharge le fichier via HttpClient (header Authorization joint par
* l'intercepteur) : fonctionne même quand les cookies de session ne sont
* pas envoyés (liens directs, <video>, http local sans Secure).
*/
downloadFileBlob(id: string): Observable<{ blob: Blob; filename: string }> {
return this.http
.get(this.fileUrl(id), { responseType: 'blob', observe: 'response', withCredentials: true })
.pipe(
map((res: HttpResponse<Blob>) => {
const blob = res.body as Blob;
const cd = res.headers.get('content-disposition') || '';
let filename = 'video';
try {
const m = /filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i.exec(cd);
filename = decodeURIComponent((m?.[1] || m?.[2] || 'video').trim());
} catch {}
return { blob, filename };
})
);
}
}
+32 -54
View File
@@ -1,76 +1,54 @@
import { Injectable } from '@angular/core';
import { GoogleGenAI } from '@google/genai';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class GeminiService {
private ai: GoogleGenAI;
private readyFlag = false;
private readinessMsg: string | undefined;
private statusChecked = false;
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;
constructor(private http: HttpClient) {
this.refreshStatus();
}
/**
* Query the server for AI availability. The GEMINI_API_KEY stays server-side.
*/
refreshStatus(): void {
this.http.get<{ ready: boolean; reason?: string }>('/api/ai/status').subscribe({
next: (s) => {
this.readyFlag = Boolean(s?.ready);
this.readinessMsg = s?.ready ? undefined : (s?.reason || 'Résumé IA indisponible (non configuré sur le serveur).');
this.statusChecked = true;
},
error: () => {
this.readyFlag = false;
this.readinessMsg = 'Résumé IA indisponible (serveur injoignable).';
this.statusChecked = true;
},
});
}
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.';
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.');
const res = await firstValueFrom(
this.http.post<{ summary: string }>('/api/ai/summarize', { comments })
);
return res?.summary || '';
} catch (error: any) {
console.error('Error summarizing comments:', error);
const details = error?.error?.error || error?.message;
throw new Error(typeof details === 'string' && details ? details : '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; }
}
+48
View File
@@ -51,6 +51,7 @@ const EN: Record<string, string> = {
'nav.history': 'History',
'nav.playlists': 'Playlists',
'nav.liked': 'Liked videos',
'nav.downloads': 'Downloads',
'nav.explore': 'Explore',
'explore.music': 'Music',
'explore.gaming': 'Gaming',
@@ -92,6 +93,29 @@ const EN: Record<string, string> = {
'theme.gaming': 'Gaming',
'theme.news': 'News',
'theme.sports': 'Sports',
// Downloads
'downloads.title': 'Downloads',
'downloads.refresh': 'Refresh',
'downloads.quota': 'Storage used',
'downloads.filter.all': 'All',
'downloads.filter.active': 'Active',
'downloads.filter.completed': 'Completed',
'downloads.filter.failed': 'Failed',
'downloads.empty': 'No downloads yet. Start one from a video page.',
'downloads.audioOnly': 'Audio only',
'downloads.getFile': 'Get file',
'downloads.play': 'Play',
'downloads.fileError': 'Unable to fetch the file. Please sign in again and retry.',
'downloads.back': 'Back to downloads',
'downloads.notReady': 'This download is not completed yet.',
'downloads.loadError': 'Unable to load this download.',
'downloads.retry': 'Retry',
'downloads.state.queued': 'Queued',
'downloads.state.running': 'Downloading',
'downloads.state.merging': 'Merging',
'downloads.state.completed': 'Completed',
'downloads.state.failed': 'Failed',
'downloads.state.interrupted': 'Interrupted',
};
const FR: Record<string, string> = {
@@ -142,6 +166,7 @@ const FR: Record<string, string> = {
'nav.history': 'Historique',
'nav.playlists': 'Listes de lecture',
'nav.liked': 'Vidéos que vous aimez',
'nav.downloads': 'Téléchargements',
'nav.explore': 'Explorer',
'explore.music': 'Musique',
'explore.gaming': 'Jeux vidéo',
@@ -183,6 +208,29 @@ const FR: Record<string, string> = {
'theme.gaming': 'Jeux vidéo',
'theme.news': 'Actualités',
'theme.sports': 'Sports',
// Downloads
'downloads.title': 'Téléchargements',
'downloads.refresh': 'Rafraîchir',
'downloads.quota': 'Stockage utilisé',
'downloads.filter.all': 'Tous',
'downloads.filter.active': 'Actifs',
'downloads.filter.completed': 'Terminés',
'downloads.filter.failed': 'Échoués',
'downloads.empty': 'Aucun téléchargement. Lancez-en un depuis une page vidéo.',
'downloads.audioOnly': 'Audio seul',
'downloads.getFile': 'Obtenir le fichier',
'downloads.play': 'Lire',
'downloads.fileError': 'Fichier inaccessible. Reconnectez-vous puis réessayez.',
'downloads.back': 'Retour aux téléchargements',
'downloads.notReady': 'Ce téléchargement n’est pas encore terminé.',
'downloads.loadError': 'Impossible de charger ce téléchargement.',
'downloads.retry': 'Réessayer',
'downloads.state.queued': 'En attente',
'downloads.state.running': 'Téléchargement',
'downloads.state.merging': 'Fusion',
'downloads.state.completed': 'Terminé',
'downloads.state.failed': 'Échoué',
'downloads.state.interrupted': 'Interrompu',
};
@Injectable({ providedIn: 'root' })
+16 -6
View File
@@ -1,4 +1,5 @@
import { Injectable, computed, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
const LS_REGION_KEY = 'newtube.region';
const LS_PROVIDER_KEY = 'newtube.provider';
@@ -23,6 +24,9 @@ export class InstanceService {
{ id: 'odysee', label: 'Odysee' },
]);
// Disponibilité Twitch connue via le serveur (le secret reste côté back)
private twitchReadySignal = signal<boolean | null>(null); // null = inconnu
private storedRegion = ((): string | null => {
try { return localStorage.getItem(LS_REGION_KEY); } catch { return null; }
})();
@@ -33,6 +37,14 @@ export class InstanceService {
readonly region = signal<string>(this.storedRegion || 'US');
readonly selectedProvider = signal<Provider>(this.storedProvider || 'youtube');
constructor(private http: HttpClient) {
// Demande au serveur si Twitch est configuré (TWITCH_CLIENT_ID/SECRET côté back)
this.http.get<any>('/api/twitch-token').subscribe({
next: (res) => this.twitchReadySignal.set(Boolean(res?.accessToken)),
error: () => this.twitchReadySignal.set(false),
});
}
readonly selectedProviderLabel = computed(() => {
const cur = this.selectedProvider();
return this.providers().find(p => p.id === cur)?.label || cur;
@@ -59,13 +71,11 @@ export class InstanceService {
}
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.' };
const status = this.twitchReadySignal();
if (status === false) {
return { ready: false, reason: 'Twitch requires TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET to be configured on the server (env).' };
}
// null = serveur pas encore répondu : on considère prêt (le token échouera proprement sinon)
return { ready: true };
}
+172
View File
@@ -0,0 +1,172 @@
import 'zone.js/node';
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { DOCUMENT } from '@angular/common';
import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing';
import { SubscriptionsService, type SubscriptionItem } from './subscriptions.service';
import type { ChannelMeta, ProviderId } from '../app/shared/models/channel.model';
const mockDocument = {
cookie: '',
createElement: () => ({
setAttribute() {},
}),
querySelector: () => null,
body: {
appendChild() {},
removeChild() {},
},
} as unknown as Document;
TestBed.resetTestEnvironment();
TestBed.initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), {
teardown: { destroyAfterEach: true, rethrowErrors: true },
});
function setup() {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
SubscriptionsService,
{ provide: DOCUMENT, useValue: mockDocument },
],
});
const service = TestBed.inject(SubscriptionsService);
const httpMock = TestBed.inject(HttpTestingController);
return { service, httpMock };
}
function assertEqual(actual: unknown, expected: unknown, message: string): void {
const a = JSON.stringify(actual);
const b = JSON.stringify(expected);
if (a !== b) {
throw new Error(`${message} (expected ${b}, got ${a})`);
}
}
function assert(condition: unknown, message: string): void {
if (!condition) {
throw new Error(message);
}
}
function makeChannel(provider: ProviderId, externalId: string, overrides: Partial<ChannelMeta> = {}): ChannelMeta {
return {
provider,
externalId,
id: overrides.id ?? 1,
title: overrides.title ?? 'Channel Title',
handle: overrides.handle ?? '@channel',
avatarUrl: overrides.avatarUrl ?? 'https://example.com/avatar.jpg',
url: overrides.url ?? 'https://example.com',
subsCount: overrides.subsCount ?? 1234,
verified: overrides.verified ?? true,
lastRefreshedAt: overrides.lastRefreshedAt ?? Date.now(),
};
}
function testListCachesResults(): void {
const { service, httpMock } = setup();
const channel = makeChannel('yt', 'abc');
const subscription: SubscriptionItem = {
subscriptionId: 101,
createdAt: Date.now(),
channel,
};
let received: SubscriptionItem[] | undefined;
service.list().subscribe(items => (received = items));
const req = httpMock.expectOne('/api/subscriptions');
assert(req.request.method === 'GET', 'list() should perform GET request');
req.flush({ items: [subscription], ttl: 600000 });
assertEqual(received, [subscription], 'list() should emit fetched subscriptions');
assertEqual(service.items(), [subscription], 'items signal should reflect fetched subscriptions');
httpMock.verify();
}
function testSubscribeAppendsToCache(): void {
const { service, httpMock } = setup();
const baseChannel = makeChannel('yt', 'abc');
const base: SubscriptionItem = {
subscriptionId: 1,
createdAt: Date.now() - 1000,
channel: baseChannel,
};
service.list().subscribe();
httpMock.expectOne('/api/subscriptions').flush({ items: [base] });
const newChannel = makeChannel('dm', 'xyz');
const newSubscription: SubscriptionItem = {
subscriptionId: 2,
createdAt: Date.now(),
channel: newChannel,
};
let subscribed: SubscriptionItem | null = null;
service.subscribe('dm', 'xyz').subscribe(result => (subscribed = result));
const req = httpMock.expectOne('/api/subscriptions');
assert(req.request.method === 'POST', 'subscribe() should perform POST request');
assertEqual(req.request.body, { provider: 'dm', externalId: 'xyz' }, 'subscribe() should send provider/externalId payload');
req.flush(newSubscription);
assertEqual(subscribed, newSubscription, 'subscribe() should emit created subscription');
assertEqual(
service.items().map(item => item.subscriptionId),
[newSubscription.subscriptionId, base.subscriptionId],
'subscribe() should prepend new subscription into cache'
);
httpMock.verify();
}
function testUnsubscribeRemovesFromCache(): void {
const { service, httpMock } = setup();
const channel = makeChannel('pt', 'foo');
const item: SubscriptionItem = {
subscriptionId: 44,
createdAt: Date.now(),
channel,
};
service.list().subscribe();
httpMock.expectOne('/api/subscriptions').flush({ items: [item] });
let result: boolean | null = null;
service.unsubscribe(item.subscriptionId).subscribe(ok => (result = ok));
const req = httpMock.expectOne(`/api/subscriptions/${item.subscriptionId}`);
assert(req.request.method === 'DELETE', 'unsubscribe() should perform DELETE request');
req.flush({}, { status: 204, statusText: 'No Content' });
assert(result === true, 'unsubscribe() should emit true when deletion succeeds');
assert(service.items().length === 0, 'unsubscribe() should remove subscription from cache');
httpMock.verify();
}
const tests: Array<[string, () => void]> = [
['list() caches fetched subscriptions', testListCachesResults],
['subscribe() appends to cache', testSubscribeAppendsToCache],
['unsubscribe() removes from cache', testUnsubscribeRemovesFromCache],
];
let failures = 0;
for (const [name, fn] of tests) {
try {
fn();
console.log(`✓ ${name}`);
} catch (error) {
failures += 1;
console.error(`✗ ${name}`);
console.error(error);
}
}
if (failures > 0) {
process.exitCode = 1;
}
+100
View File
@@ -0,0 +1,100 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { catchError, map, tap } from 'rxjs/operators';
import { Observable, of } from 'rxjs';
import type { ChannelMeta, ProviderId } from '../app/shared/models/channel.model';
export interface SubscriptionItem {
subscriptionId: number;
createdAt: number;
channel: ChannelMeta;
}
interface ListResponse {
items: SubscriptionItem[];
ttl?: number;
}
@Injectable({ providedIn: 'root' })
export class SubscriptionsService {
private http = inject(HttpClient);
private subscriptions = signal<SubscriptionItem[]>([]);
private ttlMs = signal<number | null>(null);
private loading = signal<boolean>(false);
private lastErrorMsg = signal<string | null>(null);
readonly items = computed(() => this.subscriptions());
readonly isLoading = computed(() => this.loading());
/** Dernière erreur d'abonnement/désabonnement (affichée par les pages). */
readonly lastError = computed(() => this.lastErrorMsg());
clearError(): void {
this.lastErrorMsg.set(null);
}
list(): Observable<SubscriptionItem[]> {
this.loading.set(true);
return this.http.get<ListResponse>('/api/subscriptions').pipe(
tap((resp) => {
const items = (resp.items || []).filter((item): item is SubscriptionItem => Boolean(item?.channel));
this.subscriptions.set(items);
this.ttlMs.set(typeof resp.ttl === 'number' ? resp.ttl : null);
this.loading.set(false);
}),
map((resp) => resp.items || []),
catchError((err) => {
console.error('Failed to load subscriptions', err);
this.loading.set(false);
return of([]);
})
);
}
subscribe(provider: ProviderId, externalId: string, opts?: { refresh?: boolean }): Observable<SubscriptionItem | null> {
const body: any = { provider, externalId };
if (opts?.refresh) body.refresh = true;
this.lastErrorMsg.set(null);
return this.http.post<SubscriptionItem>('/api/subscriptions', body).pipe(
tap((sub) => {
if (!sub || !sub.channel) return;
const current = this.subscriptions();
const exists = current.some((item) => item.subscriptionId === sub.subscriptionId);
if (!exists) this.subscriptions.set([sub, ...current]);
}),
catchError((err) => {
console.error('Failed to subscribe', err);
this.lastErrorMsg.set(
err?.status === 401
? 'Session expirée. Reconnectez-vous pour vous abonner.'
: "Abonnement impossible. Réessayez."
);
return of(null);
})
);
}
unsubscribe(subscriptionId: number): Observable<boolean> {
this.lastErrorMsg.set(null);
return this.http.delete(`/api/subscriptions/${subscriptionId}`).pipe(
tap(() => {
const current = this.subscriptions();
this.subscriptions.set(current.filter((item) => item.subscriptionId !== subscriptionId));
}),
map(() => true),
catchError((err) => {
console.error('Failed to unsubscribe', err);
this.lastErrorMsg.set(
err?.status === 401
? 'Session expirée. Reconnectez-vous pour gérer vos abonnements.'
: 'Désabonnement impossible. Réessayez.'
);
return of(false);
})
);
}
isSubscribed(provider: ProviderId, externalId: string): boolean {
const current = this.subscriptions();
return current.some((item) => item.channel?.provider === provider && item.channel?.externalId === externalId);
}
}
+74
View File
@@ -0,0 +1,74 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { catchError, of } from 'rxjs';
export type TelemetryEvent =
| 'search_submit'
| 'provider_picker_open'
| 'provider_apply'
| 'at_autocomplete_use'
| 'quick_menu_open';
export interface TelemetryMeta {
providers?: string[];
queryLength?: number;
resultCount?: number;
[key: string]: any;
}
const MAX_META_KEYS = 20;
const MAX_STRING_LENGTH = 200;
/**
* Step 13 — Minimal telemetry hooks for the unified search UX.
*
* Fire-and-forget: never blocks or breaks the UI (errors swallowed, bounded payload).
* Events are whitelisted server-side; unknown names are rejected with 400 and ignored.
*/
@Injectable({ providedIn: 'root' })
export class TelemetryService {
private http = inject(HttpClient);
// De-dup guard: avoid double-emitting identical events within a short window
private recent = new Map<string, number>();
track(event: TelemetryEvent, meta?: TelemetryMeta): void {
try {
const key = `${event}|${JSON.stringify(meta ?? {})}`;
const now = Date.now();
// Suppress identical event bursts within 500ms (double-click / double-handler)
const last = this.recent.get(key);
if (last && now - last < 500) return;
// Keep the map bounded
if (this.recent.size > 100) this.recent.clear();
this.recent.set(key, now);
const safeMeta = this.sanitizeMeta(meta);
this.http
.post('/api/telemetry/events', { event, meta: safeMeta }, { withCredentials: true })
.pipe(catchError(() => of(null)))
.subscribe();
} catch {
// Telemetry must never break the UI
}
}
private sanitizeMeta(meta?: TelemetryMeta): TelemetryMeta | null {
if (!meta || typeof meta !== 'object') return null;
const out: TelemetryMeta = {};
let n = 0;
for (const [k, v] of Object.entries(meta)) {
if (n >= MAX_META_KEYS) break;
if (typeof v === 'string') {
out[k] = v.slice(0, MAX_STRING_LENGTH);
n += 1;
} else if (typeof v === 'number' && Number.isFinite(v)) {
out[k] = v;
n += 1;
} else if (Array.isArray(v)) {
out[k] = v.slice(0, 10).map(x => (typeof x === 'string' ? x.slice(0, MAX_STRING_LENGTH) : x));
n += 1;
}
}
return out;
}
}
+222 -44
View File
@@ -14,6 +14,8 @@ export interface PagedResult<T> {
@Injectable({ providedIn: 'root' })
export class YoutubeApiService {
private twitchAuthToken$ = new BehaviorSubject<string | null>(null);
// Client ID renvoyé par le serveur avec le token (source de vérité côté back)
private twitchServerClientId: string | null = null;
// Quota exceeded flag to surface to UI components
public quotaExceeded$ = new BehaviorSubject<boolean>(false);
@@ -284,11 +286,15 @@ export class YoutubeApiService {
return out;
}),
catchError((e) => {
// Surface quotaExceeded if present
// Surface quotaExceeded if present + ban dead keys (quota ou clé expirée/invalide)
try {
if (e?.status === 403 && (e?.error?.error?.errors?.[0]?.reason === 'quotaExceeded' || /quota/i.test(String(e?.error?.error?.message || e?.message || '')))) {
const reason = e?.error?.error?.errors?.[0]?.reason || '';
const msg = String(e?.error?.error?.message || e?.message || '');
if (e?.status === 403 && (reason === 'quotaExceeded' || /quota/i.test(msg))) {
this.quotaExceeded$.next(true);
this.banYouTubeKey(key);
} else if (e?.status === 400 && (reason === 'API_KEY_INVALID' || /api key (expired|invalid)/i.test(msg))) {
this.banYouTubeKey(key);
}
} catch {}
console.error('YouTube trending error', e);
@@ -329,7 +335,7 @@ export class YoutubeApiService {
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',
fields: 'id,title,thumbnail_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,owner.id,owner.screenname,owner.avatar_80_url,created_time',
});
return this.fetchDailymotion('/videos', params).pipe(
map(res => {
@@ -346,23 +352,7 @@ export class YoutubeApiService {
}
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 }); })
);
})
);
return this.getTwitchTopStreamsPage(cursor);
}
case 'rumble': {
// Rumble utilise maintenant le web scraping côté serveur, pas d'API key requise
@@ -423,6 +413,8 @@ export class YoutubeApiService {
regionCode: String(region),
// Add safeSearch for better content filtering
safeSearch: 'moderate',
// Only return videos playable in an iframe (avoids player error 153).
videoEmbeddable: 'true',
key: String(key),
});
// Prefer results matching user's language when available
@@ -452,7 +444,11 @@ export class YoutubeApiService {
map((stats) => {
const enriched = out.items.map(v => {
const s = stats[v.videoId];
return s ? { ...v, duration: s.duration, views: s.views } : v;
if (!s) return v;
// duration -1 = non-embeddable (erreur 153) : on garde la
// vidéo en recherche (avec durée 0) mais les Shorts la filtrent.
const duration = typeof s.duration === 'number' && s.duration >= 0 ? s.duration : 0;
return { ...v, duration, views: s.views };
});
const next = { ...out, items: enriched } as PagedResult<Video>;
this.searchCache.set(cacheKey, { ts: now, data: next });
@@ -461,11 +457,15 @@ export class YoutubeApiService {
);
}),
catchError((e) => {
// Surface quotaExceeded if present
// Surface quotaExceeded if present + ban dead keys (quota ou clé expirée/invalide)
try {
if (e?.status === 403 && (e?.error?.error?.errors?.[0]?.reason === 'quotaExceeded' || /quota/i.test(String(e?.error?.error?.message || e?.message || '')))) {
const reason = e?.error?.error?.errors?.[0]?.reason || '';
const msg = String(e?.error?.error?.message || e?.message || '');
if (e?.status === 403 && (reason === 'quotaExceeded' || /quota/i.test(msg))) {
this.quotaExceeded$.next(true);
this.banYouTubeKey(key);
} else if (e?.status === 400 && (reason === 'API_KEY_INVALID' || /api key (expired|invalid)/i.test(msg))) {
this.banYouTubeKey(key);
}
} catch {}
console.error('YouTube search error', e);
@@ -551,10 +551,27 @@ export class YoutubeApiService {
nextCursor: next,
} as PagedResult<Video>;
}),
// Si la recherche est vide (scraper challengé / page changée),
// repli sur le flux trending pour ne jamais afficher un bloc vide.
switchMap(out => {
if ((out?.items?.length || 0) > 0) return of(out);
const page = 1;
const bParams = new URLSearchParams({ sort: 'trending', limit: '24', page: String(page) });
return this.fetchRumble('/browse', bParams).pipe(
map(res => {
const list = (res?.items || res?.result || res?.data || []) as any[];
return { items: list.map(i => this.mapRumbleItemToVideo(i)), nextCursor: null } as PagedResult<Video>;
}),
catchError(() => of(out))
);
}),
catchError((e) => { console.error('Rumble search error', e); return of({ items: [], nextCursor: null }); })
);
}
case 'twitch': {
// Recherche thématique robuste : chaînes live d'abord, puis VODs +
// clips, avec fallback top streams (évite les blocs vides sur les
// pages de thèmes où la recherche Helix pure ne matche rien).
return this.getTwitchAuthToken().pipe(
switchMap(token => {
if (!token) return of({ items: [], nextCursor: null });
@@ -571,6 +588,18 @@ export class YoutubeApiService {
nextCursor: res.pagination?.cursor || null,
} as PagedResult<Video>;
}),
// Si la recherche live est vide/maigre, compléter avec VODs+clips puis top streams.
switchMap(liveRes => {
if ((liveRes?.items?.length || 0) >= 4) return of(liveRes);
return this.searchTwitchThemePage(query, cursor).pipe(
map(themeRes => {
const seen = new Set((liveRes.items || []).map(v => v.videoId));
const extra = (themeRes?.items || []).filter(v => !seen.has(v.videoId));
return { items: [...(liveRes.items || []), ...extra], nextCursor: themeRes?.nextCursor || liveRes.nextCursor } as PagedResult<Video>;
}),
catchError(() => of(liveRes))
);
}),
catchError((e) => { console.error('Twitch search error', e); return of({ items: [], nextCursor: null }); })
);
})
@@ -603,6 +632,7 @@ export class YoutubeApiService {
const cd = i.contentDetails || {};
const thumb = sn.thumbnails?.high?.url || sn.thumbnails?.medium?.url || sn.thumbnails?.default?.url || '';
const publishedAt = sn.publishedAt || '';
const channelId = sn.channelId || '';
return {
url: `/watch/${id}`,
videoId: id,
@@ -611,8 +641,13 @@ export class YoutubeApiService {
title: sn.title || '',
thumbnail: thumb,
uploaderName: sn.channelTitle || '',
uploaderUrl: '',
uploaderAvatar: thumb, // placeholder; channel avatar would need another call
uploaderUrl: channelId ? `https://www.youtube.com/channel/${channelId}` : '',
// Ne jamais utiliser la miniature vidéo comme avatar chaîne : la page /watch
// résout le vrai avatar via /api/details puis /api/channels, avec fallback initiales.
uploaderAvatar: '',
channelId: channelId || undefined,
channelExternalId: channelId || undefined,
channelAvatarUrl: undefined,
uploadedDate: publishedAt,
duration: this.parseISODurationToSeconds(cd.duration || 'PT0S'),
views: Number(stats.viewCount || 0),
@@ -625,6 +660,7 @@ export class YoutubeApiService {
const sn = i.snippet || {};
const thumb = sn.thumbnails?.high?.url || sn.thumbnails?.medium?.url || sn.thumbnails?.default?.url || '';
const publishedAt = sn.publishedAt || '';
const channelId = sn.channelId || '';
return {
url: `/watch/${id}`,
videoId: id,
@@ -633,8 +669,11 @@ export class YoutubeApiService {
title: sn.title || '',
thumbnail: thumb,
uploaderName: sn.channelTitle || '',
uploaderUrl: '',
uploaderAvatar: thumb,
uploaderUrl: channelId ? `https://www.youtube.com/channel/${channelId}` : '',
uploaderAvatar: '',
channelId: channelId || undefined,
channelExternalId: channelId || undefined,
channelAvatarUrl: undefined,
uploadedDate: publishedAt,
duration: 0,
views: 0,
@@ -653,6 +692,9 @@ export class YoutubeApiService {
uploaderName: i['owner.screenname'] || '',
uploaderUrl: '',
uploaderAvatar: i['owner.avatar_80_url'] || i.thumbnail_480_url || i.thumbnail_url || '',
channelId: i['owner.id'] || undefined,
channelExternalId: i['owner.id'] || undefined,
channelAvatarUrl: i['owner.avatar_80_url'] || undefined,
uploadedDate: i.created_time ? new Date(i.created_time * 1000).toISOString() : '',
duration: Number(i.duration || 0),
views: Number(i.views_total || 0),
@@ -681,6 +723,9 @@ export class YoutubeApiService {
uploaderName,
uploaderUrl,
uploaderAvatar: uploaderAvatar || thumbnail,
channelId: i.account?.name || i.channel?.name || undefined,
channelExternalId: i.account?.name || i.channel?.name || undefined,
channelAvatarUrl: uploaderAvatar || undefined,
uploadedDate: i.publishedAt || '',
duration: Number(i.duration || 0),
views: Number(i.views || 0),
@@ -706,6 +751,9 @@ export class YoutubeApiService {
const duration = this.normalizeDuration(rawDuration);
const views = (i?.meta?.view_count != null) ? Number(i.meta.view_count) : 0;
const uploaderName = channelVal?.title || channel?.name || '';
const channelClaimId = channel?.claim_id || channel?.claimId || '';
const channelHandle = channel?.name || '';
const channelExternalId = channelClaimId || channelHandle.replace(/^@/, '') || '';
return {
url: webUrl,
@@ -717,6 +765,10 @@ export class YoutubeApiService {
uploaderName,
uploaderUrl: channel?.permanent_url ? this.lbryToOdyseeUrl(channel.permanent_url) : '',
uploaderAvatar: this.proxiedOdyseeThumb(channelVal?.thumbnail?.url) || thumb,
channelId: channelClaimId || undefined,
channelHandle: channelHandle || undefined,
channelExternalId: channelExternalId || undefined,
channelAvatarUrl: this.proxiedOdyseeThumb(channelVal?.thumbnail?.url) || undefined,
uploadedDate: publishedAt,
duration,
views: views,
@@ -731,6 +783,8 @@ export class YoutubeApiService {
let thumb = i?.thumbnail || i?.thumbnail_url || i?.image || '';
const uploaderName = i?.author || i?.uploader || i?.channel?.name || i?.uploaderName || '';
const uploaderAvatar = i?.author_avatar || i?.uploader_avatar || i?.channel?.avatar || i?.uploaderAvatar || thumb || '';
const channelSlug = i?.channel?.slug || i?.channel_slug || (i?.channel?.url ? i.channel.url.replace(/^https?:\/\/rumble.com\//, '').replace(/\/$/, '') : '') || '';
const channelExternalId = channelSlug || (uploaderName ? uploaderName.replace(/\s+/g, '').toLowerCase() : '');
const uploadedDate = i?.published_at || i?.created_at || i?.uploadedDate || '';
const views = Number(i?.views || i?.view_count || 0);
const duration = this.normalizeDuration(
@@ -748,6 +802,7 @@ export class YoutubeApiService {
// Best-effort: construct a thumbnail CDN path if present in URL pattern (fallback to uploader avatar)
thumb = uploaderAvatar || '';
}
const nativeShort = (i as any)?.isShort === true;
return {
url,
videoId: id,
@@ -758,10 +813,15 @@ export class YoutubeApiService {
uploaderName,
uploaderUrl: i?.channel_url || '',
uploaderAvatar,
channelId: channelExternalId || undefined,
channelExternalId: channelExternalId || undefined,
channelAvatarUrl: uploaderAvatar || undefined,
uploadedDate,
duration,
views,
uploaded: uploadedDate ? Date.parse(uploadedDate) : 0,
// Flag natif propagé du scraper serveur ; sinon durée courte ⇒ Short.
isShort: nativeShort || (duration > 0 && duration <= 75),
} as Video;
};
@@ -776,6 +836,9 @@ export class YoutubeApiService {
uploaderName: i.user_name || '',
uploaderUrl: `https://www.twitch.tv/${i.user_login}`,
uploaderAvatar: '', // Requires another API call
channelId: i.user_id || undefined,
channelHandle: i.user_login || undefined,
channelExternalId: i.user_login || undefined,
uploadedDate: i.created_at || '',
duration: this.parseTwitchDurationToSeconds(i.duration || '0s'),
views: i.view_count || 0,
@@ -793,6 +856,10 @@ export class YoutubeApiService {
uploaderName: i.user_name || '',
uploaderUrl: `https://www.twitch.tv/${i.user_login}`,
uploaderAvatar: (i.thumbnail_url || '').replace('{width}', '70').replace('{height}', '70'),
channelId: i.user_id || undefined,
channelHandle: i.user_login || undefined,
channelExternalId: i.user_login || undefined,
channelAvatarUrl: (i.thumbnail_url || '').replace('{width}', '70').replace('{height}', '70') || undefined,
uploadedDate: i.started_at || '',
duration: 0,
views: Number(i.viewer_count || 0),
@@ -877,6 +944,107 @@ export class YoutubeApiService {
);
}
/**
* Top live streams Twitch (Helix /streams) — utilisé comme "trending" Twitch
* et comme filet de sécurité quand une recherche thématique ne retourne rien
* (la recherche de chaînes Helix exige des noms proches de la requête et
* filtre souvent tout pour des thèmes génériques comme "sports" ou "tech").
*/
public getTwitchTopStreamsPage(cursor?: string | null, limit = 24): Observable<PagedResult<Video>> {
return this.getTwitchAuthToken().pipe(
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=${limit}${after}`;
const fallback = `/proxy/twitch-api/helix/streams?first=${limit}${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 top streams error', e); return of({ items: [], nextCursor: null }); })
);
})
);
}
/**
* Recherche thématique Twitch robuste : VODs puis clips, avec fallback vers
* les top streams live si les deux sont quasi vides (évite les blocs vides
* sur les pages de thèmes). `minResults` = seuil sous lequel on complète.
*/
public searchTwitchThemePage(query: string, cursor?: string | null, minResults = 4): Observable<PagedResult<Video>> {
return this.searchTwitchVodsPage(query, cursor).pipe(
switchMap(vods => {
if ((vods?.items?.length || 0) >= minResults) return of(vods);
return this.searchTwitchClipsPage(query, cursor).pipe(
map(clips => ({
items: [...(vods?.items || []), ...(clips?.items || [])],
nextCursor: clips?.nextCursor || vods?.nextCursor || null,
} as PagedResult<Video>)),
switchMap(merged => {
if ((merged?.items?.length || 0) >= minResults) return of(merged);
// Filet de sécurité : top streams live (jamais vide quand Twitch est configuré)
return this.getTwitchTopStreamsPage(cursor).pipe(
map(top => {
if (!top.items.length) return merged;
const seen = new Set((merged.items || []).map(v => v.videoId));
const extra = top.items.filter(v => !seen.has(v.videoId));
return { items: [...(merged.items || []), ...extra], nextCursor: top.nextCursor } as PagedResult<Video>;
}),
catchError(() => of(merged))
);
})
);
})
);
}
/**
* Clips Twitch sans requête (page Shorts) : la recherche de chaînes Helix
* rejette les requêtes vides, on part donc des top streams pour obtenir des
* broadcaster_id valides puis on récupère leurs clips récents.
*/
public getTwitchClipsFeed(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 sPrimary = `/api/twitch-api/helix/streams?first=10${after}`;
const sFallback = `/proxy/twitch-api/helix/streams?first=10${after}`;
return this.http.get<any>(sPrimary, { headers }).pipe(
catchError(() => this.http.get<any>(sFallback, { headers })),
switchMap(sRes => {
const streams = Array.isArray(sRes?.data) ? sRes.data : [];
const userIds: string[] = streams.map((s: any) => s?.user_id).filter(Boolean).slice(0, 10);
if (!userIds.length) return of({ items: [], nextCursor: null } as PagedResult<Video>);
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const perUser = userIds.map(uid => {
const cPrimary = `/api/twitch-api/helix/clips?broadcaster_id=${encodeURIComponent(uid)}&first=5&started_at=${encodeURIComponent(since)}`;
const cFallback = `/proxy/twitch-api/helix/clips?broadcaster_id=${encodeURIComponent(uid)}&first=5&started_at=${encodeURIComponent(since)}`;
return this.http.get<any>(cPrimary, { headers }).pipe(
catchError(() => this.http.get<any>(cFallback, { headers })),
map(res => Array.isArray(res?.data) ? res.data : [])
);
});
return forkJoin(perUser).pipe(
map((lists: any[][]) => lists.flat().slice(0, 24)),
map((clips: any[]) => ({
items: clips.map((i: any) => this.mapTwitchClipToVideo(i)),
nextCursor: sRes?.pagination?.cursor || null,
} as PagedResult<Video>)),
catchError(() => of({ items: [], nextCursor: null } as PagedResult<Video>))
);
}),
catchError((e) => { console.error('Twitch clips feed error', e); return of({ items: [], nextCursor: null }); })
);
})
);
}
private parseISODurationToSeconds(iso: string): number {
// ... (rest of the code remains the same)
const m = /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/i.exec(iso || '');
@@ -960,23 +1128,20 @@ export class YoutubeApiService {
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;
return this.twitchServerClientId || g?.TWITCH_CLIENT_ID || (typeof import.meta !== 'undefined' && (import.meta as any).env?.TWITCH_CLIENT_ID) || g?.process?.env?.TWITCH_CLIENT_ID;
}
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),
// Le token applicatif est obtenu via notre serveur (/api/twitch-token) :
// TWITCH_CLIENT_SECRET ne quitte jamais le backend.
return this.http.get<any>('/api/twitch-token').pipe(
map(res => {
if (res?.accessToken) {
if (res?.clientId) this.twitchServerClientId = String(res.clientId);
return String(res.accessToken);
}
return null;
}),
shareReplay(1),
catchError(() => of(null))
);
@@ -1041,6 +1206,8 @@ export class YoutubeApiService {
/**
* Fetch durations (in seconds) for a list of YouTube video IDs.
* Non-embeddable videos (status.embeddable === false) are excluded via
* a -1 marker so Shorts/feed callers can drop them (player error 153).
*/
public getYouTubeDurations(ids: string[]): Observable<Record<string, number>> {
const apiKey = this.getActiveYouTubeKey();
@@ -1050,7 +1217,7 @@ export class YoutubeApiService {
const params = new URLSearchParams({
key: apiKey,
part: 'contentDetails',
part: 'contentDetails,status',
id: ids.join(','),
});
@@ -1059,7 +1226,13 @@ export class YoutubeApiService {
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);
if (!id) return acc;
// -1 = non-embeddable (à exclure côté Shorts pour éviter l'erreur 153)
if (item?.status && item.status.embeddable === false) {
acc[id] = -1;
return acc;
}
acc[id] = this.parseISODurationToSeconds(duration);
return acc;
}, {} as Record<string, number>)),
catchError(() => of({}))
@@ -1068,13 +1241,14 @@ export class YoutubeApiService {
/**
* Fetch minimal details (duration seconds + viewCount) for a list of YouTube IDs.
* Non-embeddable entries get duration -1 so callers can drop them (error 153).
*/
private getYouTubeContentAndStats(ids: string[]): Observable<Record<string, { duration: number; views: number }>> {
const apiKey = this.getActiveYouTubeKey();
if (!apiKey || !ids.length) return of({});
const params = new URLSearchParams({
key: apiKey,
part: 'contentDetails,statistics',
part: 'contentDetails,statistics,status',
id: ids.join(','),
maxResults: String(Math.min(50, ids.length)),
// Add safeSearch for consistency
@@ -1087,6 +1261,10 @@ export class YoutubeApiService {
for (const it of items) {
const id = String(it?.id || '');
if (!id) continue;
if (it?.status && it.status.embeddable === false) {
out[id] = { duration: -1, views: Number(it?.statistics?.viewCount || 0) };
continue;
}
const duration = this.parseISODurationToSeconds(it?.contentDetails?.duration || 'PT0S');
const views = Number(it?.statistics?.viewCount || 0);
out[id] = { duration, views };