fix(shorts): pré-validation (clips morts, Rumble 404), autoplay Rumble, onError auto-skip, zone sous-vidéo retirée
CI / build-and-test (push) Successful in 14m16s

This commit is contained in:
2026-09-29 11:23:33 -04:00
parent f76a3795c2
commit 3f4cd56f14
3 changed files with 255 additions and 125 deletions
@@ -156,36 +156,6 @@
@if (busyMore()) {
<div class="px-3 py-1.5 rounded-full bg-black/70 text-white text-xs" role="status">Chargement…</div>
}
<!-- Navigation fine (mobile + compteur) -->
<div class="flex w-full items-center justify-between gap-3" style="max-width: 560px;">
<button type="button" (click)="prev()" [disabled]="!canPrev()"
class="chip shrink-0 rounded-lg bg-slate-800/80 px-4 py-2.5 text-white text-sm disabled:opacity-40"
aria-label="Short précédent">
<span>↑ Préc.</span>
</button>
@if (items().length > 0) {
<span class="shrink-0 text-sm tabular-nums text-slate-400" role="status" aria-live="polite">
{{ index() + 1 }} / {{ items().length }}
</span>
} @else {
<span class="shrink-0"></span>
}
<button type="button" (click)="next()" [disabled]="!canNext()"
class="chip shrink-0 rounded-lg bg-slate-800/80 px-4 py-2.5 text-white text-sm disabled:opacity-40"
aria-label="Short suivant">
<span>Suiv. ↓</span>
</button>
</div>
@if (forYou() && (topics().length > 0 || topicsLoading())) {
<p class="max-w-lg text-center text-xs text-slate-400" role="status" aria-live="polite">
@if (topicsLoading()) {
<span>Analyse de vos goûts…</span>
} @else {
<span>Sujets : {{ topics().join(' · ') }}</span>
}
</p>
}
<p class="max-w-lg text-center text-xs text-slate-500">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.</p>
<!-- Flux infini : aucune pagination visible (compteur et boutons Préc./Suiv.
retirés) — molette, flèches ↑ ↓, balayage ou flèches latérales (bureau). -->
</div>
+188 -81
View File
@@ -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<ElementRef<HTMLElement>>('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/<id> (comme /watch). */
/**
* Embed Rumble autoritaire : id demandé -> vrai id scrapé via
* /api/rumble/video/<id> (comme /watch). Les IDs de liste approximatifs
* ("Video not found") sont ainsi corrigés ou retirés du flux.
*/
resolvedRumbleEmbed = signal<Record<string, string>>({});
private rumbleResolving = new Set<string>();
private rumblePending = new Set<string>();
private rumbleInflight = new Set<string>();
private rumbleEmbedFailed = new Set<string>();
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/<vId>/?pub=4&autoplay=1.
// L'URL autoritaire résolue via /api/rumble/video/<id> (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/<id> 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<any>(`/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<any>(`/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<typeof isShortVideo>[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<Video[]> {
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)));
}
+65 -12
View File
@@ -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<any>(primary, { headers }).pipe(
catchError(() => this.http.get<any>(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<Video>;
map(res => ({
clips: Array.isArray(res?.data) ? res.data : [],
page: (res?.pagination?.cursor || null) as string | null,
})),
// Ne garde que les clips existants (les morts affichent
// "time machine" dans le player).
switchMap(({ clips, page }) => {
const ids = clips.map((c: any) => String(c?.id || '')).filter(Boolean);
if (!ids.length) {
return of({
items: [],
nextCursor: page ? `${gameId}::${page}` : this.nextTwitchGameCursor(gameId),
} as PagedResult<Video>);
}
return this.validateTwitchClipIds(ids).pipe(
map(valid => ({
items: clips.filter((c: any) => valid.has(String(c?.id || ''))).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<Video>)),
catchError(() => of({
items: clips.map((i: any) => this.mapTwitchClipToVideo(i)),
nextCursor: page ? `${gameId}::${page}` : this.nextTwitchGameCursor(gameId),
} as PagedResult<Video>))
);
}),
catchError(() => of({
items: [],
@@ -1733,6 +1750,33 @@ export class YoutubeApiService {
);
}
/**
* Valide l'existence des clips par lot (`GET /helix/clips?id=…`, 100 max).
* Les clips supprimés/expirés affichent "Sorry. Unless you've got a time
* machine, that content is unavailable." dans le player : on les exclut
* AVANT affichage. En cas d'échec de validation, le doute profite aux
* clips (on garde tout) pour ne jamais vider le flux.
*/
public validateTwitchClipIds(ids: string[]): Observable<Set<string>> {
const clean = Array.from(new Set((ids || []).map(s => String(s || '').trim()).filter(Boolean))).slice(0, 100);
if (!clean.length) return of(new Set<string>());
return this.getTwitchAuthToken().pipe(timeout(15000),
switchMap(token => {
if (!token) return of(new Set<string>(clean));
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
const params = clean.map(s => `id=${encodeURIComponent(s)}`).join('&');
const primary = `/api/twitch-api/helix/clips?${params}`;
const fallback = `/proxy/twitch-api/helix/clips?${params}`;
return this.http.get<any>(primary, { headers }).pipe(
catchError(() => this.http.get<any>(fallback, { headers })),
map(res => new Set<string>((Array.isArray(res?.data) ? res.data : []).map((c: any) => String(c?.id || '')).filter(Boolean))),
catchError(() => of(new Set<string>(clean)))
);
}),
catchError(() => of(new Set<string>(clean)))
);
}
/** Curseur vers le jeu suivant (boucle infinie sur les top games). */
private nextTwitchGameCursor(currentGameId: string): string | null {
const games = this.twitchTopGameIds || [];
@@ -1795,10 +1839,19 @@ export class YoutubeApiService {
});
return forkJoin(perUser).pipe(
map((lists: any[][]) => lists.flat().slice(0, 24)),
map((clips: any[]) => ({
items: clips.map((i: any) => this.mapTwitchClipToVideo(i)),
nextCursor: sRes?.pagination?.cursor || null,
} as PagedResult<Video>)),
// Exclut les clips morts ("time machine") avant affichage.
switchMap((clips: any[]) => {
const ids = clips.map((c: any) => String(c?.id || '')).filter(Boolean);
if (!ids.length) {
return of({ items: [], nextCursor: sRes?.pagination?.cursor || null } as PagedResult<Video>);
}
return this.validateTwitchClipIds(ids).pipe(
map(valid => ({
items: clips.filter((c: any) => valid.has(String(c?.id || ''))).map((i: any) => this.mapTwitchClipToVideo(i)),
nextCursor: sRes?.pagination?.cursor || null,
} as PagedResult<Video>))
);
}),
catchError(() => of({ items: [], nextCursor: null } as PagedResult<Video>))
);
}),