fix(shorts): orientation autoritaire, langues YouTube et autoplay fiable
CI / build-and-test (push) Successful in 14m18s

- format: des que largeur/hauteur sont connues, l'orientation fait autorite (horizontale/carree => jamais short, meme <75 s). Repli duree reserve aux providers muets
- dimensions mappees: Dailymotion (width,height), PeerTube (aspectRatio), Odysee (value.video.width/height) => validation verticale reelle
- YouTube: langue via snippet.defaultAudioLanguage (filtre FR/EN)
- autoplay: demarrage muet, suppression du deblocage automatique au scroll/selection (reload d'embed hors geste => ecran noir); son via le bouton dedie
- PeerTube embed: muted=1 pour l'autoplay
- server/search-filters.mjs: miroir de la regle d'orientation + tests mis a jour
This commit is contained in:
2026-09-29 14:51:56 -04:00
parent d4895bae92
commit 37681c4f53
7 changed files with 73 additions and 50 deletions
+4 -1
View File
@@ -197,6 +197,8 @@ export function isShortItem(item, providerId) {
}
// 2) + 3) Orientation (width/height quand le provider les expose).
// Dimensions connues ⇒ l'orientation fait AUTORITÉ : une horizontale/carrée
// n'est jamais un short, même courte.
const w = numOrZero(item.width);
const h = numOrZero(item.height);
if (w > 0 && h > 0) {
@@ -205,9 +207,10 @@ export function isShortItem(item, providerId) {
if (!hasKnownDuration) return false; // règle 3 : pas de durée ⇒ pas short
return d <= VERTICAL_SHORT_MAX_SECONDS; // règle 2
}
return false;
}
// 4) Repli durée seule (comportement historique inchangé).
// 4) Repli durée seule, uniquement quand l'orientation est inconnue.
// 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
+12 -6
View File
@@ -209,15 +209,21 @@ 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.
// Dimensions connues non verticales ⇒ JAMAIS short, même à 30 s
// (l'orientation fait autorité ; plus de repli durée quand w/h sont connus).
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',
!matchesFilters({ id: 'a', title: 'clip carré', duration: 30, width: 1080, height: 1080, type: 'video' }, { type: 'shorts' }),
'square 30s is not a short (orientation authoritative)',
);
// Règle 4 : horizontale 50 s ⇒ short (non-régression durée seule).
// Horizontale 50 s ⇒ PAS short (dimensions connues, non verticale).
expect(
isShortItem({ title: 'paysage', duration: 50, width: 1280, height: 720, type: 'video' }),
'horizontal 50s is a short (duration fallback)',
!isShortItem({ title: 'paysage', duration: 50, width: 1280, height: 720, type: 'video' }),
'horizontal 50s is not a short (orientation authoritative)',
);
// Repli durée seule réservé aux items SANS dimensions connues.
expect(
isShortItem({ title: 'courte sans dims', duration: 50, type: 'video' }),
'50s without dimensions is a short (duration fallback)',
);
// Règle 3 : verticale SANS durée connue ⇒ PAS short.
expect(
+3 -3
View File
@@ -63,9 +63,9 @@ assertEqual(
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');
// Dimensions connues non verticales ⇒ jamais short (orientation autoritaire).
assertEqual(isShortVideo({ duration: 50, width: 1280, height: 720 }), false, 'horizontale 50 s (dims connues) ⇒ pas short');
assertEqual(isShortVideo({ duration: 50 }), true, '50 s sans dimensions ⇒ short (repli durée)');
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');
+12 -6
View File
@@ -6,11 +6,12 @@
* 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).
* 3. Dimensions connues : l'orientation fait autorité.
* - verticale (`height > width`, ratio ≤ 0.8) ET durée connue ≤ 90 s ⇒ Short ;
* - verticale sans durée connue ⇒ PAS un Short ;
* - horizontale/carrée ⇒ JAMAIS un Short (même si ≤ 70/75 s).
* 4. Dimensions inconnues (YouTube Data API, Dailymotion, PeerTube, Rumble…) :
* repli durée seule : 1..70 s (YouTube), 1..75 s (autres).
*/
export type VideoKind = 'short' | 'live' | 'video';
@@ -79,17 +80,22 @@ export function isShortVideo(v: ClassifiableVideo | null | undefined): boolean {
}
// 3) + 4) Orientation (width/height quand le provider les expose).
// DÈS QUE les dimensions sont connues, l'orientation fait AUTORITÉ : une
// vidéo horizontale/carrée n'est jamais un short, même si elle est courte.
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) {
// Verticale sans durée connue ⇒ PAS un short (doctrine historique).
if (!hasKnownDuration) return false;
return duration <= VERTICAL_SHORT_MAX_SECONDS;
}
return false;
}
// 5) Repli durée seule (comportement historique inchangé).
// 5) Repli durée seule, UNIQUEMENT quand le provider n'expose pas
// l'orientation (YouTube Data API, Dailymotion, PeerTube, Rumble…).
if (!hasKnownDuration) return false;
if (provider === 'youtube') return duration <= YOUTUBE_SHORT_MAX_SECONDS;
return duration <= SHORT_MAX_SECONDS;
@@ -1,7 +1,7 @@
<div class="shorts-stage flex w-full flex-col items-center gap-3 rounded-2xl bg-black/60 p-2 sm:p-4" (click)="unlockSound(); closeMenu()">
<div class="shorts-stage flex w-full flex-col items-center gap-3 rounded-2xl bg-black/60 p-2 sm:p-4" (click)="closeMenu()">
<!-- Sélecteur : Pour toi + fournisseurs -->
<div class="scroll-rail no-scrollbar flex max-w-full items-center justify-start gap-2 sm:justify-center" role="tablist" aria-label="Fournisseur des Shorts" (click)="$event.stopPropagation()">
<button (click)="unlockSound(); toggleForYou()"
<button (click)="toggleForYou()"
[attr.aria-pressed]="forYou()"
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors"
[class]="forYou() ? 'bg-violet-600 border-violet-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'"
@@ -9,7 +9,7 @@
✨ Pour toi
</button>
<button *ngFor="let p of providersList()"
(click)="unlockSound(); selectProvider(p.id)"
(click)="selectProvider(p.id)"
[attr.aria-pressed]="!forYou() && provider() === p.id"
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors"
[class]="!forYou() && provider() === p.id ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
@@ -19,7 +19,7 @@
<!-- Scène immersive : flèches + cadre 9:16 + rail d'actions -->
<div class="flex w-full min-h-0 items-center justify-center gap-1 sm:gap-3"
(wheel)="unlockSound(); onWheel($event)" (touchstart)="unlockSound(); onTouchStart($event)" (touchend)="onTouchEnd($event)">
(wheel)="onWheel($event)" (touchstart)="onTouchStart($event)" (touchend)="onTouchEnd($event)">
<!-- Flèche précédent (bureau, façon YouTube) -->
<button type="button" (click)="prev(); $event.stopPropagation()" [disabled]="!canPrev()"
class="chip hidden sm:flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-slate-800/80 text-white hover:bg-slate-700 disabled:opacity-30"
+8 -20
View File
@@ -83,15 +83,14 @@ export class WatchShortComponent implements OnDestroy {
frameRef = viewChild<ElementRef<HTMLElement>>('shortsFrame');
/**
* Son coupé ou non. Démarrer MUET est la seule façon d'obtenir l'autoplay
* garanti des iframes (politique navigateur) : c'est aussi ce que fait la
* page Watch (mute=1). Le son est rétabli automatiquement à la première
* interaction (clic/molette/tactile) via `unlockSound()`, et l'utilisateur
* peut couper/réactiver via le bouton. Rebasculer recharge l'embed.
* garanti des iframes (politique navigateur) — c'est aussi ce que fait la
* page Watch (mute=1). L'utilisateur active le son via le bouton dédié :
* un clic sur ce bouton recharge l'embed AVEC son tout en restant dans un
* contexte de geste utilisateur (autoplay autorisé). On ne débloque jamais
* le son automatiquement : recharger l'embed pendant un scroll relançait un
* embed non lisible (écran noir).
*/
muted = signal(true);
/** L'utilisateur a-t-il touché au bouton son ? (sinon on débloque en auto). */
private muteTouched = false;
private soundUnlocked = false;
/**
* Embed Rumble autoritaire : id demandé -> vrai id scrapé via
* /api/rumble/video/<id> (comme /watch). Les IDs de liste approximatifs
@@ -243,7 +242,8 @@ export class WatchShortComponent implements OnDestroy {
// : on la dérive de l'URL vidéo, avec repli sur l'instance active.
// "Sorry this video does not exist" venait d'un mauvais host.
const inst = this.peerTubeHostOf(v) || this.instances.activePeerTubeInstance();
const u = `https://${inst}/videos/embed/${encodeURIComponent(v.videoId)}?autoplay=1&api=1`;
// `muted=1` est requis pour que l'autoplay ne soit pas bloqué.
const u = `https://${inst}/videos/embed/${encodeURIComponent(v.videoId)}?autoplay=1&muted=${this.muted() ? 1 : 0}&api=1`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'odysee') {
@@ -521,21 +521,9 @@ export class WatchShortComponent implements OnDestroy {
// ---- Actions façon YouTube Shorts ----
toggleMute(): void {
this.muteTouched = true;
this.muted.update(m => !m);
}
/**
* Première interaction de l'utilisateur : le navigateur autorise alors un
* embed avec son. On débloque le son une seule fois, sauf si l'utilisateur
* a déjà choisi explicitement (mute activé manuellement).
*/
unlockSound(): void {
if (this.soundUnlocked || this.muteTouched || !this.muted()) return;
this.soundUnlocked = true;
this.muted.set(false);
}
toggleFullscreen(): void {
try {
if (document.fullscreenElement) {
+30 -10
View File
@@ -594,7 +594,7 @@ export class YoutubeApiService {
limit: String(limit),
page: String(page),
country: String(region).toLowerCase(),
fields: 'id,title,thumbnail_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,language,allow_embed,owner.id,owner.screenname,owner.avatar_80_url,created_time',
fields: 'id,title,thumbnail_url,thumbnail_480_url,thumbnail_720_url,duration,width,height,views_total,language,allow_embed,owner.id,owner.screenname,owner.avatar_80_url,created_time',
});
return this.fetchDailymotion('/videos', params).pipe(
map(res => {
@@ -707,7 +707,7 @@ export class YoutubeApiService {
// duration -1 = non-embeddable (erreur 153) : on garde la
// vidéo en recherche (avec durée 0) mais les Shorts la filtrent.
const duration = typeof s.duration === 'number' && s.duration >= 0 ? s.duration : 0;
return { ...v, duration, views: s.views };
return { ...v, duration, views: s.views, language: s.language };
});
const next = { ...out, items: enriched } as PagedResult<Video>;
this.searchCache.set(cacheKey, { ts: now, data: next });
@@ -1076,7 +1076,7 @@ export class YoutubeApiService {
// duration -1 = non-embeddable (erreur 153) : on garde la
// vidéo en recherche (avec durée 0) mais les Shorts la filtrent.
const duration = typeof s.duration === 'number' && s.duration >= 0 ? s.duration : 0;
return { ...v, duration, views: s.views };
return { ...v, duration, views: s.views, language: s.language };
});
const next = { ...out, items: enriched } as PagedResult<Video>;
this.searchCache.set(cacheKey, { ts: now, data: next });
@@ -1111,7 +1111,8 @@ export class YoutubeApiService {
country: String(region).toLowerCase(),
// `allow_embed` indispensable : sans lui, les vidéos non-embeddables
// passent le filtre et le player répond "Forbidden".
fields: 'id,title,thumbnail_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,language,allow_embed,owner.screenname,owner.avatar_80_url,created_time',
// `width,height` : validation verticale stricte des Shorts.
fields: 'id,title,thumbnail_url,thumbnail_480_url,thumbnail_720_url,duration,width,height,views_total,language,allow_embed,owner.screenname,owner.avatar_80_url,created_time',
search: q,
});
return this.fetchDailymotion('/videos', params).pipe(
@@ -1249,6 +1250,8 @@ export class YoutubeApiService {
views: Number(i.views_total || 0),
uploaded: i.created_time ? i.created_time * 1000 : 0,
language: i?.language ? String(i.language).toLowerCase().split('-')[0] : undefined,
width: Number(i?.width) > 0 ? Number(i.width) : undefined,
height: Number(i?.height) > 0 ? Number(i.height) : undefined,
raw: { allowEmbed: i?.allow_embed !== false },
});
@@ -1263,6 +1266,11 @@ export class YoutubeApiService {
const uploaderAvatar = abs(i.account?.avatar?.path) || abs(i.account?.avatars?.[0]?.path) || abs(i.channel?.avatar?.path) || '';
const uploaderName = i.account?.displayName || i.account?.name || i.channel?.displayName || '';
const uploaderUrl = i.account?.url || i.channel?.url || '';
// PeerTube expose `aspectRatio` (largeur/hauteur) : on le convertit en
// width/height fictifs pour la validation verticale centrale.
const aspectRatio = Number(i?.aspectRatio) > 0 ? Number(i.aspectRatio) : 0;
const width = aspectRatio > 0 ? Math.round(aspectRatio * 1000) : undefined;
const height = aspectRatio > 0 ? 1000 : undefined;
return {
url: `https://${instance}/w/${i.uuid}`,
@@ -1283,6 +1291,8 @@ export class YoutubeApiService {
uploaded: i.publishedAt ? Date.parse(i.publishedAt) : 0,
// PeerTube expose `language` sous forme { id, label } ou chaîne ISO.
language: (i?.language && String(i.language.id || i.language).toLowerCase().split('-')[0]) || undefined,
width,
height,
} as Video;
};
@@ -1302,6 +1312,10 @@ export class YoutubeApiService {
?? i?.meta?.duration
?? (typeof i?.value?.duration !== 'undefined' ? i.value.duration : undefined);
const duration = this.normalizeDuration(rawDuration);
// Orientation LBRY/Odysee : `value.video.width/height` — indispensable
// pour la validation verticale stricte des Shorts.
const width = Number(val?.video?.width) > 0 ? Number(val.video.width) : undefined;
const height = Number(val?.video?.height) > 0 ? Number(val.video.height) : undefined;
const views = (i?.meta?.view_count != null) ? Number(i.meta.view_count) : 0;
// Odysee : `value.languages` (tableau ISO) ou `value.language` (chaîne).
const langRaw = Array.isArray(val?.languages) ? val.languages[0] : (val?.language ?? i?.language);
@@ -1330,6 +1344,8 @@ export class YoutubeApiService {
views: views,
uploaded: publishedAt ? Date.parse(publishedAt) : 0,
language,
width,
height,
} as Video;
};
@@ -2157,12 +2173,14 @@ export class YoutubeApiService {
* Fetch minimal details (duration seconds + viewCount) for a list of YouTube IDs.
* Non-embeddable entries get duration -1 so callers can drop them (error 153).
*/
private getYouTubeContentAndStats(ids: string[]): Observable<Record<string, { duration: number; views: number }>> {
private getYouTubeContentAndStats(ids: string[]): Observable<Record<string, { duration: number; views: number; language?: string }>> {
const apiKey = this.getActiveYouTubeKey();
if (!apiKey || !ids.length) return of({});
const params = new URLSearchParams({
key: apiKey,
part: 'contentDetails,statistics,status',
// `snippet` fournit `defaultAudioLanguage` : seul signal de langue
// exploitable pour filtrer les Shorts FR/EN.
part: 'contentDetails,statistics,status,snippet',
id: ids.join(','),
maxResults: String(Math.min(50, ids.length)),
// Add safeSearch for consistency
@@ -2171,21 +2189,23 @@ export class YoutubeApiService {
return this.fetchYouTube('/youtube/v3/videos', params, apiKey).pipe(
map((res: any) => Array.isArray(res?.items) ? res.items : []),
map((items: any[]) => {
const out: Record<string, { duration: number; views: number }> = {};
const out: Record<string, { duration: number; views: number; language?: string }> = {};
for (const it of items) {
const id = String(it?.id || '');
if (!id) continue;
const langRaw = it?.snippet?.defaultAudioLanguage || it?.snippet?.defaultLanguage || '';
const language = langRaw ? String(langRaw).toLowerCase().split('-')[0] : undefined;
if (it?.status && it.status.embeddable === false) {
out[id] = { duration: -1, views: Number(it?.statistics?.viewCount || 0) };
out[id] = { duration: -1, views: Number(it?.statistics?.viewCount || 0), language };
continue;
}
const duration = this.parseISODurationToSeconds(it?.contentDetails?.duration || 'PT0S');
const views = Number(it?.statistics?.viewCount || 0);
out[id] = { duration, views };
out[id] = { duration, views, language };
}
return out;
}),
catchError(() => of({} as Record<string, { duration: number; views: number }>))
catchError(() => of({} as Record<string, { duration: number; views: number; language?: string }>))
);
}
}