fix(shorts): embeds par fournisseur + flux infini (153/Forbidden/not-found)

This commit is contained in:
2026-09-29 09:04:09 -04:00
parent 937436658a
commit 34f432a7c1
3 changed files with 416 additions and 109 deletions
@@ -12,31 +12,33 @@
<!-- Player area: height is derived from dvh *and* vw so a 9:16 frame never
overflows either a portrait phone or a landscape one. -->
<div class="flex w-full min-h-0 items-center justify-center"
(wheel)="onWheel($event)" (touchstart)="onTouchStart($event)" (touchend)="onTouchEnd($event)">
<!-- Center column -->
<div class="relative flex w-full min-w-0 items-center justify-center">
<div class="flex w-full min-h-0 items-center justify-center gap-2 sm:gap-3"
(wheel)="onWheel($event)" (touchstart)="onTouchStart($event)" (touchend)="onTouchEnd($event)">
<!-- Center column : cadre 9:16 + rail d'actions (jamais en dehors de l'écran) -->
<div class="relative flex w-full min-w-0 items-end sm:items-center justify-center gap-2 sm:gap-3">
<!-- Video frame (9:16) -->
<div class="shorts-frame rounded-xl overflow-hidden shadow-2xl ring-1 ring-slate-700/50 bg-black/90">
<div class="shorts-frame relative shrink-0 rounded-xl overflow-hidden shadow-2xl ring-1 ring-slate-700/50 bg-black/90">
@if (embedUrl(); as url) {
<iframe [src]="url" [title]="current()?.title || 'Short video'" class="w-full h-full" frameborder="0" playsinline
<iframe [src]="url" [title]="current()?.title || 'Short video'" class="absolute inset-0 w-full h-full" frameborder="0" playsinline
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen></iframe>
allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>
} @else if (loading()) {
<div class="w-full h-full flex flex-col gap-3 items-center justify-center bg-slate-800 text-slate-400 text-sm">
<div class="absolute inset-0 flex flex-col gap-3 items-center justify-center bg-slate-800 text-slate-400 text-sm">
<div class="h-8 w-8 rounded-full border-2 border-slate-600 border-t-red-500 animate-spin" aria-hidden="true"></div>
<span role="status">Chargement des Shorts…</span>
</div>
} @else if (error()) {
<div class="w-full h-full flex flex-col gap-3 items-center justify-center text-slate-300 p-6 text-center">
<div class="absolute inset-0 flex flex-col gap-3 items-center justify-center text-slate-300 p-6 text-center">
<span>{{ error() }}</span>
<button (click)="retry()" class="chip px-4 py-3 rounded bg-slate-700 hover:bg-slate-600 text-white text-sm">Réessayer</button>
</div>
}
<!-- Bottom overlay: meta -->
<!-- Bottom overlay: meta. pointer-events-none pour ne jamais bloquer
les boutons du lecteur (play / volume / plein écran). Seul le
lien "Ouvrir" reste cliquable. -->
@if (current(); as v) {
<div class="absolute left-0 right-0 bottom-0 p-3 sm:p-4 bg-gradient-to-t from-black/70 to-transparent text-white">
<div class="pointer-events-none absolute left-0 right-0 bottom-0 p-3 sm:p-4 bg-gradient-to-t from-black/70 to-transparent text-white">
<div class="flex items-center gap-3 text-sm">
<img [src]="v.uploaderAvatar" [alt]="v.uploaderName" class="h-8 w-8 shrink-0 rounded-full object-cover">
<div class="flex-1 min-w-0">
@@ -44,12 +46,26 @@
<div class="opacity-80 line-clamp-1">@{{ v.uploaderName }}</div>
</div>
<a *ngIf="watchLink()" [routerLink]="watchLink()" [queryParams]="watchQueryParams()"
class="chip shrink-0 px-3 py-2 rounded bg-white/20 hover:bg-white/30 text-xs">Ouvrir</a>
class="chip pointer-events-auto shrink-0 px-3 py-2 rounded bg-white/20 hover:bg-white/30 text-xs">Ouvrir</a>
</div>
</div>
}
</div>
<!-- Rail d'actions façon YouTube Shorts (à droite du cadre 9:16) -->
<div class="flex shrink-0 flex-col items-center gap-2 pb-1">
<a *ngIf="providerUrl()" [href]="providerUrl()" target="_blank" rel="noopener"
class="flex h-11 w-11 items-center justify-center rounded-full bg-slate-800/80 text-white hover:bg-slate-700"
title="Ouvrir chez le fournisseur" aria-label="Ouvrir chez le fournisseur">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</a>
<button type="button" (click)="skipUnplayable()"
class="flex h-11 w-11 items-center justify-center rounded-full bg-slate-800/80 text-white hover:bg-slate-700"
title="Vidéo illisible ? Passer à la suivante" aria-label="Passer la vidéo illisible">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
</button>
</div>
@if (busyMore()) {
<div class="absolute top-2 left-1/2 -translate-x-1/2 px-3 py-2 rounded-full bg-black/70 text-white text-xs" role="status">Chargement…</div>
}
@@ -80,4 +96,5 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 inline" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 10l5 5 5-5H7z"/></svg>
</button>
</div>
<p class="max-w-lg text-center text-xs text-slate-500">Si une vidéo affiche une erreur (153, Forbidden, introuvable…), utilisez ⏭ Passer : elle est retirée du flux.</p>
</div>
+381 -93
View File
@@ -8,9 +8,20 @@ import { InstanceService, Provider } from '../../services/instance.service';
import { Video } from '../../models/video.model';
import { isShortVideo, YOUTUBE_SHORT_MAX_SECONDS } from '../../app/shared/utils/video-kind';
const SHORTS_QUERY = 'shorts';
/** Requête de secours quand la première ne donne aucun Short. */
const SHORTS_ALT_QUERY = '#shorts';
/**
* Rotation de requêtes par provider pour un défilement infini façon YouTube
* Shorts : quand une page/un curseur est épuisé, on passe à la requête
* suivante au lieu d'afficher une impasse. Les requêtes restent génériques
* pour maximiser le nombre de résultats courts.
*/
const SHORTS_QUERIES: Record<string, string[]> = {
youtube: ['shorts', '#shorts', 'vertical video', 'short film', 'funny shorts'],
dailymotion: ['shorts', '#shorts', 'vertical', 'funny', 'music'],
peertube: ['shorts', 'vertical', 'funny', 'music', 'clip'],
rumble: ['shorts', 'funny', 'viral', 'music', 'sports'],
odysee: ['shorts', 'funny', 'crypto', 'music', 'gaming'],
twitch: ['Just Chatting', 'gaming', 'music', 'sports', 'food'],
};
@Component({
selector: 'app-watch-short',
@@ -30,14 +41,18 @@ export class WatchShortComponent {
index = signal(0);
nextCursor = signal<string | null>(null);
busyMore = signal(false);
/** IDs déjà vus (dedup inter-pages pour l'infini). */
private seenIds = new Set<string>();
/** Position dans la rotation SHORTS_QUERIES pour le provider courant. */
private queryIndex = 0;
/** Requêtes déjà épuisées (curseur null) pour ce chargement. */
private exhaustedQueries = new Set<string>();
// scroll/swipe helpers
private wheelAccum = 0;
private lastSwipeY: number | null = null;
private lastScrollTs = 0;
private lastProvider: string | null = null;
/** true quand la requête de secours (#shorts) a déjà été tentée pour ce chargement. */
private altQueryTried = false;
readonly provider = computed(() => this.instances.selectedProvider());
readonly providersList = computed(() => this.instances.providers());
@@ -48,7 +63,21 @@ export class WatchShortComponent {
});
readonly canPrev = computed(() => this.index() > 0);
readonly canNext = computed(() => this.index() < this.items().length - 1 || !!this.nextCursor());
readonly canNext = computed(() => this.index() < this.items().length - 1 || !!this.nextCursor() || this.hasMoreQueries());
private queriesFor(p: string): string[] {
return SHORTS_QUERIES[p] || SHORTS_QUERIES['youtube'];
}
private currentQuery(): string {
const list = this.queriesFor(this.provider());
return list[this.queryIndex % list.length];
}
private hasMoreQueries(): boolean {
const list = this.queriesFor(this.provider());
return this.exhaustedQueries.size < list.length;
}
readonly embedUrl = computed<SafeResourceUrl | null>(() => {
const v = this.current();
@@ -57,67 +86,119 @@ export class WatchShortComponent {
try {
const host = (location && location.hostname) ? location.hostname : 'localhost';
if (p === 'youtube' && v.videoId) {
// Même base que la page /watch : youtube-nocookie + origin/enablejsapi.
// L'embed youtube.com sans origin provoque l'erreur 153
// ("Video configuration error") sur certaines vidéos.
// Strictement la même base que /watch (youtube-nocookie + origin +
// widget_referrer + enablejsapi). Le flag videoEmbeddable + le filtre
// des vidéos non-embeddables (durée -1) évitent l'erreur 153 ; les
// params loop/playlist ont été retirés car ils déclenchaient 153
// sur certaines vidéos.
let origin = '';
let ref = '';
try {
origin = window.location.origin || '';
ref = window.location.href || '';
} catch {}
let u = `https://www.youtube-nocookie.com/embed/${encodeURIComponent(v.videoId)}?autoplay=1&mute=1&rel=0&modestbranding=1&playsinline=1&controls=1&enablejsapi=1&loop=1&playlist=${encodeURIComponent(v.videoId)}`;
let u = `https://www.youtube-nocookie.com/embed/${encodeURIComponent(v.videoId)}?autoplay=1&mute=1&rel=0&modestbranding=1&playsinline=1&enablejsapi=1`;
if (origin) u += `&origin=${encodeURIComponent(origin)}`;
if (ref) u += `&widget_referrer=${encodeURIComponent(ref)}`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'dailymotion' && v.videoId) {
const u = `https://www.dailymotion.com/embed/video/${encodeURIComponent(v.videoId)}?autoplay=1&mute=1`;
// Même base que /watch : api=1 + origin. Sans origin, le player
// répond "Forbidden" sur plusieurs vidéos.
let origin = '';
try { origin = window.location.origin || ''; } catch {}
let u = `https://www.dailymotion.com/embed/video/${encodeURIComponent(v.videoId)}?api=1&autoplay=1&mute=1`;
if (origin) u += `&origin=${encodeURIComponent(origin)}`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'twitch' && v.videoId) {
const parentsSet = new Set<string>([host, 'localhost', '127.0.0.1']);
const parentParams = Array.from(parentsSet).map(h => `parent=${encodeURIComponent(h)}`).join('&');
// If it's a clip, use the Clips embed endpoint
if ((v as any).kind === 'clip') {
const u = `https://clips.twitch.tv/embed?clip=${encodeURIComponent(v.videoId)}&${parentParams}&autoplay=true`;
// Les Shorts Twitch = clips uniquement (jamais de live/channel).
const clipId = (v as any).kind === 'clip' ? v.videoId : null;
if (clipId) {
const u = `https://clips.twitch.tv/embed?clip=${encodeURIComponent(clipId)}&${parentParams}&autoplay=true`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
// Otherwise treat it as a channel embed (live stream)
// Repli : player channel/VOD (ne devrait pas arriver, les lives
// sont exclus du flux Shorts).
const base = 'https://player.twitch.tv/';
const qs = `?channel=${encodeURIComponent(v.videoId)}&${parentParams}&autoplay=false`;
const u = base + qs;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
const chan = !/^\d+$/.test(String(v.videoId)) ? v.videoId : null;
const qs = chan
? `?channel=${encodeURIComponent(chan)}&${parentParams}&autoplay=true&muted=true`
: `?video=${encodeURIComponent(v.videoId)}&${parentParams}&autoplay=true&muted=true`;
return this.sanitizer.bypassSecurityTrustResourceUrl(base + qs);
}
if (p === 'peertube' && v.videoId) {
const inst = this.instances.activePeerTubeInstance();
const u = `https://${inst}/videos/embed/${encodeURIComponent(v.videoId)}?autoplay=1`;
// L'instance peut différer de l'instance active (navigation, fédération)
// : 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`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'odysee') {
// Try to derive slug from URL
let slug: string | null = null;
try {
const url = new URL(v.url || '');
slug = url.pathname.startsWith('/') ? url.pathname.slice(1) : url.pathname;
} catch {}
if (slug || v.videoId) {
const target = slug || v.videoId;
const u = `https://odysee.com/$/embed/${target}?autoplay=1&muted=1`;
const slug = this.odyseeSlugOf(v);
if (slug) {
const u = `https://odysee.com/$/embed/${slug}?autoplay=1`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
}
if (p === 'rumble' && v.videoId) {
// L'embed officiel attend l'ID court (vXXXX) ; le videoId peut contenir le slug complet.
const m = /^v[0-9A-Za-z]+/.exec(String(v.videoId));
const id = m ? m[0] : v.videoId;
const u = `https://rumble.com/embed/${encodeURIComponent(id)}/?autoplay=2&muted=1`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
if (p === 'rumble' && (v.videoId || v.url)) {
// Même base que /watch : /embed/<vId>/?pub=4&autoplay=1.
// autoplay=2/muted=1 n'existent pas côté Rumble ("Video not found").
const id = this.rumbleIdOf(v);
if (id) {
const u = `https://rumble.com/embed/${id}/?pub=4&autoplay=1`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
}
} catch {}
return null;
});
/** Host PeerTube déduit de l'URL vidéo (fédération), sinon null. */
private peerTubeHostOf(v: Video): string | null {
try {
const raw = String((v as any)?.url || '');
if (/^https?:\/\//i.test(raw)) {
const host = new URL(raw).hostname;
if (host && host.includes('.')) return host;
}
} catch {}
return null;
}
/** Slug Odysee "canal/video" (non encodé : @ : / doivent rester bruts). */
private odyseeSlugOf(v: Video): string | null {
try {
const raw = String((v as any)?.url || '').split(/[?#]/)[0];
if (/^https?:\/\//i.test(raw)) {
const u = new URL(raw);
let path = (u.pathname || '').replace(/^\/+\$?\/+embed\/+/, '').replace(/^\/+/, '').replace(/\/+$/, '');
if (path && path !== '/' && !/^embed$/i.test(path)) return path;
}
} catch {}
const id = String((v as any)?.videoId || '').trim();
return id || null;
}
/** ID court Rumble (vXXXX…), sans encodage (l'embed le veut brut). */
private rumbleIdOf(v: Video): string | null {
try {
const url = String((v as any)?.url || '');
const mUrl = /\/(v[\w-]+?)(?:\.html|\/|$)/i.exec(url);
if (mUrl?.[1]) return mUrl[1];
} catch {}
try {
const raw = String((v as any)?.videoId || '').trim();
const m = /^(v[0-9A-Za-z]+)/.exec(raw);
if (m?.[1]) return m[1];
if (raw) return raw.replace(/\.html$/i, '').split('/').filter(Boolean).pop() || raw;
} catch {}
return null;
}
/** Lien "voir en page Watch" pour le short courant (secours si l'embed échoue). */
readonly watchLink = computed<any[] | null>(() => {
const v = this.current();
@@ -130,10 +211,41 @@ export class WatchShortComponent {
const p = this.provider();
if (!v) return null;
const qp: any = { p };
if (p === 'twitch' && v.type === 'channel' && v.videoId) qp.channel = v.videoId;
if (p === 'twitch' && (v as any).kind === 'clip' && v.videoId) qp.clip = v.videoId;
else if (p === 'twitch' && v.type === 'channel' && v.videoId) qp.channel = v.videoId;
if (p === 'odysee') {
const slug = this.odyseeSlugOf(v);
if (slug) qp.slug = slug;
}
return qp;
});
/** URL "ouvrir chez le fournisseur" (youtube.com/shorts/…, etc.). */
readonly providerUrl = computed<string | null>(() => {
const v = this.current();
if (!v) return null;
const p = this.provider();
try {
if (p === 'youtube' && v.videoId) return `https://www.youtube.com/shorts/${encodeURIComponent(v.videoId)}`;
if (p === 'dailymotion' && v.videoId) return `https://www.dailymotion.com/video/${encodeURIComponent(v.videoId)}`;
if (p === 'twitch' && v.videoId) {
if ((v as any).kind === 'clip') return `https://clips.twitch.tv/${encodeURIComponent(v.videoId)}`;
return `https://www.twitch.tv/${encodeURIComponent(v.videoId)}`;
}
if (p === 'peertube' && v.videoId) {
const inst = this.peerTubeHostOf(v) || this.instances.activePeerTubeInstance();
return `https://${inst}/w/${encodeURIComponent(v.videoId)}`;
}
if (p === 'odysee' && v.url && /^https?:\/\//i.test(v.url)) return v.url;
if (p === 'rumble') {
if (v.url && /^https?:\/\//i.test(v.url)) return v.url;
const id = this.rumbleIdOf(v);
if (id) return `https://rumble.com/${id}`;
}
} catch {}
return null;
});
// Called when a query returns no usable items; falls back to Trending
private afterNoResults(): void {
this.error.set('Aucun Short trouvé pour ce fournisseur. Essayez un autre fournisseur ou réessayez.');
@@ -180,6 +292,18 @@ export class WatchShortComponent {
this.loadFeed();
}
});
// Pré-chargement façon YouTube Shorts : dès qu'on s'approche de la fin,
// on charge la page suivante pour un défilement sans impasse.
effect(() => {
const i = this.index();
const len = this.items().length;
const cursor = this.nextCursor();
const busy = this.busyMore();
const loading = this.loading();
if (!loading && !busy && len > 0 && i >= len - 3 && (cursor || this.hasMoreQueries())) {
this.fetchNextPage(false);
}
});
}
selectProvider(p: Provider): void {
@@ -192,13 +316,38 @@ export class WatchShortComponent {
this.loadFeed();
}
/**
* La vidéo courante est illisible (erreur 153 YouTube, Forbidden
* Dailymotion, "Video not found"…): on la retire du flux et on avance,
* façon YouTube Shorts qui saute les contenus indisponibles.
*/
skipUnplayable(): void {
const list = this.items();
const i = this.index();
if (!list.length) return;
const next = list.slice(0, i).concat(list.slice(i + 1));
this.items.set(next);
if (next.length === 0) {
if (this.nextCursor() || this.hasMoreQueries()) {
this.fetchNextPage(true);
} else {
this.afterNoResults();
}
return;
}
this.index.set(Math.min(i, next.length - 1));
if (this.index() >= next.length - 3) this.fetchNextPage(false);
}
loadFeed(): void {
this.loading.set(true);
this.error.set(null);
this.items.set([]);
this.index.set(0);
this.nextCursor.set(null);
this.altQueryTried = false;
this.seenIds = new Set<string>();
this.queryIndex = 0;
this.exhaustedQueries = new Set<string>();
const ready = this.instances.getProviderReadiness(this.provider());
if (!ready.ready) {
this.loading.set(false);
@@ -212,26 +361,64 @@ export class WatchShortComponent {
this.api.getTwitchClipsFeed()?.subscribe({
next: (res: { items: Video[]; nextCursor?: string | null }) => {
const raw: Video[] = (res.items || []).filter((v: Video) => !!v.videoId);
const list: Video[] = raw.filter((v: Video) => this.isShort(v));
const list: Video[] = this.dedup(raw.filter((v: Video) => this.isShort(v)));
this.items.set(list);
this.index.set(0);
this.nextCursor.set(res.nextCursor || null);
if (list.length === 0) this.afterNoResults(); else this.error.set(null);
this.loading.set(false);
if (list.length === 0) {
// Repli : clips thématiques puis trending, pour ne jamais rester vide.
this.loadTwitchThemeFallback();
} else {
this.error.set(null);
this.loading.set(false);
if (list.length <= 4) this.fetchNextPage(false);
}
},
error: () => {
this.error.set('Failed to load Shorts.');
this.loading.set(false);
this.loadTwitchThemeFallback();
}
});
return;
}
// Shorts via le flux dédié (filtre durée côté serveur quand possible).
// Chaîne de replis : recherche "shorts" → recherche "#shorts" → trending filtré
// (utile pour Rumble/Odysee dont la recherche plein-texte reste approximative).
this.api.searchShortsPage(SHORTS_QUERY).subscribe({
// Rotation : requête courante → suivante → trending filtré (infini).
this.api.searchShortsPage(this.currentQuery()).subscribe({
next: (res) => this.handleShortsResponse(res, true),
error: () => this.loadShortsAltQueryOrTrending(),
error: () => this.tryNextShortsQueryOrTrending(),
});
}
/** Repli Twitch : clips sur requêtes génériques, puis top streams. */
private loadTwitchThemeFallback(): void {
const queries = this.queriesFor('twitch');
const q = queries[this.queryIndex % queries.length] || 'gaming';
this.api.searchTwitchClipsPage(q)?.subscribe({
next: (res) => {
const raw = (res?.items || []).filter(v => !!v.videoId);
const list = this.dedup(raw.filter(v => this.isShort(v)));
if (list.length > 0) {
this.items.set(list);
this.index.set(0);
this.nextCursor.set(res?.nextCursor || null);
this.error.set(null);
this.loading.set(false);
if (list.length <= 4) this.fetchNextPage(false);
} else {
this.api.getTwitchTopStreamsPage()?.subscribe({
next: (top) => {
const clips = (top?.items || []).filter(v => !!v.videoId);
const mapped = this.dedup(clips.map(v => ({ ...v, kind: 'clip' as const })));
this.items.set(mapped);
this.index.set(0);
this.nextCursor.set(top?.nextCursor || null);
if (!mapped.length) this.afterNoResults(); else this.error.set(null);
this.loading.set(false);
},
error: () => { this.afterNoResults(); this.loading.set(false); }
});
}
},
error: () => { this.afterNoResults(); this.loading.set(false); }
});
}
@@ -239,41 +426,46 @@ export class WatchShortComponent {
private handleShortsResponse(res: { items: Video[]; nextCursor?: string | null }, allowAltQuery: boolean): void {
const raw = (res.items || []).filter(v => !!v.videoId);
this.nextCursor.set(res.nextCursor || null);
if (!res.nextCursor) this.exhaustedQueries.add(this.currentQuery());
const show = (list: Video[]) => {
this.items.set(list);
const deduped = this.dedup(list);
this.items.set(deduped);
this.index.set(0);
this.error.set(null);
this.loading.set(false);
if (deduped.length > 0 && deduped.length <= 4) this.fetchNextPage(false);
};
if (this.provider() === 'youtube' && raw.length) {
this.pickYouTubeShorts(raw).then(list => {
const shorts = list.filter(v => this.isShort(v));
const shorts = this.dedup(list.filter(v => this.isShort(v)));
if (shorts.length > 0) show(shorts);
else if (allowAltQuery) this.loadShortsAltQueryOrTrending();
else if (allowAltQuery) this.tryNextShortsQueryOrTrending();
else this.loadTrendingFallback();
}).catch(() => {
if (allowAltQuery) this.loadShortsAltQueryOrTrending();
if (allowAltQuery) this.tryNextShortsQueryOrTrending();
else this.loadTrendingFallback();
});
return;
}
const list = raw.filter(v => this.isShort(v));
const list = this.dedup(raw.filter(v => this.isShort(v)));
if (list.length > 0) show(list);
else if (allowAltQuery) this.loadShortsAltQueryOrTrending();
else if (allowAltQuery) this.tryNextShortsQueryOrTrending();
else this.loadTrendingFallback();
}
/** 2e chance avec une requête alternative avant le repli trending. */
private loadShortsAltQueryOrTrending(): void {
if (this.altQueryTried) {
this.loadTrendingFallback();
/** Passe à la requête suivante de la rotation avant le repli trending. */
private tryNextShortsQueryOrTrending(): void {
const list = this.queriesFor(this.provider());
this.exhaustedQueries.add(this.currentQuery());
if (this.queryIndex < list.length - 1) {
this.queryIndex++;
this.api.searchShortsPage(this.currentQuery()).subscribe({
next: (res) => this.handleShortsResponse(res, true),
error: () => this.tryNextShortsQueryOrTrending(),
});
return;
}
this.altQueryTried = true;
this.api.searchShortsPage(SHORTS_ALT_QUERY).subscribe({
next: (res) => this.handleShortsResponse(res, false),
error: () => this.loadTrendingFallback(),
});
this.loadTrendingFallback();
}
/** Repli : trending du provider filtré sur les Shorts (jamais de contenu long). */
@@ -283,10 +475,11 @@ export class WatchShortComponent {
const raw = (res.items || []).filter(v => !!v.videoId);
const p = this.provider();
const apply = (list: Video[]) => {
this.items.set(list);
const deduped = this.dedup(list);
this.items.set(deduped);
this.index.set(0);
this.nextCursor.set(null);
if (list.length === 0) this.afterNoResults(); else this.error.set(null);
if (deduped.length === 0) this.afterNoResults(); else this.error.set(null);
this.loading.set(false);
};
if (p === 'youtube' && raw.length) {
@@ -302,48 +495,92 @@ export class WatchShortComponent {
});
}
// Generic Shorts check — classification partagée (une durée 0/absente
// ne qualifie JAMAIS un Short : les scrapers renvoient 0 quand inconnue).
// Filtre Shorts — classification partagée, avec tolérance pour les
// providers dont les durées sont souvent inconnues (Rumble/Odysee : le
// scraper renvoie 0) afin de ne jamais afficher une page vide.
private isShort(v: Video): boolean {
const p = (v as any).provider || this.provider();
if (String(p) === 'twitch') {
const kind = String((v as any).kind || '').toLowerCase();
if (kind === 'clip') return true;
// Les lives/channels ne sont jamais des Shorts.
const type = String((v as any).type || '').toLowerCase();
if (type === 'live' || type === 'channel' || type === 'stream') return false;
}
if ((v as any).isShort === true) {
const d = Number((v as any).duration || 0);
if (d > 0 && d > 75) return false;
return true;
}
const d = Number((v as any).duration || 0);
if ((String(p) === 'rumble' || String(p) === 'odysee') && !(d > 0)) {
// Durée inconnue : on garde la vidéo (flux infini) plutôt qu'un vide.
return true;
}
return isShortVideo({ ...(v as object), provider: p } as Parameters<typeof isShortVideo>[0]);
}
private fetchNextPage(autoAdvance = false) {
if (this.busyMore() || !this.nextCursor()) return;
this.busyMore.set(true);
const cursor = this.nextCursor();
const provider = this.provider();
const handle = (res: any) => {
const raw: Video[] = (res.items || []).filter((v: Video) => !!v.videoId);
const p = this.provider();
const apply = (more: Video[]) => {
const merged = this.items().concat(more);
this.items.set(merged);
this.nextCursor.set(res.nextCursor || null);
if (autoAdvance && more.length > 0) {
this.index.update(i => Math.min(i + 1, merged.length - 1));
}
this.busyMore.set(false);
};
if (p === 'youtube' && raw.length) {
this.pickYouTubeShorts(raw).then((list: Video[]) => apply(list.filter((v: Video) => this.isShort(v)))).catch(() => { apply([]); });
} else {
apply(raw.filter((v: Video) => this.isShort(v)));
}
};
/** Dedup inter-pages (clé videoId + provider). */
private dedup(list: Video[]): Video[] {
const out: Video[] = [];
for (const v of list) {
const key = `${String((v as any).provider || this.provider())}:${String(v.videoId)}`;
if (!v.videoId || this.seenIds.has(key)) continue;
this.seenIds.add(key);
out.push(v);
}
return out;
}
private fetchNextPage(autoAdvance = false) {
if (this.busyMore()) return;
const provider = this.provider();
// Cas Twitch : pagination du flux de clips (curseur streams) ou rotation.
if (provider === 'twitch') {
if (!this.nextCursor() && !this.hasMoreQueries() && this.items().length > 0) {
// Boucle infinie : on repart du début quand tout est épuisé.
this.nextCursor.set(null);
}
if (!this.nextCursor()) {
this.rotateTwitchQuery();
return;
}
this.busyMore.set(true);
const cursor = this.nextCursor();
this.api.getTwitchClipsFeed(cursor || undefined)?.subscribe({
next: handle,
next: (res: any) => this.handleMore(res, autoAdvance, () => this.rotateTwitchQuery(autoAdvance)),
error: () => { this.busyMore.set(false); }
});
return;
}
this.api.searchShortsPage(SHORTS_QUERY, cursor || undefined).subscribe({
const cursor = this.nextCursor();
if (!cursor) {
// Curseur épuisé → requête suivante de la rotation (infini).
const list = this.queriesFor(provider);
this.exhaustedQueries.add(this.currentQuery());
if (this.queryIndex < list.length - 1) {
this.queryIndex++;
this.busyMore.set(true);
this.api.searchShortsPage(this.currentQuery()).subscribe({
next: (res) => this.handleMore(res, autoAdvance),
error: () => { this.busyMore.set(false); }
});
} else if (this.items().length > 0) {
// Tout est épuisé : on boucle sur la première requête (jamais de fin).
this.queryIndex = 0;
this.exhaustedQueries = new Set<string>();
this.busyMore.set(true);
this.api.searchShortsPage(this.currentQuery()).subscribe({
next: (res) => this.handleMore(res, autoAdvance),
error: () => { this.busyMore.set(false); }
});
}
return;
}
this.busyMore.set(true);
this.api.searchShortsPage(this.currentQuery(), cursor || undefined).subscribe({
next: (res) => {
handle(res);
this.handleMore(res, autoAdvance);
},
error: () => {
this.busyMore.set(false);
@@ -351,10 +588,61 @@ export class WatchShortComponent {
});
}
/** Passe à la requête Twitch suivante (clips thématiques). */
private rotateTwitchQuery(autoAdvance = false): void {
const list = this.queriesFor('twitch');
if (this.queryIndex < list.length - 1) {
this.queryIndex++;
this.busyMore.set(true);
this.api.searchTwitchClipsPage(this.currentQuery())?.subscribe({
next: (res: any) => this.handleMore(res, autoAdvance),
error: () => { this.busyMore.set(false); }
});
} else if (this.items().length > 0) {
this.queryIndex = 0;
this.busyMore.set(true);
this.api.searchTwitchClipsPage(this.currentQuery())?.subscribe({
next: (res: any) => this.handleMore(res, autoAdvance),
error: () => { this.busyMore.set(false); }
});
} else {
this.busyMore.set(false);
}
}
/** Fusionne une page supplémentaire (dedup + filtre Shorts + avance auto). */
private handleMore(res: any, autoAdvance: boolean, onEmpty?: () => void): void {
const raw: Video[] = ((res.items || []) as Video[]).filter((v: Video) => !!v.videoId);
const p = this.provider();
const apply = (more: Video[]) => {
const deduped = this.dedup(more);
const merged = this.items().concat(deduped);
this.items.set(merged);
this.nextCursor.set(res.nextCursor || null);
if (!res.nextCursor) this.exhaustedQueries.add(this.currentQuery());
if (autoAdvance && deduped.length > 0) {
this.index.update(i => Math.min(i + 1, merged.length - 1));
} else if (deduped.length === 0 && onEmpty) {
onEmpty();
return;
} else if (deduped.length === 0 && !res.nextCursor && this.hasMoreQueries() && p !== 'twitch') {
this.busyMore.set(false);
this.fetchNextPage(autoAdvance);
return;
}
this.busyMore.set(false);
};
if (p === 'youtube' && raw.length) {
this.pickYouTubeShorts(raw).then((list: Video[]) => apply(list.filter((v: Video) => this.isShort(v)))).catch(() => { apply([]); });
} else {
apply(raw.filter((v: Video) => this.isShort(v)));
}
}
next(): void {
if (this.canNext() && this.index() < this.items().length - 1) {
this.index.update(i => i + 1);
} else if (this.nextCursor()) {
} else if (this.nextCursor() || this.hasMoreQueries()) {
this.fetchNextPage(true);
}
}
+6 -4
View File
@@ -1525,15 +1525,17 @@ 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 sPrimary = `/api/twitch-api/helix/streams?first=10${after}`;
const sFallback = `/proxy/twitch-api/helix/streams?first=10${after}`;
const sPrimary = `/api/twitch-api/helix/streams?first=20${after}`;
const sFallback = `/proxy/twitch-api/helix/streams?first=20${after}`;
return this.http.get<any>(sPrimary, { headers }).pipe(
catchError(() => this.http.get<any>(sFallback, { headers })),
switchMap(sRes => {
const streams = Array.isArray(sRes?.data) ? sRes.data : [];
const userIds: string[] = streams.map((s: any) => s?.user_id).filter(Boolean).slice(0, 10);
const userIds: string[] = streams.map((s: any) => s?.user_id).filter(Boolean).slice(0, 20);
if (!userIds.length) return of({ items: [], nextCursor: null } as PagedResult<Video>);
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
// Fenêtre large (90 j) : beaucoup de chaînes n'ont pas de clips sur 7 j,
// ce qui vidait la page Shorts Twitch ("Aucun Short trouvé").
const since = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString();
const perUser = userIds.map(uid => {
const cPrimary = `/api/twitch-api/helix/clips?broadcaster_id=${encodeURIComponent(uid)}&first=5&started_at=${encodeURIComponent(since)}`;
const cFallback = `/proxy/twitch-api/helix/clips?broadcaster_id=${encodeURIComponent(uid)}&first=5&started_at=${encodeURIComponent(since)}`;