feat: détection short par orientation vidéo
CI / build-and-test (push) Successful in 14m6s

Règle partagée front/back : flag natif, verticale stricte (ratio ≤ 0.8, ≤ 90 s), verticale sans durée jamais short, repli durée (70 YT / 75 autres). Dimensions alimentées par Dailymotion/Odysee/PeerTube, flag maison Rumble supprimé, condition innertube morte simplifiée. Shorts : langues utilisateur (UI + transcripts) via relevanceLanguage/languageOneOf/Twitch language, son activé par défaut.
This commit is contained in:
2026-09-29 12:17:10 -04:00
parent 3f4cd56f14
commit f443a470f9
22 changed files with 454 additions and 73 deletions
+35 -13
View File
@@ -1,13 +1,16 @@
/**
* Classification partagée Vidéo / Short / Live.
*
* Règles (alignées front + back) :
* - `isShort === true` (flag natif) ⇒ Short, sauf durée connue > 75 s.
* - Twitch `kind === 'clip'` ⇒ Short (les clips font ≤ 60 s par construction).
* - YouTube : URL `/shorts/` ⇒ Short ; sinon durée 1..70 s ⇒ Short.
* - Autres providers : durée 1..75 s ⇒ Short.
* - IMPORTANT : une durée `0` / absente ne qualifie JAMAIS un Short
* (les scrapers renvoient 0 quand la durée est inconnue).
* 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';
@@ -19,11 +22,20 @@ export interface ClassifiableVideo {
provider?: string | null;
isShort?: boolean | null;
isLive?: boolean | null;
isVertical?: boolean | null;
width?: number | null;
height?: number | 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;
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;
@@ -34,18 +46,28 @@ export function isShortVideo(v: ClassifiableVideo | null | undefined): boolean {
const url = String((v as { url?: unknown }).url || '');
const provider = String((v as { provider?: unknown }).provider || '').toLowerCase();
// Clips Twitch = équivalent Shorts (durée max 60 s côté Twitch).
// 1) Clips Twitch = équivalent Shorts.
if (kind === 'clip') return true;
if (type === 'short') return true;
// Flag natif (Rumble router, YouTube, …) — ignoré si la durée connue le contredit.
if ((v as { isShort?: unknown }).isShort === 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 (url.includes('/shorts/')) return true;
if (provider === 'youtube') return duration <= YOUTUBE_SHORT_MAX_SECONDS;
return duration <= SHORT_MAX_SECONDS;
}