diff --git a/package.json b/package.json index cfe25f1..5b58373 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "test:history": "node server/tests/history_filters.test.mjs", "test:highlight": "node --loader ts-node/esm --experimental-specifier-resolution=node src/components/account/history/highlight.util.spec.ts", "test:kind": "node --loader ts-node/esm --experimental-specifier-resolution=node src/app/shared/utils/video-kind.spec.ts", + "test:section": "node --loader ts-node/esm --experimental-specifier-resolution=node src/app/shared/utils/section-policy.spec.ts", "test:ytscrape": "node server/tests/youtube-scrape.test.mjs", "test:ytinnertube": "node server/tests/youtube-innertube.test.mjs", "ytdlp:update": "yt-dlp -U || python3 -m yt_dlp -U || echo \"yt-dlp update: installez yt-dlp puis relancez\"" diff --git a/src/app.routes.ts b/src/app.routes.ts index 678e3c1..d4740a2 100644 --- a/src/app.routes.ts +++ b/src/app.routes.ts @@ -23,6 +23,17 @@ export const APP_ROUTES: Routes = [ loadComponent: () => import('./components/shorts/watch-short.component').then(m => m.WatchShortComponent), title: 'NewTube - Shorts' }, + // Alias de deep-link produits par `routeForVideo` (mêmes pages Watch). + { + path: 'shorts/:id', + loadComponent: () => import('./components/watch/watch.component').then(m => m.WatchComponent), + title: 'NewTube - Short' + }, + { + path: 'live/:id', + loadComponent: () => import('./components/watch/watch.component').then(m => m.WatchComponent), + title: 'NewTube - Live' + }, { path: 'channel/:provider/:externalId', loadComponent: () => import('./components/channel/channel-page.component').then(m => m.ChannelPageComponent), diff --git a/src/app/core/providers/provider-registry.ts b/src/app/core/providers/provider-registry.ts index 344ae4c..e9fc86a 100644 --- a/src/app/core/providers/provider-registry.ts +++ b/src/app/core/providers/provider-registry.ts @@ -38,3 +38,19 @@ export const PROVIDERS: ProviderSpec[] = [ ]; export const PROVIDER_MAP = new Map(PROVIDERS.map(p => [p.id, p])); + +/** Provider long (instance.service) -> id court du registre. */ +const LONG_TO_SHORT: Record = { + youtube: 'yt', dailymotion: 'dm', twitch: 'tw', peertube: 'pt', rumble: 'ru', odysee: 'od', +}; + +/** + * Le provider supporte-t-il le direct ? Source unique = le registre. + * Provider inconnu ⇒ false (jamais de crash), l'ajout d'un provider live + * ne touche que ce registre. + */ +export function providerSupportsLive(provider?: string | null): boolean { + const key = String(provider || '').toLowerCase(); + const id = LONG_TO_SHORT[key] || (key as ProviderId); + return PROVIDER_MAP.get(id)?.supports.live === true; +} diff --git a/src/app/shared/utils/section-policy.spec.ts b/src/app/shared/utils/section-policy.spec.ts new file mode 100644 index 0000000..b03c2f0 --- /dev/null +++ b/src/app/shared/utils/section-policy.spec.ts @@ -0,0 +1,92 @@ +import { + sectionAccepts, routeForVideo, shortsFeedAccepts, shortsFeedAcceptsWith, isPlayable, classifyOnce, +} from './section-policy'; +import { classifyVideo } from './video-kind'; + +/** + * Politique de section (classification + routage + jouabilité). + * Run with: npm run test:section + */ + +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}`); + } +} + +// --- Classification pure (rappel : miroir de video-kind) --- +assertEqual(classifyVideo({ duration: 60, width: 608, height: 1080 }), 'short', 'verticale 60 s ⇒ short'); +// NB : la doctrine centrale qualifie AUSSI une horizontale ≤ 75 s de short +// (repli durée historique) ; on prend donc une vraie vidéo longue comme référence. +assertEqual(classifyVideo({ duration: 200, width: 1280, height: 720 }), 'video', 'horizontale 200 s ⇒ video'); +assertEqual(classifyVideo({ provider: 'rumble' }), 'video', 'durée inconnue provider muet ⇒ video'); +assertEqual(classifyVideo({ isLive: true }), 'live', 'live natif ⇒ live'); + +// --- classifyOnce --- +const enriched = classifyOnce({ duration: 60, width: 608, height: 1080, videoId: 's1' } as any); +assertEqual(enriched._kind, 'short', 'classifyOnce pose _kind'); +assertEqual(classifyOnce({ duration: 500, videoId: 'v1' } as any)._kind, 'video', 'classifyOnce 500 s ⇒ video'); + +// --- sectionAccepts --- +const vertical = { duration: 60, width: 608, height: 1080, videoId: 's1' }; +const horizontal = { duration: 200, width: 1280, height: 720, videoId: 'v1' }; +const live = { isLive: true, videoId: 'l1' }; + +assertEqual(sectionAccepts(vertical, 'home'), false, 'verticale ⇒ exclue de home'); +assertEqual(sectionAccepts(vertical, 'theme-shorts'), true, 'verticale ⇒ incluse theme-shorts'); +assertEqual(sectionAccepts(vertical, 'shorts-feed'), true, 'verticale ⇒ incluse shorts-feed'); +assertEqual(sectionAccepts(horizontal, 'home'), true, 'horizontale ⇒ incluse home'); +assertEqual(sectionAccepts(horizontal, 'theme-videos'), true, 'horizontale ⇒ incluse theme-videos'); +assertEqual(sectionAccepts(horizontal, 'theme-shorts'), false, 'horizontale ⇒ exclue theme-shorts'); +assertEqual(sectionAccepts(live, 'home'), false, 'live ⇒ exclu home'); +assertEqual(sectionAccepts(live, 'theme-live'), true, 'live ⇒ inclus theme-live'); +assertEqual(sectionAccepts({ type: 'channel', videoId: 'c1' }, 'theme-live'), false, 'channel hors-live ⇒ exclu theme-live'); +assertEqual(sectionAccepts({ provider: 'rumble', videoId: 'r1' }, 'home'), true, 'durée inconnue muette ⇒ home (video)'); + +// `_kind` pré-calculé prime sur classifyVideo (sinon 500 s serait 'video'). +assertEqual( + sectionAccepts({ duration: 500, _kind: 'short', videoId: 'x' } as any, 'home'), + false, + '_kind pré-calculé prioritaire (pas de reclassification)', +); + +// --- routeForVideo --- +assertEqual(routeForVideo(vertical, 'shorts-feed'), '/shorts/s1', 'short ⇒ /shorts/:id'); +assertEqual(routeForVideo(live, 'theme-live'), '/live/l1', 'live ⇒ /live/:id'); +assertEqual(routeForVideo(horizontal, 'home'), '/watch/v1', 'video ⇒ /watch/:id'); + +// --- shortsFeedAccepts (feed uniquement) --- +assertEqual(shortsFeedAccepts(vertical, 'youtube'), true, 'short classifié ⇒ accepté'); +assertEqual( + shortsFeedAccepts({ provider: 'rumble', videoId: 'r1' }, 'rumble'), + true, + 'durée inconnue Rumble (option ON) ⇒ accepté', +); +assertEqual( + shortsFeedAcceptsWith({ provider: 'rumble', videoId: 'r1' }, 'rumble', false), + false, + 'durée inconnue Rumble (option OFF) ⇒ refusé', +); +assertEqual( + shortsFeedAccepts({ provider: 'youtube', videoId: 'y1' }, 'youtube'), + false, + 'durée inconnue provider non-vertical ⇒ refusé', +); + +// --- isPlayable (jouabilité) --- +assertEqual( + isPlayable({ raw: { allowEmbed: false } }, { provider: 'dailymotion' }), + false, + 'Dailymotion allowEmbed=false ⇒ non jouable', +); +assertEqual(isPlayable({ type: 'channel' } as any, { provider: 'twitch' }), false, 'Twitch chaîne ⇒ non jouable'); +assertEqual(isPlayable({ isLive: true } as any, { provider: 'twitch' }), false, 'Twitch live ⇒ non jouable'); +assertEqual(isPlayable({ type: 'video' } as any, { provider: 'twitch' }), true, 'Twitch clip/VOD ⇒ jouable'); +assertEqual(isPlayable({ videoId: 'r1' } as any, { provider: 'rumble', rumbleFailed: true }), false, 'Rumble 404 ⇒ non jouable'); +assertEqual(isPlayable({ videoId: 'r1' } as any, { provider: 'rumble' }), true, 'Rumble non résolu ⇒ jouable (attente asynchrone)'); +assertEqual(isPlayable({ duration: 60 }, { provider: 'youtube' }), true, 'cas nominal ⇒ jouable'); diff --git a/src/app/shared/utils/section-policy.ts b/src/app/shared/utils/section-policy.ts new file mode 100644 index 0000000..c825a1a --- /dev/null +++ b/src/app/shared/utils/section-policy.ts @@ -0,0 +1,152 @@ +/** + * Politique de routage et d'acceptation par section. + * + * Centralise ce que les pages faisaient chacune dans leur coin : + * - quelle classification une section accepte (`sectionAccepts`) ; + * - vers quelle URL pointer une carte (`routeForVideo`) ; + * - la tolérance « durée inconnue » propre au feed Shorts (`shortsFeedAccepts`) ; + * - la jouabilité d'un item (embed réellement lisible) (`isPlayable`). + * + * Lire d'abord `_kind` s'il est pré-calculé à l'ingestion ; sinon classifier + * à la volée (repli pour les appelants qui n'ont pas encore enrichi). + */ +import { + classifyVideo, + hasKnownDuration, + isNativeVerticalProvider, + type ClassifiableVideo, + type VideoKind, +} from './video-kind'; + +export type SectionKind = + | 'home' + | 'theme-videos' + | 'theme-shorts' + | 'theme-live' + | 'shorts-feed' + | 'search' + | 'watch-related'; + +/** Un item enrichi à l'ingestion : `_kind` évite de reclassifier à chaque render. */ +export type ClassifiedVideo = ClassifiableVideo & { _kind?: VideoKind }; + +/** Item dont la classification est garantie présente. */ +export type WithKind = T & { _kind: VideoKind }; + +/** Enrichit un item une seule fois (à l'ingestion) avec sa classification. */ +export function classifyOnce(v: T): WithKind { + return { ...v, _kind: classifyVideo(v) }; +} + +/** + * La section accepte-t-elle cet item ? `_kind` pré-calculé prime ; sinon on + * classifie à la volée (repli documenté). + */ +export function sectionAccepts(v: ClassifiedVideo, section: SectionKind): boolean { + const kind = v._kind ?? classifyVideo(v); + switch (section) { + case 'home': + // Trending = vidéos classiques uniquement ; les shorts verticaux + // appartiennent à /shorts (lien latéral existant, pas de nouveau bloc). + return kind === 'video'; + case 'theme-videos': + return kind === 'video'; + case 'theme-shorts': + return kind === 'short'; + case 'theme-live': + // Garde historique : type channel hors-live exclu. + return kind === 'live' && v.type !== 'channel'; + case 'shorts-feed': + return kind === 'short'; + case 'search': + case 'watch-related': + // Filtres backend déjà en place ; la recherche mélange volontairement. + return true; + default: { + // Exhaustivité TypeScript : un nouveau SectionKind doit casser ici. + const _exhaustive: never = section; + return false; + } + } +} + +/** + * Routage d'une carte. Un short affiché dans une carte pointe vers /shorts, + * un live vers /live, sinon /watch (routes déjà déclarées dans app.routes.ts). + */ +export function routeForVideo(v: ClassifiedVideo, _section: SectionKind): string { + const kind = v._kind ?? classifyVideo(v); + const id = v.id ?? v.videoId ?? ''; + if (kind === 'short') return `/shorts/${id}`; + if (kind === 'live') return `/live/${id}`; + return `/watch/${id}`; +} + +/** + * Tolérance flux-infini propre au feed Shorts ; ne PAS répliquer ailleurs. + * `true` = la doctrine centrale gagne pour les providers muets. + */ +export const SHORTS_FEED_ALLOW_UNKNOWN_VERTICAL = true; + +/** + * Le feed Shorts accepte-t-il cet item ? + * - classification centrale = short ⇒ oui ; + * - sinon, tolerance explicite : provider nativement vertical ET durée + * inconnue (Rumble/Odysee dont le scraper renvoie 0) ⇒ on garde pour ne + * jamais casser le défilement infini. + */ +export function shortsFeedAccepts(v: ClassifiableVideo, provider?: string | null): boolean { + return shortsFeedAcceptsWith(v, provider, SHORTS_FEED_ALLOW_UNKNOWN_VERTICAL); +} + +/** Variante testable : `allowUnknown` explicite l'option du feed. */ +export function shortsFeedAcceptsWith( + v: ClassifiableVideo, + provider: string | null | undefined, + allowUnknown: boolean, +): boolean { + const p = provider ?? v?.provider ?? null; + const withProvider = { ...v, provider: p }; + if (classifyVideo(withProvider) === 'short') return true; + if (!allowUnknown) return false; + return isNativeVerticalProvider(p) && !hasKnownDuration(v); +} + +/** Contexte de jouabilité, fourni par le composant (état de résolution). */ +export interface PlayableContext { + provider?: string | null; + /** Rumble : id d'embed résolu via /api/rumble/video (embed fiable). */ + rumbleResolved?: boolean; + /** Rumble : pré-validation 404 => la vidéo ne doit plus être proposée. */ + rumbleFailed?: boolean; +} + +/** + * L'embed répondra-t-il réellement ? (indépendant de la classification) + * - Dailymotion `allow_embed === false` ⇒ le player répond "Forbidden" ; + * - Twitch live / chaîne ⇒ jamais dans le feed Shorts (clips uniquement) ; + * - Rumble pré-validé en 404 ⇒ retiré. + * Un Rumble non encore résolu reste jouable : la résolution est asynchrone et + * laisser l'item visible évite de vider le flux infini pendant l'attente. + */ +export function isPlayable(v: ClassifiableVideo, ctx: PlayableContext = {}): boolean { + const p = (ctx.provider ?? v?.provider ?? '') as string; + const provider = String(p).toLowerCase(); + + // Dailymotion : embed désactivé par le propriétaire. + if (provider === 'dailymotion' && v?.raw?.allowEmbed === false) { + return false; + } + + // Twitch : les lives/chaînes ne sont jamais jouables dans le flux Shorts. + if (provider === 'twitch') { + const type = String(v?.type || '').toLowerCase(); + if (v?.isLive === true) return false; + if (type === 'live' || type === 'stream' || type === 'channel') return false; + } + + // Rumble : une pré-validation 404 est certaine => non jouable. + if (provider === 'rumble' && ctx.rumbleFailed === true) return false; + + return true; +} diff --git a/src/app/shared/utils/video-kind.spec.ts b/src/app/shared/utils/video-kind.spec.ts index e2acaab..21669c4 100644 --- a/src/app/shared/utils/video-kind.spec.ts +++ b/src/app/shared/utils/video-kind.spec.ts @@ -1,5 +1,6 @@ import { isShortVideo, isLiveVideo, classifyVideo, + hasKnownDuration, isNativeVerticalProvider, SHORT_MAX_SECONDS, YOUTUBE_SHORT_MAX_SECONDS, VERTICAL_SHORT_MAX_SECONDS, VERTICAL_MAX_RATIO, } from './video-kind'; @@ -74,5 +75,16 @@ assertEqual(isShortVideo(null), false, 'null ⇒ pas short'); assertEqual(isLiveVideo({ isLive: true }), true, 'isLive ⇒ live'); assertEqual(isLiveVideo({ type: 'channel' }), true, 'channel ⇒ live (direct ou chaîne)'); assertEqual(classifyVideo({ duration: 60, width: 608, height: 1080 }), 'short', 'classify verticale 60 s'); +assertEqual(classifyVideo({ duration: 200, width: 1280, height: 720 }), 'video', 'classify horizontale 200 s (repli durée historique : 60 s resterait short)'); assertEqual(classifyVideo({ duration: 500 }), 'video', 'classify 500 s'); +assertEqual(classifyVideo({ provider: 'rumble' }), 'video', 'classify durée inconnue provider muet'); assertEqual(classifyVideo({ isLive: true }), 'live', 'classify live'); + +// Helpers de seuil / providers verticaux (source unique pour le feed Shorts). +assertEqual(hasKnownDuration({ duration: 60 }), true, 'hasKnownDuration 60 s'); +assertEqual(hasKnownDuration({ duration: 0 }), false, 'hasKnownDuration 0'); +assertEqual(hasKnownDuration({ duration: null }), false, 'hasKnownDuration null'); +assertEqual(hasKnownDuration(null), false, 'hasKnownDuration null video'); +assertEqual(isNativeVerticalProvider('rumble'), true, 'Rumble vertical natif'); +assertEqual(isNativeVerticalProvider('odysee'), true, 'Odysee vertical natif'); +assertEqual(isNativeVerticalProvider('youtube'), false, 'YouTube non vertical natif'); diff --git a/src/app/shared/utils/video-kind.ts b/src/app/shared/utils/video-kind.ts index e79fa25..476b765 100644 --- a/src/app/shared/utils/video-kind.ts +++ b/src/app/shared/utils/video-kind.ts @@ -24,6 +24,10 @@ export interface ClassifiableVideo { isLive?: boolean | null; width?: number | null; height?: number | null; + videoId?: string | null; + id?: string | null; + /** Métadonnées brutes provider (ex. Dailymotion `allowEmbed`). */ + raw?: { allowEmbed?: boolean } | null; } export const SHORT_MAX_SECONDS = 75; @@ -32,6 +36,25 @@ export const VERTICAL_SHORT_MAX_SECONDS = 90; // ponytail: ratio 0.8 arbitré, 0.75 si de faux carrés Instagram apparaissent. export const VERTICAL_MAX_RATIO = 0.8; +/** + * Fournisseurs à production nativement verticale dont le scraper renvoie + * souvent une durée inconnue (0). La tolérance « durée inconnue » du flux + * Shorts s'appuie sur cette liste, PAS sur une règle de type (cf. + * `shortsFeedAccepts` dans section-policy.ts). + */ +export const NATIVE_VERTICAL_PROVIDERS = new Set(['rumble', 'odysee']); + +/** Le provider est-il nativement vertical (flux Shorts dédié) ? */ +export function isNativeVerticalProvider(provider?: string | null): boolean { + return NATIVE_VERTICAL_PROVIDERS.has(String(provider || '').toLowerCase()); +} + +/** Durée connue = nombre strictement positif (0 / null / undefined = inconnu). */ +export function hasKnownDuration(v: ClassifiableVideo | null | undefined): boolean { + const d = Number((v as { duration?: unknown })?.duration ?? 0); + return Number.isFinite(d) && d > 0; +} + function numOrZero(v: unknown): number { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : 0; diff --git a/src/components/home/home.component.html b/src/components/home/home.component.html index dad1987..b18d40b 100644 --- a/src/components/home/home.component.html +++ b/src/components/home/home.component.html @@ -21,7 +21,7 @@ } @else {
@for (video of trendingVideos(); track video.videoId) { - +
diff --git a/src/components/home/home.component.ts b/src/components/home/home.component.ts index 50fed07..b6255d9 100644 --- a/src/components/home/home.component.ts +++ b/src/components/home/home.component.ts @@ -11,6 +11,7 @@ import { TranslatePipe } from '../../pipes/translate.pipe'; import { LikeButtonComponent } from '../shared/components/like-button/like-button.component'; import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component'; import { DurationPipe } from '../../app/shared/pipes/duration.pipe'; +import { sectionAccepts, routeForVideo as routeForVideoPolicy, classifyOnce } from '../../app/shared/utils/section-policy'; @Component({ selector: 'app-home', @@ -71,6 +72,10 @@ export class HomeComponent { this.fetchNextPage(); } + // ponytail: garde-fous anti-boucle si le trending est saturé de shorts verticaux. + private static readonly HOME_PAGE_MIN = 20; + private static readonly HOME_MAX_ITERATIONS = 5; + fetchNextPage() { if (this.busyMore()) return; const readiness = this.instances.getProviderReadiness(); @@ -79,28 +84,47 @@ export class HomeComponent { return; } this.busyMore.set(true); + this.fetchHomePage(0); + } + + /** + * Le trending brut mélange shorts verticaux et vidéos : on classe chaque + * item UNE fois (`classifyOnce`) puis on n'accepte que les vidéos classiques + * (`sectionAccepts(..., 'home')`). On enchaîne les pages tant que la page + * reste incomplète, avec garde-fou pour ne jamais produire de vide silencieux. + */ + private fetchHomePage(iteration: number): void { this.apiService.getTrendingPage(this.nextCursor()).subscribe({ next: res => { - const merged = [...this.trendingVideos(), ...res.items]; - this.trendingVideos.set(merged); - this.nextCursor.set(res.nextCursor || null); - this.busyMore.set(false); - this.loading.set(false); - const provider = this.instances.selectedProvider(); - if (merged.length === 0 && !this.notice()) { - const readiness2 = this.instances.getProviderReadiness(); - if (!readiness2.ready) { - this.notice.set(readiness2.reason || 'Le provider sélectionné n\'est pas prêt.'); - } else if (provider === 'youtube') { - this.notice.set('Aucune vidéo tendance YouTube chargée. Vérifiez que votre YOUTUBE_API_KEY est valide et que les restrictions HTTP referrer incluent http://localhost:4200/*.'); - } else if (provider === 'peertube') { - const inst = this.instances.activePeerTubeInstance(); - this.notice.set(`PeerTube: les vidéos ne sont pas disponibles depuis l'instance "${inst}" pour le moment. Essayez une autre instance dans l'en-tête.`); - } else if (provider === 'rumble') { - const label = this.instances.selectedProviderLabel(); - this.notice.set(`Les vidéos ne sont pas disponibles pour le provider "${label}" pour le moment. Réessayez plus tard ou choisissez un autre provider.`); + const accepted = (res.items || []) + .map((v: Video) => classifyOnce(v)) + .filter(v => sectionAccepts(v, 'home')); + const merged = [...this.trendingVideos(), ...accepted]; + this.trendingVideos.set(merged); + this.nextCursor.set(res.nextCursor || null); + const hasMore = !!res.nextCursor; + const needMore = accepted.length === 0 || merged.length < HomeComponent.HOME_PAGE_MIN; + if (hasMore && needMore && iteration < HomeComponent.HOME_MAX_ITERATIONS - 1) { + this.fetchHomePage(iteration + 1); + return; + } + this.busyMore.set(false); + this.loading.set(false); + const provider = this.instances.selectedProvider(); + if (merged.length === 0 && !this.notice()) { + const readiness2 = this.instances.getProviderReadiness(); + if (!readiness2.ready) { + this.notice.set(readiness2.reason || 'Le provider sélectionné n\'est pas prêt.'); + } else if (provider === 'youtube') { + this.notice.set('Aucune vidéo tendance YouTube chargée. Vérifiez que votre YOUTUBE_API_KEY est valide et que les restrictions HTTP referrer incluent http://localhost:4200/*.'); + } else if (provider === 'peertube') { + const inst = this.instances.activePeerTubeInstance(); + this.notice.set(`PeerTube: les vidéos ne sont pas disponibles depuis l'instance "${inst}" pour le moment. Essayez une autre instance dans l'en-tête.`); + } else if (provider === 'rumble') { + const label = this.instances.selectedProviderLabel(); + this.notice.set(`Les vidéos ne sont pas disponibles pour le provider "${label}" pour le moment. Réessayez plus tard ou choisissez un autre provider.`); + } } - } }, // Never leave the infinite scroll stuck: release flags so scrolling retries. error: () => { @@ -115,6 +139,11 @@ export class HomeComponent { }); } + /** Routage d'une carte (policy centrale) : vidéo classique ⇒ /watch. */ + routeForVideo(v: Video): string { + return routeForVideoPolicy(v, 'home'); + } + formatViews(views: number): string { if (views >= 1_000_000_000) { return (views / 1_000_000_000).toFixed(1) + 'B'; diff --git a/src/components/shorts/watch-short.component.ts b/src/components/shorts/watch-short.component.ts index c547b5f..3914985 100644 --- a/src/components/shorts/watch-short.component.ts +++ b/src/components/shorts/watch-short.component.ts @@ -13,10 +13,7 @@ import { DownloadService } from '../../services/download.service'; import { SubscriptionsService, GROUP_ICON_KEYWORDS } from '../../services/subscriptions.service'; import { AuthService } from '../../services/auth.service'; import { Video } from '../../models/video.model'; -import { isShortVideo, YOUTUBE_SHORT_MAX_SECONDS } from '../../app/shared/utils/video-kind'; - -/** Durée max d'un Short Rumble natif (rumble.com/shorts : vertical ≤ 90 s). */ -const RUMBLE_SHORT_MAX_SECONDS = 90; +import { shortsFeedAccepts, isPlayable as isPlayablePolicy, type PlayableContext } from '../../app/shared/utils/section-policy'; /** Provider long (youtube…) -> id court d'abonnement ('yt'…). */ const SUB_PROVIDER_OF: Record = { @@ -374,10 +371,11 @@ export class WatchShortComponent implements OnDestroy { const ids = Array.from(new Set(list.map(v => v.videoId))).slice(0, 50); try { const durations = await firstValueFrom(this.api.getYouTubeDurations(ids)); - return list.filter(v => { - const d = durations?.[v.videoId] ?? 0; - return d > 0 && d <= YOUTUBE_SHORT_MAX_SECONDS; - }); + // Durée issue de videos.list appliquée à l'item, puis classification centrale. + return list.filter(v => shortsFeedAccepts( + { ...(v as object), duration: durations?.[v.videoId] ?? 0, provider: 'youtube' } as Video, + 'youtube', + )); } catch { return []; } @@ -390,15 +388,13 @@ export class WatchShortComponent implements OnDestroy { * Les durées -1 (= non-embeddable, erreur 153) sont toujours exclues. */ private async pickYouTubeShorts(list: Video[]): Promise { - const shorts = list.filter(v => - typeof v.duration === 'number' && v.duration > 0 && v.duration <= YOUTUBE_SHORT_MAX_SECONDS - ); + const shorts = list.filter(v => shortsFeedAccepts({ ...(v as object), provider: 'youtube' } as Video, 'youtube')); const allKnown = list.every(v => typeof v.duration === 'number' && v.duration !== 0); if (shorts.length > 0 || allKnown) return shorts; // Certaines durées manquent : on les récupère au lieu de jeter les vidéos. const missing = list.filter(v => typeof v.duration !== 'number' || v.duration === 0); const fetched = await this.filterYouTubeShorts(missing); - return [...shorts, ...fetched.filter(v => this.isShort(v))]; + return [...shorts, ...fetched.filter(v => this.accepts(v))]; } constructor() { @@ -815,7 +811,7 @@ export class WatchShortComponent implements OnDestroy { const byProvider: Video[][] = defs.map((d, i) => { const raw = ((pages[i]?.items || []) as Video[]).filter(v => !!v?.videoId); const list = raw.filter(v => - this.isShort({ ...v, provider: d.provider } as Video) && + this.accepts({ ...v, provider: d.provider } as Video) && (d.provider !== 'twitch' || this.shortsLangOk({ ...v, provider: d.provider } as Video)) ); // Mélange léger par sujet pour varier l'ordre à chaque visite. @@ -927,7 +923,7 @@ export class WatchShortComponent implements OnDestroy { next: (res: { items: Video[]; nextCursor?: string | null }) => { const raw: Video[] = (res.items || []).filter((v: Video) => !!v.videoId); // Langue utilisateur + double sécurité anti "time machine". - this.twitchShortsReady(raw.filter((v: Video) => this.isShort(v))).subscribe({ + this.twitchShortsReady(raw.filter((v: Video) => this.accepts(v))).subscribe({ next: (filtered) => { const list: Video[] = this.dedup(filtered); this.items.set(list); @@ -966,7 +962,7 @@ export class WatchShortComponent implements OnDestroy { this.api.searchTwitchClipsPage(q)?.subscribe({ next: (res) => { const raw = (res?.items || []).filter(v => !!v.videoId); - const shorts = raw.filter(v => this.isShort(v)); + const shorts = raw.filter(v => this.accepts(v)); // Langue utilisateur + validation (recherche non validée côté service). this.twitchShortsReady(shorts).subscribe({ next: (filtered) => { @@ -1021,7 +1017,7 @@ export class WatchShortComponent implements OnDestroy { }; if (this.provider() === 'youtube' && raw.length) { this.pickYouTubeShorts(raw).then(list => { - const shorts = this.dedup(list.filter(v => this.isShort(v))); + const shorts = this.dedup(list.filter(v => this.accepts(v))); if (shorts.length > 0) show(shorts); else if (allowAltQuery) this.tryNextShortsQueryOrTrending(); else this.loadTrendingFallback(); @@ -1031,7 +1027,7 @@ export class WatchShortComponent implements OnDestroy { }); return; } - const list = this.dedup(raw.filter(v => this.isShort(v))); + const list = this.dedup(raw.filter(v => this.accepts(v))); if (list.length > 0) show(list); else if (allowAltQuery) this.tryNextShortsQueryOrTrending(); else this.loadTrendingFallback(); @@ -1067,9 +1063,9 @@ export class WatchShortComponent implements OnDestroy { this.loading.set(false); }; if (p === 'youtube' && raw.length) { - this.filterYouTubeShorts(raw).then(list => apply(list.filter(v => this.isShort(v)))).catch(() => apply([])); + this.filterYouTubeShorts(raw).then(list => apply(list.filter(v => this.accepts(v)))).catch(() => apply([])); } else { - apply(raw.filter(v => this.isShort(v))); + apply(raw.filter(v => this.accepts(v))); } }, error: () => { @@ -1079,38 +1075,30 @@ export class WatchShortComponent implements OnDestroy { }); } - // Filtre Shorts — classification partagée, avec tolérance pour les - // providers dont les durées sont souvent inconnues (Rumble/Odysee : le - // scraper renvoie 0) afin de ne jamais afficher une page vide. - private isShort(v: Video): boolean { - const p = (v as any).provider || this.provider(); - // Dailymotion : embed désactivé par le propriétaire (allow_embed=false) - // => le player répond "Forbidden" : on exclut avant même l'embed. - if (String(p) === 'dailymotion' && (v as any)?.raw?.allowEmbed === false) return false; - if (String(p) === 'twitch') { - const kind = String((v as any).kind || '').toLowerCase(); - if (kind === 'clip') return true; - // Les lives/channels ne sont jamais des Shorts. - const type = String((v as any).type || '').toLowerCase(); - if (type === 'live' || type === 'channel' || type === 'stream') return false; + /** + * Le feed accepte-t-il cet item ? Séparation stricte : + * - classification : `shortsFeedAccepts` (centre video-kind/section-policy, + * tolérance « durée inconnue » Rumble/Odysee incluse) ; + * - jouabilité : `isPlayable` (allowEmbed, lives/chaînes Twitch, Rumble 404). + */ + private accepts(v: Video): boolean { + const p = String((v as any).provider || this.provider()).toLowerCase(); + const withProvider = { ...(v as object), provider: p } as Video; + return shortsFeedAccepts(withProvider, p) && this.isPlayable(withProvider); + } + + /** Jouabilité : l'embed répondra-t-il ? (indépendant du type de contenu) */ + private isPlayable(v: Video): boolean { + const p = String((v as any).provider || this.provider()).toLowerCase(); + const ctx: PlayableContext = { provider: p }; + if (p === 'rumble') { + const id = this.rumbleIdOf(v); + if (id) { + ctx.rumbleResolved = !!this.resolvedRumbleEmbed()[id]; + ctx.rumbleFailed = this.rumbleEmbedFailed.has(id); + } } - const d = Number((v as any).duration || 0); - if (String(p) === 'rumble') { - // Shorts natifs Rumble : vertical ≤ 90 s. Durée inconnue : on garde - // (le flux /shorts natif ne contient que du vertical). - if ((v as any).isShort === true && (d <= 0 || d <= RUMBLE_SHORT_MAX_SECONDS)) return true; - if (!(d > 0)) return true; - return d <= RUMBLE_SHORT_MAX_SECONDS; - } - if ((v as any).isShort === true) { - if (d > 0 && d > 75) return false; - return true; - } - if (String(p) === 'odysee' && !(d > 0)) { - // Durée inconnue : on garde la vidéo (flux infini) plutôt qu'un vide. - return true; - } - return isShortVideo({ ...(v as object), provider: p } as Parameters[0]); + return isPlayablePolicy(v, ctx); } /** @@ -1267,16 +1255,16 @@ export class WatchShortComponent implements OnDestroy { this.busyMore.set(false); }; if (p === 'youtube' && raw.length) { - this.pickYouTubeShorts(raw).then((list: Video[]) => apply(list.filter((v: Video) => this.isShort(v)))).catch(() => { apply([]); }); + this.pickYouTubeShorts(raw).then((list: Video[]) => apply(list.filter((v: Video) => this.accepts(v)))).catch(() => { apply([]); }); } else if (p === 'twitch' && raw.length) { // Langue utilisateur + clips paginés non tous validés côté service. - const shorts = raw.filter((v: Video) => this.isShort(v)); + const shorts = raw.filter((v: Video) => this.accepts(v)); this.twitchShortsReady(shorts).subscribe({ next: (clean) => apply(clean), error: () => apply(shorts), }); } else { - apply(raw.filter((v: Video) => this.isShort(v))); + apply(raw.filter((v: Video) => this.accepts(v))); } } diff --git a/src/components/themes/provider-theme-page.component.html b/src/components/themes/provider-theme-page.component.html index 277a9b6..792eb77 100644 --- a/src/components/themes/provider-theme-page.component.html +++ b/src/components/themes/provider-theme-page.component.html @@ -172,7 +172,7 @@
LIVE CLIP - SHORT + SHORT CHAÎNE
diff --git a/src/components/themes/provider-theme-page.component.ts b/src/components/themes/provider-theme-page.component.ts index 36d0afd..20f4296 100644 --- a/src/components/themes/provider-theme-page.component.ts +++ b/src/components/themes/provider-theme-page.component.ts @@ -12,7 +12,8 @@ import { DurationPipe } from '../../app/shared/pipes/duration.pipe'; import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component'; import { ChannelIdentityComponent } from '../../app/shared/components/channel-identity/channel-identity.component'; import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component'; -import { classifyVideo, isLiveVideo, isShortVideo } from '../../app/shared/utils/video-kind'; +import { classifyOnce, type WithKind } from '../../app/shared/utils/section-policy'; +import { providerSupportsLive } from '../../app/core/providers/provider-registry'; import { map, of, switchMap, catchError, forkJoin, Observable } from 'rxjs'; import { timeout } from 'rxjs/operators'; import { PagedResult } from '../../services/youtube-api.service'; @@ -73,7 +74,7 @@ export class ProviderThemePageComponent implements OnDestroy { date = signal<'any' | 'day' | 'week' | 'month' | 'year'>('any'); // Data state - allVideos = signal([]); + allVideos = signal[]>([]); nextCursor = signal(null); // Curseurs par section Twitch (les paginations Helix sont indépendantes). private twitchCursors = { live: null as string | null, vod: null as string | null, clip: null as string | null, channel: null as string | null }; @@ -96,39 +97,20 @@ export class ProviderThemePageComponent implements OnDestroy { return this.applyFiltersWithType(this.allVideos(), 'any'); } - twitchLives = computed(() => { - const p = this.provider(); - return this.twitchFilteredNoType().filter(v => - isLiveVideo({ ...v, provider: (v.provider as string) || p })); - }); - twitchVods = computed(() => { - const p = this.provider(); - return this.twitchFilteredNoType().filter(v => { - const w = { ...v, provider: (v.provider as string) || p }; - if (isLiveVideo(w)) return false; - if (isShortVideo(w)) return false; - if (String(v.type || '').toLowerCase() === 'channel') return false; - return true; - }); - }); - twitchClips = computed(() => { - const p = this.provider(); - return this.twitchFilteredNoType().filter(v => { - const w = { ...v, provider: (v.provider as string) || p }; - if (isLiveVideo(w)) return false; - if (String(v.type || '').toLowerCase() === 'channel') return false; - if (String((v as any).kind || '').toLowerCase() === 'clip') return true; - return isShortVideo(w); - }); - }); - twitchChannels = computed(() => { - const p = this.provider(); - return this.twitchFilteredNoType().filter(v => { - if ((v as any).isLive === true) return false; - if (isLiveVideo({ ...v, provider: (v.provider as string) || p })) return false; - return String(v.type || '').toLowerCase() === 'channel'; - }); - }); + // Sections Twitch regroupées sur `_kind` pré-calculé (les chaînes hors-ligne + // ont leur propre onglet, plus de doublon dans "En direct"). + twitchLives = computed(() => + this.twitchFilteredNoType().filter(v => v._kind === 'live' && !this.isChannelCard(v)) + ); + twitchVods = computed(() => + this.twitchFilteredNoType().filter(v => v._kind === 'video') + ); + twitchClips = computed(() => + this.twitchFilteredNoType().filter(v => v._kind === 'short') + ); + twitchChannels = computed(() => + this.twitchFilteredNoType().filter(v => this.isChannelCard(v)) + ); /** Onglet Twitch synchronisé avec le filtre "type" existant. */ twitchTab = computed<'all' | 'live' | 'video' | 'clip' | 'channel'>(() => { @@ -231,8 +213,7 @@ export class ProviderThemePageComponent implements OnDestroy { const isLiveTheme = themeSlug === 'live'; // Fournisseurs sans aucun direct : aucune vidéo, message explicite. - const LIVE_PROVIDERS: Provider[] = ['youtube', 'twitch']; - if (isLiveTheme && !LIVE_PROVIDERS.includes(provider)) { + if (isLiveTheme && !providerSupportsLive(provider)) { this.error.set('Ce fournisseur ne propose pas de directs.'); this.loading.set(false); this.loadingMore.set(false); @@ -262,15 +243,13 @@ export class ProviderThemePageComponent implements OnDestroy { next: (res: PagedResult
- LIVE - CLIP - SHORT + LIVE + CLIP + SHORT CHAÎNE
{{ v.duration | duration }}
-
+
{{ v.views | number }} spectateurs
-
+
diff --git a/src/components/themes/theme-page.component.ts b/src/components/themes/theme-page.component.ts index 81f93d4..6ef25e1 100644 --- a/src/components/themes/theme-page.component.ts +++ b/src/components/themes/theme-page.component.ts @@ -10,14 +10,15 @@ import { LikeButtonComponent } from '../shared/components/like-button/like-butto import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component'; import { ChannelIdentityComponent } from '../../app/shared/components/channel-identity/channel-identity.component'; import { DurationPipe } from '../../app/shared/pipes/duration.pipe'; -import { classifyVideo, isLiveVideo, isShortVideo } from '../../app/shared/utils/video-kind'; +import { sectionAccepts, classifyOnce, type WithKind } from '../../app/shared/utils/section-policy'; +import { providerSupportsLive } from '../../app/core/providers/provider-registry'; interface ProviderBlock { provider: Provider; label: string; loading: boolean; error: string | null; - items: Video[]; + items: WithKind