fix(shorts): flux dedie avec filtre duree serveur + loop + replis

This commit is contained in:
2026-09-27 16:53:01 -04:00
parent ebfb39fec2
commit d0d1c07535
3 changed files with 214 additions and 44 deletions
@@ -18,11 +18,14 @@
<div class="rounded-xl overflow-hidden shadow-2xl ring-1 ring-slate-700/50 bg-black/90"
style="height: calc(100vh - 10rem); max-height: calc(100vh - 10rem); aspect-ratio: 9 / 16; width: auto; max-width: 92vw;">
@if (embedUrl(); as url) {
<iframe [src]="url" class="w-full h-full" frameborder="0"
<iframe [src]="url" [title]="current()?.title || 'Short video'" class="w-full h-full" frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen></iframe>
} @else if (loading()) {
<div class="w-full h-full animate-pulse bg-slate-800"></div>
<div class="w-full h-full flex flex-col gap-3 items-center justify-center bg-slate-800 text-slate-400 text-sm">
<div class="h-8 w-8 rounded-full border-2 border-slate-600 border-t-red-500 animate-spin" aria-hidden="true"></div>
<span role="status">Chargement des Shorts…</span>
</div>
} @else if (error()) {
<div class="w-full h-full flex flex-col gap-3 items-center justify-center text-slate-300 p-6 text-center">
<span>{{ error() }}</span>
@@ -47,6 +50,9 @@
</div>
<!-- Navigation buttons -->
@if (busyMore()) {
<div class="absolute top-2 left-1/2 -translate-x-1/2 px-2 py-1 rounded-full bg-black/60 text-white text-xs" role="status">Chargement…</div>
}
<button (click)="prev()" [disabled]="!canPrev()"
class="absolute -right-14 top-6 hidden md:flex items-center justify-center h-10 w-10 rounded-full bg-slate-800/70 hover:bg-slate-700 text-white ring-1 ring-slate-700/60 disabled:opacity-40"
aria-label="Previous short">
+75 -42
View File
@@ -6,9 +6,11 @@ import { firstValueFrom } from 'rxjs';
import { YoutubeApiService } from '../../services/youtube-api.service';
import { InstanceService, Provider } from '../../services/instance.service';
import { Video } from '../../models/video.model';
import { isShortVideo } from '../../app/shared/utils/video-kind';
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';
@Component({
selector: 'app-watch-short',
@@ -34,6 +36,8 @@ export class WatchShortComponent {
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());
@@ -62,7 +66,7 @@ export class WatchShortComponent {
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`;
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)}`;
if (origin) u += `&origin=${encodeURIComponent(origin)}`;
if (ref) u += `&widget_referrer=${encodeURIComponent(ref)}`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
@@ -140,16 +144,33 @@ export class WatchShortComponent {
const ids = Array.from(new Set(list.map(v => v.videoId))).slice(0, 50);
try {
const durations = await firstValueFrom(this.api.getYouTubeDurations(ids));
const maxShort = 70; // seconds (include some margin)
return list.filter(v => {
const d = durations?.[v.videoId] ?? 0;
return d > 0 && d <= maxShort;
return d > 0 && d <= YOUTUBE_SHORT_MAX_SECONDS;
});
} catch {
return [];
}
}
/**
* Filtre Shorts YouTube en préférant les durées déjà enrichies par
* searchShortsPage (0 appel API supplémentaire). Ne refait un appel
* videos.list que si aucune durée n'est connue (économie de quota).
* Les durées -1 (= non-embeddable, erreur 153) sont toujours exclues.
*/
private async pickYouTubeShorts(list: Video[]): Promise<Video[]> {
const shorts = list.filter(v =>
typeof v.duration === 'number' && v.duration > 0 && v.duration <= YOUTUBE_SHORT_MAX_SECONDS
);
const allKnown = list.every(v => typeof v.duration === 'number' && v.duration !== 0);
if (shorts.length > 0 || allKnown) return shorts;
// Certaines durées manquent : on les récupère au lieu de jeter les vidéos.
const missing = list.filter(v => typeof v.duration !== 'number' || v.duration === 0);
const fetched = await this.filterYouTubeShorts(missing);
return [...shorts, ...fetched.filter(v => this.isShort(v))];
}
constructor() {
// Recharger le flux quand le provider global change (ex. sélecteur du header).
effect(() => {
@@ -177,6 +198,7 @@ export class WatchShortComponent {
this.items.set([]);
this.index.set(0);
this.nextCursor.set(null);
this.altQueryTried = false;
const ready = this.instances.getProviderReadiness(this.provider());
if (!ready.ready) {
this.loading.set(false);
@@ -204,42 +226,53 @@ export class WatchShortComponent {
});
return;
}
// Default: search for shorts keywords via generic search, then filter.
// Si la recherche ne donne aucun Short, repli sur le trending filtré
// (utile pour Rumble/Odysee/PeerTube/Dailymotion dont la recherche
// plein-texte "shorts" est approximative).
this.api.searchVideosPage(SHORTS_QUERY).subscribe({
next: (res) => {
const raw = (res.items || []).filter(v => !!v.videoId);
this.nextCursor.set(res.nextCursor || null);
const p = this.provider();
if (p === 'youtube' && raw.length) {
this.filterYouTubeShorts(raw).then(list => {
const shorts = list.filter(v => this.isShort(v));
if (shorts.length > 0) {
this.items.set(shorts);
this.index.set(0);
this.error.set(null);
this.loading.set(false);
} else {
this.loadTrendingFallback();
}
}).catch(() => this.loadTrendingFallback());
return;
}
const list = raw.filter(v => this.isShort(v));
if (list.length > 0) {
this.items.set(list);
this.index.set(0);
this.error.set(null);
this.loading.set(false);
} else {
this.loadTrendingFallback();
}
},
error: () => {
this.loadTrendingFallback();
}
// 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({
next: (res) => this.handleShortsResponse(res, true),
error: () => this.loadShortsAltQueryOrTrending(),
});
}
/** 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);
this.nextCursor.set(res.nextCursor || null);
const show = (list: Video[]) => {
this.items.set(list);
this.index.set(0);
this.error.set(null);
this.loading.set(false);
};
if (this.provider() === 'youtube' && raw.length) {
this.pickYouTubeShorts(raw).then(list => {
const shorts = list.filter(v => this.isShort(v));
if (shorts.length > 0) show(shorts);
else if (allowAltQuery) this.loadShortsAltQueryOrTrending();
else this.loadTrendingFallback();
}).catch(() => {
if (allowAltQuery) this.loadShortsAltQueryOrTrending();
else this.loadTrendingFallback();
});
return;
}
const list = raw.filter(v => this.isShort(v));
if (list.length > 0) show(list);
else if (allowAltQuery) this.loadShortsAltQueryOrTrending();
else this.loadTrendingFallback();
}
/** 2e chance avec une requête alternative avant le repli trending. */
private loadShortsAltQueryOrTrending(): void {
if (this.altQueryTried) {
this.loadTrendingFallback();
return;
}
this.altQueryTried = true;
this.api.searchShortsPage(SHORTS_ALT_QUERY).subscribe({
next: (res) => this.handleShortsResponse(res, false),
error: () => this.loadTrendingFallback(),
});
}
@@ -294,7 +327,7 @@ export class WatchShortComponent {
this.busyMore.set(false);
};
if (p === 'youtube' && raw.length) {
this.filterYouTubeShorts(raw).then((list: Video[]) => apply(list.filter((v: Video) => this.isShort(v)))).catch(() => { apply([]); });
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)));
}
@@ -308,7 +341,7 @@ export class WatchShortComponent {
return;
}
this.api.searchVideosPage(SHORTS_QUERY, cursor || undefined).subscribe({
this.api.searchShortsPage(SHORTS_QUERY, cursor || undefined).subscribe({
next: (res) => {
handle(res);
},
+131
View File
@@ -968,6 +968,137 @@ export class YoutubeApiService {
);
}
/**
* Flux Shorts par provider (page #/shorts).
* Contrairement à une recherche plein-texte "shorts" (qui retourne surtout
* des vidéos longues ensuite éliminées par le filtre client), on filtre la
* durée côté serveur quand l'API le permet :
* - YouTube : search.list `videoDuration=short` (< 4 min), resserré à ≤70 s côté client.
* - Dailymotion : `shorter_than=2` (en minutes), resserré à ≤75 s côté client.
* - PeerTube : `durationMin=1&durationMax=75` (en secondes).
* - Autres (odysee/rumble/...) : recherche générique + filtre client uniquement.
* Les durées YouTube sont enrichies ici (comme searchVideosPage) pour éviter
* un second appel videos.list côté composant (économie de quota).
*/
public searchShortsPage(query = 'shorts', cursor?: string | null, providerOverride?: Provider | null): Observable<PagedResult<Video>> {
const provider = providerOverride || this.instances.selectedProvider();
const region = this.instances.region();
const q = String(query || '').trim() || 'shorts';
switch (provider) {
case 'youtube': {
const key = this.getActiveYouTubeKey();
if (!key) return of({ items: [], nextCursor: null });
const params = new URLSearchParams({
type: 'video',
part: 'snippet',
maxResults: '25',
q,
regionCode: String(region),
safeSearch: 'moderate',
videoEmbeddable: 'true',
// Le filtre clé : sans lui, "shorts" ne retourne que des vidéos longues.
videoDuration: 'short',
order: 'viewCount',
key: String(key),
});
try {
const lang = String(this.i18n.lang()).toLowerCase();
if (lang) params.set('relevanceLanguage', lang);
} catch {}
if (cursor) params.set('pageToken', cursor);
const cacheKey = `yt:shorts|${region}|${q}|${cursor || ''}`;
const cached = this.searchCache.get(cacheKey);
const now = Date.now();
if (cached && (now - cached.ts) < this.CACHE_TTL_MS) {
return of(cached.data);
}
const path = `/youtube/v3/search`;
return this.fetchYouTube(path, params, key).pipe(
map(res => ({
items: (res.items || []).map((i: any) => this.mapYouTubeSearchItemToVideo(i)),
nextCursor: res.nextPageToken || null,
} as PagedResult<Video>)),
switchMap((out) => {
const ids = out.items.map(v => v.videoId).filter(Boolean);
if (!ids.length) return of(out);
return this.getYouTubeContentAndStats(ids).pipe(
map((stats) => {
const enriched = out.items.map(v => {
const s = stats[v.videoId];
if (!s) return v;
// duration -1 = non-embeddable (erreur 153) : on garde la
// vidéo en recherche (avec durée 0) mais les Shorts la filtrent.
const duration = typeof s.duration === 'number' && s.duration >= 0 ? s.duration : 0;
return { ...v, duration, views: s.views };
});
const next = { ...out, items: enriched } as PagedResult<Video>;
this.searchCache.set(cacheKey, { ts: now, data: next });
return next;
})
);
}),
catchError((e) => {
try {
const reason = e?.error?.error?.errors?.[0]?.reason || '';
const msg = String(e?.error?.error?.message || e?.message || '');
if (e?.status === 403 && (reason === 'quotaExceeded' || /quota/i.test(msg))) {
this.quotaExceeded$.next(true);
this.banYouTubeKey(key);
} else if (e?.status === 400 && (reason === 'API_KEY_INVALID' || /api key (expired|invalid)/i.test(msg))) {
this.banYouTubeKey(key);
}
} catch {}
console.error('YouTube shorts search error', e);
return of({ items: [], nextCursor: null });
})
);
}
case 'dailymotion': {
const page = cursor ? String(cursor) : '1';
const limit = 24;
const params = new URLSearchParams({
limit: String(limit),
page: String(page),
// Dailymotion compte en minutes : 2 = ≤ 2 min, resserré à ≤75 s côté client.
shorter_than: '2',
country: String(region).toLowerCase(),
fields: 'id,title,thumbnail_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,owner.screenname,owner.avatar_80_url,created_time',
search: q,
});
return this.fetchDailymotion('/videos', params).pipe(
map(res => {
const list = (res.list || res.result || res.items || res.data || []) as any[];
const currentPage = Number(page) || 1;
const hasMore = typeof res.has_more === 'boolean' ? res.has_more : (list.length === limit);
return {
items: list.map((i: any) => this.mapDailymotionItemToVideo(i)),
nextCursor: hasMore ? String(currentPage + 1) : null,
} as PagedResult<Video>;
}),
catchError((e) => { console.error('Dailymotion shorts search error', e); return of({ items: [], nextCursor: null }); })
);
}
case 'peertube': {
const instance = this.instances.activePeerTubeInstance();
const start = cursor ? Number(cursor) : 0;
const dmParams = `search=${encodeURIComponent(q)}&count=24&start=${start}&durationMin=1&durationMax=75&sort=-views`;
const primary = `/api/peertube/${instance}/api/v1/search/videos?${dmParams}`;
const fallback = `/proxy/api/peertube/${instance}/api/v1/search/videos?${dmParams}`;
return this.http.get<any>(primary).pipe(
catchError(() => this.http.get<any>(fallback)),
map(res => ({
items: (res.data || []).map((i: any) => this.mapPeerTubeItemToVideo(i, instance)),
nextCursor: res.data?.length > 0 ? String(start + 24) : null,
} as PagedResult<Video>)),
catchError((e) => { console.error(`PeerTube (${instance}) shorts search error`, e); return of({ items: [], nextCursor: null }); })
);
}
default:
// Odysee/Rumble/... : pas de filtre durée côté serveur → recherche générique + filtre client.
return this.searchVideosPage(q, cursor, providerOverride);
}
}
// Dailymotion mapper
private mapDailymotionItemToVideo = (i: any): Video => ({
url: `/watch/${i.id}`,