fix(shorts): embeds DM/PT/Rumble/Twitch + flux Pour toi multi-fournisseurs
CI / build-and-test (push) Successful in 14m16s
CI / build-and-test (push) Successful in 14m16s
This commit is contained in:
@@ -548,7 +548,7 @@ export class YoutubeApiService {
|
||||
limit: String(limit),
|
||||
page: String(page),
|
||||
country: String(region).toLowerCase(),
|
||||
fields: 'id,title,thumbnail_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,owner.id,owner.screenname,owner.avatar_80_url,created_time',
|
||||
fields: 'id,title,thumbnail_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,allow_embed,owner.id,owner.screenname,owner.avatar_80_url,created_time',
|
||||
});
|
||||
return this.fetchDailymotion('/videos', params).pipe(
|
||||
map(res => {
|
||||
@@ -1099,7 +1099,81 @@ export class YoutubeApiService {
|
||||
}
|
||||
}
|
||||
|
||||
// Dailymotion mapper
|
||||
/**
|
||||
* Shorts PeerTube : vidéos LOCALES uniquement (`isLocal=true`).
|
||||
* Les résultats fédérés (distants) échouent à l'embed avec "Cannot get
|
||||
* this video regarding follow constraints" / "This video does not exist"
|
||||
* quand l'instance active ne suit pas l'instance d'origine. En local,
|
||||
* l'embed `/videos/embed/<uuid>` fonctionne toujours.
|
||||
*/
|
||||
public searchPeerTubeShortsPage(query = 'shorts', cursor?: string | null): Observable<PagedResult<Video>> {
|
||||
const instance = this.instances.activePeerTubeInstance();
|
||||
const start = cursor ? Number(cursor) : 0;
|
||||
const q = String(query || '').trim();
|
||||
const params = new URLSearchParams({
|
||||
count: '24',
|
||||
start: String(start),
|
||||
sort: '-views',
|
||||
isLocal: 'true',
|
||||
nsfw: 'false',
|
||||
});
|
||||
if (q) params.set('search', q);
|
||||
const primary = `/api/peertube/${instance}/api/v1/videos?${params.toString()}`;
|
||||
const fallback = `/proxy/api/peertube/${instance}/api/v1/videos?${params.toString()}`;
|
||||
return this.http.get<any>(primary).pipe(
|
||||
catchError(() => this.http.get<any>(fallback)),
|
||||
map(res => {
|
||||
const list = (res?.data || res?.result || []) as any[];
|
||||
return {
|
||||
items: (list || []).map((i: any) => this.mapPeerTubeItemToVideo(i, instance)),
|
||||
nextCursor: list.length > 0 ? String(start + 24) : null,
|
||||
} as PagedResult<Video>;
|
||||
}),
|
||||
catchError((e) => { console.error(`PeerTube (${instance}) shorts error`, e); return of({ items: [], nextCursor: null }); })
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorts Rumble natifs (rumble.com/shorts, vertical ≤ 90 s) + recherche
|
||||
* plein-texte, fusionnés. Le flux natif garantit des IDs /vXXXX propres,
|
||||
* compatibles avec l'embed officiel (fini "Video not found").
|
||||
* Curseur opaque : numéro de page.
|
||||
*/
|
||||
public searchRumbleShortsPage(query = 'shorts', cursor?: string | null): Observable<PagedResult<Video>> {
|
||||
const page = cursor ? Math.max(1, Number(cursor) || 1) : 1;
|
||||
const q = String(query || '').trim();
|
||||
const shortsParams = new URLSearchParams({ limit: '24', page: String(page) });
|
||||
const shorts$ = this.fetchRumble('/shorts', shortsParams).pipe(
|
||||
catchError(() => of({ items: [] }))
|
||||
);
|
||||
const search$ = q
|
||||
? this.fetchRumble('/search', new URLSearchParams({ q, limit: '24', offset: String((page - 1) * 24) })).pipe(
|
||||
catchError(() => of({ items: [] }))
|
||||
)
|
||||
: of({ items: [] });
|
||||
return forkJoin([shorts$, search$]).pipe(
|
||||
map(([a, b]: any[]) => {
|
||||
const seen = new Set<string>();
|
||||
const merged: any[] = [];
|
||||
for (const it of [...(a?.items || []), ...(b?.items || [])]) {
|
||||
const id = String(it?.videoId || it?.id || '');
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
merged.push(it);
|
||||
}
|
||||
return {
|
||||
items: merged.map(i => this.mapRumbleItemToVideo(i)),
|
||||
nextCursor: merged.length > 0 ? String(page + 1) : null,
|
||||
} as PagedResult<Video>;
|
||||
}),
|
||||
catchError((e) => { console.error('Rumble shorts error', e); return of({ items: [], nextCursor: null }); })
|
||||
);
|
||||
}
|
||||
|
||||
// Dailymotion mapper.
|
||||
// allow_embed=false (embed désactivé par le propriétaire) => le player
|
||||
// répond "Forbidden" : propagé via `raw.allowEmbed` pour que les Shorts
|
||||
// (et toute page d'embed) puissent exclure ces vidéos.
|
||||
private mapDailymotionItemToVideo = (i: any): Video => ({
|
||||
url: `/watch/${i.id}`,
|
||||
videoId: i.id || '',
|
||||
@@ -1117,6 +1191,7 @@ export class YoutubeApiService {
|
||||
duration: Number(i.duration || 0),
|
||||
views: Number(i.views_total || 0),
|
||||
uploaded: i.created_time ? i.created_time * 1000 : 0,
|
||||
raw: { allowEmbed: i?.allow_embed !== false },
|
||||
});
|
||||
|
||||
// PeerTube mapper (robust handling for thumbnail/avatar fields across instances)
|
||||
@@ -1514,12 +1589,102 @@ export class YoutubeApiService {
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Twitch Shorts : clips via les jeux les plus suivis -----------------
|
||||
// L'ancienne chaîne (top streams -> broadcaster_id -> clips 7 j) rendait
|
||||
// souvent un flux vide ("Aucun Short trouvé"). L'endpoint /helix/clips
|
||||
// accepte directement game_id (trié par vues, sans broadcaster) : on
|
||||
// tourne sur les top games, ce qui donne toujours des clips récents.
|
||||
// Curseur opaque : "<gameId>::<paginationCursor>" (pagination vide = page 1).
|
||||
private twitchTopGameIds: string[] | null = null;
|
||||
private twitchGameIdx = 0;
|
||||
private twitchClipsSinceDays = 30;
|
||||
|
||||
private getTwitchTopGameIds(): Observable<string[]> {
|
||||
if (this.twitchTopGameIds?.length) return of(this.twitchTopGameIds);
|
||||
return this.getTwitchAuthToken().pipe(
|
||||
switchMap(token => {
|
||||
if (!token) return of([] as string[]);
|
||||
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
|
||||
const primary = `/api/twitch-api/helix/games/top?first=10`;
|
||||
const fallback = `/proxy/twitch-api/helix/games/top?first=10`;
|
||||
return this.http.get<any>(primary, { headers }).pipe(
|
||||
catchError(() => this.http.get<any>(fallback, { headers })),
|
||||
map(res => {
|
||||
const ids: string[] = (Array.isArray(res?.data) ? res.data : []).map((g: any) => String(g?.id || '')).filter(Boolean);
|
||||
if (ids.length) this.twitchTopGameIds = ids;
|
||||
return ids;
|
||||
}),
|
||||
catchError(() => of([] as string[]))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private fetchTwitchGameClipsPage(gameId: string, pageCursor?: string | null): Observable<PagedResult<Video>> {
|
||||
return this.getTwitchAuthToken().pipe(
|
||||
switchMap(token => {
|
||||
if (!token || !gameId) return of({ items: [], nextCursor: null });
|
||||
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
|
||||
const since = new Date(Date.now() - this.twitchClipsSinceDays * 24 * 60 * 60 * 1000).toISOString();
|
||||
const after = pageCursor ? `&after=${encodeURIComponent(pageCursor)}` : '';
|
||||
const primary = `/api/twitch-api/helix/clips?game_id=${encodeURIComponent(gameId)}&first=20&started_at=${encodeURIComponent(since)}${after}`;
|
||||
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>;
|
||||
}),
|
||||
catchError(() => of({
|
||||
items: [],
|
||||
nextCursor: this.nextTwitchGameCursor(gameId),
|
||||
} as PagedResult<Video>))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Curseur vers le jeu suivant (boucle infinie sur les top games). */
|
||||
private nextTwitchGameCursor(currentGameId: string): string | null {
|
||||
const games = this.twitchTopGameIds || [];
|
||||
if (!games.length) return null;
|
||||
const idx = games.indexOf(currentGameId);
|
||||
const next = games[(idx + 1 + games.length) % games.length];
|
||||
return next ? `${next}::` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clips Twitch sans requête (page Shorts) : la recherche de chaînes Helix
|
||||
* rejette les requêtes vides, on part donc des top streams pour obtenir des
|
||||
* broadcaster_id valides puis on récupère leurs clips récents.
|
||||
* Clips Twitch sans requête (page Shorts) : rotation sur les top games
|
||||
* (clips triés par vues, 30 j). Repli historique broadcaster si les jeux
|
||||
* sont indisponibles.
|
||||
*/
|
||||
public getTwitchClipsFeed(cursor?: string | null): Observable<PagedResult<Video>> {
|
||||
const parsed = String(cursor || '');
|
||||
const sep = parsed.indexOf('::');
|
||||
if (sep > 0) {
|
||||
const gameId = parsed.slice(0, sep);
|
||||
const pageCursor = parsed.slice(sep + 2) || null;
|
||||
return this.fetchTwitchGameClipsPage(gameId, pageCursor);
|
||||
}
|
||||
return this.getTwitchTopGameIds().pipe(
|
||||
switchMap(games => {
|
||||
if (!games.length) return this.getTwitchBroadcasterClipsFeed(null);
|
||||
const gameId = games[this.twitchGameIdx++ % games.length];
|
||||
return this.fetchTwitchGameClipsPage(gameId, null);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ancien flux broadcaster (top streams -> clips) : conservé en repli quand
|
||||
* /helix/games/top ne répond pas.
|
||||
*/
|
||||
private getTwitchBroadcasterClipsFeed(cursor?: string | null): Observable<PagedResult<Video>> {
|
||||
return this.getTwitchAuthToken().pipe(
|
||||
switchMap(token => {
|
||||
if (!token) return of({ items: [], nextCursor: null });
|
||||
|
||||
Reference in New Issue
Block a user