feat(providers): phases 7.3/7.4/7.6/8.1 — provenance, health, NDJSON, contrat unique
CI / build-and-test (push) Successful in 14m43s
CI / build-and-test (push) Successful in 14m43s
7.3: capturedAt/source au registre + 6 adaptateurs + module provenance.ts + ?debug=1 (search-transport.mjs). 7.4: ProviderHealthService + badge source degradee. 7.6: squelettes par provider + snapshots progressifs + transport NDJSON /api/search. 8.1: ProviderAdapter unifie (search enveloppe + channelContent/channelMeta/capabilities) via getProviderAdapter + test de contrat offline.
This commit is contained in:
@@ -1,24 +0,0 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult, SearchItem, ProviderId } from '../models';
|
||||
|
||||
export abstract class HttpAdapter implements ProviderAdapter {
|
||||
abstract key: ProviderId;
|
||||
abstract label: string;
|
||||
constructor(protected http: HttpClient) {}
|
||||
abstract search(params: ProviderSearchParams, signal: AbortSignal): Promise<SearchResult>;
|
||||
|
||||
protected mapItem(partial: Partial<SearchItem> & { id: string; title: string; watchUrl: string }): SearchItem {
|
||||
return {
|
||||
provider: this.key,
|
||||
channel: undefined,
|
||||
durationSec: undefined,
|
||||
views: undefined,
|
||||
publishedAt: undefined,
|
||||
isLive: false,
|
||||
isShort: false,
|
||||
thumbUrl: undefined,
|
||||
...partial,
|
||||
} as SearchItem;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import { firstValueFrom } from 'rxjs';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
import { withFilterParams } from './filter-params';
|
||||
import { readSearchGroup } from '../search-contract';
|
||||
import { mapProvenance } from './provenance';
|
||||
import { serverRefValue } from '../../shared/providers/channel-ref';
|
||||
|
||||
// TODO: Make SearchResult generic
|
||||
export class DmAdapter implements ProviderAdapter<VideoItem> {
|
||||
@@ -17,8 +20,11 @@ export class DmAdapter implements ProviderAdapter<VideoItem> {
|
||||
// le sait, affinés en post-traitement sinon.
|
||||
httpParams = withFilterParams(httpParams, params.filters);
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||
const providerError = typeof res?.errors?.dm?.message === 'string' ? res.errors.dm.message : undefined;
|
||||
const items: VideoItem[] = (res?.groups?.dm || []).map((it: any) => ({
|
||||
const { items: rawItems, providerError } = readSearchGroup(res, 'dm');
|
||||
const items: VideoItem[] = rawItems.map((it: any) => ({
|
||||
// Phase 7.3 — provenance (instant de collecte + voie) : propriété de la
|
||||
// donnée, pas d'un provider — mapping commun aux 6 adaptateurs.
|
||||
...mapProvenance(it),
|
||||
id: it.id,
|
||||
provider: 'dailymotion',
|
||||
title: it.title,
|
||||
@@ -27,7 +33,7 @@ export class DmAdapter implements ProviderAdapter<VideoItem> {
|
||||
thumbnailUrl: it.thumbnail,
|
||||
viewCount: undefined, // Dailymotion API does not provide view count in search results
|
||||
publishedAt: undefined, // Dailymotion API does not provide published date in search results
|
||||
channelExternalId: it.channelId || undefined,
|
||||
channelExternalId: serverRefValue(it.channelRef, 'dm') || it.channelId || undefined,
|
||||
channelAvatarUrl: it.uploaderAvatar || undefined,
|
||||
// Signaux de type pour le raffinement client des filtres.
|
||||
type: typeof it.type === 'string' ? it.type : undefined,
|
||||
@@ -36,6 +42,6 @@ export class DmAdapter implements ProviderAdapter<VideoItem> {
|
||||
width: typeof it.width === 'number' ? it.width : undefined,
|
||||
height: typeof it.height === 'number' ? it.height : undefined,
|
||||
} as VideoItem));
|
||||
return { items, total: Array.isArray(res?.groups?.dm) ? res.groups.dm.length : 0, providerError };
|
||||
return { items, total: rawItems.length, providerError };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ import { firstValueFrom } from 'rxjs';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
import { withFilterParams } from './filter-params';
|
||||
import { readSearchGroup } from '../search-contract';
|
||||
import { mapProvenance } from './provenance';
|
||||
import { normalizeOdyseeClaim, serverRefValue } from '../../shared/providers/channel-ref';
|
||||
|
||||
export class OdAdapter implements ProviderAdapter<VideoItem> {
|
||||
key = 'od' as const;
|
||||
@@ -16,8 +19,8 @@ export class OdAdapter implements ProviderAdapter<VideoItem> {
|
||||
// le sait, affinés en post-traitement sinon.
|
||||
httpParams = withFilterParams(httpParams, params.filters);
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||
const providerError = typeof res?.errors?.od?.message === 'string' ? res.errors.od.message : undefined;
|
||||
const items: VideoItem[] = (res?.groups?.od || []).map((it: any) => {
|
||||
const { items: rawItems, providerError } = readSearchGroup(res, 'od');
|
||||
const items: VideoItem[] = rawItems.map((it: any) => {
|
||||
let slug: string | undefined;
|
||||
try {
|
||||
const url: string | undefined = it?.url;
|
||||
@@ -26,6 +29,8 @@ export class OdAdapter implements ProviderAdapter<VideoItem> {
|
||||
}
|
||||
} catch {}
|
||||
return {
|
||||
// Phase 7.3 — provenance (instant de collecte + voie), commune aux 6.
|
||||
...mapProvenance(it),
|
||||
id: it.id,
|
||||
provider: 'odysee',
|
||||
title: it.title,
|
||||
@@ -37,7 +42,9 @@ export class OdAdapter implements ProviderAdapter<VideoItem> {
|
||||
slug,
|
||||
// Nom de chaîne LBRY (ex. @MaChaine) : le backend /api/channels/od
|
||||
// sait le résoudre avec ou sans préfixe @.
|
||||
channelExternalId: it.uploaderName || undefined,
|
||||
// Phase 6 : `channelRef` du serveur prime (claim canonique, un seul `@`).
|
||||
channelExternalId: serverRefValue(it?.channelRef, 'od')
|
||||
?? normalizeOdyseeClaim(it?.uploaderName || it?.channel || undefined),
|
||||
// Signaux de type pour le raffinement client des filtres.
|
||||
type: typeof it.type === 'string' ? it.type : undefined,
|
||||
isShort: it.isShort === true ? true : undefined,
|
||||
@@ -46,6 +53,6 @@ export class OdAdapter implements ProviderAdapter<VideoItem> {
|
||||
height: typeof it.height === 'number' ? it.height : undefined,
|
||||
} as VideoItem;
|
||||
});
|
||||
return { items, total: Array.isArray(res?.groups?.od) ? res.groups.od.length : 0, providerError };
|
||||
return { items, total: rawItems.length, providerError };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { mapProvenance, capturedAgoLabel, sourceLabel } from './provenance';
|
||||
|
||||
/**
|
||||
* Phase 7.3 — helpers de provenance côté client.
|
||||
*
|
||||
* Miroir de `server/tests/provenance.test.mjs` : ces helpers sont le dernier
|
||||
* filtre avant l'affichage de la fraîcheur. Leur métier n'est pas de « convertir
|
||||
* une date » mais de REFUSER d'inventer une information — un `capturedAt` absent
|
||||
* doit produire `null` (pas de badge), et une source non reconnue ne doit jamais
|
||||
* être maquillée en voie officielle.
|
||||
*
|
||||
* Run with: npm run test:provenance-front
|
||||
*/
|
||||
|
||||
function assertEqual(actual: unknown, expected: unknown, message: string): void {
|
||||
const a = JSON.stringify(actual);
|
||||
const b = JSON.stringify(expected);
|
||||
if (a !== b) {
|
||||
console.error(`FAIL: ${message}\n actual: ${a}\n expected: ${b}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(`ok: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const NOW = 1700000000000;
|
||||
|
||||
console.log('--- mapProvenance ---');
|
||||
assertEqual(mapProvenance({ capturedAt: NOW, source: 'innertube' }), { capturedAt: NOW, source: 'innertube' }, 'provenance valide propagée');
|
||||
assertEqual(mapProvenance({}), {}, 'rien n\'est inventé sur un objet vide');
|
||||
assertEqual(mapProvenance(null), {}, 'null toléré');
|
||||
assertEqual(mapProvenance('texte'), {}, 'non-objet toléré');
|
||||
// 0, négatif, timestamp EN SECONDES et chaîne : aucun n'est une epoch ms.
|
||||
assertEqual(mapProvenance({ capturedAt: 0 }).capturedAt, undefined, '0 rejeté (afficherait « il y a 0 s »)');
|
||||
assertEqual(mapProvenance({ capturedAt: -5 }).capturedAt, undefined, 'négatif rejeté');
|
||||
assertEqual(mapProvenance({ capturedAt: 1700000000 }).capturedAt, undefined, 'secondes rejetées (sinon « dans 55 ans »)');
|
||||
assertEqual(mapProvenance({ capturedAt: 'hier' }).capturedAt, undefined, 'chaîne non numérique rejetée');
|
||||
assertEqual(mapProvenance({ capturedAt: null }).capturedAt, undefined, 'null rejeté');
|
||||
assertEqual(mapProvenance({ capturedAt: NOW + 0.4 }).capturedAt, NOW, 'arrondi à l\'entier');
|
||||
assertEqual(mapProvenance({ source: ' InnerTube ' }).source, 'innertube', 'source normalisée (casse + espaces)');
|
||||
assertEqual(mapProvenance({ source: 'bidon' }).source, undefined, 'source inconnue NON validée (pas de badge mensonger)');
|
||||
|
||||
console.log('--- capturedAgoLabel ---');
|
||||
assertEqual(capturedAgoLabel(NOW - 3000, NOW), 'à l’instant', 'moins de 10 s');
|
||||
assertEqual(capturedAgoLabel(NOW - 240000, NOW), 'il y a 4 min', 'minutes');
|
||||
assertEqual(capturedAgoLabel(NOW - 3 * 3600000, NOW), 'il y a 3 h', 'heures');
|
||||
assertEqual(capturedAgoLabel(NOW - 2 * 86400000, NOW), 'il y a 2 j', 'jours');
|
||||
assertEqual(capturedAgoLabel(NOW - 60 * 86400000, NOW), 'il y a 2 mois', 'au-delà de 30 j => mois');
|
||||
assertEqual(capturedAgoLabel(undefined, NOW), null, 'pas de date => pas de badge');
|
||||
assertEqual(capturedAgoLabel(0, NOW), null, '0 => pas de badge');
|
||||
assertEqual(capturedAgoLabel(NaN, NOW), null, 'NaN => pas de badge');
|
||||
assertEqual(capturedAgoLabel(NOW + 60000, NOW), 'à l’instant', 'horloge en arrière bornée, jamais « dans -1 min »');
|
||||
|
||||
console.log('--- sourceLabel ---');
|
||||
assertEqual(sourceLabel('innertube'), 'API interne YouTube', 'voie YouTube nommée');
|
||||
assertEqual(sourceLabel('cache'), 'cache', 'cache nommé');
|
||||
assertEqual(sourceLabel('bidon'), 'source inconnue (bidon)', 'inconnue signalée, pas masquée');
|
||||
assertEqual(sourceLabel(undefined), null, 'pas de source => null');
|
||||
assertEqual(sourceLabel(' '), null, 'source blanche => null');
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Phase 7.3 — provenance d'une suggestion lue côté front.
|
||||
*
|
||||
* Petit module à part car les 6 adaptateurs font le même mapping : la
|
||||
* provenance est une propriété de la DONNÉE collectée, pas d'un provider. Le
|
||||
* dupliquer dans les 6 fichiers rouvrirait exactement la classe de bug que la
|
||||
* phase 0 a supprimé (une règle à six implémentations finit par diverger).
|
||||
*
|
||||
* Règle de lecture : un champ absent reste `undefined`. On n'invente ni
|
||||
* `capturedAt: Date.now()` (qui ferait passer une donnée vieille de 4 min pour
|
||||
* fraîche) ni une source par défaut affichable — un ancien serveur ou un cache
|
||||
* sans provenance doivent rester visibles comme tels.
|
||||
*/
|
||||
|
||||
export interface SuggestionProvenance {
|
||||
/** Epoch ms : instant réel de la collecte. `undefined` = inconnu. */
|
||||
capturedAt?: number;
|
||||
/** Voie de collecte : `innertube` / `api` / `scrape` / `cache`… */
|
||||
source?: string;
|
||||
}
|
||||
|
||||
const KNOWN_SOURCES = new Set(['innertube', 'api', 'scrape', 'rest', 'cache', 'resolve', 'graphql']);
|
||||
|
||||
/**
|
||||
* Extrait la provenance d'un item brut, en validant les types.
|
||||
* @param it item de `groups[provider]` (contrat Suggestion v2)
|
||||
*/
|
||||
export function mapProvenance(it: any): SuggestionProvenance {
|
||||
if (!it || typeof it !== 'object') return {};
|
||||
const out: SuggestionProvenance = {};
|
||||
|
||||
const at = Number(it.capturedAt);
|
||||
// Fenêtre epoch-ms plausible : 1e12 ms = 2001-09-09, 4.1e12 ms ≈ 2100.
|
||||
// Le seuil bas rejette aussi les timestamps EN SECONDES (~1.7e9) : un adaptateur
|
||||
// qui oublierait ×1000 afficherait « dans 55 ans ».
|
||||
if (Number.isFinite(at) && at > 1e12 && at < 4.1e12) out.capturedAt = Math.round(at);
|
||||
|
||||
const src = typeof it.source === 'string' ? it.source.trim().toLowerCase() : '';
|
||||
if (src && KNOWN_SOURCES.has(src)) out.source = src;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Source lisible en français pour l'infobulle (jamais de code brut affiché). */
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
innertube: 'API interne YouTube',
|
||||
api: 'API officielle',
|
||||
scrape: 'analyse de page',
|
||||
rest: 'API REST',
|
||||
cache: 'cache',
|
||||
resolve: 'resolve LBRY',
|
||||
graphql: 'API GraphQL',
|
||||
};
|
||||
|
||||
export function sourceLabel(source?: string | null): string | null {
|
||||
const src = String(source || '').trim().toLowerCase();
|
||||
if (!src) return null;
|
||||
return SOURCE_LABELS[src] || `source inconnue (${src})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* « capturé il y a 4 min » — la fraîcheur que l'utilisateur veut voir.
|
||||
* @param capturedAt epoch ms
|
||||
* @param now référence (injectable pour les tests)
|
||||
*/
|
||||
export function capturedAgoLabel(capturedAt?: number | null, now: number = Date.now()): string | null {
|
||||
const at = Number(capturedAt);
|
||||
if (!Number.isFinite(at) || at <= 0) return null;
|
||||
const seconds = Math.max(0, Math.round((now - at) / 1000));
|
||||
if (seconds < 10) return 'à l’instant';
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `il y a ${minutes} min`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) return `il y a ${hours} h`;
|
||||
const days = Math.round(hours / 24);
|
||||
return days <= 30 ? `il y a ${days} j` : `il y a ${Math.round(days / 30)} mois`;
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import { firstValueFrom } from 'rxjs';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
import { withFilterParams } from './filter-params';
|
||||
import { readSearchGroup } from '../search-contract';
|
||||
import { mapProvenance } from './provenance';
|
||||
import { buildPeerTubeComposite, serverRefValue } from '../../shared/providers/channel-ref';
|
||||
|
||||
export class PtAdapter implements ProviderAdapter<VideoItem> {
|
||||
key = 'pt' as const;
|
||||
@@ -16,24 +19,31 @@ export class PtAdapter implements ProviderAdapter<VideoItem> {
|
||||
// le sait, affinés en post-traitement sinon.
|
||||
httpParams = withFilterParams(httpParams, params.filters);
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||
const providerError = typeof res?.errors?.pt?.message === 'string' ? res.errors.pt.message : undefined;
|
||||
const items: VideoItem[] = (res?.groups?.pt || []).map((it: any) => {
|
||||
const { items: rawItems, providerError } = readSearchGroup(res, 'pt');
|
||||
const items: VideoItem[] = rawItems.map((it: any) => {
|
||||
// PeerTube identifie une chaîne par `instance|channel` : l'instance est
|
||||
// dérivée de l'URL vidéo, le nom de chaîne de `channelId`.
|
||||
let channelExternalId: string | undefined;
|
||||
try {
|
||||
const host = it?.url ? new URL(String(it.url)).hostname : '';
|
||||
if (host && it?.channelId) channelExternalId = `${host}|${it.channelId}`;
|
||||
} catch {}
|
||||
// Phase 6 : si le serveur a envoyé `channelRef`, sa valeur canonique
|
||||
// prime — sinon on reconstruit avec la règle historique.
|
||||
let channelExternalId: string | undefined =
|
||||
serverRefValue(it?.channelRef, 'pt') ?? buildPeerTubeComposite(it?.url, it?.channelId);
|
||||
return {
|
||||
// Phase 7.3 — provenance (instant de collecte + voie), commune aux 6.
|
||||
...mapProvenance(it),
|
||||
id: it.id,
|
||||
provider: 'peertube',
|
||||
title: it.title,
|
||||
channelName: it.uploaderName,
|
||||
durationSec: it.duration,
|
||||
thumbnailUrl: it.thumbnail,
|
||||
viewCount: undefined,
|
||||
publishedAt: undefined,
|
||||
// `viewCount: undefined` en dur alors que le serveur envoie déjà le
|
||||
// nombre de vues : les compteurs PeerTube n'arrivaient jamais dans l'UI
|
||||
// (trouvé en gelant la sortie réelle, phase 8.4). On lit le champ
|
||||
// canonique `views` et on garde `viewCount` en repli.
|
||||
viewCount: typeof it.views === 'number' ? it.views
|
||||
: (typeof it.viewCount === 'number' ? it.viewCount : undefined),
|
||||
// Le serveur envoie `publishedAt` (ISO) quand l'instance l'expose.
|
||||
publishedAt: typeof it.publishedAt === 'string' ? it.publishedAt : undefined,
|
||||
channelExternalId,
|
||||
// Signaux de type pour le raffinement client des filtres.
|
||||
type: typeof it.type === 'string' ? it.type : undefined,
|
||||
@@ -43,6 +53,6 @@ export class PtAdapter implements ProviderAdapter<VideoItem> {
|
||||
height: typeof it.height === 'number' ? it.height : undefined,
|
||||
} as VideoItem;
|
||||
});
|
||||
return { items, total: Array.isArray(res?.groups?.pt) ? res.groups.pt.length : 0, providerError };
|
||||
return { items, total: rawItems.length, providerError };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ import { firstValueFrom } from 'rxjs';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
import { withFilterParams } from './filter-params';
|
||||
import { readSearchGroup } from '../search-contract';
|
||||
import { mapProvenance } from './provenance';
|
||||
import { serverRefValue } from '../../shared/providers/channel-ref';
|
||||
|
||||
export class RuAdapter implements ProviderAdapter<VideoItem> {
|
||||
key = 'ru' as const;
|
||||
@@ -16,16 +19,24 @@ export class RuAdapter implements ProviderAdapter<VideoItem> {
|
||||
// le sait, affinés en post-traitement sinon.
|
||||
httpParams = withFilterParams(httpParams, params.filters);
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||
const providerError = typeof res?.errors?.ru?.message === 'string' ? res.errors.ru.message : undefined;
|
||||
const items: VideoItem[] = (res?.groups?.ru || []).map((it: any) => ({
|
||||
const { items: rawItems, providerError } = readSearchGroup(res, 'ru');
|
||||
const items: VideoItem[] = rawItems.map((it: any) => ({
|
||||
// Phase 7.3 — provenance (instant de collecte + voie) : propriété de la
|
||||
// donnée, pas d'un provider — mapping commun aux 6 adaptateurs.
|
||||
...mapProvenance(it),
|
||||
id: it.id,
|
||||
provider: 'rumble',
|
||||
title: it.title,
|
||||
channelName: it.uploaderName,
|
||||
durationSec: it.duration,
|
||||
thumbnailUrl: it.thumbnail,
|
||||
viewCount: typeof it.views === 'number' ? it.views : undefined,
|
||||
publishedAt: it.publishedAt,
|
||||
viewCount: typeof it.views === 'number' ? it.views : undefined,
|
||||
viewers: typeof it.viewers === 'number' ? it.viewers : undefined,
|
||||
publishedAt: it.publishedAt,
|
||||
// Phase 3.5 a extrait le slug `/c/<slug>` côté serveur, phase 6 le
|
||||
// normalise en `channelRef` — mais l'adaptateur le jetait : le nom de la
|
||||
// chaîne n'était donc pas cliquable vers la page chaîne.
|
||||
channelExternalId: serverRefValue(it.channelRef, 'ru') || it.channelExternalId || undefined,
|
||||
// Signaux de type (Shorts / direct) pour le raffinement client.
|
||||
type: typeof it.type === 'string' ? it.type : undefined,
|
||||
isShort: it.isShort === true ? true : undefined,
|
||||
@@ -33,6 +44,6 @@ export class RuAdapter implements ProviderAdapter<VideoItem> {
|
||||
width: typeof it.width === 'number' ? it.width : undefined,
|
||||
height: typeof it.height === 'number' ? it.height : undefined,
|
||||
} as VideoItem));
|
||||
return { items, total: Array.isArray(res?.groups?.ru) ? res.groups.ru.length : 0, providerError };
|
||||
return { items, total: rawItems.length, providerError };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ import { firstValueFrom } from 'rxjs';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
import { withFilterParams } from './filter-params';
|
||||
import { readSearchGroup } from '../search-contract';
|
||||
import { mapProvenance } from './provenance';
|
||||
import { serverRefValue } from '../../shared/providers/channel-ref';
|
||||
|
||||
export class TwAdapter implements ProviderAdapter<VideoItem> {
|
||||
key = 'tw' as const;
|
||||
@@ -16,18 +19,20 @@ export class TwAdapter implements ProviderAdapter<VideoItem> {
|
||||
// le sait, affinés en post-traitement sinon.
|
||||
httpParams = withFilterParams(httpParams, params.filters);
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||
const providerError = typeof res?.errors?.tw?.message === 'string' ? res.errors.tw.message : undefined;
|
||||
const items: VideoItem[] = (res?.groups?.tw || []).map((it: any) => {
|
||||
const { items: rawItems, providerError } = readSearchGroup(res, 'tw');
|
||||
const items: VideoItem[] = rawItems.map((it: any) => {
|
||||
// /api/search (tw) renvoie un mixte façon directory : lives, VODs, clips, chaînes.
|
||||
// Seuls les items 'live' / 'channel' utilisent l'embed ?channel=<login> ;
|
||||
// VODs (id numérique) et clips (slug) ont leur propre lecture.
|
||||
const login: string | undefined =
|
||||
it.channelExternalId || it.channelHandle || undefined;
|
||||
serverRefValue(it.channelRef, 'tw') || it.channelExternalId || it.channelHandle || undefined;
|
||||
const kind = String(it?.type || '').toLowerCase();
|
||||
const clipKind = String(it?.kind || '').toLowerCase();
|
||||
const isClip = clipKind === 'clip';
|
||||
const isChannelLike = kind === 'channel' || kind === 'live';
|
||||
return {
|
||||
// Phase 7.3 — provenance (instant de collecte + voie), commune aux 6.
|
||||
...mapProvenance(it),
|
||||
id: it.id,
|
||||
provider: 'twitch',
|
||||
title: it.title,
|
||||
@@ -35,6 +40,8 @@ export class TwAdapter implements ProviderAdapter<VideoItem> {
|
||||
durationSec: typeof it.duration === 'number' ? it.duration : undefined,
|
||||
thumbnailUrl: it.thumbnail,
|
||||
viewCount: typeof it.views === 'number' ? it.views : undefined,
|
||||
// Phase 3.8 — `viewer_count` remonte dans `viewers`, pas dans `views`.
|
||||
viewers: typeof it.viewers === 'number' ? it.viewers : undefined,
|
||||
publishedAt: it.publishedAt,
|
||||
channelExternalId: login,
|
||||
channelAvatarUrl: it.uploaderAvatar || undefined,
|
||||
|
||||
@@ -3,6 +3,9 @@ import { firstValueFrom } from 'rxjs';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
import { withFilterParams } from './filter-params';
|
||||
import { readSearchGroup } from '../search-contract';
|
||||
import { mapProvenance } from './provenance';
|
||||
import { serverRefValue } from '../../shared/providers/channel-ref';
|
||||
|
||||
export class YtAdapter implements ProviderAdapter<VideoItem> {
|
||||
key = 'yt' as const;
|
||||
@@ -16,8 +19,11 @@ export class YtAdapter implements ProviderAdapter<VideoItem> {
|
||||
// le sait, affinés en post-traitement sinon.
|
||||
httpParams = withFilterParams(httpParams, params.filters);
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||
const providerError = typeof res?.errors?.yt?.message === 'string' ? res.errors.yt.message : undefined;
|
||||
const items: VideoItem[] = (res?.groups?.yt || []).map((it: any) => ({
|
||||
const { items: rawItems, providerError } = readSearchGroup(res, 'yt');
|
||||
const items: VideoItem[] = rawItems.map((it: any) => ({
|
||||
// Phase 7.3 — provenance (instant de collecte + voie) : propriété de la
|
||||
// donnée, pas d'un provider — mapping commun aux 6 adaptateurs.
|
||||
...mapProvenance(it),
|
||||
id: it.id,
|
||||
provider: 'youtube',
|
||||
title: it.title,
|
||||
@@ -25,10 +31,12 @@ export class YtAdapter implements ProviderAdapter<VideoItem> {
|
||||
durationSec: it.duration,
|
||||
thumbnailUrl: it.thumbnail,
|
||||
viewCount: typeof it.views === 'number' ? it.views : (typeof it.viewCount === 'number' ? it.viewCount : undefined),
|
||||
viewers: typeof it.viewers === 'number' ? it.viewers : undefined,
|
||||
publishedAt: it.publishedAt,
|
||||
// ID chaîne conservé : rend le logo/nom cliquables vers /channel
|
||||
// (l'avatar est résolu via /api/channels, l'API search n'en fournit pas).
|
||||
channelExternalId: it.channelExternalId || it.channelId || undefined,
|
||||
// Phase 6 : `channelRef` du serveur prime sur la chaîne de repli.
|
||||
channelExternalId: serverRefValue(it.channelRef, 'yt') || it.channelExternalId || it.channelId || undefined,
|
||||
// Signaux de type : indispensables au raffinement client des filtres
|
||||
// (Shorts / direct / chaîne) quand le serveur les a déjà appliqués.
|
||||
type: typeof it.type === 'string' ? it.type : undefined,
|
||||
@@ -38,6 +46,6 @@ export class YtAdapter implements ProviderAdapter<VideoItem> {
|
||||
width: typeof it.width === 'number' ? it.width : undefined,
|
||||
height: typeof it.height === 'number' ? it.height : undefined,
|
||||
} as VideoItem));
|
||||
return { items, total: Array.isArray(res?.groups?.yt) ? res.groups.yt.length : 0, providerError };
|
||||
return { items, total: rawItems.length, providerError };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,74 @@
|
||||
export type ProviderId = 'yt' | 'dm' | 'tw' | 'pt' | 'od' | 'ru';
|
||||
import type { ProviderId } from '../shared/providers/provider-ids';
|
||||
import type { ChannelRef } from '../shared/providers/channel-ref';
|
||||
|
||||
export type { ProviderId, ChannelRef };
|
||||
|
||||
/**
|
||||
* Contrat `Suggestion` v2 — doit rester aligné avec le JSDoc de
|
||||
* `server/providers/registry.mjs` (vérifié par `npm run test:contract`).
|
||||
*
|
||||
* v1 (inchangé, rétrocompatible) : title, id, duration, isShort, thumbnail,
|
||||
* uploaderName, url, type.
|
||||
* v2 (additif) : compteurs, dates ISO, identité de chaîne, avatars, et flags.
|
||||
*
|
||||
* Règle : un champ absent est `undefined`, jamais `0` ni `''`. Une durée nulle
|
||||
* n'existe pas — elle casse les règles de classification (voir §2.2 du plan).
|
||||
*/
|
||||
export interface SuggestionItemV1 {
|
||||
title: string;
|
||||
id: string;
|
||||
/** Secondes, strictement > 0. `undefined` = durée inconnue. */
|
||||
duration?: number;
|
||||
isShort?: boolean;
|
||||
thumbnail?: string;
|
||||
uploaderName?: string;
|
||||
url?: string;
|
||||
type?: string;
|
||||
|
||||
// --- v2 ---
|
||||
/** Vues (> 0). */
|
||||
views?: number;
|
||||
/**
|
||||
* Phase 3.8 / 7.3 — spectateurs EN DIRECT (Twitch `viewer_count`).
|
||||
* Distinct de `views` : ne jamais afficher comme un compteur de vues.
|
||||
*/
|
||||
viewers?: number;
|
||||
likes?: number;
|
||||
dislikes?: number;
|
||||
/** ISO 8601. Jamais « il y a 2 mois ». */
|
||||
publishedAt?: string;
|
||||
channelId?: string;
|
||||
channelExternalId?: string;
|
||||
channelHandle?: string;
|
||||
channelUrl?: string;
|
||||
uploaderAvatar?: string;
|
||||
channelAvatarUrl?: string;
|
||||
slug?: string;
|
||||
channel?: string;
|
||||
/** 'vod' | 'live' | 'clip' (Twitch). */
|
||||
kind?: string;
|
||||
game?: string;
|
||||
/** BCP-47. */
|
||||
language?: string;
|
||||
hasSubtitles?: boolean;
|
||||
isLive?: boolean;
|
||||
embeddable?: boolean;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
/** Libellés bruts, pour afficher « valeur approchée » plutôt que « — ». */
|
||||
durationRaw?: string;
|
||||
viewCountRaw?: string;
|
||||
/**
|
||||
* Phase 6 — identité de chaîne normalisée. `channelExternalId` reste lu en
|
||||
* repli (transition non cassante) mais `channelRef` est la forme canonique :
|
||||
* `scheme` dit comment lire `value`.
|
||||
*/
|
||||
channelRef?: ChannelRef;
|
||||
}
|
||||
|
||||
export interface SearchResponseV1 {
|
||||
/** Version du contrat `Suggestion` (2 depuis la phase 0). */
|
||||
v?: number;
|
||||
q: string;
|
||||
providers: ProviderId[];
|
||||
groups: Record<ProviderId, SuggestionItemV1[]>;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export type ProviderId = 'yt'|'dm'|'tw'|'pt'|'od'|'ru';
|
||||
import type { ProviderId } from '../shared/providers/provider-ids';
|
||||
import type { SearchFilters } from './filters';
|
||||
|
||||
export type { ProviderId };
|
||||
|
||||
export interface SearchItem {
|
||||
id: string;
|
||||
provider: ProviderId;
|
||||
@@ -8,6 +10,8 @@ export interface SearchItem {
|
||||
channel?: string;
|
||||
durationSec?: number;
|
||||
views?: number;
|
||||
/** Phase 3.8 — spectateurs EN DIRECT (Twitch). Pas un compteur de vues. */
|
||||
viewers?: number;
|
||||
publishedAt?: string; // ISO
|
||||
isLive?: boolean;
|
||||
isShort?: boolean;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Phase 7.4 — qualification de l'état de santé des sources.
|
||||
*
|
||||
* Volontairement dans un module SANS import Angular : c'est de la logique pure,
|
||||
* testable avec le runner `ts-node` du repo (un fichier qui importe `HttpClient`
|
||||
* exige le compilateur JIT, absent en test node). Le service ne fait que
|
||||
* l'appel HTTP.
|
||||
*/
|
||||
|
||||
export type ProviderHealthState = 'ok' | 'degraded' | 'disabled';
|
||||
|
||||
export interface ProviderHealth {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
/**
|
||||
* `disabled` = provider volontairement éteint par `FF_<PROVIDER>`.
|
||||
* Ce n'est PAS une panne : l'afficher comme « source dégradée » ferait
|
||||
* accuser l'infrastructure alors que l'administrateur l'a coupé exprès, et
|
||||
* l'utilisateur verrait un avertissement permanent sur une recherche saine.
|
||||
*/
|
||||
disabled: boolean;
|
||||
latencyMs: number;
|
||||
lastError: string | null;
|
||||
consecutiveFailures: number;
|
||||
errorRate: number;
|
||||
state: ProviderHealthState;
|
||||
}
|
||||
|
||||
/** Toute réponse absente ou illisible vaut « dégradé », jamais « sain ». */
|
||||
function stateOf(ok: boolean, disabled: boolean): ProviderHealthState {
|
||||
if (disabled) return 'disabled';
|
||||
return ok ? 'ok' : 'degraded';
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforme la réponse de `/api/providers/health` en liste qualifiée.
|
||||
*
|
||||
* Volontairement défensive : le backend peut évoluer, et une entrée inconnue
|
||||
* doit dégrader l'affichage (badge ambre) plutôt que faire disparaître le
|
||||
* signal — l'erreur de communication est plus grave qu'un provider muet.
|
||||
*/
|
||||
export function normalizeHealth(providers: Record<string, any> | null | undefined): ProviderHealth[] {
|
||||
if (!providers || typeof providers !== 'object') return [];
|
||||
return Object.entries(providers).map(([id, raw]) => {
|
||||
const p = raw as Partial<{
|
||||
ok: boolean; disabled: boolean; latencyMs: number; lastError: string | null;
|
||||
consecutiveFailures: number; errorRate: number;
|
||||
}>;
|
||||
const disabled = p?.disabled === true;
|
||||
const ok = p?.ok === true;
|
||||
return {
|
||||
id,
|
||||
ok,
|
||||
disabled,
|
||||
latencyMs: Number(p?.latencyMs) || 0,
|
||||
lastError: p?.lastError == null ? null : String(p.lastError),
|
||||
consecutiveFailures: Number(p?.consecutiveFailures) || 0,
|
||||
errorRate: Number(p?.errorRate) || 0,
|
||||
state: stateOf(ok, disabled),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { normalizeHealth } from './provider-health.model';
|
||||
|
||||
/**
|
||||
* Phase 7.4 — qualification de l'état de santé des sources.
|
||||
*
|
||||
* Le point non négociable de ce fichier : un provider ÉTEINT par feature flag
|
||||
* n'est pas une source dégradée. Si on le compterait, l'app afficherait un
|
||||
* bandeau d'incident alors que l'administrateur a coupé la source exprès — et
|
||||
* l'utilisateur en débranche lui-même verrait un avertissement permanent sur
|
||||
* une recherche parfaitement saine.
|
||||
*
|
||||
* Run with: npm run test:provider-health
|
||||
*/
|
||||
|
||||
function assertEqual(actual: unknown, expected: unknown, message: string): void {
|
||||
const a = JSON.stringify(actual);
|
||||
const b = JSON.stringify(expected);
|
||||
if (a !== b) {
|
||||
console.error(`FAIL: ${message}\n actual: ${a}\n expected: ${b}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(`ok: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const base = { ok: true, disabled: false, latencyMs: 120, lastError: null, consecutiveFailures: 0, errorRate: 0 };
|
||||
|
||||
console.log('--- état de santé ---');
|
||||
assertEqual(
|
||||
normalizeHealth({ yt: base })[0].state,
|
||||
'ok',
|
||||
'provider joignable => ok',
|
||||
);
|
||||
assertEqual(
|
||||
normalizeHealth({ ru: { ...base, ok: false, lastError: 'HTTP 503' } })[0].state,
|
||||
'degraded',
|
||||
'échec amont => dégradé',
|
||||
);
|
||||
assertEqual(
|
||||
normalizeHealth({ ru: { ...base, ok: false, disabled: true, lastError: 'disabled_by_ff' } })[0].state,
|
||||
'disabled',
|
||||
'provider éteint par FF => désactivé, PAS dégradé',
|
||||
);
|
||||
assertEqual(
|
||||
normalizeHealth({ ru: { ...base, ok: false, disabled: true } })[0].state,
|
||||
'disabled',
|
||||
'un provider éteint reste « désactivé » même si une sonde l\'a vu échouer',
|
||||
);
|
||||
|
||||
console.log('--- données exploitables ---');
|
||||
const h = normalizeHealth({ ru: { ...base, ok: false, latencyMs: '900', consecutiveFailures: '3', errorRate: 0.25 } })[0];
|
||||
assertEqual(h.id, 'ru', 'identifiant conservé');
|
||||
assertEqual(h.latencyMs, 900, 'latence stringée => nombre');
|
||||
assertEqual(h.consecutiveFailures, 3, 'échecs consécutifs stringés => nombre');
|
||||
assertEqual(h.errorRate, 0.25, 'taux d\'erreur conservé');
|
||||
assertEqual(normalizeHealth({ ru: { ...base, lastError: null } })[0].lastError, null, 'absence d\'erreur => null, pas "undefined"');
|
||||
assertEqual(normalizeHealth({ ru: { ...base, lastError: 'boom' } })[0].lastError, 'boom', 'message d\'erreur conservé');
|
||||
assertEqual(normalizeHealth({ ru: {} })[0].state, 'degraded', 'réponse vide => dégradé (pas un faux « tout va bien »)');
|
||||
assertEqual(normalizeHealth({ ru: null })[0].state, 'degraded', 'null => dégradé');
|
||||
assertEqual(normalizeHealth(null), [], 'payload absent => aucun badge');
|
||||
assertEqual(normalizeHealth(undefined), [], 'undefined => aucun badge');
|
||||
assertEqual(normalizeHealth({}), [], 'objet vide => aucun badge');
|
||||
|
||||
console.log('--- volumétrie ---');
|
||||
const many = normalizeHealth(Object.fromEntries(
|
||||
['yt', 'dm', 'tw', 'pt', 'od', 'ru'].map((id, i) => [id, i % 2 ? { ...base, ok: false } : base]),
|
||||
));
|
||||
assertEqual(many.length, 6, 'les 6 sources sont qualifiées');
|
||||
assertEqual(many.filter((x) => x.state === 'degraded').map((x) => x.id), ['dm', 'pt', 'ru'], 'sources dégradées identifiées');
|
||||
assertEqual(many.filter((x) => x.state === 'ok').map((x) => x.id), ['yt', 'tw', 'od'], 'sources saines identifiées');
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { catchError, map, shareReplay } from 'rxjs/operators';
|
||||
import { normalizeHealth, type ProviderHealth } from './provider-health.model';
|
||||
|
||||
export type { ProviderHealth, ProviderHealthState } from './provider-health.model';
|
||||
|
||||
/**
|
||||
* Phase 7.4 — santé des sources, pour le badge discret « source dégradée ».
|
||||
*
|
||||
* Ce que `/api/search` ne sait PAS dire : une source peut être morte alors que
|
||||
* la requête courante a « réussi » (résultats servis depuis le cache, ou
|
||||
* provider non sélectionné). Le bandeau d'erreur de la grille ne couvre que les
|
||||
* échecs de la requête affichée ; ce service couvre l'état amont réel.
|
||||
*
|
||||
* Deux garde-fous, tous deux appris à l'usage :
|
||||
* - TTL client de 60 s, aligné sur le cache serveur (`PROVIDER_HEALTH_TTL_MS`) :
|
||||
* cet endpoint sonde RÉELLEMENT les upstreams, l'appeler à chaque rendu (ou
|
||||
* à chaque frappe) transformerait un panneau d'information en martèlement ;
|
||||
* - `shareReplay(1)` : un abonnement tardif reçoit la valeur déjà récupérée au
|
||||
* lieu de repartir en réseau.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProviderHealthService {
|
||||
private http = inject(HttpClient);
|
||||
private readonly ttlMs = 60_000;
|
||||
private cached: { at: number; value: Observable<ProviderHealth[]> } | null = null;
|
||||
|
||||
/** État courant, avec déduplication temporelle. */
|
||||
health(): Observable<ProviderHealth[]> {
|
||||
const now = Date.now();
|
||||
if (this.cached && now - this.cached.at < this.ttlMs) return this.cached.value;
|
||||
|
||||
const request = this.http.get<{ providers: Record<string, any> }>('/api/providers/health').pipe(
|
||||
map((res) => normalizeHealth(res?.providers)),
|
||||
// Un endpoint de santé qui tombe ne doit pas casser la recherche : on
|
||||
// renvoie une liste vide, ce qui fait disparaître le badge au lieu
|
||||
// d'afficher un « tout va bien » qui serait faux.
|
||||
catchError(() => of([] as ProviderHealth[])),
|
||||
shareReplay(1),
|
||||
);
|
||||
this.cached = { at: now, value: request };
|
||||
return request;
|
||||
}
|
||||
|
||||
/** Force la relecture au prochain appel (bouton « réessayer »). */
|
||||
invalidate(): void {
|
||||
this.cached = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Phase 8.2 — lecture stricte du contrat `Suggestion` v2 côté front.
|
||||
*
|
||||
* Le serveur Stampille sa réponse avec `v: SUGGESTION_CONTRACT_VERSION`. Un
|
||||
* front qui lit `groups` sans vérifier `v` ne peut pas distinguer :
|
||||
* - un serveur ancien (contrat v1 : pas de `views`, pas de `publishedAt`) ;
|
||||
* - un serveur buggé ou un proxy qui renvoie autre chose.
|
||||
* Dans les deux cas les champs attendues sont `undefined` et l'UI affiche des
|
||||
* zéros ou des « — » **sans explication** — exactement la classe de bug que la
|
||||
* phase 0 a introduite pour la traquer.
|
||||
*
|
||||
* Comportement retenu : on ne casse pas l'utilisateur qui tourne un front
|
||||
* plus récent qu'un serveur (déploiement partiel, cache de service worker). Le
|
||||
* chemin v1 reste, mais il est **averti une seule fois par process** — un
|
||||
* avertissement par recherche noierait la console et serait ignoré.
|
||||
*/
|
||||
|
||||
import type { ProviderId } from '../shared/providers/provider-ids';
|
||||
import type { SearchResponseV1, SuggestionItemV1 } from './api.v1';
|
||||
|
||||
/** Miroir de `SUGGESTION_CONTRACT_VERSION` (`server/providers/registry.mjs`). */
|
||||
export const EXPECTED_CONTRACT_VERSION = 2;
|
||||
|
||||
/** Une seule ligne par process, sinon chaque frappe au clavier en produit une. */
|
||||
const warned = new Set<string>();
|
||||
let contractWarning: string | null = null;
|
||||
|
||||
export interface ReadGroupResult {
|
||||
items: any[];
|
||||
/** Raison d'échec déjà remontée par le serveur pour CE provider. */
|
||||
providerError?: string;
|
||||
/** Renseigné si la réponse n'est pas en v2 (chemin de compatibilité). */
|
||||
contractVersion: number | null;
|
||||
/** `true` si le serveur a répondu en v1 (ou sans version). */
|
||||
legacyContract: boolean;
|
||||
}
|
||||
|
||||
function warnOnce(key: string, message: string): void {
|
||||
if (warned.has(key)) return;
|
||||
warned.add(key);
|
||||
console.warn(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le groupe d'un provider et applique le contrôle de version.
|
||||
*
|
||||
* @param res réponse de `/api/search` (ou `undefined` si le transport a échoué)
|
||||
* @param providerId id court
|
||||
*/
|
||||
export function readSearchGroup(res: any, providerId: ProviderId): ReadGroupResult {
|
||||
const version = typeof res?.v === 'number' ? (res.v as number) : null;
|
||||
const legacyContract = version !== EXPECTED_CONTRACT_VERSION;
|
||||
|
||||
if (legacyContract) {
|
||||
warnOnce(
|
||||
`contract:${version}`,
|
||||
`[search] Réponse /api/search en contrat v${version ?? '?'} alors que `
|
||||
+ `l'application attend v${EXPECTED_CONTRACT_VERSION} : les champs optionnels `
|
||||
+ '(views, publishedAt, channelRef…) seront absents. Vérifiez que le serveur '
|
||||
+ 'a bien été redéployé.',
|
||||
);
|
||||
contractWarning = version === null ? 'missing_version' : `v${version}`;
|
||||
} else {
|
||||
contractWarning = null;
|
||||
}
|
||||
|
||||
const group = res?.groups?.[providerId];
|
||||
const items = Array.isArray(group) ? group : [];
|
||||
const rawError = res?.errors?.[providerId];
|
||||
const providerError = typeof rawError?.message === 'string'
|
||||
? rawError.message
|
||||
: (typeof rawError === 'string' && rawError ? rawError : undefined);
|
||||
|
||||
// Un provider désactivé par feature flag (phase 8.3) n'est pas une panne :
|
||||
// `disabled_by_ff` est traité comme un état, pas comme une erreur technique.
|
||||
const isDisabled = rawError?.code === 'disabled_by_ff';
|
||||
return {
|
||||
items,
|
||||
providerError: isDisabled ? undefined : providerError,
|
||||
contractVersion: version,
|
||||
legacyContract,
|
||||
};
|
||||
}
|
||||
|
||||
/** Dernier avertissement de contrat émis (pour l'écran de debug). */
|
||||
export function getContractWarning(): string | null {
|
||||
return contractWarning;
|
||||
}
|
||||
|
||||
/** Réinitialise l'état « déjà averti » — réservé aux tests. */
|
||||
export function resetContractWarningsForTests(): void {
|
||||
warned.clear();
|
||||
contractWarning = null;
|
||||
}
|
||||
|
||||
export type { SearchResponseV1, SuggestionItemV1 };
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { BehaviorSubject, combineLatest, distinctUntilChanged, debounceTime, map, switchMap, filter, shareReplay, from, of } from 'rxjs';
|
||||
import type { ProviderId } from '../core/providers/provider-registry';
|
||||
import { mergeMap, scan, take } from 'rxjs/operators';
|
||||
import { ALL_PROVIDER_IDS, type ProviderId } from '../shared/providers/provider-ids';
|
||||
import type { SuggestionItemV1, SearchResponseV1 } from './api.v1';
|
||||
import { YtAdapter } from './adapters/yt';
|
||||
import { DmAdapter } from './adapters/dm';
|
||||
@@ -14,7 +15,7 @@ import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
import { DEFAULT_FILTERS, activeFilters, normalizeFilters, type SearchFilters } from './filters';
|
||||
|
||||
export type SuggestionItem = SuggestionItemV1;
|
||||
export type SearchResponse = SearchResponseV1;
|
||||
export type SearchResponse = SearchResponseV1 & { partial?: boolean };
|
||||
|
||||
type CacheKey = string;
|
||||
interface CacheEntry<T> { t: number; data: SearchResult<T>; }
|
||||
@@ -54,75 +55,95 @@ export class SearchService {
|
||||
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b))
|
||||
);
|
||||
|
||||
// Public request stream: aggregates groups by provider, runs adapters in parallel with timeout/abort/retry
|
||||
// Public request stream: emits each provider's group AS SOON AS it resolves,
|
||||
// then a final `partial: false` snapshot. Le blast d'auparavant (Promise.all
|
||||
// des 6 providers, un seul snapshot) est exactement le « flash blanc » des
|
||||
// sections 7.6 : tant que la source la plus lente répondait, rien du tout.
|
||||
readonly request$ = this.params$.pipe(
|
||||
filter(([q]) => typeof q === 'string' && q.trim().length >= 2),
|
||||
switchMap(([q, prov, page, pageSize, sort, filters]) => from(this.runAdapters({
|
||||
q: String(q),
|
||||
pageToken: String(page || 1),
|
||||
sort: (sort as any) || 'relevance',
|
||||
// Filtres globaux (type / durée / période) : envoyés à chaque adapter,
|
||||
// appliqués nativement par le provider quand c'est possible.
|
||||
filters: normalizeFilters(filters),
|
||||
}, prov)))
|
||||
,
|
||||
// Map provider results to the format expected by the UI
|
||||
map((byProvider) => {
|
||||
const groups = Object.entries(byProvider).reduce((acc, [pid, result]) => {
|
||||
acc[pid as ProviderId] = result.items;
|
||||
return acc;
|
||||
}, {} as Record<ProviderId, VideoItem[]>);
|
||||
// Surface per-provider backend failures (e.g. YouTube quota) for UI notice.
|
||||
const errors: Partial<Record<ProviderId, string>> = {};
|
||||
for (const [pid, result] of Object.entries(byProvider)) {
|
||||
if (result?.providerError) errors[pid as ProviderId] = result.providerError;
|
||||
}
|
||||
|
||||
const providers = Object.keys(byProvider) as ProviderId[];
|
||||
const resp: SearchResponse = {
|
||||
q: this.q$.value, providers, groups,
|
||||
...(Object.keys(errors).length > 0 ? { errors } : {}),
|
||||
switchMap(([q, prov, page, pageSize, sort, filters]) => {
|
||||
const active: ProviderId[] = prov === 'all' ? [...ALL_PROVIDER_IDS] : (Array.isArray(prov) ? prov : []);
|
||||
const params: ProviderSearchParams = {
|
||||
q: String(q),
|
||||
pageToken: String(page || 1),
|
||||
sort: (sort as any) || 'relevance',
|
||||
filters: normalizeFilters(filters),
|
||||
};
|
||||
return resp;
|
||||
if (active.length === 0) {
|
||||
// Aucune source : une seule émission vide et terminale, comme avant.
|
||||
return of(this.toSearchResponse({} as Record<ProviderId, SearchResult<VideoItem>>, false));
|
||||
}
|
||||
let received = 0;
|
||||
const total = active.length;
|
||||
return from(active).pipe(
|
||||
// Chaque provider rend son groupe dès qu'il est prêt, dans l'ordre où
|
||||
// il répond (pas l'ordre de la liste). Un repli ne bloque donc plus la
|
||||
// mise à jour des autres.
|
||||
mergeMap((pid) => from(this.searchAdapter(pid, params).then((result) => [pid, result] as const))),
|
||||
scan((acc, [pid, result]) => {
|
||||
acc[pid] = result;
|
||||
return acc;
|
||||
}, {} as Record<ProviderId, SearchResult<VideoItem>>),
|
||||
take(total),
|
||||
map((byProvider) => {
|
||||
received += 1;
|
||||
// `partial: true` = certains providers répondent encore ; le front
|
||||
// affiche le squelette de CELUI-là (sections 7.6) au lieu d'un vide.
|
||||
return this.toSearchResponse(byProvider, received < total);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
// Orchestrate all active providers in parallel with timeout(8s), retry(1) and abort on param change.
|
||||
private async runAdapters(params: ProviderSearchParams, prov: ProviderId[] | 'all'): Promise<Record<ProviderId, SearchResult<VideoItem>>> {
|
||||
const active: ProviderId[] = prov === 'all' ? ['yt','dm','tw','pt','od','ru'] : (Array.isArray(prov) ? prov : []);
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
const tasks = active.map(async (pid) => {
|
||||
const adapter = this.adapters[pid];
|
||||
if (!adapter) return [pid, { items: [] } as SearchResult<VideoItem>] as const;
|
||||
const key: CacheKey = `${pid}|${params.q}|${params.pageToken}|${params.sort}|${this.filterKey(params.filters)}`;
|
||||
const now = Date.now();
|
||||
const cached = this.cache.get(key);
|
||||
if (cached && (now - cached.t) < this.cacheTtlMs) {
|
||||
return [pid, cached.data] as const;
|
||||
}
|
||||
const perProviderTimeout = 8000;
|
||||
const withTimeout = <T>(p: Promise<T>): Promise<T> => new Promise((resolve, reject) => {
|
||||
const tid = setTimeout(() => reject(new Error('timeout')), perProviderTimeout);
|
||||
p.then(v => { clearTimeout(tid); resolve(v); }).catch(e => { clearTimeout(tid); reject(e); });
|
||||
});
|
||||
const attempt = async (): Promise<SearchResult<VideoItem>> => withTimeout(adapter.search(params, signal));
|
||||
try {
|
||||
const res: SearchResult<VideoItem> = await attempt().catch(async (e) => {
|
||||
// retry once on network-like errors
|
||||
if (e && (e.name === 'AbortError' || String(e.message || '').includes('abort'))) throw e;
|
||||
try { return await attempt(); } catch (err) { throw err; }
|
||||
});
|
||||
this.cache.set(key, { t: Date.now(), data: res });
|
||||
return [pid, res] as const;
|
||||
} catch {
|
||||
// Swallow errors per provider, return empty
|
||||
return [pid, { items: [] } as SearchResult<VideoItem>] as const;
|
||||
}
|
||||
/** Même agrégation que le mapping historique (`groups` + `errors`). */
|
||||
private toSearchResponse(byProvider: Record<ProviderId, SearchResult<VideoItem>>, partial: boolean): SearchResponse {
|
||||
const groups = Object.entries(byProvider).reduce((acc, [pid, result]) => {
|
||||
acc[pid as ProviderId] = result.items;
|
||||
return acc;
|
||||
}, {} as Record<ProviderId, VideoItem[]>);
|
||||
const errors: Partial<Record<ProviderId, string>> = {};
|
||||
for (const [pid, result] of Object.entries(byProvider)) {
|
||||
if (result?.providerError) errors[pid as ProviderId] = result.providerError;
|
||||
}
|
||||
return {
|
||||
q: this.q$.value,
|
||||
providers: Object.keys(byProvider) as ProviderId[],
|
||||
groups,
|
||||
...(Object.keys(errors).length > 0 ? { errors } : {}),
|
||||
partial,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Un provider, isolé : cache 60 s, timeout 8 s, un seul retry. Le cœur est
|
||||
* identique à l'ancien `runAdapters`, mais tiré en `searchOne` pour que la
|
||||
* liste en parallèle puisse émettre au fur et à mesure.
|
||||
*/
|
||||
private async searchAdapter(pid: ProviderId, params: ProviderSearchParams): Promise<SearchResult<VideoItem>> {
|
||||
const adapter = this.adapters[pid];
|
||||
if (!adapter) return { items: [] };
|
||||
const key: CacheKey = `${pid}|${params.q}|${params.pageToken}|${params.sort}|${this.filterKey(params.filters)}`;
|
||||
const now = Date.now();
|
||||
const cached = this.cache.get(key);
|
||||
if (cached && (now - cached.t) < this.cacheTtlMs) return cached.data;
|
||||
const perProviderTimeout = 8000;
|
||||
const withTimeout = <T>(p: Promise<T>): Promise<T> => new Promise((resolve, reject) => {
|
||||
const tid = setTimeout(() => reject(new Error('timeout')), perProviderTimeout);
|
||||
p.then(v => { clearTimeout(tid); resolve(v); }).catch(e => { clearTimeout(tid); reject(e); });
|
||||
});
|
||||
const results = await Promise.all(tasks);
|
||||
return Object.fromEntries(results) as Record<ProviderId, SearchResult<VideoItem>>;
|
||||
try {
|
||||
const res: SearchResult<VideoItem> = await withTimeout(adapter.search(params, new AbortController().signal)).catch(async (e) => {
|
||||
// retry once on network-like errors
|
||||
if (e && (e.name === 'AbortError' || String(e.message || '').includes('abort'))) throw e;
|
||||
try { return await withTimeout(adapter.search(params, new AbortController().signal)); } catch (err) { throw err; }
|
||||
});
|
||||
this.cache.set(key, { t: Date.now(), data: res });
|
||||
return res;
|
||||
} catch {
|
||||
// Swallow errors per provider, return empty
|
||||
return { items: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience setter helpers
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* Step 15 — Pure helpers for query typeahead (testable without Angular).
|
||||
*/
|
||||
|
||||
import { ALL_PROVIDER_IDS } from '../shared/providers/provider-ids';
|
||||
|
||||
/** Normalize a suggestion string for dedup comparisons. */
|
||||
export function normSuggest(s: string): string {
|
||||
return String(s ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
@@ -87,7 +89,7 @@ export function buildLocalQuerySuggestions(q: string, max = 10): string[] {
|
||||
*/
|
||||
export function mergeGroups(
|
||||
groups: Record<string, string[]> | null | undefined,
|
||||
order: string[] = ['yt', 'dm', 'tw', 'pt', 'od', 'ru'],
|
||||
order: string[] = [...ALL_PROVIDER_IDS],
|
||||
): Array<{ text: string; provider: string }> {
|
||||
const out: Array<{ text: string; provider: string }> = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
Reference in New Issue
Block a user