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
+37 -8
View File
@@ -346,9 +346,14 @@ function parseDurationToSeconds(text) {
return 0; // Par défaut si aucun format n'est reconnu
}
async function scrapeRumbleList({ q, page = 1, limit = 24, sort = 'viral' }) {
async function scrapeRumbleList({ q, page = 1, limit = 24, sort = 'viral', mode = 'videos' }) {
try {
const url = q
// Mode "shorts" : le flux natif rumble.com/shorts (vertical ≤ 90 s).
// Les liens sont de la forme /shorts/vXXXX (plus éventuellement ?tracking).
const isShortsMode = mode === 'shorts';
const url = isShortsMode
? `https://rumble.com/shorts?page=${page}`
: q
? `https://rumble.com/search/video?q=${encodeURIComponent(q)}&page=${page}`
: `https://rumble.com/videos?sort=${encodeURIComponent(sort)}&page=${page}`;
@@ -357,10 +362,13 @@ async function scrapeRumbleList({ q, page = 1, limit = 24, sort = 'viral' }) {
const found = [];
// 1) Cartes "vidéos" standards (li/article/div)
$('a[href^="/v"], a[href^="/video/"]').each((_, el) => {
const href = $(el).attr('href') || '';
// On préfère STRICTEMENT l’ID /vXXXX
let m = /^\/(v[0-9A-Za-z]+)(?:[-/.]|$)/.exec(href);
const linkSelector = isShortsMode
? 'a[href*="/shorts/v"], a[href^="/v"], a[href^="/video/"]'
: 'a[href^="/v"], a[href^="/video/"]';
$(linkSelector).each((_, el) => {
const href = ($(el).attr('href') || '').split('?')[0];
// On préfère STRICTEMENT l’ID /vXXXX (aussi après /shorts/).
let m = /\/(v[0-9A-Za-z]+)(?:[-/.]|$)/.exec(href);
let id = m?.[1] || null;
// Fallback minimaliste pour /video/123 → on ne convertit pas ici; on laissera /video/:id passer au détails qui normalise par parse de la page.
@@ -460,9 +468,11 @@ async function scrapeRumbleList({ q, page = 1, limit = 24, sort = 'viral' }) {
uploaderName,
views,
duration: durationSeconds,
// Indicateur natif pour le front : une vidéo ≤ 75 s est un Short probable.
// Indicateur natif pour le front. En mode "shorts", tout ce qui sort
// du flux rumble.com/shorts (vertical ≤ 90 s) est un Short, même si
// le badge de durée est absent du HTML.
// (Rumble n'expose pas de flag natif ; la durée reste la source de vérité.)
isShort: durationSeconds > 0 && durationSeconds <= 75,
isShort: isShortsMode ? (durationSeconds <= 0 || durationSeconds <= 90) : (durationSeconds > 0 && durationSeconds <= 75),
uploadedDate: '',
url,
type: 'video'
@@ -517,6 +527,25 @@ router.get('/browse', async (req, res) => {
return res.json(data);
});
/* ---------------- Route "shorts" natifs (rumble.com/shorts) -------------- */
/**
* GET /api/rumble/shorts?page=&limit=
* Scrape le flux natif des Shorts Rumble (vertical ≤ 90 s) : IDs /vXXXX
* propres, compatibles avec l'embed officiel /embed/vXXXX/.
*/
router.get('/shorts', async (req, res) => {
const page = Math.max(1, parseInt(String(req.query.page || '1'), 10) || 1);
const limit = Math.min(50, Math.max(1, parseInt(String(req.query.limit || '24'), 10) || 24));
const key = cacheKey('/shorts', { page, limit });
const cached = getCache(key);
if (cached) return res.json(cached);
const data = await scrapeRumbleList({ page, limit, mode: 'shorts' });
setCache(key, data);
return res.json(data);
});
router.get('/search', async (req, res) => {
const q = String(req.query.q || '').trim();
if (!q) return res.status(400).json({ error: 'Query parameter required' });
@@ -1,6 +1,13 @@
<div class="flex w-full flex-col items-center gap-3 p-2 sm:p-4">
<!-- Sélecteur de fournisseur : la page n'est plus bloquée sur un seul provider -->
<div class="scroll-rail no-scrollbar flex max-w-full items-center justify-start gap-2 sm:justify-center" role="tablist" aria-label="Fournisseur des Shorts">
<button (click)="toggleForYou()"
[attr.aria-pressed]="forYou()"
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors"
[class]="forYou() ? 'bg-violet-600 border-violet-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'"
title="Shorts personnalisés multi-fournisseurs à partir de vos abonnements, groupes, recherches, likes et téléchargements">
✨ Pour toi
</button>
<button *ngFor="let p of providersList()"
(click)="selectProvider(p.id)"
[attr.aria-pressed]="provider() === p.id"
@@ -96,5 +103,14 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 inline" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 10l5 5 5-5H7z"/></svg>
</button>
</div>
@if (forYou() && (topics().length > 0 || topicsLoading())) {
<p class="max-w-lg text-center text-xs text-slate-400" role="status" aria-live="polite">
@if (topicsLoading()) {
<span>Analyse de vos goûts…</span>
} @else {
<span>Sujets : {{ topics().join(' · ') }}</span>
}
</p>
}
<p class="max-w-lg text-center text-xs text-slate-500">Si une vidéo affiche une erreur (153, Forbidden, introuvable…), utilisez ⏭ Passer : elle est retirée du flux.</p>
</div>
+274 -22
View File
@@ -2,12 +2,35 @@ import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, H
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { firstValueFrom } from 'rxjs';
import { firstValueFrom, forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { YoutubeApiService } from '../../services/youtube-api.service';
import { InstanceService, Provider } from '../../services/instance.service';
import { HistoryService } from '../../services/history.service';
import { LikesService } from '../../services/likes.service';
import { DownloadService } from '../../services/download.service';
import { SubscriptionsService, GROUP_ICON_KEYWORDS } from '../../services/subscriptions.service';
import { AuthService } from '../../services/auth.service';
import { Video } from '../../models/video.model';
import { isShortVideo, YOUTUBE_SHORT_MAX_SECONDS } from '../../app/shared/utils/video-kind';
/** Durée max d'un Short Rumble natif (rumble.com/shorts : vertical ≤ 90 s). */
const RUMBLE_SHORT_MAX_SECONDS = 90;
/** Mots vides FR/EN + termes vidéo génériques (extraction de sujets "Pour toi"). */
const TOPIC_STOPWORDS = new Set([
'les', 'des', 'une', 'dans', 'avec', 'pour', 'sur', 'par', 'pas', 'plus', 'tout', 'tous', 'toute', 'toutes',
'cette', 'ces', 'son', 'sa', 'ses', 'mon', 'ma', 'mes', 'ton', 'ta', 'tes', 'notre', 'votre', 'leurs',
'qui', 'que', 'quoi', 'dont', 'est', 'sont', 'été', 'être', 'avoir', 'faire', 'fait', 'faites', 'vous',
'nous', 'ils', 'elles', 'mais', 'comme', 'aussi', 'entre', 'sans', 'sous', 'chez', 'vers', 'très',
'the', 'and', 'for', 'with', 'from', 'that', 'this', 'these', 'those', 'have', 'has', 'had', 'will',
'would', 'can', 'all', 'your', 'you', 'our', 'their', 'they', 'them', 'she', 'her', 'his', 'him',
'are', 'was', 'were', 'been', 'being', 'what', 'when', 'where', 'which', 'how', 'why', 'not', 'but',
'video', 'vidéo', 'videos', 'vidéos', 'official', 'officiel', 'officielle', 'clip', 'clips', 'live',
'short', 'shorts', 'bande', 'annonce', 'trailer', 'episode', 'épisode', 'part', 'partie', 'full',
'entier', 'entière', 'complete', 'complet', 'complète', 'best', 'top', 'nouveau', 'nouvelle', 'bestof',
]);
/**
* Rotation de requêtes par provider pour un défilement infini façon YouTube
* Shorts : quand une page/un curseur est épuisé, on passe à la requête
@@ -34,6 +57,11 @@ export class WatchShortComponent {
private api = inject(YoutubeApiService);
private instances = inject(InstanceService);
private sanitizer = inject(DomSanitizer);
private history = inject(HistoryService);
private likes = inject(LikesService);
private downloads = inject(DownloadService);
private subs = inject(SubscriptionsService);
private auth = inject(AuthService);
loading = signal(true);
error = signal<string | null>(null);
@@ -41,6 +69,10 @@ export class WatchShortComponent {
index = signal(0);
nextCursor = signal<string | null>(null);
busyMore = signal(false);
/** Mode "Pour toi" : flux perso multi-fournisseurs (voir loadForYou). */
forYou = signal(false);
topics = signal<string[]>([]);
topicsLoading = signal(false);
/** IDs déjà vus (dedup inter-pages pour l'infini). */
private seenIds = new Set<string>();
/** Position dans la rotation SHORTS_QUERIES pour le provider courant. */
@@ -63,7 +95,20 @@ export class WatchShortComponent {
});
readonly canPrev = computed(() => this.index() > 0);
readonly canNext = computed(() => this.index() < this.items().length - 1 || !!this.nextCursor() || this.hasMoreQueries());
readonly canNext = computed(() =>
this.index() < this.items().length - 1 || (!this.forYou() && (!!this.nextCursor() || this.hasMoreQueries()))
);
/** Provider effectif d'une vidéo : en mode "Pour toi", chaque item garde son fournisseur. */
private providerOf(v: Video | null): Provider {
if (this.forYou() && v) {
const p = String((v as any).provider || '').toLowerCase();
if (p === 'youtube' || p === 'dailymotion' || p === 'twitch' || p === 'peertube' || p === 'rumble' || p === 'odysee') {
return p as Provider;
}
}
return this.provider();
}
private queriesFor(p: string): string[] {
return SHORTS_QUERIES[p] || SHORTS_QUERIES['youtube'];
@@ -82,7 +127,7 @@ export class WatchShortComponent {
readonly embedUrl = computed<SafeResourceUrl | null>(() => {
const v = this.current();
if (!v) return null;
const p = this.provider();
const p = this.providerOf(v);
try {
const host = (location && location.hostname) ? location.hostname : 'localhost';
if (p === 'youtube' && v.videoId) {
@@ -103,12 +148,10 @@ export class WatchShortComponent {
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'dailymotion' && v.videoId) {
// Même base que /watch : api=1 + origin. Sans origin, le player
// répond "Forbidden" sur plusieurs vidéos.
let origin = '';
try { origin = window.location.origin || ''; } catch {}
let u = `https://www.dailymotion.com/embed/video/${encodeURIComponent(v.videoId)}?api=1&autoplay=1&mute=1`;
if (origin) u += `&origin=${encodeURIComponent(origin)}`;
// Nouveau player obligatoire depuis fév. 2026 (l'ancien /embed est
// coupé et répond "Forbidden"). Seuls mute/startTime/loop passent
// en query sans Player ID ; autoplay est tenté au mieux.
const u = `https://geo.dailymotion.com/player.html?video=${encodeURIComponent(v.videoId)}&mute=1&autoplay=1`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'twitch' && v.videoId) {
@@ -208,8 +251,8 @@ export class WatchShortComponent {
readonly watchQueryParams = computed<Record<string, any> | null>(() => {
const v = this.current();
const p = this.provider();
if (!v) return null;
const p = this.providerOf(v);
const qp: any = { p };
if (p === 'twitch' && (v as any).kind === 'clip' && v.videoId) qp.clip = v.videoId;
else if (p === 'twitch' && v.type === 'channel' && v.videoId) qp.channel = v.videoId;
@@ -224,7 +267,7 @@ export class WatchShortComponent {
readonly providerUrl = computed<string | null>(() => {
const v = this.current();
if (!v) return null;
const p = this.provider();
const p = this.providerOf(v);
try {
if (p === 'youtube' && v.videoId) return `https://www.youtube.com/shorts/${encodeURIComponent(v.videoId)}`;
if (p === 'dailymotion' && v.videoId) return `https://www.dailymotion.com/video/${encodeURIComponent(v.videoId)}`;
@@ -300,18 +343,214 @@ export class WatchShortComponent {
const cursor = this.nextCursor();
const busy = this.busyMore();
const loading = this.loading();
if (!loading && !busy && len > 0 && i >= len - 3 && (cursor || this.hasMoreQueries())) {
const fy = this.forYou();
if (!fy && !loading && !busy && len > 0 && i >= len - 3 && (cursor || this.hasMoreQueries())) {
this.fetchNextPage(false);
}
});
}
/**
* Répartiteur du flux Shorts par provider :
* - PeerTube : vidéos locales uniquement (les distantes échouent à
* l'embed : "follow constraints" / "does not exist") ;
* - Rumble : flux natif /shorts (vertical ≤ 90 s) + recherche ;
* - autres : recherche filtrée durée côté serveur quand possible.
*/
private shortsPage(query: string, cursor?: string | null) {
const p = this.provider();
if (p === 'peertube') return this.api.searchPeerTubeShortsPage(query, cursor);
if (p === 'rumble') return this.api.searchRumbleShortsPage(query, cursor);
return this.api.searchShortsPage(query, cursor);
}
selectProvider(p: Provider): void {
if (p === this.provider()) return;
if (this.forYou()) this.forYou.set(false);
if (p === this.provider()) {
if (this.items().length === 0) this.loadFeed();
return;
}
this.instances.setSelectedProvider(p);
// L'effect ci-dessus déclenche loadFeed().
}
// ------------------------- Mode "Pour toi" -------------------------
// Flux personnalisé multi-fournisseurs : les sujets sont extraits des
// abonnements (+ groupes), de l'historique de recherche et de visionnage,
// des likes et des téléchargements, puis chaque sujet est cherché en
// Shorts sur tous les fournisseurs et les résultats sont entrelacés.
toggleForYou(): void {
if (this.forYou()) {
this.forYou.set(false);
this.loadFeed();
return;
}
this.forYou.set(true);
this.loadForYou();
}
private loadForYou(): void {
this.loading.set(true);
this.error.set(null);
this.items.set([]);
this.index.set(0);
this.nextCursor.set(null);
this.seenIds = new Set<string>();
this.topicsLoading.set(true);
// Hydrate les abonnements/groupes (signaux) s'ils ne sont pas chargés.
try { if (this.subs.items().length === 0) this.subs.list().subscribe(); } catch {}
try { this.subs.listGroups().subscribe(); } catch {}
const loggedIn = (() => { try { return !!this.auth.currentUser(); } catch { return false; } })();
const search$ = loggedIn ? this.history.getSearchHistory(30).pipe(catchError(() => of([]))) : of([]);
const watch$ = loggedIn ? this.history.getWatchHistory(30).pipe(catchError(() => of([]))) : of([]);
const likes$ = loggedIn ? this.likes.list(100).pipe(catchError(() => of([]))) : of([]);
const dl$ = loggedIn ? this.downloads.listJobs({ limit: 50 }).pipe(catchError(() => of({ items: [] }))) : of({ items: [] });
forkJoin([search$, watch$, likes$, dl$]).subscribe({
next: ([searches, watches, liked, jobs]: any[]) => {
const topics = this.extractTopics(searches || [], watches || [], liked || [], (jobs as any)?.items || []);
this.topics.set(topics);
this.topicsLoading.set(false);
if (!topics.length) {
// Aucun signal : repli sur des sujets génériques populaires.
this.topics.set(['music', 'gaming', 'funny', 'sports']);
}
this.fetchForYou(this.topics());
},
error: () => {
this.topicsLoading.set(false);
this.topics.set(['music', 'gaming', 'funny', 'sports']);
this.fetchForYou(this.topics());
}
});
}
/** Extrait des sujets pondérés (abos ×3, recherches ×2, reste ×1). */
private extractTopics(searches: any[], watches: any[], liked: any[], jobs: any[]): string[] {
const scores = new Map<string, number>();
const fullQueries: Array<{ q: string; w: number }> = [];
const addText = (text: string, weight: number) => {
const clean = String(text || '').toLowerCase().replace(/[#@]/g, ' ').replace(/[^a-zà-ÿ0-9\s'-]/gi, ' ');
for (const tok of clean.split(/[\s'’-]+/)) {
const t = tok.trim().replace(/^-+|-+$/g, '');
if (t.length < 3 || TOPIC_STOPWORDS.has(t) || /^\d+$/.test(t)) continue;
scores.set(t, (scores.get(t) || 0) + weight);
}
};
try {
for (const s of this.subs.items() || []) {
const name = String((s as any)?.channel?.title || (s as any)?.channel?.handle || '');
if (name) addText(name, 3);
}
for (const g of this.subs.subscriptionGroups() || []) {
if ((g as any)?.name) addText(String((g as any).name), 3);
const kw = GROUP_ICON_KEYWORDS[String((g as any)?.icon || '')];
if (kw) addText(kw, 2);
}
} catch {}
for (const h of searches || []) {
const q = String((h as any)?.query || '').trim().slice(0, 40);
if (q) {
fullQueries.push({ q, w: 4 });
addText(q, 2);
}
}
for (const w of watches || []) {
const t = String((w as any)?.title || '');
if (t) addText(t, 1.5);
}
for (const l of liked || []) {
const t = String((l as any)?.title || '');
if (t) addText(t, 1.5);
}
for (const j of jobs || []) {
const t = String((j as any)?.title || (j as any)?.fileName || '');
if (t) addText(t, 1);
}
const ranked = Array.from(scores.entries()).sort((a, b) => b[1] - a[1]).map(([t]) => t);
const out: string[] = [];
const seen = new Set<string>();
for (const fq of fullQueries.slice(0, 3)) {
const key = fq.q.toLowerCase();
if (!seen.has(key)) { seen.add(key); out.push(fq.q); }
}
for (const t of ranked) {
if (out.length >= 8) break;
if (!seen.has(t)) { seen.add(t); out.push(t); }
}
return out;
}
/** Cherche chaque sujet en Shorts sur tous les fournisseurs et entrelace. */
private fetchForYou(topics: string[]): void {
const picks = topics.slice(0, 4);
if (!picks.length) {
this.afterNoResults();
this.loading.set(false);
return;
}
const defs: Array<{ provider: Provider; call: any }> = [];
const ready = (p: Provider) => {
try { return this.instances.getProviderReadiness(p).ready; } catch { return true; }
};
for (const t of picks) {
if (ready('youtube')) defs.push({ provider: 'youtube', call: this.api.searchShortsPage(t, null, 'youtube' as Provider) });
if (ready('dailymotion')) defs.push({ provider: 'dailymotion', call: this.api.searchShortsPage(t, null, 'dailymotion' as Provider) });
if (ready('peertube')) defs.push({ provider: 'peertube', call: this.api.searchPeerTubeShortsPage(t, null) });
if (ready('rumble')) defs.push({ provider: 'rumble', call: this.api.searchRumbleShortsPage(t, null) });
if (ready('odysee')) defs.push({ provider: 'odysee', call: this.api.searchShortsPage(t, null, 'odysee' as Provider) });
}
if (ready('twitch')) {
for (const t of picks.slice(0, 2)) {
defs.push({ provider: 'twitch', call: this.api.searchTwitchClipsPage(t).pipe(catchError(() => of({ items: [], nextCursor: null }))) });
}
}
if (!defs.length) {
this.afterNoResults();
this.loading.set(false);
return;
}
forkJoin(defs.map(d => d.call.pipe(catchError(() => of({ items: [], nextCursor: null }))))).subscribe({
next: (pages: any[]) => {
// Regroupe par provider puis entrelace (tourniquet) pour mixer les sources.
const byProvider: Video[][] = defs.map((d, i) => {
const raw = ((pages[i]?.items || []) as Video[]).filter(v => !!v?.videoId);
const list = raw.filter(v => this.isShort({ ...v, provider: d.provider } as Video));
// Mélange léger par sujet pour varier l'ordre à chaque visite.
for (let k = list.length - 1; k > 0; k--) {
const j = Math.floor(Math.random() * (k + 1));
[list[k], list[j]] = [list[j], list[k]];
}
return list;
});
const merged: Video[] = [];
let added = true;
while (added && merged.length < 80) {
added = false;
for (const arr of byProvider) {
const v = arr.shift();
if (!v) continue;
const key = `${String((v as any).provider || '')}:${String(v.videoId)}`;
if (this.seenIds.has(key)) continue;
this.seenIds.add(key);
merged.push(v);
added = true;
if (merged.length >= 80) break;
}
}
this.items.set(merged);
this.index.set(0);
this.nextCursor.set(null);
if (!merged.length) this.afterNoResults(); else this.error.set(null);
this.loading.set(false);
},
error: () => {
this.afterNoResults();
this.loading.set(false);
}
});
}
retry(): void {
this.loadFeed();
}
@@ -340,6 +579,10 @@ export class WatchShortComponent {
}
loadFeed(): void {
if (this.forYou()) {
this.loadForYou();
return;
}
this.loading.set(true);
this.error.set(null);
this.items.set([]);
@@ -382,7 +625,7 @@ export class WatchShortComponent {
}
// Shorts via le flux dédié (filtre durée côté serveur quand possible).
// Rotation : requête courante → suivante → trending filtré (infini).
this.api.searchShortsPage(this.currentQuery()).subscribe({
this.shortsPage(this.currentQuery()).subscribe({
next: (res) => this.handleShortsResponse(res, true),
error: () => this.tryNextShortsQueryOrTrending(),
});
@@ -459,7 +702,7 @@ export class WatchShortComponent {
this.exhaustedQueries.add(this.currentQuery());
if (this.queryIndex < list.length - 1) {
this.queryIndex++;
this.api.searchShortsPage(this.currentQuery()).subscribe({
this.shortsPage(this.currentQuery()).subscribe({
next: (res) => this.handleShortsResponse(res, true),
error: () => this.tryNextShortsQueryOrTrending(),
});
@@ -500,6 +743,9 @@ export class WatchShortComponent {
// scraper renvoie 0) afin de ne jamais afficher une page vide.
private isShort(v: Video): boolean {
const p = (v as any).provider || this.provider();
// Dailymotion : embed désactivé par le propriétaire (allow_embed=false)
// => le player répond "Forbidden" : on exclut avant même l'embed.
if (String(p) === 'dailymotion' && (v as any)?.raw?.allowEmbed === false) return false;
if (String(p) === 'twitch') {
const kind = String((v as any).kind || '').toLowerCase();
if (kind === 'clip') return true;
@@ -507,13 +753,19 @@ export class WatchShortComponent {
const type = String((v as any).type || '').toLowerCase();
if (type === 'live' || type === 'channel' || type === 'stream') return false;
}
if ((v as any).isShort === true) {
const d = Number((v as any).duration || 0);
if (String(p) === 'rumble') {
// Shorts natifs Rumble : vertical ≤ 90 s. Durée inconnue : on garde
// (le flux /shorts natif ne contient que du vertical).
if ((v as any).isShort === true && (d <= 0 || d <= RUMBLE_SHORT_MAX_SECONDS)) return true;
if (!(d > 0)) return true;
return d <= RUMBLE_SHORT_MAX_SECONDS;
}
if ((v as any).isShort === true) {
if (d > 0 && d > 75) return false;
return true;
}
const d = Number((v as any).duration || 0);
if ((String(p) === 'rumble' || String(p) === 'odysee') && !(d > 0)) {
if (String(p) === 'odysee' && !(d > 0)) {
// Durée inconnue : on garde la vidéo (flux infini) plutôt qu'un vide.
return true;
}
@@ -533,7 +785,7 @@ export class WatchShortComponent {
}
private fetchNextPage(autoAdvance = false) {
if (this.busyMore()) return;
if (this.busyMore() || this.forYou()) return;
const provider = this.provider();
// Cas Twitch : pagination du flux de clips (curseur streams) ou rotation.
if (provider === 'twitch') {
@@ -561,7 +813,7 @@ export class WatchShortComponent {
if (this.queryIndex < list.length - 1) {
this.queryIndex++;
this.busyMore.set(true);
this.api.searchShortsPage(this.currentQuery()).subscribe({
this.shortsPage(this.currentQuery()).subscribe({
next: (res) => this.handleMore(res, autoAdvance),
error: () => { this.busyMore.set(false); }
});
@@ -570,7 +822,7 @@ export class WatchShortComponent {
this.queryIndex = 0;
this.exhaustedQueries = new Set<string>();
this.busyMore.set(true);
this.api.searchShortsPage(this.currentQuery()).subscribe({
this.shortsPage(this.currentQuery()).subscribe({
next: (res) => this.handleMore(res, autoAdvance),
error: () => { this.busyMore.set(false); }
});
@@ -578,7 +830,7 @@ export class WatchShortComponent {
return;
}
this.busyMore.set(true);
this.api.searchShortsPage(this.currentQuery(), cursor || undefined).subscribe({
this.shortsPage(this.currentQuery(), cursor || undefined).subscribe({
next: (res) => {
this.handleMore(res, autoAdvance);
},
+5 -5
View File
@@ -407,11 +407,11 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'dailymotion') {
// Use standard Dailymotion embed player and enable JS API
const origin = (typeof window !== 'undefined' && window.location && window.location.origin) ? window.location.origin : '';
let u = `https://www.dailymotion.com/embed/video/${encodeURIComponent(id)}?api=1&autoplay=1&mute=1`;
if (origin) u += `&origin=${encodeURIComponent(origin)}`;
if (start > 0) u += `&start=${start}`;
// Nouveau player obligatoire depuis fév. 2026 (l'ancien /embed est
// coupé et répond "Forbidden"). Seuls mute/startTime/loop passent
// en query sans Player ID.
let u = `https://geo.dailymotion.com/player.html?video=${encodeURIComponent(id)}&mute=1&autoplay=1`;
if (start > 0) u += `&startTime=${start}`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'twitch') {
+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 });