refactor(video): uniformiser classification et routage par section
CI / build-and-test (push) Successful in 14m9s

Source unique de classification (video-kind.ts) + politique de section centralisee (section-policy.ts). Les composants ne portent plus de seuils ni de regles de type maison.

- section-policy.ts: sectionAccepts / routeForVideo / shortsFeedAccepts /
  isPlayable + classifyOnce (enrichissement _kind a l'ingestion)
- watch-short: separe classification (shortsFeedAccepts) et jouabilite
  (isPlayable: allowEmbed DM, live/channel Twitch, Rumble 404); supprime le
  seuil Rumble 90 s et l'import YOUTUBE_SHORT_MAX_SECONDS du composant
- home: filtre sectionAccepts(...,'home') (plus de shorts dans le trending),
  routeForVideo, pagination bouclee (min 20, max 5 iterations)
- themes: _kind pre-calcule une fois, sectionAccepts, capacites live lues
  depuis le registre providers (fin de LIVE_PROVIDERS codes en dur)
- video-kind: helpers isNativeVerticalProvider / hasKnownDuration
- routes: alias /shorts/:id et /live/:id pour routeForVideo
- tests: section-policy.spec.ts + video-kind.spec.ts etendus; npm run test:section
This commit is contained in:
2026-09-29 13:34:58 -04:00
parent f443a470f9
commit 983c6cb5ab
15 changed files with 482 additions and 203 deletions
+34 -39
View File
@@ -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<Video>[];
}
@Component({
@@ -48,42 +49,37 @@ export class ThemePageComponent {
this.contentFilter.set(v);
}
/** Fournisseurs capables de fournir de vrais directs. */
private static readonly LIVE_PROVIDERS: Provider[] = ['youtube', 'twitch'];
/** Fournisseurs capables de fournir de vrais directs (source unique = registre). */
readonly providerSupportsLive = providerSupportsLive;
isLiveTheme(): boolean {
return this.themeSlug() === 'live';
}
isLiveProvider(p: Provider): boolean {
return (ThemePageComponent.LIVE_PROVIDERS as Provider[]).includes(p);
}
/** Items du bloc après application du filtre Vidéos/Shorts (+ LIVE seulement sur thème live). */
filteredItems(b: ProviderBlock): Video[] {
let items = b.items;
// Thème "En direct" : ne jamais mélanger avec des VODs (garde-fou si une API renvoie du non-live).
if (this.themeSlug() === 'live') {
items = items.filter(v => isLiveVideo({ ...v, provider: (v.provider as string) || b.provider }));
}
const f = this.contentFilter();
if (f === 'all') return items;
return items.filter(v =>
f === 'shorts' ? isShortVideo({ ...v, provider: (v.provider as string) || b.provider }) : !isShortVideo({ ...v, provider: (v.provider as string) || b.provider })
/** Enrichit une page : `_kind` calculé UNE fois à l'ingestion. */
private enrich(items: Video[], provider: Provider): WithKind<Video>[] {
return (items || []).map(v =>
classifyOnce({ ...(v as object), provider: (v.provider as string) || provider } as Video)
);
}
kindOf(v: Video, provider: Provider): string {
return classifyVideo({ ...v, provider: (v.provider as string) || provider });
/** Items du bloc après filtre Vidéos/Shorts (+ LIVE seulement sur thème live). */
filteredItems(b: ProviderBlock): Video[] {
let items: Video[] = b.items;
// Thème "En direct" : ne jamais mélanger avec des VODs (garde-fou si une API renvoie du non-live).
if (this.themeSlug() === 'live') {
items = items.filter(v => sectionAccepts(v, 'theme-live'));
}
const f = this.contentFilter();
if (f === 'all') return items;
return items.filter(v => f === 'shorts'
? sectionAccepts(v, 'theme-shorts')
: sectionAccepts(v, 'theme-videos'));
}
/** LIVE seulement si réellement en direct (les chaînes hors-ligne ne le sont pas). */
isLiveCard(v: Video, provider: Provider): boolean {
if ((v as any).isLive === true) return true;
if (String(v.type || '').toLowerCase() === 'live') return true;
if (String(v.type || '').toLowerCase() === 'channel') return false;
return isLiveVideo({ ...v, provider: (v.provider as string) || provider })
&& String(v.type || '').toLowerCase() !== 'channel';
isLiveCard(v: Video): boolean {
return v._kind === 'live' && !this.isChannelCard(v);
}
isChannelCard(v: Video): boolean {
@@ -91,14 +87,13 @@ export class ThemePageComponent {
return String(v.type || '').toLowerCase() === 'channel';
}
isClipCard(v: Video, provider: Provider): boolean {
if (this.isLiveCard(v, provider) || this.isChannelCard(v)) return false;
if (String((v as any).kind || '').toLowerCase() === 'clip') return true;
return isShortVideo({ ...v, provider: (v.provider as string) || provider });
isClipCard(v: Video): boolean {
if (this.isLiveCard(v) || this.isChannelCard(v)) return false;
return v._kind === 'short';
}
shortsCount(b: ProviderBlock): number {
return b.items.filter(v => isShortVideo({ ...v, provider: (v.provider as string) || b.provider })).length;
return b.items.filter(v => v._kind === 'short').length;
}
constructor() {
@@ -137,7 +132,7 @@ export class ThemePageComponent {
private fetchForProvider(block: ProviderBlock, slug: string) {
// Thème "En direct" : les fournisseurs sans live n'affichent aucune vidéo.
if (slug === 'live' && !this.isLiveProvider(block.provider)) {
if (slug === 'live' && !providerSupportsLive(block.provider)) {
block.loading = false;
block.error = null;
block.items = [];
@@ -168,7 +163,7 @@ export class ThemePageComponent {
if (slug === 'trending') {
this.api.getTrendingPage(null, block.provider).subscribe({
next: res => {
block.items = (res.items || []).slice(0, max);
block.items = this.enrich(res.items || [], block.provider).slice(0, max);
block.loading = false;
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug });
this.blocks.update(a => [...a]);
@@ -195,8 +190,8 @@ export class ThemePageComponent {
const mode = block.provider === 'youtube' ? 'youtube_eventType_live' : 'twitch_top_streams';
liveCall.subscribe({
next: res => {
block.items = (res.items || [])
.filter(v => isLiveVideo({ ...v, provider: (v.provider as string) || block.provider }))
block.items = this.enrich(res.items || [], block.provider)
.filter(v => v._kind === 'live')
.slice(0, max);
block.loading = false;
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, mode });
@@ -220,7 +215,7 @@ export class ThemePageComponent {
const raw = tokens.join(' ').trim();
const q = (raw.split(/[^A-Za-z0-9à-ÿ]+/).filter(w => w.length >= 3)[0] || raw).trim();
const show = (items: Video[]) => {
block.items = (items || []).slice(0, max);
block.items = this.enrich(items || [], block.provider).slice(0, max);
block.loading = false;
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, query: q });
this.blocks.update(a => [...a]);
@@ -254,7 +249,7 @@ export class ThemePageComponent {
// fallback to trending
this.api.getTrendingPage(null, block.provider).subscribe({
next: res => {
block.items = (res.items || []).slice(0, max);
block.items = this.enrich(res.items || [], block.provider).slice(0, max);
block.loading = false;
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, mode: 'fallback_trending' });
this.blocks.update(a => [...a]);
@@ -272,7 +267,7 @@ export class ThemePageComponent {
} else {
this.api.searchVideosPage(q, null, block.provider).subscribe({
next: res => {
block.items = (res.items || []).slice(0, max);
block.items = this.enrich(res.items || [], block.provider).slice(0, max);
block.loading = false;
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, query: q });
this.blocks.update(a => [...a]);