fix(shorts): autoplay muet, langues FR/EN, proxy prod et Dailymotion embeds
CI / build-and-test (push) Successful in 14m9s
CI / build-and-test (push) Successful in 14m9s
- autoplay: les Shorts demarrent muets (comme Watch) pour garantir l'autoplay des iframes; le son se debloque a la premiere interaction (unlockSound) et reste controlable
- proxy prod: ajoute les alias /proxy/twitch-api, /proxy/twitch-auth, /proxy/dm, /proxy/odysee (evite le fallback SPA -> parse JSON 'status 200 ok:false'); forwardJson accepte les deux prefixes. Corrige aussi la validation des clips Twitch morts
- Dailymotion: allow_embed demande dans les flux shorts/trending -> les videos non-embeddables ('Forbidden') sont exclues
- langues: mapping language (Dailymotion/PeerTube/Odysee) + shortsLangOk applique a tous les fournisseurs; en+fr toujours acceptes
- PeerTube shorts: durationMin/Max cote serveur (plus de videos longues au hasard)
- Twitch: validation helix/clips de nouveau fonctionnelle + autoplay muet
This commit is contained in:
+11
-1
@@ -3071,7 +3071,11 @@ app.post('/api/ai/summarize', aiLimiter, async (req, res) => {
|
|||||||
// Generic JSON forwarder helper
|
// Generic JSON forwarder helper
|
||||||
async function forwardJson(req, res, base) {
|
async function forwardJson(req, res, base) {
|
||||||
try {
|
try {
|
||||||
const pathPart = req.originalUrl.replace(/^\/api\/(dm|odysee|twitch-api|twitch-auth)/, '');
|
// Accepte les deux préfixes : `/api/<key>` (prod canonique) ET
|
||||||
|
// `/proxy/<key>` (repli du front, défini dans proxy.conf.json pour le dev).
|
||||||
|
// Sans le second, les replis `/proxy/...` tombaient sur le fallback SPA
|
||||||
|
// (index.html) → erreur de parse JSON "status 200 / ok:false".
|
||||||
|
const pathPart = req.originalUrl.replace(/^\/(?:proxy\/)?(?:api\/)?(dm|odysee|twitch-api|twitch-auth)/, '');
|
||||||
const targetUrl = `${base}${pathPart}`;
|
const targetUrl = `${base}${pathPart}`;
|
||||||
const method = (req.method || 'GET').toUpperCase();
|
const method = (req.method || 'GET').toUpperCase();
|
||||||
// Ne transfère que les headers utiles : tout le reste (cookies, content-length
|
// Ne transfère que les headers utiles : tout le reste (cookies, content-length
|
||||||
@@ -3105,6 +3109,12 @@ app.all('/api/dm/*', (req, res) => forwardJson(req, res, 'https://api.dailymotio
|
|||||||
app.all('/api/odysee/*', (req, res) => forwardJson(req, res, 'https://api.na-backend.odysee.com'));
|
app.all('/api/odysee/*', (req, res) => forwardJson(req, res, 'https://api.na-backend.odysee.com'));
|
||||||
app.all('/api/twitch-api/*', (req, res) => forwardJson(req, res, 'https://api.twitch.tv'));
|
app.all('/api/twitch-api/*', (req, res) => forwardJson(req, res, 'https://api.twitch.tv'));
|
||||||
app.all('/api/twitch-auth/*', (req, res) => forwardJson(req, res, 'https://id.twitch.tv'));
|
app.all('/api/twitch-auth/*', (req, res) => forwardJson(req, res, 'https://id.twitch.tv'));
|
||||||
|
// Alias `/proxy/...` : le front utilise ces chemins en repli (cf. proxy.conf.json
|
||||||
|
// pour le dev). En prod ils doivent répondre du JSON, pas le shell SPA.
|
||||||
|
app.all('/proxy/dm/*', (req, res) => forwardJson(req, res, 'https://api.dailymotion.com'));
|
||||||
|
app.all('/proxy/odysee/*', (req, res) => forwardJson(req, res, 'https://api.na-backend.odysee.com'));
|
||||||
|
app.all('/proxy/twitch-api/*', (req, res) => forwardJson(req, res, 'https://api.twitch.tv'));
|
||||||
|
app.all('/proxy/twitch-auth/*', (req, res) => forwardJson(req, res, 'https://id.twitch.tv'));
|
||||||
|
|
||||||
// -------------------- Unified search endpoint (GET) --------------------
|
// -------------------- Unified search endpoint (GET) --------------------
|
||||||
app.get('/api/search', async (req, res) => {
|
app.get('/api/search', async (req, res) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<div class="shorts-stage flex w-full flex-col items-center gap-3 rounded-2xl bg-black/60 p-2 sm:p-4" (click)="closeMenu()">
|
<div class="shorts-stage flex w-full flex-col items-center gap-3 rounded-2xl bg-black/60 p-2 sm:p-4" (click)="unlockSound(); closeMenu()">
|
||||||
<!-- Sélecteur : Pour toi + fournisseurs -->
|
<!-- Sélecteur : Pour toi + fournisseurs -->
|
||||||
<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" (click)="$event.stopPropagation()">
|
<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" (click)="$event.stopPropagation()">
|
||||||
<button (click)="toggleForYou()"
|
<button (click)="unlockSound(); toggleForYou()"
|
||||||
[attr.aria-pressed]="forYou()"
|
[attr.aria-pressed]="forYou()"
|
||||||
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors"
|
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'"
|
[class]="forYou() ? 'bg-violet-600 border-violet-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'"
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
✨ Pour toi
|
✨ Pour toi
|
||||||
</button>
|
</button>
|
||||||
<button *ngFor="let p of providersList()"
|
<button *ngFor="let p of providersList()"
|
||||||
(click)="selectProvider(p.id)"
|
(click)="unlockSound(); selectProvider(p.id)"
|
||||||
[attr.aria-pressed]="!forYou() && provider() === p.id"
|
[attr.aria-pressed]="!forYou() && provider() === p.id"
|
||||||
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors"
|
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors"
|
||||||
[class]="!forYou() && provider() === p.id ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
[class]="!forYou() && provider() === p.id ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
<!-- Scène immersive : flèches + cadre 9:16 + rail d'actions -->
|
<!-- Scène immersive : flèches + cadre 9:16 + rail d'actions -->
|
||||||
<div class="flex w-full min-h-0 items-center justify-center gap-1 sm:gap-3"
|
<div class="flex w-full min-h-0 items-center justify-center gap-1 sm:gap-3"
|
||||||
(wheel)="onWheel($event)" (touchstart)="onTouchStart($event)" (touchend)="onTouchEnd($event)">
|
(wheel)="unlockSound(); onWheel($event)" (touchstart)="unlockSound(); onTouchStart($event)" (touchend)="onTouchEnd($event)">
|
||||||
<!-- Flèche précédent (bureau, façon YouTube) -->
|
<!-- Flèche précédent (bureau, façon YouTube) -->
|
||||||
<button type="button" (click)="prev(); $event.stopPropagation()" [disabled]="!canPrev()"
|
<button type="button" (click)="prev(); $event.stopPropagation()" [disabled]="!canPrev()"
|
||||||
class="chip hidden sm:flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-slate-800/80 text-white hover:bg-slate-700 disabled:opacity-30"
|
class="chip hidden sm:flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-slate-800/80 text-white hover:bg-slate-700 disabled:opacity-30"
|
||||||
|
|||||||
@@ -82,12 +82,16 @@ export class WatchShortComponent implements OnDestroy {
|
|||||||
/** Cadre 9:16 (plein écran + focus). */
|
/** Cadre 9:16 (plein écran + focus). */
|
||||||
frameRef = viewChild<ElementRef<HTMLElement>>('shortsFrame');
|
frameRef = viewChild<ElementRef<HTMLElement>>('shortsFrame');
|
||||||
/**
|
/**
|
||||||
* Son coupé ou non. Par défaut le son est ACTIVÉ sur les Shorts : les
|
* Son coupé ou non. Démarrer MUET est la seule façon d'obtenir l'autoplay
|
||||||
* navigateurs le permettent dès la première interaction (molette/clic),
|
* garanti des iframes (politique navigateur) : c'est aussi ce que fait la
|
||||||
* sinon le player démarre en pause et l'utilisateur appuie sur lecture.
|
* page Watch (mute=1). Le son est rétabli automatiquement à la première
|
||||||
* Rebasculer rebascule l'URL d'embed (reprend au début).
|
* interaction (clic/molette/tactile) via `unlockSound()`, et l'utilisateur
|
||||||
|
* peut couper/réactiver via le bouton. Rebasculer recharge l'embed.
|
||||||
*/
|
*/
|
||||||
muted = signal(false);
|
muted = signal(true);
|
||||||
|
/** L'utilisateur a-t-il touché au bouton son ? (sinon on débloque en auto). */
|
||||||
|
private muteTouched = false;
|
||||||
|
private soundUnlocked = false;
|
||||||
/**
|
/**
|
||||||
* Embed Rumble autoritaire : id demandé -> vrai id scrapé via
|
* Embed Rumble autoritaire : id demandé -> vrai id scrapé via
|
||||||
* /api/rumble/video/<id> (comme /watch). Les IDs de liste approximatifs
|
* /api/rumble/video/<id> (comme /watch). Les IDs de liste approximatifs
|
||||||
@@ -517,9 +521,21 @@ export class WatchShortComponent implements OnDestroy {
|
|||||||
|
|
||||||
// ---- Actions façon YouTube Shorts ----
|
// ---- Actions façon YouTube Shorts ----
|
||||||
toggleMute(): void {
|
toggleMute(): void {
|
||||||
|
this.muteTouched = true;
|
||||||
this.muted.update(m => !m);
|
this.muted.update(m => !m);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Première interaction de l'utilisateur : le navigateur autorise alors un
|
||||||
|
* embed avec son. On débloque le son une seule fois, sauf si l'utilisateur
|
||||||
|
* a déjà choisi explicitement (mute activé manuellement).
|
||||||
|
*/
|
||||||
|
unlockSound(): void {
|
||||||
|
if (this.soundUnlocked || this.muteTouched || !this.muted()) return;
|
||||||
|
this.soundUnlocked = true;
|
||||||
|
this.muted.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
toggleFullscreen(): void {
|
toggleFullscreen(): void {
|
||||||
try {
|
try {
|
||||||
if (document.fullscreenElement) {
|
if (document.fullscreenElement) {
|
||||||
@@ -1084,7 +1100,7 @@ export class WatchShortComponent implements OnDestroy {
|
|||||||
private accepts(v: Video): boolean {
|
private accepts(v: Video): boolean {
|
||||||
const p = String((v as any).provider || this.provider()).toLowerCase();
|
const p = String((v as any).provider || this.provider()).toLowerCase();
|
||||||
const withProvider = { ...(v as object), provider: p } as Video;
|
const withProvider = { ...(v as object), provider: p } as Video;
|
||||||
return shortsFeedAccepts(withProvider, p) && this.isPlayable(withProvider);
|
return shortsFeedAccepts(withProvider, p) && this.isPlayable(withProvider) && this.shortsLangOk(withProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Jouabilité : l'embed répondra-t-il ? (indépendant du type de contenu) */
|
/** Jouabilité : l'embed répondra-t-il ? (indépendant du type de contenu) */
|
||||||
@@ -1102,18 +1118,17 @@ export class WatchShortComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* La vidéo respecte-t-elle les langues de Shorts de l'utilisateur
|
* La vidéo respecte-t-elle les langues acceptées (FR/EN + préférences) ?
|
||||||
* (langue UI + préférences + transcripts) ? Seuls les items Twitch
|
* On lit `language` (mappé pour DM/PeerTube/Odysee) ou `raw.language`
|
||||||
* portent la langue (champ Helix) ; sans métadonnée, le doute profite.
|
* (Twitch). Sans métadonnée, le doute profite à la vidéo : YouTube et
|
||||||
* YouTube/PeerTube sont filtrés côté serveur (relevanceLanguage,
|
* PeerTube sont de toute façon filtrés côté serveur (relevanceLanguage,
|
||||||
* languageOneOf) ; Dailymotion/Odysee/Rumble n'exposent pas la langue.
|
* languageOneOf) et Rumble n'expose pas la langue.
|
||||||
*/
|
*/
|
||||||
private shortsLangOk(v: Video): boolean {
|
private shortsLangOk(v: Video): boolean {
|
||||||
const p = (v as any).provider || this.provider();
|
const lang = String((v as any)?.language || (v as any)?.raw?.language || '')
|
||||||
if (String(p) !== 'twitch') return true;
|
.toLowerCase().split('-')[0];
|
||||||
const lang = String((v as any)?.raw?.language || '').toLowerCase().split('-')[0];
|
|
||||||
if (!lang || lang === 'other') return true;
|
if (!lang || lang === 'other') return true;
|
||||||
let accepted: string[] = ['en'];
|
let accepted: string[] = ['en', 'fr'];
|
||||||
try { accepted = this.api.getShortsLanguages(); } catch {}
|
try { accepted = this.api.getShortsLanguages(); } catch {}
|
||||||
return accepted.includes(lang);
|
return accepted.includes(lang);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface Video {
|
|||||||
isLive?: boolean; // true si diffusion en direct (YouTube eventType=live, Twitch streams)
|
isLive?: boolean; // true si diffusion en direct (YouTube eventType=live, Twitch streams)
|
||||||
kind?: 'clip'|'vod'|'short'|'channel'; // ex. Twitch=clip
|
kind?: 'clip'|'vod'|'short'|'channel'; // ex. Twitch=clip
|
||||||
raw?: any; // m�tadonn�es brutes si besoin
|
raw?: any; // m�tadonn�es brutes si besoin
|
||||||
|
language?: string; // code ISO 639-1 (2 lettres) quand le provider l'expose
|
||||||
// Classification pr�-calcul�e � l'ingestion (cf. section-policy.classifyOnce).
|
// Classification pr�-calcul�e � l'ingestion (cf. section-policy.classifyOnce).
|
||||||
_kind?: 'short'|'live'|'video';
|
_kind?: 'short'|'live'|'video';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export class YoutubeApiService {
|
|||||||
const base = String(v || '').trim().toLowerCase().split('-')[0];
|
const base = String(v || '').trim().toLowerCase().split('-')[0];
|
||||||
if (/^[a-z]{2,3}$/.test(base) && !out.includes(base)) out.push(base);
|
if (/^[a-z]{2,3}$/.test(base) && !out.includes(base)) out.push(base);
|
||||||
};
|
};
|
||||||
|
// 1) Préférences d'abord (l'UI guide la langue primaire des recherches).
|
||||||
try { push(this.i18n.lang()); } catch {}
|
try { push(this.i18n.lang()); } catch {}
|
||||||
try {
|
try {
|
||||||
const prefs = this.users.preferences() as { language?: unknown; downloadLanguages?: unknown } | null;
|
const prefs = this.users.preferences() as { language?: unknown; downloadLanguages?: unknown } | null;
|
||||||
@@ -59,7 +60,11 @@ export class YoutubeApiService {
|
|||||||
const dl = (prefs as any)?.downloadLanguages;
|
const dl = (prefs as any)?.downloadLanguages;
|
||||||
if (Array.isArray(dl)) for (const l of dl) push(l);
|
if (Array.isArray(dl)) for (const l of dl) push(l);
|
||||||
} catch {}
|
} catch {}
|
||||||
return out.length ? out : ['en'];
|
// 2) L'app ne propose que FR/EN : ces deux langues sont TOUJOURS acceptées
|
||||||
|
// (on filtre les autres uniquement quand le provider expose `language`).
|
||||||
|
push('en');
|
||||||
|
push('fr');
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Langue primaire (2 lettres) pour les APIs qui n'acceptent qu'un code ISO 639-1. */
|
/** Langue primaire (2 lettres) pour les APIs qui n'acceptent qu'un code ISO 639-1. */
|
||||||
@@ -589,7 +594,7 @@ export class YoutubeApiService {
|
|||||||
limit: String(limit),
|
limit: String(limit),
|
||||||
page: String(page),
|
page: String(page),
|
||||||
country: String(region).toLowerCase(),
|
country: String(region).toLowerCase(),
|
||||||
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',
|
fields: 'id,title,thumbnail_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,language,allow_embed,owner.id,owner.screenname,owner.avatar_80_url,created_time',
|
||||||
});
|
});
|
||||||
return this.fetchDailymotion('/videos', params).pipe(
|
return this.fetchDailymotion('/videos', params).pipe(
|
||||||
map(res => {
|
map(res => {
|
||||||
@@ -1104,7 +1109,9 @@ export class YoutubeApiService {
|
|||||||
// Dailymotion compte en minutes : 2 = ≤ 2 min, resserré à ≤75 s côté client.
|
// Dailymotion compte en minutes : 2 = ≤ 2 min, resserré à ≤75 s côté client.
|
||||||
shorter_than: '2',
|
shorter_than: '2',
|
||||||
country: String(region).toLowerCase(),
|
country: String(region).toLowerCase(),
|
||||||
fields: 'id,title,thumbnail_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,owner.screenname,owner.avatar_80_url,created_time',
|
// `allow_embed` indispensable : sans lui, les vidéos non-embeddables
|
||||||
|
// passent le filtre et le player répond "Forbidden".
|
||||||
|
fields: 'id,title,thumbnail_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,language,allow_embed,owner.screenname,owner.avatar_80_url,created_time',
|
||||||
search: q,
|
search: q,
|
||||||
});
|
});
|
||||||
return this.fetchDailymotion('/videos', params).pipe(
|
return this.fetchDailymotion('/videos', params).pipe(
|
||||||
@@ -1158,6 +1165,10 @@ export class YoutubeApiService {
|
|||||||
sort: '-views',
|
sort: '-views',
|
||||||
isLocal: 'true',
|
isLocal: 'true',
|
||||||
nsfw: 'false',
|
nsfw: 'false',
|
||||||
|
// Bornes de durée côté serveur : sans elles, le feed rendait des vidéos
|
||||||
|
// longues (rejetées ensuite) et pouvait se retrouver quasi vide.
|
||||||
|
durationMin: '1',
|
||||||
|
durationMax: '90',
|
||||||
});
|
});
|
||||||
// Langues de l'utilisateur (filtre natif PeerTube, ignoré si inconnu).
|
// Langues de l'utilisateur (filtre natif PeerTube, ignoré si inconnu).
|
||||||
try {
|
try {
|
||||||
@@ -1237,6 +1248,7 @@ export class YoutubeApiService {
|
|||||||
duration: Number(i.duration || 0),
|
duration: Number(i.duration || 0),
|
||||||
views: Number(i.views_total || 0),
|
views: Number(i.views_total || 0),
|
||||||
uploaded: i.created_time ? i.created_time * 1000 : 0,
|
uploaded: i.created_time ? i.created_time * 1000 : 0,
|
||||||
|
language: i?.language ? String(i.language).toLowerCase().split('-')[0] : undefined,
|
||||||
raw: { allowEmbed: i?.allow_embed !== false },
|
raw: { allowEmbed: i?.allow_embed !== false },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1269,6 +1281,8 @@ export class YoutubeApiService {
|
|||||||
duration: Number(i.duration || 0),
|
duration: Number(i.duration || 0),
|
||||||
views: Number(i.views || 0),
|
views: Number(i.views || 0),
|
||||||
uploaded: i.publishedAt ? Date.parse(i.publishedAt) : 0,
|
uploaded: i.publishedAt ? Date.parse(i.publishedAt) : 0,
|
||||||
|
// PeerTube expose `language` sous forme { id, label } ou chaîne ISO.
|
||||||
|
language: (i?.language && String(i.language.id || i.language).toLowerCase().split('-')[0]) || undefined,
|
||||||
} as Video;
|
} as Video;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1289,6 +1303,9 @@ export class YoutubeApiService {
|
|||||||
?? (typeof i?.value?.duration !== 'undefined' ? i.value.duration : undefined);
|
?? (typeof i?.value?.duration !== 'undefined' ? i.value.duration : undefined);
|
||||||
const duration = this.normalizeDuration(rawDuration);
|
const duration = this.normalizeDuration(rawDuration);
|
||||||
const views = (i?.meta?.view_count != null) ? Number(i.meta.view_count) : 0;
|
const views = (i?.meta?.view_count != null) ? Number(i.meta.view_count) : 0;
|
||||||
|
// Odysee : `value.languages` (tableau ISO) ou `value.language` (chaîne).
|
||||||
|
const langRaw = Array.isArray(val?.languages) ? val.languages[0] : (val?.language ?? i?.language);
|
||||||
|
const language = langRaw ? String(langRaw).toLowerCase().split('-')[0] : undefined;
|
||||||
const uploaderName = channelVal?.title || channel?.name || '';
|
const uploaderName = channelVal?.title || channel?.name || '';
|
||||||
const channelClaimId = channel?.claim_id || channel?.claimId || '';
|
const channelClaimId = channel?.claim_id || channel?.claimId || '';
|
||||||
const channelHandle = channel?.name || '';
|
const channelHandle = channel?.name || '';
|
||||||
@@ -1312,6 +1329,7 @@ export class YoutubeApiService {
|
|||||||
duration,
|
duration,
|
||||||
views: views,
|
views: views,
|
||||||
uploaded: publishedAt ? Date.parse(publishedAt) : 0,
|
uploaded: publishedAt ? Date.parse(publishedAt) : 0,
|
||||||
|
language,
|
||||||
} as Video;
|
} as Video;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user