feat(providers): phases 7.3/7.4/7.6/8.1 — provenance, health, NDJSON, contrat unique
CI / build-and-test (push) Successful in 14m43s
CI / build-and-test (push) Successful in 14m43s
7.3: capturedAt/source au registre + 6 adaptateurs + module provenance.ts + ?debug=1 (search-transport.mjs). 7.4: ProviderHealthService + badge source degradee. 7.6: squelettes par provider + snapshots progressifs + transport NDJSON /api/search. 8.1: ProviderAdapter unifie (search enveloppe + channelContent/channelMeta/capabilities) via getProviderAdapter + test de contrat offline.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import type { ProviderId } from '../../../shared/models/channel.model';
|
||||
import { toShortProviderId } from '../../../shared/providers/provider-ids';
|
||||
import type { ChannelProviderInterface } from './channel-provider.interface';
|
||||
import {
|
||||
DailymotionChannelProvider,
|
||||
@@ -13,19 +14,25 @@ import {
|
||||
|
||||
/** 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;
|
||||
return toShortProviderId(value);
|
||||
}
|
||||
|
||||
type ChannelProviderCtor = new (http: HttpClient) => ChannelProviderInterface;
|
||||
|
||||
/**
|
||||
* Phase 8.1 — un provider = une entrée de table, pas un `case` de switch : la
|
||||
* stratégie de chaîne est la même source unique que l'identifiant. Ajouter un
|
||||
* provider = une ligne ici (+ l'id partagé), le reste est dérivé.
|
||||
*/
|
||||
const CHANNEL_STRATEGIES: Record<ProviderId, ChannelProviderCtor> = {
|
||||
yt: YouTubeChannelProvider,
|
||||
dm: DailymotionChannelProvider,
|
||||
tw: TwitchChannelProvider,
|
||||
pt: PeerTubeChannelProvider,
|
||||
od: OdyseeChannelProvider,
|
||||
ru: RumbleChannelProvider,
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ChannelProviderFactory {
|
||||
private http = inject(HttpClient);
|
||||
@@ -34,16 +41,8 @@ export class ChannelProviderFactory {
|
||||
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;
|
||||
}
|
||||
const Ctor = CHANNEL_STRATEGIES[provider] ?? YouTubeChannelProvider;
|
||||
const created = new Ctor(this.http);
|
||||
this.instances.set(provider, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface ChannelContentQuery {
|
||||
limit?: number;
|
||||
sort?: ChannelSort;
|
||||
q?: string;
|
||||
/** Curseur opaque des API paginées par curseur (Twitch Helix). */
|
||||
cursor?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,31 +11,73 @@ import type {
|
||||
} 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 { SHORT_TO_LONG } from '../../../shared/providers/provider-ids';
|
||||
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',
|
||||
};
|
||||
/** Phase 0.1 — dérivé de la table unique (`provider-ids.ts`), plus de copie locale. */
|
||||
const LONG_PROVIDER: Record<ProviderId, VideoItem['provider']> = SHORT_TO_LONG;
|
||||
|
||||
/**
|
||||
* Phase 2.1 — `toVideoItem` complet. Ce convertisseur perdait 8 champs pourtant
|
||||
* présents dans la réponse serveur (et dans le contrat `Suggestion`), ce qui
|
||||
* vidait la page chaîne de la metade de ses metadonnees.
|
||||
* Regles appliquees :
|
||||
* - on lit le nom long ET le nom court pour chaque champ (le serveur emet le
|
||||
* long, le cache historique le court) ;
|
||||
* - une duree nulle/absente devient `undefined`, jamais `0` (sinon la regle
|
||||
* "verticale sans duree => pas un short" devient inoperante) ;
|
||||
* - rien n'est invente : un champ absent reste `undefined`.
|
||||
*/
|
||||
function toVideoItem(raw: any, provider: ProviderId, fallbackChannelId: string): VideoItem {
|
||||
const pick = (...keys: string[]): any => {
|
||||
for (const k of keys) {
|
||||
const v = raw?.[k];
|
||||
if (v !== undefined && v !== null && v !== '') return v;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const num = (...keys: string[]): number | undefined => {
|
||||
const v = pick(...keys);
|
||||
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
|
||||
};
|
||||
const str = (...keys: string[]): string | undefined => {
|
||||
const v = pick(...keys);
|
||||
return typeof v === 'string' ? v : undefined;
|
||||
};
|
||||
const thumb = str('thumbnailUrl', 'thumbnail', 'thumbUrl', 'thumbnail_url');
|
||||
return {
|
||||
id: String(raw?.id ?? ''),
|
||||
id: String(pick('id', 'videoId') ?? ''),
|
||||
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,
|
||||
title: String(pick('title', 'name') ?? ''),
|
||||
thumbnailUrl: thumb ?? '',
|
||||
// 0 => undefined : evite de classer "verticale sans duree" en short.
|
||||
durationSec: num('durationSec', 'duration'),
|
||||
channelName: str('uploaderName', 'channelName', 'channel'),
|
||||
channelExternalId: str('channelExternalId', 'channelId', 'uploaderId') ?? (fallbackChannelId || undefined),
|
||||
channelAvatarUrl: str('uploaderAvatar', 'channelAvatarUrl', 'uploaderAvatarUrl'),
|
||||
viewCount: num('viewCount', 'views'),
|
||||
// Phase 7.3 — spectateurs du live, distincts des vues.
|
||||
viewers: num('viewers', 'viewerCount'),
|
||||
// Phase 2.1 — classification : ces 6 champs étaient collectés puis perdus.
|
||||
// `type` manquait : sans lui, la page chaîne ne pouvait pas distinguer un
|
||||
// live d'un VOD sur la seule base du `kind`.
|
||||
type: str('type'),
|
||||
isLive: raw?.isLive === true,
|
||||
isShort: raw?.isShort === true,
|
||||
kind: str('kind'),
|
||||
game: str('game'),
|
||||
width: num('width'),
|
||||
height: num('height'),
|
||||
publishedAt: str('publishedAt', 'uploadedDate', 'uploadedAt'),
|
||||
slug: str('slug'),
|
||||
channel: str('channel'),
|
||||
likes: num('likes', 'likeCount', 'likesCount'),
|
||||
dislikes: num('dislikes', 'dislikeCount'),
|
||||
language: str('language'),
|
||||
hasSubtitles: typeof raw?.hasSubtitles === 'boolean' ? raw.hasSubtitles : undefined,
|
||||
embeddable: typeof raw?.embeddable === 'boolean' ? raw.embeddable : undefined,
|
||||
description: str('description'),
|
||||
tags: Array.isArray(raw?.tags) ? raw.tags.filter((t: unknown) => typeof t === 'string') : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,6 +101,8 @@ interface ContentApiResponse {
|
||||
items?: any[];
|
||||
nextPage?: number | null;
|
||||
total?: number | null;
|
||||
/** Curseur pour les APIs paginées par curseur (Twitch Helix). */
|
||||
nextCursor?: string | null;
|
||||
}
|
||||
|
||||
export class HttpChannelProvider implements ChannelProviderInterface {
|
||||
@@ -134,6 +178,9 @@ export class HttpChannelProvider implements ChannelProviderInterface {
|
||||
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);
|
||||
// Phase 3.1 - on renvoie le curseur reçu : sans ça, la page 2 demandait
|
||||
// exactement la même chose que la page 1 (pagination infinie sur Twitch).
|
||||
if (query?.cursor) params = params.set('cursor', query.cursor);
|
||||
return this.http
|
||||
.get<ContentApiResponse>(`/api/channels/${this.id}/${encodeURIComponent(externalId)}/content`, { params })
|
||||
.pipe(
|
||||
@@ -141,8 +188,9 @@ export class HttpChannelProvider implements ChannelProviderInterface {
|
||||
items: Array.isArray(resp?.items) ? resp!.items! : [],
|
||||
nextPage: resp?.nextPage ?? null,
|
||||
total: resp?.total ?? null,
|
||||
nextCursor: resp?.nextCursor ?? null,
|
||||
})),
|
||||
catchError(() => of({ items: [], nextPage: null })),
|
||||
catchError(() => of({ items: [], nextPage: null, total: null, nextCursor: null })),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
export type ProviderId = 'yt' | 'dm' | 'tw' | 'pt' | 'od' | 'ru';
|
||||
import {
|
||||
ALL_PROVIDER_IDS_CSV,
|
||||
type ProviderId,
|
||||
SHORT_TO_LONG,
|
||||
toShortProviderId,
|
||||
} from '../../shared/providers/provider-ids';
|
||||
import {
|
||||
capabilitiesOf,
|
||||
PROVIDER_CAPABILITIES,
|
||||
toLegacySupports,
|
||||
type LegacySupports,
|
||||
} from '../../shared/providers/provider-capabilities';
|
||||
|
||||
/**
|
||||
* Phase 0 — le registre ne contient plus que de la présentation (libellés, icônes,
|
||||
* couleurs). Les identifiants vivent dans `shared/providers/provider-ids.ts` et les
|
||||
* capacités dans `shared/providers/provider-capabilities.ts` (source unique).
|
||||
*/
|
||||
export type { ProviderId } from '../../shared/providers/provider-ids';
|
||||
|
||||
export interface ProviderSpec {
|
||||
id: ProviderId;
|
||||
@@ -6,51 +24,52 @@ export interface ProviderSpec {
|
||||
shortLabel: string; // YT, DM, ...
|
||||
icon: string; // lucide or custom svg name
|
||||
colorClass: string; // tailwind text-red-500, etc.
|
||||
supports: { search: boolean; shorts: boolean; live: boolean; playlists: boolean };
|
||||
supports: LegacySupports; // dérivé de PROVIDER_CAPABILITIES — ne plus éditer à la main
|
||||
buildSearchUrl: (q: string, opts?: { page?: number; limit?: number; providers?: ProviderId[] | 'all' }) => string; // front-only fallback
|
||||
}
|
||||
|
||||
export const PROVIDERS: ProviderSpec[] = [
|
||||
{ id: 'yt', displayName: 'YouTube', shortLabel: 'YT', icon: 'youtube', colorClass: 'text-red-500',
|
||||
supports: { search: true, shorts: true, live: true, playlists: true },
|
||||
buildSearchUrl: (q, opts) => `/api/search?q=${encodeURIComponent(q)}&providers=${encodeURIComponent((opts?.providers && opts.providers !== 'all') ? opts.providers.join(',') : 'yt,dm,tw,pt,od,ru')}`
|
||||
},
|
||||
{ id: 'dm', displayName: 'Dailymotion', shortLabel: 'DM', icon: 'dailymotion', colorClass: 'text-blue-500',
|
||||
supports: { search: true, shorts: false, live: false, playlists: false },
|
||||
buildSearchUrl: (q, opts) => `/api/search?q=${encodeURIComponent(q)}&providers=${encodeURIComponent((opts?.providers && opts.providers !== 'all') ? opts.providers.join(',') : 'yt,dm,tw,pt,od,ru')}`
|
||||
},
|
||||
{ id: 'tw', displayName: 'Twitch', shortLabel: 'TW', icon: 'twitch', colorClass: 'text-purple-500',
|
||||
supports: { search: true, shorts: false, live: true, playlists: false },
|
||||
buildSearchUrl: (q, opts) => `/api/search?q=${encodeURIComponent(q)}&providers=${encodeURIComponent((opts?.providers && opts.providers !== 'all') ? opts.providers.join(',') : 'yt,dm,tw,pt,od,ru')}`
|
||||
},
|
||||
{ id: 'pt', displayName: 'PeerTube', shortLabel: 'PT', icon: 'peertube', colorClass: 'text-green-500',
|
||||
supports: { search: true, shorts: false, live: false, playlists: true },
|
||||
buildSearchUrl: (q, opts) => `/api/search?q=${encodeURIComponent(q)}&providers=${encodeURIComponent((opts?.providers && opts.providers !== 'all') ? opts.providers.join(',') : 'yt,dm,tw,pt,od,ru')}`
|
||||
},
|
||||
{ id: 'od', displayName: 'Odysee', shortLabel: 'OD', icon: 'odysee', colorClass: 'text-yellow-500',
|
||||
supports: { search: true, shorts: false, live: false, playlists: false },
|
||||
buildSearchUrl: (q, opts) => `/api/search?q=${encodeURIComponent(q)}&providers=${encodeURIComponent((opts?.providers && opts.providers !== 'all') ? opts.providers.join(',') : 'yt,dm,tw,pt,od,ru')}`
|
||||
},
|
||||
{ id: 'ru', displayName: 'Rumble', shortLabel: 'RU', icon: 'rumble', colorClass: 'text-orange-500',
|
||||
supports: { search: true, shorts: false, live: false, playlists: false },
|
||||
buildSearchUrl: (q, opts) => `/api/search?q=${encodeURIComponent(q)}&providers=${encodeURIComponent((opts?.providers && opts.providers !== 'all') ? opts.providers.join(',') : 'yt,dm,tw,pt,od,ru')}`
|
||||
},
|
||||
/** Closure partagée : la liste "par défaut" vient de ALL_PROVIDER_IDS_CSV (plus de littéral en dur). */
|
||||
const defaultBuildSearchUrl: ProviderSpec['buildSearchUrl'] = (q, opts) => {
|
||||
const csv = (opts?.providers && opts.providers !== 'all' && opts.providers.length)
|
||||
? opts.providers.join(',')
|
||||
: ALL_PROVIDER_IDS_CSV;
|
||||
return `/api/search?q=${encodeURIComponent(q)}&providers=${encodeURIComponent(csv)}`;
|
||||
};
|
||||
|
||||
/** Présentation pure — aucune donnée de capacité ici. */
|
||||
const PROVIDER_PRESENTATION: ReadonlyArray<Omit<ProviderSpec, 'supports'>> = [
|
||||
{ id: 'yt', displayName: 'YouTube', shortLabel: 'YT', icon: 'youtube', colorClass: 'text-red-500', buildSearchUrl: defaultBuildSearchUrl },
|
||||
{ id: 'dm', displayName: 'Dailymotion', shortLabel: 'DM', icon: 'dailymotion', colorClass: 'text-blue-500', buildSearchUrl: defaultBuildSearchUrl },
|
||||
{ id: 'tw', displayName: 'Twitch', shortLabel: 'TW', icon: 'twitch', colorClass: 'text-purple-500', buildSearchUrl: defaultBuildSearchUrl },
|
||||
{ id: 'pt', displayName: 'PeerTube', shortLabel: 'PT', icon: 'peertube', colorClass: 'text-green-500', buildSearchUrl: defaultBuildSearchUrl },
|
||||
{ id: 'od', displayName: 'Odysee', shortLabel: 'OD', icon: 'odysee', colorClass: 'text-yellow-500', buildSearchUrl: defaultBuildSearchUrl },
|
||||
{ id: 'ru', displayName: 'Rumble', shortLabel: 'RU', icon: 'rumble', colorClass: 'text-orange-500', buildSearchUrl: defaultBuildSearchUrl },
|
||||
];
|
||||
|
||||
export const PROVIDERS: ProviderSpec[] = PROVIDER_PRESENTATION.map(p => ({
|
||||
...p,
|
||||
supports: toLegacySupports(PROVIDER_CAPABILITIES[p.id]),
|
||||
}));
|
||||
|
||||
export const PROVIDER_MAP = new Map(PROVIDERS.map(p => [p.id, p]));
|
||||
|
||||
/** Provider long (instance.service) -> id court du registre. */
|
||||
const LONG_TO_SHORT: Record<string, ProviderId> = {
|
||||
youtube: 'yt', dailymotion: 'dm', twitch: 'tw', peertube: 'pt', rumble: 'ru', odysee: 'od',
|
||||
};
|
||||
|
||||
/**
|
||||
* Le provider supporte-t-il le direct ? Source unique = le registre.
|
||||
* Provider inconnu ⇒ false (jamais de crash), l'ajout d'un provider live
|
||||
* ne touche que ce registre.
|
||||
* Le provider supporte-t-il le direct ? Délègue à la source unique des capacités
|
||||
* (accepte ids courts ET longs ; provider inconnu ⇒ false, jamais de crash).
|
||||
*/
|
||||
export function providerSupportsLive(provider?: string | null): boolean {
|
||||
const key = String(provider || '').toLowerCase();
|
||||
const id = LONG_TO_SHORT[key] || (key as ProviderId);
|
||||
return PROVIDER_MAP.get(id)?.supports.live === true;
|
||||
return capabilitiesOf(provider).live === true;
|
||||
}
|
||||
|
||||
/** Libellé lisible d'un provider (court ou long) — sinon le token brut. */
|
||||
export function providerDisplayName(provider?: string | null): string {
|
||||
const id = toShortProviderId(provider);
|
||||
return (id && PROVIDER_MAP.get(id)?.displayName) || String(provider ?? '');
|
||||
}
|
||||
|
||||
/** Nom long (« youtube ») depuis un id court — helper de compatibilité. */
|
||||
export function providerLongName(id: ProviderId): string {
|
||||
return SHORT_TO_LONG[id];
|
||||
}
|
||||
|
||||
export { capabilitiesOf };
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult, SearchItem, ProviderId } from '../models';
|
||||
|
||||
export abstract class HttpAdapter implements ProviderAdapter {
|
||||
abstract key: ProviderId;
|
||||
abstract label: string;
|
||||
constructor(protected http: HttpClient) {}
|
||||
abstract search(params: ProviderSearchParams, signal: AbortSignal): Promise<SearchResult>;
|
||||
|
||||
protected mapItem(partial: Partial<SearchItem> & { id: string; title: string; watchUrl: string }): SearchItem {
|
||||
return {
|
||||
provider: this.key,
|
||||
channel: undefined,
|
||||
durationSec: undefined,
|
||||
views: undefined,
|
||||
publishedAt: undefined,
|
||||
isLive: false,
|
||||
isShort: false,
|
||||
thumbUrl: undefined,
|
||||
...partial,
|
||||
} as SearchItem;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import { firstValueFrom } from 'rxjs';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
import { withFilterParams } from './filter-params';
|
||||
import { readSearchGroup } from '../search-contract';
|
||||
import { mapProvenance } from './provenance';
|
||||
import { serverRefValue } from '../../shared/providers/channel-ref';
|
||||
|
||||
// TODO: Make SearchResult generic
|
||||
export class DmAdapter implements ProviderAdapter<VideoItem> {
|
||||
@@ -17,8 +20,11 @@ export class DmAdapter implements ProviderAdapter<VideoItem> {
|
||||
// le sait, affinés en post-traitement sinon.
|
||||
httpParams = withFilterParams(httpParams, params.filters);
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||
const providerError = typeof res?.errors?.dm?.message === 'string' ? res.errors.dm.message : undefined;
|
||||
const items: VideoItem[] = (res?.groups?.dm || []).map((it: any) => ({
|
||||
const { items: rawItems, providerError } = readSearchGroup(res, 'dm');
|
||||
const items: VideoItem[] = rawItems.map((it: any) => ({
|
||||
// Phase 7.3 — provenance (instant de collecte + voie) : propriété de la
|
||||
// donnée, pas d'un provider — mapping commun aux 6 adaptateurs.
|
||||
...mapProvenance(it),
|
||||
id: it.id,
|
||||
provider: 'dailymotion',
|
||||
title: it.title,
|
||||
@@ -27,7 +33,7 @@ export class DmAdapter implements ProviderAdapter<VideoItem> {
|
||||
thumbnailUrl: it.thumbnail,
|
||||
viewCount: undefined, // Dailymotion API does not provide view count in search results
|
||||
publishedAt: undefined, // Dailymotion API does not provide published date in search results
|
||||
channelExternalId: it.channelId || undefined,
|
||||
channelExternalId: serverRefValue(it.channelRef, 'dm') || it.channelId || undefined,
|
||||
channelAvatarUrl: it.uploaderAvatar || undefined,
|
||||
// Signaux de type pour le raffinement client des filtres.
|
||||
type: typeof it.type === 'string' ? it.type : undefined,
|
||||
@@ -36,6 +42,6 @@ export class DmAdapter implements ProviderAdapter<VideoItem> {
|
||||
width: typeof it.width === 'number' ? it.width : undefined,
|
||||
height: typeof it.height === 'number' ? it.height : undefined,
|
||||
} as VideoItem));
|
||||
return { items, total: Array.isArray(res?.groups?.dm) ? res.groups.dm.length : 0, providerError };
|
||||
return { items, total: rawItems.length, providerError };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ import { firstValueFrom } from 'rxjs';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
import { withFilterParams } from './filter-params';
|
||||
import { readSearchGroup } from '../search-contract';
|
||||
import { mapProvenance } from './provenance';
|
||||
import { normalizeOdyseeClaim, serverRefValue } from '../../shared/providers/channel-ref';
|
||||
|
||||
export class OdAdapter implements ProviderAdapter<VideoItem> {
|
||||
key = 'od' as const;
|
||||
@@ -16,8 +19,8 @@ export class OdAdapter implements ProviderAdapter<VideoItem> {
|
||||
// le sait, affinés en post-traitement sinon.
|
||||
httpParams = withFilterParams(httpParams, params.filters);
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||
const providerError = typeof res?.errors?.od?.message === 'string' ? res.errors.od.message : undefined;
|
||||
const items: VideoItem[] = (res?.groups?.od || []).map((it: any) => {
|
||||
const { items: rawItems, providerError } = readSearchGroup(res, 'od');
|
||||
const items: VideoItem[] = rawItems.map((it: any) => {
|
||||
let slug: string | undefined;
|
||||
try {
|
||||
const url: string | undefined = it?.url;
|
||||
@@ -26,6 +29,8 @@ export class OdAdapter implements ProviderAdapter<VideoItem> {
|
||||
}
|
||||
} catch {}
|
||||
return {
|
||||
// Phase 7.3 — provenance (instant de collecte + voie), commune aux 6.
|
||||
...mapProvenance(it),
|
||||
id: it.id,
|
||||
provider: 'odysee',
|
||||
title: it.title,
|
||||
@@ -37,7 +42,9 @@ export class OdAdapter implements ProviderAdapter<VideoItem> {
|
||||
slug,
|
||||
// Nom de chaîne LBRY (ex. @MaChaine) : le backend /api/channels/od
|
||||
// sait le résoudre avec ou sans préfixe @.
|
||||
channelExternalId: it.uploaderName || undefined,
|
||||
// Phase 6 : `channelRef` du serveur prime (claim canonique, un seul `@`).
|
||||
channelExternalId: serverRefValue(it?.channelRef, 'od')
|
||||
?? normalizeOdyseeClaim(it?.uploaderName || it?.channel || undefined),
|
||||
// Signaux de type pour le raffinement client des filtres.
|
||||
type: typeof it.type === 'string' ? it.type : undefined,
|
||||
isShort: it.isShort === true ? true : undefined,
|
||||
@@ -46,6 +53,6 @@ export class OdAdapter implements ProviderAdapter<VideoItem> {
|
||||
height: typeof it.height === 'number' ? it.height : undefined,
|
||||
} as VideoItem;
|
||||
});
|
||||
return { items, total: Array.isArray(res?.groups?.od) ? res.groups.od.length : 0, providerError };
|
||||
return { items, total: rawItems.length, providerError };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { mapProvenance, capturedAgoLabel, sourceLabel } from './provenance';
|
||||
|
||||
/**
|
||||
* Phase 7.3 — helpers de provenance côté client.
|
||||
*
|
||||
* Miroir de `server/tests/provenance.test.mjs` : ces helpers sont le dernier
|
||||
* filtre avant l'affichage de la fraîcheur. Leur métier n'est pas de « convertir
|
||||
* une date » mais de REFUSER d'inventer une information — un `capturedAt` absent
|
||||
* doit produire `null` (pas de badge), et une source non reconnue ne doit jamais
|
||||
* être maquillée en voie officielle.
|
||||
*
|
||||
* Run with: npm run test:provenance-front
|
||||
*/
|
||||
|
||||
function assertEqual(actual: unknown, expected: unknown, message: string): void {
|
||||
const a = JSON.stringify(actual);
|
||||
const b = JSON.stringify(expected);
|
||||
if (a !== b) {
|
||||
console.error(`FAIL: ${message}\n actual: ${a}\n expected: ${b}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(`ok: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const NOW = 1700000000000;
|
||||
|
||||
console.log('--- mapProvenance ---');
|
||||
assertEqual(mapProvenance({ capturedAt: NOW, source: 'innertube' }), { capturedAt: NOW, source: 'innertube' }, 'provenance valide propagée');
|
||||
assertEqual(mapProvenance({}), {}, 'rien n\'est inventé sur un objet vide');
|
||||
assertEqual(mapProvenance(null), {}, 'null toléré');
|
||||
assertEqual(mapProvenance('texte'), {}, 'non-objet toléré');
|
||||
// 0, négatif, timestamp EN SECONDES et chaîne : aucun n'est une epoch ms.
|
||||
assertEqual(mapProvenance({ capturedAt: 0 }).capturedAt, undefined, '0 rejeté (afficherait « il y a 0 s »)');
|
||||
assertEqual(mapProvenance({ capturedAt: -5 }).capturedAt, undefined, 'négatif rejeté');
|
||||
assertEqual(mapProvenance({ capturedAt: 1700000000 }).capturedAt, undefined, 'secondes rejetées (sinon « dans 55 ans »)');
|
||||
assertEqual(mapProvenance({ capturedAt: 'hier' }).capturedAt, undefined, 'chaîne non numérique rejetée');
|
||||
assertEqual(mapProvenance({ capturedAt: null }).capturedAt, undefined, 'null rejeté');
|
||||
assertEqual(mapProvenance({ capturedAt: NOW + 0.4 }).capturedAt, NOW, 'arrondi à l\'entier');
|
||||
assertEqual(mapProvenance({ source: ' InnerTube ' }).source, 'innertube', 'source normalisée (casse + espaces)');
|
||||
assertEqual(mapProvenance({ source: 'bidon' }).source, undefined, 'source inconnue NON validée (pas de badge mensonger)');
|
||||
|
||||
console.log('--- capturedAgoLabel ---');
|
||||
assertEqual(capturedAgoLabel(NOW - 3000, NOW), 'à l’instant', 'moins de 10 s');
|
||||
assertEqual(capturedAgoLabel(NOW - 240000, NOW), 'il y a 4 min', 'minutes');
|
||||
assertEqual(capturedAgoLabel(NOW - 3 * 3600000, NOW), 'il y a 3 h', 'heures');
|
||||
assertEqual(capturedAgoLabel(NOW - 2 * 86400000, NOW), 'il y a 2 j', 'jours');
|
||||
assertEqual(capturedAgoLabel(NOW - 60 * 86400000, NOW), 'il y a 2 mois', 'au-delà de 30 j => mois');
|
||||
assertEqual(capturedAgoLabel(undefined, NOW), null, 'pas de date => pas de badge');
|
||||
assertEqual(capturedAgoLabel(0, NOW), null, '0 => pas de badge');
|
||||
assertEqual(capturedAgoLabel(NaN, NOW), null, 'NaN => pas de badge');
|
||||
assertEqual(capturedAgoLabel(NOW + 60000, NOW), 'à l’instant', 'horloge en arrière bornée, jamais « dans -1 min »');
|
||||
|
||||
console.log('--- sourceLabel ---');
|
||||
assertEqual(sourceLabel('innertube'), 'API interne YouTube', 'voie YouTube nommée');
|
||||
assertEqual(sourceLabel('cache'), 'cache', 'cache nommé');
|
||||
assertEqual(sourceLabel('bidon'), 'source inconnue (bidon)', 'inconnue signalée, pas masquée');
|
||||
assertEqual(sourceLabel(undefined), null, 'pas de source => null');
|
||||
assertEqual(sourceLabel(' '), null, 'source blanche => null');
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Phase 7.3 — provenance d'une suggestion lue côté front.
|
||||
*
|
||||
* Petit module à part car les 6 adaptateurs font le même mapping : la
|
||||
* provenance est une propriété de la DONNÉE collectée, pas d'un provider. Le
|
||||
* dupliquer dans les 6 fichiers rouvrirait exactement la classe de bug que la
|
||||
* phase 0 a supprimé (une règle à six implémentations finit par diverger).
|
||||
*
|
||||
* Règle de lecture : un champ absent reste `undefined`. On n'invente ni
|
||||
* `capturedAt: Date.now()` (qui ferait passer une donnée vieille de 4 min pour
|
||||
* fraîche) ni une source par défaut affichable — un ancien serveur ou un cache
|
||||
* sans provenance doivent rester visibles comme tels.
|
||||
*/
|
||||
|
||||
export interface SuggestionProvenance {
|
||||
/** Epoch ms : instant réel de la collecte. `undefined` = inconnu. */
|
||||
capturedAt?: number;
|
||||
/** Voie de collecte : `innertube` / `api` / `scrape` / `cache`… */
|
||||
source?: string;
|
||||
}
|
||||
|
||||
const KNOWN_SOURCES = new Set(['innertube', 'api', 'scrape', 'rest', 'cache', 'resolve', 'graphql']);
|
||||
|
||||
/**
|
||||
* Extrait la provenance d'un item brut, en validant les types.
|
||||
* @param it item de `groups[provider]` (contrat Suggestion v2)
|
||||
*/
|
||||
export function mapProvenance(it: any): SuggestionProvenance {
|
||||
if (!it || typeof it !== 'object') return {};
|
||||
const out: SuggestionProvenance = {};
|
||||
|
||||
const at = Number(it.capturedAt);
|
||||
// Fenêtre epoch-ms plausible : 1e12 ms = 2001-09-09, 4.1e12 ms ≈ 2100.
|
||||
// Le seuil bas rejette aussi les timestamps EN SECONDES (~1.7e9) : un adaptateur
|
||||
// qui oublierait ×1000 afficherait « dans 55 ans ».
|
||||
if (Number.isFinite(at) && at > 1e12 && at < 4.1e12) out.capturedAt = Math.round(at);
|
||||
|
||||
const src = typeof it.source === 'string' ? it.source.trim().toLowerCase() : '';
|
||||
if (src && KNOWN_SOURCES.has(src)) out.source = src;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Source lisible en français pour l'infobulle (jamais de code brut affiché). */
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
innertube: 'API interne YouTube',
|
||||
api: 'API officielle',
|
||||
scrape: 'analyse de page',
|
||||
rest: 'API REST',
|
||||
cache: 'cache',
|
||||
resolve: 'resolve LBRY',
|
||||
graphql: 'API GraphQL',
|
||||
};
|
||||
|
||||
export function sourceLabel(source?: string | null): string | null {
|
||||
const src = String(source || '').trim().toLowerCase();
|
||||
if (!src) return null;
|
||||
return SOURCE_LABELS[src] || `source inconnue (${src})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* « capturé il y a 4 min » — la fraîcheur que l'utilisateur veut voir.
|
||||
* @param capturedAt epoch ms
|
||||
* @param now référence (injectable pour les tests)
|
||||
*/
|
||||
export function capturedAgoLabel(capturedAt?: number | null, now: number = Date.now()): string | null {
|
||||
const at = Number(capturedAt);
|
||||
if (!Number.isFinite(at) || at <= 0) return null;
|
||||
const seconds = Math.max(0, Math.round((now - at) / 1000));
|
||||
if (seconds < 10) return 'à l’instant';
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `il y a ${minutes} min`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) return `il y a ${hours} h`;
|
||||
const days = Math.round(hours / 24);
|
||||
return days <= 30 ? `il y a ${days} j` : `il y a ${Math.round(days / 30)} mois`;
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import { firstValueFrom } from 'rxjs';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
import { withFilterParams } from './filter-params';
|
||||
import { readSearchGroup } from '../search-contract';
|
||||
import { mapProvenance } from './provenance';
|
||||
import { buildPeerTubeComposite, serverRefValue } from '../../shared/providers/channel-ref';
|
||||
|
||||
export class PtAdapter implements ProviderAdapter<VideoItem> {
|
||||
key = 'pt' as const;
|
||||
@@ -16,24 +19,31 @@ export class PtAdapter implements ProviderAdapter<VideoItem> {
|
||||
// le sait, affinés en post-traitement sinon.
|
||||
httpParams = withFilterParams(httpParams, params.filters);
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||
const providerError = typeof res?.errors?.pt?.message === 'string' ? res.errors.pt.message : undefined;
|
||||
const items: VideoItem[] = (res?.groups?.pt || []).map((it: any) => {
|
||||
const { items: rawItems, providerError } = readSearchGroup(res, 'pt');
|
||||
const items: VideoItem[] = rawItems.map((it: any) => {
|
||||
// PeerTube identifie une chaîne par `instance|channel` : l'instance est
|
||||
// dérivée de l'URL vidéo, le nom de chaîne de `channelId`.
|
||||
let channelExternalId: string | undefined;
|
||||
try {
|
||||
const host = it?.url ? new URL(String(it.url)).hostname : '';
|
||||
if (host && it?.channelId) channelExternalId = `${host}|${it.channelId}`;
|
||||
} catch {}
|
||||
// Phase 6 : si le serveur a envoyé `channelRef`, sa valeur canonique
|
||||
// prime — sinon on reconstruit avec la règle historique.
|
||||
let channelExternalId: string | undefined =
|
||||
serverRefValue(it?.channelRef, 'pt') ?? buildPeerTubeComposite(it?.url, it?.channelId);
|
||||
return {
|
||||
// Phase 7.3 — provenance (instant de collecte + voie), commune aux 6.
|
||||
...mapProvenance(it),
|
||||
id: it.id,
|
||||
provider: 'peertube',
|
||||
title: it.title,
|
||||
channelName: it.uploaderName,
|
||||
durationSec: it.duration,
|
||||
thumbnailUrl: it.thumbnail,
|
||||
viewCount: undefined,
|
||||
publishedAt: undefined,
|
||||
// `viewCount: undefined` en dur alors que le serveur envoie déjà le
|
||||
// nombre de vues : les compteurs PeerTube n'arrivaient jamais dans l'UI
|
||||
// (trouvé en gelant la sortie réelle, phase 8.4). On lit le champ
|
||||
// canonique `views` et on garde `viewCount` en repli.
|
||||
viewCount: typeof it.views === 'number' ? it.views
|
||||
: (typeof it.viewCount === 'number' ? it.viewCount : undefined),
|
||||
// Le serveur envoie `publishedAt` (ISO) quand l'instance l'expose.
|
||||
publishedAt: typeof it.publishedAt === 'string' ? it.publishedAt : undefined,
|
||||
channelExternalId,
|
||||
// Signaux de type pour le raffinement client des filtres.
|
||||
type: typeof it.type === 'string' ? it.type : undefined,
|
||||
@@ -43,6 +53,6 @@ export class PtAdapter implements ProviderAdapter<VideoItem> {
|
||||
height: typeof it.height === 'number' ? it.height : undefined,
|
||||
} as VideoItem;
|
||||
});
|
||||
return { items, total: Array.isArray(res?.groups?.pt) ? res.groups.pt.length : 0, providerError };
|
||||
return { items, total: rawItems.length, providerError };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ import { firstValueFrom } from 'rxjs';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
import { withFilterParams } from './filter-params';
|
||||
import { readSearchGroup } from '../search-contract';
|
||||
import { mapProvenance } from './provenance';
|
||||
import { serverRefValue } from '../../shared/providers/channel-ref';
|
||||
|
||||
export class RuAdapter implements ProviderAdapter<VideoItem> {
|
||||
key = 'ru' as const;
|
||||
@@ -16,16 +19,24 @@ export class RuAdapter implements ProviderAdapter<VideoItem> {
|
||||
// le sait, affinés en post-traitement sinon.
|
||||
httpParams = withFilterParams(httpParams, params.filters);
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||
const providerError = typeof res?.errors?.ru?.message === 'string' ? res.errors.ru.message : undefined;
|
||||
const items: VideoItem[] = (res?.groups?.ru || []).map((it: any) => ({
|
||||
const { items: rawItems, providerError } = readSearchGroup(res, 'ru');
|
||||
const items: VideoItem[] = rawItems.map((it: any) => ({
|
||||
// Phase 7.3 — provenance (instant de collecte + voie) : propriété de la
|
||||
// donnée, pas d'un provider — mapping commun aux 6 adaptateurs.
|
||||
...mapProvenance(it),
|
||||
id: it.id,
|
||||
provider: 'rumble',
|
||||
title: it.title,
|
||||
channelName: it.uploaderName,
|
||||
durationSec: it.duration,
|
||||
thumbnailUrl: it.thumbnail,
|
||||
viewCount: typeof it.views === 'number' ? it.views : undefined,
|
||||
publishedAt: it.publishedAt,
|
||||
viewCount: typeof it.views === 'number' ? it.views : undefined,
|
||||
viewers: typeof it.viewers === 'number' ? it.viewers : undefined,
|
||||
publishedAt: it.publishedAt,
|
||||
// Phase 3.5 a extrait le slug `/c/<slug>` côté serveur, phase 6 le
|
||||
// normalise en `channelRef` — mais l'adaptateur le jetait : le nom de la
|
||||
// chaîne n'était donc pas cliquable vers la page chaîne.
|
||||
channelExternalId: serverRefValue(it.channelRef, 'ru') || it.channelExternalId || undefined,
|
||||
// Signaux de type (Shorts / direct) pour le raffinement client.
|
||||
type: typeof it.type === 'string' ? it.type : undefined,
|
||||
isShort: it.isShort === true ? true : undefined,
|
||||
@@ -33,6 +44,6 @@ export class RuAdapter implements ProviderAdapter<VideoItem> {
|
||||
width: typeof it.width === 'number' ? it.width : undefined,
|
||||
height: typeof it.height === 'number' ? it.height : undefined,
|
||||
} as VideoItem));
|
||||
return { items, total: Array.isArray(res?.groups?.ru) ? res.groups.ru.length : 0, providerError };
|
||||
return { items, total: rawItems.length, providerError };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ import { firstValueFrom } from 'rxjs';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
import { withFilterParams } from './filter-params';
|
||||
import { readSearchGroup } from '../search-contract';
|
||||
import { mapProvenance } from './provenance';
|
||||
import { serverRefValue } from '../../shared/providers/channel-ref';
|
||||
|
||||
export class TwAdapter implements ProviderAdapter<VideoItem> {
|
||||
key = 'tw' as const;
|
||||
@@ -16,18 +19,20 @@ export class TwAdapter implements ProviderAdapter<VideoItem> {
|
||||
// le sait, affinés en post-traitement sinon.
|
||||
httpParams = withFilterParams(httpParams, params.filters);
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||
const providerError = typeof res?.errors?.tw?.message === 'string' ? res.errors.tw.message : undefined;
|
||||
const items: VideoItem[] = (res?.groups?.tw || []).map((it: any) => {
|
||||
const { items: rawItems, providerError } = readSearchGroup(res, 'tw');
|
||||
const items: VideoItem[] = rawItems.map((it: any) => {
|
||||
// /api/search (tw) renvoie un mixte façon directory : lives, VODs, clips, chaînes.
|
||||
// Seuls les items 'live' / 'channel' utilisent l'embed ?channel=<login> ;
|
||||
// VODs (id numérique) et clips (slug) ont leur propre lecture.
|
||||
const login: string | undefined =
|
||||
it.channelExternalId || it.channelHandle || undefined;
|
||||
serverRefValue(it.channelRef, 'tw') || it.channelExternalId || it.channelHandle || undefined;
|
||||
const kind = String(it?.type || '').toLowerCase();
|
||||
const clipKind = String(it?.kind || '').toLowerCase();
|
||||
const isClip = clipKind === 'clip';
|
||||
const isChannelLike = kind === 'channel' || kind === 'live';
|
||||
return {
|
||||
// Phase 7.3 — provenance (instant de collecte + voie), commune aux 6.
|
||||
...mapProvenance(it),
|
||||
id: it.id,
|
||||
provider: 'twitch',
|
||||
title: it.title,
|
||||
@@ -35,6 +40,8 @@ export class TwAdapter implements ProviderAdapter<VideoItem> {
|
||||
durationSec: typeof it.duration === 'number' ? it.duration : undefined,
|
||||
thumbnailUrl: it.thumbnail,
|
||||
viewCount: typeof it.views === 'number' ? it.views : undefined,
|
||||
// Phase 3.8 — `viewer_count` remonte dans `viewers`, pas dans `views`.
|
||||
viewers: typeof it.viewers === 'number' ? it.viewers : undefined,
|
||||
publishedAt: it.publishedAt,
|
||||
channelExternalId: login,
|
||||
channelAvatarUrl: it.uploaderAvatar || undefined,
|
||||
|
||||
@@ -3,6 +3,9 @@ import { firstValueFrom } from 'rxjs';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
import { withFilterParams } from './filter-params';
|
||||
import { readSearchGroup } from '../search-contract';
|
||||
import { mapProvenance } from './provenance';
|
||||
import { serverRefValue } from '../../shared/providers/channel-ref';
|
||||
|
||||
export class YtAdapter implements ProviderAdapter<VideoItem> {
|
||||
key = 'yt' as const;
|
||||
@@ -16,8 +19,11 @@ export class YtAdapter implements ProviderAdapter<VideoItem> {
|
||||
// le sait, affinés en post-traitement sinon.
|
||||
httpParams = withFilterParams(httpParams, params.filters);
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||
const providerError = typeof res?.errors?.yt?.message === 'string' ? res.errors.yt.message : undefined;
|
||||
const items: VideoItem[] = (res?.groups?.yt || []).map((it: any) => ({
|
||||
const { items: rawItems, providerError } = readSearchGroup(res, 'yt');
|
||||
const items: VideoItem[] = rawItems.map((it: any) => ({
|
||||
// Phase 7.3 — provenance (instant de collecte + voie) : propriété de la
|
||||
// donnée, pas d'un provider — mapping commun aux 6 adaptateurs.
|
||||
...mapProvenance(it),
|
||||
id: it.id,
|
||||
provider: 'youtube',
|
||||
title: it.title,
|
||||
@@ -25,10 +31,12 @@ export class YtAdapter implements ProviderAdapter<VideoItem> {
|
||||
durationSec: it.duration,
|
||||
thumbnailUrl: it.thumbnail,
|
||||
viewCount: typeof it.views === 'number' ? it.views : (typeof it.viewCount === 'number' ? it.viewCount : undefined),
|
||||
viewers: typeof it.viewers === 'number' ? it.viewers : undefined,
|
||||
publishedAt: it.publishedAt,
|
||||
// ID chaîne conservé : rend le logo/nom cliquables vers /channel
|
||||
// (l'avatar est résolu via /api/channels, l'API search n'en fournit pas).
|
||||
channelExternalId: it.channelExternalId || it.channelId || undefined,
|
||||
// Phase 6 : `channelRef` du serveur prime sur la chaîne de repli.
|
||||
channelExternalId: serverRefValue(it.channelRef, 'yt') || it.channelExternalId || it.channelId || undefined,
|
||||
// Signaux de type : indispensables au raffinement client des filtres
|
||||
// (Shorts / direct / chaîne) quand le serveur les a déjà appliqués.
|
||||
type: typeof it.type === 'string' ? it.type : undefined,
|
||||
@@ -38,6 +46,6 @@ export class YtAdapter implements ProviderAdapter<VideoItem> {
|
||||
width: typeof it.width === 'number' ? it.width : undefined,
|
||||
height: typeof it.height === 'number' ? it.height : undefined,
|
||||
} as VideoItem));
|
||||
return { items, total: Array.isArray(res?.groups?.yt) ? res.groups.yt.length : 0, providerError };
|
||||
return { items, total: rawItems.length, providerError };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,74 @@
|
||||
export type ProviderId = 'yt' | 'dm' | 'tw' | 'pt' | 'od' | 'ru';
|
||||
import type { ProviderId } from '../shared/providers/provider-ids';
|
||||
import type { ChannelRef } from '../shared/providers/channel-ref';
|
||||
|
||||
export type { ProviderId, ChannelRef };
|
||||
|
||||
/**
|
||||
* Contrat `Suggestion` v2 — doit rester aligné avec le JSDoc de
|
||||
* `server/providers/registry.mjs` (vérifié par `npm run test:contract`).
|
||||
*
|
||||
* v1 (inchangé, rétrocompatible) : title, id, duration, isShort, thumbnail,
|
||||
* uploaderName, url, type.
|
||||
* v2 (additif) : compteurs, dates ISO, identité de chaîne, avatars, et flags.
|
||||
*
|
||||
* Règle : un champ absent est `undefined`, jamais `0` ni `''`. Une durée nulle
|
||||
* n'existe pas — elle casse les règles de classification (voir §2.2 du plan).
|
||||
*/
|
||||
export interface SuggestionItemV1 {
|
||||
title: string;
|
||||
id: string;
|
||||
/** Secondes, strictement > 0. `undefined` = durée inconnue. */
|
||||
duration?: number;
|
||||
isShort?: boolean;
|
||||
thumbnail?: string;
|
||||
uploaderName?: string;
|
||||
url?: string;
|
||||
type?: string;
|
||||
|
||||
// --- v2 ---
|
||||
/** Vues (> 0). */
|
||||
views?: number;
|
||||
/**
|
||||
* Phase 3.8 / 7.3 — spectateurs EN DIRECT (Twitch `viewer_count`).
|
||||
* Distinct de `views` : ne jamais afficher comme un compteur de vues.
|
||||
*/
|
||||
viewers?: number;
|
||||
likes?: number;
|
||||
dislikes?: number;
|
||||
/** ISO 8601. Jamais « il y a 2 mois ». */
|
||||
publishedAt?: string;
|
||||
channelId?: string;
|
||||
channelExternalId?: string;
|
||||
channelHandle?: string;
|
||||
channelUrl?: string;
|
||||
uploaderAvatar?: string;
|
||||
channelAvatarUrl?: string;
|
||||
slug?: string;
|
||||
channel?: string;
|
||||
/** 'vod' | 'live' | 'clip' (Twitch). */
|
||||
kind?: string;
|
||||
game?: string;
|
||||
/** BCP-47. */
|
||||
language?: string;
|
||||
hasSubtitles?: boolean;
|
||||
isLive?: boolean;
|
||||
embeddable?: boolean;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
/** Libellés bruts, pour afficher « valeur approchée » plutôt que « — ». */
|
||||
durationRaw?: string;
|
||||
viewCountRaw?: string;
|
||||
/**
|
||||
* Phase 6 — identité de chaîne normalisée. `channelExternalId` reste lu en
|
||||
* repli (transition non cassante) mais `channelRef` est la forme canonique :
|
||||
* `scheme` dit comment lire `value`.
|
||||
*/
|
||||
channelRef?: ChannelRef;
|
||||
}
|
||||
|
||||
export interface SearchResponseV1 {
|
||||
/** Version du contrat `Suggestion` (2 depuis la phase 0). */
|
||||
v?: number;
|
||||
q: string;
|
||||
providers: ProviderId[];
|
||||
groups: Record<ProviderId, SuggestionItemV1[]>;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export type ProviderId = 'yt'|'dm'|'tw'|'pt'|'od'|'ru';
|
||||
import type { ProviderId } from '../shared/providers/provider-ids';
|
||||
import type { SearchFilters } from './filters';
|
||||
|
||||
export type { ProviderId };
|
||||
|
||||
export interface SearchItem {
|
||||
id: string;
|
||||
provider: ProviderId;
|
||||
@@ -8,6 +10,8 @@ export interface SearchItem {
|
||||
channel?: string;
|
||||
durationSec?: number;
|
||||
views?: number;
|
||||
/** Phase 3.8 — spectateurs EN DIRECT (Twitch). Pas un compteur de vues. */
|
||||
viewers?: number;
|
||||
publishedAt?: string; // ISO
|
||||
isLive?: boolean;
|
||||
isShort?: boolean;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Phase 7.4 — qualification de l'état de santé des sources.
|
||||
*
|
||||
* Volontairement dans un module SANS import Angular : c'est de la logique pure,
|
||||
* testable avec le runner `ts-node` du repo (un fichier qui importe `HttpClient`
|
||||
* exige le compilateur JIT, absent en test node). Le service ne fait que
|
||||
* l'appel HTTP.
|
||||
*/
|
||||
|
||||
export type ProviderHealthState = 'ok' | 'degraded' | 'disabled';
|
||||
|
||||
export interface ProviderHealth {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
/**
|
||||
* `disabled` = provider volontairement éteint par `FF_<PROVIDER>`.
|
||||
* Ce n'est PAS une panne : l'afficher comme « source dégradée » ferait
|
||||
* accuser l'infrastructure alors que l'administrateur l'a coupé exprès, et
|
||||
* l'utilisateur verrait un avertissement permanent sur une recherche saine.
|
||||
*/
|
||||
disabled: boolean;
|
||||
latencyMs: number;
|
||||
lastError: string | null;
|
||||
consecutiveFailures: number;
|
||||
errorRate: number;
|
||||
state: ProviderHealthState;
|
||||
}
|
||||
|
||||
/** Toute réponse absente ou illisible vaut « dégradé », jamais « sain ». */
|
||||
function stateOf(ok: boolean, disabled: boolean): ProviderHealthState {
|
||||
if (disabled) return 'disabled';
|
||||
return ok ? 'ok' : 'degraded';
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforme la réponse de `/api/providers/health` en liste qualifiée.
|
||||
*
|
||||
* Volontairement défensive : le backend peut évoluer, et une entrée inconnue
|
||||
* doit dégrader l'affichage (badge ambre) plutôt que faire disparaître le
|
||||
* signal — l'erreur de communication est plus grave qu'un provider muet.
|
||||
*/
|
||||
export function normalizeHealth(providers: Record<string, any> | null | undefined): ProviderHealth[] {
|
||||
if (!providers || typeof providers !== 'object') return [];
|
||||
return Object.entries(providers).map(([id, raw]) => {
|
||||
const p = raw as Partial<{
|
||||
ok: boolean; disabled: boolean; latencyMs: number; lastError: string | null;
|
||||
consecutiveFailures: number; errorRate: number;
|
||||
}>;
|
||||
const disabled = p?.disabled === true;
|
||||
const ok = p?.ok === true;
|
||||
return {
|
||||
id,
|
||||
ok,
|
||||
disabled,
|
||||
latencyMs: Number(p?.latencyMs) || 0,
|
||||
lastError: p?.lastError == null ? null : String(p.lastError),
|
||||
consecutiveFailures: Number(p?.consecutiveFailures) || 0,
|
||||
errorRate: Number(p?.errorRate) || 0,
|
||||
state: stateOf(ok, disabled),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { normalizeHealth } from './provider-health.model';
|
||||
|
||||
/**
|
||||
* Phase 7.4 — qualification de l'état de santé des sources.
|
||||
*
|
||||
* Le point non négociable de ce fichier : un provider ÉTEINT par feature flag
|
||||
* n'est pas une source dégradée. Si on le compterait, l'app afficherait un
|
||||
* bandeau d'incident alors que l'administrateur a coupé la source exprès — et
|
||||
* l'utilisateur en débranche lui-même verrait un avertissement permanent sur
|
||||
* une recherche parfaitement saine.
|
||||
*
|
||||
* Run with: npm run test:provider-health
|
||||
*/
|
||||
|
||||
function assertEqual(actual: unknown, expected: unknown, message: string): void {
|
||||
const a = JSON.stringify(actual);
|
||||
const b = JSON.stringify(expected);
|
||||
if (a !== b) {
|
||||
console.error(`FAIL: ${message}\n actual: ${a}\n expected: ${b}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(`ok: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const base = { ok: true, disabled: false, latencyMs: 120, lastError: null, consecutiveFailures: 0, errorRate: 0 };
|
||||
|
||||
console.log('--- état de santé ---');
|
||||
assertEqual(
|
||||
normalizeHealth({ yt: base })[0].state,
|
||||
'ok',
|
||||
'provider joignable => ok',
|
||||
);
|
||||
assertEqual(
|
||||
normalizeHealth({ ru: { ...base, ok: false, lastError: 'HTTP 503' } })[0].state,
|
||||
'degraded',
|
||||
'échec amont => dégradé',
|
||||
);
|
||||
assertEqual(
|
||||
normalizeHealth({ ru: { ...base, ok: false, disabled: true, lastError: 'disabled_by_ff' } })[0].state,
|
||||
'disabled',
|
||||
'provider éteint par FF => désactivé, PAS dégradé',
|
||||
);
|
||||
assertEqual(
|
||||
normalizeHealth({ ru: { ...base, ok: false, disabled: true } })[0].state,
|
||||
'disabled',
|
||||
'un provider éteint reste « désactivé » même si une sonde l\'a vu échouer',
|
||||
);
|
||||
|
||||
console.log('--- données exploitables ---');
|
||||
const h = normalizeHealth({ ru: { ...base, ok: false, latencyMs: '900', consecutiveFailures: '3', errorRate: 0.25 } })[0];
|
||||
assertEqual(h.id, 'ru', 'identifiant conservé');
|
||||
assertEqual(h.latencyMs, 900, 'latence stringée => nombre');
|
||||
assertEqual(h.consecutiveFailures, 3, 'échecs consécutifs stringés => nombre');
|
||||
assertEqual(h.errorRate, 0.25, 'taux d\'erreur conservé');
|
||||
assertEqual(normalizeHealth({ ru: { ...base, lastError: null } })[0].lastError, null, 'absence d\'erreur => null, pas "undefined"');
|
||||
assertEqual(normalizeHealth({ ru: { ...base, lastError: 'boom' } })[0].lastError, 'boom', 'message d\'erreur conservé');
|
||||
assertEqual(normalizeHealth({ ru: {} })[0].state, 'degraded', 'réponse vide => dégradé (pas un faux « tout va bien »)');
|
||||
assertEqual(normalizeHealth({ ru: null })[0].state, 'degraded', 'null => dégradé');
|
||||
assertEqual(normalizeHealth(null), [], 'payload absent => aucun badge');
|
||||
assertEqual(normalizeHealth(undefined), [], 'undefined => aucun badge');
|
||||
assertEqual(normalizeHealth({}), [], 'objet vide => aucun badge');
|
||||
|
||||
console.log('--- volumétrie ---');
|
||||
const many = normalizeHealth(Object.fromEntries(
|
||||
['yt', 'dm', 'tw', 'pt', 'od', 'ru'].map((id, i) => [id, i % 2 ? { ...base, ok: false } : base]),
|
||||
));
|
||||
assertEqual(many.length, 6, 'les 6 sources sont qualifiées');
|
||||
assertEqual(many.filter((x) => x.state === 'degraded').map((x) => x.id), ['dm', 'pt', 'ru'], 'sources dégradées identifiées');
|
||||
assertEqual(many.filter((x) => x.state === 'ok').map((x) => x.id), ['yt', 'tw', 'od'], 'sources saines identifiées');
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { catchError, map, shareReplay } from 'rxjs/operators';
|
||||
import { normalizeHealth, type ProviderHealth } from './provider-health.model';
|
||||
|
||||
export type { ProviderHealth, ProviderHealthState } from './provider-health.model';
|
||||
|
||||
/**
|
||||
* Phase 7.4 — santé des sources, pour le badge discret « source dégradée ».
|
||||
*
|
||||
* Ce que `/api/search` ne sait PAS dire : une source peut être morte alors que
|
||||
* la requête courante a « réussi » (résultats servis depuis le cache, ou
|
||||
* provider non sélectionné). Le bandeau d'erreur de la grille ne couvre que les
|
||||
* échecs de la requête affichée ; ce service couvre l'état amont réel.
|
||||
*
|
||||
* Deux garde-fous, tous deux appris à l'usage :
|
||||
* - TTL client de 60 s, aligné sur le cache serveur (`PROVIDER_HEALTH_TTL_MS`) :
|
||||
* cet endpoint sonde RÉELLEMENT les upstreams, l'appeler à chaque rendu (ou
|
||||
* à chaque frappe) transformerait un panneau d'information en martèlement ;
|
||||
* - `shareReplay(1)` : un abonnement tardif reçoit la valeur déjà récupérée au
|
||||
* lieu de repartir en réseau.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProviderHealthService {
|
||||
private http = inject(HttpClient);
|
||||
private readonly ttlMs = 60_000;
|
||||
private cached: { at: number; value: Observable<ProviderHealth[]> } | null = null;
|
||||
|
||||
/** État courant, avec déduplication temporelle. */
|
||||
health(): Observable<ProviderHealth[]> {
|
||||
const now = Date.now();
|
||||
if (this.cached && now - this.cached.at < this.ttlMs) return this.cached.value;
|
||||
|
||||
const request = this.http.get<{ providers: Record<string, any> }>('/api/providers/health').pipe(
|
||||
map((res) => normalizeHealth(res?.providers)),
|
||||
// Un endpoint de santé qui tombe ne doit pas casser la recherche : on
|
||||
// renvoie une liste vide, ce qui fait disparaître le badge au lieu
|
||||
// d'afficher un « tout va bien » qui serait faux.
|
||||
catchError(() => of([] as ProviderHealth[])),
|
||||
shareReplay(1),
|
||||
);
|
||||
this.cached = { at: now, value: request };
|
||||
return request;
|
||||
}
|
||||
|
||||
/** Force la relecture au prochain appel (bouton « réessayer »). */
|
||||
invalidate(): void {
|
||||
this.cached = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Phase 8.2 — lecture stricte du contrat `Suggestion` v2 côté front.
|
||||
*
|
||||
* Le serveur Stampille sa réponse avec `v: SUGGESTION_CONTRACT_VERSION`. Un
|
||||
* front qui lit `groups` sans vérifier `v` ne peut pas distinguer :
|
||||
* - un serveur ancien (contrat v1 : pas de `views`, pas de `publishedAt`) ;
|
||||
* - un serveur buggé ou un proxy qui renvoie autre chose.
|
||||
* Dans les deux cas les champs attendues sont `undefined` et l'UI affiche des
|
||||
* zéros ou des « — » **sans explication** — exactement la classe de bug que la
|
||||
* phase 0 a introduite pour la traquer.
|
||||
*
|
||||
* Comportement retenu : on ne casse pas l'utilisateur qui tourne un front
|
||||
* plus récent qu'un serveur (déploiement partiel, cache de service worker). Le
|
||||
* chemin v1 reste, mais il est **averti une seule fois par process** — un
|
||||
* avertissement par recherche noierait la console et serait ignoré.
|
||||
*/
|
||||
|
||||
import type { ProviderId } from '../shared/providers/provider-ids';
|
||||
import type { SearchResponseV1, SuggestionItemV1 } from './api.v1';
|
||||
|
||||
/** Miroir de `SUGGESTION_CONTRACT_VERSION` (`server/providers/registry.mjs`). */
|
||||
export const EXPECTED_CONTRACT_VERSION = 2;
|
||||
|
||||
/** Une seule ligne par process, sinon chaque frappe au clavier en produit une. */
|
||||
const warned = new Set<string>();
|
||||
let contractWarning: string | null = null;
|
||||
|
||||
export interface ReadGroupResult {
|
||||
items: any[];
|
||||
/** Raison d'échec déjà remontée par le serveur pour CE provider. */
|
||||
providerError?: string;
|
||||
/** Renseigné si la réponse n'est pas en v2 (chemin de compatibilité). */
|
||||
contractVersion: number | null;
|
||||
/** `true` si le serveur a répondu en v1 (ou sans version). */
|
||||
legacyContract: boolean;
|
||||
}
|
||||
|
||||
function warnOnce(key: string, message: string): void {
|
||||
if (warned.has(key)) return;
|
||||
warned.add(key);
|
||||
console.warn(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le groupe d'un provider et applique le contrôle de version.
|
||||
*
|
||||
* @param res réponse de `/api/search` (ou `undefined` si le transport a échoué)
|
||||
* @param providerId id court
|
||||
*/
|
||||
export function readSearchGroup(res: any, providerId: ProviderId): ReadGroupResult {
|
||||
const version = typeof res?.v === 'number' ? (res.v as number) : null;
|
||||
const legacyContract = version !== EXPECTED_CONTRACT_VERSION;
|
||||
|
||||
if (legacyContract) {
|
||||
warnOnce(
|
||||
`contract:${version}`,
|
||||
`[search] Réponse /api/search en contrat v${version ?? '?'} alors que `
|
||||
+ `l'application attend v${EXPECTED_CONTRACT_VERSION} : les champs optionnels `
|
||||
+ '(views, publishedAt, channelRef…) seront absents. Vérifiez que le serveur '
|
||||
+ 'a bien été redéployé.',
|
||||
);
|
||||
contractWarning = version === null ? 'missing_version' : `v${version}`;
|
||||
} else {
|
||||
contractWarning = null;
|
||||
}
|
||||
|
||||
const group = res?.groups?.[providerId];
|
||||
const items = Array.isArray(group) ? group : [];
|
||||
const rawError = res?.errors?.[providerId];
|
||||
const providerError = typeof rawError?.message === 'string'
|
||||
? rawError.message
|
||||
: (typeof rawError === 'string' && rawError ? rawError : undefined);
|
||||
|
||||
// Un provider désactivé par feature flag (phase 8.3) n'est pas une panne :
|
||||
// `disabled_by_ff` est traité comme un état, pas comme une erreur technique.
|
||||
const isDisabled = rawError?.code === 'disabled_by_ff';
|
||||
return {
|
||||
items,
|
||||
providerError: isDisabled ? undefined : providerError,
|
||||
contractVersion: version,
|
||||
legacyContract,
|
||||
};
|
||||
}
|
||||
|
||||
/** Dernier avertissement de contrat émis (pour l'écran de debug). */
|
||||
export function getContractWarning(): string | null {
|
||||
return contractWarning;
|
||||
}
|
||||
|
||||
/** Réinitialise l'état « déjà averti » — réservé aux tests. */
|
||||
export function resetContractWarningsForTests(): void {
|
||||
warned.clear();
|
||||
contractWarning = null;
|
||||
}
|
||||
|
||||
export type { SearchResponseV1, SuggestionItemV1 };
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { BehaviorSubject, combineLatest, distinctUntilChanged, debounceTime, map, switchMap, filter, shareReplay, from, of } from 'rxjs';
|
||||
import type { ProviderId } from '../core/providers/provider-registry';
|
||||
import { mergeMap, scan, take } from 'rxjs/operators';
|
||||
import { ALL_PROVIDER_IDS, type ProviderId } from '../shared/providers/provider-ids';
|
||||
import type { SuggestionItemV1, SearchResponseV1 } from './api.v1';
|
||||
import { YtAdapter } from './adapters/yt';
|
||||
import { DmAdapter } from './adapters/dm';
|
||||
@@ -14,7 +15,7 @@ import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
import { DEFAULT_FILTERS, activeFilters, normalizeFilters, type SearchFilters } from './filters';
|
||||
|
||||
export type SuggestionItem = SuggestionItemV1;
|
||||
export type SearchResponse = SearchResponseV1;
|
||||
export type SearchResponse = SearchResponseV1 & { partial?: boolean };
|
||||
|
||||
type CacheKey = string;
|
||||
interface CacheEntry<T> { t: number; data: SearchResult<T>; }
|
||||
@@ -54,75 +55,95 @@ export class SearchService {
|
||||
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b))
|
||||
);
|
||||
|
||||
// Public request stream: aggregates groups by provider, runs adapters in parallel with timeout/abort/retry
|
||||
// Public request stream: emits each provider's group AS SOON AS it resolves,
|
||||
// then a final `partial: false` snapshot. Le blast d'auparavant (Promise.all
|
||||
// des 6 providers, un seul snapshot) est exactement le « flash blanc » des
|
||||
// sections 7.6 : tant que la source la plus lente répondait, rien du tout.
|
||||
readonly request$ = this.params$.pipe(
|
||||
filter(([q]) => typeof q === 'string' && q.trim().length >= 2),
|
||||
switchMap(([q, prov, page, pageSize, sort, filters]) => from(this.runAdapters({
|
||||
q: String(q),
|
||||
pageToken: String(page || 1),
|
||||
sort: (sort as any) || 'relevance',
|
||||
// Filtres globaux (type / durée / période) : envoyés à chaque adapter,
|
||||
// appliqués nativement par le provider quand c'est possible.
|
||||
filters: normalizeFilters(filters),
|
||||
}, prov)))
|
||||
,
|
||||
// Map provider results to the format expected by the UI
|
||||
map((byProvider) => {
|
||||
const groups = Object.entries(byProvider).reduce((acc, [pid, result]) => {
|
||||
acc[pid as ProviderId] = result.items;
|
||||
return acc;
|
||||
}, {} as Record<ProviderId, VideoItem[]>);
|
||||
// Surface per-provider backend failures (e.g. YouTube quota) for UI notice.
|
||||
const errors: Partial<Record<ProviderId, string>> = {};
|
||||
for (const [pid, result] of Object.entries(byProvider)) {
|
||||
if (result?.providerError) errors[pid as ProviderId] = result.providerError;
|
||||
}
|
||||
|
||||
const providers = Object.keys(byProvider) as ProviderId[];
|
||||
const resp: SearchResponse = {
|
||||
q: this.q$.value, providers, groups,
|
||||
...(Object.keys(errors).length > 0 ? { errors } : {}),
|
||||
switchMap(([q, prov, page, pageSize, sort, filters]) => {
|
||||
const active: ProviderId[] = prov === 'all' ? [...ALL_PROVIDER_IDS] : (Array.isArray(prov) ? prov : []);
|
||||
const params: ProviderSearchParams = {
|
||||
q: String(q),
|
||||
pageToken: String(page || 1),
|
||||
sort: (sort as any) || 'relevance',
|
||||
filters: normalizeFilters(filters),
|
||||
};
|
||||
return resp;
|
||||
if (active.length === 0) {
|
||||
// Aucune source : une seule émission vide et terminale, comme avant.
|
||||
return of(this.toSearchResponse({} as Record<ProviderId, SearchResult<VideoItem>>, false));
|
||||
}
|
||||
let received = 0;
|
||||
const total = active.length;
|
||||
return from(active).pipe(
|
||||
// Chaque provider rend son groupe dès qu'il est prêt, dans l'ordre où
|
||||
// il répond (pas l'ordre de la liste). Un repli ne bloque donc plus la
|
||||
// mise à jour des autres.
|
||||
mergeMap((pid) => from(this.searchAdapter(pid, params).then((result) => [pid, result] as const))),
|
||||
scan((acc, [pid, result]) => {
|
||||
acc[pid] = result;
|
||||
return acc;
|
||||
}, {} as Record<ProviderId, SearchResult<VideoItem>>),
|
||||
take(total),
|
||||
map((byProvider) => {
|
||||
received += 1;
|
||||
// `partial: true` = certains providers répondent encore ; le front
|
||||
// affiche le squelette de CELUI-là (sections 7.6) au lieu d'un vide.
|
||||
return this.toSearchResponse(byProvider, received < total);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
// Orchestrate all active providers in parallel with timeout(8s), retry(1) and abort on param change.
|
||||
private async runAdapters(params: ProviderSearchParams, prov: ProviderId[] | 'all'): Promise<Record<ProviderId, SearchResult<VideoItem>>> {
|
||||
const active: ProviderId[] = prov === 'all' ? ['yt','dm','tw','pt','od','ru'] : (Array.isArray(prov) ? prov : []);
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
const tasks = active.map(async (pid) => {
|
||||
const adapter = this.adapters[pid];
|
||||
if (!adapter) return [pid, { items: [] } as SearchResult<VideoItem>] as const;
|
||||
const key: CacheKey = `${pid}|${params.q}|${params.pageToken}|${params.sort}|${this.filterKey(params.filters)}`;
|
||||
const now = Date.now();
|
||||
const cached = this.cache.get(key);
|
||||
if (cached && (now - cached.t) < this.cacheTtlMs) {
|
||||
return [pid, cached.data] as const;
|
||||
}
|
||||
const perProviderTimeout = 8000;
|
||||
const withTimeout = <T>(p: Promise<T>): Promise<T> => new Promise((resolve, reject) => {
|
||||
const tid = setTimeout(() => reject(new Error('timeout')), perProviderTimeout);
|
||||
p.then(v => { clearTimeout(tid); resolve(v); }).catch(e => { clearTimeout(tid); reject(e); });
|
||||
});
|
||||
const attempt = async (): Promise<SearchResult<VideoItem>> => withTimeout(adapter.search(params, signal));
|
||||
try {
|
||||
const res: SearchResult<VideoItem> = await attempt().catch(async (e) => {
|
||||
// retry once on network-like errors
|
||||
if (e && (e.name === 'AbortError' || String(e.message || '').includes('abort'))) throw e;
|
||||
try { return await attempt(); } catch (err) { throw err; }
|
||||
});
|
||||
this.cache.set(key, { t: Date.now(), data: res });
|
||||
return [pid, res] as const;
|
||||
} catch {
|
||||
// Swallow errors per provider, return empty
|
||||
return [pid, { items: [] } as SearchResult<VideoItem>] as const;
|
||||
}
|
||||
/** Même agrégation que le mapping historique (`groups` + `errors`). */
|
||||
private toSearchResponse(byProvider: Record<ProviderId, SearchResult<VideoItem>>, partial: boolean): SearchResponse {
|
||||
const groups = Object.entries(byProvider).reduce((acc, [pid, result]) => {
|
||||
acc[pid as ProviderId] = result.items;
|
||||
return acc;
|
||||
}, {} as Record<ProviderId, VideoItem[]>);
|
||||
const errors: Partial<Record<ProviderId, string>> = {};
|
||||
for (const [pid, result] of Object.entries(byProvider)) {
|
||||
if (result?.providerError) errors[pid as ProviderId] = result.providerError;
|
||||
}
|
||||
return {
|
||||
q: this.q$.value,
|
||||
providers: Object.keys(byProvider) as ProviderId[],
|
||||
groups,
|
||||
...(Object.keys(errors).length > 0 ? { errors } : {}),
|
||||
partial,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Un provider, isolé : cache 60 s, timeout 8 s, un seul retry. Le cœur est
|
||||
* identique à l'ancien `runAdapters`, mais tiré en `searchOne` pour que la
|
||||
* liste en parallèle puisse émettre au fur et à mesure.
|
||||
*/
|
||||
private async searchAdapter(pid: ProviderId, params: ProviderSearchParams): Promise<SearchResult<VideoItem>> {
|
||||
const adapter = this.adapters[pid];
|
||||
if (!adapter) return { items: [] };
|
||||
const key: CacheKey = `${pid}|${params.q}|${params.pageToken}|${params.sort}|${this.filterKey(params.filters)}`;
|
||||
const now = Date.now();
|
||||
const cached = this.cache.get(key);
|
||||
if (cached && (now - cached.t) < this.cacheTtlMs) return cached.data;
|
||||
const perProviderTimeout = 8000;
|
||||
const withTimeout = <T>(p: Promise<T>): Promise<T> => new Promise((resolve, reject) => {
|
||||
const tid = setTimeout(() => reject(new Error('timeout')), perProviderTimeout);
|
||||
p.then(v => { clearTimeout(tid); resolve(v); }).catch(e => { clearTimeout(tid); reject(e); });
|
||||
});
|
||||
const results = await Promise.all(tasks);
|
||||
return Object.fromEntries(results) as Record<ProviderId, SearchResult<VideoItem>>;
|
||||
try {
|
||||
const res: SearchResult<VideoItem> = await withTimeout(adapter.search(params, new AbortController().signal)).catch(async (e) => {
|
||||
// retry once on network-like errors
|
||||
if (e && (e.name === 'AbortError' || String(e.message || '').includes('abort'))) throw e;
|
||||
try { return await withTimeout(adapter.search(params, new AbortController().signal)); } catch (err) { throw err; }
|
||||
});
|
||||
this.cache.set(key, { t: Date.now(), data: res });
|
||||
return res;
|
||||
} catch {
|
||||
// Swallow errors per provider, return empty
|
||||
return { items: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience setter helpers
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* Step 15 — Pure helpers for query typeahead (testable without Angular).
|
||||
*/
|
||||
|
||||
import { ALL_PROVIDER_IDS } from '../shared/providers/provider-ids';
|
||||
|
||||
/** Normalize a suggestion string for dedup comparisons. */
|
||||
export function normSuggest(s: string): string {
|
||||
return String(s ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
@@ -87,7 +89,7 @@ export function buildLocalQuerySuggestions(q: string, max = 10): string[] {
|
||||
*/
|
||||
export function mergeGroups(
|
||||
groups: Record<string, string[]> | null | undefined,
|
||||
order: string[] = ['yt', 'dm', 'tw', 'pt', 'od', 'ru'],
|
||||
order: string[] = [...ALL_PROVIDER_IDS],
|
||||
): Array<{ text: string; provider: string }> {
|
||||
const out: Array<{ text: string; provider: string }> = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||
import { NgClass, NgIf } from '@angular/common';
|
||||
import { SHORT_TO_LONG, toShortProviderId } from '../../providers/provider-ids';
|
||||
|
||||
export type ProviderKey = 'youtube' | 'dailymotion' | 'twitch' | 'peertube' | 'rumble' | 'odysee';
|
||||
|
||||
@@ -55,18 +56,10 @@ export class ProviderBadgeComponent {
|
||||
|
||||
private normalizeProvider(): ProviderKey | null {
|
||||
if (!this.provider) return null;
|
||||
const raw = this.provider.toLowerCase();
|
||||
// Accepte les ids courts (yt/dm/tw/pt/od/ru) utilisés par ChannelMeta
|
||||
// en plus des noms longs (youtube/...).
|
||||
const shortMap: Record<string, ProviderKey> = {
|
||||
yt: 'youtube',
|
||||
dm: 'dailymotion',
|
||||
tw: 'twitch',
|
||||
pt: 'peertube',
|
||||
od: 'odysee',
|
||||
ru: 'rumble',
|
||||
};
|
||||
const key = (shortMap[raw] ?? raw) as ProviderKey;
|
||||
// Accepte les ids courts (yt/dm/tw/pt/od/ru) utilises par ChannelMeta
|
||||
// en plus des noms longs (youtube/...) — table unique provider-ids.ts.
|
||||
const id = toShortProviderId(this.provider);
|
||||
const key = (id ? SHORT_TO_LONG[id] : this.provider.toLowerCase()) as ProviderKey;
|
||||
return (key in PROVIDER_PRESETS) ? key : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
<!-- Phase 7.4 : echecs partiels. Affiches meme quand des resultats sont presents :
|
||||
une source tombee ne doit pas disparaitre derriere les autres. -->
|
||||
<div *ngIf="hasProviderErrors" class="mb-4 rounded-lg border border-amber-700/50 bg-amber-950/30 p-3" role="status" aria-live="polite">
|
||||
<p class="text-sm font-medium text-amber-200">Certaines sources n'ont pas répondu</p>
|
||||
<ul class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-amber-200/80">
|
||||
<li *ngFor="let e of providerErrors">
|
||||
<span class="font-semibold">{{ e.label }}</span> — {{ errorHint(e.message) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Videos already loaded: keep them mounted during continuation loads (no scroll jump). -->
|
||||
<div *ngIf="videos.length > 0">
|
||||
<!-- Rafraichissement d'une grille deja peuplee : on garde les cartes et on
|
||||
@@ -10,20 +21,44 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<!-- Phase 7.2 : `items-start` empeche une carte verticale d'ETIRE ses voisins
|
||||
dans la meme ligne de grille (ils se retrouvaient hauts et vides). Le
|
||||
ratio reel est porte par la carte elle-meme, pas par la ligne. -->
|
||||
<div class="grid grid-cols-1 items-start gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
<app-video-card *ngFor="let video of videos; let i = index; trackBy: trackById" [video]="video" [eager]="i < eagerCount"></app-video-card>
|
||||
<ng-container *ngIf="loadingMore">
|
||||
<app-video-card-skeleton *ngFor="let _ of moreSkeletonItems"></app-video-card-skeleton>
|
||||
<app-video-card-skeleton *ngFor="let _ of moreSkeletonItems" [aspect]="skeletonAspect"></app-video-card-skeleton>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Initial load only: skeleton grid while the first page arrives. -->
|
||||
<div *ngIf="videos.length === 0 && loading" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<app-video-card-skeleton *ngFor="let _ of skeletonItems"></app-video-card-skeleton>
|
||||
<!-- Phase 7.6 : une section par source EN ATTENTE, et non un bloc anonyme.
|
||||
Tant qu'aucun résultat n'est arrivé, l'utilisateur sait quelle source
|
||||
répond et laquelle ne répond pas encore — au lieu d'un flash blanc. -->
|
||||
<div *ngIf="videos.length === 0 && loading" class="flex flex-col gap-6">
|
||||
<section *ngFor="let p of awaitingProviders" [attr.aria-label]="'Chargement ' + p.label">
|
||||
<h2 class="mb-2 flex items-center gap-2 text-[11px] uppercase tracking-wide text-zinc-500">
|
||||
<span class="h-3 w-3 shrink-0 animate-spin rounded-full border-2 border-zinc-700 border-t-red-500"></span>
|
||||
{{ p.label }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 items-start gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
<app-video-card-skeleton *ngFor="let _ of providerSkeletonItems" [aspect]="skeletonAspect"></app-video-card-skeleton>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Filet de sécurité : sources connues mais liste vide (filtre strict, état
|
||||
legacy) — on retombe sur la grille anonyme d'avant plutôt que sur un
|
||||
écran vide. -->
|
||||
<div *ngIf="awaitingProviders.length === 0" class="grid grid-cols-1 items-start gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
<app-video-card-skeleton *ngFor="let _ of skeletonItems" [aspect]="skeletonAspect"></app-video-card-skeleton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!loading && !loadingMore && videos.length === 0" class="flex flex-col items-center justify-center gap-4 rounded-lg border border-zinc-800 bg-zinc-900/50 p-8 text-center">
|
||||
<h3 class="text-lg font-semibold text-white">Aucun résultat</h3>
|
||||
<p class="text-zinc-400">Essayez une autre recherche ou sélectionnez un autre fournisseur.</p>
|
||||
<h3 class="text-lg font-semibold text-white">Aucun r�sultat</h3>
|
||||
<!-- Phase 7.4 : le message ne doit pas accuser la requete si la cause est
|
||||
en amont. Deux messages distincts, sinon l'utilisateur reessaie a l'identique. -->
|
||||
<p *ngIf="hasProviderErrors" class="text-zinc-400">
|
||||
La recherche a échoué côté source. Réessayez dans un instant ou retirez les fournisseurs concernés.
|
||||
</p>
|
||||
<p *ngIf="!hasProviderErrors" class="text-zinc-400">Essayez une autre recherche ou s�lectionnez un autre fournisseur.</p>
|
||||
</div>
|
||||
|
||||
@@ -20,11 +20,69 @@ export class SearchResultGridComponent {
|
||||
@Input() skeletons = 12;
|
||||
// Nombre de cartes chargées sans `loading="lazy"` (celles visibles au premier ecran).
|
||||
@Input() eagerCount = 8;
|
||||
/**
|
||||
* Phase 7.4 — échecs par provider (`errors` de `/api/search`).
|
||||
* Sans ce bandeau, un provider bloqué (Cloudflare sur Rumble, quota YouTube)
|
||||
* produisait un groupe `[]` et l'écran affichait « Aucun résultat » : l'utilisateur
|
||||
* concluait que la chaîne n'existait pas. On distingue donc explicitement
|
||||
* « pas de résultat » de « source indisponible ».
|
||||
*/
|
||||
@Input() providerErrors: { id: string; label: string; message: string }[] = [];
|
||||
|
||||
/**
|
||||
* Phase 7.6 — un squelette PAR SOURCE.
|
||||
*
|
||||
* Avec 6 providers interrogés en parallèle, un écran d'attente unique et
|
||||
* anonyme laisse croire que la recherche a échoué : l'utilisateur ne sait pas
|
||||
* si rien n'a été trouvé ou si une source est encore en train de répondre.
|
||||
* Nommer les sources en attente rend la attente lisible, et permet de ne
|
||||
* montrer le vide que pour celles qui ont réellement rendu la main.
|
||||
*
|
||||
* @Input pendingProviders sources interrogées dont le résultat n'est pas encore revenu
|
||||
* @Input skeletonAspect ratio attendu des cartes (9:16 si l'utilisateur a filtré sur les Shorts)
|
||||
*/
|
||||
@Input() pendingProviders: { id: string; label: string }[] = [];
|
||||
@Input() skeletonAspect: number | null = null;
|
||||
|
||||
/**
|
||||
* Sources en attente = demandées MOINS celles déjà livrées ou en erreur.
|
||||
* Une source en erreur n'est pas « en attente » : la faire clignoter encore
|
||||
* serait un mensonge (elle ne répondra pas).
|
||||
*/
|
||||
get awaitingProviders(): { id: string; label: string }[] {
|
||||
const settled = new Set(this.providerErrors.map((e) => e.id));
|
||||
return this.pendingProviders.filter((p) => p && !settled.has(p.id));
|
||||
}
|
||||
|
||||
/** Nombre de cartes squelette PAR source (2 : assez pour figer une ligne). */
|
||||
readonly skeletonsPerProvider = 2;
|
||||
|
||||
get hasProviderErrors(): boolean {
|
||||
return this.providerErrors.length > 0;
|
||||
}
|
||||
|
||||
/** Message court et actionnable, sans exposer de jargon technique. */
|
||||
errorHint(message: string): string {
|
||||
const m = String(message || '').toLowerCase();
|
||||
if (m.includes('cloudflare') || m.includes('challenge') || m.includes('rumble_unavailable')) {
|
||||
return 'bloque temporairement par le fournisseur';
|
||||
}
|
||||
if (m.includes('quota') || m.includes('api_key') || m.includes('403')) {
|
||||
return 'quota API épuisé';
|
||||
}
|
||||
if (m.includes('timeout') || m.includes('aborted')) return 'délai dépassé';
|
||||
if (m.includes('404')) return 'introuvable';
|
||||
return 'indisponible';
|
||||
}
|
||||
|
||||
get skeletonItems() {
|
||||
return new Array(this.skeletons);
|
||||
}
|
||||
|
||||
get providerSkeletonItems() {
|
||||
return new Array(this.skeletonsPerProvider);
|
||||
}
|
||||
|
||||
get moreSkeletonItems() {
|
||||
return new Array(4);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="flex flex-col gap-2 animate-pulse" role="status" aria-label="Chargement…">
|
||||
<div class="aspect-video w-full overflow-hidden rounded-lg bg-zinc-800">
|
||||
<div class="flex flex-col gap-2 animate-pulse" role="status" aria-label="Chargement.">
|
||||
<div class="overflow-hidden rounded-lg bg-zinc-800" [ngStyle]="frameStyle">
|
||||
<div class="flex h-full w-full items-center justify-center">
|
||||
<span class="h-7 w-7 animate-spin rounded-full border-2 border-zinc-700 border-t-red-500"></span>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,53 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { NgStyle } from '@angular/common';
|
||||
|
||||
/** Ratio par défaut : 16:9, la forme largement dominante. */
|
||||
export const DEFAULT_SKELETON_ASPECT = 16 / 9;
|
||||
|
||||
/** En deçà de ce ratio, un squelette « vertical » (9:16). */
|
||||
const VERTICAL_MAX_RATIO = 0.8;
|
||||
|
||||
@Component({
|
||||
selector: 'app-video-card-skeleton',
|
||||
templateUrl: './video-card-skeleton.component.html',
|
||||
styleUrls: ['./video-card-skeleton.component.scss'],
|
||||
standalone: true,
|
||||
imports: [NgStyle],
|
||||
})
|
||||
export class VideoCardSkeletonComponent {}
|
||||
export class VideoCardSkeletonComponent {
|
||||
/**
|
||||
* Phase 7.6 — forme du squelette.
|
||||
*
|
||||
* Le squelette était figé en 16:9 alors que, depuis la phase 7.2, une carte
|
||||
* porte son VRAI ratio (9:16 pour un Short, une diffusion verticale Twitch).
|
||||
* Conséquence observée : à l'arrivée des résultats, chaque carte verticale
|
||||
* faisait sauter la page d'une hauteur d'écran. Un squelette doit donc
|
||||
* épouser la forme que le contenu prendra, sinon il ne masque rien — il
|
||||
* provoque un saut de mise en page à l'instant précis où l'utilisateur
|
||||
* commence à lire.
|
||||
*
|
||||
* `width`/`height` plutôt qu'un `aspect-video` figé : c'est la même technique
|
||||
* que la carte réelle, donc le même code de calcul.
|
||||
*/
|
||||
@Input() aspect: number | null | undefined = DEFAULT_SKELETON_ASPECT;
|
||||
|
||||
/** `true` si la forme attendue est verticale (ratio < 0,8). */
|
||||
get isVertical(): boolean {
|
||||
const a = Number(this.aspect);
|
||||
if (!Number.isFinite(a) || a <= 0) return false;
|
||||
return a < VERTICAL_MAX_RATIO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hauteur bornée comme la carte : un 9:16 sans borne ferait déraper la page
|
||||
* bien avant l'arrivée du contenu.
|
||||
*/
|
||||
get frameStyle(): { [key: string]: string } {
|
||||
const a = Number(this.aspect);
|
||||
const ratio = Number.isFinite(a) && a > 0 ? a : DEFAULT_SKELETON_ASPECT;
|
||||
return {
|
||||
'aspect-ratio': String(ratio),
|
||||
width: ratio < 1 ? `min(100%, ${Math.round(56.25 * ratio * 2)}%)` : '100%',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-2" [class.is-vertical]="isVertical">
|
||||
<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">
|
||||
<!-- Phase 7.2 : la frame prend le ratio reel quand width/height sont connus
|
||||
([style] porte aspect-ratio + largeur bornee), 16:9 sinon. Le
|
||||
`object-cover` reste car une verticale peut deja etre rognee en amont. -->
|
||||
<div class="relative w-full overflow-hidden rounded-lg bg-zinc-800" [style]="frameStyle">
|
||||
<!-- Frame en cours de chargement : shimmer + spinner (plus de frame vide) -->
|
||||
<div *ngIf="!thumbLoaded() && !thumbFailed()" class="absolute inset-0 animate-pulse bg-zinc-800/80" aria-hidden="true">
|
||||
<div class="flex h-full w-full items-center justify-center">
|
||||
@@ -32,15 +35,18 @@
|
||||
<app-provider-badge [provider]="video.provider"></app-provider-badge>
|
||||
</div>
|
||||
<div class="absolute top-1 left-1 flex gap-1">
|
||||
<span *ngIf="isTwitchLive" class="rounded bg-purple-600 px-1.5 py-0.5 text-[11px] font-bold text-white">LIVE</span>
|
||||
<span *ngIf="isTwitchClip" class="rounded bg-red-600 px-1.5 py-0.5 text-[11px] font-bold text-white">CLIP</span>
|
||||
<span *ngIf="isTwitchChannel" class="rounded bg-slate-700/90 px-1.5 py-0.5 text-[11px] font-bold text-slate-200 ring-1 ring-slate-500/50">CHAÎNE</span>
|
||||
<!-- Phase 7.1 — badges génériques (plus « Twitch-only ») : un live
|
||||
YouTube ou un clip Rumble reçoit enfin son marqueur. -->
|
||||
<span *ngIf="showLiveBadge" class="rounded bg-red-600 px-1.5 py-0.5 text-[11px] font-bold text-white">LIVE</span>
|
||||
<span *ngIf="showShortBadge && !showLiveBadge" class="rounded bg-fuchsia-600 px-1.5 py-0.5 text-[11px] font-bold text-white">SHORT</span>
|
||||
<span *ngIf="showClipBadge" class="rounded bg-orange-600 px-1.5 py-0.5 text-[11px] font-bold text-white">CLIP</span>
|
||||
<span *ngIf="showChannelBadge" class="rounded bg-slate-700/90 px-1.5 py-0.5 text-[11px] font-bold text-slate-200 ring-1 ring-slate-500/50">CHAÎNE</span>
|
||||
</div>
|
||||
<span *ngIf="showDuration" class="absolute bottom-1 right-1 rounded bg-black/80 px-1.5 py-0.5 text-xs text-white">
|
||||
{{ video.durationSec | duration }}
|
||||
</span>
|
||||
<span *ngIf="isTwitchLive && video.viewCount" class="absolute bottom-1 left-1 rounded bg-black/80 px-1.5 py-0.5 text-xs text-white">
|
||||
{{ video.viewCount | number }} spectateurs
|
||||
<span *ngIf="viewerCount" class="absolute bottom-1 left-1 rounded bg-black/80 px-1.5 py-0.5 text-xs text-white">
|
||||
{{ viewerCount | number }} spectateurs
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -49,10 +55,22 @@
|
||||
{{ video.title }}
|
||||
</h3>
|
||||
<div class="text-xs text-zinc-400 flex flex-wrap gap-x-2 gap-y-0.5">
|
||||
<span *ngIf="video.viewCount && !isTwitchLive">{{ video.viewCount | number }} vues</span>
|
||||
<!-- Phase 7.3 — « vues indisponibles » plutôt qu'un vide : un espace
|
||||
vide se lisait comme une vidéo à 0 vues. -->
|
||||
<span *ngIf="viewCount">{{ viewCount | number }} vues</span>
|
||||
<span *ngIf="!hasViewCount" class="text-zinc-500" title="Ce fournisseur n'expose pas de compteur de vues">vues indisponibles</span>
|
||||
<span *ngIf="isTwitchChannel">Chaîne Twitch</span>
|
||||
<span *ngIf="$any(video).game">{{ $any(video).game }}</span>
|
||||
<span *ngIf="video.game">{{ video.game }}</span>
|
||||
<span *ngIf="video.publishedAt">{{ video.publishedAt | date:'shortDate' }}</span>
|
||||
<!-- Phase 7.3 : fraîcheur + voie de collecte. L'infobulle n'apparait que
|
||||
si la donnée est REELLEMENT connue (jamais de « a l'instant » invente
|
||||
sur une reponse de cache vieille de plusieurs minutes). -->
|
||||
<span *ngIf="provenanceTitle" class="cursor-help text-zinc-600 hover:text-zinc-400" [title]="provenanceTitle" aria-label="Provenance des données">
|
||||
<svg class="inline h-3 w-3 align-[-1px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path stroke-linecap="round" d="M12 16v-4M12 8h.01" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -7,6 +7,9 @@ import { ProviderBadgeComponent } from '../provider-badge/provider-badge.compone
|
||||
import { SubscribeButtonComponent } from '../subscribe-button/subscribe-button.component';
|
||||
import { ChannelIdentityComponent } from '../channel-identity/channel-identity.component';
|
||||
import type { ChannelMeta, ProviderId } from '../../models/channel.model';
|
||||
import { toShortProviderId } from '../../providers/provider-ids';
|
||||
import { isLiveVideoItem, isShortVideoItem, isVerticalVideoItem, videoAspectRatio, verticalFrameWidthPercent } from '../../utils/video-kind';
|
||||
import { capturedAgoLabel, sourceLabel } from '../../../search/adapters/provenance';
|
||||
|
||||
@Component({
|
||||
selector: 'app-video-card',
|
||||
@@ -95,6 +98,53 @@ export class VideoCardComponent implements OnChanges {
|
||||
return v?.provider === 'twitch' && String(v?.kind || '').toLowerCase() === 'clip';
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 7.1 — badges génériques, plus « Twitch-only ».
|
||||
* Avant, seuls les items Twitch pouvaient afficher LIVE/CLIP/CHAÎNE : une vidéo
|
||||
* YouTube en direct ou un clip Rumble n'avaient aucun marqueur, alors que le
|
||||
* contrat `Suggestion v2` expose `isLive`/`type`/`kind` pour tous les providers.
|
||||
* On passe par la classification partagée au lieu de la dupliquer.
|
||||
*/
|
||||
get showLiveBadge(): boolean { return isLiveVideoItem(this.video as any); }
|
||||
|
||||
get showShortBadge(): boolean { return isShortVideoItem(this.video as any); }
|
||||
|
||||
get showClipBadge(): boolean {
|
||||
return String((this.video as any)?.kind || '').toLowerCase() === 'clip';
|
||||
}
|
||||
|
||||
get showChannelBadge(): boolean {
|
||||
const v: any = this.video;
|
||||
return String(v?.type || '').toLowerCase() === 'channel' && v?.isLive !== true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 7.3 — « spectateurs » n'a de sens que pour un live. Hors live,
|
||||
* `viewer_count` n'est pas un compteur de vues et ne doit pas être présenté
|
||||
* comme tel.
|
||||
*/
|
||||
get viewerCount(): number | null {
|
||||
const v: any = this.video;
|
||||
if (!v?.isLive) return null;
|
||||
const n = Number(v.viewers ?? v.viewCount);
|
||||
return Number.isFinite(n) && n > 0 ? Math.round(n) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 7.3 — compteur de vues.
|
||||
* `null` = le provider ne l'expose pas. La carte affiche alors
|
||||
* « vues indisponibles » plutôt qu'un vide silencieux, qui se lisait comme
|
||||
* une vidéo à 0 vues.
|
||||
*/
|
||||
get viewCount(): number | null {
|
||||
const n = Number((this.video as any)?.viewCount);
|
||||
return Number.isFinite(n) && n > 0 ? Math.round(n) : null;
|
||||
}
|
||||
|
||||
get hasViewCount(): boolean {
|
||||
return this.viewCount !== null || this.viewerCount !== null;
|
||||
}
|
||||
|
||||
get showDuration(): boolean {
|
||||
const d = Number((this.video as any)?.durationSec ?? NaN);
|
||||
return Number.isFinite(d) && d > 0;
|
||||
@@ -105,15 +155,7 @@ export class VideoCardComponent implements OnChanges {
|
||||
}
|
||||
|
||||
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;
|
||||
return toShortProviderId(this.video.provider);
|
||||
}
|
||||
|
||||
channelExternalId(): string | null {
|
||||
@@ -135,4 +177,61 @@ export class VideoCardComponent implements OnChanges {
|
||||
get showSubscribe(): boolean {
|
||||
return Boolean(this.video.channelMeta && this.channelProviderId && this.channelExternalId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 7.2 — orientation géométrique, source unique `video-kind`.
|
||||
* La frame était figée en `aspect-video` : une verticale était donc rognée
|
||||
* (`object-cover`) — donc la moitié du sujet disparaissait, alors que la donnée
|
||||
* `width`/`height` est disponible depuis la phase 2.1.
|
||||
*/
|
||||
get isVertical(): boolean {
|
||||
return isVerticalVideoItem(this.video as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Style inline de la frame : le ratio VRAI quand il est connu, sinon 16:9.
|
||||
* Une verticale voit en plus sa largeur bornée (voir
|
||||
* `verticalFrameWidthPercent`) : on garde le ratio sans laisser une 9:16
|
||||
* déformer la ligne de grille et laisser du vide autour des cartes voisines.
|
||||
*/
|
||||
get frameStyle(): Record<string, string> {
|
||||
const ratio = this.aspectRatio;
|
||||
if (ratio === null) return { 'aspect-ratio': String(16 / 9) };
|
||||
const style: Record<string, string> = { 'aspect-ratio': String(ratio) };
|
||||
if (ratio < 1) {
|
||||
style['width'] = `min(100%, ${verticalFrameWidthPercent(ratio)}%)`;
|
||||
style['margin-inline'] = 'auto';
|
||||
}
|
||||
return style;
|
||||
}
|
||||
|
||||
get aspectRatio(): number | null {
|
||||
return videoAspectRatio(this.video as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 7.3 — fraîcheur et voie de collecte.
|
||||
*
|
||||
* L'ignorer laissait croire que tout ce qui est affiché venait d'être
|
||||
* récupéré, alors qu'un résultat de cache a pu être servi 4 minutes après le
|
||||
* dernier appel amont. `null` = information inconnue : on n'affiche alors RIEN
|
||||
* plutôt qu'un « à l'instant » inventé (doctrine : métadonnée absente = absent).
|
||||
*/
|
||||
get capturedAgo(): string | null {
|
||||
return capturedAgoLabel((this.video as any)?.capturedAt);
|
||||
}
|
||||
|
||||
get sourceText(): string | null {
|
||||
return sourceLabel((this.video as any)?.source);
|
||||
}
|
||||
|
||||
/** Infobulle : seulement si l'on sait quelque chose de vrai. */
|
||||
get provenanceTitle(): string | null {
|
||||
const parts: string[] = [];
|
||||
const ago = this.capturedAgo;
|
||||
if (ago) parts.push(`Données ${ago}`);
|
||||
const src = this.sourceText;
|
||||
if (src) parts.push(`via ${src}`);
|
||||
return parts.length ? parts.join(' · ') : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ChannelMeta, ProviderId } from './channel.model';
|
||||
import type { VideoItem } from './video-item.model';
|
||||
import { PROVIDER_CAPABILITIES, type ProviderCapabilities } from '../providers/provider-capabilities';
|
||||
|
||||
export type ChannelTabId = 'videos' | 'shorts' | 'playlists' | 'live' | 'about';
|
||||
export type ChannelSort = 'recent' | 'popular';
|
||||
@@ -28,6 +29,12 @@ export interface ChannelContentPage<T> {
|
||||
items: T[];
|
||||
nextPage: number | null;
|
||||
total?: number | null;
|
||||
/**
|
||||
* Curseur opaque renvoyé par les API paginées par curseur (Twitch Helix).
|
||||
* À renvoyer tel quel dans `?cursor=` : sans lui, `page=2` re-demandait la
|
||||
* page 1 (anomalie #4). Absent pour les providers paginés par offset.
|
||||
*/
|
||||
nextCursor?: string | null;
|
||||
}
|
||||
|
||||
export interface ChannelPlaylist {
|
||||
@@ -39,13 +46,30 @@ export interface ChannelPlaylist {
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue « page chaîne » des capacités unifiées.
|
||||
* Ne plus éditer cette matrice à la main : elle est dérivée de
|
||||
* `PROVIDER_CAPABILITIES` (source unique, phase 0 §2.2), lui-même alignée sur ce
|
||||
* que `server/providers/channel-content.mjs` sait réellement servir.
|
||||
*/
|
||||
function toChannelCapabilities(caps: ProviderCapabilities): ChannelCapabilities {
|
||||
return {
|
||||
videos: caps.videos,
|
||||
shorts: caps.shorts,
|
||||
playlists: caps.playlists,
|
||||
live: caps.live,
|
||||
about: caps.about,
|
||||
searchInChannel: caps.searchInChannel,
|
||||
};
|
||||
}
|
||||
|
||||
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 },
|
||||
yt: toChannelCapabilities(PROVIDER_CAPABILITIES.yt),
|
||||
dm: toChannelCapabilities(PROVIDER_CAPABILITIES.dm),
|
||||
tw: toChannelCapabilities(PROVIDER_CAPABILITIES.tw),
|
||||
pt: toChannelCapabilities(PROVIDER_CAPABILITIES.pt),
|
||||
od: toChannelCapabilities(PROVIDER_CAPABILITIES.od),
|
||||
ru: toChannelCapabilities(PROVIDER_CAPABILITIES.ru),
|
||||
};
|
||||
|
||||
export function buildChannelDetail(meta: ChannelMeta, extra?: Partial<ChannelDetail>): ChannelDetail {
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
import type { ChannelMeta } from './channel.model';
|
||||
import type { ProviderLongId } from '../providers/provider-ids';
|
||||
|
||||
/**
|
||||
* Vue « grille » d'une vidéo. Miroir du contrat `Suggestion` v2
|
||||
* (server/providers/registry.mjs) — voir src/app/search/api.v1.ts.
|
||||
*
|
||||
* Règle structurante (phase 2.1) : une métadonnée absente reste `undefined`.
|
||||
* `durationSec: 0` est interdit : les règles de classification s'appuient sur
|
||||
* « pas de durée connue » pour distinguer une verticale d'un short.
|
||||
*/
|
||||
export interface VideoItem {
|
||||
id: string; // id vidéo canonicalisé
|
||||
provider: 'youtube' | 'dailymotion' | 'twitch' | 'peertube' | 'odysee' | 'rumble';
|
||||
provider: ProviderLongId;
|
||||
title: string;
|
||||
thumbnailUrl: string;
|
||||
/** Secondes, > 0. `undefined` = inconnue. */
|
||||
durationSec?: number;
|
||||
channelName?: string;
|
||||
channelExternalId?: string;
|
||||
channelAvatarUrl?: string;
|
||||
channelMeta?: ChannelMeta;
|
||||
viewCount?: number;
|
||||
publishedAt?: string; // ISO
|
||||
/**
|
||||
* Phase 7.3 — spectateurs EN DIRECT (Twitch `viewer_count`). Ce n'est pas un
|
||||
* compteur de vues : à n'afficher que si `isLive === true`.
|
||||
*/
|
||||
viewers?: number;
|
||||
/** ISO 8601. */
|
||||
publishedAt?: string;
|
||||
// Provider-specific routing hints
|
||||
slug?: string; // Odysee slug when available
|
||||
channel?: string; // Twitch channel when item represents a channel/live
|
||||
@@ -23,4 +39,28 @@ export interface VideoItem {
|
||||
game?: string; // e.g. Twitch game/category name
|
||||
width?: number; // video width in px (orientation signal, when exposed)
|
||||
height?: number; // video height in px (orientation signal, when exposed)
|
||||
|
||||
// --- v2 (phase 1 + 2) ---
|
||||
likes?: number;
|
||||
dislikes?: number;
|
||||
uploaderAvatar?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
/** BCP-47. */
|
||||
language?: string;
|
||||
hasSubtitles?: boolean;
|
||||
embeddable?: boolean;
|
||||
/** Libellés bruts : permettent d'afficher une valeur approchée plutôt qu'un vide. */
|
||||
durationRaw?: string;
|
||||
viewCountRaw?: string;
|
||||
|
||||
// --- Phase 7.3 : provenance ---
|
||||
/**
|
||||
* Epoch ms : instant RÉEL de la collecte. `undefined` = inconnu (jamais
|
||||
* `Date.now()` côté front : une donnée vieille de 4 min ne doit pas passer
|
||||
* pour fraîche).
|
||||
*/
|
||||
capturedAt?: number;
|
||||
/** Voie de collecte : `innertube` / `api` / `scrape` / `cache`… */
|
||||
source?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Phase 6 — identité de chaîne normalisée, côté front.
|
||||
*
|
||||
* MROIT de `server/providers/channel-ref.mjs` : mêmes schemes, mêmes règles de
|
||||
* dérivation. `npm run test:channelref` échoue si les deux divergent — le serveur
|
||||
* étant du `.mjs` et le front du TypeScript, il n'y a pas d'import commun
|
||||
* possible, seulement une parité vérifiée.
|
||||
*
|
||||
* Rappel du problème résolu : avant, l'identifiant d'une chaîne PeerTube
|
||||
* voyageait sous la forme `instance|channel` reconstruite par l'adaptateur front
|
||||
* à partir de l'URL de la vidéo, celui d'Odysee sous forme de claim LBRY brut
|
||||
* (avec ou sans `@` selon le chemin), celui de YouTube en `UC…`… sans que rien
|
||||
* ne dise lequel était lequel. Voir `docs/plan-phases-catalogue-classification.md` §8.
|
||||
*/
|
||||
|
||||
import { ProviderId, SHORT_TO_LONG } from './provider-ids';
|
||||
|
||||
export type ChannelRefScheme = 'yt-uc' | 'dm-user' | 'tw-login' | 'pt-composite' | 'od-claim' | 'ru-slug';
|
||||
|
||||
export interface ChannelRef {
|
||||
provider: ProviderId;
|
||||
/** Déclare comment lire `value`. */
|
||||
scheme: ChannelRefScheme;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** Schemes par provider — doit rester identique à `CHANNEL_REF_SCHEMES`. */
|
||||
export const CHANNEL_REF_SCHEMES: Record<ProviderId, ChannelRefScheme> = {
|
||||
yt: 'yt-uc',
|
||||
dm: 'dm-user',
|
||||
tw: 'tw-login',
|
||||
pt: 'pt-composite',
|
||||
od: 'od-claim',
|
||||
ru: 'ru-slug',
|
||||
};
|
||||
|
||||
const firstString = (...values: unknown[]): string | undefined => {
|
||||
for (const v of values) {
|
||||
const s = String(v ?? '').trim();
|
||||
if (s) return s;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Un seul `@` en tête — forme canonique d'un claim LBRY. */
|
||||
export function normalizeOdyseeClaim(value: unknown): string | undefined {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) return undefined;
|
||||
return raw.startsWith('@') ? raw : `@${raw}`;
|
||||
}
|
||||
|
||||
export function odyseeClaimToSlug(value: unknown): string | undefined {
|
||||
const claim = normalizeOdyseeClaim(value);
|
||||
return claim ? claim.slice(1) : undefined;
|
||||
}
|
||||
|
||||
/** Découpe `instance|channel` (instance inconnue = ancien format « channel seul »). */
|
||||
export function parsePeerTubeComposite(value: unknown): { instance?: string; channel?: string } {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) return {};
|
||||
const sep = raw.indexOf('|');
|
||||
if (sep < 0) return { channel: raw };
|
||||
return { instance: raw.slice(0, sep) || undefined, channel: raw.slice(sep + 1) || undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit `instance|channel` à partir de l'URL vidéo et du channelId.
|
||||
* L'instance est obligatoire : sans elle, `fetchPeerTubeChannel` produirait
|
||||
* `https://<channel>`, une URL fausse. Doit rester aligné avec
|
||||
* `buildPeerTubeComposite` de `server/providers/channel-ref.mjs`.
|
||||
*/
|
||||
export function buildPeerTubeComposite(url: unknown, channelId: unknown): string | undefined {
|
||||
const channel = String(channelId ?? '').trim();
|
||||
if (!channel) return undefined;
|
||||
let host = '';
|
||||
try {
|
||||
if (url) host = new URL(String(url)).hostname || '';
|
||||
} catch {}
|
||||
return host ? `${host}|${channel}` : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Champs legacy d'une Suggestion (`server` ou cache localStorage), dont on peut
|
||||
* encore avoir besoin de reconstruire l'identité de chaîne — entrées en cache
|
||||
* écrites avant la phase 6, qui ne contiennent pas `channelRef`.
|
||||
*/
|
||||
export interface LegacyChannelFields {
|
||||
channelExternalId?: string;
|
||||
channelId?: string;
|
||||
channelHandle?: string;
|
||||
channel?: string;
|
||||
uploaderName?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tâche 6.3 — reconstruit un `channelRef` depuis les champs legacy.
|
||||
*
|
||||
* `providerRef` est le `VideoItem.provider` (nom **long**), parce que c'est
|
||||
* sous cette forme que le front stocke ses entrées de cache.
|
||||
*
|
||||
* Renvoie `undefined` si rien n'est exploitable : jamais de valeur inventée, et
|
||||
* surtout jamais une chaîne vide qui passerait pour un identifiant valide.
|
||||
*/
|
||||
export function channelRefFrom(
|
||||
providerRef: string | undefined | null,
|
||||
fields: LegacyChannelFields | null | undefined,
|
||||
): ChannelRef | undefined {
|
||||
const short = (Object.keys(SHORT_TO_LONG) as ProviderId[]).find((id) => SHORT_TO_LONG[id] === providerRef)
|
||||
?? (Object.keys(SHORT_TO_LONG) as ProviderId[]).find((id) => id === String(providerRef ?? '').trim().toLowerCase());
|
||||
if (!short || !fields || typeof fields !== 'object') return undefined;
|
||||
|
||||
let value: string | undefined;
|
||||
switch (short) {
|
||||
case 'yt': value = firstString(fields.channelExternalId, fields.channelId); break;
|
||||
case 'dm': value = firstString(fields.channelId, fields.channelExternalId); break;
|
||||
case 'tw': value = firstString(fields.channelExternalId, fields.channelHandle); break;
|
||||
case 'pt': value = buildPeerTubeComposite(fields.url, fields.channelId); break;
|
||||
case 'od': value = normalizeOdyseeClaim(firstString(fields.channel, fields.uploaderName, fields.channelHandle)); break;
|
||||
case 'ru': value = firstString(fields.channelExternalId, fields.channelId); break;
|
||||
}
|
||||
if (!value) return undefined;
|
||||
return { provider: short, scheme: CHANNEL_REF_SCHEMES[short], value };
|
||||
}
|
||||
|
||||
/**
|
||||
* Valeur canonique reçue du serveur, si elle est cohérente avec le provider.
|
||||
*
|
||||
* Les adaptateurs l'utilisent en tête de chaîne avant de retomber sur leur
|
||||
* dérivation legacy : une réponse déjà annotée (phase 6) est reprise telle
|
||||
* quelle, une entrée de cache ancienne (sans `channelRef`) est reconstruite
|
||||
* localement. Le `scheme` est vérifié pour ne pas reprendre un ref qui
|
||||
* viendrait d'un autre provider.
|
||||
*/
|
||||
export function serverRefValue(ref: unknown, short: ProviderId): string | undefined {
|
||||
if (!ref || typeof ref !== 'object') return undefined;
|
||||
const r = ref as Partial<ChannelRef>;
|
||||
if (r?.scheme !== CHANNEL_REF_SCHEMES[short]) return undefined;
|
||||
const value = typeof r.value === 'string' ? r.value.trim() : '';
|
||||
return value || undefined;
|
||||
}
|
||||
|
||||
/** URL de la page chaîne à partir d'un `channelRef` — source unique côté front. */
|
||||
export function channelUrlFromRef(ref: ChannelRef | undefined): string | undefined {
|
||||
if (!ref) return undefined;
|
||||
switch (ref.scheme) {
|
||||
case 'yt-uc': return `https://www.youtube.com/channel/${ref.value}`;
|
||||
case 'dm-user': return `https://www.dailymotion.com/${ref.value}`;
|
||||
case 'tw-login': return `https://www.twitch.tv/${ref.value}`;
|
||||
case 'pt-composite': {
|
||||
const { instance, channel } = parsePeerTubeComposite(ref.value);
|
||||
return instance ? `https://${instance}/video-channels/${channel}` : undefined;
|
||||
}
|
||||
case 'od-claim': return `https://odysee.com/${odyseeClaimToSlug(ref.value) || ''}`;
|
||||
case 'ru-slug': return `https://rumble.com/c/${ref.value}`;
|
||||
default: return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Phase 0 (plan-phases-catalogue-classification.md §2.2) — SOURCE UNIQUE de la
|
||||
* matrice de capacités par provider.
|
||||
*
|
||||
* AVANT : deux matrices contradictoires coexistaient.
|
||||
* - `provider-registry.ts` → `dm.playlists: false`
|
||||
* - `channel-detail.model.ts` → `dm.playlists: true`
|
||||
* Résultat : selon le code appelant, l'onglet Playlists apparaissait ou non sur
|
||||
* une chaîne Dailymotion.
|
||||
*
|
||||
* RÈGLE DE RÉCONCILIATION : le serveur fait foi. Une capacité n'est `true` que
|
||||
* si `server/providers/channel-content.mjs` sait réellement la servir (voir le
|
||||
* tableau de couverture du document d'analyse §5.1). Tout le reste vaut `false`,
|
||||
* ce qui évite d'exposer des onglets qui renvoient toujours `[]`.
|
||||
*/
|
||||
|
||||
import type { ProviderId } from './provider-ids';
|
||||
import { ALL_PROVIDER_IDS, toShortProviderId } from './provider-ids';
|
||||
|
||||
/**
|
||||
* Capacités unifiées. Les 3 premiers champs servent aussi à la recherche
|
||||
* (`supports` du registre front) ; les suivants à la page chaîne.
|
||||
*/
|
||||
export interface ProviderCapabilities {
|
||||
/** Le provider est branché sur le fan-out `/api/search`. */
|
||||
search: boolean;
|
||||
/** Onglet « Vidéos » de la page chaîne. */
|
||||
videos: boolean;
|
||||
/** Onglet « Shorts » de la page chaîne. */
|
||||
shorts: boolean;
|
||||
/** Onglet « Live » de la page chaîne. */
|
||||
live: boolean;
|
||||
/** Onglet « Playlists » de la page chaîne. */
|
||||
playlists: boolean;
|
||||
/** Section « À propos » + bandeau/description. */
|
||||
about: boolean;
|
||||
/** Recherche interne à la chaîne (`?q=` sur le contenu de chaîne). */
|
||||
searchInChannel: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérité = couverture réelle du backend (`server/providers/channel-content.mjs`).
|
||||
*
|
||||
* yt : 4 onglets (scrape yt-dlp + Data API v3)
|
||||
* dm : vidéos + playlists (`/user/{u}/playlists`) — PAS de shorts ni live
|
||||
* tw : vidéos (VOD) + live (`/streams`) — PAS de playlists ni shorts
|
||||
* pt : vidéos + playlists (`/video-playlists`) — PAS DE live (l'API l'expose
|
||||
* via `?isLive=true` mais ce n'est pas câblé : cf. anomalie #5 du doc)
|
||||
* od : vidéos uniquement (le handler ne traite que `type === 'videos'`)
|
||||
* ru : vidéos uniquement (délégation à la recherche globale + filtre client)
|
||||
*/
|
||||
export const PROVIDER_CAPABILITIES: Readonly<Record<ProviderId, ProviderCapabilities>> = {
|
||||
yt: { search: true, videos: true, shorts: true, live: true, playlists: true, about: true, searchInChannel: true },
|
||||
dm: { search: true, videos: true, shorts: false, live: false, playlists: true, about: true, searchInChannel: true },
|
||||
tw: { search: true, videos: true, shorts: false, live: true, playlists: false, about: true, searchInChannel: true },
|
||||
pt: { search: true, videos: true, shorts: false, live: false, playlists: true, about: true, searchInChannel: true },
|
||||
od: { search: true, videos: true, shorts: false, live: false, playlists: false, about: true, searchInChannel: true },
|
||||
ru: { search: true, videos: true, shorts: false, live: false, playlists: false, about: true, searchInChannel: true },
|
||||
};
|
||||
|
||||
/** Projection vers l'ancienne forme `supports` du registre front (compat). */
|
||||
export interface LegacySupports {
|
||||
search: boolean;
|
||||
shorts: boolean;
|
||||
live: boolean;
|
||||
playlists: boolean;
|
||||
}
|
||||
|
||||
export function toLegacySupports(caps: ProviderCapabilities): LegacySupports {
|
||||
return { search: caps.search, shorts: caps.shorts, live: caps.live, playlists: caps.playlists };
|
||||
}
|
||||
|
||||
/** Capacités d'un provider (id court OU long), avec repli « tout faux ». */
|
||||
export function capabilitiesOf(provider: unknown): ProviderCapabilities {
|
||||
const id = toShortProviderId(provider);
|
||||
if (!id) {
|
||||
return {
|
||||
search: false, videos: false, shorts: false, live: false, playlists: false, about: false, searchInChannel: false,
|
||||
};
|
||||
}
|
||||
return PROVIDER_CAPABILITIES[id];
|
||||
}
|
||||
|
||||
/** Le provider supporte-t-il le direct ? Source unique (remplace l'ancien helper). */
|
||||
export function providerSupportsLive(provider: unknown): boolean {
|
||||
return capabilitiesOf(provider).live === true;
|
||||
}
|
||||
|
||||
/** Providers dont la recherche fonctionne réellement (filtre des pastilles). */
|
||||
export function searchableProviders(): ProviderId[] {
|
||||
return ALL_PROVIDER_IDS.filter((id) => PROVIDER_CAPABILITIES[id].search);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Phase 0 (plan-phases-catalogue-classification.md §2.1) — SOURCE UNIQUE des
|
||||
* identifiants de providers.
|
||||
*
|
||||
* Avant, la table court<->long et la liste "tous les providers" étaient dupliquées
|
||||
* dans ~8 fichiers (`search.service.ts`, `search.component.ts`, `provider-registry.ts`,
|
||||
* `models.ts`, `suggest.util.ts`, `provider-badge`, `video-card`, …). Ajouter un
|
||||
* provider obligeait à toucher tous ces points, ce qui contredisait la promesse
|
||||
* "un provider en 5 minutes" du README.
|
||||
*
|
||||
* Règle : ce fichier ne contient QUE des données. Aucune logique de registre.
|
||||
*/
|
||||
|
||||
export type ProviderId = 'yt' | 'dm' | 'tw' | 'pt' | 'od' | 'ru';
|
||||
|
||||
/** Noms longs — utilisés par `VideoItem.provider`, l'historique SQLite et les URLs. */
|
||||
export type ProviderLongId = 'youtube' | 'dailymotion' | 'twitch' | 'peertube' | 'odysee' | 'rumble';
|
||||
|
||||
/** Liste complète, dans l'ordre d'affichage et d'entrelacement du fan-out. */
|
||||
export const ALL_PROVIDER_IDS: readonly ProviderId[] = ['yt', 'dm', 'tw', 'pt', 'od', 'ru'] as const;
|
||||
|
||||
/** Ids courts -> noms longs. */
|
||||
export const SHORT_TO_LONG: Record<ProviderId, ProviderLongId> = {
|
||||
yt: 'youtube',
|
||||
dm: 'dailymotion',
|
||||
tw: 'twitch',
|
||||
pt: 'peertube',
|
||||
od: 'odysee',
|
||||
ru: 'rumble',
|
||||
};
|
||||
|
||||
/**
|
||||
* Noms longs -> ids courts. Volontairement `Partial` : on accepte des tokens
|
||||
* externes (OAuth, URLs, Takeout) qui peuvent ne pas être des providers connus.
|
||||
*/
|
||||
export const LONG_TO_SHORT: Partial<Record<string, ProviderId>> = {
|
||||
youtube: 'yt',
|
||||
dailymotion: 'dm',
|
||||
twitch: 'tw',
|
||||
peertube: 'pt',
|
||||
odysee: 'od',
|
||||
rumble: 'ru',
|
||||
};
|
||||
|
||||
/** CSV déterministe pour les query params / l'API (`?providers=yt,dm,…`). */
|
||||
export const ALL_PROVIDER_IDS_CSV: string = ALL_PROVIDER_IDS.join(',');
|
||||
|
||||
/** Normalise n'importe quel token (court ou long, quelle casse) vers un id court. */
|
||||
export function toShortProviderId(value: unknown): ProviderId | null {
|
||||
const key = String(value ?? '').trim().toLowerCase();
|
||||
if (!key) return null;
|
||||
const mapped = LONG_TO_SHORT[key];
|
||||
if (mapped) return mapped;
|
||||
return (ALL_PROVIDER_IDS as readonly string[]).includes(key) ? (key as ProviderId) : null;
|
||||
}
|
||||
|
||||
/** Normalise vers un id court, avec repli sur un fournisseur par défaut. */
|
||||
export function toShortProviderIdOr(value: unknown, fallback: ProviderId): ProviderId {
|
||||
return toShortProviderId(value) ?? fallback;
|
||||
}
|
||||
|
||||
/** Normalise vers un nom long, avec repli. */
|
||||
export function toLongProviderId(value: unknown, fallback: ProviderLongId = 'youtube'): ProviderLongId {
|
||||
const short = toShortProviderId(value);
|
||||
return short ? SHORT_TO_LONG[short] : fallback;
|
||||
}
|
||||
|
||||
/** Élargit `unknown` en tableau d'ids courts, en filtrant l'inconnu et les doublons. */
|
||||
export function normalizeProviderList(value: unknown): ProviderId[] {
|
||||
if (value === 'all') return [...ALL_PROVIDER_IDS];
|
||||
if (!Array.isArray(value)) return [];
|
||||
const seen = new Set<ProviderId>();
|
||||
const out: ProviderId[] = [];
|
||||
for (const raw of value) {
|
||||
const id = toShortProviderId(raw);
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
out.push(id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Parse un CSV de query param (`?providers=yt,ru`) — tokens inconnus ignorés. */
|
||||
export function parseProviderCsv(value: unknown): ProviderId[] {
|
||||
if (Array.isArray(value)) return normalizeProviderList(value);
|
||||
const csv = String(value ?? '').trim();
|
||||
if (!csv) return [];
|
||||
return normalizeProviderList(csv.split(','));
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
hasKnownDuration, isNativeVerticalProvider,
|
||||
SHORT_MAX_SECONDS, YOUTUBE_SHORT_MAX_SECONDS,
|
||||
VERTICAL_SHORT_MAX_SECONDS, VERTICAL_MAX_RATIO,
|
||||
isVerticalVideoItem, videoAspectRatio, verticalFrameWidthPercent,
|
||||
VERTICAL_FRAME_HEIGHT_FACTOR,
|
||||
} from './video-kind';
|
||||
|
||||
/**
|
||||
@@ -88,3 +90,58 @@ assertEqual(hasKnownDuration(null), false, 'hasKnownDuration null video');
|
||||
assertEqual(isNativeVerticalProvider('rumble'), true, 'Rumble vertical natif');
|
||||
assertEqual(isNativeVerticalProvider('odysee'), true, 'Odysee vertical natif');
|
||||
assertEqual(isNativeVerticalProvider('youtube'), false, 'YouTube non vertical natif');
|
||||
|
||||
// --- Phase 7.2 : orientation pour la mise en page -----------------------------
|
||||
// Distinct de `isShortVideo` : une verticale de 10 min n'est pas un Short mais
|
||||
// s'affiche en 9:16. La mise en page ne doit PAS dépendre de la durée.
|
||||
assertEqual(isVerticalVideoItem({ width: 1080, height: 1920 }), true, '9:16 ⇒ vertical');
|
||||
assertEqual(isVerticalVideoItem({ width: 608, height: 1080 }), true, 'vertical YouTube Shorts');
|
||||
assertEqual(isVerticalVideoItem({ width: 1920, height: 1080 }), false, '16:9 ⇒ pas vertical');
|
||||
assertEqual(isVerticalVideoItem({ width: 1080, height: 1080 }), false, 'carré ⇒ pas vertical');
|
||||
assertEqual(isVerticalVideoItem({ width: 900, height: 1080 }), false, '5:6 (ratio 0.83 > 0.8) ⇒ pas vertical');
|
||||
// Dimensions absentes ⇒ orientation inconnue, jamais devinée.
|
||||
assertEqual(isVerticalVideoItem({}), false, 'sans dimensions ⇒ pas vertical');
|
||||
assertEqual(isVerticalVideoItem({ width: 1080 }), false, 'width seul ⇒ pas vertical (half-dimension)');
|
||||
assertEqual(isVerticalVideoItem({ width: 0, height: 1080 }), false, 'width 0 ⇒ pas vertical');
|
||||
assertEqual(isVerticalVideoItem(null), false, 'null ⇒ pas vertical');
|
||||
// Cohérence avec isShortVideo sur le MÊME seuil : ce que la mise en page
|
||||
// appelle « vertical » doit être exactement ce que la classification voit.
|
||||
const shared = [
|
||||
{ width: 1080, height: 1920 },
|
||||
{ width: 608, height: 1080 },
|
||||
{ width: 900, height: 1080 },
|
||||
{ width: 1920, height: 1080 },
|
||||
{ width: 1080, height: 1080 },
|
||||
{ width: 720, height: 1280 },
|
||||
];
|
||||
for (const dims of shared) {
|
||||
// isShortVideo ne classe « short » une verticale que si la durée est <= 90 s :
|
||||
// à durée courte, son verdict vertical doit coïncider avec le nôtre.
|
||||
const agrees = isShortVideo({ ...dims, duration: 30 }) === isVerticalVideoItem(dims);
|
||||
assertEqual(agrees, true, `seuil partagé respecté pour ${dims.width}x${dims.height}`);
|
||||
}
|
||||
|
||||
assertEqual(videoAspectRatio({ width: 1920, height: 1080 }), 1.78, 'ratio 16:9 arrondi');
|
||||
assertEqual(videoAspectRatio({ width: 1080, height: 1920 }), 0.56, 'ratio 9:16 arrondi');
|
||||
assertEqual(videoAspectRatio({}), null, 'sans dimensions ⇒ ratio inconnu');
|
||||
assertEqual(videoAspectRatio({ width: 0, height: 1080 }), null, 'dimension 0 ⇒ ratio inconnu');
|
||||
assertEqual(videoAspectRatio({ width: 1, height: 4000 }), null, 'ratio extrême rejeté (frame de 1 px)');
|
||||
assertEqual(videoAspectRatio(null), null, 'null ⇒ ratio inconnu');
|
||||
|
||||
assertEqual(verticalFrameWidthPercent(1.78), 100, 'horizontale ⇒ aucune contrainte');
|
||||
assertEqual(verticalFrameWidthPercent(1), 100, 'carré ⇒ aucune contrainte');
|
||||
assertEqual(verticalFrameWidthPercent(null), 100, 'ratio inconnu ⇒ repli 16:9 pleine largeur');
|
||||
// 9:16 : borné par VERTICAL_FRAME_HEIGHT_FACTOR, jamais 100 %.
|
||||
const p916 = verticalFrameWidthPercent(0.5625);
|
||||
assertEqual(p916 < 100 && p916 >= 38, true, '9:16 bornée, lisible');
|
||||
// La hauteur obtenue reste dans le budget voulu.
|
||||
const cardW = 300;
|
||||
assertEqual(
|
||||
Math.round((cardW * p916 / 100) / 0.5625) <= Math.round(cardW / (16 / 9) * VERTICAL_FRAME_HEIGHT_FACTOR) + 1,
|
||||
true,
|
||||
'hauteur verticale <= 1,8x la hauteur 16:9',
|
||||
);
|
||||
// Garde-fou : une frame trop étroite rendrait les badges (LIVE/durée) illisibles.
|
||||
assertEqual(verticalFrameWidthPercent(0.05), 38, 'plancher de lisibilité');
|
||||
assertEqual(verticalFrameWidthPercent(0), 100, 'ratio 0 ⇒ repli pleine largeur');
|
||||
assertEqual(verticalFrameWidthPercent(NaN), 100, 'NaN ⇒ repli pleine largeur');
|
||||
|
||||
@@ -70,10 +70,14 @@ export function isShortVideo(v: ClassifiableVideo | null | undefined): boolean {
|
||||
const url = String((v as { url?: unknown }).url || '');
|
||||
const provider = String((v as { provider?: unknown }).provider || '').toLowerCase();
|
||||
|
||||
// 1) Clips Twitch = équivalent Shorts.
|
||||
// 1) Clips Twitch = équivalent Shorts. Règle (c) : ce retour prime sur
|
||||
// `type: 'video'`, ce que produisait `channel-content.mjs` pour les lives.
|
||||
if (kind === 'clip') return true;
|
||||
|
||||
// 2) Flag natif — ignoré si la durée connue le contredit.
|
||||
// Règle (a) : flag + durée INCONNUE => short (« le provider a parlé »).
|
||||
// Règle (b) : le flag prime aussi sur un 1:1 (repost Instagram/TikTok),
|
||||
// mais JAMAIS sur la seule durée.
|
||||
if ((v as { isShort?: unknown }).isShort === true || type === 'short' || url.includes('/shorts/')) {
|
||||
if (hasKnownDuration && duration > SHORT_MAX_SECONDS) return false;
|
||||
return true;
|
||||
@@ -108,6 +112,98 @@ export function isLiveVideo(v: ClassifiableVideo | null | undefined): boolean {
|
||||
return type === 'live' || type === 'stream' || type === 'channel';
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 7.1 — même classification, mais appliquée à un objet déjà normalisé
|
||||
* (`VideoItem`, qui porte `durationSec`) au lieu d'une `Suggestion` brute
|
||||
* (`duration`). Évite d'avoir à réécrire le mapping à chaque appelant, et
|
||||
* garantit que la carte et le filtre serveur ultimately partagent les MÊMES
|
||||
* règles.
|
||||
*/
|
||||
export function isShortVideoItem(v: Record<string, unknown> | null | undefined): boolean {
|
||||
if (!v) return false;
|
||||
return isShortVideo({
|
||||
...(v as ClassifiableVideo),
|
||||
duration: (v['durationSec'] ?? v['duration']) as number | undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** Idem pour le live (miroir de `isLiveItem` côté serveur). */
|
||||
export function isLiveVideoItem(v: Record<string, unknown> | null | undefined): boolean {
|
||||
if (!v) return false;
|
||||
return isLiveVideo(v as ClassifiableVideo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 7.2 — orientation **purement géométrique**, sans jugement sur la durée :
|
||||
* sert à la mise en page (frame verticale plutôt qu'un 16:9 rogné).
|
||||
*
|
||||
* Volontairement distincte de `isShortVideo` : une vidéo verticale de 10 min
|
||||
* n'est pas un Short, mais elle s'affiche quand même en 9:16. Mélanger les deux
|
||||
* ferait dépendre la mise en page de la durée.
|
||||
*
|
||||
* MÊME seuil que `isShortVideo` (`VERTICAL_MAX_RATIO`) et pas une copie : les
|
||||
* deux doivent reconnaître la même chose, sinon un 4:5 est « vertical » pour l'un
|
||||
* et « horizontal » pour l'autre, et la carte se contredit.
|
||||
*
|
||||
* Dimensions absentes ⇒ `false` : la doctrine reste « métadonnée absente =
|
||||
* absent », on ne devine pas une orientation par défaut.
|
||||
*/
|
||||
export function isVerticalVideoItem(v: Record<string, unknown> | null | undefined): boolean {
|
||||
if (!v) return false;
|
||||
const w = numOrZero(v['width']);
|
||||
const h = numOrZero(v['height']);
|
||||
if (w <= 0 || h <= 0) return false;
|
||||
return h > w && w / h <= VERTICAL_MAX_RATIO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 7.2 — ratio d'aspect à afficher, ou `null` si inconnu.
|
||||
* Renvoie `width/height` arrondi à 2 décimales, borné pour éviter qu'une
|
||||
* valeur parasite (1 x 4000) ne produise une frame de 1 px de large.
|
||||
*/
|
||||
export function videoAspectRatio(v: Record<string, unknown> | null | undefined): number | null {
|
||||
const w = numOrZero(v?.['width']);
|
||||
const h = numOrZero(v?.['height']);
|
||||
if (w <= 0 || h <= 0) return null;
|
||||
const ratio = w / h;
|
||||
if (!Number.isFinite(ratio) || ratio <= 0.05 || ratio >= 20) return null;
|
||||
return Math.round(ratio * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 7.2 — hauteur de frame verticale, exprimée en FOIS la hauteur d'une
|
||||
* frame 16:9 de même largeur. Vaut 1 pour une horizontale (donc : aucune
|
||||
* contrainte appliquée) et borne la verticale pour qu'une 9:16 n'explose pas
|
||||
* la hauteur de la ligne de grille.
|
||||
*/
|
||||
export const VERTICAL_FRAME_HEIGHT_FACTOR = 1.8;
|
||||
|
||||
/**
|
||||
* Phase 7.2 — largeur de frame verticale, en % de la largeur de la carte.
|
||||
*
|
||||
* Garder le ratio RÉEL coûte cher : dans une grille 4 colonnes, une 9:16 à
|
||||
* 100 % de largeur ferait 1,78× la hauteur d'une 16:9, et toutes les cartes
|
||||
* voisines se retrouvaient entourées de vide (le letterbox que la tâche veut
|
||||
* supprimer). On borne donc la hauteur à `VERTICAL_FRAME_HEIGHT_FACTOR`, ce
|
||||
* qui, à ratio `r` fixé, impose :
|
||||
*
|
||||
* hauteur = largeur / r ≤ 1,8 × (16:9 de hauteur = largeur / 1,778)
|
||||
* largeur ≤ 1,8 × largeur_carte / (1,778 × r)
|
||||
*
|
||||
* d'où le pourcentage ci-dessous, arrondi à 1 % près. La carte recentre alors
|
||||
* sa frame (`margin-inline: auto`) au lieu de laisser un trou à gauche.
|
||||
*
|
||||
* Un ratio horizontal rend `100` : aucune contrainte appliquée, la grille
|
||||
* continue de gérer la largeur comme avant.
|
||||
*/
|
||||
export function verticalFrameWidthPercent(ratio: number | null | undefined): number {
|
||||
if (!ratio || !Number.isFinite(ratio) || ratio <= 0) return 100;
|
||||
if (ratio >= 1) return 100;
|
||||
const pct = (VERTICAL_FRAME_HEIGHT_FACTOR / (16 / 9)) * ratio * 100;
|
||||
// 1 px de garde : en dessous, la frame devient illisible (badges superposés).
|
||||
return Math.max(38, Math.min(100, Math.round(pct)));
|
||||
}
|
||||
|
||||
export function classifyVideo(v: ClassifiableVideo | null | undefined): VideoKind {
|
||||
if (isShortVideo(v)) return 'short';
|
||||
if (isLiveVideo(v)) return 'live';
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ThemesService } from '../../services/themes.service';
|
||||
import { HistoryService, SearchHistoryItem } from '../../services/history.service';
|
||||
import { SearchBoxComponent } from '../search/search-box.component';
|
||||
import { filtersToQueryParams, type SearchFilters } from '../../app/search/filters';
|
||||
import { ALL_PROVIDER_IDS_CSV, type ProviderId } from '../../app/shared/providers/provider-ids';
|
||||
|
||||
@Component({
|
||||
selector: 'app-header',
|
||||
@@ -142,7 +143,7 @@ export class HeaderComponent implements AfterViewInit {
|
||||
});
|
||||
|
||||
// New SearchBox submit handler (navigate with providers list + filters)
|
||||
onSearchBoxSubmit(evt: { q: string; providers: ("yt"|"dm"|"tw"|"pt"|"od"|"ru")[] | 'all'; filters?: SearchFilters }) {
|
||||
onSearchBoxSubmit(evt: { q: string; providers: ProviderId[] | 'all'; filters?: SearchFilters }) {
|
||||
const q = (evt?.q || '').trim();
|
||||
if (!q) return;
|
||||
const provider = this.selectedProvider();
|
||||
@@ -152,7 +153,7 @@ export class HeaderComponent implements AfterViewInit {
|
||||
qp.providers = evt.providers.join(',');
|
||||
} else if (evt.providers === 'all') {
|
||||
// Explicitly include all providers so SearchComponent uses unified multi-provider mode
|
||||
qp.providers = 'yt,dm,tw,pt,od,ru';
|
||||
qp.providers = ALL_PROVIDER_IDS_CSV;
|
||||
}
|
||||
// Filtres (type / durée / période / tri) : seulement les valeurs actives,
|
||||
// pour garder une URL lisible et partageable.
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type SubscriptionItem,
|
||||
} from '../../../services/subscriptions.service';
|
||||
import type { ProviderId } from '../../../app/shared/models/channel.model';
|
||||
import { toShortProviderId } from '../../../app/shared/providers/provider-ids';
|
||||
import { ProviderBadgeComponent } from '../../../app/shared/components/provider-badge/provider-badge.component';
|
||||
import { SubscribeButtonComponent } from '../../../app/shared/components/subscribe-button/subscribe-button.component';
|
||||
|
||||
@@ -488,19 +489,7 @@ export class SubscriptionsComponent {
|
||||
if (filter !== 'all') {
|
||||
// Normalise les codes courts ('yt') et longs ('youtube') : les imports
|
||||
// mixtes stockent les deux formes.
|
||||
const alias = (p: string | null | undefined): string => {
|
||||
const v = String(p || '').toLowerCase();
|
||||
return (
|
||||
{
|
||||
youtube: 'yt', yt: 'yt',
|
||||
dailymotion: 'dm', dm: 'dm',
|
||||
twitch: 'tw', tw: 'tw',
|
||||
peertube: 'pt', pt: 'pt',
|
||||
odysee: 'od', od: 'od',
|
||||
rumble: 'ru', ru: 'ru',
|
||||
} as Record<string, string>
|
||||
)[v] || v;
|
||||
};
|
||||
const alias = (p: string | null | undefined): string => toShortProviderId(p) || String(p || '').toLowerCase();
|
||||
filtered = filtered.filter((item) => alias(item.channel?.provider) === filter);
|
||||
}
|
||||
if (groupId !== 'all') {
|
||||
|
||||
@@ -85,25 +85,25 @@
|
||||
[attr.aria-pressed]="typePill() === 'all'">
|
||||
Tout <span class="ml-1.5 text-[11px] tabular-nums opacity-60">{{ countLabel(typeCounts().all) }}</span>
|
||||
</button>
|
||||
<button type="button" *ngIf="typeCounts().live > 0 || typePill() === 'live'" (click)="setTypePill('live')"
|
||||
<button type="button" *ngIf="canShowPill('live')" (click)="setTypePill('live')"
|
||||
class="chip inline-flex h-8 shrink-0 items-center whitespace-nowrap rounded-full px-3 text-[13px] font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
|
||||
[ngClass]="typePill() === 'live' ? 'border border-transparent bg-purple-600 text-white' : 'border border-slate-700 bg-slate-800 text-slate-300 hover:bg-slate-700'"
|
||||
[attr.aria-pressed]="typePill() === 'live'">
|
||||
Directs <span class="ml-1.5 text-[11px] tabular-nums opacity-60">{{ countLabel(typeCounts().live) }}</span>
|
||||
</button>
|
||||
<button type="button" *ngIf="typeCounts().video > 0 || typePill() === 'video'" (click)="setTypePill('video')"
|
||||
<button type="button" *ngIf="canShowPill('video')" (click)="setTypePill('video')"
|
||||
class="chip inline-flex h-8 shrink-0 items-center whitespace-nowrap rounded-full px-3 text-[13px] font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
|
||||
[ngClass]="typePill() === 'video' ? 'border border-transparent bg-slate-200 text-slate-900' : 'border border-slate-700 bg-slate-800 text-slate-300 hover:bg-slate-700'"
|
||||
[attr.aria-pressed]="typePill() === 'video'">
|
||||
Vidéos <span class="ml-1.5 text-[11px] tabular-nums opacity-60">{{ countLabel(typeCounts().video) }}</span>
|
||||
</button>
|
||||
<button type="button" *ngIf="typeCounts().clip > 0 || typePill() === 'shorts'" (click)="setTypePill('shorts')"
|
||||
<button type="button" *ngIf="canShowPill('clip')" (click)="setTypePill('shorts')"
|
||||
class="chip inline-flex h-8 shrink-0 items-center whitespace-nowrap rounded-full px-3 text-[13px] font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
|
||||
[ngClass]="typePill() === 'shorts' ? 'border border-transparent bg-purple-600 text-white' : 'border border-slate-700 bg-slate-800 text-slate-300 hover:bg-slate-700'"
|
||||
[attr.aria-pressed]="typePill() === 'shorts'">
|
||||
Clips <span class="ml-1.5 text-[11px] tabular-nums opacity-60">{{ countLabel(typeCounts().clip) }}</span>
|
||||
</button>
|
||||
<button type="button" *ngIf="typeCounts().channel > 0 || typePill() === 'channel'" (click)="setTypePill('channel')"
|
||||
<button type="button" *ngIf="canShowPill('channel')" (click)="setTypePill('channel')"
|
||||
class="chip inline-flex h-8 shrink-0 items-center whitespace-nowrap rounded-full px-3 text-[13px] font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
|
||||
[ngClass]="typePill() === 'channel' ? 'border border-transparent bg-slate-200 text-slate-900' : 'border border-slate-700 bg-slate-800 text-slate-300 hover:bg-slate-700'"
|
||||
[attr.aria-pressed]="typePill() === 'channel'">
|
||||
@@ -280,7 +280,24 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-search-result-grid [videos]="filteredResults()" [loading]="loading()" [loadingMore]="loadingMore()"></app-search-result-grid>
|
||||
<!-- Phase 7.4 : badge discret « source dégradée ». Distinct du bandeau d'erreur
|
||||
ci-dessous : celui-ci concerne CETTE requete, celui-la l'etat amont reel
|
||||
(une source non selectionnee peut etre morte). Masque si rien n'est
|
||||
degrade, et jamais pour un provider volontairement eteint (FF_*). -->
|
||||
@if (degradedSources().length > 0) {
|
||||
<div class="mb-2 flex items-start gap-2 text-[11px] text-amber-200/90" role="status" aria-live="polite" [title]="degradedSourcesTitle()">
|
||||
<svg class="mt-px h-3 w-3 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path stroke-linecap="round" d="M12 9v4M12 17h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" />
|
||||
</svg>
|
||||
<span>
|
||||
{{ degradedSources().length }} source{{ degradedSources().length > 1 ? 's' : '' }} dégradée{{ degradedSources().length > 1 ? 's' : '' }} :
|
||||
<span class="text-amber-200/70">{{ degradedSources()[0].label }}</span><span *ngIf="degradedSources().length > 1"> +{{ degradedSources().length - 1 }}</span>
|
||||
<span class="ml-1 text-amber-200/50">(résultats peut-être incomplets)</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-search-result-grid [videos]="filteredResults()" [loading]="loading()" [loadingMore]="loadingMore()" [providerErrors]="providerErrorList()" [pendingProviders]="pendingProviderList()" [skeletonAspect]="skeletonAspect()"></app-search-result-grid>
|
||||
|
||||
<!-- Standard infinite scroll: shared sentinel + bottom loader + end state -->
|
||||
<div class="flex flex-col items-center justify-center py-6">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, ViewChild, ElementRef } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, DestroyRef, effect, inject, signal, ViewChild, ElementRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router, ParamMap } from '@angular/router';
|
||||
@@ -21,6 +21,9 @@ import {
|
||||
} from '../../app/search/filters';
|
||||
import { UserService, type UserPreferences } from '../../services/user.service';
|
||||
import { PROVIDERS, type ProviderId } from '../../app/core/providers/provider-registry';
|
||||
import { ALL_PROVIDER_IDS, ALL_PROVIDER_IDS_CSV, SHORT_TO_LONG, toShortProviderId } from '../../app/shared/providers/provider-ids';
|
||||
import { PROVIDER_CAPABILITIES } from '../../app/shared/providers/provider-capabilities';
|
||||
import { ProviderHealthService, type ProviderHealth } from '../../app/search/provider-health.service';
|
||||
import { Observable, Subscription } from 'rxjs';
|
||||
|
||||
/** Longueur minimale d'une requete (aligne sur `SearchService.request$` et le backend). */
|
||||
@@ -57,6 +60,8 @@ export class SearchComponent {
|
||||
private title = inject(Title);
|
||||
private unified = inject(SearchService);
|
||||
private users = inject(UserService);
|
||||
private providerHealth = inject(ProviderHealthService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
q = signal<string>('');
|
||||
loading = signal<boolean>(false);
|
||||
@@ -104,13 +109,7 @@ export class SearchComponent {
|
||||
// Active providers derived from `providers` query param or fallback to all available.
|
||||
// Always normalized to short ids ('yt', …) so ordering/badges stay consistent.
|
||||
activeProviders = computed<ProviderId[]>(() => {
|
||||
const norm = (v: unknown): ProviderId | null => {
|
||||
const s = String(v || '').trim();
|
||||
if (!s) return null;
|
||||
if ((PROVIDERS as { id: string }[]).some(pr => pr.id === s)) return s as ProviderId;
|
||||
const hit = Object.entries(this.providerIdMap).find(([, full]) => full === s);
|
||||
return hit ? (hit[0] as ProviderId) : null;
|
||||
};
|
||||
const norm = (v: unknown): ProviderId | null => toShortProviderId(v);
|
||||
const p = this.providersListParam();
|
||||
if (p === 'all' || p == null) {
|
||||
try {
|
||||
@@ -118,26 +117,21 @@ export class SearchComponent {
|
||||
.map(x => norm(x.id))
|
||||
.filter((x): x is ProviderId => !!x);
|
||||
if (ids.length > 0) return ids;
|
||||
return ['yt','dm','tw','pt','od','ru'];
|
||||
return [...ALL_PROVIDER_IDS];
|
||||
}
|
||||
catch { return ['yt','dm','tw','pt','od','ru']; }
|
||||
catch { return [...ALL_PROVIDER_IDS]; }
|
||||
}
|
||||
if (Array.isArray(p) && p.length > 0) {
|
||||
const ids = p.map(norm).filter((x): x is ProviderId => !!x);
|
||||
if (ids.length > 0) return ids;
|
||||
}
|
||||
return ['yt','dm','tw','pt','od','ru'];
|
||||
return [...ALL_PROVIDER_IDS];
|
||||
});
|
||||
|
||||
// Map between Provider and ProviderId
|
||||
private readonly providerIdMap: Record<string, Provider> = {
|
||||
'yt': 'youtube',
|
||||
'dm': 'dailymotion',
|
||||
'tw': 'twitch',
|
||||
'pt': 'peertube',
|
||||
'od': 'odysee',
|
||||
'ru': 'rumble'
|
||||
};
|
||||
// Map between Provider (long name) and ProviderId (short) — derived from the single source.
|
||||
private readonly providerIdMap: Record<ProviderId, Provider> = Object.fromEntries(
|
||||
ALL_PROVIDER_IDS.map(id => [id, SHORT_TO_LONG[id] as unknown as Provider]),
|
||||
) as Record<ProviderId, Provider>;
|
||||
|
||||
// ---- En-tete compact : filtres repliables + fournisseurs en boutons ----
|
||||
/**
|
||||
@@ -412,6 +406,12 @@ export class SearchComponent {
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe({
|
||||
next: (resp: any) => {
|
||||
// Phase 7.6 — agrégation progressive : `partial` = certains providers
|
||||
// répondent encore. On met à jour la grille MAIS on ne conclut rien
|
||||
// (pas de fin atteinte, pas de fin du chargement) tant que le snapshot
|
||||
// final n'est pas arrivé.
|
||||
const partial = resp?.partial === true;
|
||||
|
||||
// Convertir la réponse et concaténer si page > 1 (scroll infini)
|
||||
const incoming: Record<string, any[]> = {};
|
||||
if (resp.groups) {
|
||||
@@ -426,21 +426,38 @@ export class SearchComponent {
|
||||
const merged = this.mergeGroups(this.groups(), incoming);
|
||||
this.groups.set(merged);
|
||||
// If no new items were added, mark end reached
|
||||
const addedCount = Object.values(incoming).reduce((a: number, arr: any[]) => a + (arr?.length || 0), 0);
|
||||
if (addedCount === 0) this.endReached.set(true);
|
||||
if (!partial) {
|
||||
const addedCount = Object.values(incoming).reduce((a: number, arr: any[]) => a + (arr?.length || 0), 0);
|
||||
if (addedCount === 0) this.endReached.set(true);
|
||||
}
|
||||
} else {
|
||||
this.groups.set(incoming);
|
||||
// If first page is empty for all providers, end reached
|
||||
const total = Object.values(incoming).reduce((a: number, arr: any[]) => a + (arr?.length || 0), 0);
|
||||
this.endReached.set(total === 0);
|
||||
// Only the final snapshot decides. Un snapshot partiel a forcément
|
||||
// `total === 0` pour les sources pas encore revenues : conclure « fini »
|
||||
// là-dessus fermerait la page avant la fin.
|
||||
if (!partial) {
|
||||
const total = Object.values(incoming).reduce((a: number, arr: any[]) => a + (arr?.length || 0), 0);
|
||||
this.endReached.set(total === 0);
|
||||
}
|
||||
}
|
||||
if (partial) {
|
||||
// Les sources déjà livrées quittent la liste des squelettes.
|
||||
const settled = new Set<string>((resp.providers || []).map((p: string) => String(p)));
|
||||
this.pendingProviderListUpdate(settled);
|
||||
} else {
|
||||
this.loading.set(false);
|
||||
this.loadingMore.set(false);
|
||||
this.clearLoadingWatchdog();
|
||||
// Phase 7.6 : plus rien en attente -> plus aucun squelette.
|
||||
this.pendingProviderListUpdate(null);
|
||||
}
|
||||
this.loading.set(false);
|
||||
this.loadingMore.set(false);
|
||||
this.clearLoadingWatchdog();
|
||||
this.error.set(null);
|
||||
// Surface per-provider backend failures (e.g. YouTube quota) so
|
||||
// users understand why only some providers return results.
|
||||
this.reflectProviderErrors(resp?.errors);
|
||||
// Phase 7.4 : une fois la réponse reçue, on rafraîchit l'état de santé
|
||||
// des sources (le bandeau ci-dessus ne parle que de CETTE requête).
|
||||
this.refreshProviderHealth();
|
||||
},
|
||||
error: () => {
|
||||
this.groups.set({} as any);
|
||||
@@ -619,6 +636,30 @@ export class SearchComponent {
|
||||
return { live, video, clip, channel, all: (list || []).length };
|
||||
});
|
||||
|
||||
/**
|
||||
* Phase 7.5 — une pastille n'apparaît que si au moins UN fournisseur actif sait
|
||||
* réellement servir ce type. Sans cette garde, on proposait « Directs » sur une
|
||||
* recherche 100 % Dailymotion (qui n'a pas de live) : la pastille menait à un
|
||||
* vide, ce qui se lisait comme un bug. Source unique : `PROVIDER_CAPABILITIES`.
|
||||
*/
|
||||
private hasLiveProvider(): boolean {
|
||||
return this.activeProviders().some((id) => PROVIDER_CAPABILITIES[id]?.live === true);
|
||||
}
|
||||
|
||||
private hasChannelCapableProvider(): boolean {
|
||||
return this.activeProviders().length > 0;
|
||||
}
|
||||
|
||||
canShowPill(kind: 'live' | 'video' | 'clip' | 'channel'): boolean {
|
||||
const counts = this.typeCounts();
|
||||
if (this.typePill() === kind) return true; // toujours atteignable si déjà sélectionné
|
||||
if (kind === 'live') return counts.live > 0 || this.hasLiveProvider();
|
||||
if (kind === 'video') return counts.video > 0;
|
||||
if (kind === 'clip') return counts.clip > 0;
|
||||
if (kind === 'channel') return counts.channel > 0 || this.hasChannelCapableProvider();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Nombre compact pour les compteurs de l'en-tête (1 284 -> « 1,3 k »). */
|
||||
countLabel(n: number): string {
|
||||
return formatCount(n);
|
||||
@@ -752,11 +793,7 @@ export class SearchComponent {
|
||||
|
||||
/** Normalize a provider token (short 'yt' or full 'youtube') to ProviderId. */
|
||||
private toProviderId(v: unknown): ProviderId | null {
|
||||
const s = String(v || '').trim();
|
||||
if (!s) return null;
|
||||
if ((PROVIDERS as { id: string }[]).some(p => p.id === s)) return s as ProviderId;
|
||||
const hit = Object.entries(this.providerIdMap).find(([, full]) => full === s);
|
||||
return hit ? (hit[0] as ProviderId) : null;
|
||||
return toShortProviderId(v);
|
||||
}
|
||||
|
||||
/** Round-robin interleave preserving per-provider order and provider order. */
|
||||
@@ -801,28 +838,118 @@ export class SearchComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 7.4 — échecs par provider, version structurée.
|
||||
* L'ancien `notice` en texte libre collapsait tout en une phrase et exposait
|
||||
* le message technique brut au lecteur. On garde un signal structuré que la
|
||||
* grille rend (une seule bannière, lisible, avec une cause traduite).
|
||||
*/
|
||||
providerErrorList = signal<{ id: string; label: string; message: string }[]>([]);
|
||||
|
||||
/** Show backend per-provider failures as an amber notice (partial results). */
|
||||
private reflectProviderErrors(errors: unknown): void {
|
||||
if (!errors || typeof errors !== 'object') {
|
||||
this.notice.set(null);
|
||||
this.providerErrorList.set([]);
|
||||
return;
|
||||
}
|
||||
const entries = Object.entries(errors as Record<string, unknown>)
|
||||
.filter(([, m]) => typeof m === 'string' && String(m).trim().length > 0);
|
||||
if (entries.length === 0) {
|
||||
this.notice.set(null);
|
||||
this.providerErrorList.set([]);
|
||||
return;
|
||||
}
|
||||
const parts = entries.map(([pid, m]) => {
|
||||
const msg = String(m).replace(/\s+/g, ' ').trim();
|
||||
const short = msg.length > 140 ? msg.slice(0, 140) + '…' : msg;
|
||||
return `${this.providerLabelOf(pid)} : ${short}`;
|
||||
});
|
||||
this.notice.set(
|
||||
`Résultats partiels — certains fournisseurs n'ont pas répondu (${parts.join(' · ')}).`
|
||||
);
|
||||
this.providerErrorList.set(entries.map(([pid, m]) => ({
|
||||
id: pid,
|
||||
label: this.providerLabelOf(pid),
|
||||
message: String(m).replace(/\s+/g, ' ').trim(),
|
||||
})));
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 7.4 — seconde moitié de la tâche : le badge « source dégradée ».
|
||||
*
|
||||
* Le bandeau ci-dessus répond à « cette recherche a-t-elle échoué ? ». Il ne
|
||||
* répond pas à « une source que je n'ai pas sélectionnée est-elle morte ? »,
|
||||
* ce qui est précisément le cas qui fait croire à l'utilisateur qu'une
|
||||
* recherche renvoie moins de résultats qu'avant. `/api/providers/health`
|
||||
* apporte cette information ; on n'affiche un badge que pour les sources
|
||||
* RÉELLEMENT dégradées — un provider éteint par feature flag n'est pas en
|
||||
* panne, et l'annoncer comme tel serait un faux signal d'incident.
|
||||
*/
|
||||
private readonly providerHealthState = signal<ProviderHealth[]>([]);
|
||||
|
||||
/** Sources dégradées, une fois retirées celles volontairement désactivées. */
|
||||
degradedSources = computed<{ id: string; label: string; detail: string }[]>(() =>
|
||||
this.providerHealthState()
|
||||
.filter((h) => h.state === 'degraded')
|
||||
.map((h) => ({
|
||||
id: h.id,
|
||||
label: this.providerLabelOf(h.id),
|
||||
detail: this.degradedDetail(h),
|
||||
})),
|
||||
);
|
||||
|
||||
/** Phrase courte pour l'infobulle du badge. */
|
||||
degradedSourcesTitle = computed(() => {
|
||||
const list = this.degradedSources();
|
||||
if (!list.length) return '';
|
||||
return list.map((d) => `${d.label} : ${d.detail}`).join('\n');
|
||||
});
|
||||
|
||||
private degradedDetail(h: ProviderHealth): string {
|
||||
if (h.consecutiveFailures > 0) return `${h.consecutiveFailures} échec(s) consécutif(s)`;
|
||||
if (h.errorRate > 0) return `taux d'erreur ${Math.round(h.errorRate * 100)} %`;
|
||||
return 'réponse en échec';
|
||||
}
|
||||
|
||||
/**
|
||||
* Rafraîchit l'état de santé APRÈS une recherche, jamais au chargement de la
|
||||
* page : cet endpoint sonde les upstreams, on ne veut pas six requêtes
|
||||
* réseau avant même que l'utilisateur ait tapé quoi que ce soit. Le TTL de 60 s
|
||||
* du service rend les rafraîchissements suivants gratuits.
|
||||
*/
|
||||
private refreshProviderHealth(): void {
|
||||
this.providerHealth.health()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((entries) => this.providerHealthState.set(entries || []));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Phase 7.6 — sources en attente, pour un squelette PAR SOURCE.
|
||||
* Les providers réellement interrogés (ceux de l'URL), pas la liste entière :
|
||||
* afficher « YouTube est en attente » alors qu'il n'a pas été demandé serait
|
||||
* un faux signal.
|
||||
*/
|
||||
private readonly pendingSettled = new Set<string>();
|
||||
|
||||
pendingProviderList = computed<{ id: string; label: string }[]>(() =>
|
||||
this.visibleProviders()
|
||||
.filter((id) => !this.pendingSettled.has(id))
|
||||
.map((id) => ({ id, label: this.providerLabelOf(id) })),
|
||||
);
|
||||
|
||||
/**
|
||||
* Fait sortir une source de la liste des squelettes quand son groupe arrive,
|
||||
* ou vide tout (snapshot final).
|
||||
*/
|
||||
private pendingProviderListUpdate(settled: Set<string> | null): void {
|
||||
if (!settled) { this.pendingSettled.clear(); return; }
|
||||
for (const id of settled) this.pendingSettled.add(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ratio des squelettes. Si l'utilisateur filtre sur les Shorts, les cartes
|
||||
* qui arrivent seront verticales : un squelette 16:9 ferait sauter la page
|
||||
* à l'arrivée des résultats (le défaut qu'on veut justement supprimer).
|
||||
*/
|
||||
skeletonAspect = computed<number | null>(() => {
|
||||
const type = (this.filters() as { type?: string })?.type;
|
||||
return type === 'short' ? 9 / 16 : null;
|
||||
});
|
||||
|
||||
|
||||
formatRelative(dateIso?: string): string {
|
||||
if (!dateIso) return '';
|
||||
@@ -869,7 +996,7 @@ export class SearchComponent {
|
||||
if (Array.isArray(evt.providers)) {
|
||||
queryParams.providers = evt.providers.join(',');
|
||||
} else if (evt.providers === 'all') {
|
||||
queryParams.providers = 'yt,dm,tw,pt,od,ru';
|
||||
queryParams.providers = ALL_PROVIDER_IDS_CSV;
|
||||
}
|
||||
|
||||
// Nettoyer les paramètres avant la navigation
|
||||
|
||||
@@ -14,11 +14,12 @@ import { SubscriptionsService, GROUP_ICON_KEYWORDS } from '../../services/subscr
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { Video } from '../../models/video.model';
|
||||
import { shortsFeedAccepts, isPlayable as isPlayablePolicy, type PlayableContext } from '../../app/shared/utils/section-policy';
|
||||
import { SHORT_TO_LONG, type ProviderId } from '../../app/shared/providers/provider-ids';
|
||||
|
||||
/** Provider long (youtube…) -> id court d'abonnement ('yt'…). */
|
||||
const SUB_PROVIDER_OF: Record<string, 'yt' | 'dm' | 'tw' | 'pt' | 'od' | 'ru'> = {
|
||||
youtube: 'yt', dailymotion: 'dm', twitch: 'tw', peertube: 'pt', odysee: 'od', rumble: 'ru',
|
||||
};
|
||||
const SUB_PROVIDER_OF: Record<string, ProviderId> = Object.fromEntries(
|
||||
Object.entries(SHORT_TO_LONG).map(([shortId, longId]) => [longId, shortId]),
|
||||
) as Record<string, ProviderId>;
|
||||
|
||||
/** Mots vides FR/EN + termes vidéo génériques (extraction de sujets "Pour toi"). */
|
||||
const TOPIC_STOPWORDS = new Set([
|
||||
|
||||
@@ -16,6 +16,7 @@ import { AuthService } from '../../services/auth.service';
|
||||
import { ChannelsService } from '../../services/channels.service';
|
||||
import { SubscriptionsService } from '../../services/subscriptions.service';
|
||||
import type { ChannelMeta, ProviderId } from '../../app/shared/models/channel.model';
|
||||
import { toShortProviderId } from '../../app/shared/providers/provider-ids';
|
||||
import { SubscribeButtonComponent } from '../../app/shared/components/subscribe-button/subscribe-button.component';
|
||||
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||
import { IframeProgressService } from '../../services/iframe-progress.service';
|
||||
@@ -60,13 +61,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
subscribeBusy = signal(false);
|
||||
|
||||
/** Mappe le provider d'affichage ('youtube') vers l'id canal ('yt'). */
|
||||
subscribeProvider = computed<ProviderId | null>(() => {
|
||||
const map: Record<string, ProviderId> = {
|
||||
youtube: 'yt', dailymotion: 'dm', twitch: 'tw',
|
||||
peertube: 'pt', odysee: 'od', rumble: 'ru',
|
||||
};
|
||||
return map[this.provider()] || null;
|
||||
});
|
||||
subscribeProvider = computed<ProviderId | null>(() => toShortProviderId(this.provider()));
|
||||
|
||||
/** ExternalId de la chaîne : meta résolue > détails vidéo > néant. */
|
||||
subscribeExternalId = computed<string | null>(() => {
|
||||
|
||||
@@ -18,10 +18,16 @@ export interface ChannelListState<T> {
|
||||
loadingMore: boolean;
|
||||
error: string | null;
|
||||
fetchedAt: number | null;
|
||||
/**
|
||||
* Phase 3.1 — curseur opaque des APIs paginées par curseur (Twitch Helix).
|
||||
* Doit être renvoyé tel quel à la requête suivante. `nextPage` seul ne suffit
|
||||
* pas : sans curseur, la page 2 est identique à la page 1.
|
||||
*/
|
||||
nextCursor?: string | null;
|
||||
}
|
||||
|
||||
function emptyState<T>(): ChannelListState<T> {
|
||||
return { items: [], nextPage: null, total: null, loading: false, loadingMore: false, error: null, fetchedAt: null };
|
||||
return { items: [], nextPage: null, total: null, loading: false, loadingMore: false, error: null, fetchedAt: null, nextCursor: null };
|
||||
}
|
||||
|
||||
const TTL_MS = 5 * 60 * 1000; // 5 min pour les listes (meta reste 6h dans ChannelsService)
|
||||
@@ -77,7 +83,7 @@ export class ChannelContentService {
|
||||
const current = (this.states().get(key) as ChannelListState<any>) ?? emptyState();
|
||||
if (current.loadingMore || current.loading || current.nextPage == null) return of(false);
|
||||
this.patch(key, { loadingMore: true, error: null });
|
||||
return this.fetchPage(provider, channelId, type, current.nextPage, sort, q).pipe(
|
||||
return this.fetchPage(provider, channelId, type, current.nextPage, sort, q, current.nextCursor).pipe(
|
||||
tap((page) => {
|
||||
const seen = new Set(current.items.map((i: any) => i?.id ?? i?.title));
|
||||
const merged = [...current.items];
|
||||
@@ -88,7 +94,10 @@ export class ChannelContentService {
|
||||
// Si la page ne rapporte rien de nouveau (ex. provider sans vrai
|
||||
// curseur), on stoppe la pagination au lieu de boucler à l'infini.
|
||||
const nextPage = merged.length === current.items.length ? null : page.nextPage;
|
||||
this.patch(key, { items: merged, nextPage, total: page.total ?? current.total, fetchedAt: Date.now() });
|
||||
this.patch(key, {
|
||||
items: merged, nextPage, total: page.total ?? current.total,
|
||||
fetchedAt: Date.now(), nextCursor: page.nextCursor ?? null,
|
||||
});
|
||||
}),
|
||||
map(() => true),
|
||||
catchError(() => {
|
||||
@@ -113,8 +122,11 @@ export class ChannelContentService {
|
||||
const current = (this.states().get(key) as ChannelListState<T>) ?? emptyState<T>();
|
||||
if (!opts?.refresh && (current.loading || this.isFresh(current))) return of(current.items);
|
||||
this.patch(key, { loading: true, error: null });
|
||||
return this.fetchPage(provider, channelId, type, 1, sort, q).pipe(
|
||||
tap((page) => this.patch(key, { items: page.items as T[], nextPage: page.nextPage, total: page.total ?? null, fetchedAt: Date.now() })),
|
||||
return this.fetchPage(provider, channelId, type, 1, sort, q, null).pipe(
|
||||
tap((page) => this.patch(key, {
|
||||
items: page.items as T[], nextPage: page.nextPage, total: page.total ?? null,
|
||||
fetchedAt: Date.now(), nextCursor: page.nextCursor ?? null,
|
||||
})),
|
||||
map((page) => page.items as T[]),
|
||||
catchError(() => {
|
||||
this.patch(key, { error: 'channel_content_failed' });
|
||||
@@ -131,9 +143,10 @@ export class ChannelContentService {
|
||||
page: number,
|
||||
sort: ChannelSort,
|
||||
q: string,
|
||||
cursor?: string | null,
|
||||
): Observable<ChannelContentPage<any>> {
|
||||
const p = this.factory.get(provider);
|
||||
const query = { page, limit: PAGE_SIZE, sort, q: q || undefined };
|
||||
const query = { page, limit: PAGE_SIZE, sort, q: q || undefined, cursor: cursor || undefined };
|
||||
if (type === 'videos') {
|
||||
if (q && p.searchInChannel) return p.searchInChannel(channelId, q, query);
|
||||
return p.getChannelVideos(channelId, query);
|
||||
|
||||
Reference in New Issue
Block a user