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