From 34f432a7c183c7303c1b58236087a1a90cb03c27 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Tue, 29 Sep 2026 09:04:09 -0400 Subject: [PATCH] fix(shorts): embeds par fournisseur + flux infini (153/Forbidden/not-found) --- .../shorts/watch-short.component.html | 41 +- .../shorts/watch-short.component.ts | 474 ++++++++++++++---- src/services/youtube-api.service.ts | 10 +- 3 files changed, 416 insertions(+), 109 deletions(-) diff --git a/src/components/shorts/watch-short.component.html b/src/components/shorts/watch-short.component.html index bfd0ee6..fcc5805 100644 --- a/src/components/shorts/watch-short.component.html +++ b/src/components/shorts/watch-short.component.html @@ -12,31 +12,33 @@ -
- -
+
+ +
-
+
@if (embedUrl(); as url) { - + allowfullscreen referrerpolicy="strict-origin-when-cross-origin"> } @else if (loading()) { -
+
Chargement des Shorts…
} @else if (error()) { -
+
{{ error() }}
} - + @if (current(); as v) { -
+
@@ -44,12 +46,26 @@
@{{ v.uploaderName }}
Ouvrir + class="chip pointer-events-auto shrink-0 px-3 py-2 rounded bg-white/20 hover:bg-white/30 text-xs">Ouvrir
}
+ +
+ + + + +
+ @if (busyMore()) {
Chargement…
} @@ -80,4 +96,5 @@
+

Si une vidéo affiche une erreur (153, Forbidden, introuvable…), utilisez ⏭ Passer : elle est retirée du flux.

diff --git a/src/components/shorts/watch-short.component.ts b/src/components/shorts/watch-short.component.ts index b98f628..4998bc2 100644 --- a/src/components/shorts/watch-short.component.ts +++ b/src/components/shorts/watch-short.component.ts @@ -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 = { + 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(null); busyMore = signal(false); + /** IDs déjà vus (dedup inter-pages pour l'infini). */ + private seenIds = new Set(); + /** 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(); // 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(() => { 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([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//?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(() => { 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(() => { + 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(); + this.queryIndex = 0; + this.exhaustedQueries = new Set(); 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[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(); + 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); } } diff --git a/src/services/youtube-api.service.ts b/src/services/youtube-api.service.ts index 4c7c942..0874904 100644 --- a/src/services/youtube-api.service.ts +++ b/src/services/youtube-api.service.ts @@ -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(sPrimary, { headers }).pipe( catchError(() => this.http.get(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