diff --git a/server/rumble.mjs b/server/rumble.mjs index c27be85..7209767 100644 --- a/server/rumble.mjs +++ b/server/rumble.mjs @@ -346,21 +346,29 @@ 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 - ? `https://rumble.com/search/video?q=${encodeURIComponent(q)}&page=${page}` - : `https://rumble.com/videos?sort=${encodeURIComponent(sort)}&page=${page}`; + // 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}`; const html = await httpGet(url); const $ = cheerio.load(html); 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' }); diff --git a/src/components/shorts/watch-short.component.html b/src/components/shorts/watch-short.component.html index fcc5805..7740797 100644 --- a/src/components/shorts/watch-short.component.html +++ b/src/components/shorts/watch-short.component.html @@ -1,6 +1,13 @@
+
+ @if (forYou() && (topics().length > 0 || topicsLoading())) { +

+ @if (topicsLoading()) { + Analyse de vos goûts… + } @else { + Sujets : {{ topics().join(' · ') }} + } +

+ }

Si une vidéo affiche une erreur (153, Forbidden, introuvable…), utilisez ⏭ Passer : elle est retirée du flux.

diff --git a/src/components/shorts/watch-short.component.ts b/src/components/shorts/watch-short.component.ts index 4998bc2..680ada2 100644 --- a/src/components/shorts/watch-short.component.ts +++ b/src/components/shorts/watch-short.component.ts @@ -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(null); @@ -41,6 +69,10 @@ export class WatchShortComponent { index = signal(0); nextCursor = signal(null); busyMore = signal(false); + /** Mode "Pour toi" : flux perso multi-fournisseurs (voir loadForYou). */ + forYou = signal(false); + topics = signal([]); + topicsLoading = signal(false); /** IDs déjà vus (dedup inter-pages pour l'infini). */ private seenIds = new Set(); /** 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(() => { 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 | 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(() => { 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(); + 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(); + 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(); + 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; } + 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) { - const d = Number((v as any).duration || 0); 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(); 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); }, diff --git a/src/components/watch/watch.component.ts b/src/components/watch/watch.component.ts index 31899b3..ae59eae 100644 --- a/src/components/watch/watch.component.ts +++ b/src/components/watch/watch.component.ts @@ -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') { diff --git a/src/services/youtube-api.service.ts b/src/services/youtube-api.service.ts index 0874904..c38bbe2 100644 --- a/src/services/youtube-api.service.ts +++ b/src/services/youtube-api.service.ts @@ -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/` fonctionne toujours. + */ + public searchPeerTubeShortsPage(query = 'shorts', cursor?: string | null): Observable> { + 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(primary).pipe( + catchError(() => this.http.get(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