MovixOpenSource/public/sw.js
Movix 3a7ab5ad42 feat: synchronise le repo privé — Cast, sources, API et mobile 2.5.7
Gros lot de travail synchronisé depuis le repo privé. Cette mise à jour
regroupe la refonte du lecteur et du Cast, de nouvelles sources VOD/Live TV,
le durcissement de l’API et des proxies, ainsi qu’une nouvelle version de
l’application mobile.

Lecteur, sources et Live TV

- Refonte du lecteur HLS avec sélection et mémorisation de la qualité,
  détection des résolutions, scan des liens, choix des pistes audio et
  fallback contrôlé de 1080p vers 720p.
- Ajoute une garde contre les boucles de changement de source, notamment
  lors des erreurs vidéo et des réponses 429.
- Revoit SeekStreaming de bout en bout : validation des embeds, prise en
  charge de plusieurs candidats, regroupement par serveur, règles de headers
  persistantes et cache d’extraction raccourci.
- Ajoute SwiftFlow pour les films et séries, avec lecteurs VF/VOSTFR, cache
  stale-while-revalidate, déduplication des requêtes et limite partagée côté API.
- Adapte Coflix à son nouveau domaine et à son nouveau thème, ajoute un kill
  switch, développe les wrappers 1jour1film et actualise le domaine Wiflix.
- Ajoute NorthLive et Vavoo à la Live TV, regroupe les variantes d’une même
  chaîne et permet de choisir le serveur ou la qualité.
- Enrichit les Top 10 avec des filtres par période et par algorithme, ainsi
  que de nouvelles métriques de visionnage.

Cast et application mobile

- Unifie le contrôle Cast entre le Web Cast SDK et le pont Android :
  état distant, lecture, pause, seek, arrêt et reprise après reconnexion.
- Ajoute sur Android un relais média LAN sécurisé, limité au Chromecast et
  au réseau Wi-Fi sélectionnés, avec sessions opaques et service foreground.
- Prépare les sources avant diffusion et prend en charge les playlists HLS
  TS/fMP4, les flux progressifs, les sous-titres VTT, SeekStreaming et les
  segments MPEG-TS encapsulés dans des PNG.
- Renforce le proxy média local : support Range, validation des redirections,
  liaison DNS/socket au réseau Cast et meilleure gestion des headers Fsvid.
- Sécurise le bridge WebView avec contrôle de l’origine top-frame, capacités
  propres à chaque document et invalidation lors des navigations.
- Ajoute le Picture-in-Picture Android avec lecture/pause et seek de
  10 secondes depuis la fenêtre flottante.
- Maintient désormais l’écran allumé uniquement pendant une lecture active.
- Publie l’application Android 2.5.7, build 16, avec le nouvel APK et ses
  métadonnées d’intégrité.

API, base de données et paiements

- Remplace 13 migrations SQL isolées par un manifeste de schéma MySQL
  centralisé, additif et capable de détecter les divergences de structure.
- Ajoute le workflow db:init avec prévisualisation, dry-run, verrou MySQL,
  confirmations pour les tables Wrapped et couverture de tests dédiée.
- Rend configurables les limites du pool MySQL et fiabilise les advisory locks.
- Revoit entièrement PayGate : références de callback séparées, nonces,
  validation des wallets et transactions, états monotones, déduplication des
  paiements et protection contre les courses ou réutilisations de txid.
- Renforce l’annulation, la réconciliation et la livraison idempotente des
  factures VIP, avec réponses no-store sur les routes sensibles.
- Distingue les JWT invalides des indisponibilités MySQL afin de renvoyer
  correctement 401 ou 503 sans provoquer de déconnexions inutiles.
- Utilise ZZAPI/Claude Haiku pour la modération, avec OpenRouter/DeepSeek en
  solution de secours.
- Améliore la gestion HLS et Range dans proxiesembed.

Frontend, profils et infrastructure

- Protège la sélection et l’hydratation des profils contre les réponses
  concurrentes, les intentions obsolètes et l’effacement accidentel du
  stockage local.
- Rend VITE_SITE_URL obligatoire et l’injecte dans le runtime, les données
  structurées, le service worker, robots.txt et sitemap.xml.
- Optimise les probes réseau du service worker avec cache court et single-flight.
- Charge dynamiquement les traductions anglaises, plusieurs routes secondaires
  et les données Search/TMDB uniquement lorsqu’elles deviennent nécessaires.
- Ajoute des caches mémoire/session pour les images et contenus TMDB et réduit
  les animations, calculs et rendus hors écran.
- Ajoute la page publique des frais, les coins carrés persistants, une nouvelle
  intro Movix, les avertissements de publicités adultes et de nombreux textes
  Cast, qualité, Live TV et sources.
- Met à jour les extensions Chrome 1.3.12 et Firefox 1.5.9, ainsi que le
  userscript, avec la nouvelle extraction SeekStreaming et la préparation Cast.
- Ajoute une large couverture de tests Kotlin et JavaScript pour le Cast, le
  proxy média, le PiP, le bridge, la base de données, PayGate et le lecteur.
- Ajoute bootstrap-icons et patch-package, régénère les lockfiles npm et
  actualise la documentation d’installation et d’initialisation MySQL.

Co-authored-by: GPT-5.6 Sol <noreply@openai.com>
2026-08-02 12:09:37 +02:00

484 lines
18 KiB
JavaScript

// ============================================================================
// Fallback domain — constantes injectées au build par vite.config.ts
// ============================================================================
const DEFAULT_MIRRORS = __MOVIX_DEFAULT_MIRRORS__;
const CONFIG_URL = __MOVIX_CONFIG_URL__;
const NAV_TIMEOUT_MS = 3000;
const CONFIG_TIMEOUT_MS = 3000;
// Ping de confirmation sur un asset statique de l'origine. Volontairement
// généreux : sur mobile, sortir de veille peut prendre 3-5s (DNS + TLS +
// radio cellulaire qui se réveille). Si même ce ping fail, l'origine est
// vraiment injoignable.
const REACHABILITY_TIMEOUT_MS = 4000;
const REACHABILITY_PROBE_PATH = '/movix.png';
const HOSTNAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
// ============================================================================
// Image cache — TMDB images (posters, backdrops, logos)
// ============================================================================
//
// Stratégie : cache-first sur image.tmdb.org. Premier hit = network + cache,
// hits suivants = direct depuis CacheStorage (instantané, no network).
//
// Bonus : queue de concurrence côté SW, cap à 6 simultanés. Sans throttle,
// le navigateur peut déclencher 30+ fetchs parallèles au mount du home.
//
// Bump IMAGE_CACHE_NAME pour invalider toutes les images cachées d'un coup
// (ex. quand on change la taille standard w500→w342 — sinon on continue à
// servir les vieilles URLs pendant des semaines). v2 = passage à w342 posters
// + w300 logos.
const IMAGE_CACHE_NAME = 'movix-tmdb-images-v2';
const TMDB_IMAGE_HOST = 'image.tmdb.org';
const MAX_CONCURRENT_IMAGE_FETCHES = 6;
let activeImageFetches = 0;
const imageFetchQueue = [];
function acquireImageFetchSlot() {
return new Promise((resolve) => {
if (activeImageFetches < MAX_CONCURRENT_IMAGE_FETCHES) {
activeImageFetches++;
resolve();
} else {
imageFetchQueue.push(resolve);
}
});
}
function releaseImageFetchSlot() {
const next = imageFetchQueue.shift();
if (next) {
next();
} else {
activeImageFetches = Math.max(0, activeImageFetches - 1);
}
}
async function handleTmdbImage(req) {
const cache = await caches.open(IMAGE_CACHE_NAME);
const cached = await cache.match(req);
if (cached) return cached;
await acquireImageFetchSlot();
try {
const res = await fetch(req);
if (res && (res.ok || res.type === 'opaque')) {
// .clone() avant .put() : la response ne peut être consommée qu'une fois.
// .catch silently : QuotaExceededError quand storage full → on sert la
// réponse non-cachée à l'utilisateur, qui marche quand même.
cache.put(req, res.clone()).catch(() => {});
}
return res;
} finally {
releaseImageFetchSlot();
}
}
// ============================================================================
// Helpers fallback domain
// ============================================================================
// Dev/LAN guard : on skip toute logique de redirect miroir quand le SW tourne
// sur localhost ou une IP privée. Sinon un backend absent en dev ou un HMR qui
// bouge déclenche des fetch failures et balance le dev sur le miroir prod.
function isLocalHost(hostname) {
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return true;
if (hostname.endsWith('.localhost')) return true;
if (/^10\./.test(hostname)) return true;
if (/^192\.168\./.test(hostname)) return true;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)) return true;
return false;
}
function parseConfig(text) {
// Deux formats supportés :
// - JSON : {"mirrors":["movix.health",...]} (dpaste.org, gist raw, etc.)
// - HTML : page rendue rentry.co/<slug> — on extrait les hostnames des <a href>
// à l'intérieur du <article>. Rentry.co exige un access code pour /raw
// depuis un durcissement anti-abuse ; on parse le HTML rendu à la place.
let hostnames = [];
try {
const parsed = JSON.parse(text);
if (parsed && Array.isArray(parsed.mirrors)) {
hostnames = parsed.mirrors
.map((m) => (typeof m === 'string' ? m.trim().toLowerCase() : ''));
}
} catch {
const articleMatch = text.match(/<article\b[^>]*>([\s\S]*?)<\/article>/i);
const scope = articleMatch ? articleMatch[1] : text;
const hrefRe = /href=["']https?:\/\/([^/"'\s?#]+)/gi;
const seen = new Set();
let match;
while ((match = hrefRe.exec(scope)) !== null) {
const host = match[1].trim().toLowerCase();
if (!seen.has(host)) {
seen.add(host);
hostnames.push(host);
}
}
}
// Filtre : format hostname valide + exclusion de rentry.co (lien canonique,
// CDN-cgi, footer, etc. qui peuvent se retrouver dans le scope si <article>
// n'est pas trouvé).
const mirrors = hostnames
.filter((h) => h.length > 0 && HOSTNAME_RE.test(h))
.filter((h) => h !== 'rentry.co' && !h.endsWith('.rentry.co'));
if (mirrors.length === 0) return null;
return { mirrors };
}
async function loadMirrors() {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), CONFIG_TIMEOUT_MS);
const res = await fetch(CONFIG_URL, { cache: 'no-store', signal: controller.signal });
clearTimeout(timer);
if (res.ok) {
const text = await res.text();
const config = parseConfig(text);
if (config) return config.mirrors;
}
} catch {}
return Array.isArray(DEFAULT_MIRRORS) ? DEFAULT_MIRRORS.slice() : [];
}
function pickNextMirror(mirrors, currentHost) {
const candidates = mirrors.filter((h) => h !== currentHost);
return candidates[0] || null;
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function buildMirrorUrl(targetHost, { from, reason, error, via } = {}) {
const url = new URL(`https://${targetHost}/`);
if (from) url.searchParams.set('from', from);
if (reason) url.searchParams.set('reason', reason);
if (error) url.searchParams.set('error', String(error).slice(0, 100));
if (via) url.searchParams.set('via', via);
return url.href;
}
function renderRedirectPage(url) {
const safe = escapeHtml(url);
const html = `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="0; url=${safe}">
<title>Movix — Redirection</title>
<link rel="canonical" href="${safe}">
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #000; color: #fff;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
display: grid; place-items: center; }
.wrap { text-align: center; padding: 1.5rem; max-width: 420px; }
.logo { font-size: 2rem; font-weight: 900; color: #dc2626; letter-spacing: 0.1em; margin-bottom: 1.5rem; }
.spinner { width: 40px; height: 40px; margin: 0 auto 1.5rem;
border: 3px solid rgba(255,255,255,.1); border-top-color: #dc2626;
border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
p { margin: 0.5rem 0; color: #aaa; font-size: 0.9rem; line-height: 1.5; }
a { color: #dc2626; text-decoration: none; font-weight: 600; }
a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="wrap">
<div class="logo">MOVIX</div>
<div class="spinner"></div>
<p>Redirection vers notre nouveau domaine…</p>
<p><a href="${safe}">Cliquer ici si rien ne se passe</a></p>
</div>
<script>
setTimeout(function () { window.location.replace(${JSON.stringify(url)}); }, 100);
</script>
</body>
</html>`;
return new Response(html, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
},
});
}
function render503Page() {
const html = `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Movix — Indisponible</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #000; color: #fff;
font-family: system-ui, -apple-system, sans-serif; display: grid; place-items: center; }
.wrap { text-align: center; padding: 1.5rem; max-width: 420px; }
.logo { font-size: 2rem; font-weight: 900; color: #dc2626; letter-spacing: 0.1em; margin-bottom: 1rem; }
h1 { font-size: 1.2rem; margin: 0 0 1rem; }
p { margin: 0.5rem 0; color: #aaa; font-size: 0.9rem; line-height: 1.5; }
a { display: inline-block; margin-top: 1rem; background: #229ED9; color: #fff;
padding: 0.7rem 1.2rem; border-radius: 0.5rem; text-decoration: none; font-weight: 600; }
</style>
</head>
<body>
<div class="wrap">
<div class="logo">MOVIX</div>
<h1>Site temporairement indisponible</h1>
<p>Tous nos domaines connus sont inaccessibles depuis votre connexion.</p>
<p>Rejoins notre canal Telegram pour recevoir l'adresse du nouveau domaine.</p>
<a href="https://t.me/movix_site">Ouvrir Telegram</a>
</div>
</body>
</html>`;
return new Response(html, {
status: 503,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
},
});
}
// ============================================================================
// Lifecycle
// ============================================================================
self.addEventListener('install', () => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
// Préserve le cache d'images courant ; supprime tout le reste (anciennes
// versions de cache, caches légacy d'avant cette logique). Quand on bumpe
// IMAGE_CACHE_NAME (ex. v1 → v2), l'ancienne version sera supprimée ici
// automatiquement.
await Promise.all(
keys
.filter((k) => k !== IMAGE_CACHE_NAME)
.map((k) => caches.delete(k))
);
await self.clients.claim();
})()
);
});
// ============================================================================
// Push notifications (préservé tel quel)
// ============================================================================
self.addEventListener('push', (event) => {
if (!event.data) return;
const data = event.data.json();
const baseUrl = self.location.origin;
event.waitUntil(
self.registration.showNotification(data.title || 'Movix', {
body: data.body || '',
icon: data.icon ? new URL(data.icon, baseUrl).href : `${baseUrl}/movix.png`,
badge: `${baseUrl}/movix.png`,
image: data.image || undefined,
data: data.data || {},
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const { contentType, contentId } = event.notification.data || {};
let url = '/';
if (contentType && contentId) {
url = contentType === 'movie' ? `/movie/${contentId}` : `/tv/${contentId}`;
}
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
for (const client of windowClients) {
if (client.url.includes(self.location.origin)) {
client.focus();
client.navigate(url);
return;
}
}
return clients.openWindow(url);
})
);
});
// ============================================================================
// Fetch — intercepte les navigations top-level pour fallback domain
// ============================================================================
// Ping de confirmation : l'origine répond-elle réellement ? Utilisé pour
// distinguer un VRAI blocage FAI (toute l'origine bloquée) d'un échec
// transient (mobile qui sort de veille, throttling de tab background, blip
// réseau). On vise un asset statique stable, cache: 'no-store' pour forcer
// un round-trip frais.
async function probeOriginOnce() {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REACHABILITY_TIMEOUT_MS);
try {
// URL stable, sans cache-buster : un blocage FAI casse au niveau DNS/TLS,
// donc même une réponse servie par l'edge CDN prouve que le domaine est
// joignable. `no-store` suffit à bypasser le cache HTTP du navigateur
// (round-trip réseau garanti) ; un buster unique forcerait en plus un MISS
// CDN et ferait traverser chaque probe jusqu'au serveur pour rien.
const url = new URL(REACHABILITY_PROBE_PATH, self.location.origin).href;
const res = await fetch(url, {
method: 'HEAD',
cache: 'no-store',
signal: controller.signal,
credentials: 'omit',
redirect: 'manual',
});
clearTimeout(timer);
// 2xx, 3xx, 4xx = origine répond (même un 404 confirme que le serveur
// est joignable). Seul un échec réseau ou un 5xx massif = injoignable.
return res.status < 500;
} catch {
clearTimeout(timer);
return false;
}
}
// Deux tentatives avant de conclure à un blocage. Un seul HEAD raté n'est
// pas un signal suffisant : un blip réseau ponctuel (perte de paquet, switch
// de cell tower, throttling court) peut le faire échouer. Si la 1ère échoue
// on attend 500ms et on retente — un VRAI blocage FAI est persistant et
// échouera les deux fois ; un blip transient laissera passer la 2ème.
//
// Memo 30s + single-flight (même pattern que dnsErrorDetection.ts côté page) :
// sur réseau flaky, chaque navigation échouée déclenchait jusqu'à 2 HEADs — un
// user qui spam reload générait une rafale de probes. 30s = assez long pour
// absorber un burst, assez court pour redétecter un blocage qui démarre. État
// en scope module : reset quand le browser tue le SW idle, ce qui est OK (le
// memo ne vise que les bursts, pas la persistance).
const PROBE_CACHE_MS = 30_000;
let probeInFlight = null;
let lastProbeAt = 0;
let lastProbeResult = null;
async function isOriginReachable() {
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
return false;
}
const now = Date.now();
if (lastProbeResult !== null && now - lastProbeAt < PROBE_CACHE_MS) {
return lastProbeResult;
}
if (probeInFlight) return probeInFlight;
probeInFlight = (async () => {
const first = await probeOriginOnce();
if (first) return true;
await new Promise((r) => setTimeout(r, 500));
return await probeOriginOnce();
})();
try {
const result = await probeInFlight;
lastProbeResult = result;
lastProbeAt = Date.now();
return result;
} finally {
probeInFlight = null;
}
}
async function handleNavigation(req) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), NAV_TIMEOUT_MS);
try {
const res = await fetch(req, { signal: controller.signal });
clearTimeout(timer);
return res;
} catch (err) {
clearTimeout(timer);
// Si l'utilisateur est offline, on laisse l'erreur réseau naturelle
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
throw err;
}
// Confirmation : l'origine est-elle vraiment injoignable ? Sinon (mobile
// qui se réveille, network blip, tab throttlé), on relaie l'erreur
// d'origine et on laisse le browser gérer (retry naturel, page d'erreur).
// On ne bascule au miroir QUE si même un HEAD simple échoue.
const reachable = await isOriginReachable();
if (reachable) {
throw err;
}
return await redirectToMirror({
from: self.location.hostname,
reason: 'unreachable',
error: `${err.name}: ${err.message}`,
via: 'sw-fetch',
});
}
}
async function redirectToMirror({ from, reason, error, via } = {}) {
const mirrors = await loadMirrors();
const target = pickNextMirror(mirrors, self.location.hostname);
if (!target) return render503Page();
const redirectUrl = buildMirrorUrl(target, { from, reason, error, via });
return renderRedirectPage(redirectUrl);
}
self.addEventListener('fetch', (event) => {
const req = event.request;
if (req.method !== 'GET') return;
// 1. TMDB image cache — intercepte tous les GET sur image.tmdb.org peu
// importe l'origine du SW. Pas de garde localhost ici : le cache est utile
// aussi en dev pour éviter de re-fetcher les mêmes posters à chaque reload.
let url;
try {
url = new URL(req.url);
} catch {
return;
}
if (url.hostname === TMDB_IMAGE_HOST) {
event.respondWith(handleTmdbImage(req));
return;
}
// 2. Navigation fallback (logique existante — préservée)
if (req.mode !== 'navigate') return;
if (isLocalHost(self.location.hostname)) return;
event.respondWith(handleNavigation(req));
});
// ============================================================================
// Message — handle force-redirect trigger depuis la page (block detection)
// ============================================================================
self.addEventListener('message', async (event) => {
const data = event.data;
if (!data || data.type !== 'MOVIX_FORCE_REDIRECT') return;
if (isLocalHost(self.location.hostname)) return;
try {
// Garde-fou : la page peut compter ses erreurs de manière trop
// optimiste (burst d'API calls qui fail au réveil mobile). On confirme
// avant de rediriger — sinon on enverrait l'utilisateur sur un miroir
// alors que l'origine répond très bien.
const reachable = await isOriginReachable();
if (reachable) return;
const mirrors = await loadMirrors();
const target = pickNextMirror(mirrors, self.location.hostname);
if (!target) return;
const url = buildMirrorUrl(target, {
from: self.location.hostname,
reason: 'api-errors',
error: data.error || 'API error threshold reached',
via: 'sw-message',
});
event.source?.postMessage({ type: 'MOVIX_REDIRECT_TO', url });
} catch {}
});