feat(providers): phases 7.3/7.4/7.6/8.1 — provenance, health, NDJSON, contrat unique
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:
2026-09-30 07:57:11 -04:00
parent 37681c4f53
commit 665a0f0ebd
87 changed files with 8850 additions and 550 deletions
@@ -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 })),
);
}
}
+57 -38
View File
@@ -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 };
-24
View File
@@ -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;
}
}
+10 -4
View File
@@ -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 };
}
}
+11 -4
View File
@@ -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');
+77
View File
@@ -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`;
}
+20 -10
View File
@@ -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 };
}
}
+16 -5
View File
@@ -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 };
}
}
+10 -3
View File
@@ -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,
+12 -4
View File
@@ -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 };
}
}
+58 -1
View File
@@ -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[]>;
+5 -1
View File
@@ -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;
+62
View File
@@ -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');
+51
View File
@@ -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;
}
}
+96
View File
@@ -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 };
+84 -63
View File
@@ -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
+3 -1
View File
@@ -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;
}
}
+30 -6
View File
@@ -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 {
+42 -2
View File
@@ -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;
}
+158
View File
@@ -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);
}
+89
View File
@@ -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(','));
}
+57
View File
@@ -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');
+97 -1
View File
@@ -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';
+3 -2
View File
@@ -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') {
+22 -5
View File
@@ -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">
+169 -42
View File
@@ -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([
+2 -7
View File
@@ -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>(() => {
+19 -6
View File
@@ -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);