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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user