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
110 lines
4.3 KiB
TypeScript
110 lines
4.3 KiB
TypeScript
/**
|
|
* Classification partagée Vidéo / Short / Live.
|
|
*
|
|
* Miroir exact de `isShortItem()` (`server/search-filters.mjs`). Ordre :
|
|
* 1. `kind === 'clip'` (Twitch) ⇒ Short (les clips font ≤ 60 s par
|
|
* construction ; une durée aberrante est du bruit, pas une preuve).
|
|
* 2. Flag natif (`isShort`, `type === 'short'`, URL `/shorts/`) ⇒ Short,
|
|
* sauf durée connue > SHORT_MAX_SECONDS.
|
|
* 3. Orientation verticale stricte (`height > width`, ratio ≤ 0.8) ET durée
|
|
* connue ≤ VERTICAL_SHORT_MAX_SECONDS (90 s) ⇒ Short.
|
|
* 4. Verticale SANS durée connue ⇒ PAS un Short (même doctrine que
|
|
* « durée 0 ne qualifie jamais »).
|
|
* 5. Repli durée seule : 1..70 s (YouTube), 1..75 s (autres).
|
|
*/
|
|
export type VideoKind = 'short' | 'live' | 'video';
|
|
|
|
export interface ClassifiableVideo {
|
|
duration?: number | null;
|
|
type?: string | null;
|
|
kind?: string | null;
|
|
url?: string | null;
|
|
provider?: string | null;
|
|
isShort?: boolean | null;
|
|
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;
|
|
export const YOUTUBE_SHORT_MAX_SECONDS = 70;
|
|
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;
|
|
}
|
|
|
|
export function isShortVideo(v: ClassifiableVideo | null | undefined): boolean {
|
|
if (!v) return false;
|
|
const duration = Number((v as { duration?: unknown }).duration ?? 0);
|
|
const hasKnownDuration = Number.isFinite(duration) && duration > 0;
|
|
const type = String((v as { type?: unknown }).type || '').toLowerCase();
|
|
const kind = String((v as { kind?: unknown }).kind || '').toLowerCase();
|
|
const url = String((v as { url?: unknown }).url || '');
|
|
const provider = String((v as { provider?: unknown }).provider || '').toLowerCase();
|
|
|
|
// 1) Clips Twitch = équivalent Shorts.
|
|
if (kind === 'clip') return true;
|
|
|
|
// 2) Flag natif — ignoré si la durée connue le contredit.
|
|
if ((v as { isShort?: unknown }).isShort === true || type === 'short' || url.includes('/shorts/')) {
|
|
if (hasKnownDuration && duration > SHORT_MAX_SECONDS) return false;
|
|
return true;
|
|
}
|
|
|
|
// 3) + 4) Orientation (width/height quand le provider les expose).
|
|
const w = numOrZero((v as { width?: unknown }).width);
|
|
const h = numOrZero((v as { height?: unknown }).height);
|
|
if (w > 0 && h > 0) {
|
|
const vertical = h > w && w / h <= VERTICAL_MAX_RATIO;
|
|
if (vertical) {
|
|
if (!hasKnownDuration) return false;
|
|
return duration <= VERTICAL_SHORT_MAX_SECONDS;
|
|
}
|
|
}
|
|
|
|
// 5) Repli durée seule (comportement historique inchangé).
|
|
if (!hasKnownDuration) return false;
|
|
if (provider === 'youtube') return duration <= YOUTUBE_SHORT_MAX_SECONDS;
|
|
return duration <= SHORT_MAX_SECONDS;
|
|
}
|
|
|
|
export function isLiveVideo(v: ClassifiableVideo | null | undefined): boolean {
|
|
if (!v) return false;
|
|
if ((v as { isLive?: unknown }).isLive === true) return true;
|
|
const type = String((v as { type?: unknown }).type || '').toLowerCase();
|
|
return type === 'live' || type === 'stream' || type === 'channel';
|
|
}
|
|
|
|
export function classifyVideo(v: ClassifiableVideo | null | undefined): VideoKind {
|
|
if (isShortVideo(v)) return 'short';
|
|
if (isLiveVideo(v)) return 'live';
|
|
return 'video';
|
|
}
|