fix(shorts): embeds par fournisseur + flux infini (153/Forbidden/not-found)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
Reference in New Issue
Block a user