feat(channel): page details chaine multi-providers avec onglets et infinite scroll
CI / build-and-test (push) Successful in 13m34s
CI / build-and-test (push) Successful in 13m34s
- Strategy Pattern front: ChannelProviderInterface + 6 providers (yt/dm/tw/pt/od/ru) + factory (ids longs/courts) - ChannelContentService: cache par onglet/tri/recherche (TTL 5min), pagination nextPage - ChannelPage: header (banniere/avatar/subs/description ...plus), tabs dynamiques par capabilities, grilles videos/shorts 9:16/playlists/live, recherche debounced, tri recent/populaire, infinite scroll IntersectionObserver, skeletons/empty/error - Backend: GET /api/channels/:provider/:id/content (videos/shorts/playlists/live) natif par provider
This commit is contained in:
@@ -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 })),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user