feat(shorts): page /shorts alimentee par le catalogue + refonte pro/mobile
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:
2026-09-30 09:16:56 -04:00
parent 665a0f0ebd
commit 8e087daf93
9 changed files with 691 additions and 84 deletions
+39 -2
View File
@@ -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 } : {}) };
}
}
+90
View File
@@ -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');
+139
View File
@@ -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`;
}