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
CI / build-and-test (push) Successful in 14m16s
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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>))
|
||||
);
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user