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
+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}`,