diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88c0363..fc9a385 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,6 +97,9 @@ jobs: - name: Unified ProviderAdapter contract (offline) run: npm run test:adapter + - name: Shorts catalogue bridge (offline) + run: npm run test:shorts-catalog + - name: Contract v2 strict + `FF_` feature flags (offline) run: npm run test:flags diff --git a/docs/plan-phases-catalogue-classification.md b/docs/plan-phases-catalogue-classification.md index 23d3217..f870167 100644 --- a/docs/plan-phases-catalogue-classification.md +++ b/docs/plan-phases-catalogue-classification.md @@ -476,6 +476,14 @@ La table est **additive**. Le Phase 5.3 (LEFT JOIN) est le seul changement de le - [x] Un provider en échec affiche un bandeau ; un provider réellement vide affiche un état vide neutre. - [x] Aucun onglet d'onglet vide n'est proposé sur les pages chaîne. *(`canShowPill()`)* +### 9.9 Shorts-catalogue — la page `/shorts` alimentée par le catalogue (hors plan initial) + +- [x] `SearchService.fetchCatalogShorts(providerLong, q, { page, pageSize })` : un provider, filtre `type: 'shorts'` (serveur quand il sait, `matchesFilters` sinon), via `searchAdapter` (cache 60 s, timeout 8 s, 1 retry, erreurs avalées) — le flux ne casse jamais sur un provider lent ou éteint (`providerError` → pastille « Source dégradée »). +- [x] `WatchShortComponent` : catalogue d'abord (feed, pagination par pages, mode « Pour toi » via `catalogOrLegacy`), repli historique direct sous `SHORTS_CATALOG_MIN_ITEMS` (= 3) ou en erreur ; Twitch garde ses clips (pas d'équivalent catalogue) ; enrichissement des durées YouTube et portes `accepts()` inchangés. +- [x] Pont pur `src/app/search/shorts-catalog.ts` (`catalogShortToVideo`, `formatShortMeta`, `formatCapturedAgo`) + spec hors-ligne `npm run test:shorts-catalog` (27 assertions : pas d'id fantôme, pas de fraîcheur inventée, pas de « 0 vue »). +- [x] Avatars paresseux via `channelMeta` (`GET /channels/:provider/:externalId`, une requête par vidéo, silence en échec) + initiale de repli — plus d'image cassée quand la recherche ne fournit pas d'avatar. +- [x] Refonte pro façon YouTube + mobile : scène plein écran (`100dvh`, fond d'ambiance flouté), rail d'actions **overlay dans le cadre** (J'aime / Commentaires / Partager / Son, cibles 48 px), méta sous le titre (« 12 k vues • il y a 2 j » + « capturé il y a 4 min • api »), pastille provider, safe-area, `touch-action: pan-y`, cadre sans radius sur mobile. + --- ## 10. Phase 8 — Architecture de fond diff --git a/index.css b/index.css index feb68cd..bbbea5c 100644 --- a/index.css +++ b/index.css @@ -506,6 +506,46 @@ body { --shorts-chrome: 9rem; } } +@media (max-width: 767px) { + /* Mobile : cadre quasi plein écran (seuls les chips + marges sont réservés). */ + .shorts-frame { + --shorts-chrome: 8.5rem; + } +} + +/* --- Shorts immersive stage (façon YouTube) -------------------------------- */ +/* Plein écran vertical : le cadre 9:16 flotte sur un fond d'ambiance tiré de + la vignette courante. `100dvh` (barres navigateur mobiles) avec repli. */ +.shorts-stage { + min-height: calc(var(--app-vh, 100dvh) - var(--header-offset, 0px)); + touch-action: pan-y; + overscroll-behavior: contain; +} +.shorts-ambient { + position: absolute; + inset: 0; + z-index: 0; + overflow: hidden; + background: #000; + pointer-events: none; +} +.shorts-ambient img { + width: 100%; + height: 100%; + object-fit: cover; + filter: blur(48px) brightness(0.45); + transform: scale(1.2); +} +@media (prefers-reduced-motion: no-preference) { + .shorts-ambient img { + transition: opacity 0.4s ease; + } +} +/* Zone de méta basse : la safe-area des encoches ne doit jamais manger le + titre ou le bouton S'abonner. */ +.shorts-meta { + padding-bottom: max(0.75rem, env(safe-area-inset-bottom)); +} /* --- Drawer / overlay plumbing -------------------------------------------- */ /* Applied to while a full-screen drawer or modal owns the viewport. diff --git a/package.json b/package.json index a53f2ba..3de99fb 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "test:provenance": "node --test ./server/tests/provenance.test.mjs ./server/tests/search_debug.test.mjs", "test:stream": "node --test ./server/tests/search_stream.test.mjs ./server/tests/search_stream_provider.test.mjs", "test:adapter": "node --test ./server/tests/adapter_contract.test.mjs", + "test:shorts-catalog": "node --loader ts-node/esm --experimental-specifier-resolution=node src/app/search/shorts-catalog.spec.ts", "test:provider-health": "node --loader ts-node/esm --experimental-specifier-resolution=node src/app/search/provider-health.service.spec.ts", "test:provenance-front": "node --loader ts-node/esm --experimental-specifier-resolution=node src/app/search/adapters/provenance.spec.ts", "test:transcript": "node --test server/tests/transcript.test.mjs", diff --git a/src/app/search/search.service.ts b/src/app/search/search.service.ts index 45a7955..fb5141d 100644 --- a/src/app/search/search.service.ts +++ b/src/app/search/search.service.ts @@ -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 { 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 } : {}) }; + } } diff --git a/src/app/search/shorts-catalog.spec.ts b/src/app/search/shorts-catalog.spec.ts new file mode 100644 index 0000000..efc2771 --- /dev/null +++ b/src/app/search/shorts-catalog.spec.ts @@ -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'); diff --git a/src/app/search/shorts-catalog.ts b/src/app/search/shorts-catalog.ts new file mode 100644 index 0000000..bbf6ff3 --- /dev/null +++ b/src/app/search/shorts-catalog.ts @@ -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`; +} diff --git a/src/components/shorts/watch-short.component.html b/src/components/shorts/watch-short.component.html index 2458639..ab4746e 100644 --- a/src/components/shorts/watch-short.component.html +++ b/src/components/shorts/watch-short.component.html @@ -1,103 +1,164 @@ -
+
+ + + -
+
- -
+
- -
+ +
-
+
@if (embedUrl(); as url) { } @else if (loading()) { -
+
Chargement des Shorts…
} @else if (error()) {
{{ error() }} - +
} @if (current(); as v) {
- -
- @if (menuOpen()) { -
- -
- + +
+
+ + J'aime +
+
+ + + + Commentaires +
+
+ + Partager +
+
+ + Son +
- -
-
- + +
+
+ @if (v.uploaderAvatar) { + + } @else { + + }
-
{{ v.title }}
-
@{{ v.uploaderName }}
+
{{ v.title }}
+
+ @{{ v.uploaderName }} + {{ providerLabel() }} +
+ @if (currentMeta()) { +
{{ currentMeta() }}
+ } + @if (currentCaptured() || catalogError()) { +
+ @if (currentCaptured()) { + {{ currentCaptured() }}{{ currentSource() ? ' • ' + currentSource() : '' }} + } @else if (currentSource()) { + Source : {{ currentSource() }} + } + @if (catalogError()) { + Source dégradée + } +
+ }
@if (isLoggedIn() && currentChannelKey()) { } @@ -115,46 +176,18 @@
Lien copié ✓
}
- - -
-
- - J'aime -
-
- - - - Commentaires -
-
- - Partager -
-
@if (busyMore()) { -
Chargement…
+
Chargement…
} diff --git a/src/components/shorts/watch-short.component.ts b/src/components/shorts/watch-short.component.ts index 1d4b099..f33edb8 100644 --- a/src/components/shorts/watch-short.component.ts +++ b/src/components/shorts/watch-short.component.ts @@ -3,8 +3,8 @@ import { CommonModule } from '@angular/common'; import { RouterLink } from '@angular/router'; import { HttpClient } from '@angular/common/http'; import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; -import { firstValueFrom, forkJoin, map, of } from 'rxjs'; -import { catchError } from 'rxjs/operators'; +import { firstValueFrom, forkJoin, from, map, of } from 'rxjs'; +import { catchError, switchMap } from 'rxjs/operators'; import { YoutubeApiService } from '../../services/youtube-api.service'; import { InstanceService, Provider } from '../../services/instance.service'; import { HistoryService } from '../../services/history.service'; @@ -15,6 +15,15 @@ import { AuthService } from '../../services/auth.service'; import { Video } from '../../models/video.model'; import { shortsFeedAccepts, isPlayable as isPlayablePolicy, type PlayableContext } from '../../app/shared/utils/section-policy'; import { SHORT_TO_LONG, type ProviderId } from '../../app/shared/providers/provider-ids'; +import { SearchService } from '../../app/search/search.service'; +import { ChannelProviderFactory } from '../../app/core/providers/channel/channel-provider.factory'; +import { + catalogShortToVideo, + formatShortMeta, + formatCapturedAgo, + SHORTS_CATALOG_PAGE_SIZE, + SHORTS_CATALOG_MIN_ITEMS, +} from '../../app/search/shorts-catalog'; /** Provider long (youtube…) -> id court d'abonnement ('yt'…). */ const SUB_PROVIDER_OF: Record = Object.fromEntries( @@ -67,6 +76,14 @@ export class WatchShortComponent implements OnDestroy { private downloads = inject(DownloadService); private subs = inject(SubscriptionsService); private auth = inject(AuthService); + /** + * Phase Shorts-catalogue — le flux est alimenté par le catalogue unifié + * (`/api/search?type=shorts` : cache, provenance, feature-flags, capacités), + * le chemin historique direct restant en repli (Twitch clips, panne serveur, + * provider sans équivalent Shorts catalogue). + */ + private catalog = inject(SearchService); + private channelStrategies = inject(ChannelProviderFactory); loading = signal(true); error = signal(null); @@ -144,6 +161,17 @@ export class WatchShortComponent implements OnDestroy { private queryIndex = 0; /** Requêtes déjà épuisées (curseur null) pour ce chargement. */ private exhaustedQueries = new Set(); + /** + * Alimentation catalogue active : la pagination suit alors les pages + * `/api/search` (`catalogPage`), pas les curseurs du chemin historique. + * Toute bascule vers le repli historique remet ce drapeau à `false`. + */ + private catalogActive = false; + private catalogPage = 1; + /** Erreur non bloquante du catalogue (« source dégradée », esprit 7.4). */ + readonly catalogError = signal(null); + /** Clés déjà tentées pour la résolution paresseuse d'avatar (anti-boucle). */ + private avatarResolved = new Set(); // scroll/swipe helpers private wheelAccum = 0; @@ -159,6 +187,47 @@ export class WatchShortComponent implements OnDestroy { return (i >= 0 && i < list.length) ? list[i] : null; }); + /** + * Méta façon YouTube sous le titre (« 12 k vues • il y a 2 j ») : vient du + * catalogue (vues + date réelles), chaque morceau inconnu est omis. + */ + readonly currentMeta = computed(() => { + const v = this.current(); + if (!v) return ''; + try { return formatShortMeta((v as Video).views, (v as Video).uploadedDate); } catch { return ''; } + }); + + /** Fraîcheur de collecte catalogue (« capturé il y a 4 min », '' si inconnue). */ + readonly currentCaptured = computed(() => { + const v = this.current(); + if (!v) return ''; + try { + const raw = (v as unknown as { raw?: { capturedAt?: number } })?.raw; + return formatCapturedAgo(raw?.capturedAt); + } catch { return ''; } + }); + + /** Voie de collecte catalogue (`api`, `innertube`, `cache`… — '' si inconnue). */ + readonly currentSource = computed(() => { + const v = this.current(); + if (!v) return ''; + try { + const raw = (v as unknown as { raw?: { source?: unknown } })?.raw; + const s = String(raw?.source || '').trim(); + return s || ''; + } catch { return ''; } + }); + + /** Libellé du fournisseur courant (pastille façon YouTube). */ + readonly providerLabel = computed(() => { + try { + const list = this.providersList() as Array<{ id?: unknown; label?: unknown }>; + const hit = (list || []).find((p) => String(p?.id || '') === String(this.provider())); + const label = String(hit?.label || '').trim(); + return label || String(this.provider()); + } catch { return String(this.provider()); } + }); + readonly canPrev = computed(() => this.index() > 0); readonly canNext = computed(() => this.index() < this.items().length - 1 || (!this.forYou() && (!!this.nextCursor() || this.hasMoreQueries())) @@ -439,6 +508,9 @@ export class WatchShortComponent implements OnDestroy { if (!v) return; const nx = (i >= 0 && i + 1 < list.length) ? list[i + 1] : null; this.queueRumbleResolves([v, nx]); + // Avatar paresseux via le catalogue (`channelMeta`) : le flux n'affiche + // plus une image cassée quand la recherche ne fournit pas d'avatar. + this.ensureAvatar(v); // État J'aime du short courant (connecté uniquement). try { if (this.isLoggedIn() && v.videoId) { @@ -467,6 +539,41 @@ export class WatchShortComponent implements OnDestroy { if (this.shareCopiedTimer) { try { clearTimeout(this.shareCopiedTimer); } catch {} } } + /** + * Résout l'avatar de la chaîne du short courant via le catalogue + * (`GET /channels/:provider/:externalId`, une seule requête par vidéo grâce + * au garde `avatarResolved`). Le lot est patché immuablement pour rester + * compatible OnPush. Silence total en échec : pas d'avatar vaut mieux qu'un + * spinner ou une erreur dans un flux infini. + */ + private ensureAvatar(v: Video | null): void { + try { + if (!v?.videoId || v.uploaderAvatar) return; + const extId = String((v as unknown as { channelExternalId?: unknown })?.channelExternalId || '').trim(); + if (!extId) return; + const p = this.providerOf(v); + const short = SUB_PROVIDER_OF[p]; + if (!short) return; + const key = `${p}:${String(v.videoId)}`; + if (this.avatarResolved.has(key)) return; + this.avatarResolved.add(key); + this.channelStrategies.get(short).getChannelInfo(extId).subscribe({ + next: (detail) => { + const avatar = String((detail as unknown as { avatarUrl?: unknown })?.avatarUrl || '').trim(); + if (!avatar) return; + const at = this.items().findIndex((x) => + String(x.videoId) === String(v.videoId) && + String((x as unknown as { provider?: unknown })?.provider || '') === String((v as unknown as { provider?: unknown })?.provider || '')); + if (at < 0 || this.items()[at]?.uploaderAvatar) return; + const next = this.items().slice(); + next[at] = { ...next[at], uploaderAvatar: avatar }; + this.items.set(next); + }, + error: () => {}, + }); + } catch {} + } + /** * File de résolution Rumble (concurrence bornée) : chaque id est vérifié * via le backend (comme /watch). Succès => l'embed bascule sur le vrai @@ -781,9 +888,32 @@ export class WatchShortComponent implements OnDestroy { return out; } + /** + * Voie catalogue avec repli historique (mode « Pour toi ») : le catalogue + * est tenté d'abord (cache 60 s, provenance) ; s'il rend moins de + * `SHORTS_CATALOG_MIN_ITEMS` items — ou en erreur — on rejoue l'appel + * historique fourni. Le reste du pipeline (filtre `accepts`, mélange, + * entrelacement) est inchangé. + */ + private catalogOrLegacy(p: Provider, topic: string, legacy: any): any { + const attempt = this.catalog.fetchCatalogShorts(p, topic, { page: 1, pageSize: SHORTS_CATALOG_PAGE_SIZE }).then( + (r) => { + const videos = (r.items || []) + .map((it) => catalogShortToVideo(it)) + .filter((v): v is Video => !!v?.videoId); + return videos.length >= SHORTS_CATALOG_MIN_ITEMS ? { items: videos, nextCursor: null } : null; + }, + () => null, + ).catch(() => null); + return from(attempt).pipe( + switchMap((hit) => hit + ? of(hit) + : (legacy as any).pipe(catchError(() => of({ items: [], nextCursor: null })))), + ); + } + /** Cherche chaque sujet en Shorts sur tous les fournisseurs et entrelace. */ - private fetchForYou(topics: string[]): void { - const picks = topics.slice(0, 4); + private fetchForYou(topics: string[]): void { const picks = topics.slice(0, 4); if (!picks.length) { this.afterNoResults(); this.loading.set(false); @@ -794,11 +924,11 @@ export class WatchShortComponent implements OnDestroy { try { return this.instances.getProviderReadiness(p).ready; } catch { return true; } }; for (const t of picks) { - if (ready('youtube')) defs.push({ provider: 'youtube', call: this.api.searchShortsPage(t, null, 'youtube' as Provider) }); - if (ready('dailymotion')) defs.push({ provider: 'dailymotion', call: this.api.searchShortsPage(t, null, 'dailymotion' as Provider) }); - if (ready('peertube')) defs.push({ provider: 'peertube', call: this.api.searchPeerTubeShortsPage(t, null) }); - if (ready('rumble')) defs.push({ provider: 'rumble', call: this.api.searchRumbleShortsPage(t, null) }); - if (ready('odysee')) defs.push({ provider: 'odysee', call: this.api.searchShortsPage(t, null, 'odysee' as Provider) }); + if (ready('youtube')) defs.push({ provider: 'youtube', call: this.catalogOrLegacy('youtube', t, this.api.searchShortsPage(t, null, 'youtube' as Provider)) }); + if (ready('dailymotion')) defs.push({ provider: 'dailymotion', call: this.catalogOrLegacy('dailymotion', t, this.api.searchShortsPage(t, null, 'dailymotion' as Provider)) }); + if (ready('peertube')) defs.push({ provider: 'peertube', call: this.catalogOrLegacy('peertube', t, this.api.searchPeerTubeShortsPage(t, null)) }); + if (ready('rumble')) defs.push({ provider: 'rumble', call: this.catalogOrLegacy('rumble', t, this.api.searchRumbleShortsPage(t, null)) }); + if (ready('odysee')) defs.push({ provider: 'odysee', call: this.catalogOrLegacy('odysee', t, this.api.searchShortsPage(t, null, 'odysee' as Provider)) }); } if (ready('twitch')) { for (const t of picks.slice(0, 2)) { @@ -914,6 +1044,8 @@ export class WatchShortComponent implements OnDestroy { this.seenIds = new Set(); this.queryIndex = 0; this.exhaustedQueries = new Set(); + this.catalogActive = false; + this.catalogError.set(null); const ready = this.instances.getProviderReadiness(this.provider()); if (!ready.ready) { this.loading.set(false); @@ -921,9 +1053,11 @@ export class WatchShortComponent implements OnDestroy { return; } const provider = this.provider(); - // Twitch : les Clips sont l'équivalent Shorts. La recherche Helix rejette - // les requêtes vides → on part des top streams (getTwitchClipsFeed). + // Shorts via le CATALOGUE d'abord (`/api/search?type=shorts` : cache, + // provenance, feature-flags). Twitch garde son flux de clips historique + // (pas d'équivalent catalogue : la recherche serveur renvoie des VOD). if (provider === 'twitch') { + this.catalogActive = false; this.api.getTwitchClipsFeed()?.subscribe({ next: (res: { items: Video[]; nextCursor?: string | null }) => { const raw: Video[] = (res.items || []).filter((v: Video) => !!v.videoId); @@ -952,8 +1086,71 @@ export class WatchShortComponent implements OnDestroy { }); return; } - // Shorts via le flux dédié (filtre durée côté serveur quand possible). - // Rotation : requête courante → suivante → trending filtré (infini). + this.loadCatalogFeed(); + } + + /** + * Voie catalogue : une page `/api/search?type=shorts` pour la requête + * courante, mappée vers le modèle du flux puis soumise aux mêmes portes que + * l'historique (`accepts()` : classification centrale + jouabilité + + * langue, enrichissement des durées YouTube inclus). Sous le seuil + * `SHORTS_CATALOG_MIN_ITEMS` — ou en erreur — on rejoue le chemin + * historique direct : le flux ne reste jamais vide pour une raison + * d'alimentation. + */ + private loadCatalogFeed(): void { + this.catalogActive = true; + this.catalogPage = 1; + this.catalogError.set(null); + const query = this.currentQuery(); + this.catalog.fetchCatalogShorts(this.provider(), query, { page: 1, pageSize: SHORTS_CATALOG_PAGE_SIZE }).then( + (res) => { + if (!this.catalogActive) return; // un rechargement a eu lieu entre-temps + if (res.providerError) this.catalogError.set(res.providerError); + const raw = (res.items || []) + .map((it) => catalogShortToVideo(it)) + .filter((v): v is Video => !!v?.videoId); + if (this.provider() === 'youtube' && raw.length) { + this.pickYouTubeShorts(raw).then( + (list) => this.showCatalogList(list.filter((v) => this.accepts(v)), query), + () => this.loadLegacyFeed(), + ); + return; + } + this.showCatalogList(raw.filter((v) => this.accepts(v)), query); + }, + () => this.loadLegacyFeed(), + ).catch(() => this.loadLegacyFeed()); + } + + /** Affiche un lot catalogue (mêmes invariants que la voie historique). */ + private showCatalogList(list: Video[], query: string): void { + if (!this.catalogActive) return; + if (list.length >= SHORTS_CATALOG_MIN_ITEMS) { + const deduped = this.dedup(list); + this.items.set(deduped); + this.index.set(0); + this.nextCursor.set(null); + this.error.set(null); + this.loading.set(false); + this.queueRumbleResolves(deduped); + this.ensureAvatar(deduped[0] || null); + if (deduped.length > 0 && deduped.length <= 4) this.fetchNextPage(false); + return; + } + // Lot trop maigre : la requête est marquée épuisée côté catalogue et on + // rejoue l'historique (qui a ses propres requêtes de secours + trending). + this.catalogActive = false; + this.loadLegacyFeed(); + } + + /** + * Chemin historique direct (requêtes `SHORTS_QUERIES` vers les API + * fournisseurs) : inchangé, il sert de repli au catalogue et reste la voie + * des cas sans équivalent catalogue. + */ + private loadLegacyFeed(): void { + this.catalogActive = false; this.shortsPage(this.currentQuery()).subscribe({ next: (res) => this.handleShortsResponse(res, true), error: () => this.tryNextShortsQueryOrTrending(), @@ -1159,6 +1356,14 @@ export class WatchShortComponent implements OnDestroy { private fetchNextPage(autoAdvance = false) { if (this.busyMore() || this.forYou()) return; const provider = this.provider(); + // Voie catalogue : pages `/api/search` successives de la requête courante, + // puis requête suivante de la rotation. Contrairement au chemin historique + // (curseurs opaques), la pagination est un simple compteur de pages. + if (this.catalogActive && provider !== 'twitch') { + this.busyMore.set(true); + this.fetchCatalogMore(this.currentQuery(), this.catalogPage + 1, autoAdvance); + return; + } // Cas Twitch : pagination du flux de clips (curseur streams) ou rotation. if (provider === 'twitch') { if (!this.nextCursor() && !this.hasMoreQueries() && this.items().length > 0) { @@ -1212,6 +1417,57 @@ export class WatchShortComponent implements OnDestroy { }); } + /** + * Page catalogue suivante (ajout au flux, jamais de remplacement). + * Page vide (tout est déjà vu) → requête suivante de la rotation ; rotation + * épuisée sans rien de neuf → on s'arrête proprement (pas de boucle de + * re-fetch : le cache 60 s resservirait les mêmes items, tous dédupliqués). + */ + private fetchCatalogMore(query: string, page: number, autoAdvance: boolean): void { + const provider = this.provider(); + this.catalog.fetchCatalogShorts(provider, query, { page, pageSize: SHORTS_CATALOG_PAGE_SIZE }).then( + (res) => { + if (!this.catalogActive) { this.busyMore.set(false); return; } + const raw = (res.items || []) + .map((it) => catalogShortToVideo(it)) + .filter((v): v is Video => !!v?.videoId); + const apply = (list: Video[]) => { + const deduped = this.dedup(list.filter((v) => this.accepts(v))); + if (deduped.length > 0) { + this.catalogPage = page; + const merged = this.items().concat(deduped); + this.items.set(merged); + this.queueRumbleResolves(deduped); + if (autoAdvance) this.index.update((i) => Math.min(i + 1, merged.length - 1)); + this.busyMore.set(false); + return; + } + const rotation = this.queriesFor(provider); + if (this.queryIndex < rotation.length - 1) { + this.queryIndex++; + this.catalogPage = 1; + this.fetchCatalogMore(this.currentQuery(), 1, autoAdvance); + } else { + // Rotation complète sans rien de neuf : fin du pré-chargement, + // le flux existant reste consultable (jamais de spinner infini). + this.queryIndex = 0; + this.catalogPage = 1; + this.busyMore.set(false); + } + }; + if (provider === 'youtube' && raw.length) { + this.pickYouTubeShorts(raw).then( + (list) => apply(list), + () => { this.busyMore.set(false); }, + ); + } else { + apply(raw); + } + }, + () => { this.busyMore.set(false); }, + ).catch(() => { this.busyMore.set(false); }); + } + /** Passe à la requête Twitch suivante (clips thématiques). */ private rotateTwitchQuery(autoAdvance = false): void { const list = this.queriesFor('twitch');