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
@@ -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';
}