refactor(video): uniformiser classification et routage par section
CI / build-and-test (push) Successful in 14m9s
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:
@@ -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<string, ProviderId> = {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
@@ -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> = T & { _kind: VideoKind };
|
||||
|
||||
/** Enrichit un item une seule fois (à l'ingestion) avec sa classification. */
|
||||
export function classifyOnce<T extends ClassifiableVideo>(v: T): WithKind<T> {
|
||||
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;
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user