Files
NewTube/src/app/shared/utils/video-kind.ts
T
bruno 983c6cb5ab
CI / build-and-test (push) Successful in 14m9s
refactor(video): uniformiser classification et routage par section
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
2026-09-29 13:34:58 -04:00

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';
}