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
-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>();