diff --git a/src/components/shorts/watch-short.component.html b/src/components/shorts/watch-short.component.html index e097783..2458639 100644 --- a/src/components/shorts/watch-short.component.html +++ b/src/components/shorts/watch-short.component.html @@ -156,36 +156,6 @@ @if (busyMore()) {
Chargement…
} - - -
- - @if (items().length > 0) { - - {{ index() + 1 }} / {{ items().length }} - - } @else { - - } - -
- - @if (forYou() && (topics().length > 0 || topicsLoading())) { -

- @if (topicsLoading()) { - Analyse de vos goûts… - } @else { - Sujets : {{ topics().join(' · ') }} - } -

- } -

Molette, flèches ↑ ↓ ou balayage pour zapper. Son coupé par défaut (🔇) : l'activer reprend la vidéo au début. Vidéo illisible ? Menu ⋯ → Passer.

+ diff --git a/src/components/shorts/watch-short.component.ts b/src/components/shorts/watch-short.component.ts index 5672930..69a62b6 100644 --- a/src/components/shorts/watch-short.component.ts +++ b/src/components/shorts/watch-short.component.ts @@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common'; import { RouterLink } from '@angular/router'; import { HttpClient } from '@angular/common/http'; import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; -import { firstValueFrom, forkJoin, of } from 'rxjs'; +import { firstValueFrom, forkJoin, map, of } from 'rxjs'; import { catchError } from 'rxjs/operators'; import { YoutubeApiService } from '../../services/youtube-api.service'; import { InstanceService, Provider } from '../../services/instance.service'; @@ -86,10 +86,16 @@ export class WatchShortComponent implements OnDestroy { frameRef = viewChild>('shortsFrame'); /** Son coupé (autoplay) : rebascule l'URL d'embed (reprend au début). */ muted = signal(true); - /** Embed Rumble autoritaire résolu via /api/rumble/video/ (comme /watch). */ + /** + * Embed Rumble autoritaire : id demandé -> vrai id scrapé via + * /api/rumble/video/ (comme /watch). Les IDs de liste approximatifs + * ("Video not found") sont ainsi corrigés ou retirés du flux. + */ resolvedRumbleEmbed = signal>({}); - private rumbleResolving = new Set(); + private rumblePending = new Set(); + private rumbleInflight = new Set(); private rumbleEmbedFailed = new Set(); + private readonly RUMBLE_RESOLVE_CONCURRENCY = 3; /** J'aime (état + busy). */ liked = signal(false); private likedCheckedFor = ''; @@ -242,16 +248,13 @@ export class WatchShortComponent implements OnDestroy { } } if (p === 'rumble' && (v.videoId || v.url)) { - // Même base que /watch : /embed//?pub=4&autoplay=1. - // L'URL autoritaire résolue via /api/rumble/video/ (scrape de - // la page, comme /watch) prime : les IDs de liste sont parfois - // approximatifs ("Video not found" alors que Watch fonctionne). + // Même base que /watch. L'id autoritaire résolu via + // /api/rumble/video/ prime (les IDs de liste approximatifs + // donnent "Video not found" alors que Watch fonctionne). const id = this.rumbleIdOf(v); - const resolved = (id && this.resolvedRumbleEmbed()[id]) || null; - if (resolved) return this.sanitizer.bypassSecurityTrustResourceUrl(resolved); - if (id) { - const u = `https://rumble.com/embed/${id}/?pub=4&autoplay=1`; - return this.sanitizer.bypassSecurityTrustResourceUrl(u); + const clean = (id && this.resolvedRumbleEmbed()[id]) || id; + if (clean) { + return this.sanitizer.bypassSecurityTrustResourceUrl(this.rumbleEmbedUrl(clean)); } } } catch {} @@ -284,6 +287,15 @@ export class WatchShortComponent implements OnDestroy { return id || null; } + /** + * URL d'embed Rumble : autoplay=2 = lecture auto (mode muet, compatible + * avec le blocage autoplay des navigateurs) ; autoplay=1 + son sinon. + */ + private rumbleEmbedUrl(id: string): string { + const ap = this.muted() ? 2 : 1; + return `https://rumble.com/embed/${id}/?pub=4&autoplay=${ap}`; + } + /** ID court Rumble (vXXXX…), sans encodage (l'embed le veut brut). */ private rumbleIdOf(v: Video): string | null { try { @@ -411,7 +423,7 @@ export class WatchShortComponent implements OnDestroy { if (this.subs.items().length === 0) this.subs.list().subscribe(); this.subs.listGroups().subscribe(); } catch {} - // Résolution Rumble autoritaire (courant + suivant) + reset progression. + // Pré-validation Rumble (courant + suivant) + reset progression. effect(() => { const v = this.current(); const i = this.index(); @@ -419,9 +431,8 @@ export class WatchShortComponent implements OnDestroy { this.ytProgress.set(null); this.menuOpen.set(false); if (!v) return; - if (this.providerOf(v) === 'rumble') this.ensureRumbleEmbed(v); const nx = (i >= 0 && i + 1 < list.length) ? list[i + 1] : null; - if (nx && this.providerOf(nx) === 'rumble') this.ensureRumbleEmbed(nx); + this.queueRumbleResolves([v, nx]); // État J'aime du short courant (connecté uniquement). try { if (this.isLoggedIn() && v.videoId) { @@ -450,30 +461,57 @@ export class WatchShortComponent implements OnDestroy { if (this.shareCopiedTimer) { try { clearTimeout(this.shareCopiedTimer); } catch {} } } - /** Résout l'embed Rumble autoritaire via le backend (comme /watch). */ - private ensureRumbleEmbed(v: Video): void { - const id = this.rumbleIdOf(v); - if (!id || this.resolvedRumbleEmbed()[id] || this.rumbleResolving.has(id) || this.rumbleEmbedFailed.has(id)) return; - this.rumbleResolving.add(id); - this.http.get(`/api/rumble/video/${encodeURIComponent(id)}`).pipe( - catchError(() => of(null)) - ).subscribe({ - next: (data) => { - this.rumbleResolving.delete(id); - const eurl = String(data?.embedUrl || ''); - const m = /\/embed\/(v[0-9A-Za-z]+)/.exec(eurl); - if (eurl && m?.[1]) { - const clean = `https://rumble.com/embed/${m[1]}/?pub=4&autoplay=1`; - this.resolvedRumbleEmbed.update(r => ({ ...r, [id]: clean })); - } else { - this.rumbleEmbedFailed.add(id); + /** + * File de résolution Rumble (concurrence bornée) : chaque id est vérifié + * via le backend (comme /watch). Succès => l'embed bascule sur le vrai + * id ; 404 (page absente, premium…) => la vidéo est retirée du flux + * AVANT d'afficher "Video not found". Autres erreurs réseau => on garde + * l'URL construite (le doute profite à la vidéo). + */ + private queueRumbleResolves(videos: (Video | null)[]): void { + let added = false; + for (const v of videos) { + if (!v || this.providerOf(v) !== 'rumble') continue; + const id = this.rumbleIdOf(v); + if (!id || this.resolvedRumbleEmbed()[id] || this.rumbleEmbedFailed.has(id) || + this.rumblePending.has(id) || this.rumbleInflight.has(id)) continue; + this.rumblePending.add(id); + added = true; + } + if (added) this.pumpRumbleQueue(); + } + + private pumpRumbleQueue(): void { + while (this.rumbleInflight.size < this.RUMBLE_RESOLVE_CONCURRENCY) { + const next = [...this.rumblePending][0]; + if (!next) return; + this.rumblePending.delete(next); + if (this.resolvedRumbleEmbed()[next] || this.rumbleEmbedFailed.has(next)) continue; + this.rumbleInflight.add(next); + this.http.get(`/api/rumble/video/${encodeURIComponent(next)}`).subscribe({ + next: (data) => { + this.rumbleInflight.delete(next); + const eurl = String(data?.embedUrl || ''); + const m = /\/embed\/(v[0-9A-Za-z]+)/.exec(eurl); + if (eurl && m?.[1]) { + this.resolvedRumbleEmbed.update(r => ({ ...r, [next]: m[1] })); + } else { + this.rumbleEmbedFailed.add(next); + this.dropRumbleId(next); + } + this.pumpRumbleQueue(); + }, + error: (err) => { + this.rumbleInflight.delete(next); + // 404 = introuvable de façon certaine => retrait du flux. + if (err?.status === 404) { + this.rumbleEmbedFailed.add(next); + this.dropRumbleId(next); + } + this.pumpRumbleQueue(); } - }, - error: () => { - this.rumbleResolving.delete(id); - this.rumbleEmbedFailed.add(id); - } - }); + }); + } } // ---- Actions façon YouTube Shorts ---- @@ -580,7 +618,7 @@ export class WatchShortComponent implements OnDestroy { }); } - /** Progression YouTube via postMessage (enablejsapi=1) : barre rouge façon Shorts. */ + /** Progression + erreurs YouTube via postMessage (enablejsapi=1). */ private handleShortMessage = (ev: MessageEvent): void => { try { const origin = String(ev?.origin || ''); @@ -589,6 +627,12 @@ export class WatchShortComponent implements OnDestroy { if (!v || this.providerOf(v) !== 'youtube') return; const data = typeof ev?.data === 'string' ? JSON.parse(ev.data) : ev?.data; if (!data || typeof data !== 'object') return; + // Erreur player (153/150/101…) : on saute automatiquement, comme l'app + // native qui ne montre jamais une erreur à la place d'un Short. + if (data.event === 'onError') { + try { this.skipUnplayable(); } catch {} + return; + } if (data.event === 'infoDelivery' && data.info && typeof data.info === 'object') { const ct = Number(data.info.currentTime); let dur = Number(data.info.duration); @@ -791,6 +835,8 @@ export class WatchShortComponent implements OnDestroy { this.items.set(merged); this.index.set(0); this.nextCursor.set(null); + // Pré-validation Rumble (retire les "Video not found"). + this.queueRumbleResolves(merged); if (!merged.length) this.afterNoResults(); else this.error.set(null); this.loading.set(false); }, @@ -811,21 +857,39 @@ export class WatchShortComponent implements OnDestroy { * façon YouTube Shorts qui saute les contenus indisponibles. */ skipUnplayable(): void { + this.dropAt(this.index()); + } + + /** + * Retire silencieusement l'item à `idx` (pré-validation échouée, erreur + * player…) en gardant la position de lecture cohérente, puis relance le + * flux si la liste se vide (infini). + */ + private dropAt(idx: number): void { const list = this.items(); - const i = this.index(); - if (!list.length) return; - const next = list.slice(0, i).concat(list.slice(i + 1)); + if (idx < 0 || idx >= list.length) return; + const cur = this.index(); + const next = list.slice(0, idx).concat(list.slice(idx + 1)); this.items.set(next); - if (next.length === 0) { - if (this.nextCursor() || this.hasMoreQueries()) { + if (idx < cur) this.index.set(cur - 1); + else if (cur >= next.length) this.index.set(Math.max(0, next.length - 1)); + if (next.length === 0 && !this.loading()) { + if (this.forYou()) { + this.afterNoResults(); + } else if (this.nextCursor() || this.hasMoreQueries() || this.provider() === 'rumble' || this.provider() === 'twitch') { this.fetchNextPage(true); } else { this.afterNoResults(); } - return; + } else if (!this.forYou() && this.index() >= Math.max(0, next.length - 3)) { + this.fetchNextPage(false); } - this.index.set(Math.min(i, next.length - 1)); - if (this.index() >= next.length - 3) this.fetchNextPage(false); + } + + private dropRumbleId(id: string): void { + const list = this.items(); + const idx = list.findIndex(v => this.providerOf(v) === 'rumble' && this.rumbleIdOf(v) === id); + if (idx >= 0) this.dropAt(idx); } loadFeed(): void { @@ -854,18 +918,24 @@ export class WatchShortComponent implements OnDestroy { this.api.getTwitchClipsFeed()?.subscribe({ next: (res: { items: Video[]; nextCursor?: string | null }) => { const raw: Video[] = (res.items || []).filter((v: Video) => !!v.videoId); - 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) { - // 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); - } + // Double sécurité anti "time machine" sur les clips non validés. + this.withoutDeadClips(raw.filter((v: Video) => this.isShort(v))).subscribe({ + next: (filtered) => { + const list: Video[] = this.dedup(filtered); + this.items.set(list); + this.index.set(0); + this.nextCursor.set(res.nextCursor || null); + 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.loadTwitchThemeFallback(), + }); }, error: () => { this.loadTwitchThemeFallback(); @@ -888,33 +958,44 @@ export class WatchShortComponent implements OnDestroy { 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); } - }); - } + const shorts = raw.filter(v => this.isShort(v)); + // Les clips de recherche ne sont pas validés côté service. + this.withoutDeadClips(shorts).subscribe({ + next: (filtered) => { + const list = this.dedup(filtered); + this.showTwitchFallbackList(list, res?.nextCursor || null); + }, + error: () => this.showTwitchFallbackList(this.dedup(shorts), res?.nextCursor || null), + }); }, error: () => { this.afterNoResults(); this.loading.set(false); } }); } + private showTwitchFallbackList(list: Video[], nextCursor: string | null): void { + if (list.length > 0) { + this.items.set(list); + this.index.set(0); + this.nextCursor.set(nextCursor); + 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); } + }); + } + } + /** Applique le filtre Shorts à une page de résultats (1er passage ou requête de secours). */ private handleShortsResponse(res: { items: Video[]; nextCursor?: string | null }, allowAltQuery: boolean): void { const raw = (res.items || []).filter(v => !!v.videoId); @@ -926,6 +1007,8 @@ export class WatchShortComponent implements OnDestroy { this.index.set(0); this.error.set(null); this.loading.set(false); + // Pré-validation Rumble de tout le lot (retire les "Video not found"). + this.queueRumbleResolves(deduped); if (deduped.length > 0 && deduped.length <= 4) this.fetchNextPage(false); }; if (this.provider() === 'youtube' && raw.length) { @@ -1022,6 +1105,22 @@ export class WatchShortComponent implements OnDestroy { return isShortVideo({ ...(v as object), provider: p } as Parameters[0]); } + /** + * Exclut les clips Twitch morts ("time machine") avant affichage. + * Les flux game/broadcaster sont déjà validés côté service ; ce garde-fou + * couvre la recherche de clips thématique (repli). + */ + private withoutDeadClips(list: Video[]): import('rxjs').Observable { + const clips = list.filter(v => String((v as any).kind || '').toLowerCase() === 'clip' && !!v.videoId); + if (!clips.length) return of(list); + return this.api.validateTwitchClipIds(clips.map(v => v.videoId)).pipe( + map(valid => list.filter(v => + String((v as any).kind || '').toLowerCase() !== 'clip' || !v.videoId || valid.has(v.videoId) + )), + catchError(() => of(list)) + ); + } + /** Dedup inter-pages (clé videoId + provider). */ private dedup(list: Video[]): Video[] { const out: Video[] = []; @@ -1120,6 +1219,8 @@ export class WatchShortComponent implements OnDestroy { const deduped = this.dedup(more); const merged = this.items().concat(deduped); this.items.set(merged); + // Pré-validation Rumble du nouveau lot. + this.queueRumbleResolves(deduped); this.nextCursor.set(res.nextCursor || null); if (!res.nextCursor) this.exhaustedQueries.add(this.currentQuery()); if (autoAdvance && deduped.length > 0) { @@ -1136,6 +1237,12 @@ export class WatchShortComponent implements OnDestroy { }; if (p === 'youtube' && raw.length) { this.pickYouTubeShorts(raw).then((list: Video[]) => apply(list.filter((v: Video) => this.isShort(v)))).catch(() => { apply([]); }); + } else if (p === 'twitch' && raw.length) { + // Les clips paginés ne sont pas tous validés côté service. + this.withoutDeadClips(raw.filter((v: Video) => this.isShort(v))).subscribe({ + next: (clean) => apply(clean), + error: () => apply(raw.filter((v: Video) => this.isShort(v))), + }); } else { apply(raw.filter((v: Video) => this.isShort(v))); } diff --git a/src/services/youtube-api.service.ts b/src/services/youtube-api.service.ts index 46875ad..3776d25 100644 --- a/src/services/youtube-api.service.ts +++ b/src/services/youtube-api.service.ts @@ -1715,14 +1715,31 @@ export class YoutubeApiService { const fallback = `/proxy/twitch-api/helix/clips?game_id=${encodeURIComponent(gameId)}&first=20&started_at=${encodeURIComponent(since)}${after}`; return this.http.get(primary, { headers }).pipe( catchError(() => this.http.get(fallback, { headers })), - map(res => { - const clips = Array.isArray(res?.data) ? res.data : []; - const page: string | null = res?.pagination?.cursor || null; - return { - items: clips.map((i: any) => this.mapTwitchClipToVideo(i)), - // Jeu suivant quand la page est épuisée (rotation infinie). - nextCursor: page ? `${gameId}::${page}` : this.nextTwitchGameCursor(gameId), - } as PagedResult