feat(shorts): page /shorts alimentee par le catalogue + refonte pro/mobile
CI / build-and-test (push) Successful in 14m45s
CI / build-and-test (push) Successful in 14m45s
SearchService.fetchCatalogShorts (type shorts via /api/search : cache, provenance, FF) + pont pur shorts-catalog.ts (mapping, meta, fraicheur, spec 27 assertions). WatchShortComponent : catalogue d'abord (feed, pagination, Pour toi), repli legacy, Twitch clips inchanges, avatars paresseux via channelMeta. UI facon YouTube : plein ecran 100dvh, fond d'ambiance, rail overlay 48px, meta/fraicheur/pastille provider, safe-area.
This commit is contained in:
@@ -2,7 +2,7 @@ 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 { mergeMap, scan, take } from 'rxjs/operators';
|
||||
import { ALL_PROVIDER_IDS, type ProviderId } from '../shared/providers/provider-ids';
|
||||
import { ALL_PROVIDER_IDS, toShortProviderId, type ProviderId } from '../shared/providers/provider-ids';
|
||||
import type { SuggestionItemV1, SearchResponseV1 } from './api.v1';
|
||||
import { YtAdapter } from './adapters/yt';
|
||||
import { DmAdapter } from './adapters/dm';
|
||||
@@ -12,7 +12,7 @@ import { OdAdapter } from './adapters/od';
|
||||
import { RuAdapter } from './adapters/ru';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from './models';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
import { DEFAULT_FILTERS, activeFilters, normalizeFilters, type SearchFilters } from './filters';
|
||||
import { DEFAULT_FILTERS, activeFilters, matchesFilters, normalizeFilters, type SearchFilters } from './filters';
|
||||
|
||||
export type SuggestionItem = SuggestionItemV1;
|
||||
export type SearchResponse = SearchResponseV1 & { partial?: boolean };
|
||||
@@ -178,4 +178,41 @@ export class SearchService {
|
||||
}
|
||||
/** Filtres actifs seulement (pour l'URL / le partage). */
|
||||
activeFilters(): Partial<SearchFilters> { return activeFilters(this.filters$.value); }
|
||||
|
||||
/**
|
||||
* Phase Shorts-catalogue — la page `/shorts` alimentée par le catalogue.
|
||||
*
|
||||
* Un provider (nom long : `youtube`, `dailymotion`…), une requête, filtre
|
||||
* `type: 'shorts'` : appliqué côté serveur quand le provider le sait
|
||||
* (`withFilterParams` → `/api/search`), affiné ici dans tous les cas
|
||||
* (`matchesFilters`, même doctrine que la page de recherche). Réutilise
|
||||
* `searchAdapter` : cache 60 s, timeout 8 s, 1 retry, erreurs avalées —
|
||||
* le flux Shorts ne casse donc jamais sur un provider lent ou éteint
|
||||
* (`providerError` remonte pour le bandeau « source dégradée »).
|
||||
*
|
||||
* Les providers sans équivalent Shorts catalogue (ex. Twitch : le flux
|
||||
* historique utilise les *clips*, pas les VOD) renvoient `[]` ici : c'est
|
||||
* le signal pour l'appelant de rejouer son chemin historique.
|
||||
*/
|
||||
async fetchCatalogShorts(
|
||||
providerLong: string,
|
||||
q: string,
|
||||
opts?: { page?: number; pageSize?: number },
|
||||
): Promise<{ items: VideoItem[]; providerError?: string }> {
|
||||
const pid = toShortProviderId(providerLong);
|
||||
const query = String(q || '').trim();
|
||||
if (!pid || !query) return { items: [] };
|
||||
const params: ProviderSearchParams = {
|
||||
q: query,
|
||||
pageToken: String(Math.max(1, Math.floor(opts?.page || 1))),
|
||||
sort: 'relevance',
|
||||
filters: normalizeFilters({ type: 'shorts' }),
|
||||
};
|
||||
const res = await this.searchAdapter(pid, params);
|
||||
const pageSize = Math.min(50, Math.max(1, Math.floor(opts?.pageSize || 25)));
|
||||
const items = (res.items || [])
|
||||
.filter((it) => matchesFilters(it, { type: 'shorts' }))
|
||||
.slice(0, pageSize);
|
||||
return { items, ...(res.providerError ? { providerError: res.providerError } : {}) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
catalogShortToVideo,
|
||||
formatShortMeta,
|
||||
formatCapturedAgo,
|
||||
formatCompactFr,
|
||||
SHORTS_CATALOG_PAGE_SIZE,
|
||||
SHORTS_CATALOG_MIN_ITEMS,
|
||||
} from './shorts-catalog';
|
||||
|
||||
/**
|
||||
* Phase Shorts-catalogue — pont pur catalogue → flux Shorts.
|
||||
*
|
||||
* Miroir offline du câblage `SearchService.fetchCatalogShorts` +
|
||||
* `WatchShortComponent` : chaque règle d'honnêteté du mapping est figée ici
|
||||
* (pas d'id inventé, pas de fraîcheur inventée, pas de « 0 vue »).
|
||||
*
|
||||
* Run with: npm run test:shorts-catalog
|
||||
*/
|
||||
|
||||
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('--- catalogShortToVideo ---');
|
||||
assertEqual(catalogShortToVideo(null), null, 'null → null');
|
||||
assertEqual(catalogShortToVideo({} as any), null, 'sans id → null (pas de fantôme)');
|
||||
assertEqual(
|
||||
catalogShortToVideo({ id: 'x1', provider: 'youtube', title: 'T', thumbnailUrl: 'th', channelName: 'C' } as any)?.videoId,
|
||||
'x1',
|
||||
'id → videoId',
|
||||
);
|
||||
{
|
||||
const v: any = catalogShortToVideo({
|
||||
id: 'o1', provider: 'odysee', title: 'O', thumbnailUrl: '', channelName: '@chan',
|
||||
slug: '@chan/video-1', capturedAt: NOW - 240000, source: 'api',
|
||||
} as any);
|
||||
assertEqual(v?.url, 'https://odysee.com/@chan/video-1', 'slug Odysee → url embeddable');
|
||||
assertEqual(v?.raw, { slug: '@chan/video-1', capturedAt: NOW - 240000, source: 'api' }, 'provenance conservée dans raw');
|
||||
assertEqual(v?.views, 0, 'vues inconnues → 0 (pas de devinette)');
|
||||
assertEqual(v?.duration, 0, 'durée inconnue → 0');
|
||||
}
|
||||
{
|
||||
const v: any = catalogShortToVideo({
|
||||
id: 'y1', provider: 'youtube', title: 'Y', thumbnailUrl: '', channelName: 'C',
|
||||
durationSec: 42, viewCount: 12345, publishedAt: '2023-01-01T00:00:00.000Z',
|
||||
isShort: true, width: 720, height: 1280, language: 'fr',
|
||||
} as any);
|
||||
assertEqual(v?.type, 'short', 'flag isShort → type short');
|
||||
assertEqual(v?.duration, 42, 'durationSec propagée');
|
||||
assertEqual(v?.views, 12345, 'viewCount propagé');
|
||||
assertEqual(v?.language, 'fr', 'langue propagée (filtre FR/EN du flux)');
|
||||
assertEqual(v?.raw, undefined, 'sans slug/provenance → raw absent');
|
||||
}
|
||||
{
|
||||
const v: any = catalogShortToVideo({ id: 't1', provider: 'twitch', title: 'L', thumbnailUrl: '', channelName: 'C', isLive: true } as any);
|
||||
assertEqual(v?.type, 'live', 'live prime sur le reste');
|
||||
}
|
||||
|
||||
console.log('--- formatShortMeta ---');
|
||||
assertEqual(formatShortMeta(undefined, undefined, NOW), '', 'rien connu → rien affiché');
|
||||
assertEqual(formatShortMeta(0, undefined, NOW), '', '0 vue → omis (pas « 0 vue »)');
|
||||
assertEqual(formatShortMeta(12345, undefined, NOW).includes('vues'), true, 'vues seules');
|
||||
assertEqual(formatShortMeta(undefined, new Date(NOW - 2 * 86400000).toISOString(), NOW), 'il y a 2 j', 'âge seul');
|
||||
assertEqual(
|
||||
formatShortMeta(12345, new Date(NOW - 2 * 86400000).toISOString(), NOW).includes('•'),
|
||||
true,
|
||||
'vues + âge joints par •',
|
||||
);
|
||||
|
||||
console.log('--- formatCapturedAgo ---');
|
||||
assertEqual(formatCapturedAgo(undefined, NOW), '', 'capturedAt absent → pas de badge');
|
||||
assertEqual(formatCapturedAgo(0, NOW), '', 'capturedAt 0 → pas de badge');
|
||||
assertEqual(formatCapturedAgo(-5, NOW), '', 'capturedAt négatif → pas de badge');
|
||||
assertEqual(formatCapturedAgo(NOW - 30000, NOW), 'capturé à l’instant', 'frais → à l’instant');
|
||||
assertEqual(formatCapturedAgo(NOW - 4 * 60000, NOW), 'capturé il y a 4 min', 'minutes');
|
||||
assertEqual(formatCapturedAgo(NOW + 60000, NOW), 'capturé à l’instant', 'futur → à l’instant (jamais « dans 1 min »)');
|
||||
|
||||
console.log('--- constantes ---');
|
||||
assertEqual(SHORTS_CATALOG_PAGE_SIZE, 25, 'page catalogue 25 (serveur plafonne à 50)');
|
||||
assertEqual(SHORTS_CATALOG_MIN_ITEMS, 3, 'seuil de repli legacy');
|
||||
assertEqual(formatCompactFr(12345).length > 0, true, 'compact fr non vide');
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { VideoItem } from '../shared/models/video-item.model';
|
||||
import type { Video } from '../../models/video.model';
|
||||
|
||||
/**
|
||||
* Adaptation de la page Shorts au catalogue (phase Shorts-catalogue).
|
||||
*
|
||||
* La page `/shorts` interrogeait chaque fournisseur en direct depuis le
|
||||
* navigateur (clés YouTube côté client, scrapers ad hoc) : ni cache, ni
|
||||
* provenance, ni feature-flags, ni capacités. Ce module est le pont PUR
|
||||
* (aucune dépendance Angular : testable hors-ligne) entre le catalogue unifié
|
||||
* (`SearchService.fetchCatalogShorts`, qui passe par les 6 adaptateurs vers
|
||||
* `/api/search?type=shorts`) et le modèle historique du flux (`Video`).
|
||||
*
|
||||
* Règles :
|
||||
* - métadonnée absente = absente (jamais de `Date.now()` inventé, même
|
||||
* doctrine que la phase 7.3) ;
|
||||
* - la provenance (`capturedAt`/`source`) voyage dans `raw` pour l'affichage
|
||||
* de fraîcheur, sans polluer le modèle historique ;
|
||||
* - `null` si l'item n'a pas d'id (jamais d'entrée fantôme dans le flux).
|
||||
*/
|
||||
|
||||
/** Taille d'une page catalogue pour le flux (le serveur plafonne à 50). */
|
||||
export const SHORTS_CATALOG_PAGE_SIZE = 25;
|
||||
|
||||
/** Seuil catalogue→repli : en dessous, on rejoue le chemin historique direct. */
|
||||
export const SHORTS_CATALOG_MIN_ITEMS = 3;
|
||||
|
||||
function numOrUndef(v: unknown): number | undefined {
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
|
||||
}
|
||||
|
||||
function strOrEmpty(v: unknown): string {
|
||||
return typeof v === 'string' ? v : '';
|
||||
}
|
||||
|
||||
/** `VideoItem` (catalogue) → `Video` (flux Shorts historique). */
|
||||
export function catalogShortToVideo(item: VideoItem | null | undefined): Video | null {
|
||||
if (!item || typeof item !== 'object') return null;
|
||||
const id = strOrEmpty((item as { id?: unknown }).id).trim();
|
||||
if (!id) return null;
|
||||
const provider = strOrEmpty((item as { provider?: unknown }).provider) as Video['provider'];
|
||||
const publishedAt = strOrEmpty(item.publishedAt);
|
||||
const uploaded = publishedAt ? Date.parse(publishedAt) : NaN;
|
||||
const slug = strOrEmpty((item as { slug?: unknown }).slug);
|
||||
const capturedAt = numOrUndef((item as { capturedAt?: unknown }).capturedAt);
|
||||
const sourceRaw = (item as { source?: unknown }).source;
|
||||
const source = typeof sourceRaw === 'string' && sourceRaw.trim() ? sourceRaw.trim() : undefined;
|
||||
const kindRaw = (item as { kind?: unknown }).kind;
|
||||
const kind = kindRaw === 'clip' || kindRaw === 'vod' || kindRaw === 'short' || kindRaw === 'channel'
|
||||
? kindRaw
|
||||
: undefined;
|
||||
return {
|
||||
url: provider === 'odysee' && slug ? `https://odysee.com/${slug}` : '',
|
||||
type: item.isLive === true ? 'live' : (item.isShort === true ? 'short' : 'video'),
|
||||
title: strOrEmpty(item.title),
|
||||
thumbnail: strOrEmpty(item.thumbnailUrl),
|
||||
uploaderName: strOrEmpty(item.channelName),
|
||||
uploaderAvatar: strOrEmpty(item.uploaderAvatar) || strOrEmpty(item.channelAvatarUrl),
|
||||
channelExternalId: strOrEmpty(item.channelExternalId) || undefined,
|
||||
uploadedDate: publishedAt,
|
||||
duration: typeof item.durationSec === 'number' && Number.isFinite(item.durationSec) ? item.durationSec : 0,
|
||||
views: typeof item.viewCount === 'number' && Number.isFinite(item.viewCount) ? item.viewCount : 0,
|
||||
uploaded: Number.isFinite(uploaded) ? uploaded : 0,
|
||||
videoId: id,
|
||||
provider,
|
||||
width: typeof item.width === 'number' ? item.width : null,
|
||||
height: typeof item.height === 'number' ? item.height : null,
|
||||
isShort: item.isShort === true ? true : undefined,
|
||||
isLive: item.isLive === true ? true : undefined,
|
||||
kind,
|
||||
language: typeof item.language === 'string' ? item.language : undefined,
|
||||
// Provenance catalogue : le flux l'affiche (« capturé il y a X »), le
|
||||
// modèle historique n'a pas ces champs → repli `raw`, jamais de perte.
|
||||
raw: (slug || capturedAt !== undefined || source)
|
||||
? { ...(slug ? { slug } : {}), ...(capturedAt !== undefined ? { capturedAt } : {}), ...(source ? { source } : {}) }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Nombre compact fr (« 12 k », « 3,4 M ») — `Intl` plutôt qu'un format maison. */
|
||||
export function formatCompactFr(n: number): string {
|
||||
if (!Number.isFinite(n) || n < 0) return '';
|
||||
try {
|
||||
return new Intl.NumberFormat('fr-FR', { notation: 'compact', maximumFractionDigits: 1 }).format(n);
|
||||
} catch {
|
||||
return String(Math.round(n));
|
||||
}
|
||||
}
|
||||
|
||||
/** Âge relatif court fr (« à l'instant », « il y a 4 min », « il y a 2 j »). */
|
||||
export function formatAgeFr(ts: number, now: number = Date.now()): string {
|
||||
if (!Number.isFinite(ts) || ts <= 0) return '';
|
||||
const diff = Math.max(0, now - ts);
|
||||
const min = Math.floor(diff / 60000);
|
||||
if (min < 1) return "à l'instant";
|
||||
if (min < 60) return `il y a ${min} min`;
|
||||
const h = Math.floor(min / 60);
|
||||
if (h < 24) return `il y a ${h} h`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 7) return `il y a ${d} j`;
|
||||
if (d < 30) return `il y a ${Math.floor(d / 7)} sem.`;
|
||||
if (d < 365) return `il y a ${Math.floor(d / 30)} mois`;
|
||||
return `il y a ${Math.floor(d / 365)} an${Math.floor(d / 365) > 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ligne de méta sous le titre façon YouTube (« 12 k vues • il y a 2 j »).
|
||||
* Chaque morceau inconnu est omis, jamais deviné — une chaîne sans vues
|
||||
* connues affiche seulement son âge, pas « 0 vue ».
|
||||
*/
|
||||
export function formatShortMeta(views?: number, publishedAt?: string, now: number = Date.now()): string {
|
||||
const parts: string[] = [];
|
||||
if (typeof views === 'number' && Number.isFinite(views) && views > 0) {
|
||||
parts.push(`${formatCompactFr(views)} vues`);
|
||||
}
|
||||
if (typeof publishedAt === 'string' && publishedAt.trim()) {
|
||||
const ts = Date.parse(publishedAt);
|
||||
const age = formatAgeFr(ts, now);
|
||||
if (age) parts.push(age);
|
||||
}
|
||||
return parts.join(' • ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fraîcheur de collecte catalogue (« capturé il y a 4 min »).
|
||||
* `undefined` → `''` (pas de badge) : un `capturedAt` absent ne doit jamais
|
||||
* passer pour du frais — doctrine phase 7.3.
|
||||
*/
|
||||
export function formatCapturedAgo(capturedAt?: number, now: number = Date.now()): string {
|
||||
if (!Number.isFinite(Number(capturedAt)) || Number(capturedAt) <= 0) return '';
|
||||
const at = Number(capturedAt);
|
||||
if (at > now) return 'capturé à l’instant';
|
||||
const min = Math.floor((now - at) / 60000);
|
||||
if (min < 1) return 'capturé à l’instant';
|
||||
if (min < 60) return `capturé il y a ${min} min`;
|
||||
const h = Math.floor(min / 60);
|
||||
if (h < 24) return `capturé il y a ${h} h`;
|
||||
return `capturé il y a ${Math.floor(h / 24)} j`;
|
||||
}
|
||||
Reference in New Issue
Block a user