fix(shorts): embeds DM/PT/Rumble/Twitch + flux Pour toi multi-fournisseurs
CI / build-and-test (push) Successful in 14m16s

This commit is contained in:
2026-09-29 09:38:13 -04:00
parent 34f432a7c1
commit 5994841bda
5 changed files with 504 additions and 42 deletions
+170 -5
View File
@@ -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 });