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
+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)));