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
@@ -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;
}
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<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') {