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 };
}
}