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
+1
View File
@@ -25,6 +25,7 @@
"test:transcript": "node --test server/tests/transcript.test.mjs",
"test:history": "node server/tests/history_filters.test.mjs",
"test:highlight": "node --loader ts-node/esm --experimental-specifier-resolution=node src/components/account/history/highlight.util.spec.ts",
"test:kind": "node --loader ts-node/esm --experimental-specifier-resolution=node src/app/shared/utils/video-kind.spec.ts",
"test:ytscrape": "node server/tests/youtube-scrape.test.mjs",
"test:ytinnertube": "node server/tests/youtube-innertube.test.mjs",
"ytdlp:update": "yt-dlp -U || python3 -m yt_dlp -U || echo \"yt-dlp update: installez yt-dlp puis relancez\""
+22 -14
View File
@@ -20,7 +20,7 @@ const handler = {
page: Math.max(1, Number(page || 1)).toString(),
// Map our sort to Dailymotion API sort: relevance | visited (views) | recent (date)
sort: (sort === 'date' ? 'recent' : (sort === 'views' ? 'visited' : 'relevance')),
fields: 'id,title,thumbnail_url,thumbnail_360_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,owner.id,owner.screenname,owner.avatar_80_url,created_time'
fields: 'id,title,thumbnail_url,thumbnail_360_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,width,height,owner.id,owner.screenname,owner.avatar_80_url,created_time'
})
);
@@ -30,19 +30,27 @@ const handler = {
const data = await response.json();
return (data.list || []).map(item => ({
title: item.title || '',
id: item.id || '',
url: `https://www.dailymotion.com/video/${item.id}`,
thumbnail: item.thumbnail_720_url || item.thumbnail_480_url || item.thumbnail_360_url || item.thumbnail_url || '',
uploaderName: item['owner.screenname'] || item['owner.username'] || '',
channelId: item['owner.id'] || item['owner.username'] || undefined,
uploaderAvatar: item['owner.avatar_80_url'] || '',
duration: Number(item.duration || 0),
views: Number(item.views_total || 0),
uploadedDate: item.created_time ? new Date(item.created_time * 1000).toISOString() : '',
type: 'video'
}));
return (data.list || []).map(item => {
// Dimensions d'origine (signal d'orientation pour isShortItem).
// Défensif : le champ n'est pas garanti sur toutes les réponses.
const w = Number(item.width);
const h = Number(item.height);
return {
title: item.title || '',
id: item.id || '',
url: `https://www.dailymotion.com/video/${item.id}`,
thumbnail: item.thumbnail_720_url || item.thumbnail_480_url || item.thumbnail_360_url || item.thumbnail_url || '',
uploaderName: item['owner.screenname'] || item['owner.username'] || '',
channelId: item['owner.id'] || item['owner.username'] || undefined,
uploaderAvatar: item['owner.avatar_80_url'] || '',
duration: Number(item.duration || 0),
views: Number(item.views_total || 0),
uploadedDate: item.created_time ? new Date(item.created_time * 1000).toISOString() : '',
type: 'video',
...(Number.isFinite(w) && w > 0 ? { width: w } : {}),
...(Number.isFinite(h) && h > 0 ? { height: h } : {}),
};
});
} catch (error) {
console.error('Dailymotion search error:', error);
return [];
+7 -1
View File
@@ -40,6 +40,10 @@ const handler = {
const name = item.name || '';
const claimId = item.claimId || item.claim_id || '';
const urlSegment = claimId ? `${encodeURIComponent(name)}:${claimId}` : encodeURIComponent(name);
// Dimensions vidéo (signal d'orientation pour isShortItem) quand
// lighthouse les expose — lecture défensive, aucun appel en plus.
const vw = Number(item.video?.width ?? item.video?.video_width ?? item.width);
const vh = Number(item.video?.height ?? item.video?.video_height ?? item.height);
return {
title: item.title || name,
@@ -50,7 +54,9 @@ const handler = {
type: 'video',
duration: typeof item.duration === 'number' && item.duration > 0
? Math.round(item.duration)
: (typeof item.video?.duration === 'number' && item.video.duration > 0 ? Math.round(item.video.duration) : undefined)
: (typeof item.video?.duration === 'number' && item.video.duration > 0 ? Math.round(item.video.duration) : undefined),
...(Number.isFinite(vw) && vw > 0 ? { width: vw } : {}),
...(Number.isFinite(vh) && vh > 0 ? { height: vh } : {}),
};
});
} catch (error) {
+17 -1
View File
@@ -37,6 +37,20 @@ const handler = {
? `https:${rawThumb}`
: new URL(rawThumb, item.url || 'https://sepiasearch.org').toString()
: undefined;
// Dimensions max des fichiers (signal d'orientation pour isShortItem).
// Défensif : sepiasearch n'expose pas toujours `files`.
let fw = 0;
let fh = 0;
try {
for (const f of (Array.isArray(item.files) ? item.files : [])) {
const w = Number(f?.width);
const h = Number(f?.height);
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0 && w * h >= fw * fh) {
fw = w;
fh = h;
}
}
} catch {}
return {
title: item.name,
@@ -46,7 +60,9 @@ const handler = {
uploaderName: (item.account && (item.account.displayName || item.account.name)) || undefined,
channelId: (item.channel && (item.channel.name || item.channel.uuid)) || (item.account && item.account.name) || undefined,
type: 'video',
duration: typeof item.duration === 'number' && item.duration > 0 ? Math.round(item.duration) : undefined
duration: typeof item.duration === 'number' && item.duration > 0 ? Math.round(item.duration) : undefined,
...(fw > 0 ? { width: fw } : {}),
...(fh > 0 ? { height: fh } : {}),
};
});
} catch (error) {
+2
View File
@@ -11,6 +11,8 @@
* @property {string=} thumbnail
* @property {string=} uploaderName
* @property {string=} type
* @property {number=} width (largeur vidéo en px, quand le provider l'expose)
* @property {number=} height (hauteur vidéo en px, quand le provider l'expose)
*/
/** @typedef {'yt'|'dm'|'tw'|'pt'|'od'|'ru'} ProviderId */
+12 -12
View File
@@ -221,18 +221,18 @@ function parseSearchHtml(html, { limit = 50 } = {}) {
}
const viewsText = $el.find('.video-item--views').first().text().trim();
const views = Number(String(viewsText).replace(/[^\d]/g, '')) || undefined;
items.push({
title: title || url,
id,
url,
thumbnail: normalizeThumb(rawThumbnail),
uploaderName: uploaderName || undefined,
views,
type: 'video',
duration: durationSeconds,
// Pas de flag natif côté Rumble : durée courte (1..75 s) ⇒ Short probable.
isShort: typeof durationSeconds === 'number' && durationSeconds > 0 && durationSeconds <= 75,
});
items.push({
title: title || url,
id,
url,
thumbnail: normalizeThumb(rawThumbnail),
uploaderName: uploaderName || undefined,
views,
type: 'video',
duration: durationSeconds,
// Pas de flag maison ici : le filtre central (isShortItem,
// durée ≤75 s) tranche. Le scraper HTML n'expose pas de dimensions.
});
});
return items;
}
+5 -1
View File
@@ -136,7 +136,11 @@ export function mapVideoNode(n) {
|| bestThumb(n.thumbnail)
|| bestThumb(n.on_tap_endpoint?.payload?.thumbnail?.thumbnails)
|| undefined;
const isShort = /short|reel/i.test(type) || (Number.isFinite(duration) && duration > 0 && duration <= 70 && /short/i.test(title) === false && /reel|short/i.test(type));
// Un ShortsLockupView / ReelItem EST un short par construction. L'ancienne
// disjonction `|| (durée≤70 && titre sans "short" && type reel|short)` était
// morte : subsumée par le premier test (A || (B && C && A) === A), et son
// `!/short/i.test(title)` excluait à tort les shorts titrés « …shorts ».
const isShort = /short|reel/i.test(type);
const badges = Array.isArray(n.badges) ? n.badges.map((b) => b?.label).filter(Boolean) : [];
return {
title: String(title),
+72 -8
View File
@@ -151,12 +151,73 @@ export function isLiveItem(item) {
return String(item.type || '').toLowerCase() === 'live';
}
export function isShortItem(item) {
/**
* Seuils partagés de détection Short (miroir de `src/app/shared/utils/video-kind.ts`).
* - SHORT_MAX_SECONDS : durée seule, repli historique (70 YouTube, 75 autres).
* - VERTICAL_SHORT_MAX_SECONDS : une verticale reste un "short" jusqu'à 90 s
* (au-delà c'est une vidéo verticale, pas un short).
* - VERTICAL_MAX_RATIO : width/height ≤ 0.8 (les quasi-carrés sont exclus).
*/
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) {
const n = Number(v);
return Number.isFinite(n) && n > 0 ? n : 0;
}
/**
* Classification Vidéo / Short, source de vérité côté serveur (miroir exact
* de `isShortVideo()` front). Ordre :
* 1. flag natif (isShort / type 'short' / kind 'clip') — contredit par une
* durée connue > SHORT_MAX_SECONDS ;
* 2. orientation verticale stricte + durée connue ≤ 90 s ;
* 3. verticale SANS durée connue ⇒ PAS un short (même doctrine que
* « durée 0 ne qualifie jamais » : un faux positif pollue le swipe) ;
* 4. repli durée seule (≤70 YouTube, ≤75 autres).
*/
export function isShortItem(item, providerId) {
if (!item) return false;
if (item.isShort === true) return true;
if (String(item.kind || '').toLowerCase() === 'clip') return true;
const d = itemDurationSec(item);
return d > 0 && d <= 70 && !/short/i.test(String(item.title || ''));
const hasKnownDuration = d > 0;
const type = String(item.type || '').toLowerCase();
const kind = String(item.kind || '').toLowerCase();
// 1) Flag natif — ignoré si la durée connue le contredit.
// Exception : `kind === 'clip'` (Twitch) est toujours un short : les clips
// font ≤ 60 s par construction, une durée > 75 s sur un clip est du bruit
// de métadonnées (le test historique « shorts keeps clips » l'impose).
if (kind === 'clip') return true;
if (item.isShort === true || type === 'short') {
if (hasKnownDuration && d > SHORT_MAX_SECONDS) return false;
return true;
}
// 2) + 3) Orientation (width/height quand le provider les expose).
const w = numOrZero(item.width);
const h = numOrZero(item.height);
if (w > 0 && h > 0) {
const vertical = h > w && (w / h) <= VERTICAL_MAX_RATIO;
if (vertical) {
if (!hasKnownDuration) return false; // règle 3 : pas de durée ⇒ pas short
return d <= VERTICAL_SHORT_MAX_SECONDS; // règle 2
}
}
// 4) Repli durée seule (comportement historique inchangé).
// NOTE : l'ancien code excluait les titres contenant « short »
// (`&& !/short/i.test(title)`), ce qui est l'inverse de l'intuition : un
// vrai Short titré « … #shorts » était EXCLU du filtre shorts. Les tests
// ne couvraient que des titres neutres (« x »), donc le bug passait
// inaperçu. Condition supprimée : la durée seule tranche désormais.
if (!hasKnownDuration) return false;
const max = String(providerId || '').toLowerCase() === 'youtube' || String(item.provider || '').toLowerCase() === 'youtube'
? YOUTUBE_SHORT_MAX_SECONDS
: SHORT_MAX_SECONDS;
return d <= max;
}
export function isChannelItem(item) {
@@ -170,11 +231,14 @@ function hasTypeSignal(item) {
if (item.isShort === true || item.isShort === false) return true;
if (String(item.type || '').trim()) return true;
if (String(item.kind || '').trim()) return true;
// Des dimensions connues sont un signal : l'item n'est jamais "muet",
// sinon une verticale 60 s apparaîtrait dans le filtre « vidéos ».
if (numOrZero(item.width) > 0 && numOrZero(item.height) > 0) return true;
return false;
}
/** Un item respecte-t-il les filtres ? (champs absents => jamais écarté) */
export function matchesFilters(item, filters) {
export function matchesFilters(item, filters, providerId) {
const n = normalizeFilters(filters);
if (n.type === 'all' && n.duration === 'all' && n.period === 'all') return true;
@@ -194,9 +258,9 @@ export function matchesFilters(item, filters) {
if (!hasTypeSignal(item)) return true; // provider muet : on ne peut pas exclure
if (n.type === 'live') return isLiveItem(item);
if (n.type === 'channel') return isChannelItem(item);
if (n.type === 'shorts') return isShortItem(item);
if (n.type === 'shorts') return isShortItem(item, providerId);
if (n.type === 'video') {
return !isLiveItem(item) && !isChannelItem(item) && !isShortItem(item);
return !isLiveItem(item) && !isChannelItem(item) && !isShortItem(item, providerId);
}
return true;
}
@@ -217,7 +281,7 @@ export function applySearchFilters(providerId, items, filters) {
if (wanted.length === 0) return items;
// `channel` : hors YouTube, on ne filtre pas (sinon résultat vide).
if (wanted.includes('type') && n.type === 'channel' && providerId !== 'yt') return items;
return items.filter((it) => matchesFilters(it, n));
return items.filter((it) => matchesFilters(it, n, providerId));
}
// ---------------------------------------------------------------------------
+71
View File
@@ -7,6 +7,7 @@ import {
normalizeFilters, parseSearchFilters, activeFilters, filtersCacheKey,
periodMs, durationBounds, matchesFilters, applySearchFilters,
innertubeSearchFilters, apiSearchParams, itemDurationSec, itemPublishedTs,
isShortItem, SHORT_MAX_SECONDS, VERTICAL_SHORT_MAX_SECONDS, VERTICAL_MAX_RATIO,
} from '../search-filters.mjs';
function expect(cond, msg) {
@@ -186,4 +187,74 @@ expect(
);
logOk('innertubeSearchFilters / apiSearchParams');
// ---------- Détection Short par orientation ----------
eq(typeof SHORT_MAX_SECONDS, 'number', 'seuils exportés');
eq(VERTICAL_SHORT_MAX_SECONDS, 90, 'verticale short jusqu’à 90 s');
eq(VERTICAL_MAX_RATIO, 0.8, 'ratio vertical max 0.8');
// Règle 2 : verticale 60 s (ex. repost TikTok 608x1080) ⇒ short.
expect(
isShortItem({ title: 'repost', duration: 60, width: 608, height: 1080, type: 'video' }),
'vertical 60s is a short',
);
// Règle 2 : verticale 200 s ⇒ PAS short (vidéo verticale, pas short).
expect(
!isShortItem({ title: 'vlog', duration: 200, width: 720, height: 1280, type: 'video' }),
'vertical 200s is not a short',
);
// Règle 2 : un carré 80 s (> 75 s) n'est PAS short : l'orientation seule
// (ratio ~1) ne suffit pas ET le repli durée ne s'applique plus.
expect(
!isShortItem({ title: 'clip carré', duration: 80, width: 1080, height: 1080, type: 'video' }),
'square 80s is not a short (orientation alone is not enough)',
);
// …mais à 30 s, la règle 4 (durée seule) le garde : non-régression historique.
expect(
matchesFilters({ id: 'a', title: 'clip carré', duration: 30, width: 1080, height: 1080, type: 'video' }, { type: 'shorts' }),
'square 30s still matches shorts via duration fallback',
);
// Règle 4 : horizontale 50 s ⇒ short (non-régression durée seule).
expect(
isShortItem({ title: 'paysage', duration: 50, width: 1280, height: 720, type: 'video' }),
'horizontal 50s is a short (duration fallback)',
);
// Règle 3 : verticale SANS durée connue ⇒ PAS short.
expect(
!isShortItem({ title: 'inconnu', width: 608, height: 1080, type: 'video' }),
'vertical without duration is not a short',
);
// Règle 1 : flag contredit par une durée > 75 s ⇒ PAS short.
expect(
!isShortItem({ title: 'faux short', duration: 300, isShort: true, type: 'video' }),
'native flag contradicted by duration is not a short',
);
// Règle 1 : un vrai Short titré « …shorts » n'est plus exclu (ancien bug
// `!/short/i.test(title)`).
expect(
isShortItem({ title: 'mon voyage #shorts', duration: 40, type: 'video' }),
'title containing shorts is kept',
);
// Seuil YouTube (70) vs autres (75) : 72 s.
expect(
!isShortItem({ title: 'yt', duration: 72, type: 'video', provider: 'youtube' }, 'yt'),
'youtube 72s is not a short',
);
expect(
isShortItem({ title: 'dm', duration: 72, type: 'video' }, 'dm'),
'dailymotion 72s is a short',
);
// §4 : une verticale 60 s ne remonte jamais dans le filtre « vidéos »…
expect(
!matchesFilters({ id: 'b', title: 'v', duration: 60, width: 608, height: 1080, type: 'video' }, { type: 'video' }),
'vertical 60s excluded from video filter',
);
// …et les dimensions comptent comme signal : l'item n'est plus "muet",
// donc le filtre `live` l'exclut (une verticale n'est pas un direct).
expect(
!matchesFilters({ id: 'c', title: 'v', duration: 0, width: 608, height: 1080 }, { type: 'live' }),
'dimensions are a signal: vertical without live flag is dropped from live',
);
logOk('isShortItem (orientation + durée)');
console.log('\nAll search-filters tests passed.');
+3
View File
@@ -32,6 +32,9 @@ export class DmAdapter implements ProviderAdapter<VideoItem> {
// Signaux de type pour le raffinement client des filtres.
type: typeof it.type === 'string' ? it.type : undefined,
isShort: it.isShort === true ? true : undefined,
// Dimensions (signal d'orientation pour la détection Short).
width: typeof it.width === 'number' ? it.width : undefined,
height: typeof it.height === 'number' ? it.height : undefined,
} as VideoItem));
return { items, total: Array.isArray(res?.groups?.dm) ? res.groups.dm.length : 0, providerError };
}
+3
View File
@@ -41,6 +41,9 @@ export class OdAdapter implements ProviderAdapter<VideoItem> {
// Signaux de type pour le raffinement client des filtres.
type: typeof it.type === 'string' ? it.type : undefined,
isShort: it.isShort === true ? true : undefined,
// Dimensions (signal d'orientation pour la détection Short).
width: typeof it.width === 'number' ? it.width : undefined,
height: typeof it.height === 'number' ? it.height : undefined,
} as VideoItem;
});
return { items, total: Array.isArray(res?.groups?.od) ? res.groups.od.length : 0, providerError };
+3
View File
@@ -38,6 +38,9 @@ export class PtAdapter implements ProviderAdapter<VideoItem> {
// Signaux de type pour le raffinement client des filtres.
type: typeof it.type === 'string' ? it.type : undefined,
isShort: it.isShort === true ? true : undefined,
// Dimensions (signal d'orientation pour la détection Short).
width: typeof it.width === 'number' ? it.width : undefined,
height: typeof it.height === 'number' ? it.height : undefined,
} as VideoItem;
});
return { items, total: Array.isArray(res?.groups?.pt) ? res.groups.pt.length : 0, providerError };
+3
View File
@@ -29,6 +29,9 @@ export class RuAdapter implements ProviderAdapter<VideoItem> {
// Signaux de type (Shorts / direct) pour le raffinement client.
type: typeof it.type === 'string' ? it.type : undefined,
isShort: it.isShort === true ? true : undefined,
// Dimensions (signal d'orientation pour la détection Short).
width: typeof it.width === 'number' ? it.width : undefined,
height: typeof it.height === 'number' ? it.height : undefined,
} as VideoItem));
return { items, total: Array.isArray(res?.groups?.ru) ? res.groups.ru.length : 0, providerError };
}
+3
View File
@@ -46,6 +46,9 @@ export class TwAdapter implements ProviderAdapter<VideoItem> {
isLive: typeof it.isLive === 'boolean' ? it.isLive : undefined,
kind: typeof it.kind === 'string' ? it.kind : undefined,
isShort: it.isShort === true ? true : undefined,
// Dimensions (signal d'orientation pour la détection Short).
width: typeof it.width === 'number' ? it.width : undefined,
height: typeof it.height === 'number' ? it.height : undefined,
game: typeof it.game === 'string' ? it.game : undefined,
} as VideoItem;
}).filter((v: VideoItem) => v.id != null && String(v.id).length > 0 && (v.thumbnailUrl || v.channelAvatarUrl));
+3
View File
@@ -34,6 +34,9 @@ export class YtAdapter implements ProviderAdapter<VideoItem> {
type: typeof it.type === 'string' ? it.type : undefined,
isLive: typeof it.isLive === 'boolean' ? it.isLive : undefined,
isShort: it.isShort === true ? true : undefined,
// Dimensions (signal d'orientation pour la détection Short).
width: typeof it.width === 'number' ? it.width : undefined,
height: typeof it.height === 'number' ? it.height : undefined,
} as VideoItem));
return { items, total: Array.isArray(res?.groups?.yt) ? res.groups.yt.length : 0, providerError };
}
+8
View File
@@ -378,7 +378,12 @@ export function isShortItem(item: any): boolean {
return isShortVideo({
duration: itemDurationSec(item),
type: typeof item.type === 'string' ? item.type : undefined,
kind: typeof item.kind === 'string' ? item.kind : undefined,
url: typeof item.url === 'string' ? item.url : undefined,
provider: typeof item.provider === 'string' ? item.provider : undefined,
isShort: item.isShort === true ? true : undefined,
width: typeof item.width === 'number' ? item.width : undefined,
height: typeof item.height === 'number' ? item.height : undefined,
});
}
@@ -401,6 +406,9 @@ function hasTypeSignal(item: any): boolean {
if (item.isShort === true || item.isShort === false) return true;
if (String(item.type || '').trim()) return true;
if (String(item.kind || '').trim()) return true;
// Des dimensions connues sont un signal (miroir back) : une verticale
// 60 s ne doit jamais apparaître dans le filtre « vidéos ».
if (typeof item.width === 'number' && item.width > 0 && typeof item.height === 'number' && item.height > 0) return true;
return false;
}
@@ -21,4 +21,6 @@ export interface VideoItem {
isShort?: boolean; // true when the provider flagged a Short/clip
kind?: string; // e.g. Twitch 'vod' | 'clip'
game?: string; // e.g. Twitch game/category name
width?: number; // video width in px (orientation signal, when exposed)
height?: number; // video height in px (orientation signal, when exposed)
}
+78
View File
@@ -0,0 +1,78 @@
import {
isShortVideo, isLiveVideo, classifyVideo,
SHORT_MAX_SECONDS, YOUTUBE_SHORT_MAX_SECONDS,
VERTICAL_SHORT_MAX_SECONDS, VERTICAL_MAX_RATIO,
} from './video-kind';
/**
* Classification Vidéo / Short (orientation + durée).
* Miroir de `server/tests/search-filters.test.mjs`.
* Run with: npm run test:kind
*/
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}`);
}
}
assertEqual(typeof SHORT_MAX_SECONDS, 'number', 'seuils exportés');
assertEqual(VERTICAL_SHORT_MAX_SECONDS, 90, 'verticale short jusqu’à 90 s');
assertEqual(VERTICAL_MAX_RATIO, 0.8, 'ratio vertical max 0.8');
assertEqual(YOUTUBE_SHORT_MAX_SECONDS, 70, 'seuil YouTube 70 s');
// Règle 1 : flag natif / clip.
assertEqual(isShortVideo({ duration: 40, isShort: true }), true, 'flag natif 40 s ⇒ short');
assertEqual(isShortVideo({ duration: 300, isShort: true }), false, 'flag contredit par 300 s ⇒ pas short');
assertEqual(isShortVideo({ duration: 100, kind: 'clip' }), true, 'clip Twitch ⇒ short');
assertEqual(isShortVideo({ duration: 45, type: 'short' }), true, "type 'short' ⇒ short");
assertEqual(isShortVideo({ duration: 45, url: 'https://www.youtube.com/shorts/abc' }), true, 'URL /shorts/ ⇒ short');
// Règle 2 : verticale 60 s (ex. 608x1080) ⇒ short.
assertEqual(
isShortVideo({ duration: 60, width: 608, height: 1080 }),
true,
'verticale 60 s ⇒ short',
);
// Règle 2 : verticale 200 s ⇒ PAS short.
assertEqual(
isShortVideo({ duration: 200, width: 720, height: 1280 }),
false,
'verticale 200 s ⇒ pas short',
);
// Règle 2 : carré 80 s (ratio ~1) ⇒ PAS short (l'orientation seule ne suffit pas).
assertEqual(
isShortVideo({ duration: 80, width: 1080, height: 1080 }),
false,
'carré 80 s ⇒ pas short',
);
// Règle 3 : verticale SANS durée ⇒ PAS short.
assertEqual(
isShortVideo({ width: 608, height: 1080 }),
false,
'verticale sans durée ⇒ pas short',
);
assertEqual(
isShortVideo({ duration: 0, width: 608, height: 1080 }),
false,
'verticale durée 0 ⇒ pas short',
);
// Règle 4 : repli durée seule (non-régression).
assertEqual(isShortVideo({ duration: 50, width: 1280, height: 720 }), true, 'horizontale 50 s ⇒ short');
assertEqual(isShortVideo({ duration: 50 }), true, '50 s sans dimensions ⇒ short');
assertEqual(isShortVideo({ duration: 72 }), true, '72 s non-YouTube ⇒ short (≤ 75)');
assertEqual(isShortVideo({ duration: 72, provider: 'youtube' }), false, '72 s YouTube ⇒ pas short (> 70)');
assertEqual(isShortVideo({ duration: 0 }), false, 'durée 0 ⇒ jamais short');
assertEqual(isShortVideo(null), false, 'null ⇒ pas short');
// Live / classification globale.
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: 500 }), 'video', 'classify 500 s');
assertEqual(classifyVideo({ isLive: true }), 'live', 'classify live');
+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;
}
+42 -10
View File
@@ -84,8 +84,13 @@ export class WatchShortComponent implements OnDestroy {
// ---- Expérience façon YouTube Shorts ----
/** Cadre 9:16 (plein écran + focus). */
frameRef = viewChild<ElementRef<HTMLElement>>('shortsFrame');
/** Son coupé (autoplay) : rebascule l'URL d'embed (reprend au début). */
muted = signal(true);
/**
* Son coupé ou non. Par défaut le son est ACTIVÉ sur les Shorts : les
* navigateurs le permettent dès la première interaction (molette/clic),
* sinon le player démarre en pause et l'utilisateur appuie sur lecture.
* Rebasculer rebascule l'URL d'embed (reprend au début).
*/
muted = signal(false);
/**
* Embed Rumble autoritaire : id demandé -> vrai id scrapé via
* /api/rumble/video/<id> (comme /watch). Les IDs de liste approximatifs
@@ -809,7 +814,10 @@ export class WatchShortComponent implements OnDestroy {
// Regroupe par provider puis entrelace (tourniquet) pour mixer les sources.
const byProvider: Video[][] = defs.map((d, i) => {
const raw = ((pages[i]?.items || []) as Video[]).filter(v => !!v?.videoId);
const list = raw.filter(v => this.isShort({ ...v, provider: d.provider } as Video));
const list = raw.filter(v =>
this.isShort({ ...v, provider: d.provider } as Video) &&
(d.provider !== 'twitch' || this.shortsLangOk({ ...v, provider: d.provider } as Video))
);
// Mélange léger par sujet pour varier l'ordre à chaque visite.
for (let k = list.length - 1; k > 0; k--) {
const j = Math.floor(Math.random() * (k + 1));
@@ -918,8 +926,8 @@ export class WatchShortComponent implements OnDestroy {
this.api.getTwitchClipsFeed()?.subscribe({
next: (res: { items: Video[]; nextCursor?: string | null }) => {
const raw: Video[] = (res.items || []).filter((v: Video) => !!v.videoId);
// Double sécurité anti "time machine" sur les clips non validés.
this.withoutDeadClips(raw.filter((v: Video) => this.isShort(v))).subscribe({
// Langue utilisateur + double sécurité anti "time machine".
this.twitchShortsReady(raw.filter((v: Video) => this.isShort(v))).subscribe({
next: (filtered) => {
const list: Video[] = this.dedup(filtered);
this.items.set(list);
@@ -959,8 +967,8 @@ export class WatchShortComponent implements OnDestroy {
next: (res) => {
const raw = (res?.items || []).filter(v => !!v.videoId);
const shorts = raw.filter(v => this.isShort(v));
// Les clips de recherche ne sont pas validés côté service.
this.withoutDeadClips(shorts).subscribe({
// Langue utilisateur + validation (recherche non validée côté service).
this.twitchShortsReady(shorts).subscribe({
next: (filtered) => {
const list = this.dedup(filtered);
this.showTwitchFallbackList(list, res?.nextCursor || null);
@@ -1105,6 +1113,29 @@ export class WatchShortComponent implements OnDestroy {
return isShortVideo({ ...(v as object), provider: p } as Parameters<typeof isShortVideo>[0]);
}
/**
* La vidéo respecte-t-elle les langues de Shorts de l'utilisateur
* (langue UI + préférences + transcripts) ? Seuls les items Twitch
* portent la langue (champ Helix) ; sans métadonnée, le doute profite.
* YouTube/PeerTube sont filtrés côté serveur (relevanceLanguage,
* languageOneOf) ; Dailymotion/Odysee/Rumble n'exposent pas la langue.
*/
private shortsLangOk(v: Video): boolean {
const p = (v as any).provider || this.provider();
if (String(p) !== 'twitch') return true;
const lang = String((v as any)?.raw?.language || '').toLowerCase().split('-')[0];
if (!lang || lang === 'other') return true;
let accepted: string[] = ['en'];
try { accepted = this.api.getShortsLanguages(); } catch {}
return accepted.includes(lang);
}
/** Shorts Twitch : langue utilisateur + clips morts exclus avant affichage. */
private twitchShortsReady(list: Video[]): import('rxjs').Observable<Video[]> {
const inLang = list.filter(v => this.shortsLangOk(v));
return this.withoutDeadClips(inLang);
}
/**
* Exclut les clips Twitch morts ("time machine") avant affichage.
* Les flux game/broadcaster sont déjà validés côté service ; ce garde-fou
@@ -1238,10 +1269,11 @@ export class WatchShortComponent implements OnDestroy {
if (p === 'youtube' && raw.length) {
this.pickYouTubeShorts(raw).then((list: Video[]) => apply(list.filter((v: Video) => this.isShort(v)))).catch(() => { apply([]); });
} else if (p === 'twitch' && raw.length) {
// Les clips paginés ne sont pas tous validés côté service.
this.withoutDeadClips(raw.filter((v: Video) => this.isShort(v))).subscribe({
// Langue utilisateur + clips paginés non tous validés côté service.
const shorts = raw.filter((v: Video) => this.isShort(v));
this.twitchShortsReady(shorts).subscribe({
next: (clean) => apply(clean),
error: () => apply(raw.filter((v: Video) => this.isShort(v))),
error: () => apply(shorts),
});
} else {
apply(raw.filter((v: Video) => this.isShort(v)));
+2 -1
View File
@@ -20,7 +20,8 @@ export interface Video {
providerUrl?: string; // URL complète de la vidéo sur le site du fournisseur
// Champs optionnels pour le filtrage des Shorts (non obligatoires selon les providers)
provider?: 'youtube'|'dailymotion'|'twitch'|'rumble'|'odysee'|'peertube';
isVertical?: boolean; // true si 9:16
width?: number | null; // largeur vidéo en px (signal d'orientation, quand exposé)
height?: number | null; // hauteur vidéo en px (signal d'orientation, quand exposé)
isShort?: boolean; // indicateur natif si disponible
isLive?: boolean; // true si diffusion en direct (YouTube eventType=live, Twitch streams)
kind?: 'clip'|'vod'|'short'|'channel'; // ex. Twitch=clip
+60 -12
View File
@@ -5,6 +5,7 @@ import { catchError, map, switchMap, shareReplay, first, tap, timeout } from 'rx
import { Video, VideoDetail, Comment } from '../models/video.model';
import { InstanceService, Provider } from './instance.service';
import { I18nService } from './i18n.service';
import { UserService } from './user.service';
export interface PagedResult<T> {
items: T[];
@@ -33,11 +34,45 @@ export class YoutubeApiService {
try { const v = Number((globalThis as any)?.YT_KEY_BAN_MS); return Number.isFinite(v) && v > 0 ? v : 6 * 60 * 60 * 1000; } catch { return 6 * 60 * 60 * 1000; }
})();
constructor(private http: HttpClient, private instances: InstanceService, private i18n: I18nService) {
constructor(private http: HttpClient, private instances: InstanceService, private i18n: I18nService, private users: UserService) {
this.getTwitchAuthToken().subscribe(this.twitchAuthToken$);
this.ytKeys = this.loadYouTubeKeys();
}
/**
* Langues acceptées pour les Shorts : langue d'interface + langue par
* défaut des préférences + langues de transcripts (`downloadLanguages`).
* Ex. UI en anglais + transcripts fr/en ⇒ ['en', 'fr'] : les Shorts sont
* cherchés dans ces langues, jamais dans d'autres quand l'API le permet
* (YouTube relevanceLanguage, PeerTube languageOneOf, Twitch language).
*/
public getShortsLanguages(): string[] {
const out: string[] = [];
const push = (v: unknown) => {
const base = String(v || '').trim().toLowerCase().split('-')[0];
if (/^[a-z]{2,3}$/.test(base) && !out.includes(base)) out.push(base);
};
try { push(this.i18n.lang()); } catch {}
try {
const prefs = this.users.preferences() as { language?: unknown; downloadLanguages?: unknown } | null;
push((prefs as any)?.language);
const dl = (prefs as any)?.downloadLanguages;
if (Array.isArray(dl)) for (const l of dl) push(l);
} catch {}
return out.length ? out : ['en'];
}
/** Langue primaire (2 lettres) pour les APIs qui n'acceptent qu'un code ISO 639-1. */
private primaryShortsLang2(): string | null {
try {
const langs = this.getShortsLanguages();
const two = langs.find(l => l.length === 2);
return two || null;
} catch {
return null;
}
}
private normalizeDuration(raw: any): number {
if (typeof raw === 'number' && Number.isFinite(raw)) {
return raw > 0 ? Math.round(raw) : 0;
@@ -263,8 +298,9 @@ export class YoutubeApiService {
if (!token) return of({ items: [], nextCursor: null });
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
const after = cursor ? `&after=${encodeURIComponent(cursor)}` : '';
const primary = `/api/twitch-api/helix/streams?game_id=${encodeURIComponent(gameId)}&first=${limit}${after}`;
const fallback = `/proxy/twitch-api/helix/streams?game_id=${encodeURIComponent(gameId)}&first=${limit}${after}`;
const langParam = this.primaryShortsLang2() ? `&language=${encodeURIComponent(this.primaryShortsLang2() as string)}` : '';
const primary = `/api/twitch-api/helix/streams?game_id=${encodeURIComponent(gameId)}&first=${limit}${after}${langParam}`;
const fallback = `/proxy/twitch-api/helix/streams?game_id=${encodeURIComponent(gameId)}&first=${limit}${after}${langParam}`;
return this.http.get<any>(primary, { headers }).pipe(
catchError(() => this.http.get<any>(fallback, { headers })),
map(res => ({
@@ -357,6 +393,8 @@ export class YoutubeApiService {
}
}
const userIds: string[] = channels.map((c: any) => c?.id).filter(Boolean).slice(0, 6);
// Langue de l'utilisateur (filtre natif Helix /videos).
const vodLang = this.primaryShortsLang2() ? `&language=${encodeURIComponent(this.primaryShortsLang2() as string)}` : '';
const fetchVodsForUsers = (ids: string[]) => {
if (!ids.length) return of([] as any[]);
// Fan-out réduit : 6 chaînes × 6 VODs suffisent, le budget
@@ -364,8 +402,8 @@ export class YoutubeApiService {
// remercient (une page Explore en lance déjà 4 sections).
const perUserRequests = ids.slice(0, 6).map(uid => {
// type=all to include archive, upload, highlight; fetch a few more to increase hit rate
const vPrimary = `/api/twitch-api/helix/videos?user_id=${encodeURIComponent(uid)}&first=6&type=all`;
const vFallback = `/proxy/twitch-api/helix/videos?user_id=${encodeURIComponent(uid)}&first=6&type=all`;
const vPrimary = `/api/twitch-api/helix/videos?user_id=${encodeURIComponent(uid)}&first=6&type=all${vodLang}`;
const vFallback = `/proxy/twitch-api/helix/videos?user_id=${encodeURIComponent(uid)}&first=6&type=all${vodLang}`;
return this.http.get<any>(vPrimary, { headers }).pipe(
catchError(() => this.http.get<any>(vFallback, { headers })),
map(vres => Array.isArray(vres?.data) ? vres.data : []),
@@ -390,8 +428,8 @@ export class YoutubeApiService {
}
if (!gameIds.length) return of([]);
const gameCalls = gameIds.slice(0, 2).map(gid => {
const gPrimary = `/api/twitch-api/helix/videos?game_id=${encodeURIComponent(gid)}&first=10&sort=trending&type=all`;
const gFallback = `/proxy/twitch-api/helix/videos?game_id=${encodeURIComponent(gid)}&first=10&sort=trending&type=all`;
const gPrimary = `/api/twitch-api/helix/videos?game_id=${encodeURIComponent(gid)}&first=10&sort=trending&type=all${vodLang}`;
const gFallback = `/proxy/twitch-api/helix/videos?game_id=${encodeURIComponent(gid)}&first=10&sort=trending&type=all${vodLang}`;
return this.http.get<any>(gPrimary, { headers }).pipe(
catchError(() => this.http.get<any>(gFallback, { headers })),
map(vres => Array.isArray(vres?.data) ? vres.data : []),
@@ -1005,11 +1043,12 @@ export class YoutubeApiService {
key: String(key),
});
try {
const lang = String(this.i18n.lang()).toLowerCase();
// Langue de l'utilisateur en priorité (relevanceLanguage YouTube).
const lang = this.getShortsLanguages()[0];
if (lang) params.set('relevanceLanguage', lang);
} catch {}
if (cursor) params.set('pageToken', cursor);
const cacheKey = `yt:shorts|${region}|${q}|${cursor || ''}`;
const cacheKey = `yt:shorts|${region}|${q}|${this.getShortsLanguages().join(',')}|${cursor || ''}`;
const cached = this.searchCache.get(cacheKey);
const now = Date.now();
if (cached && (now - cached.ts) < this.CACHE_TTL_MS) {
@@ -1120,6 +1159,10 @@ export class YoutubeApiService {
isLocal: 'true',
nsfw: 'false',
});
// Langues de l'utilisateur (filtre natif PeerTube, ignoré si inconnu).
try {
for (const l of this.getShortsLanguages()) params.append('languageOneOf', l);
} catch {}
if (q) params.set('search', q);
const primary = `/api/peertube/${instance}/api/v1/videos?${params.toString()}`;
const fallback = `/proxy/api/peertube/${instance}/api/v1/videos?${params.toString()}`;
@@ -1321,7 +1364,8 @@ export class YoutubeApiService {
} as Video;
};
// Twitch Mappers
// Twitch Mappers (la langue Helix est conservée dans `raw` pour le
// filtre langue des Shorts : l'endpoint /clips n'a pas de param langue).
private mapTwitchItemToVideo = (i: any): Video => ({
url: `/watch/${i.id}`,
videoId: i.id || '',
@@ -1342,6 +1386,7 @@ export class YoutubeApiService {
views: Number(i.view_count || i.viewer_count || 0),
uploaded: i.created_at ? Date.parse(i.created_at) : 0,
isLive: false,
raw: { language: i?.language || undefined },
});
// Twitch Streams mapper (for trending popular live streams)
@@ -1433,6 +1478,7 @@ export class YoutubeApiService {
views: Number(i.view_count || 0),
uploaded: i.created_at ? Date.parse(i.created_at) : 0,
isLive: false,
raw: { language: i?.language || undefined },
});
/**
@@ -1547,8 +1593,10 @@ export class YoutubeApiService {
if (!token) return this.getTwitchServerTopStreams(limit);
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
const after = cursor ? `&after=${encodeURIComponent(cursor)}` : '';
const primary = `/api/twitch-api/helix/streams?first=${limit}${after}`;
const fallback = `/proxy/twitch-api/helix/streams?first=${limit}${after}`;
// Langue de l'utilisateur (filtre natif Helix /streams).
const langParam = this.primaryShortsLang2() ? `&language=${encodeURIComponent(this.primaryShortsLang2() as string)}` : '';
const primary = `/api/twitch-api/helix/streams?first=${limit}${after}${langParam}`;
const fallback = `/proxy/twitch-api/helix/streams?first=${limit}${after}${langParam}`;
return this.http.get<any>(primary, { headers }).pipe(
catchError(() => this.http.get<any>(fallback, { headers })),
map(res => ({