fix(shorts): flux dedie avec filtre duree serveur + loop + replis
This commit is contained in:
@@ -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