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
+9
View File
@@ -5,6 +5,7 @@ import { HeaderComponent } from './components/header/header.component';
import { SidebarComponent } from './components/sidebar/sidebar.component';
import { ThemesNavComponent } from './components/themes/themes-nav.component';
import { I18nService } from './services/i18n.service';
import { AuthService } from './services/auth.service';
@Component({
selector: 'app-root',
@@ -15,6 +16,7 @@ import { I18nService } from './services/i18n.service';
})
export class AppComponent {
private i18n = inject(I18nService);
private auth = inject(AuthService);
private router = inject(Router);
sidebarOpen = signal(false);
sidebarCollapsed = signal(false);
@@ -39,6 +41,13 @@ export class AppComponent {
}
constructor() {
// Restaure la session persistée (cookies) après F5 : sans ça l'app croit
// l'utilisateur déconnecté et toutes les actions authentifiées font 401.
try {
this.auth.initSession().subscribe((ok) => {
if (ok) this.auth.fetchMe().subscribe();
});
} catch {}
// Initialize and react to route changes to show/hide the global themes bar
try {
this.updateShowThemesBar();
+15
View File
@@ -23,6 +23,11 @@ export const APP_ROUTES: Routes = [
loadComponent: () => import('./components/shorts/watch-short.component').then(m => m.WatchShortComponent),
title: 'NewTube - Shorts'
},
{
path: 'channel/:provider/:externalId',
loadComponent: () => import('./components/channel/channel-page.component').then(m => m.ChannelPageComponent),
title: 'NewTube - Channel'
},
{
path: 'watch/:id',
loadComponent: () => import('./components/watch/watch.component').then(m => m.WatchComponent),
@@ -59,6 +64,16 @@ export const APP_ROUTES: Routes = [
loadComponent: () => import('./components/library/subscriptions/subscriptions.component').then(m => m.SubscriptionsComponent),
title: 'NewTube - Subscriptions'
},
{
path: 'library/downloads/:id',
loadComponent: () => import('./components/library/downloads/download-play.component').then(m => m.DownloadPlayComponent),
title: 'NewTube - Play download'
},
{
path: 'library/downloads',
loadComponent: () => import('./components/library/downloads/downloads.component').then(m => m.DownloadsComponent),
title: 'NewTube - Downloads'
},
{
path: 'account',
pathMatch: 'full',
@@ -0,0 +1,50 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import type { ProviderId } from '../../../shared/models/channel.model';
import type { ChannelProviderInterface } from './channel-provider.interface';
import {
DailymotionChannelProvider,
OdyseeChannelProvider,
PeerTubeChannelProvider,
RumbleChannelProvider,
TwitchChannelProvider,
YouTubeChannelProvider,
} from './channel-providers';
/** Normalise les ids longs (youtube) + courts (yt) vers ProviderId. */
export function normalizeChannelProvider(value: string | null | undefined): ProviderId | null {
if (!value) return null;
const v = String(value).trim().toLowerCase();
const map: Record<string, ProviderId> = {
yt: 'yt', youtube: 'yt',
dm: 'dm', dailymotion: 'dm',
tw: 'tw', twitch: 'tw',
pt: 'pt', peertube: 'pt',
od: 'od', odysee: 'od',
ru: 'ru', rumble: 'ru',
};
return map[v] ?? null;
}
@Injectable({ providedIn: 'root' })
export class ChannelProviderFactory {
private http = inject(HttpClient);
private instances = new Map<ProviderId, ChannelProviderInterface>();
get(provider: ProviderId): ChannelProviderInterface {
const existing = this.instances.get(provider);
if (existing) return existing;
let created: ChannelProviderInterface;
switch (provider) {
case 'yt': created = new YouTubeChannelProvider(this.http); break;
case 'dm': created = new DailymotionChannelProvider(this.http); break;
case 'tw': created = new TwitchChannelProvider(this.http); break;
case 'pt': created = new PeerTubeChannelProvider(this.http); break;
case 'od': created = new OdyseeChannelProvider(this.http); break;
case 'ru': created = new RumbleChannelProvider(this.http); break;
default: created = new YouTubeChannelProvider(this.http); break;
}
this.instances.set(provider, created);
return created;
}
}
@@ -0,0 +1,32 @@
import { Observable } from 'rxjs';
import type { ProviderId } from '../../../shared/models/channel.model';
import type {
ChannelContentPage,
ChannelContentType,
ChannelDetail,
ChannelPlaylist,
ChannelSort,
} from '../../../shared/models/channel-detail.model';
import type { VideoItem } from '../../../shared/models/video-item.model';
export interface ChannelContentQuery {
page?: number;
limit?: number;
sort?: ChannelSort;
q?: string;
}
/**
* Strategy Pattern : un provider par plateforme.
* Chaque implémentation normalise vers VideoItem / ChannelPlaylist.
*/
export interface ChannelProviderInterface {
readonly id: ProviderId;
getChannelInfo(externalId: string): Observable<ChannelDetail | null>;
getChannelVideos(externalId: string, query?: ChannelContentQuery): Observable<ChannelContentPage<VideoItem>>;
getChannelShorts?(externalId: string, query?: ChannelContentQuery): Observable<ChannelContentPage<VideoItem>>;
getChannelPlaylists?(externalId: string, query?: ChannelContentQuery): Observable<ChannelContentPage<ChannelPlaylist>>;
getChannelStreams?(externalId: string, query?: ChannelContentQuery): Observable<ChannelContentPage<VideoItem>>;
searchInChannel?(externalId: string, q: string, query?: ChannelContentQuery): Observable<ChannelContentPage<VideoItem>>;
supports(type: ChannelContentType | 'searchInChannel'): boolean;
}
@@ -0,0 +1,22 @@
import type { HttpClient } from '@angular/common/http';
import { DEFAULT_CAPABILITIES } from '../../../shared/models/channel-detail.model';
import { HttpChannelProvider } from './http-channel.provider';
export class YouTubeChannelProvider extends HttpChannelProvider {
constructor(http: HttpClient) { super('yt', http, DEFAULT_CAPABILITIES['yt']); }
}
export class DailymotionChannelProvider extends HttpChannelProvider {
constructor(http: HttpClient) { super('dm', http, DEFAULT_CAPABILITIES['dm']); }
}
export class TwitchChannelProvider extends HttpChannelProvider {
constructor(http: HttpClient) { super('tw', http, DEFAULT_CAPABILITIES['tw']); }
}
export class PeerTubeChannelProvider extends HttpChannelProvider {
constructor(http: HttpClient) { super('pt', http, DEFAULT_CAPABILITIES['pt']); }
}
export class OdyseeChannelProvider extends HttpChannelProvider {
constructor(http: HttpClient) { super('od', http, DEFAULT_CAPABILITIES['od']); }
}
export class RumbleChannelProvider extends HttpChannelProvider {
constructor(http: HttpClient) { super('ru', http, DEFAULT_CAPABILITIES['ru']); }
}
@@ -0,0 +1,148 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, catchError, map, of } from 'rxjs';
import type { ChannelMeta, ProviderId } from '../../../shared/models/channel.model';
import type {
ChannelCapabilities,
ChannelContentPage,
ChannelContentType,
ChannelDetail,
ChannelPlaylist,
ChannelSort,
} from '../../../shared/models/channel-detail.model';
import { DEFAULT_CAPABILITIES, buildChannelDetail } from '../../../shared/models/channel-detail.model';
import type { VideoItem } from '../../../shared/models/video-item.model';
import type { ChannelContentQuery, ChannelProviderInterface } from './channel-provider.interface';
const LONG_PROVIDER: Record<ProviderId, VideoItem['provider']> = {
yt: 'youtube',
dm: 'dailymotion',
tw: 'twitch',
pt: 'peertube',
od: 'odysee',
ru: 'rumble',
};
function toVideoItem(raw: any, provider: ProviderId, fallbackChannelId: string): VideoItem {
return {
id: String(raw?.id ?? ''),
provider: LONG_PROVIDER[provider],
title: String(raw?.title ?? ''),
thumbnailUrl: raw?.thumbnail ?? raw?.thumbnailUrl ?? '',
durationSec: typeof raw?.duration === 'number' ? raw.duration : raw?.durationSec,
channelName: raw?.uploaderName ?? raw?.channelName,
channelExternalId: raw?.channelExternalId ?? raw?.channelId ?? fallbackChannelId,
channelAvatarUrl: raw?.uploaderAvatar ?? raw?.channelAvatarUrl,
viewCount: typeof raw?.views === 'number' ? raw.views : raw?.viewCount,
publishedAt: raw?.publishedAt ?? raw?.uploadedDate,
slug: raw?.slug,
channel: raw?.channel,
};
}
function toPlaylist(raw: any, provider: ProviderId): ChannelPlaylist {
return {
id: String(raw?.id ?? ''),
provider,
title: String(raw?.title ?? raw?.name ?? ''),
thumbnailUrl: raw?.thumbnail ?? raw?.thumbnailUrl ?? null,
videoCount: typeof raw?.videoCount === 'number' ? raw.videoCount : (typeof raw?.videos_total === 'number' ? raw.videos_total : null),
updatedAt: raw?.updatedAt ?? null,
};
}
/**
* Implémentation générique : appelle le backend
* GET /api/channels/:provider/:id/content?type=&page=&limit=&sort=&q=
* et normalise vers VideoItem / ChannelPlaylist.
*/
interface ContentApiResponse {
items?: any[];
nextPage?: number | null;
total?: number | null;
}
export class HttpChannelProvider implements ChannelProviderInterface {
readonly id: ProviderId;
protected capabilities: ChannelCapabilities;
constructor(
id: ProviderId,
protected http: HttpClient,
capabilities?: ChannelCapabilities,
) {
this.id = id;
this.capabilities = capabilities ?? DEFAULT_CAPABILITIES[id];
}
supports(type: ChannelContentType | 'searchInChannel'): boolean {
return Boolean((this.capabilities as unknown as Record<string, boolean>)[type]);
}
getChannelInfo(externalId: string): Observable<ChannelDetail | null> {
return this.http.get<ChannelMeta>(`/api/channels/${this.id}/${encodeURIComponent(externalId)}`).pipe(
map((meta) => (meta ? buildChannelDetail(meta, { capabilities: this.capabilities }) : null)),
catchError(() => of(null)),
);
}
getChannelVideos(externalId: string, query?: ChannelContentQuery): Observable<ChannelContentPage<VideoItem>> {
return this.fetchContent(externalId, 'videos', query).pipe(
map((r) => ({ ...r, items: r.items.map((raw) => toVideoItem(raw, this.id, externalId)) })),
);
}
getChannelShorts(externalId: string, query?: ChannelContentQuery): Observable<ChannelContentPage<VideoItem>> {
if (!this.supports('shorts')) return of({ items: [], nextPage: null });
return this.fetchContent(externalId, 'shorts', query).pipe(
map((r) => ({ ...r, items: r.items.map((raw) => toVideoItem(raw, this.id, externalId)) })),
);
}
getChannelPlaylists(externalId: string, query?: ChannelContentQuery): Observable<ChannelContentPage<ChannelPlaylist>> {
if (!this.supports('playlists')) return of({ items: [], nextPage: null });
return this.fetchContent(externalId, 'playlists', query).pipe(
map((r) => ({ ...r, items: r.items.map((raw) => toPlaylist(raw, this.id)) })),
);
}
getChannelStreams(externalId: string, query?: ChannelContentQuery): Observable<ChannelContentPage<VideoItem>> {
if (!this.supports('live')) return of({ items: [], nextPage: null });
return this.fetchContent(externalId, 'live', query).pipe(
map((r) => ({ ...r, items: r.items.map((raw) => toVideoItem(raw, this.id, externalId)) })),
);
}
searchInChannel(externalId: string, q: string, query?: ChannelContentQuery): Observable<ChannelContentPage<VideoItem>> {
if (!this.supports('searchInChannel')) return of({ items: [], nextPage: null });
return this.fetchContent(externalId, 'videos', { ...query, q }).pipe(
map((page) => {
const needle = q.trim().toLowerCase();
const items = page.items
.map((raw) => toVideoItem(raw, this.id, externalId))
.filter((v) => !needle || v.title.toLowerCase().includes(needle));
return { ...page, items };
}),
);
}
protected fetchContent(
externalId: string,
type: ChannelContentType,
query?: ChannelContentQuery,
): Observable<ChannelContentPage<any>> {
let params = new HttpParams().set('type', type);
if (query?.page) params = params.set('page', String(query.page));
if (query?.limit) params = params.set('limit', String(query.limit));
if (query?.sort) params = params.set('sort', query.sort as ChannelSort);
if (query?.q) params = params.set('q', query.q);
return this.http
.get<ContentApiResponse>(`/api/channels/${this.id}/${encodeURIComponent(externalId)}/content`, { params })
.pipe(
map((resp) => ({
items: Array.isArray(resp?.items) ? resp!.items! : [],
nextPage: resp?.nextPage ?? null,
total: resp?.total ?? null,
})),
catchError(() => of({ items: [], nextPage: null })),
);
}
}
+11 -2
View File
@@ -13,7 +13,13 @@ export class TwAdapter implements ProviderAdapter<VideoItem> {
if (params.sort) httpParams = httpParams.set('sort', params.sort);
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
const items: VideoItem[] = (res?.groups?.tw || []).map((it: any) => {
const isChannel = String(it?.type || '').toLowerCase() === 'channel';
// /api/search (tw) ne retourne que des chaînes : le player exige le login
// (?channel=<login>), jamais l'id numérique. Sans `channel`, /watch tente
// ?video=<id> et Twitch répond par une erreur.
const login: string | undefined =
it.channelExternalId || it.channelHandle || undefined;
const kind = String(it?.type || '').toLowerCase();
const isChannel = kind === 'channel' || kind === 'live' || Boolean(login);
return {
id: it.id,
provider: 'twitch',
@@ -23,7 +29,10 @@ export class TwAdapter implements ProviderAdapter<VideoItem> {
thumbnailUrl: it.thumbnail,
viewCount: typeof it.views === 'number' ? it.views : undefined,
publishedAt: it.publishedAt,
channel: isChannel ? it.id : undefined,
channelExternalId: login,
channelAvatarUrl: it.uploaderAvatar || undefined,
// Login pour l'embed ?channel=... (video-card -> watchQueryParams).
channel: isChannel ? login : undefined,
} as VideoItem;
});
return { items, total: Array.isArray(res?.groups?.tw) ? res.groups.tw.length : 0 };
+223
View File
@@ -0,0 +1,223 @@
import 'zone.js/node';
import '@angular/compiler';
import { Injector, runInInjectionContext, ɵChangeDetectionScheduler as ChangeDetectionScheduler, ɵEffectScheduler as EffectScheduler } from '@angular/core';
/**
* Step 11 — Unit tests for the unified search UX components.
*
* ts-node compiles with isolated transpilation (no decorator output => no ɵfac),
* so TestBed cannot instantiate Angular classes here. We import dynamically and
* construct components inside a minimal injection context with fakes.
*/
function fmtError(e: unknown): string {
if (!e) return String(e);
if (e instanceof Error) return e.stack || `${e.name}: ${e.message}`;
try { return JSON.stringify(e, Object.getOwnPropertyNames(e)); } catch { return String(e); }
}
process.on('uncaughtException', (e) => { console.error('UNCAUGHT:', fmtError(e)); process.exit(1); });
process.on('unhandledRejection', (e) => { console.error('UNHANDLED:', fmtError(e)); process.exit(1); });
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 sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
(async () => {
const [{ SearchBoxComponent }, { ProviderPickerComponent }, { SearchService }, { UserService }, { HistoryService }, { TelemetryService }, { PROVIDERS }] = await Promise.all([
import('../../components/search/search-box.component'),
import('../../components/search/provider-picker.component'),
import('./search.service'),
import('../../services/user.service'),
import('../../services/history.service'),
import('../../services/telemetry.service'),
import('../core/providers/provider-registry'),
]);
// --- Fakes for SearchBox dependencies ---
const makeSearchServiceFake = (initialProviders: any = 'all') => ({
q$: { value: '' },
providers$: { value: initialProviders },
});
const makeUsersServiceFake = (prefs: any = null) => ({
preferences: () => prefs,
updatePreferences: (_patch: any) => ({ subscribe: () => {} }),
});
const makeHistoryServiceFake = () => ({
getSearchHistory: (_n: number) => ({ subscribe: () => {} }),
});
function newBox(opts: { prefs?: any; providers?: any } = {}): any {
// Constructor uses inject() + effect(): provide a minimal injection context with
// the Angular internal schedulers plus fakes for the component's own deps.
// The EffectScheduler mock runs the effect work immediately so component effects
// (hydration, preference application) execute deterministically in tests.
const injector = Injector.create({
providers: [
{ provide: ChangeDetectionScheduler, useValue: { notify: () => {} } },
{ provide: EffectScheduler, useValue: { add: (e: any) => { try { e?.run?.(); } catch {} }, remove: () => {}, schedule: (e: any) => { try { e?.run?.(); } catch {} }, flush: () => {} } },
{ provide: SearchService, useValue: makeSearchServiceFake(opts.providers ?? 'all') },
{ provide: UserService, useValue: makeUsersServiceFake(opts.prefs ?? null) },
{ provide: HistoryService, useValue: makeHistoryServiceFake() },
{ provide: TelemetryService, useValue: { track: () => {} } },
],
});
return runInInjectionContext(injector, () => new SearchBoxComponent());
}
// --- SearchBox: inline @ parsing ---
function testAtParsingDetectsAtQuery(): void {
const box = newBox();
box.queryUpdate('hello world');
assert(box.atOptions().length === 0, 'plain text yields no @ options');
assert(box.atOpen() === false, 'atOpen stays false on plain text');
box.queryUpdate('look for @yo');
const opts = box.atOptions();
assert(opts.length === 1, `'@yo' filters to one provider (got ${opts.length})`);
assertEqual(opts[0].id, 'yt', "'@yo' matches YouTube");
assert(box.atOpen() === true, 'atOpen becomes true when @ options exist');
box.queryUpdate('look for @yt');
assert(box.atOptions().length === 1, '@yt still offers YouTube');
assertEqual(box.atOptions()[0].id, 'yt', '@yt matches YouTube');
box.queryUpdate('look for @yt ');
assert(box.atOpen() === false, 'space after @term closes autocomplete');
box.queryUpdate('look for @zz');
assert(box.atOpen() === false && box.atOptions().length === 0, '@zz yields no options');
}
function testApplyAtOptionStripsTermAndTogglesChip(): void {
const box = newBox();
box.queryUpdate('linux @dm');
box.applyAtOption('dm');
assertEqual(box.query(), 'linux', 'applying an @ option strips the @term from the query');
assertEqual(box.selected(), ['dm'], 'applying an @ option toggles the provider chip on');
assert(box.atOpen() === false, 'popover closes after applying');
assertEqual(box.providerMode(), 'custom', 'provider mode switches to custom');
box.applyAtOption('dm');
assertEqual(box.selected(), [], 'applying again toggles the chip off');
box.toggleChip('pt');
assertEqual(box.providersForRequest(), ['pt'], 'providersForRequest returns the selected list');
box.toggleAll();
assertEqual(box.providersForRequest(), 'all', 'toggleAll switches back to all');
}
async function testDefaultProvidersPreferenceApplied(): Promise<void> {
const box = newBox({ prefs: { defaultProviders: ['yt', 'dm'] } });
// Allow queued microtasks/timeouts a tick (effects are driven by the fake scheduler)
await sleep(50);
assertEqual(box.providerMode(), 'custom', 'preference switches mode to custom');
assertEqual(box.selected(), ['yt', 'dm'], 'preference providers become selected');
}
function testAltShortcutsToggleChips(): void {
const box = newBox();
const yt = PROVIDERS[0].id;
const ev = {
key: '1',
altKey: true,
ctrlKey: false,
metaKey: false,
shiftKey: false,
preventDefault: () => {},
stopImmediatePropagation: () => {},
} as unknown as KeyboardEvent;
box.handleKeydown(ev);
assertEqual(box.selected(), [yt], 'Alt+1 toggles the first chip (yt) on');
box.handleKeydown(ev);
assertEqual(box.selected(), [], 'Alt+1 again toggles the chip off');
}
// --- ProviderPicker toggles ---
function testPickerDraftSelection(): void {
const picker = new ProviderPickerComponent();
picker.selected = 'all';
assert(picker.isChecked('yt'), 'All mode checks every provider');
picker.toggle('yt');
const yt = 'yt';
assert(picker.isChecked(yt) === false, 'unchecking from All removes only that provider');
assert(picker.isChecked('dm'), 'other providers stay checked after unchecking one');
assertEqual(picker.allMode(), false, 'All mode turns off after unchecking');
picker.toggle(yt);
assert(picker.isChecked(yt), 're-checking adds the provider back');
let emitted: any = null;
picker.apply.subscribe((v: any) => { emitted = v; });
picker.onApply();
assert(Array.isArray(emitted?.selection), 'apply emits a list selection');
assertEqual(emitted.selection.length, PROVIDERS.length, 'apply emits every provider when all are checked');
picker.toggleAllMode();
picker.onApply();
assertEqual(emitted.selection, 'all', 'apply emits all after toggleAllMode');
assert(emitted.rememberDefault === false, 'rememberDefault defaults to false');
}
function testPickerRememberDefault(): void {
const picker = new ProviderPickerComponent();
picker.selected = ['yt'];
picker.rememberDefault.set(true);
let emitted: any = null;
picker.apply.subscribe((v: any) => { emitted = v; });
picker.toggle('dm');
picker.onApply();
assertEqual(emitted.selection, ['yt', 'dm'], 'draft selection is emitted on apply');
assert(emitted.rememberDefault === true, 'rememberDefault flag is emitted');
}
const tests: Array<[string, () => void | Promise<void>]> = [
['SearchBox @ parsing detects and filters providers', testAtParsingDetectsAtQuery],
['SearchBox applyAtOption strips term and toggles chip', testApplyAtOptionStripsTermAndTogglesChip],
['SearchBox applies defaultProviders preference', testDefaultProvidersPreferenceApplied],
['SearchBox Alt+1..6 shortcut toggles chips', testAltShortcutsToggleChips],
['ProviderPicker draft selection + apply', testPickerDraftSelection],
['ProviderPicker rememberDefault flag', testPickerRememberDefault],
];
let failures = 0;
for (const [name, fn] of tests) {
try {
await fn();
console.log(`✓ ${name}`);
} catch (error) {
failures += 1;
console.error(`✗ ${name}`);
console.error(error instanceof Error ? error.stack : fmtError(error));
}
}
console.log(failures === 0 ? '\nAll search component tests passed.' : `\n${failures} test(s) failed.`);
process.exit(failures === 0 ? 0 : 1);
})().catch((e) => {
console.error('Unhandled test runner error:', fmtError(e));
process.exit(1);
});
+158
View File
@@ -0,0 +1,158 @@
import 'zone.js/node';
import '@angular/compiler'; // JIT needed: decorators are evaluated at import time under ts-node
import { of } from 'rxjs';
import { Injector, runInInjectionContext } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { SearchService } from './search.service';
import type { ProviderId } from '../core/providers/provider-registry';
/**
* Step 11 — Unit tests for SearchService.
*
* ts-node compiles in isolation (no decorator output => no ɵfac), so TestBed
* cannot instantiate Angular classes here. We build the service manually with
* a minimal HttpClient fake returning real Observables.
*/
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 sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
interface RecordedRequest {
url: string;
providerParam: string | null;
}
function makeHttpFake(payloads: Record<string, any>) {
const requests: RecordedRequest[] = [];
const fake = {
// Adapters call firstValueFrom(http.get(...)) — return a real Observable
get: (url: string, opts: any) => {
let providerParam: string | null = null;
try { providerParam = opts?.params?.get('providers') ?? null; } catch {}
requests.push({ url, providerParam });
// provider id embedded in the request is the key into payloads
const key = providerParam || 'all';
return of(payloads[key] ?? { groups: {} });
},
requests,
};
return fake;
}
function makeService(payloads: Record<string, any> = {}) {
const httpFake = makeHttpFake(payloads);
// SearchService uses inject(HttpClient): create it inside an injection context
const injector = Injector.create({
providers: [{ provide: HttpClient, useValue: httpFake as unknown as HttpClient }],
});
const service = runInInjectionContext(injector, () => new SearchService());
return { service, httpFake };
}
function testSetProvidersNormalizesValues(): void {
const { service } = makeService();
service.setProviders(['yt', 'dm'] as ProviderId[]);
assertEqual(service.providers$.value, ['yt', 'dm'], 'setProviders keeps a non-empty list');
service.setProviders([] as any);
assertEqual(service.providers$.value, 'all', 'empty list falls back to all');
service.setProviders('all');
assertEqual(service.providers$.value, 'all', 'all stays all');
}
function testSetPageAndPageSizeNormalize(): void {
const { service } = makeService();
service.setPage(3);
assert(service.page$.value === 3, 'page 3 is kept');
service.setPage(-5);
assert(service.page$.value === 1, 'negative page is floored to ' + 1);
service.setPageSize(999);
assert(service.pageSize$.value === 50, 'pageSize is capped at 50');
service.setPageSize(0);
assert(service.pageSize$.value === 24, 'pageSize 0 falls back to default 24');
service.setSort('views');
assertEqual(service.sort$.value, 'views', 'sort is applied');
service.setSort('bogus' as any);
assertEqual(service.sort$.value, 'relevance', 'invalid sort falls back to relevance');
}
async function testRequestFanOutPerProvider(): Promise<void> {
const payload = {
yt: { groups: { yt: [{ id: 'a', title: 'A' }] } },
dm: { groups: { dm: [{ id: 'b', title: 'B' }] } },
};
const { service, httpFake } = makeService(payload);
// request$ is cold: subscribing activates the params$ pipeline
const responses: any[] = [];
service.request$.subscribe((r: any) => responses.push(r));
service.setQuery('linux');
service.setProviders(['yt', 'dm'] as ProviderId[]);
// params$ debounces 120ms before fanning out to adapters
await sleep(400);
assert(httpFake.requests.length === 2, `two adapter HTTP calls expected (got ${httpFake.requests.length})`);
const providerParams = httpFake.requests.map((r) => r.providerParam).sort();
assertEqual(providerParams, ['dm', 'yt'], 'each adapter requests its own provider id');
// The aggregated response exposes groups keyed by provider
await sleep(50);
assert(responses.length >= 1, 'request$ emitted an aggregated response');
const last = responses[responses.length - 1];
assertEqual(last.groups?.yt?.length, 1, 'yt group has 1 item');
assertEqual(last.groups?.dm?.length, 1, 'dm group has 1 item');
}
async function testShortQueryTriggersNothing(): Promise<void> {
const { service, httpFake } = makeService({});
service.setQuery('a');
await sleep(350);
assert(httpFake.requests.length === 0, 'query shorter than 2 chars triggers no request');
}
const tests: Array<[string, () => void | Promise<void>]> = [
['SearchService.setProviders normalizes values', testSetProvidersNormalizesValues],
['SearchService.setPage/setPageSize/setSort normalize', testSetPageAndPageSizeNormalize],
['SearchService.request$ fans out one call per provider', testRequestFanOutPerProvider],
['SearchService ignores short queries', testShortQueryTriggersNothing],
];
(async () => {
let failures = 0;
for (const [name, fn] of tests) {
try {
await fn();
console.log(`✓ ${name}`);
} catch (error) {
failures += 1;
console.error(`✗ ${name}`);
console.error(error instanceof Error ? error.stack : error);
}
}
console.log(failures === 0 ? '\nAll SearchService tests passed.' : `\n${failures} test(s) failed.`);
if (failures > 0) {
process.exitCode = 1;
}
// Explicit exit: keep-alive handles (BehaviorSubjects, timers) can hang the process
process.exit(failures === 0 ? 0 : 1);
})().catch((e) => {
console.error('Unhandled test runner error:', e instanceof Error ? e.stack : e);
process.exit(1);
});
+8 -2
View File
@@ -111,8 +111,14 @@ export class SearchService {
// Convenience setter helpers
setQuery(q: string) { this.q$.next(q || ''); }
setProviders(list: ProviderId[] | 'all') { this.providers$.next(list && (Array.isArray(list) ? list : 'all')); }
setProviders(list: ProviderId[] | 'all') {
// Normalize: 'all' or a non-empty list; anything else falls back to 'all'
this.providers$.next(list === 'all' || (Array.isArray(list) && list.length > 0) ? list : 'all');
}
setPage(page: number) { this.page$.next(Math.max(1, Math.floor(page || 1))); }
setPageSize(size: number) { this.pageSize$.next(Math.min(50, Math.max(1, Math.floor(size || 24)))); }
setSort(sort: 'relevance' | 'date' | 'views' | 'duration') { this.sort$.next(sort || 'relevance'); }
setSort(sort: 'relevance' | 'date' | 'views' | 'duration') {
const allowed: Array<'relevance' | 'date' | 'views' | 'duration'> = ['relevance', 'date', 'views', 'duration'];
this.sort$.next(allowed.includes(sort) ? sort : 'relevance');
}
}
@@ -0,0 +1,61 @@
import { ChangeDetectionStrategy, Component, Input, computed, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import type { ProviderId, ChannelMeta } from '../../models/channel.model';
import { ChannelsService } from '../../../../services/channels.service';
@Component({
selector: 'app-channel-link',
standalone: true,
imports: [CommonModule, RouterLink],
template: `
<ng-container *ngIf="hasDestination(); else plainLabel">
<a
[routerLink]="['/channel', provider, externalId]"
class="inline-flex items-center gap-2 font-medium transition hover:text-red-300"
(mouseenter)="prefetch()"
>
<span>{{ label() }}</span>
<ng-container *ngIf="channel()?.verified">
<span class="text-xs text-emerald-300">✓</span>
</ng-container>
</a>
</ng-container>
<ng-template #plainLabel>
<span class="font-medium">{{ label() }}</span>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChannelLinkComponent {
private channels = inject(ChannelsService);
@Input() provider: ProviderId | null = null;
@Input() externalId: string | null = null;
@Input() fallbackLabel: string | null = null;
@Input() eager = false;
private cached = signal<ChannelMeta | null>(null);
readonly channel = computed(() => this.cached());
label(): string {
const meta = this.cached();
return meta?.title || meta?.handle || this.fallbackLabel || 'Chaîne';
}
hasDestination(): boolean {
return Boolean(this.provider && this.externalId);
}
ngOnInit() {
if (this.eager) this.prefetch();
}
prefetch(): void {
if (!this.provider || !this.externalId) return;
if (this.cached()) return;
this.channels.get(this.provider, this.externalId).subscribe((meta) => {
if (meta) this.cached.set(meta);
});
}
}
@@ -0,0 +1,22 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-compact-number',
standalone: true,
imports: [CommonModule],
template: `{{ formatted }}`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CompactNumberComponent {
@Input() value: number | null | undefined = null;
get formatted(): string {
const n = Number(this.value);
if (!Number.isFinite(n)) return '—';
try {
return new Intl.NumberFormat('fr-FR', { notation: 'compact', compactDisplay: 'short' }).format(n);
} catch {
return String(n);
}
}
}
@@ -0,0 +1,74 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, computed, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { ProviderId, ChannelMeta } from '../../models/channel.model';
import { SubscriptionsService } from '../../../../services/subscriptions.service';
@Component({
selector: 'app-subscribe-button',
standalone: true,
imports: [CommonModule],
template: `
<button
type="button"
class="inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
[class.border-red-500/60]="isSubscribed()"
[class.bg-red-500/20]="isSubscribed()"
[class.text-red-100]="isSubscribed()"
[class.border-slate-600]="!isSubscribed()"
[class.bg-slate-800/70]="!isSubscribed()"
[class.text-slate-200]="!isSubscribed()"
[disabled]="isBusy()"
(click)="toggle()"
[attr.aria-pressed]="isSubscribed()"
>
<span *ngIf="!isSubscribed(); else unsub">S'abonner</span>
<ng-template #unsub>Se désabonner</ng-template>
</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SubscribeButtonComponent {
private readonly subs = inject(SubscriptionsService);
@Input({ required: true }) provider!: ProviderId;
@Input({ required: true }) externalId!: string;
@Input() channelMeta?: ChannelMeta | null;
@Input() optimistic = true;
@Output() subscribed = new EventEmitter<ChannelMeta | null>();
@Output() unsubscribed = new EventEmitter<void>();
private busy = signal(false);
readonly isBusy = computed(() => this.busy());
readonly isSubscribed = computed(() => this.subs.isSubscribed(this.provider, this.externalId));
toggle(): void {
if (this.busy()) return;
const subscribed = this.isSubscribed();
this.busy.set(true);
if (subscribed) {
const current = this.subs.items().find((item) => item.channel.provider === this.provider && item.channel.externalId === this.externalId);
if (!current) {
this.busy.set(false);
return;
}
this.subs.unsubscribe(current.subscriptionId).subscribe({
next: (ok) => {
if (ok) this.unsubscribed.emit();
},
complete: () => this.busy.set(false),
error: () => this.busy.set(false),
});
} else {
this.subs.subscribe(this.provider, this.externalId, { refresh: this.optimistic }).subscribe({
next: (item) => {
this.subscribed.emit(item?.channel ?? null);
},
complete: () => this.busy.set(false),
error: () => this.busy.set(false),
});
}
}
}
@@ -1,24 +1,94 @@
<a [routerLink]="['/watch', video.id]" [queryParams]="buildQueryParams(video)" [state]="{ video }" class="group flex flex-col gap-2">
<div class="relative aspect-video w-full overflow-hidden rounded-lg bg-zinc-800">
<img [src]="video.thumbnailUrl" [alt]="video.title" class="h-full w-full object-cover transition-transform group-hover:scale-105" />
<div class="absolute top-1 right-1">
<app-provider-badge [provider]="video.provider"></app-provider-badge>
<div class="flex flex-col gap-2">
<a [routerLink]="['/watch', video.id]" [queryParams]="buildQueryParams(video)" [state]="{ video }" class="group flex flex-col gap-2">
<div class="relative aspect-video w-full overflow-hidden rounded-lg bg-zinc-800">
<img [src]="video.thumbnailUrl" [alt]="video.title" class="h-full w-full object-cover transition-transform group-hover:scale-105" />
<div class="absolute top-1 right-1">
<app-provider-badge [provider]="video.provider"></app-provider-badge>
</div>
<span *ngIf="video.durationSec !== undefined" class="absolute bottom-1 right-1 rounded bg-black/80 px-1.5 py-0.5 text-xs text-white">
{{ video.durationSec | duration }}
</span>
</div>
<span *ngIf="video.durationSec !== undefined" class="absolute bottom-1 right-1 rounded bg-black/80 px-1.5 py-0.5 text-xs text-white">
{{ video.durationSec | duration }}
</span>
</div>
<div class="flex flex-col">
<h3 class="text-base font-semibold text-zinc-100 line-clamp-2" [title]="video.title">
{{ video.title }}
</h3>
<div class="text-sm text-zinc-400">
<p *ngIf="video.channelName" class="font-medium">{{ video.channelName }}</p>
<div class="flex gap-2">
<div class="flex flex-col gap-1">
<h3 class="text-base font-semibold text-zinc-100 line-clamp-2" [title]="video.title">
{{ video.title }}
</h3>
<div class="text-xs text-zinc-400 flex gap-2">
<span *ngIf="video.viewCount">{{ video.viewCount | number }} vues</span>
<span *ngIf="video.publishedAt">{{ video.publishedAt | date:'shortDate' }}</span>
</div>
</div>
</a>
<div class="flex items-center justify-between gap-2 text-sm text-zinc-400">
<ng-container *ngIf="channelProviderId as provider; else noProvider">
<ng-container *ngIf="channelExternalId() as externalId; else noLink">
<a
class="flex flex-1 items-center gap-2"
[routerLink]="['/channel', provider, externalId]"
>
<div class="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-zinc-700 bg-zinc-800">
<img
*ngIf="channelAvatarUrl(); else linkedInitials"
[src]="channelAvatarUrl()!"
[alt]="channelDisplayName(video.channelMeta ?? null, video.channelName)"
class="h-full w-full object-cover"
loading="lazy"
/>
<ng-template #linkedInitials>
<span class="text-sm font-semibold text-zinc-200">
{{ channelInitial(video.channelMeta ?? null, video.channelName) }}
</span>
</ng-template>
</div>
<span class="font-medium text-zinc-200 hover:text-red-300">
{{ channelDisplayName(video.channelMeta ?? null, video.channelName) }}
</span>
</a>
<ng-container *ngIf="video.channelMeta as channel">
<app-subscribe-button
class="shrink-0"
[provider]="provider"
[externalId]="externalId"
[channelMeta]="channel"
[optimistic]="false"
></app-subscribe-button>
</ng-container>
</ng-container>
<ng-template #noLink>
<div class="flex flex-1 items-center gap-2">
<div class="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-zinc-700 bg-zinc-800">
<img
*ngIf="channelAvatarUrl(); else noLinkInitials"
[src]="channelAvatarUrl()!"
[alt]="channelDisplayName(video.channelMeta ?? null, video.channelName)"
class="h-full w-full object-cover"
loading="lazy"
/>
<ng-template #noLinkInitials>
<span class="text-sm font-semibold text-zinc-200">
{{ channelInitial(video.channelMeta ?? null, video.channelName) }}
</span>
</ng-template>
</div>
<span class="font-medium text-zinc-200">
{{ channelDisplayName(video.channelMeta ?? null, video.channelName) }}
</span>
</div>
</ng-template>
</ng-container>
<ng-template #noProvider>
<div class="flex flex-1 items-center gap-2">
<div class="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-zinc-700 bg-zinc-800">
<span class="text-sm font-semibold text-zinc-200">
{{ channelInitial(null, video.channelName) }}
</span>
</div>
<span *ngIf="video.channelName" class="font-medium text-zinc-200">
{{ video.channelName }}
</span>
</div>
</ng-template>
</div>
</a>
</div>
@@ -4,13 +4,15 @@ import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { DurationPipe } from '../../pipes/duration.pipe';
import { ProviderBadgeComponent } from '../provider-badge/provider-badge.component';
import { SubscribeButtonComponent } from '../subscribe-button/subscribe-button.component';
import type { ChannelMeta, ProviderId } from '../../models/channel.model';
@Component({
selector: 'app-video-card',
templateUrl: './video-card.component.html',
styleUrls: ['./video-card.component.scss'],
standalone: true,
imports: [CommonModule, RouterLink, DurationPipe, ProviderBadgeComponent],
imports: [CommonModule, RouterLink, DurationPipe, ProviderBadgeComponent, SubscribeButtonComponent],
})
export class VideoCardComponent {
@Input() video!: VideoItem;
@@ -21,4 +23,37 @@ export class VideoCardComponent {
if (v.provider === 'twitch' && v.channel) qp.channel = v.channel;
return qp;
}
get channelProviderId(): ProviderId | null {
const map: Record<VideoItem['provider'], ProviderId> = {
youtube: 'yt',
dailymotion: 'dm',
twitch: 'tw',
peertube: 'pt',
odysee: 'od',
rumble: 'ru',
};
return map[this.video.provider] || null;
}
channelExternalId(): string | null {
return this.video.channelMeta?.externalId ?? this.video.channelExternalId ?? null;
}
channelAvatarUrl(): string | null {
return this.video.channelMeta?.avatarUrl ?? this.video.channelAvatarUrl ?? null;
}
channelDisplayName(channel?: ChannelMeta | null, fallback?: string | null): string {
const preferred = (channel?.title || channel?.handle || fallback || '').toString().trim();
if (preferred.length > 0) {
return preferred;
}
return (channel?.externalId || '').trim();
}
channelInitial(channel?: ChannelMeta | null, fallback?: string | null): string {
const name = this.channelDisplayName(channel, fallback);
return name ? name.charAt(0).toUpperCase() : 'C';
}
}
@@ -0,0 +1,64 @@
import type { ChannelMeta, ProviderId } from './channel.model';
import type { VideoItem } from './video-item.model';
export type ChannelTabId = 'videos' | 'shorts' | 'playlists' | 'live' | 'about';
export type ChannelSort = 'recent' | 'popular';
export type ChannelContentType = 'videos' | 'shorts' | 'playlists' | 'live';
export interface ChannelCapabilities {
videos: boolean;
shorts: boolean;
playlists: boolean;
live: boolean;
about: boolean;
searchInChannel: boolean;
}
export interface ChannelDetail extends ChannelMeta {
bannerUrl?: string | null;
description?: string | null;
videoCount?: number | null;
viewCount?: number | null;
createdAt?: string | null;
links?: { label: string; url: string }[];
capabilities: ChannelCapabilities;
}
export interface ChannelContentPage<T> {
items: T[];
nextPage: number | null;
total?: number | null;
}
export interface ChannelPlaylist {
id: string;
provider: ProviderId;
title: string;
thumbnailUrl?: string | null;
videoCount?: number | null;
updatedAt?: string | null;
}
export const DEFAULT_CAPABILITIES: Record<ProviderId, ChannelCapabilities> = {
yt: { videos: true, shorts: true, playlists: true, live: true, about: true, searchInChannel: true },
dm: { videos: true, shorts: false, playlists: true, live: false, about: true, searchInChannel: true },
tw: { videos: true, shorts: false, playlists: false, live: true, about: true, searchInChannel: true },
pt: { videos: true, shorts: false, playlists: true, live: false, about: true, searchInChannel: true },
od: { videos: true, shorts: false, playlists: false, live: false, about: true, searchInChannel: true },
ru: { videos: true, shorts: false, playlists: false, live: false, about: true, searchInChannel: true },
};
export function buildChannelDetail(meta: ChannelMeta, extra?: Partial<ChannelDetail>): ChannelDetail {
return {
...meta,
bannerUrl: extra?.bannerUrl ?? null,
description: extra?.description ?? null,
videoCount: extra?.videoCount ?? null,
viewCount: extra?.viewCount ?? null,
createdAt: extra?.createdAt ?? null,
links: extra?.links ?? [],
capabilities: extra?.capabilities ?? DEFAULT_CAPABILITIES[meta.provider],
};
}
export type { VideoItem };
+14
View File
@@ -0,0 +1,14 @@
export type ProviderId = 'yt' | 'dm' | 'tw' | 'pt' | 'od' | 'ru';
export interface ChannelMeta {
id?: number;
provider: ProviderId;
externalId: string;
title?: string | null;
handle?: string | null;
avatarUrl?: string | null;
url?: string | null;
subsCount?: number;
verified?: boolean;
lastRefreshedAt?: number | null;
}
@@ -1,3 +1,5 @@
import type { ChannelMeta } from './channel.model';
export interface VideoItem {
id: string; // id vidéo canonicalisé
provider: 'youtube' | 'dailymotion' | 'twitch' | 'peertube' | 'odysee' | 'rumble';
@@ -5,6 +7,9 @@ export interface VideoItem {
thumbnailUrl: string;
durationSec?: number;
channelName?: string;
channelExternalId?: string;
channelAvatarUrl?: string;
channelMeta?: ChannelMeta;
viewCount?: number;
publishedAt?: string; // ISO
// Provider-specific routing hints
+64
View File
@@ -0,0 +1,64 @@
/**
* Classification partagée Vidéo / Short / Live.
*
* Règles (alignées front + back) :
* - `isShort === true` (flag natif) ⇒ Short, sauf durée connue > 75 s.
* - Twitch `kind === 'clip'` ⇒ Short (les clips font ≤ 60 s par construction).
* - YouTube : URL `/shorts/` ⇒ Short ; sinon durée 1..70 s ⇒ Short.
* - Autres providers : durée 1..75 s ⇒ Short.
* - IMPORTANT : une durée `0` / absente ne qualifie JAMAIS un Short
* (les scrapers renvoient 0 quand la durée est inconnue).
*/
export type VideoKind = 'short' | 'live' | 'video';
export interface ClassifiableVideo {
duration?: number | null;
type?: string | null;
kind?: string | null;
url?: string | null;
provider?: string | null;
isShort?: boolean | null;
isLive?: boolean | null;
isVertical?: boolean | null;
}
export const SHORT_MAX_SECONDS = 75;
export const YOUTUBE_SHORT_MAX_SECONDS = 70;
export function isShortVideo(v: ClassifiableVideo | null | undefined): boolean {
if (!v) return false;
const duration = Number((v as { duration?: unknown }).duration ?? 0);
const hasKnownDuration = Number.isFinite(duration) && duration > 0;
const type = String((v as { type?: unknown }).type || '').toLowerCase();
const kind = String((v as { kind?: unknown }).kind || '').toLowerCase();
const url = String((v as { url?: unknown }).url || '');
const provider = String((v as { provider?: unknown }).provider || '').toLowerCase();
// Clips Twitch = équivalent Shorts (durée max 60 s côté Twitch).
if (kind === 'clip') return true;
if (type === 'short') return true;
// Flag natif (Rumble router, YouTube, …) — ignoré si la durée connue le contredit.
if ((v as { isShort?: unknown }).isShort === true) {
if (hasKnownDuration && duration > SHORT_MAX_SECONDS) return false;
return true;
}
if (!hasKnownDuration) return false;
if (url.includes('/shorts/')) return true;
if (provider === 'youtube') return duration <= YOUTUBE_SHORT_MAX_SECONDS;
return duration <= SHORT_MAX_SECONDS;
}
export function isLiveVideo(v: ClassifiableVideo | null | undefined): boolean {
if (!v) return false;
if ((v as { isLive?: unknown }).isLive === true) return true;
const type = String((v as { type?: unknown }).type || '').toLowerCase();
return type === 'live' || type === 'stream' || type === 'channel';
}
export function classifyVideo(v: ClassifiableVideo | null | undefined): VideoKind {
if (isShortVideo(v)) return 'short';
if (isLiveVideo(v)) return 'live';
return 'video';
}
@@ -0,0 +1,40 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { CommonModule, DatePipe } from '@angular/common';
import type { ChannelDetail } from '../../../app/shared/models/channel-detail.model';
@Component({
selector: 'app-channel-about',
standalone: true,
imports: [CommonModule, DatePipe],
template: `
<div *ngIf="channel" class="grid gap-4 rounded-xl border border-zinc-800 bg-zinc-900/50 p-5 text-sm text-zinc-300 lg:grid-cols-2">
<div>
<h3 class="mb-2 text-base font-semibold text-white">Description</h3>
<p class="whitespace-pre-line">{{ channel.description || 'Aucune description disponible.' }}</p>
</div>
<div class="space-y-2">
<h3 class="text-base font-semibold text-white">Informations</h3>
<p *ngIf="channel.handle"><span class="text-zinc-500">Handle : </span>{{ channel.handle }}</p>
<p><span class="text-zinc-500">ID externe : </span><span class="font-mono text-xs">{{ channel.externalId }}</span></p>
<p *ngIf="channel.viewCount != null"><span class="text-zinc-500">Vues totales : </span>{{ channel.viewCount | number }}</p>
<p *ngIf="channel.createdAt"><span class="text-zinc-500">Créée le : </span>{{ channel.createdAt | date:'longDate' }}</p>
<p *ngIf="channel.lastRefreshedAt"><span class="text-zinc-500">Dernière synchro : </span>{{ channel.lastRefreshedAt | date:'medium' }}</p>
<div *ngIf="channel.links?.length">
<p class="text-zinc-500">Liens :</p>
<ul class="mt-1 space-y-1">
<li *ngFor="let link of channel.links">
<a [href]="link.url" target="_blank" rel="noopener" class="text-red-300 hover:text-red-200 hover:underline">{{ link.label }}</a>
</li>
</ul>
</div>
<a *ngIf="channel.url" [href]="channel.url" target="_blank" rel="noopener" class="inline-block rounded-md border border-zinc-700 px-3 py-1.5 text-zinc-200 hover:border-zinc-500 hover:text-white">
Ouvrir la chaîne officielle
</a>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChannelAboutComponent {
@Input() channel: ChannelDetail | null = null;
}
@@ -0,0 +1,83 @@
<div *ngIf="loading" class="animate-pulse">
<div class="h-36 w-full rounded-xl bg-zinc-800 sm:h-48"></div>
<div class="mt-4 flex items-center gap-4 px-1">
<div class="h-24 w-24 rounded-full bg-zinc-800"></div>
<div class="flex-1 space-y-2">
<div class="h-6 w-1/3 rounded bg-zinc-800"></div>
<div class="h-4 w-1/4 rounded bg-zinc-800"></div>
</div>
</div>
</div>
<div *ngIf="!loading && channel" class="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-950">
<!-- Bannière (masquée proprement si absente) -->
<div *ngIf="channel.bannerUrl" class="h-36 w-full overflow-hidden bg-zinc-900 sm:h-48">
<img [src]="channel.bannerUrl" [alt]="" class="h-full w-full object-cover" loading="lazy" />
</div>
<div class="flex flex-col gap-4 p-4 sm:p-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start">
<div class="relative -mt-2 shrink-0 sm:-mt-10">
<img
[src]="channel.avatarUrl || 'https://placehold.co/160x160/png'"
[alt]="channel.title || channel.handle || 'Avatar'"
class="h-24 w-24 rounded-full border-4 border-zinc-950 bg-zinc-800 object-cover sm:h-28 sm:w-28"
/>
</div>
<div class="min-w-0 flex-1">
<h1 class="truncate text-2xl font-bold text-white sm:text-3xl">
{{ channel.title || channel.handle || 'Chaîne inconnue' }}
</h1>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-zinc-400">
<app-provider-badge [provider]="longProvider"></app-provider-badge>
<span *ngIf="channel.handle" class="truncate">{{ channel.handle }}</span>
<span *ngIf="channel.subsCount != null">
<app-compact-number [value]="channel.subsCount"></app-compact-number> abonnés
</span>
<span *ngIf="channel.videoCount != null">
<app-compact-number [value]="channel.videoCount"></app-compact-number> vidéos
</span>
<span *ngIf="channel.verified" class="font-medium text-emerald-300">✓ Vérifiée</span>
</div>
<!-- Description tronquée + ...plus -->
<div *ngIf="channel.description" class="mt-2 max-w-3xl text-sm text-zinc-300">
<p [class.line-clamp-2]="!expanded()">{{ channel.description }}</p>
<button type="button" (click)="toggleExpand()" class="mt-1 font-medium text-zinc-100 hover:text-red-300">
{{ expanded() ? '...moins' : '...plus' }}
</button>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<app-subscribe-button
[provider]="channel.provider"
[externalId]="channel.externalId"
[channelMeta]="channel"
></app-subscribe-button>
<button
type="button"
(click)="share()"
class="rounded-md border border-zinc-700 px-3 py-1.5 text-sm font-medium text-zinc-200 transition hover:border-zinc-500 hover:text-white"
>
Partager
</button>
<a
*ngIf="channel.url"
[href]="channel.url"
target="_blank"
rel="noopener"
class="rounded-md border border-zinc-700 px-3 py-1.5 text-sm font-medium text-zinc-200 transition hover:border-zinc-500 hover:text-white"
>
Ouvrir la chaîne officielle
</a>
<button
type="button"
(click)="refresh.emit()"
class="rounded-md border border-zinc-700 px-3 py-1.5 text-sm font-medium text-zinc-200 transition hover:border-zinc-500 hover:text-white"
>
Rafraîchir
</button>
</div>
</div>
</div>
@@ -0,0 +1,42 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { ChannelDetail } from '../../../app/shared/models/channel-detail.model';
import { ProviderBadgeComponent } from '../../../app/shared/components/provider-badge/provider-badge.component';
import { SubscribeButtonComponent } from '../../../app/shared/components/subscribe-button/subscribe-button.component';
import { CompactNumberComponent } from '../../../app/shared/components/compact-number/compact-number.component';
@Component({
selector: 'app-channel-header',
standalone: true,
imports: [CommonModule, ProviderBadgeComponent, SubscribeButtonComponent, CompactNumberComponent],
templateUrl: './channel-header.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChannelHeaderComponent {
@Input() channel: ChannelDetail | null = null;
@Input() loading = false;
@Output() refresh = new EventEmitter<void>();
readonly expanded = signal(false);
toggleExpand(): void {
this.expanded.update((v) => !v);
}
share(): void {
const url = window.location.href;
const title = this.channel?.title || this.channel?.handle || 'Chaîne';
if (navigator.share) {
navigator.share({ title, url }).catch(() => {});
} else {
navigator.clipboard?.writeText(url).catch(() => {});
}
}
get longProvider(): string | null {
const map: Record<string, string> = {
yt: 'youtube', dm: 'dailymotion', tw: 'twitch', pt: 'peertube', od: 'odysee', ru: 'rumble',
};
return this.channel ? (map[this.channel.provider] ?? null) : null;
}
}
@@ -0,0 +1,97 @@
<div class="container mx-auto max-w-7xl px-4 py-6">
<!-- Erreur header (params invalides / chaîne introuvable) -->
<div *ngIf="headerError() && !headerLoading()" class="rounded-xl border border-red-500/40 bg-red-500/10 p-8 text-center">
<h2 class="mb-2 text-xl font-semibold text-red-100">Impossible de charger la chaîne</h2>
<p class="text-sm text-red-200/80">{{ headerError() }}</p>
<button type="button" (click)="refreshAll(true)" class="mt-4 rounded-md border border-red-500/60 px-4 py-1.5 text-sm text-red-100 hover:bg-red-500/20">
Réessayer
</button>
</div>
<div *ngIf="!headerError() || headerLoading()">
<app-channel-header [channel]="detail()" [loading]="headerLoading()" (refresh)="refreshAll(true)"></app-channel-header>
<div *ngIf="!headerLoading() && detail()" class="mt-4">
<app-channel-tabs [capabilities]="capabilities()" [active]="activeTab()" (select)="onTabSelect($event)"></app-channel-tabs>
<!-- Barre d'outils : tri + recherche locale (masquée sur À propos) -->
<div *ngIf="activeTab() !== 'about'" class="flex flex-col gap-2 py-4 sm:flex-row sm:items-center">
<div *ngIf="activeTab() === 'videos' || activeTab() === 'shorts'" class="flex items-center gap-2 text-sm">
<label for="channel-sort" class="text-zinc-400">Trier :</label>
<select
id="channel-sort"
[value]="sort()"
(change)="onSortChange($any($event.target).value)"
class="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1.5 text-sm text-zinc-100"
>
<option value="recent">Plus récentes</option>
<option value="popular">Plus populaires</option>
</select>
</div>
<div class="relative flex-1 sm:max-w-md sm:ml-auto">
<input
type="search"
placeholder="Rechercher dans cette chaîne…"
[value]="searchQuery()"
(input)="onSearchInput($any($event.target).value)"
class="w-full rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-2 pr-9 text-sm text-zinc-100 placeholder:text-zinc-500 focus:border-red-500 focus:outline-none"
/>
<button *ngIf="searchQuery()" type="button" (click)="clearSearch()" aria-label="Effacer"
class="absolute right-2 top-1/2 -translate-y-1/2 rounded px-1 text-zinc-400 hover:text-white">✕</button>
</div>
</div>
<!-- Onglets contenu -->
<div class="pb-10">
<app-channel-video-grid
*ngIf="activeTab() === 'videos'"
[videos]="videos()"
[loading]="listLoading()"
[loadingMore]="listLoadingMore()"
[hasMore]="hasMore()"
[error]="listError()"
layout="video"
emptyMessage="Aucune vidéo trouvée pour cette chaîne."
(loadMore)="onLoadMore()"
(retry)="onRetryTab()"
></app-channel-video-grid>
<app-channel-video-grid
*ngIf="activeTab() === 'shorts'"
[videos]="shorts()"
[loading]="listLoading()"
[loadingMore]="listLoadingMore()"
[hasMore]="hasMore()"
[error]="listError()"
layout="short"
emptyMessage="Aucun short trouvé pour cette chaîne."
(loadMore)="onLoadMore()"
(retry)="onRetryTab()"
></app-channel-video-grid>
<app-channel-playlists
*ngIf="activeTab() === 'playlists'"
[playlists]="playlists()"
[loading]="listLoading()"
[error]="listError()"
(retry)="onRetryTab()"
></app-channel-playlists>
<app-channel-video-grid
*ngIf="activeTab() === 'live'"
[videos]="streams()"
[loading]="listLoading()"
[loadingMore]="listLoadingMore()"
[hasMore]="hasMore()"
[error]="listError()"
layout="video"
emptyMessage="Aucun stream en direct ou replay pour le moment."
(loadMore)="onLoadMore()"
(retry)="onRetryTab()"
></app-channel-video-grid>
<app-channel-about *ngIf="activeTab() === 'about'" [channel]="detail()"></app-channel-about>
</div>
</div>
</div>
</div>
@@ -0,0 +1,296 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import { Subject, debounceTime, distinctUntilChanged, map, of, switchMap, take } from 'rxjs';
import type { ProviderId } from '../../app/shared/models/channel.model';
import type {
ChannelDetail,
ChannelPlaylist,
ChannelSort,
ChannelTabId,
} from '../../app/shared/models/channel-detail.model';
import { buildChannelDetail } from '../../app/shared/models/channel-detail.model';
import type { VideoItem } from '../../app/shared/models/video-item.model';
import { normalizeChannelProvider, ChannelProviderFactory } from '../../app/core/providers/channel/channel-provider.factory';
import { ChannelsService } from '../../services/channels.service';
import { ChannelContentService } from '../../services/channel-content.service';
import { SubscriptionsService } from '../../services/subscriptions.service';
import { ChannelHeaderComponent } from './channel-header/channel-header.component';
import { ChannelTabsComponent } from './channel-tabs/channel-tabs.component';
import { ChannelVideoGridComponent } from './channel-video-grid/channel-video-grid.component';
import { ChannelPlaylistsComponent } from './channel-playlists/channel-playlists.component';
import { ChannelAboutComponent } from './channel-about/channel-about.component';
@Component({
selector: 'app-channel-page',
standalone: true,
imports: [
CommonModule,
ChannelHeaderComponent,
ChannelTabsComponent,
ChannelVideoGridComponent,
ChannelPlaylistsComponent,
ChannelAboutComponent,
],
templateUrl: './channel-page.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChannelPageComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly channels = inject(ChannelsService);
private readonly content = inject(ChannelContentService);
private readonly providers = inject(ChannelProviderFactory);
private readonly subs = inject(SubscriptionsService);
private readonly searchInput$ = new Subject<string>();
readonly provider = signal<ProviderId | null>(null);
readonly externalId = signal<string | null>(null);
readonly detail = signal<ChannelDetail | null>(null);
readonly headerLoading = signal(true);
readonly headerError = signal<string | null>(null);
readonly activeTab = signal<ChannelTabId>('videos');
readonly sort = signal<ChannelSort>('recent');
readonly searchQuery = signal('');
readonly videos = signal<VideoItem[]>([]);
readonly shorts = signal<VideoItem[]>([]);
readonly streams = signal<VideoItem[]>([]);
readonly playlists = signal<ChannelPlaylist[]>([]);
readonly listLoading = signal(false);
readonly listLoadingMore = signal(false);
readonly listError = signal<string | null>(null);
readonly hasMore = signal(false);
readonly capabilities = computed(() => this.detail()?.capabilities ?? null);
constructor() {
if (this.subs.items().length === 0) {
this.subs.list().pipe(takeUntilDestroyed()).subscribe();
}
// Recherche locale débouncée -> refetch via searchInChannel (ou filtre)
this.searchInput$
.pipe(debounceTime(400), distinctUntilChanged(), takeUntilDestroyed())
.subscribe((q) => {
this.searchQuery.set(q);
this.syncQueryParams();
this.reloadTab(true);
});
this.route.paramMap
.pipe(
map((params) => ({
provider: normalizeChannelProvider(params.get('provider')),
externalId: params.get('externalId')?.trim() || null,
})),
switchMap(({ provider, externalId }) => {
this.provider.set(provider);
this.externalId.set(externalId);
// Onglet / tri / recherche depuis l'URL (?tab=&sort=&q=)
const qp = this.route.snapshot.queryParamMap;
const tab = qp.get('tab') as ChannelTabId | null;
if (tab && ['videos', 'shorts', 'playlists', 'live', 'about'].includes(tab)) this.activeTab.set(tab);
const sort = qp.get('sort') as ChannelSort | null;
if (sort === 'popular' || sort === 'recent') this.sort.set(sort);
this.searchQuery.set(qp.get('q') ?? '');
if (!provider || !externalId) {
this.headerLoading.set(false);
this.headerError.set('channel_invalid_params');
this.detail.set(null);
return of(null);
}
this.headerLoading.set(true);
this.headerError.set(null);
const refresh = qp.get('refresh') === '1' || qp.get('refresh') === 'true';
// Si ?refresh=1, vide aussi le cache des listes
if (refresh) this.content.clear(provider, externalId);
return this.channels.resolve(provider, externalId, { refresh });
}),
takeUntilDestroyed(),
)
.subscribe((meta) => {
this.headerLoading.set(false);
if (!meta) {
this.headerError.set('channel_unavailable');
this.detail.set(null);
return;
}
// Enrichit la meta (cache 6h) en ChannelDetail avec capabilities du provider
const providerApi = this.providers.get(meta.provider);
this.detail.set(
buildChannelDetail(meta, {
capabilities: {
videos: true,
shorts: providerApi.supports('shorts'),
playlists: providerApi.supports('playlists'),
live: providerApi.supports('live'),
about: true,
searchInChannel: providerApi.supports('searchInChannel'),
},
}),
);
this.headerError.set(null);
// Si l'onglet actif n'est pas supporté, retomber sur videos
const capsNow = this.detail()?.capabilities;
if (capsNow && !(capsNow as unknown as Record<string, boolean>)[this.activeTab()]) {
this.activeTab.set('videos');
}
this.reloadTab();
});
}
onTabSelect(tab: ChannelTabId): void {
if (tab === this.activeTab()) return;
this.activeTab.set(tab);
this.syncQueryParams();
this.reloadTab();
}
onSortChange(value: string): void {
this.sort.set(value === 'popular' ? 'popular' : 'recent');
this.syncQueryParams();
this.reloadTab(true);
}
onSearchInput(value: string): void {
this.searchInput$.next(value);
}
clearSearch(): void {
this.searchQuery.set('');
this.syncQueryParams();
this.reloadTab(true);
}
onLoadMore(): void {
const p = this.provider();
const id = this.externalId();
if (!p || !id || this.listLoadingMore() || this.listLoading() || !this.hasMore()) return;
this.listLoadingMore.set(true);
const type = this.tabToContentType();
if (!type) return;
this.content
.loadMore(p, id, type, { sort: this.sort(), q: this.searchQuery() })
.pipe(take(1))
.subscribe((ok) => {
this.listLoadingMore.set(false);
if (ok) this.pullState();
});
}
onRetryTab(): void {
this.reloadTab(true);
}
refreshAll(force = true): void {
const p = this.provider();
const id = this.externalId();
if (!p || !id) return;
this.headerLoading.set(true);
if (force) this.content.clear(p, id);
this.channels
.resolve(p, id, { refresh: force })
.pipe(take(1))
.subscribe({
next: (meta) => {
if (meta) {
const api = this.providers.get(meta.provider);
this.detail.set(
buildChannelDetail(meta, {
description: this.detail()?.description ?? null,
capabilities: {
videos: true,
shorts: api.supports('shorts'),
playlists: api.supports('playlists'),
live: api.supports('live'),
about: true,
searchInChannel: api.supports('searchInChannel'),
},
}),
);
this.headerError.set(null);
} else {
this.headerError.set('channel_unavailable');
}
},
error: () => this.headerError.set('channel_unavailable'),
complete: () => {
this.headerLoading.set(false);
this.reloadTab(true);
},
});
}
private tabToContentType(): 'videos' | 'shorts' | 'playlists' | 'live' | null {
switch (this.activeTab()) {
case 'videos': return 'videos';
case 'shorts': return 'shorts';
case 'playlists': return 'playlists';
case 'live': return 'live';
default: return null;
}
}
private reloadTab(force = false): void {
const p = this.provider();
const id = this.externalId();
const tab = this.activeTab();
if (!p || !id || tab === 'about') return;
this.listLoading.set(true);
this.listError.set(null);
const sort = this.sort();
const q = this.searchQuery();
const done = (ok: boolean) => {
this.listLoading.set(false);
if (!ok) this.listError.set('channel_content_failed');
this.pullState();
};
if (tab === 'videos') {
this.content.loadVideos(p, id, { sort, q, refresh: force }).pipe(take(1)).subscribe({ next: () => done(true), error: () => done(false) });
} else if (tab === 'shorts') {
this.content.loadShorts(p, id, { sort, q, refresh: force }).pipe(take(1)).subscribe({ next: () => done(true), error: () => done(false) });
} else if (tab === 'live') {
this.content.loadStreams(p, id, { q, refresh: force }).pipe(take(1)).subscribe({ next: () => done(true), error: () => done(false) });
} else if (tab === 'playlists') {
this.content.loadPlaylists(p, id, { q, refresh: force }).pipe(take(1)).subscribe({ next: () => done(true), error: () => done(false) });
}
}
private pullState(): void {
const p = this.provider();
const id = this.externalId();
if (!p || !id) return;
const sort = this.sort();
const q = this.searchQuery();
const tab = this.activeTab();
if (tab === 'videos') {
const s = this.content.snapshot<VideoItem>(p, id, 'videos', sort, q);
this.videos.set(s.items); this.listError.set(s.error); this.hasMore.set(s.nextPage != null);
} else if (tab === 'shorts') {
const s = this.content.snapshot<VideoItem>(p, id, 'shorts', sort, q);
this.shorts.set(s.items); this.listError.set(s.error); this.hasMore.set(s.nextPage != null);
} else if (tab === 'live') {
const s = this.content.snapshot<VideoItem>(p, id, 'live', 'recent', q);
this.streams.set(s.items); this.listError.set(s.error); this.hasMore.set(s.nextPage != null);
} else if (tab === 'playlists') {
const s = this.content.snapshot<ChannelPlaylist>(p, id, 'playlists', 'recent', q);
this.playlists.set(s.items); this.listError.set(s.error); this.hasMore.set(s.nextPage != null);
}
}
private syncQueryParams(): void {
const p = this.provider();
const id = this.externalId();
if (!p || !id) return;
this.router.navigate(['/channel', p, id], {
queryParams: { tab: this.activeTab(), sort: this.sort(), q: this.searchQuery() || null },
queryParamsHandling: 'merge',
replaceUrl: true,
}).catch(() => {});
}
}
@@ -0,0 +1,46 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { ChannelPlaylist } from '../../../app/shared/models/channel-detail.model';
import { VideoCardSkeletonComponent } from '../../../app/shared/components/video-card-skeleton/video-card-skeleton.component';
@Component({
selector: 'app-channel-playlists',
standalone: true,
imports: [CommonModule, VideoCardSkeletonComponent],
template: `
<div *ngIf="loading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<app-video-card-skeleton *ngFor="let _ of [0,1,2,3,4,5]"></app-video-card-skeleton>
</div>
<div *ngIf="!loading && error" class="flex flex-col items-center gap-3 rounded-xl border border-red-500/40 bg-red-500/10 p-8 text-center">
<h3 class="text-lg font-semibold text-red-100">Impossible de charger les playlists</h3>
<button type="button" (click)="retry.emit()" class="rounded-md border border-red-500/60 px-4 py-1.5 text-sm text-red-100 hover:bg-red-500/20">Réessayer</button>
</div>
<div *ngIf="!loading && !error && playlists.length === 0" class="flex flex-col items-center gap-2 rounded-xl border border-zinc-800 bg-zinc-900/50 p-10 text-center">
<h3 class="text-lg font-semibold text-white">Aucune playlist trouvée</h3>
<p class="text-sm text-zinc-400">Cette chaîne ne propose pas encore de playlists.</p>
</div>
<div *ngIf="!loading && !error && playlists.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div *ngFor="let pl of playlists" class="group overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 transition hover:border-zinc-600">
<div class="relative aspect-video w-full overflow-hidden bg-zinc-800">
<img *ngIf="pl.thumbnailUrl; else noThumb" [src]="pl.thumbnailUrl!" [alt]="pl.title" loading="lazy" class="h-full w-full object-cover" />
<ng-template #noThumb><div class="flex h-full w-full items-center justify-center text-4xl text-zinc-600">▤</div></ng-template>
<div class="absolute inset-y-0 right-0 flex w-24 flex-col items-center justify-center gap-1 bg-black/70 text-white">
<span class="text-xl font-bold">{{ pl.videoCount ?? '–' }}</span>
<span class="text-xs">vidéos</span>
</div>
</div>
<div class="p-3">
<h3 class="line-clamp-2 text-sm font-semibold text-zinc-100">{{ pl.title }}</h3>
<p *ngIf="pl.updatedAt" class="mt-1 text-xs text-zinc-400">Mis à jour le {{ pl.updatedAt | date:'shortDate' }}</p>
</div>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChannelPlaylistsComponent {
@Input() playlists: ChannelPlaylist[] = [];
@Input() loading = false;
@Input() error: string | null = null;
@Output() retry = new EventEmitter<void>();
}
@@ -0,0 +1,53 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { ChannelCapabilities, ChannelTabId } from '../../../app/shared/models/channel-detail.model';
export interface ChannelTabDef {
id: ChannelTabId;
label: string;
}
const ALL_TABS: ChannelTabDef[] = [
{ id: 'videos', label: 'Vidéos' },
{ id: 'shorts', label: 'Shorts' },
{ id: 'playlists', label: 'Playlists' },
{ id: 'live', label: 'En direct' },
{ id: 'about', label: 'À propos' },
];
@Component({
selector: 'app-channel-tabs',
standalone: true,
imports: [CommonModule],
template: `
<nav class="sticky top-0 z-10 -mx-4 border-b border-zinc-800 bg-zinc-950/95 px-4 backdrop-blur" aria-label="Onglets chaîne">
<div class="flex gap-1 overflow-x-auto py-2" role="tablist">
<button
*ngFor="let tab of visibleTabs"
type="button"
role="tab"
[attr.aria-selected]="tab.id === active"
(click)="select.emit(tab.id)"
class="whitespace-nowrap rounded-lg px-4 py-2 text-sm font-medium transition"
[class.bg-red-500/20]="tab.id === active"
[class.text-red-100]="tab.id === active"
[class.text-zinc-400]="tab.id !== active"
[class.hover:text-white]="tab.id !== active"
>
{{ tab.label }}
</button>
</div>
</nav>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChannelTabsComponent {
@Input() capabilities: ChannelCapabilities | null = null;
@Input() active: ChannelTabId = 'videos';
@Output() select = new EventEmitter<ChannelTabId>();
get visibleTabs(): ChannelTabDef[] {
if (!this.capabilities) return [{ id: 'videos', label: 'Vidéos' }];
return ALL_TABS.filter((t) => (this.capabilities as unknown as Record<string, boolean>)[t.id]);
}
}
@@ -0,0 +1,46 @@
<!-- Loading initial : squelettes -->
<div *ngIf="loading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<app-video-card-skeleton *ngFor="let _ of [0,1,2,3,4,5,6,7]"></app-video-card-skeleton>
</div>
<!-- Erreur -->
<div *ngIf="!loading && error" class="flex flex-col items-center gap-3 rounded-xl border border-red-500/40 bg-red-500/10 p-8 text-center">
<h3 class="text-lg font-semibold text-red-100">Impossible de charger le contenu</h3>
<p class="text-sm text-red-200/80">{{ error }}</p>
<button type="button" (click)="retry.emit()" class="rounded-md border border-red-500/60 px-4 py-1.5 text-sm font-medium text-red-100 hover:bg-red-500/20">
Réessayer
</button>
</div>
<!-- Vide -->
<div *ngIf="!loading && !error && videos.length === 0" class="flex flex-col items-center gap-2 rounded-xl border border-zinc-800 bg-zinc-900/50 p-10 text-center">
<h3 class="text-lg font-semibold text-white">{{ emptyMessage }}</h3>
<p class="text-sm text-zinc-400">Essayez un autre onglet ou revenez plus tard.</p>
</div>
<!-- Grille vidéos classiques -->
<div *ngIf="!loading && !error && videos.length > 0 && layout === 'video'" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<app-video-card *ngFor="let video of videos" [video]="video"></app-video-card>
</div>
<!-- Grille shorts verticale 9:16 -->
<div *ngIf="!loading && !error && videos.length > 0 && layout === 'short'" class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
<a *ngFor="let video of videos" [routerLink]="['/watch', video.id]" [queryParams]="{ p: video.provider }"
class="group overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900">
<div class="aspect-[9/16] w-full overflow-hidden bg-zinc-800">
<img [src]="video.thumbnailUrl" [alt]="video.title" loading="lazy" class="h-full w-full object-cover transition-transform group-hover:scale-105" />
</div>
<div class="p-2">
<h3 class="line-clamp-2 text-sm font-medium text-zinc-100">{{ video.title }}</h3>
<p *ngIf="video.viewCount" class="mt-1 text-xs text-zinc-400">{{ video.viewCount | number }} vues</p>
</div>
</a>
</div>
<!-- Sentinelle infinite scroll -->
<div *ngIf="hasMore && !loading && !error" #sentinel class="flex justify-center py-6" aria-hidden="true">
<div *ngIf="loadingMore" class="flex items-center gap-2 text-sm text-zinc-400">
<span class="h-5 w-5 animate-spin rounded-full border-2 border-zinc-700 border-t-red-500"></span>
Chargement…
</div>
</div>
@@ -0,0 +1,69 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
ElementRef,
EventEmitter,
Input,
OnChanges,
OnDestroy,
Output,
SimpleChanges,
ViewChild,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import type { VideoItem } from '../../../app/shared/models/video-item.model';
import { VideoCardComponent } from '../../../app/shared/components/video-card/video-card.component';
import { VideoCardSkeletonComponent } from '../../../app/shared/components/video-card-skeleton/video-card-skeleton.component';
@Component({
selector: 'app-channel-video-grid',
standalone: true,
imports: [CommonModule, RouterLink, VideoCardComponent, VideoCardSkeletonComponent],
templateUrl: './channel-video-grid.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChannelVideoGridComponent implements AfterViewInit, OnChanges, OnDestroy {
@Input() videos: VideoItem[] = [];
@Input() loading = false;
@Input() loadingMore = false;
@Input() hasMore = false;
@Input() error: string | null = null;
/** 'video' (16:9) ou 'short' (9:16) */
@Input() layout: 'video' | 'short' = 'video';
@Input() emptyMessage = 'Aucune vidéo trouvée.';
@Output() loadMore = new EventEmitter<void>();
@Output() retry = new EventEmitter<void>();
@ViewChild('sentinel', { static: false }) sentinel?: ElementRef<HTMLDivElement>;
private observer: IntersectionObserver | null = null;
ngAfterViewInit(): void {
this.observe();
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['hasMore'] || changes['videos']) {
// Ré-observe après chaque rendu (sentinelle recréée par *ngIf)
queueMicrotask(() => this.observe());
}
}
ngOnDestroy(): void {
this.observer?.disconnect();
}
private observe(): void {
this.observer?.disconnect();
const el = this.sentinel?.nativeElement;
if (!el) return;
this.observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) this.loadMore.emit();
},
{ rootMargin: '600px' },
);
this.observer.observe(el);
}
}
@@ -0,0 +1,50 @@
<section class="mx-auto max-w-4xl px-4 py-6">
<a routerLink="/library/downloads"
class="inline-flex items-center gap-2 text-sm text-slate-300 hover:text-white transition">
<span aria-hidden="true">←</span> {{ 'downloads.back' | t }}
</a>
@if (loading()) {
<div class="mt-6 text-sm text-slate-400">{{ 'loading.more' | t }}</div>
} @else if (error(); as errKey) {
<div class="mt-6 rounded-lg border border-red-700/60 bg-red-900/30 px-4 py-3 text-sm text-red-200">
{{ errKey | t }}
</div>
} @else if (job(); as j) {
<div class="mt-4 flex flex-wrap items-center gap-2">
<app-provider-badge [provider]="j.provider" />
<h1 class="min-w-0 flex-1 truncate text-xl font-bold text-slate-100" [title]="j.title || j.videoId">
{{ j.title || j.videoId }}
</h1>
</div>
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-400">
@if (j.fileSize) {
<span>{{ formatBytes(j.fileSize) }}</span>
}
@if (j.audioOnly) {
<span>{{ 'downloads.audioOnly' | t }}</span>
}
</div>
@if (mediaUrl(); as src) {
<div class="mt-4 overflow-hidden rounded-xl border border-slate-700/60 bg-black">
@if (isAudio()) {
<audio [src]="src" controls preload="metadata" class="w-full"></audio>
} @else {
<video [src]="src" controls preload="metadata" playsinline class="max-h-[70vh] w-full"></video>
}
</div>
}
<div class="mt-4 flex flex-wrap gap-2">
<a [routerLink]="['/watch', j.provider, j.videoId]" queryParamsHandling="merge"
class="rounded-lg border border-slate-600 px-3 py-1.5 text-xs text-slate-200 hover:bg-slate-700 transition">
Watch
</a>
<button type="button" (click)="downloadCopy()" [disabled]="downloading()"
class="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-500 disabled:opacity-50 transition">
{{ 'downloads.getFile' | t }}
</button>
</div>
}
</section>
@@ -0,0 +1,119 @@
import { ChangeDetectionStrategy, Component, OnDestroy, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { DownloadService, type DownloadJob } from '../../../services/download.service';
import { AuthService } from '../../../services/auth.service';
import { TranslatePipe } from '../../../pipes/translate.pipe';
import { ProviderBadgeComponent } from '../../../app/shared/components/provider-badge/provider-badge.component';
const AUDIO_EXTS = new Set(['mp3', 'm4a', 'aac', 'opus', 'ogg', 'oga', 'wav', 'flac', 'weba']);
@Component({
selector: 'app-download-play',
standalone: true,
imports: [CommonModule, RouterLink, TranslatePipe, ProviderBadgeComponent],
templateUrl: './download-play.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DownloadPlayComponent implements OnDestroy {
private route = inject(ActivatedRoute);
private downloads = inject(DownloadService);
private auth = inject(AuthService);
readonly job = signal<DownloadJob | null>(null);
readonly loading = signal<boolean>(true);
readonly error = signal<string | null>(null);
readonly mediaUrl = signal<string | null>(null);
readonly isAudio = signal<boolean>(false);
readonly downloading = signal<boolean>(false);
private objectUrl: string | null = null;
constructor() {
const id = (this.route.snapshot.paramMap.get('id') || '').trim();
if (!id) {
this.error.set('downloads.loadError');
this.loading.set(false);
return;
}
if (!this.auth.currentUser()) {
this.error.set('downloads.loadError');
this.loading.set(false);
return;
}
this.downloads.getJob(id).subscribe({
next: (job) => {
if (!job || job.state !== 'completed') {
this.error.set('downloads.notReady');
this.loading.set(false);
return;
}
this.job.set(job);
const ext = String((job as any)?.fileExt || '').toLowerCase();
this.isAudio.set(Boolean((job as any)?.audioOnly) || AUDIO_EXTS.has(ext));
this.downloads.downloadFileBlob(job.id).subscribe({
next: ({ blob }) => {
try {
if (this.objectUrl) URL.revokeObjectURL(this.objectUrl);
this.objectUrl = URL.createObjectURL(blob);
this.mediaUrl.set(this.objectUrl);
} catch {
this.error.set('downloads.loadError');
}
this.loading.set(false);
},
error: (err) => {
this.error.set(err?.status === 401 ? 'downloads.fileError' : 'downloads.loadError');
this.loading.set(false);
},
});
},
error: (err) => {
this.error.set(err?.status === 401 ? 'downloads.fileError' : 'downloads.loadError');
this.loading.set(false);
},
});
}
ngOnDestroy(): void {
try {
if (this.objectUrl) URL.revokeObjectURL(this.objectUrl);
} catch {}
this.objectUrl = null;
}
downloadCopy(): void {
const job = this.job();
if (!job || this.downloading()) return;
this.downloading.set(true);
this.downloads.downloadFileBlob(job.id).subscribe({
next: ({ blob, filename }) => {
try {
const a = document.createElement('a');
const url = URL.createObjectURL(blob);
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 0);
} catch {}
this.downloading.set(false);
},
error: () => {
this.error.set('downloads.fileError');
this.downloading.set(false);
},
});
}
formatBytes(n?: number | null): string {
const x = typeof n === 'number' ? n : 0;
if (x <= 0) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(units.length - 1, Math.floor(Math.log(x) / Math.log(1024)));
return `${(x / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
}
}
@@ -0,0 +1,138 @@
<section class="mx-auto max-w-5xl px-4 py-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-2xl font-bold text-slate-100">{{ 'downloads.title' | t }}</h1>
<button
type="button"
(click)="reload()"
class="rounded-lg border border-slate-700 px-3 py-1.5 text-sm text-slate-200 hover:bg-slate-800 transition">
{{ 'downloads.refresh' | t }}
</button>
</div>
<!-- Storage quota -->
@if (quota(); as q) {
<div class="mt-4 rounded-xl border border-slate-700/60 bg-slate-800/40 p-4">
<div class="flex items-center justify-between text-sm text-slate-300">
<span>{{ 'downloads.quota' | t }}</span>
<span>{{ formatBytes(q.usedBytes) }} / {{ formatBytes(q.quotaBytes) }}</span>
</div>
@if (quotaPct(); as pct) {
<div class="mt-2 h-2 w-full overflow-hidden rounded-full bg-slate-700">
<div class="h-full rounded-full bg-sky-500 transition-all" [style.width.%]="pct"></div>
</div>
}
</div>
}
<!-- State filters -->
<div class="mt-4 flex flex-wrap gap-2">
<button type="button" (click)="setFilter('all')"
class="rounded-full px-3 py-1 text-sm transition"
[ngClass]="stateFilter() === 'all' ? 'bg-sky-600 text-white' : 'bg-slate-800 text-slate-300 hover:bg-slate-700'">
{{ 'downloads.filter.all' | t }}
</button>
<button type="button" (click)="setFilter('active')"
class="rounded-full px-3 py-1 text-sm transition"
[ngClass]="stateFilter() === 'active' ? 'bg-sky-600 text-white' : 'bg-slate-800 text-slate-300 hover:bg-slate-700'">
{{ 'downloads.filter.active' | t }} ({{ activeCount() }})
</button>
<button type="button" (click)="setFilter('completed')"
class="rounded-full px-3 py-1 text-sm transition"
[ngClass]="stateFilter() === 'completed' ? 'bg-sky-600 text-white' : 'bg-slate-800 text-slate-300 hover:bg-slate-700'">
{{ 'downloads.filter.completed' | t }}
</button>
<button type="button" (click)="setFilter('failed')"
class="rounded-full px-3 py-1 text-sm transition"
[ngClass]="stateFilter() === 'failed' ? 'bg-sky-600 text-white' : 'bg-slate-800 text-slate-300 hover:bg-slate-700'">
{{ 'downloads.filter.failed' | t }}
</button>
</div>
<!-- Error -->
@if (error(); as err) {
<div class="mt-4 rounded-lg border border-red-700/60 bg-red-900/30 px-4 py-3 text-sm text-red-200">{{ err }}</div>
}
@if (fileError(); as ferr) {
<div class="mt-4 rounded-lg border border-red-700/60 bg-red-900/30 px-4 py-3 text-sm text-red-200">{{ ferr }}</div>
}
<!-- Loading -->
@if (loading()) {
<div class="mt-6 text-sm text-slate-400">{{ 'loading.more' | t }}</div>
}
<!-- Empty state -->
@if (!loading() && filteredItems().length === 0) {
<div class="mt-6 rounded-xl border border-dashed border-slate-700 p-8 text-center text-sm text-slate-400">
{{ 'downloads.empty' | t }}
</div>
}
<!-- Job list -->
<ul class="mt-4 space-y-3">
@for (job of filteredItems(); track job.id) {
<li class="rounded-xl border border-slate-700/60 bg-slate-800/40 p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<app-provider-badge [provider]="job.provider" />
<a [routerLink]="watchLink(job)" queryParamsHandling="merge"
class="truncate text-sm font-semibold text-slate-100 hover:text-sky-400"
[title]="job.title || job.videoId">
{{ job.title || job.videoId }}
</a>
</div>
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-400">
<span class="rounded px-1.5 py-0.5 font-medium"
[ngClass]="{
'bg-sky-900/60 text-sky-300': isActive(job),
'bg-emerald-900/60 text-emerald-300': job.state === 'completed',
'bg-red-900/60 text-red-300': job.state === 'failed',
'bg-amber-900/60 text-amber-300': job.state === 'interrupted'
}">
{{ 'downloads.state.' + job.state | t }}
</span>
@if (job.audioOnly) {
<span>{{ 'downloads.audioOnly' | t }}</span>
}
@if (job.fileSize) {
<span>{{ formatBytes(job.fileSize) }}</span>
}
@if (job.error) {
<span class="truncate text-red-300" [title]="job.error">{{ job.error }}</span>
}
</div>
@if (isActive(job)) {
<div class="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-700">
<div class="h-full rounded-full bg-sky-500 transition-all" [style.width.%]="job.progress"></div>
</div>
}
</div>
<div class="flex flex-shrink-0 items-center gap-2">
@if (job.state === 'completed') {
<a [routerLink]="playLink(job)"
class="rounded-lg bg-sky-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-sky-500 transition">
{{ 'downloads.play' | t }}
</a>
<button type="button" (click)="getFile(job)" [disabled]="fetchingFileIds().has(job.id)"
class="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-500 disabled:opacity-50 transition">
{{ 'downloads.getFile' | t }}
</button>
}
@if (job.state === 'failed' || job.state === 'interrupted') {
<button type="button" (click)="retry(job)" [disabled]="busyIds().has(job.id)"
class="rounded-lg bg-sky-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-sky-500 disabled:opacity-50 transition">
{{ 'downloads.retry' | t }}
</button>
}
<button type="button" (click)="cancel(job)" [disabled]="busyIds().has(job.id)"
class="rounded-lg border border-slate-600 px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-700 disabled:opacity-50 transition">
{{ 'btn.remove' | t }}
</button>
</div>
</div>
</li>
}
</ul>
</section>
@@ -0,0 +1,208 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { DownloadService, type DownloadJob, type DownloadQuota } from '../../../services/download.service';
import { AuthService } from '../../../services/auth.service';
import { TranslatePipe } from '../../../pipes/translate.pipe';
import { ProviderBadgeComponent } from '../../../app/shared/components/provider-badge/provider-badge.component';
type StateFilter = 'all' | 'active' | 'completed' | 'failed';
const ACTIVE_STATES = new Set(['queued', 'running', 'merging']);
@Component({
selector: 'app-library-downloads',
standalone: true,
imports: [CommonModule, RouterLink, TranslatePipe, ProviderBadgeComponent],
templateUrl: './downloads.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DownloadsComponent {
private downloads = inject(DownloadService);
private auth = inject(AuthService);
readonly loading = signal<boolean>(true);
readonly error = signal<string | null>(null);
readonly items = signal<DownloadJob[]>([]);
readonly quota = signal<DownloadQuota | null>(null);
readonly stateFilter = signal<StateFilter>('all');
readonly busyIds = signal<Set<string>>(new Set());
readonly fileError = signal<string | null>(null);
readonly fetchingFileIds = signal<Set<string>>(new Set());
readonly filteredItems = computed(() => {
const filter = this.stateFilter();
const list = this.items();
if (filter === 'all') return list;
if (filter === 'active') return list.filter((j) => ACTIVE_STATES.has(j.state));
return list.filter((j) => j.state === filter);
});
readonly activeCount = computed(() => this.items().filter((j) => ACTIVE_STATES.has(j.state)).length);
readonly quotaPct = computed(() => {
const q = this.quota();
if (!q || !q.quotaBytes) return null;
return Math.min(100, Math.round((q.usedBytes / q.quotaBytes) * 100));
});
// Poller keeps active job progress fresh while the page is open
private poller: any = null;
constructor() {
this.reload();
}
isLoggedIn(): boolean {
return this.auth.currentUser() !== null;
}
reload(): void {
this.loading.set(true);
this.error.set(null);
this.downloads.listJobs({ limit: 100 }).subscribe({
next: (res) => {
this.items.set(res.items || []);
this.quota.set(res.quota ?? null);
this.loading.set(false);
this.syncPoller();
},
error: (err) => {
if (err?.status === 401) {
this.error.set('Connectez-vous pour voir vos téléchargements.');
} else {
this.error.set('Impossible de charger la file de téléchargement.');
}
this.loading.set(false);
},
});
}
setFilter(f: StateFilter): void {
this.stateFilter.set(f);
}
isActive(job: DownloadJob): boolean {
return ACTIVE_STATES.has(job.state);
}
retry(job: DownloadJob): void {
if (this.busyIds().has(job.id)) return;
this.markBusy(job.id, true);
this.downloads.retryJob(job.id).subscribe({
next: () => {
this.markBusy(job.id, false);
this.reload();
},
error: () => {
this.markBusy(job.id, false);
this.error.set('Nouvel essai impossible pour le moment.');
},
});
}
cancel(job: DownloadJob): void {
if (this.busyIds().has(job.id)) return;
this.markBusy(job.id, true);
this.downloads.cancelJob(job.id).subscribe({
next: () => {
this.markBusy(job.id, false);
this.items.update((list) => list.filter((j) => j.id !== job.id));
},
error: () => {
this.markBusy(job.id, false);
this.error.set('Suppression impossible pour le moment.');
},
});
}
// Deep link back to the watch page for a job's video
watchLink(job: DownloadJob): any[] {
return ['/watch', job.provider, job.videoId];
}
// Deep link to the local playback page for a completed job
playLink(job: DownloadJob): any[] {
return ['/library/downloads', job.id];
}
/**
* "Obtenir le fichier" via requête authentifiée (Bearer) puis blob :
* ne dépend pas des cookies de session (liens <a href> directs -> 401).
*/
getFile(job: DownloadJob): void {
if (this.fetchingFileIds().has(job.id)) return;
this.fileError.set(null);
this.fetchingFileIds.update((set) => new Set(set).add(job.id));
this.downloads.downloadFileBlob(job.id).subscribe({
next: ({ blob, filename }) => {
try {
const a = document.createElement('a');
const objUrl = URL.createObjectURL(blob);
a.href = objUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(objUrl);
}, 0);
} catch {}
this.fetchingFileIds.update((set) => {
const next = new Set(set);
next.delete(job.id);
return next;
});
},
error: (err) => {
this.fetchingFileIds.update((set) => {
const next = new Set(set);
next.delete(job.id);
return next;
});
this.fileError.set(
err?.status === 401
? 'Session expirée. Reconnectez-vous puis réessayez.'
: 'Fichier inaccessible pour le moment.'
);
},
});
}
formatBytes(n?: number | null): string {
const x = typeof n === 'number' ? n : 0;
if (x <= 0) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(units.length - 1, Math.floor(Math.log(x) / Math.log(1024)));
return `${(x / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
}
private markBusy(id: string, busy: boolean): void {
this.busyIds.update((set) => {
const next = new Set(set);
if (busy) next.add(id);
else next.delete(id);
return next;
});
}
private syncPoller(): void {
const hasActive = this.items().some((j) => ACTIVE_STATES.has(j.state));
if (hasActive && !this.poller) {
this.poller = setInterval(() => this.refreshActive(), 3000);
} else if (!hasActive && this.poller) {
clearInterval(this.poller);
this.poller = null;
}
}
private refreshActive(): void {
this.downloads.listJobs({ limit: 100 }).subscribe({
next: (res) => {
this.items.set(res.items || []);
this.quota.set(res.quota ?? null);
this.syncPoller();
},
error: () => {},
});
}
}
@@ -136,6 +136,10 @@ export class PlaylistsComponent {
savePlaylist() {
if (!this.formTitle().trim()) return;
if (!this.currentUser()) {
this.error.set('Connectez-vous pour créer une playlist.');
return;
}
this.busySave.set(true);
const body = {
title: this.formTitle().trim(),
@@ -149,7 +153,13 @@ export class PlaylistsComponent {
: this.http.post(`${this.apiBase()}/playlists`, body, { withCredentials: true });
req.subscribe({
next: () => { this.busySave.set(false); this.modalOpen.set(false); this.reload(this.searchQuery().trim() || undefined); },
error: (err) => { this.error.set(err?.error?.error || err?.message || 'Échec de l’enregistrement'); this.busySave.set(false); }
error: (err) => {
this.error.set(
err?.status === 401 ? 'Session expirée. Reconnectez-vous puis réessayez.'
: (err?.error?.error || err?.message || 'Échec de l’enregistrement')
);
this.busySave.set(false);
}
});
}
@@ -1,6 +1,146 @@
<div class="container mx-auto p-4 sm:p-6">
<h2 class="text-3xl font-bold mb-6 text-slate-100 border-l-4 border-red-500 pl-4">Abonnements</h2>
<div class="bg-slate-800/50 rounded-lg p-6 border border-slate-700">
<p class="text-slate-300">Section pour suivre vos abonnements (à implémenter). Vous verrez ici les dernières vidéos des chaînes suivies sur chaque provider.</p>
<div class="mb-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 class="text-3xl font-bold text-slate-100">Abonnements</h2>
<p class="text-sm text-slate-400">Gérez vos chaînes suivies multi-fournisseurs</p>
</div>
<div class="flex flex-wrap gap-2">
<button
class="rounded-md border border-slate-700 bg-slate-800 px-3 py-1.5 text-sm text-slate-200 transition hover:border-slate-500 hover:text-white"
(click)="refresh()"
type="button"
>
Rafraîchir
</button>
</div>
</div>
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
<div class="bg-slate-900/60 rounded-xl border border-slate-800 p-4 shadow-lg shadow-black/30">
<div class="flex flex-wrap items-center gap-2 border-b border-slate-800 pb-4">
<span class="text-xs uppercase tracking-wide text-slate-400">Filtres</span>
<div class="flex flex-wrap gap-2">
<button
*ngFor="let filter of filters"
type="button"
(click)="setFilter(filter.id)"
class="rounded-full border px-3 py-1 text-xs font-semibold transition"
[class.bg-red-500/20]="activeFilter() === filter.id"
[class.text-red-200]="activeFilter() === filter.id"
[class.border-red-500/40]="activeFilter() === filter.id"
[class.border-slate-700]="activeFilter() !== filter.id"
[class.text-slate-300]="activeFilter() !== filter.id"
[class.bg-slate-800/70]="activeFilter() !== filter.id"
>
{{ filter.label }}
</button>
</div>
</div>
<div class="flex items-center justify-between py-4">
<div class="flex items-center gap-2 text-sm text-slate-400">
<span>Trier par :</span>
<button
*ngFor="let sort of sorts"
type="button"
(click)="setSort(sort.key)"
class="rounded-md px-3 py-1 text-xs transition"
[class.bg-emerald-500/20]="activeSort() === sort.key"
[class.text-emerald-200]="activeSort() === sort.key"
[class.bg-slate-800/60]="activeSort() !== sort.key"
[class.text-slate-300]="activeSort() !== sort.key"
>
{{ sort.label }}
</button>
</div>
</div>
<ng-container *ngIf="filteredItems().length > 0; else emptyState">
<div class="divide-y divide-slate-800">
<div
*ngFor="let item of filteredItems()"
class="flex flex-col gap-4 py-4 md:flex-row md:items-center md:justify-between"
>
<ng-container *ngIf="item.channel as channel; else subscriptionFallback">
<div class="flex items-center gap-4">
<img
[src]="channel.avatarUrl || 'https://placehold.co/80x80/png'"
[alt]="channel.title || channel.handle || 'Avatar'"
class="h-16 w-16 rounded-full border border-slate-800 object-cover"
/>
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-lg font-semibold text-slate-100">
{{ channel.title || channel.handle || 'Chaîne inconnue' }}
</h3>
<app-provider-badge [provider]="channel.provider"></app-provider-badge>
<span *ngIf="channel.verified" class="text-xs text-emerald-300">✓ Vérifiée</span>
</div>
<div class="flex flex-wrap gap-3 text-sm text-slate-400">
<span *ngIf="channel.handle">{{ channel.handle }}</span>
<span *ngIf="channel.subsCount != null">
{{ channel.subsCount | number }} abonnés
</span>
<span class="text-slate-500">
Abonné depuis {{ item.createdAt | date:'mediumDate' }}
</span>
</div>
<a
*ngIf="channel.url"
class="text-xs text-blue-300 hover:text-blue-200"
[href]="channel.url"
target="_blank"
rel="noopener"
>
Voir la chaîne externe
</a>
</div>
</div>
<div class="flex flex-col items-stretch gap-2 text-sm md:text-right">
<app-subscribe-button
[provider]="channel.provider"
[externalId]="channel.externalId"
[channelMeta]="channel"
(unsubscribed)="refresh()"
(subscribed)="refresh()"
></app-subscribe-button>
<button
class="rounded-md border border-slate-700 bg-slate-800/60 px-3 py-1 text-sm text-slate-300 transition hover:border-slate-500 hover:text-white"
type="button"
(click)="openChannel(item)"
>
Voir la chaîne
</button>
</div>
</ng-container>
<ng-template #subscriptionFallback>
<div class="flex-1 text-sm text-slate-400">
<p class="font-medium text-slate-200">Chaîne non disponible</p>
<p class="text-xs">Essayez de rafraîchir l'abonnement plus tard.</p>
</div>
</ng-template>
</div>
</div>
</ng-container>
<ng-template #emptyState>
<div class="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-slate-700 bg-slate-900/60 p-10 text-center">
<h3 class="text-lg font-semibold text-slate-100">Aucune chaîne suivie</h3>
<p class="text-sm text-slate-400">Explorez la page Trending ou effectuez une recherche pour commencer à vous abonner.</p>
</div>
</ng-template>
</div>
<aside class="rounded-xl border border-slate-800 bg-slate-900/50 p-4 shadow-lg shadow-black/30">
<h3 class="text-lg font-semibold text-slate-100">Astuce</h3>
<p class="mt-2 text-sm text-slate-400">
Utilisez le raccourci <code class="rounded bg-slate-800 px-2 py-0.5 text-xs">S</code> sur une carte vidéo pour activer
rapidement le bouton d'abonnement. Les abonnements sont synchronisés sur tous vos appareils.
</p>
<p class="mt-4 text-sm text-slate-500">
Cache TTL: {{ hasCache() ? '6h' : '—' }}.
</p>
</aside>
</div>
</div>
@@ -1,11 +1,101 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DatePipe, DecimalPipe } from '@angular/common';
import { Router } from '@angular/router';
import { SubscriptionsService, type SubscriptionItem } from '../../../services/subscriptions.service';
import type { ProviderId } from '../../../app/shared/models/channel.model';
import { ProviderBadgeComponent } from '../../../app/shared/components/provider-badge/provider-badge.component';
import { SubscribeButtonComponent } from '../../../app/shared/components/subscribe-button/subscribe-button.component';
interface FilterChip {
id: 'all' | ProviderId;
label: string;
}
type SortKey = 'alpha' | 'recent' | 'subs';
@Component({
selector: 'app-library-subscriptions',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, DatePipe, DecimalPipe, ProviderBadgeComponent, SubscribeButtonComponent],
templateUrl: './subscriptions.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SubscriptionsComponent { }
export class SubscriptionsComponent {
private subs = inject(SubscriptionsService);
private router = inject(Router);
filters: FilterChip[] = [
{ id: 'all', label: 'Tous' },
{ id: 'yt', label: 'YouTube' },
{ id: 'dm', label: 'Dailymotion' },
{ id: 'tw', label: 'Twitch' },
{ id: 'pt', label: 'PeerTube' },
{ id: 'od', label: 'Odysee' },
{ id: 'ru', label: 'Rumble' },
];
sorts: { key: SortKey; label: string }[] = [
{ key: 'alpha', label: 'A → Z' },
{ key: 'recent', label: 'Récents' },
{ key: 'subs', label: 'Abonnés' },
];
activeFilter = signal<FilterChip['id']>('all');
activeSort = signal<SortKey>('alpha');
readonly items = computed(() => this.subs.items());
readonly filteredItems = computed(() => this.applyFilters(this.items()));
readonly hasCache = computed(() => this.items().length > 0);
constructor() {
this.refresh();
}
refresh(): void {
this.subs.list().subscribe();
}
setFilter(id: FilterChip['id']): void {
this.activeFilter.set(id);
}
setSort(key: SortKey): void {
this.activeSort.set(key);
}
openChannel(item: SubscriptionItem): void {
if (!item?.channel?.provider || !item?.channel?.externalId) {
return;
}
this.router.navigate(['/channel', item.channel.provider, item.channel.externalId]);
}
private applyFilters(list: SubscriptionItem[]): SubscriptionItem[] {
const filter = this.activeFilter();
const sort = this.activeSort();
let filtered = list;
if (filter !== 'all') {
filtered = filtered.filter((item) => item.channel?.provider === filter);
}
switch (sort) {
case 'recent':
filtered = [...filtered].sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
break;
case 'subs':
filtered = [...filtered].sort(
(a, b) => (b.channel?.subsCount ?? 0) - (a.channel?.subsCount ?? 0)
);
break;
case 'alpha':
default:
filtered = [...filtered].sort((a, b) => {
const ta = (a.channel?.title || a.channel?.handle || '').toLowerCase();
const tb = (b.channel?.title || b.channel?.handle || '').toLowerCase();
return ta.localeCompare(tb);
});
break;
}
return filtered;
}
}
@@ -1,22 +1,22 @@
<div class="fixed inset-0 z-[9999] bg-black/60 flex justify-center items-center" (click)="close.emit()">
<div class="fixed inset-0 z-[9999] bg-black/60" (click)="close.emit()">
<div class="fixed inset-0 grid place-items-center">
<div #dialog
class="w-[90%] max-w-lg bg-slate-900 rounded-xl shadow-2xl border border-slate-700"
role="dialog"
aria-modal="true"
aria-labelledby="providersTitle"
(click)="$event.stopPropagation()"
tabindex="0"
<div #dialog
class="w-[90%] max-w-lg bg-slate-900 rounded-xl shadow-2xl border border-slate-700"
role="dialog"
aria-modal="true"
aria-labelledby="providersTitle"
(click)="$event.stopPropagation()"
tabindex="0"
(keydown)="onDialogKeydown($event)">
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
<h2 id="providersTitle" class="text-slate-100 font-semibold">Providers</h2>
<button (click)="close.emit()" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100">Esc</button>
<button type="button" (click)="close.emit()" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" aria-label="Fermer (Esc)">Esc</button>
</div>
<div class="p-4 space-y-3">
<input type="text" [ngModel]="filter()" (ngModelChange)="filter.set($event)" placeholder="filter providers…" class="w-full px-3 py-2 rounded bg-slate-800 text-slate-100 placeholder-slate-400 border border-slate-700"/>
<input type="text" [ngModel]="filter()" (ngModelChange)="filter.set($event)" placeholder="filter providers…" aria-label="Filtrer les fournisseurs" class="w-full px-3 py-2 rounded bg-slate-800 text-slate-100 placeholder-slate-400 border border-slate-700"/>
<div class="flex items-center gap-2">
<label class="inline-flex items-center gap-2 text-slate-200">
<input type="checkbox" [checked]="all()" (change)="toggleAll()"/>
<input type="checkbox" [checked]="allMode()" (change)="toggleAllMode()"/>
<span>All</span>
</label>
</div>
@@ -33,8 +33,9 @@
</label>
</div>
<div class="p-4 border-t border-slate-700 flex items-center justify-end gap-2">
<button (click)="close.emit()" class="px-3 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-100">Cancel</button>
<button (click)="onApply()" class="px-3 py-2 rounded bg-red-600 hover:bg-red-500 text-white">Apply filters</button>
<button type="button" (click)="close.emit()" class="px-3 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-100">Cancel</button>
<button type="button" (click)="onApply()" class="px-3 py-2 rounded bg-red-600 hover:bg-red-500 text-white">Apply filters</button>
</div>
</div>
</div>
</div>
@@ -1,7 +1,7 @@
import { Component, EventEmitter, Output, Input, signal, computed, ElementRef, ViewChild, AfterViewInit } from '@angular/core';
import { Component, EventEmitter, Output, Input, signal, computed, ElementRef, ViewChild, AfterViewInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ProviderId, PROVIDERS } from '../../app/core/providers/provider-registry';
import { ProviderId, PROVIDERS, PROVIDER_MAP } from '../../app/core/providers/provider-registry';
@Component({
selector: 'app-provider-picker',
@@ -9,45 +9,66 @@ import { ProviderId, PROVIDERS } from '../../app/core/providers/provider-registr
imports: [CommonModule, FormsModule],
templateUrl: './provider-picker.component.html'
})
export class ProviderPickerComponent implements AfterViewInit {
export class ProviderPickerComponent implements AfterViewInit, OnDestroy {
@Input() open = false;
@Input() selected: ProviderId[] | 'all' = 'all';
// Incoming selection: 'all' or a list of provider ids
@Input() set selected(value: ProviderId[] | 'all') {
// Reset the draft each time the picker opens with a new incoming selection
this._draft.set(value === 'all' ? [] : Array.isArray(value) ? [...value] : []);
this._allMode.set(value === 'all');
}
@Output() close = new EventEmitter<void>();
@Output() apply = new EventEmitter<{ selection: ProviderId[] | 'all'; rememberDefault: boolean }>();
filter = signal('');
rememberDefault = signal(false);
// Draft state: working copy while the modal is open
private _draft = signal<ProviderId[]>([]);
private _allMode = signal(true);
readonly draft = computed(() => this._draft());
readonly allMode = computed(() => this._allMode() && this._draft().length === 0);
@ViewChild('dialog', { static: false }) dialogRef?: ElementRef<HTMLDivElement>;
readonly all = computed(() => this.selected === 'all');
readonly providers = computed(() => {
const q = this.filter().toLowerCase();
return PROVIDERS.filter(p => !q || p.displayName.toLowerCase().includes(q) || p.shortLabel.toLowerCase().includes(q));
});
toggleAll() {
this.apply.emit({ selection: 'all', rememberDefault: this.rememberDefault() });
toggleAllMode() {
this._allMode.set(true);
this._draft.set([]);
}
isChecked(id: ProviderId): boolean {
if (this.selected === 'all') return true;
return Array.isArray(this.selected) && this.selected.includes(id);
if (this.allMode()) return true;
return this._draft().includes(id);
}
toggle(id: ProviderId) {
if (this.selected === 'all') {
this.apply.emit({ selection: [id], rememberDefault: this.rememberDefault() });
if (this.allMode()) {
// Leaving All mode by unchecking one provider: start from every provider
// then remove the unchecked one ("all except this one")
const set = new Set(PROVIDERS.map(p => p.id));
set.delete(id);
this._draft.set(Array.from(set));
this._allMode.set(false);
return;
}
const set = new Set(this.selected || []);
if (set.has(id)) set.delete(id); else set.add(id);
const next = Array.from(set) as ProviderId[];
this.selected = next;
const set = new Set(this._draft());
if (set.has(id)) {
set.delete(id);
} else {
set.add(id);
}
this._draft.set(Array.from(set));
this._allMode.set(false);
}
onApply() {
this.apply.emit({ selection: this.selected, rememberDefault: this.rememberDefault() });
const selection: ProviderId[] | 'all' = this.allMode() ? 'all' : this._draft();
this.apply.emit({ selection, rememberDefault: this.rememberDefault() });
}
ngAfterViewInit() {
@@ -57,8 +78,17 @@ export class ProviderPickerComponent implements AfterViewInit {
});
}
// Basic focus trap within the dialog
ngOnDestroy() {
try { document.body.style.overflow = ''; } catch {}
}
// Focus trap within the dialog (Tab / Shift+Tab)
onDialogKeydown(ev: KeyboardEvent) {
if (ev.key === 'Escape') {
ev.preventDefault();
this.close.emit();
return;
}
if (ev.key !== 'Tab') return;
const root = this.dialogRef?.nativeElement;
if (!root) return;
@@ -71,7 +101,7 @@ export class ProviderPickerComponent implements AfterViewInit {
if (!ev.shiftKey && active === last) {
ev.preventDefault();
first.focus();
} else if (ev.shiftKey && active === first) {
} else if (ev.shiftKey && (active === first || active === root)) {
ev.preventDefault();
last.focus();
}
+21 -12
View File
@@ -1,4 +1,4 @@
<form class="w-full relative" novalidate (ngSubmit)="onSubmit($event)" (submit)="onSubmit($event)" (keydown)="handleKeydown($event)" role="search" aria-label="Recherche">
<form class="w-full relative" novalidate (submit)="onSubmit($event)" role="search" aria-label="Recherche">
<!-- Input -->
<div class="flex items-center gap-2 rounded-lg ring-1 ring-slate-600/50 bg-slate-800/70 px-3 py-2">
<!-- Provider chips -->
@@ -9,6 +9,7 @@
'bg-slate-200 text-slate-800 border-slate-300': providerMode()==='all',
'bg-slate-700 text-slate-100 border-slate-600': providerMode()!=='all'
}"
[attr.aria-pressed]="providerMode()==='all'"
title="Tous les fournisseurs">
All
</button>
@@ -19,20 +20,25 @@
'bg-slate-700 text-slate-100 border-slate-600': !(selected().includes(c.id)),
'bg-slate-200 text-slate-800 border-slate-300': selected().includes(c.id)
}"
[title]="'Inclure ' + c.label">
[attr.aria-pressed]="selected().includes(c.id)"
[title]="'Inclure ' + c.label + ' (Alt+' + (i + 1) + ')'">
<span [class]="c.colorClass">{{ c.label }}</span>
</button>
</ng-container>
<!-- Text input -->
<!-- Text input: combobox pattern for the inline '@' autocomplete -->
<input #qInput type="search"
class="flex-1 bg-transparent outline-none text-slate-100 placeholder-slate-400 px-2"
[placeholder]="placeholder"
[ngModel]="query()" (ngModelChange)="queryUpdate($event)" (keydown)="handleKeydown($event)" (keydown.enter)="onSubmit($event)" name="q"
autocomplete="off" aria-label="Rechercher des vidéos"/>
autocomplete="off" aria-label="Rechercher des vidéos"
role="combobox" aria-expanded="false" aria-autocomplete="list"
[attr.aria-expanded]="atOpen()"
[attr.aria-controls]="atOpen() ? 'atOptionsList' : null"
[attr.aria-activedescendant]="atOpen() && atOptions().length > 0 ? 'atOption-' + atIndex() : null"/>
<!-- Actions -->
<button type="button" (click)="openPicker()" class="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600" aria-label="Choisir des fournisseurs">@</button>
<button type="button" (click)="openPicker()" class="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600" aria-label="Choisir des fournisseurs (@)">@</button>
<button type="submit" class="ml-1 px-3 py-1 rounded bg-red-600 hover:bg-red-500 text-white"
(click)="onSubmit($event)"
[attr.title]="(query() || '').trim().length < 2 ? 'Tapez au moins 2 caractères' : null">
@@ -51,10 +57,13 @@
<div *ngIf="atOpen() && atOptions().length > 0"
class="absolute z-50 mt-1 left-3 right-3 rounded-lg shadow-xl overflow-hidden"
style="top: 100%">
<ul class="max-h-64 overflow-y-auto"
<ul id="atOptionsList" role="listbox" aria-label="Fournisseurs"
class="max-h-64 overflow-y-auto"
[ngClass]="{ 'bg-slate-900 border border-slate-700 text-slate-200': true }">
<li *ngFor="let opt of atOptions(); let i = index">
<button type="button" (click)="toggleChip(opt.id); atOpen.set(false)"
<li *ngFor="let opt of atOptions(); let i = index" role="option"
[attr.aria-selected]="i === atIndex()"
[id]="'atOption-' + i">
<button type="button" (click)="applyAtOption(opt.id)"
class="w-full text-left px-3 py-2 text-sm"
[ngClass]="{
'bg-slate-800': i === atIndex(),
@@ -67,11 +76,11 @@
</div>
<!-- Quick Menu (Ctrl/Cmd+K) -->
<div *ngIf="quickOpen()" style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999; background-color: rgba(0, 0, 0, 0.6);" role="dialog" aria-modal="true" (click)="closeQuickMenu()">
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -0%); width: 90%; max-width: 56rem; max-height: 85vh; border-radius: 0.75rem; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; background-color: #0f172a; border: 1px solid #334155; display: flex; flex-direction: column;" role="document" aria-labelledby="quickMenuTitle" (click)="$event.stopPropagation()" tabindex="0">
<div *ngIf="quickOpen()" style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999; background-color: rgba(0, 0, 0, 0.6);" role="presentation" (click)="closeQuickMenu()">
<div #quickDialog style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -0%); width: 90%; max-width: 56rem; max-height: 85vh; border-radius: 0.75rem; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; background-color: #0f172a; border: 1px solid #334155; display: flex; flex-direction: column;" role="dialog" aria-modal="true" aria-labelledby="quickMenuTitle" (click)="$event.stopPropagation()" (keydown)="onQuickMenuKeydown($event)">
<div class="p-3 border-b border-slate-700 flex items-center justify-between">
<h2 id="quickMenuTitle" class="text-slate-100 font-semibold">Quick Menu</h2>
<button (click)="closeQuickMenu()" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" autofocus>Esc</button>
<button type="button" (click)="closeQuickMenu()" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" aria-label="Fermer le menu (Esc)">Esc</button>
</div>
<div class="grid grid-cols-2 gap-0 overflow-y-auto">
<!-- History column -->
@@ -80,7 +89,7 @@
<ul class="space-y-1 max-h-80 overflow-y-auto">
<li *ngFor="let it of recentSearches()">
<button type="button" class="w-full text-left px-2 py-1 rounded hover:bg-slate-800 text-slate-200"
(click)="queryUpdate(it.query || '')">{{ it.query }}</button>
(click)="queryUpdate(it.query || ''); closeQuickMenu()">{{ it.query }}</button>
</li>
<li *ngIf="(recentSearches()?.length || 0) === 0" class="text-slate-500 text-sm">No recent searches.</li>
</ul>
+129 -83
View File
@@ -7,17 +7,20 @@ import { ProviderPickerComponent } from './provider-picker.component';
import { SearchService } from '../../app/search/search.service';
import { UserService, type UserPreferences } from '../../services/user.service';
import { HistoryService, type SearchHistoryItem } from '../../services/history.service';
import { TelemetryService } from '../../services/telemetry.service';
import { Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
// No overlays; we render fixed-position modals with high z-index
const AT_QUERY_RE = /(^|\s)@([a-z]{1,24})$/i;
@Component({
selector: 'app-search-box',
standalone: true,
imports: [
CommonModule,
FormsModule,
CommonModule,
FormsModule,
ProviderPickerComponent,
HttpClientModule // Nécessaire pour SearchService
],
@@ -32,7 +35,9 @@ export class SearchBoxComponent {
private search = inject(SearchService);
private users = inject(UserService);
private history = inject(HistoryService);
private telemetry = inject(TelemetryService);
@ViewChild('qInput', { static: false }) qInputRef?: ElementRef<HTMLInputElement>;
@ViewChild('quickDialog', { static: false }) quickDialogRef?: ElementRef<HTMLDivElement>;
// Input/Output bindings
@Input() placeholder: string = 'Search videos…';
@@ -46,25 +51,24 @@ export class SearchBoxComponent {
selected = signal<ProviderId[]>([]);
pickerOpen = signal(false);
suggestionsOpen = signal(false);
// Inline @autocomplete state
// Inline @autocomplete state (atOpen is derived from the query, atOptions is pure)
atOpen = signal(false);
atIndex = signal(0);
atOptions = computed(() => {
const q = this.query();
const m = /(^|\s)@([a-z]{1,24})$/i.exec(q);
const m = AT_QUERY_RE.exec(this.query());
if (!m) return [] as { id: ProviderId; label: string }[];
const term = (m[2] || '').toLowerCase();
const opts = PROVIDERS.filter(p =>
p.shortLabel.toLowerCase().includes(term) || p.displayName.toLowerCase().includes(term)
).map(p => ({ id: p.id, label: p.displayName }));
this.atOpen.set(opts.length > 0);
if (this.atIndex() >= opts.length) this.atIndex.set(0);
return opts as any;
return PROVIDERS
.filter(p => p.shortLabel.toLowerCase().includes(term) || p.displayName.toLowerCase().includes(term))
.map(p => ({ id: p.id, label: p.displayName }));
});
// Quick menu modal (Ctrl/Cmd+K)
quickOpen = signal(false);
recentSearches = signal<SearchHistoryItem[]>([]);
// Element to restore focus to when a modal closes (a11y)
private lastFocused: HTMLElement | null = null;
readonly chips = PROVIDERS.map(p => ({ id: p.id, label: p.shortLabel, colorClass: p.colorClass }));
// Derived state
@@ -100,39 +104,68 @@ export class SearchBoxComponent {
hydratedFromService = true;
});
// Apply default providers from user preferences if none explicitly set
// Apply default providers from user preferences if none explicitly set (Step 9 client hook)
effect(() => {
const prefs: UserPreferences | null = this.users.preferences();
if (!prefs) return;
const list = (prefs as any).defaultProviders as string[] | undefined;
if (Array.isArray(list) && list.length > 0) {
// Keep only known provider ids
const valid = list.filter(id => PROVIDERS.some(p => p.id === (id as ProviderId))) as ProviderId[];
if (valid.length === 0) return;
// Only apply if current mode is 'all' and nothing selected
if (this.providerMode() === 'all' && this.selected().length === 0) {
// Cast to ProviderId[] while trusting server-provided list
this.providerMode.set('custom');
this.selected.set(list as any);
this.selected.set(valid);
}
}
});
// Focus the first focusable element of the quick menu dialog when it opens (a11y)
effect(() => {
if (this.quickOpen()) {
setTimeout(() => {
try {
const root = this.quickDialogRef?.nativeElement;
const first = root?.querySelector<HTMLElement>('button');
(first || root)?.focus();
} catch {}
}, 0);
}
});
// Lock body scroll when a modal is open (a11y UX)
effect(() => {
const open = this.quickOpen() || this.pickerOpen();
try {
document.body.style.overflow = open ? 'hidden' : '';
} catch {}
});
}
// Update query and handle inline '@provider' prefix to open filtered picker
// Update query and refresh inline '@provider' autocomplete state
queryUpdate(value: string) {
try { console.log('[SearchBox] queryUpdate', { value }); } catch {}
this.query.set(value);
// Keep SearchService in sync with current text to avoid stale overwrite
try { this.search.q$.next(value); } catch {}
this._qChanges.next(value);
const m = /(^|\s)@([a-z]{1,12})$/i.exec(value);
const hasOptions = this.atOptions().length > 0;
this.atOpen.set(AT_QUERY_RE.test(value) && hasOptions);
if (this.atIndex() >= this.atOptions().length) this.atIndex.set(0);
}
// Pick an option from the inline '@' popover: strip the '@term' and toggle the chip
applyAtOption(id: ProviderId) {
const q = this.query();
const m = AT_QUERY_RE.exec(q);
if (m) {
const term = m[2] || '';
this.pickerOpen.set(true);
const lower = term.toLowerCase();
const matched = PROVIDERS.filter(p => p.shortLabel.toLowerCase().startsWith(lower) || p.displayName.toLowerCase().includes(lower)).map(p => p.id);
if (matched.length === 1) {
this.toggleChip(matched[0]);
}
const start = (m.index || 0) + (m[1] || '').length;
this.queryUpdate(q.slice(0, start).trimEnd());
}
this.atOpen.set(false);
this.toggleChip(id);
this.telemetry.track('at_autocomplete_use', { providers: [id] });
try { this.qInputRef?.nativeElement?.focus(); } catch {}
}
// Toggle All chip
@@ -152,43 +185,37 @@ export class SearchBoxComponent {
if (this.providerMode() === 'all') this.providerMode.set('custom');
const cur = new Set(this.selected());
if (cur.has(id)) cur.delete(id); else cur.add(id);
const next = Array.from(cur);
this.selected.set(next);
if (next.length === 0) {
// Empty selection warning UX could be handled by parent; keep custom mode
}
this.selected.set(Array.from(cur));
}
// Keyboard shortcuts: Alt+1..6 map to chips by order
// Single keyboard handler for the whole form (input events bubble here)
handleKeydown(ev: KeyboardEvent) {
if (ev.key === '@') {
ev.preventDefault();
this.pickerOpen.set(true);
return;
}
// Inline @autocomplete navigation
// Inline @autocomplete navigation (highest priority)
if (this.atOpen()) {
const opts = this.atOptions();
if (ev.key === 'ArrowDown') { ev.preventDefault(); this.atIndex.set(Math.min(opts.length - 1, this.atIndex() + 1)); return; }
if (ev.key === 'ArrowUp') { ev.preventDefault(); this.atIndex.set(Math.max(0, this.atIndex() - 1)); return; }
if (ev.key === 'Enter') {
// Prevent the sibling (keydown.enter) submit handler from also firing
ev.stopImmediatePropagation();
ev.preventDefault();
const pick = opts[this.atIndex()];
if (pick) this.toggleChip(pick.id);
this.atOpen.set(false);
if (pick) this.applyAtOption(pick.id);
return;
}
if (ev.key === 'Escape') { this.atOpen.set(false); }
if (ev.key === 'Escape') { this.atOpen.set(false); return; }
}
// Alt+1..6 map to chips by registry order
if (ev.altKey) {
const map: Record<string, ProviderId | undefined> = { '1': 'yt', '2': 'dm', '3': 'tw', '4': 'pt', '5': 'od', '6': 'ru' };
const pid = map[ev.key];
if (pid) { ev.preventDefault(); this.toggleChip(pid); }
return;
}
if (ev.key === 'Escape') {
// Escape clears transient UI; if nothing is open, also clear the query
// Escape closes transient UI; if nothing is open, clear the query
const hadAny = this.pickerOpen() || this.suggestionsOpen() || this.atOpen() || this.quickOpen();
this.pickerOpen.set(false);
this.closePicker();
this.suggestionsOpen.set(false);
this.atOpen.set(false);
if (!hadAny && (this.query() || '').length > 0) {
@@ -197,19 +224,13 @@ export class SearchBoxComponent {
}
}
// Global shortcut Ctrl/Cmd+K to open the Providers picker
// Global shortcut Ctrl/Cmd+K to open the Providers quick menu
@HostListener('document:keydown', ['$event'])
onGlobalKey(ev: KeyboardEvent) {
if ((ev.key.toLowerCase() === 'k') && (ev.ctrlKey || ev.metaKey)) {
ev.preventDefault();
this.quickOpen.set(true);
// Load last 15 searches
try { this.history.getSearchHistory(15).subscribe({ next: (items) => this.recentSearches.set(items || []), error: () => {} }); } catch {}
}
// Also support Ctrl+Shift+K explicitly
if ((ev.key.toLowerCase() === 'k') && ev.ctrlKey && ev.shiftKey) {
ev.preventDefault();
this.quickOpen.set(true);
this.openQuickMenu();
return;
}
if (ev.key === 'Escape') {
if (this.quickOpen()) {
@@ -234,35 +255,68 @@ export class SearchBoxComponent {
try {
const raw = this.qInputRef.nativeElement.value || '';
q = String(raw).trim();
console.log('[SearchBox] fallback native value used', { q, qLength: q.length });
} catch {}
}
// Debug: minimal console trace to confirm handler runs
try {
console.log('[SearchBox] onSubmit called', {
q,
qLength: q.length,
hasParentHandler: !!this.submitted.observers.length
});
} catch(e) {
console.error('Error in SearchBox onSubmit:', e);
}
if (q.length < 2) {
console.log('[SearchBox] Query too short, not submitting');
return;
}
if (q.length < 2) return;
const prov = this.providersForRequest();
console.log('[SearchBox] Emitting submit event', { q, providers: prov });
this.telemetry.track('search_submit', { providers: Array.isArray(prov) ? prov : ['all'], queryLength: q.length });
// Emit payload to parent (Header/Search page)
this.submitted.emit({ q, providers: prov });
console.log('[SearchBox] Submit event emitted');
}
openPicker() { this.pickerOpen.set(true); }
closePicker() { this.pickerOpen.set(false); }
openPicker() {
this.saveFocus();
this.pickerOpen.set(true);
this.telemetry.track('provider_picker_open');
}
closePicker() {
if (!this.pickerOpen()) return;
this.pickerOpen.set(false);
this.restoreFocus();
}
// Quick menu open/close with focus management (a11y)
openQuickMenu() {
this.saveFocus();
this.quickOpen.set(true);
this.telemetry.track('quick_menu_open');
// Load last 15 searches
try { this.history.getSearchHistory(15).subscribe({ next: (items) => this.recentSearches.set(items || []), error: () => {} }); } catch {}
}
closeQuickMenu() {
if (!this.quickOpen()) return;
this.quickOpen.set(false);
this.restoreFocus();
}
private saveFocus() {
try { this.lastFocused = document.activeElement as HTMLElement | null; } catch { this.lastFocused = null; }
}
private restoreFocus() {
try { this.lastFocused?.focus(); } catch {}
this.lastFocused = null;
}
// Generic focus trap used by the quick menu dialog (Tab / Shift+Tab)
trapFocus(ev: KeyboardEvent, root: HTMLElement | null | undefined) {
if (ev.key !== 'Tab' || !root) return;
const focusables = Array.from(root.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'))
.filter(el => !el.hasAttribute('disabled'));
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement as HTMLElement | null;
if (!ev.shiftKey && active === last) {
ev.preventDefault();
first.focus();
} else if (ev.shiftKey && (active === first || active === root)) {
ev.preventDefault();
last.focus();
}
}
onQuickMenuKeydown(ev: KeyboardEvent) {
this.trapFocus(ev, this.quickDialogRef?.nativeElement);
}
// Accept both legacy payload (array|'all') and new object payload; use any to satisfy strict template binder
applyPicker(payload: any) {
@@ -272,20 +326,20 @@ export class SearchBoxComponent {
if (prov === 'all') {
this.providerMode.set('all');
this.selected.set([]);
} else {
} else if (Array.isArray(prov)) {
this.providerMode.set('custom');
this.selected.set(prov);
}
// Persist default selection if requested and user is logged-in
// Persist default selection if requested and user is logged-in (Step 9)
if (rememberDefault) {
const body: any = { defaultProviders: (prov === 'all') ? [] : prov };
try { this.users.updatePreferences(body).subscribe({ next: () => {}, error: () => {} }); } catch {}
}
this.telemetry.track('provider_apply', { providers: Array.isArray(prov) ? prov : ['all'], rememberDefault });
this.closePicker();
}
// Quick menu close/apply (re-using same payload shape as picker)
closeQuickMenu() { this.quickOpen.set(false); }
applyQuickMenu(payload: any) {
this.applyPicker(payload);
this.closeQuickMenu();
@@ -299,14 +353,6 @@ export class SearchBoxComponent {
this.selected.set(Array.from(set));
}
// Lock body scroll when a modal is open (a11y UX)
private _scrollLock = effect(() => {
const open = this.quickOpen() || this.pickerOpen();
try {
document.body.style.overflow = open ? 'hidden' : '';
} catch {}
});
// Internal subject for debouncing query changes
private _qChanges = new Subject<string>();
}
@@ -1,28 +1,43 @@
<div class="mt-3 space-y-4">
<!-- Grouped suggestion sections by provider -->
<div *ngFor="let entry of (groups | keyvalue)" class="border border-slate-700 rounded-lg overflow-hidden">
<div class="px-3 py-2 bg-slate-800 text-slate-200 text-sm font-semibold">
{{ entry.key | uppercase }}
</div>
<ul class="divide-y divide-slate-700">
<li *ngFor="let s of (entry.value | slice:0:3); let i = index" class="p-3 flex items-start gap-3">
<img *ngIf="s.thumbnail" [src]="s.thumbnail" class="h-12 w-20 object-cover rounded" alt=""/>
<div class="min-w-0">
<div class="text-slate-100 truncate">{{ s.title }}</div>
<div class="text-slate-400 text-xs">{{ s.uploaderName }}</div>
<div class="mt-3 space-y-4" role="region" aria-label="Suggestions par fournisseur">
@for (s of sections(); track s.id) {
<section class="border border-slate-700 rounded-lg overflow-hidden" [attr.aria-label]="'Résultats ' + s.name">
<header class="px-3 py-2 bg-slate-800 text-slate-200 text-sm font-semibold flex items-center gap-2">
<span [class]="s.colorClass">{{ s.shortLabel }}</span>
<span>{{ s.name }}</span>
</header>
@if (!s.supported) {
<!-- Explicit unsupported-provider row (Step 6) -->
<div class="p-3 text-slate-500 text-sm italic flex items-center gap-2">
<span aria-hidden="true">⚠</span>
{{ s.name }} ne supporte pas la recherche pour le moment.
</div>
</li>
<li *ngIf="(entry.value?.length || 0) === 0" class="p-3 text-slate-500 text-sm">
Aucun résultat pour ce fournisseur.
</li>
</ul>
<div class="px-3 py-2 bg-slate-900 text-right">
<a class="text-xs text-red-300 hover:text-red-200"
[routerLink]="['/search']"
[queryParams]="{ q: query, providers: entry.key }"
[attr.aria-label]="'View all results on ' + (entry.key | uppercase)">
View all on {{ entry.key | uppercase }}
</a>
</div>
</div>
} @else {
<ul class="divide-y divide-slate-700">
@for (item of s.items | slice:0:3; track item.id) {
<li class="p-3 flex items-start gap-3">
@if (item.thumbnail) {
<img [src]="item.thumbnail" class="h-12 w-20 object-cover rounded" alt=""/>
}
<div class="min-w-0">
<div class="text-slate-100 truncate">{{ item.title }}</div>
<div class="text-slate-400 text-xs">{{ item.uploaderName }}</div>
</div>
</li>
}
@empty {
<li class="p-3 text-slate-500 text-sm">Aucun résultat pour ce fournisseur.</li>
}
</ul>
<div class="px-3 py-2 bg-slate-900 text-right">
<a class="text-xs text-red-300 hover:text-red-200"
[routerLink]="['/search']"
[queryParams]="{ q: query, providers: s.deepLinkProviders }"
[attr.aria-label]="'Voir tous les résultats sur ' + s.name">
View all on {{ s.name }}
</a>
</div>
}
</section>
}
</div>
@@ -1,9 +1,19 @@
import { Component, Input } from '@angular/core';
import { Component, Input, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import type { ProviderId } from '../../app/core/providers/provider-registry';
import { PROVIDERS, PROVIDER_MAP, type ProviderId } from '../../app/core/providers/provider-registry';
import type { SuggestionItem } from '../../app/search/search.service';
interface SuggestionSection {
id: ProviderId;
name: string;
shortLabel: string;
colorClass: string;
items: SuggestionItem[];
supported: boolean;
deepLinkProviders: string;
}
@Component({
selector: 'app-search-suggestions',
standalone: true,
@@ -15,4 +25,26 @@ export class SearchSuggestionsComponent {
yt: [], dm: [], tw: [], pt: [], od: [], ru: []
} as any;
@Input() query: string = '';
// Providers that were actually requested; drives which sections render and
// which unsupported providers get an explicit row (Step 6)
@Input() activeProviders: ProviderId[] | 'all' = 'all';
readonly sections = computed<SuggestionSection[]>(() => {
const groups = this.groups || ({} as Record<ProviderId, SuggestionItem[]>);
const active = this.activeProviders === 'all'
? PROVIDERS.map(p => p.id)
: (this.activeProviders || []).filter(id => PROVIDER_MAP.has(id));
return active.map(id => {
const spec = PROVIDER_MAP.get(id)!;
return {
id,
name: spec.displayName,
shortLabel: spec.shortLabel,
colorClass: spec.colorClass,
items: Array.isArray(groups[id]) ? groups[id] : [],
supported: !!spec.supports?.search,
deepLinkProviders: id,
} as SuggestionSection;
});
});
}
+21 -3
View File
@@ -11,7 +11,8 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
import { SearchResultGridComponent } from 'src/app/shared/components/search-result-grid/search-result-grid.component';
import { VideoItem } from 'src/app/shared/models/video-item.model';
import { SearchService, type SearchResponse } from '../../app/search/search.service';
import type { ProviderId } from '../../app/core/providers/provider-registry';
import { UserService, type UserPreferences } from '../../services/user.service';
import { PROVIDERS, type ProviderId } from '../../app/core/providers/provider-registry';
import { Observable, Subscription } from 'rxjs';
@Component({
@@ -29,6 +30,7 @@ export class SearchComponent implements AfterViewInit {
private history = inject(HistoryService);
private title = inject(Title);
private unified = inject(SearchService);
private users = inject(UserService);
q = signal<string>('');
loading = signal<boolean>(false);
@@ -36,6 +38,8 @@ export class SearchComponent implements AfterViewInit {
providerParam = signal<Provider | null>(null);
themeParam = signal<string | null>(null);
providersListParam = signal<ProviderId[] | 'all' | null>(null);
// Step 7: default providers loaded from user preferences (server), applied when URL has none
private defaultProvidersPref = signal<ProviderId[] | null>(null);
pageParam = signal<number>(1);
sortParam = signal<'relevance' | 'date' | 'views'>('relevance');
@@ -258,6 +262,20 @@ export class SearchComponent implements AfterViewInit {
this.title.setTitle(this.pageHeading());
});
// Step 7: load default providers preference once (server) for deep-link fallback
try {
this.users.loadPreferences().subscribe({
next: (prefs: UserPreferences) => {
const list = (prefs as any)?.defaultProviders;
if (Array.isArray(list)) {
const valid = list.filter((id: string) => PROVIDERS.some(p => p.id === id)) as ProviderId[];
if (valid.length > 0) this.defaultProvidersPref.set(valid);
}
},
error: () => {} // anonymous users: no preference
});
} catch {}
// Subscribe once to unified multi-provider results and reflect to UI
const subscription: Subscription = this.unified.request$
.pipe(takeUntilDestroyed())
@@ -330,8 +348,8 @@ export class SearchComponent implements AfterViewInit {
const provider = prov || this.instances.selectedProvider();
this.filterTag.set(provider === 'twitch' ? 'twitch_all' : 'all');
// Use unified search in all cases
const providersToUse = providersList || [provider as ProviderId];
// Use unified search in all cases (URL providers > user default preference > single provider)
const providersToUse = providersList || this.defaultProvidersPref() || [provider as ProviderId];
this.unified.setQuery(q);
this.unified.setProviders(providersToUse);
@@ -1,10 +1,22 @@
<div class="h-[calc(100vh-4rem)] w-full flex items-center justify-center p-2 sm:p-4"
(wheel)="onWheel($event)" (touchstart)="onTouchStart($event)" (touchend)="onTouchEnd($event)">
<div class="w-full flex flex-col items-center gap-3 p-2 sm:p-4">
<!-- Sélecteur de fournisseur : la page n'est plus bloquée sur un seul provider -->
<div class="flex flex-wrap items-center justify-center gap-2" role="tablist" aria-label="Fournisseur des Shorts">
<button *ngFor="let p of providersList()"
(click)="selectProvider(p.id)"
[attr.aria-pressed]="provider() === p.id"
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
[class]="provider() === p.id ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
{{ p.label }}
</button>
</div>
<div class="h-[calc(100vh-8rem)] w-full flex items-center justify-center"
(wheel)="onWheel($event)" (touchstart)="onTouchStart($event)" (touchend)="onTouchEnd($event)">
<!-- Center column -->
<div class="relative">
<!-- Video frame (9:16) -->
<div class="rounded-xl overflow-hidden shadow-2xl ring-1 ring-slate-700/50 bg-black/90"
style="height: calc(100vh - 6rem); max-height: calc(100vh - 6rem); aspect-ratio: 9 / 16; width: auto; max-width: 92vw;">
style="height: calc(100vh - 10rem); max-height: calc(100vh - 10rem); aspect-ratio: 9 / 16; width: auto; max-width: 92vw;">
@if (embedUrl(); as url) {
<iframe [src]="url" class="w-full h-full" frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
@@ -12,8 +24,9 @@
} @else if (loading()) {
<div class="w-full h-full animate-pulse bg-slate-800"></div>
} @else if (error()) {
<div class="w-full h-full flex items-center justify-center text-slate-300">
{{ error() }}
<div class="w-full h-full flex flex-col gap-3 items-center justify-center text-slate-300 p-6 text-center">
<span>{{ error() }}</span>
<button (click)="retry()" class="px-4 py-2 rounded bg-slate-700 hover:bg-slate-600 text-white text-sm">Réessayer</button>
</div>
}
@@ -22,10 +35,12 @@
<div class="absolute left-0 right-0 bottom-0 p-3 sm:p-4 bg-gradient-to-t from-black/60 to-transparent text-white">
<div class="flex items-center gap-3 text-sm">
<img [src]="v.uploaderAvatar" [alt]="v.uploaderName" class="h-8 w-8 rounded-full object-cover">
<div class="flex-1">
<div class="flex-1 min-w-0">
<div class="font-semibold line-clamp-1">{{ v.title }}</div>
<div class="opacity-80 line-clamp-1">@{{ v.uploaderName }}</div>
</div>
<a *ngIf="watchLink()" [routerLink]="watchLink()" [queryParams]="watchQueryParams()"
class="shrink-0 px-2 py-1 rounded bg-white/15 hover:bg-white/25 text-xs">Ouvrir</a>
</div>
</div>
}
@@ -43,10 +58,19 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor"><path d="M7 10l5 5 5-5H7z"/></svg>
</button>
<!-- Position indicator -->
@if (items().length > 0) {
<div class="absolute -left-14 top-6 hidden md:flex items-center justify-center px-2 py-1 rounded-full bg-slate-800/70 text-white text-xs ring-1 ring-slate-700/60">
{{ index() + 1 }} / {{ items().length }}
</div>
}
<!-- Mobile next/prev (bottom) -->
<div class="flex md:hidden items-center justify-between mt-3">
<button (click)="prev()" [disabled]="!canPrev()" class="px-3 py-2 rounded bg-slate-800/70 text-white disabled:opacity-40">Prev</button>
<span *ngIf="items().length > 0" class="text-slate-400 text-sm">{{ index() + 1 }} / {{ items().length }}</span>
<button (click)="next()" [disabled]="!canNext()" class="px-3 py-2 rounded bg-slate-800/70 text-white disabled:opacity-40">Next</button>
</div>
</div>
</div>
</div>
+121 -70
View File
@@ -1,16 +1,20 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal, HostListener } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, HostListener } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { firstValueFrom } from 'rxjs';
import { YoutubeApiService } from '../../services/youtube-api.service';
import { InstanceService } from '../../services/instance.service';
import { InstanceService, Provider } from '../../services/instance.service';
import { Video } from '../../models/video.model';
import { isShortVideo } from '../../app/shared/utils/video-kind';
const SHORTS_QUERY = 'shorts';
@Component({
selector: 'app-watch-short',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
imports: [CommonModule, RouterLink],
templateUrl: './watch-short.component.html'
})
export class WatchShortComponent {
@@ -29,8 +33,10 @@ export class WatchShortComponent {
private wheelAccum = 0;
private lastSwipeY: number | null = null;
private lastScrollTs = 0;
private lastProvider: string | null = null;
readonly provider = computed(() => this.instances.selectedProvider());
readonly providersList = computed(() => this.instances.providers());
readonly current = computed<Video | null>(() => {
const list = this.items();
const i = this.index();
@@ -38,7 +44,7 @@ export class WatchShortComponent {
});
readonly canPrev = computed(() => this.index() > 0);
readonly canNext = computed(() => this.index() < this.items().length - 1);
readonly canNext = computed(() => this.index() < this.items().length - 1 || !!this.nextCursor());
readonly embedUrl = computed<SafeResourceUrl | null>(() => {
const v = this.current();
@@ -47,8 +53,18 @@ export class WatchShortComponent {
try {
const host = (location && location.hostname) ? location.hostname : 'localhost';
if (p === 'youtube' && v.videoId) {
const qs = 'autoplay=1&mute=1&rel=0&modestbranding=1&playsinline=1&controls=1';
const u = `https://www.youtube.com/embed/${encodeURIComponent(v.videoId)}?${qs}`;
// Même base que la page /watch : youtube-nocookie + origin/enablejsapi.
// L'embed youtube.com sans origin provoque l'erreur 153
// ("Video configuration error") sur certaines vidéos.
let origin = '';
let ref = '';
try {
origin = window.location.origin || '';
ref = window.location.href || '';
} catch {}
let u = `https://www.youtube-nocookie.com/embed/${encodeURIComponent(v.videoId)}?autoplay=1&mute=1&rel=0&modestbranding=1&playsinline=1&controls=1&enablejsapi=1`;
if (origin) u += `&origin=${encodeURIComponent(origin)}`;
if (ref) u += `&widget_referrer=${encodeURIComponent(ref)}`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'dailymotion' && v.videoId) {
@@ -88,23 +104,35 @@ export class WatchShortComponent {
}
}
if (p === 'rumble' && v.videoId) {
const u = `https://rumble.com/embed/${encodeURIComponent(v.videoId)}/?autoplay=2&muted=1`;
// L'embed officiel attend l'ID court (vXXXX) ; le videoId peut contenir le slug complet.
const m = /^v[0-9A-Za-z]+/.exec(String(v.videoId));
const id = m ? m[0] : v.videoId;
const u = `https://rumble.com/embed/${encodeURIComponent(id)}/?autoplay=2&muted=1`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
} catch {}
return null;
});
/** Lien "voir en page Watch" pour le short courant (secours si l'embed échoue). */
readonly watchLink = computed<any[] | null>(() => {
const v = this.current();
if (!v?.videoId) return null;
return ['/watch', v.videoId];
});
readonly watchQueryParams = computed<Record<string, any> | null>(() => {
const v = this.current();
const p = this.provider();
if (!v) return null;
const qp: any = { p };
if (p === 'twitch' && v.type === 'channel' && v.videoId) qp.channel = v.videoId;
return qp;
});
// Called when a query returns no usable items; falls back to Trending
private afterNoResults(): void {
// For Shorts page, do NOT fallback to trending; display an appropriate message instead.
const p = this.provider();
const unsupported = this.isProviderUnsupportedForShorts(p);
if (unsupported) {
this.error.set('Ce fournisseur ne supporte pas les shorts');
} else {
this.error.set('Aucun Short trouvé pour ce fournisseur.');
}
this.error.set('Aucun Short trouvé pour ce fournisseur. Essayez un autre fournisseur ou réessayez.');
}
// Filter YouTube results to real Shorts using the Videos API to get durations.
@@ -123,13 +151,32 @@ export class WatchShortComponent {
}
constructor() {
// Initialize feed for Shorts according to current provider
// Recharger le flux quand le provider global change (ex. sélecteur du header).
effect(() => {
const p = this.provider();
if (this.lastProvider !== p) {
this.lastProvider = p;
this.loadFeed();
}
});
}
selectProvider(p: Provider): void {
if (p === this.provider()) return;
this.instances.setSelectedProvider(p);
// L'effect ci-dessus déclenche loadFeed().
}
retry(): void {
this.loadFeed();
}
loadFeed(): void {
this.loading.set(true);
this.error.set(null);
this.items.set([]);
this.index.set(0);
this.nextCursor.set(null);
const ready = this.instances.getProviderReadiness(this.provider());
if (!ready.ready) {
this.loading.set(false);
@@ -137,10 +184,10 @@ export class WatchShortComponent {
return;
}
const provider = this.provider();
// Use provider-specific source for Shorts
// Twitch : les Clips sont l'équivalent Shorts. La recherche Helix rejette
// les requêtes vides → on part des top streams (getTwitchClipsFeed).
if (provider === 'twitch') {
// Use Twitch Clips as Shorts
this.api.searchTwitchClipsPage('')?.subscribe({
this.api.getTwitchClipsFeed()?.subscribe({
next: (res: { items: Video[]; nextCursor?: string | null }) => {
const raw: Video[] = (res.items || []).filter((v: Video) => !!v.videoId);
const list: Video[] = raw.filter((v: Video) => this.isShort(v));
@@ -157,72 +204,76 @@ export class WatchShortComponent {
});
return;
}
// Default: search for shorts keywords via generic search
this.api.searchVideosPage('shorts OR #shorts').subscribe({
// Default: search for shorts keywords via generic search, then filter.
// Si la recherche ne donne aucun Short, repli sur le trending filtré
// (utile pour Rumble/Odysee/PeerTube/Dailymotion dont la recherche
// plein-texte "shorts" est approximative).
this.api.searchVideosPage(SHORTS_QUERY).subscribe({
next: (res) => {
const raw = (res.items || []).filter(v => !!v.videoId);
this.nextCursor.set(res.nextCursor || null);
const p = this.provider();
if (p === 'youtube' && raw.length) {
this.filterYouTubeShorts(raw).then(list => {
this.items.set(list.filter(v => this.isShort(v)));
this.index.set(0);
if (list.length === 0) this.afterNoResults();
else this.error.set(null);
this.loading.set(false);
});
const shorts = list.filter(v => this.isShort(v));
if (shorts.length > 0) {
this.items.set(shorts);
this.index.set(0);
this.error.set(null);
this.loading.set(false);
} else {
this.loadTrendingFallback();
}
}).catch(() => this.loadTrendingFallback());
return;
}
// Non-YouTube: apply provider-specific minimal filtering
const list = raw.filter(v => this.isShort(v));
this.items.set(list);
this.index.set(0);
if (list.length === 0) this.afterNoResults(); else this.error.set(null);
this.loading.set(false);
if (list.length > 0) {
this.items.set(list);
this.index.set(0);
this.error.set(null);
this.loading.set(false);
} else {
this.loadTrendingFallback();
}
},
error: () => {
this.error.set('Failed to load Shorts.');
this.loadTrendingFallback();
}
});
}
/** Repli : trending du provider filtré sur les Shorts (jamais de contenu long). */
private loadTrendingFallback(): void {
this.api.getTrendingPage(null).subscribe({
next: (res) => {
const raw = (res.items || []).filter(v => !!v.videoId);
const p = this.provider();
const apply = (list: Video[]) => {
this.items.set(list);
this.index.set(0);
this.nextCursor.set(null);
if (list.length === 0) this.afterNoResults(); else this.error.set(null);
this.loading.set(false);
};
if (p === 'youtube' && raw.length) {
this.filterYouTubeShorts(raw).then(list => apply(list.filter(v => this.isShort(v)))).catch(() => apply([]));
} else {
apply(raw.filter(v => this.isShort(v)));
}
},
error: () => {
this.afterNoResults();
this.loading.set(false);
}
});
}
// Trending fallback removed for Shorts page to avoid showing non-short content
private loadFallbackTrending(_hint?: string): void { this.error.set('Ce fournisseur ne supporte pas les shorts'); this.loading.set(false); }
// Generic Shorts check based on provider
// Generic Shorts check — classification partagée (une durée 0/absente
// ne qualifie JAMAIS un Short : les scrapers renvoient 0 quand inconnue).
private isShort(v: Video): boolean {
const p = (v as any).provider || this.provider();
const url = (v as any).url || '';
switch (p) {
case 'youtube':
return (v as any).isShort === true || (typeof url === 'string' && url.includes('/shorts/')) || ((v.duration ?? 0) > 0 && (v.duration ?? 0) <= 70);
case 'dailymotion':
return (v as any).isShort === true || (((v.duration ?? 0) <= 60) && ((v as any).isVertical === true));
case 'twitch':
return (v as any).kind === 'clip';
case 'rumble':
return (v as any).isShort === true;
case 'odysee':
// Odysee: use duration <= 60s as shorts indicator if native isShort unavailable
return (v as any).isShort === true || ((v.duration ?? 0) <= 60);
case 'peertube':
return (v as any).isShort === true || (((v.duration ?? 0) <= 60) && ((v as any).isVertical === true));
default:
return false;
}
}
private isProviderUnsupportedForShorts(p: string | undefined | null): boolean {
switch (p) {
case 'rumble':
return true; // Rumble requires native isShort indicator
case 'twitch':
case 'youtube':
return false;
default:
return false;
}
return isShortVideo({ ...(v as object), provider: p } as Parameters<typeof isShortVideo>[0]);
}
private fetchNextPage(autoAdvance = false) {
@@ -250,14 +301,14 @@ export class WatchShortComponent {
};
if (provider === 'twitch') {
this.api.searchTwitchClipsPage('', cursor || undefined)?.subscribe({
this.api.getTwitchClipsFeed(cursor || undefined)?.subscribe({
next: handle,
error: () => { this.busyMore.set(false); }
});
return;
}
this.api.searchVideosPage('shorts OR #shorts', cursor || undefined).subscribe({
this.api.searchVideosPage(SHORTS_QUERY, cursor || undefined).subscribe({
next: (res) => {
handle(res);
},
@@ -268,7 +319,7 @@ export class WatchShortComponent {
}
next(): void {
if (this.canNext()) {
if (this.canNext() && this.index() < this.items().length - 1) {
this.index.update(i => i + 1);
} else if (this.nextCursor()) {
this.fetchNextPage(true);
@@ -53,6 +53,12 @@
<span [class.hidden]="collapsed">{{ 'nav.liked' | t }}</span>
</a>
</li>
<li>
<a routerLink="/library/downloads" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.downloads' | t) : null">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M12 3v10.17l3.59-3.58L17 11l-5 5-5-5 1.41-1.41L12 13.17V3h0zm-7 15h14v2H5v-2z"/></svg>
<span [class.hidden]="collapsed">{{ 'nav.downloads' | t }}</span>
</a>
</li>
</ul>
</div>
}
@@ -24,6 +24,25 @@
</div>
</div>
<!-- Filtre rapide Vidéos / Shorts -->
<div class="flex items-center gap-2 mb-4" role="tablist" aria-label="Filtrer vidéos ou shorts">
<button (click)="setContentFilter('all')" [attr.aria-pressed]="contentFilter() === 'all'"
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
[class]="contentFilter() === 'all' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
Tout
</button>
<button (click)="setContentFilter('videos')" [attr.aria-pressed]="contentFilter() === 'videos'"
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
[class]="contentFilter() === 'videos' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
Vidéos
</button>
<button (click)="setContentFilter('shorts')" [attr.aria-pressed]="contentFilter() === 'shorts'"
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
[class]="contentFilter() === 'shorts' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
Shorts<span *ngIf="allVideos().length"> ({{ shortsCount() }})</span>
</button>
</div>
<!-- Filters -->
<div class="bg-slate-800/50 p-4 rounded-xl border border-slate-700 mb-8">
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-5 gap-4">
@@ -107,6 +126,10 @@
<div class="absolute top-2 right-2">
<app-provider-badge [provider]="provider()"></app-provider-badge>
</div>
<div class="absolute top-2 left-2 flex gap-1">
<span *ngIf="kindOf(v) === 'short'" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">SHORT</span>
<span *ngIf="kindOf(v) === 'live'" class="bg-purple-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">LIVE</span>
</div>
<div *ngIf="v.duration && v.duration > 0" class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded-md font-mono">
{{ v.duration | duration }}
</div>
@@ -10,7 +10,9 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
import { LikeButtonComponent } from '../shared/components/like-button/like-button.component';
import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component';
import { map, of, switchMap } from 'rxjs';
import { classifyVideo, isShortVideo } from '../../app/shared/utils/video-kind';
import { map, of, switchMap, catchError, Observable } from 'rxjs';
import { PagedResult } from '../../services/youtube-api.service';
const TWITCH_THEME_KEYWORDS: Record<string, string[]> = {
trending: ['just chatting', 'highlights'],
@@ -141,18 +143,32 @@ export class ProviderThemePageComponent implements OnDestroy {
const themeSlug = this.theme();
const isLiveTheme = themeSlug === 'live';
let apiCall;
let apiCall: Observable<PagedResult<Video>>;
if (provider === 'twitch') {
if (isLiveTheme) {
apiCall = this.api.searchTwitchChannelsPage(query || 'live', cursor || undefined);
// Thème live : top streams (jamais vide quand Twitch est configuré).
apiCall = this.api.getTwitchTopStreamsPage(cursor || undefined);
} else {
// Thème classique : VODs puis clips, puis top streams en filet de sécurité.
apiCall = this.api.searchTwitchVodsPage(query, cursor || undefined).pipe(
switchMap((res) => {
if ((res?.items?.length || 0) >= 8) {
return of(res);
}
return this.api.searchTwitchClipsPage(query, cursor || undefined).pipe(
map((clips) => ({ ...clips, items: [...(res?.items || []), ...(clips.items || [])] }))
map((clips) => ({ ...clips, items: [...(res?.items || []), ...(clips.items || [])] })),
switchMap((merged: any) => {
if (((merged?.items?.length) || 0) >= 4) return of(merged);
return this.api.getTwitchTopStreamsPage(cursor || undefined).pipe(
map((top) => {
const seen = new Set((merged?.items || []).map((v: Video) => v.videoId));
const extra = (top?.items || []).filter((v: Video) => !seen.has(v.videoId));
return { items: [...(merged?.items || []), ...extra], nextCursor: top?.nextCursor || merged?.nextCursor || null };
}),
// En cas d'échec du fallback, garder au moins VODs+clips.
catchError(() => of(merged))
);
})
);
})
);
@@ -164,11 +180,11 @@ export class ProviderThemePageComponent implements OnDestroy {
}
apiCall.subscribe({
next: (res) => {
next: (res: PagedResult<Video>) => {
if (snapshotVersion !== this.version) return;
const existingIds = new Set(this.allVideos().map(v => v.videoId));
const newVideos = (res.items || []).filter(v => !existingIds.has(v.videoId));
const existingIds = new Set(this.allVideos().map((v: Video) => v.videoId));
const newVideos = (res.items || []).filter((v: Video) => !existingIds.has(v.videoId));
this.allVideos.update(current => [...current, ...newVideos]);
this.nextCursor.set(res.nextCursor || null);
@@ -199,11 +215,14 @@ export class ProviderThemePageComponent implements OnDestroy {
const typeFilter = this.type();
if (typeFilter !== 'any') {
const p = this.provider();
arr = arr.filter(v => {
if (typeFilter === 'video') return v.type === 'video';
if (typeFilter === 'live') return v.type === 'live' || v.type === 'channel';
if (typeFilter === 'recorded') return v.type === 'recorded';
if (typeFilter === 'short') return v.type === 'short' || v.duration < 60;
const withProvider = { ...v, provider: (v.provider as string) || p };
if (typeFilter === 'video') return !isShortVideo(withProvider) && (v.type === 'video' || v.type === 'recorded');
if (typeFilter === 'live') return v.type === 'live' || v.type === 'channel' || v.type === 'stream' || (v as any).isLive === true;
if (typeFilter === 'recorded') return v.type === 'recorded' || (v.type === 'video' && !isShortVideo(withProvider));
// Shorts : classification partagée (durée 0/absente ⇒ jamais un Short).
if (typeFilter === 'short') return isShortVideo(withProvider);
return true;
});
}
@@ -252,6 +271,27 @@ export class ProviderThemePageComponent implements OnDestroy {
onSortChange(val: any) { this.sort.set(val as any); }
onDurationChange(val: any) { this.duration.set(val as any); }
onTypeChange(val: any) { this.type.set(val as any); }
// Raccourci Vidéos / Shorts synchronisé avec le filtre "type" existant.
contentFilter = computed<'all' | 'videos' | 'shorts'>(() => {
const t = this.type();
if (t === 'short') return 'shorts';
if (t === 'video') return 'videos';
return 'all';
});
setContentFilter(v: 'all' | 'videos' | 'shorts'): void {
this.type.set(v === 'shorts' ? 'short' : v === 'videos' ? 'video' : 'any');
}
kindOf(v: Video): string {
const p = this.provider();
return classifyVideo({ ...v, provider: (v.provider as string) || p });
}
shortsCount(): number {
const p = this.provider();
return this.allVideos().filter(v => isShortVideo({ ...v, provider: (v.provider as string) || p })).length;
}
onLanguageChange(val: any) { this.language.set(val as any); }
onDateChange(val: any) { this.date.set(val as any); }
@@ -1,8 +1,27 @@
<div class="container mx-auto p-4 sm:p-6">
<h2 class="text-3xl font-bold mb-6 text-slate-100 border-l-4 border-red-500 pl-4 flex items-center gap-2">
<h2 class="text-3xl font-bold mb-4 text-slate-100 border-l-4 border-red-500 pl-4 flex items-center gap-2">
<span>{{ themeLabel() }}</span>
</h2>
<!-- Filtre Vidéos / Shorts : les deux formats ne sont plus mélangés sans contrôle -->
<div class="flex items-center gap-2 mb-6" role="tablist" aria-label="Filtrer vidéos ou shorts">
<button (click)="setContentFilter('all')" [attr.aria-pressed]="contentFilter() === 'all'"
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
[class]="contentFilter() === 'all' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
Tout
</button>
<button (click)="setContentFilter('videos')" [attr.aria-pressed]="contentFilter() === 'videos'"
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
[class]="contentFilter() === 'videos' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
Vidéos
</button>
<button (click)="setContentFilter('shorts')" [attr.aria-pressed]="contentFilter() === 'shorts'"
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
[class]="contentFilter() === 'shorts' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
Shorts
</button>
</div>
<div class="space-y-10">
<section *ngFor="let b of blocks()">
<div class="flex items-center justify-between mb-3">
@@ -18,10 +37,13 @@
<!-- Error / empty -->
<div *ngIf="!b.loading && b.error" class="text-amber-300">{{ b.error }}</div>
<div *ngIf="!b.loading && !b.error && b.items.length === 0" class="text-slate-400">{{ 'empty.noItems' | t }}</div>
<div *ngIf="!b.loading && !b.error && b.items.length > 0 && filteredItems(b).length === 0" class="text-slate-400">
Aucun {{ contentFilter() === 'shorts' ? 'Short' : 'vidéo' }} pour ce fournisseur — essayez l'autre filtre.
</div>
<!-- Grid -->
<div *ngIf="!b.loading && !b.error && b.items.length" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
<a *ngFor="let v of b.items"
<div *ngIf="!b.loading && !b.error && filteredItems(b).length" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
<a *ngFor="let v of filteredItems(b)"
[routerLink]="['/watch', v.videoId]"
[queryParams]="watchQueryParams(b.provider, v)"
[state]="{ video: v }"
@@ -31,6 +53,10 @@
<div class="absolute top-2 right-2">
<app-provider-badge [provider]="b.provider"></app-provider-badge>
</div>
<div class="absolute top-2 left-2 flex gap-1">
<span *ngIf="kindOf(v, b.provider) === 'short'" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">SHORT</span>
<span *ngIf="kindOf(v, b.provider) === 'live'" class="bg-purple-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">LIVE</span>
</div>
<div class="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-2 py-0.5 rounded" *ngIf="v.duration && v.duration > 0">
{{ v.duration | duration }}
</div>
@@ -9,6 +9,7 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
import { LikeButtonComponent } from '../shared/components/like-button/like-button.component';
import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component';
import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
import { classifyVideo, isShortVideo } from '../../app/shared/utils/video-kind';
interface ProviderBlock {
provider: Provider;
@@ -39,6 +40,30 @@ export class ThemePageComponent {
blocks = signal<ProviderBlock[]>([]);
// Filtre d'affichage Vidéos / Shorts (les Shorts ne sont plus mélangés sans contrôle).
readonly contentFilter = signal<'all' | 'videos' | 'shorts'>('all');
setContentFilter(v: 'all' | 'videos' | 'shorts'): void {
this.contentFilter.set(v);
}
/** Items du bloc après application du filtre Vidéos/Shorts. */
filteredItems(b: ProviderBlock): Video[] {
const f = this.contentFilter();
if (f === 'all') return b.items;
return b.items.filter(v =>
f === 'shorts' ? isShortVideo({ ...v, provider: (v.provider as string) || b.provider }) : !isShortVideo({ ...v, provider: (v.provider as string) || b.provider })
);
}
kindOf(v: Video, provider: Provider): string {
return classifyVideo({ ...v, provider: (v.provider as string) || provider });
}
shortsCount(b: ProviderBlock): number {
return b.items.filter(v => isShortVideo({ ...v, provider: (v.provider as string) || b.provider })).length;
}
constructor() {
this.route.paramMap.subscribe(pm => {
const slug = pm.get('theme') || pm.get('slug') || 'trending';
@@ -115,6 +140,35 @@ export class ThemePageComponent {
});
return;
}
// Twitch / live : les top streams live garantissent un bloc rempli
// (la recherche de chaînes "live" est trop restrictive).
if (block.provider === 'twitch' && slug === 'live') {
this.api.getTwitchTopStreamsPage(null).subscribe({
next: res => {
block.items = (res.items || []).slice(0, max);
block.loading = false;
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, mode: 'twitch_top_streams' });
this.blocks.update(a => [...a]);
},
error: () => { block.error = 'Erreur de chargement'; block.loading = false; this.blocks.update(a => [...a]); }
});
return;
}
// Twitch hors live/trending : VODs + clips + fallback top streams.
if (block.provider === 'twitch') {
const q = tokens.join(' ');
const call = q ? this.api.searchTwitchThemePage(q, null) : this.api.getTwitchTopStreamsPage(null);
call.subscribe({
next: res => {
block.items = (res.items || []).slice(0, max);
block.loading = false;
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, query: q });
this.blocks.update(a => [...a]);
},
error: () => { block.error = 'Erreur de chargement'; block.loading = false; this.blocks.update(a => [...a]); }
});
return;
}
const q = tokens.join(' ');
if (!q) {
// fallback to trending
+35 -4
View File
@@ -59,14 +59,45 @@
{{ formatViewsFr(v.views) }} • {{ formatDateAbsFr(v.uploadedDate) }}
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mt-3 text-slate-400">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mt-3 text-slate-400 gap-3">
<div class="flex items-center space-x-3">
<img [src]="v.uploaderAvatar" [alt]="v.uploaderName" class="w-10 h-10 rounded-full">
@if (channelAvatarUrl(); as avatar) {
<img [src]="avatar" [alt]="v.uploaderName" (error)="onAvatarError()"
class="w-10 h-10 rounded-full object-cover bg-slate-700" loading="lazy" referrerpolicy="no-referrer">
} @else {
<div class="w-10 h-10 rounded-full bg-slate-700 flex items-center justify-center text-slate-200 font-bold"
[attr.aria-label]="v.uploaderName">{{ channelInitial() }}</div>
}
<div>
<div class="font-semibold text-slate-200">{{ v.uploaderName }}</div>
<div>{{ formatNumber(v.subscribers) }} subscribers</div>
<div class="font-semibold text-slate-200">{{ v.uploaderName || channelMeta()?.title || 'Chaîne inconnue' }}</div>
@if (channelSubsLabel(); as subs) {
<div>{{ subs }} abonnés</div>
}
</div>
@if (canSubscribe() && isLoggedIn()) {
<app-subscribe-button
[provider]="subscribeProvider()!"
[externalId]="subscribeExternalId()!"
[channelMeta]="channelMeta()"
(subscribed)="onSubscribedFromWatch($event)"
(unsubscribed)="onUnsubscribedFromWatch()">
</app-subscribe-button>
} @else if (canSubscribe() && !isLoggedIn()) {
<a routerLink="/auth/login" title="Connectez-vous pour vous abonner"
class="inline-flex items-center gap-2 rounded-md border border-slate-600 bg-slate-800/70 px-3 py-1.5 text-sm font-semibold text-slate-200">
S'abonner
</a>
}
</div>
@if (subscribeError(); as err) {
<div class="text-sm text-red-300">{{ err }}</div>
}
@if (subServiceError(); as serr) {
<div class="text-sm text-red-300">{{ serr }}</div>
}
@if (canSubscribe() && isLoggedIn()) {
<div class="text-xs text-slate-500" title="Raccourci clavier">Astuce : touche « s » pour (dé)s'abonner</div>
}
<div class="flex items-center space-x-4 mt-3 sm:mt-0">
<a [href]="openOnProviderUrl() || '#'" target="_blank" rel="noopener noreferrer"
class="flex items-center space-x-2 bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-full transition"
+178 -9
View File
@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, inject, signal, computed, OnDestroy, AfterViewInit, ViewChild, ElementRef } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject, signal, computed, OnDestroy, AfterViewInit, ViewChild, ElementRef, HostListener } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
@@ -13,6 +13,10 @@ import { HistoryService } from '../../services/history.service';
import { InstanceService, Provider } from '../../services/instance.service';
import { DownloadService, DownloadFormat, DownloadJob } from '../../services/download.service';
import { AuthService } from '../../services/auth.service';
import { ChannelsService } from '../../services/channels.service';
import { SubscriptionsService } from '../../services/subscriptions.service';
import type { ChannelMeta, ProviderId } from '../../app/shared/models/channel.model';
import { SubscribeButtonComponent } from '../../app/shared/components/subscribe-button/subscribe-button.component';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { IframeProgressService } from '../../services/iframe-progress.service';
import { formatAbsoluteFr, formatNumberFr } from '../../utils/date.util';
@@ -24,7 +28,7 @@ import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
templateUrl: './watch.component.html',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, VideoPlayerComponent, RouterLink, AddToPlaylistComponent, ProviderBadgeComponent, DurationPipe]
imports: [CommonModule, VideoPlayerComponent, RouterLink, AddToPlaylistComponent, ProviderBadgeComponent, DurationPipe, SubscribeButtonComponent]
})
export class WatchComponent implements OnDestroy, AfterViewInit {
private route = inject(ActivatedRoute);
@@ -39,10 +43,69 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
private auth = inject(AuthService);
private http = inject(HttpClient);
private iframeProgress = inject(IframeProgressService);
private channels = inject(ChannelsService);
private subs = inject(SubscriptionsService);
private routeSubscription!: Subscription;
@ViewChild('embedFrame', { static: false }) embedFrame?: ElementRef<HTMLIFrameElement>;
private attachTryCount = 0;
// --- Chaîne / abonnement ---
channelMeta = signal<ChannelMeta | null>(null);
channelAvatarBroken = signal(false);
subscribeError = signal<string | null>(null);
subscribeBusy = signal(false);
/** Mappe le provider d'affichage ('youtube') vers l'id canal ('yt'). */
subscribeProvider = computed<ProviderId | null>(() => {
const map: Record<string, ProviderId> = {
youtube: 'yt', dailymotion: 'dm', twitch: 'tw',
peertube: 'pt', odysee: 'od', rumble: 'ru',
};
return map[this.provider()] || null;
});
/** ExternalId de la chaîne : meta résolue > détails vidéo > néant. */
subscribeExternalId = computed<string | null>(() => {
const meta = this.channelMeta();
if (meta?.externalId) return meta.externalId;
const v: any = this.video();
const ext = v?.channelExternalId || v?.channelId || null;
return ext ? String(ext) : null;
});
canSubscribe = computed(() => !!this.subscribeProvider() && !!this.subscribeExternalId());
/** Erreur remontée par le bouton S'abonner (ex. session expirée). */
subServiceError = computed(() => this.subs.lastError());
/** Avatar à afficher : meta chaîne > détails vidéo > néant (initiales). */
channelAvatarUrl = computed<string | null>(() => {
if (this.channelAvatarBroken()) return null;
const metaAvatar = this.channelMeta()?.avatarUrl;
if (metaAvatar) return metaAvatar;
const v: any = this.video();
const direct = v?.channelAvatarUrl || v?.uploaderAvatar;
if (direct && /^https?:\/\//i.test(String(direct))) {
// Évite d'afficher la miniature vidéo comme logo (cas YouTube historique).
if (v?.thumbnail && String(direct) === String(v.thumbnail)) return null;
return String(direct);
}
return null;
});
channelInitial = computed(() => {
const name = (this.video()?.uploaderName || this.channelMeta()?.title || this.channelMeta()?.handle || 'C').trim();
return name ? name.charAt(0).toUpperCase() : 'C';
});
channelSubsLabel = computed(() => {
const fromMeta = this.channelMeta()?.subsCount;
if (typeof fromMeta === 'number') return this.formatNumber(fromMeta);
const v = this.video()?.subscribers;
if (typeof v === 'number' && v > 0) return this.formatNumber(v);
return null;
});
// Choose correct API base: use dev proxy when UI runs on a different port than the backend
private apiBase(): string {
try {
@@ -264,8 +327,11 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
const parentsSet = new Set<string>([host, 'localhost', '127.0.0.1']);
const parentParams = Array.from(parentsSet).map(h => `parent=${encodeURIComponent(h)}`).join('&');
const base = 'https://player.twitch.tv/';
const qs = ch
? `?channel=${encodeURIComponent(ch)}&${parentParams}&autoplay=false`
// ?video= exige un id de VOD numérique ; un login (non numérique) sans
// ?channel= doit être traité comme une chaîne (résultats de recherche).
const chan = ch || (!/^\d+$/.test(String(id || '')) && id ? id : null);
const qs = chan
? `?channel=${encodeURIComponent(chan)}&${parentParams}&autoplay=false`
: `?video=${encodeURIComponent(id)}&${parentParams}&autoplay=false`;
const u = base + qs;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
@@ -316,7 +382,78 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
return null;
});
@HostListener('document:keydown', ['$event'])
onGlobalKey(ev: KeyboardEvent): void {
try {
const target = ev.target as HTMLElement | null;
if (target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
if (ev.key !== 's' && ev.key !== 'S') return;
if (!this.canSubscribe() || !this.isLoggedIn() || this.subscribeBusy()) return;
ev.preventDefault();
this.toggleSubscribeShortcut();
} catch {}
}
onAvatarError(): void {
this.channelAvatarBroken.set(true);
}
onSubscribedFromWatch(meta: ChannelMeta | null): void {
this.subscribeError.set(null);
if (meta) this.channelMeta.set(meta);
}
onUnsubscribedFromWatch(): void {
this.subscribeError.set(null);
}
/** Fallback quand le bouton n'est pas utilisable : même logique que SubscribeButton. */
toggleSubscribeShortcut(): void {
const provider = this.subscribeProvider();
const externalId = this.subscribeExternalId();
if (!provider || !externalId || this.subscribeBusy()) return;
this.subscribeBusy.set(true);
this.subscribeError.set(null);
const isSub = this.subs.isSubscribed(provider, externalId);
if (isSub) {
const current = this.subs.items().find((i) => i.channel.provider === provider && i.channel.externalId === externalId);
if (!current) { this.subscribeBusy.set(false); return; }
this.subs.unsubscribe(current.subscriptionId).subscribe({
next: (ok) => { if (!ok) this.subscribeError.set("Désabonnement impossible. Réessayez."); },
error: () => this.subscribeError.set("Désabonnement impossible. Réessayez."),
complete: () => this.subscribeBusy.set(false),
});
} else {
this.subs.subscribe(provider, externalId, { refresh: true }).subscribe({
next: (item) => {
if (item?.channel) this.channelMeta.set(item.channel);
else this.subscribeError.set("Abonnement impossible. Réessayez.");
},
error: () => this.subscribeError.set("Abonnement impossible. Réessayez."),
complete: () => this.subscribeBusy.set(false),
});
}
}
private resolveChannelMeta(): void {
const provider = this.subscribeProvider();
// subscribeExternalId dépend de video(), donc on le lit après chaque merge.
const externalId = this.subscribeExternalId();
if (!provider || !externalId) return;
try {
this.channels.get(provider, externalId).subscribe({
next: (meta) => { if (meta) this.channelMeta.set(meta); },
error: () => {},
});
} catch {}
}
constructor() {
// Charge le cache d'abonnements pour que isSubscribed() soit fiable sur /watch.
try {
if (this.subs.items().length === 0) this.subs.list().subscribe();
} catch {}
this.routeSubscription = this.route.paramMap.subscribe(params => {
const id = params.get('id');
const providerFromPath = params.get('provider');
@@ -398,7 +535,14 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
// Clear any previous Rumble embed URL to avoid stale iframes during fast navigation
this.rumbleEmbed.set(null);
// Prepare placeholder from router state if available for richer UI
const stateVideo = (history.state && (history.state as any).video) as Video | undefined;
const stateVideo = (history.state && (history.state as any).video) as (Video & { channelAvatarUrl?: string }) | undefined;
const placeholderAvatar = ((): string => {
const a = stateVideo?.uploaderAvatar || '';
const thumb = stateVideo?.thumbnail || '';
// N'affiche jamais la miniature vidéo comme logo chaîne.
if (a && thumb && a === thumb) return '';
return a;
})();
this.video.set({
url: stateVideo?.url || '',
type: stateVideo?.type || 'video',
@@ -406,7 +550,10 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
thumbnail: stateVideo?.thumbnail || '',
uploaderName: stateVideo?.uploaderName || '',
uploaderUrl: stateVideo?.uploaderUrl || '',
uploaderAvatar: stateVideo?.uploaderAvatar || '',
uploaderAvatar: placeholderAvatar,
channelId: (stateVideo as any)?.channelId,
channelExternalId: (stateVideo as any)?.channelExternalId,
channelAvatarUrl: (stateVideo as any)?.channelAvatarUrl,
uploadedDate: stateVideo?.uploadedDate || '',
shortDescription: stateVideo?.shortDescription || '',
duration: stateVideo?.duration || 0,
@@ -421,7 +568,14 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
audioStreams: [],
relatedStreams: [],
comments: []
});
} as VideoDetail);
// Reset état chaîne à chaque navigation.
this.channelMeta.set(null);
this.channelAvatarBroken.set(false);
this.subscribeError.set(null);
this.subscribeBusy.set(false);
// Si le state contient déjà un channelId, pré-résout l'avatar sans attendre /details.
this.resolveChannelMeta();
this.showFullDescription.set(false);
this.summary.set(null);
this.isSummarizing.set(false);
@@ -462,13 +616,20 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
title: data?.title || v.title,
thumbnail: data?.thumbnail || v.thumbnail,
uploaderName: data?.uploaderName || v.uploaderName,
uploaderUrl: (data as any)?.uploaderUrl || (v as any).uploaderUrl,
uploaderAvatar: data?.uploaderAvatar || v.uploaderAvatar,
channelId: (data as any)?.channelId || (v as any)?.channelId,
channelExternalId: (data as any)?.channelExternalId || (v as any)?.channelExternalId,
channelAvatarUrl: (data as any)?.uploaderAvatar || (v as any)?.channelAvatarUrl,
uploadedDate: data?.uploadedDate || v.uploadedDate,
views: typeof data?.views === 'number' ? data.views : v.views,
subscribers: typeof (data as any)?.subscribers === 'number' && (data as any).subscribers > 0 ? (data as any).subscribers : v.subscribers,
duration: typeof data?.duration === 'number' ? data.duration : v.duration,
description: data?.description || v.description,
url: data?.url || v.url,
} : v);
this.channelAvatarBroken.set(false);
this.resolveChannelMeta();
this.loading.set(false);
this.loadRelatedSuggestions();
// Update watch history with enriched title/thumbnail/duration
@@ -523,12 +684,20 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
title: data?.title || v.title,
thumbnail: data?.thumbnail || v.thumbnail,
uploaderName: data?.uploaderName || v.uploaderName,
uploaderUrl: (data as any)?.uploaderUrl || (v as any).uploaderUrl,
uploaderAvatar: (data as any)?.uploaderAvatar || v.uploaderAvatar,
channelId: (data as any)?.channelId || (v as any)?.channelId,
channelExternalId: (data as any)?.channelExternalId || (v as any)?.channelExternalId,
channelAvatarUrl: (data as any)?.uploaderAvatar || (v as any)?.channelAvatarUrl,
subscribers: typeof (data as any)?.subscribers === 'number' && (data as any).subscribers > 0 ? (data as any).subscribers : v.subscribers,
uploadedDate: data?.uploadedDate || v.uploadedDate,
views: typeof data?.views === 'number' ? data.views : v.views,
duration: typeof data?.duration === 'number' ? data.duration : v.duration,
description: (data?.description || v.description || v.shortDescription || ''),
url: data?.url || v.url,
} : v);
this.channelAvatarBroken.set(false);
this.resolveChannelMeta();
this.loading.set(false);
this.loadRelatedSuggestions();
// Update watch history with enriched title/thumbnail/duration
@@ -883,9 +1052,9 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
if (slug.startsWith('/')) slug = slug.slice(1);
qp.slug = slug;
}
if (p === 'twitch' && v.type === 'channel') {
if (p === 'twitch' && (v.type === 'channel' || (v as any).type === 'live')) {
// Prefer explicit videoId as channel login; fallback to parsing URL
if (v.videoId) {
if (v.videoId && !/^\d+$/.test(String(v.videoId))) {
qp.channel = v.videoId;
} else {
const url = v.uploaderUrl || v.url || '';
+4
View File
@@ -7,6 +7,10 @@ export interface Video {
uploaderName: string;
uploaderUrl?: string;
uploaderAvatar: string;
channelId?: string;
channelHandle?: string;
channelExternalId?: string;
channelAvatarUrl?: string;
uploadedDate: string;
shortDescription?: string | null;
duration: number;
+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 };