feat(channel): page details chaine multi-providers avec onglets et infinite scroll
CI / build-and-test (push) Successful in 13m34s
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:
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user