fix(shorts): flux dedie avec filtre duree serveur + loop + replis
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
Reference in New Issue
Block a user