diff --git a/API/Mainapi/.env.example b/API/Mainapi/.env.example index 54b2ac5..f947276 100644 --- a/API/Mainapi/.env.example +++ b/API/Mainapi/.env.example @@ -85,3 +85,13 @@ VIP_PAYBLIS_STORE_NAME=Movix # incoming request, and the checkout domain defaults to pay.payblis.com. VIP_PAYBLIS_IPN_BASE_URL= VIP_PAYBLIS_DOMAIN=pay.payblis.com + +# === Hydracker queue decoding === +# When deployed without frontend support for 202, set to 'false' to disable +# the queue (route returns 404 on cache miss instead of 202). Default: true. +HYDRACKER_QUEUE_ENABLED=true +# When 'false', /decode/:id bypasses Redis/queue entirely and POSTs hydracker +# inline (synchronous mode, like before the queue was introduced). The drain +# timer is also skipped. Useful in dev / low-traffic where the queue rarely +# reaches 50. Default: true (queue+batch of 50 enabled). +HYDRACKER_BATCHING_ENABLED=true diff --git a/API/Mainapi/app.js b/API/Mainapi/app.js index 1025d6b..08de642 100644 --- a/API/Mainapi/app.js +++ b/API/Mainapi/app.js @@ -646,26 +646,30 @@ function getAppPool() { // configured via axiosHelpers.configure earlier in this file). const hydrackerQueue = require('./utils/hydrackerQueue'); const DRAIN_INTERVAL_MS = 5000; -setInterval(async () => { - try { - const result = await hydrackerQueue.drainQueueOnce({ - redis, - cacheDir: DOWNLOAD_CACHE_DIR, - generateCacheKey, - getFromCacheNoExpiration, - saveToCache, - axiosDarkinoRequest: axiosHelpers.axiosDarkinoRequest, - refreshDarkinoSessionIfNeeded - }); - if (result.drained) { - console.log(`[hydracker] drained batch of ${result.batchSize}`); - } else if (result.error) { - console.warn(`[hydracker] drain error: ${result.error} (requeued ${result.requeued || 0})`); +if (hydrackerQueue.BATCHING_ENABLED) { + setInterval(async () => { + try { + const result = await hydrackerQueue.drainQueueOnce({ + redis, + cacheDir: DOWNLOAD_CACHE_DIR, + generateCacheKey, + getFromCacheNoExpiration, + saveToCache, + axiosDarkinoRequest: axiosHelpers.axiosDarkinoRequest, + refreshDarkinoSessionIfNeeded + }); + if (result.drained) { + console.log(`[hydracker] drained batch of ${result.batchSize}`); + } else if (result.error) { + console.warn(`[hydracker] drain error: ${result.error} (requeued ${result.requeued || 0})`); + } + } catch (e) { + console.warn(`[hydracker] drain tick threw:`, e?.message || e); } - } catch (e) { - console.warn(`[hydracker] drain tick threw:`, e?.message || e); - } -}, DRAIN_INTERVAL_MS); + }, DRAIN_INTERVAL_MS); +} else { + console.log('[hydracker] HYDRACKER_BATCHING_ENABLED=false → drain timer skipped, decode runs synchronously'); +} // === Unified error handler === app.use((err, req, res, next) => { diff --git a/API/Mainapi/liveTvRoutes.js b/API/Mainapi/liveTvRoutes.js index a10c1f8..f7c4520 100644 --- a/API/Mainapi/liveTvRoutes.js +++ b/API/Mainapi/liveTvRoutes.js @@ -265,7 +265,7 @@ const BOLALOCA_CHANNELS = [ }, ]; -const LIVETV_BASE_URL = "https://livetv876.me"; +const LIVETV_BASE_URL = "https://livetv882.me"; const LIVETV_EMBED_REFERER = `${LIVETV_BASE_URL}/`; const LIVETV_ALLUPCOMING_PATHS = ["/frx/allupcoming/", "/frx/ads/"]; const LIVETV_CATEGORIES = { @@ -1958,7 +1958,7 @@ function shouldIgnoreLiveTvIframeUrl(rawUrl) { const combined = `${hostname}${pathname}${search}`; if ( - hostname === "ads.livetv876.me" || + hostname === "ads.livetv882.me" || hostname.startsWith("ads.") || hostname.startsWith("ad.") ) { diff --git a/API/Mainapi/routes/darkiworld.js b/API/Mainapi/routes/darkiworld.js index 8ef09f6..8b4063a 100644 --- a/API/Mainapi/routes/darkiworld.js +++ b/API/Mainapi/routes/darkiworld.js @@ -30,6 +30,9 @@ const HOST_ICON_MAP = { 'Dropbox': '/hosts/dropbox.svg', }; +// Hydracker uploaders to filter out (unreliable / spam). Hardcoded; not env-driven. +const BLOCKED_DARKIWORLD_USERS = new Set(['Guest']); + async function resolveMovixUsername(userId, authType) { try { const safeUserId = String(userId).replace(/[^a-zA-Z0-9_\-]/g, ''); @@ -307,6 +310,34 @@ async function findAllEntriesForEpisode({ titleId, seasonId, episodeId, perPage return foundEntries; } +// --------------------------------------------------------------------------- +// partitionLinksByDecodeCache — orders darkiworld links so that entries with +// a successful decode cache file on disk appear first. Cache file presence is +// verified via getFromCacheNoExpiration; only `success === true` payloads +// count (failed markers and missing files do NOT count as "cached"). +// Movix links are passed through untouched (caller prepends them). +// --------------------------------------------------------------------------- +async function partitionLinksByDecodeCache(links) { + if (!Array.isArray(links) || links.length === 0) return links || []; + const probes = await Promise.all(links.map(async (link) => { + if (link?.id == null) return { link, available: false }; + try { + const cacheKey = generateCacheKey(`darkiworld_decode_v2_${link.id}`); + const payload = await getFromCacheNoExpiration(DOWNLOAD_CACHE_DIR, cacheKey); + return { link, available: payload?.success === true }; + } catch (_) { + return { link, available: false }; + } + })); + const available = []; + const rest = []; + for (const { link, available: ok } of probes) { + if (ok) available.push(link); + else rest.push(link); + } + return [...available, ...rest]; +} + // --------------------------------------------------------------------------- // GET /download/:type/:id // Récupérer tous les liens d'amélioration DarkiWorld pour un film ou un épisode @@ -350,16 +381,46 @@ router.get('/download/:type/:id', async (req, res) => { let dataReturned = false; if (cachedData) { - // console.log(`Résultats de téléchargement pour ${type}/${id} récupérés du cache`); + // Re-sort by disk decode cache presence on every read so previously + // decoded entries (from past clicks / past prewarms) bubble to the top + // even though the list cache itself is frozen between writes. const cachedAll = Array.isArray(cachedData?.all) ? cachedData.all.map(r => ({ ...r, source: r.source || 'darkiworld' })) : []; - res.status(200).json({ ...cachedData, all: [...movixLinks, ...cachedAll], movixCount: movixLinks.length }); + const sortedCachedAll = await partitionLinksByDecodeCache(cachedAll); + res.status(200).json({ ...cachedData, all: [...movixLinks, ...sortedCachedAll], movixCount: movixLinks.length }); dataReturned = true; } + // Determine refresh staleness once and reuse below. shouldUpdateCache + // returns true if the file is missing OR older than 40 min. + const cacheNeedsUpdate = await shouldUpdateCache(DOWNLOAD_CACHE_DIR, cacheKey); + // Vérifier si l'utilisateur a accès premium (optionnel) const auth = await getAuthIfValid(req); const darkiworld_premium = auth && auth.userType === 'premium'; + // Pre-warm decode cache via hydracker's new /download endpoint. Fires on + // cache miss AND on stale cache (>40min) — the new /download endpoint is + // separate from the /decode endpoint that gets rate-limited, so prewarm + // can keep the decode cache hot for the curated subset even during a + // rate-limit window. Best-effort: a failure here is a no-op for the + // response — the queue still handles uncached ids on click. + const prewarmPromise = cacheNeedsUpdate + ? hydrackerQueue.prewarmDecodeCache({ + type, id, season, episode, + deps: { + axiosDarkinoRequest, + refreshDarkinoSessionIfNeeded, + cacheDir: DOWNLOAD_CACHE_DIR, + generateCacheKey, + saveToCache, + blockedUsers: BLOCKED_DARKIWORLD_USERS + } + }).catch((e) => { + console.warn(`[hydracker] prewarm launcher caught: ${e?.message || e}`); + return { warmed: 0, warmedIds: new Set() }; + }) + : Promise.resolve({ warmed: 0, warmedIds: new Set() }); + let allEnhancementLinks = []; if (type === 'movie') { @@ -375,7 +436,8 @@ router.get('/download/:type/:id', async (req, res) => { url: `/api/v1/titles/${id}/content/liens?perPage=100&loader=linksdl&filters=&paginate=preferLengthAware` }); - const allEntries = liensResp.data?.pagination?.data || []; + const rawEntries = liensResp.data?.pagination?.data || []; + const allEntries = rawEntries.filter(e => !BLOCKED_DARKIWORLD_USERS.has(e?.id_user)); // Traiter directement les entrées sans faire de requête de décodage const enhancementSources = allEntries.map(entry => { @@ -410,7 +472,7 @@ router.get('/download/:type/:id', async (req, res) => { // Pour les séries (épisodes) try { // 1. Paginer intelligemment pour trouver l'épisode - const allEntries = await findAllEntriesForEpisode({ + const rawEntries = await findAllEntriesForEpisode({ titleId: id, seasonId: parseInt(season), episodeId: parseInt(episode), @@ -418,6 +480,8 @@ router.get('/download/:type/:id', async (req, res) => { maxPages: 10 }); + const allEntries = rawEntries.filter(e => !BLOCKED_DARKIWORLD_USERS.has(e?.id_user)); + // Traiter directement les entrées sans faire de requête de décodage const enhancementSources = allEntries.map(entry => { if (!entry) return null; @@ -453,7 +517,22 @@ router.get('/download/:type/:id', async (req, res) => { } catch (_) { /* upstream failure leaves allEnhancementLinks empty */ } } - const taggedDarkiLinks = allEnhancementLinks.map(r => ({ ...r, source: 'darkiworld' })); + // Wait for the prewarm to settle so the disk decode cache is hot before + // we respond — without this await the client could click a link before + // the pre-warmed entry was written and would needlessly hit the queue. + const prewarmResult = await prewarmPromise; + if (prewarmResult?.warmed) { + console.log(`[hydracker] prewarm ${type}/${id} warmed=${prewarmResult.warmed}`); + } + + // Sort by disk decode cache presence: anything with an existing + // success-shaped payload at darkiworld_decode_v2_{id} (whether from a + // prior queue decode, a prior decodeRequestSync, or this request's + // prewarm) bubbles to the top. The prewarm just completed above so its + // writes are already on disk and counted here. + const orderedEnhancementLinks = await partitionLinksByDecodeCache(allEnhancementLinks); + + const taggedDarkiLinks = orderedEnhancementLinks.map(r => ({ ...r, source: 'darkiworld' })); const responseData = { success: true, all: [...movixLinks, ...taggedDarkiLinks], @@ -465,21 +544,20 @@ router.get('/download/:type/:id', async (req, res) => { res.json(responseData); } - // Background update du cache + // Background update du cache — reuses cacheNeedsUpdate computed above + // so we don't restat the file twice per request. (async () => { try { - // Vérifier si le cache doit être mis à jour - const shouldUpdate = await shouldUpdateCache(DOWNLOAD_CACHE_DIR, cacheKey); - if (!shouldUpdate) { - return; // Ne pas mettre à jour le cache + if (!cacheNeedsUpdate) { + return; // Cache encore frais (<40 min), pas de réécriture. } // Si on a des données, sauvegarder dans le cache - if (allEnhancementLinks && allEnhancementLinks.length > 0) { + if (orderedEnhancementLinks && orderedEnhancementLinks.length > 0) { // Store only DarkiWorld entries in cache — Movix links are fetched fresh each request const darkiOnlyData = { success: true, - all: allEnhancementLinks.map(r => ({ ...r, source: 'darkiworld' })) + all: orderedEnhancementLinks.map(r => ({ ...r, source: 'darkiworld' })) }; await saveToCache(DOWNLOAD_CACHE_DIR, cacheKey, darkiOnlyData); } @@ -538,13 +616,21 @@ router.get('/decode/:id', async (req, res) => { return res.status(200).json({ error: 'Service Darkino temporairement indisponible (maintenance)' }); } - const result = await hydrackerQueue.decodeRequest(id, { - redis, - cacheDir: DOWNLOAD_CACHE_DIR, - generateCacheKey, - getFromCacheNoExpiration, - shouldUpdateCache48h - }); + const result = hydrackerQueue.BATCHING_ENABLED + ? await hydrackerQueue.decodeRequest(id, { + redis, + cacheDir: DOWNLOAD_CACHE_DIR, + generateCacheKey, + getFromCacheNoExpiration + }) + : await hydrackerQueue.decodeRequestSync(id, { + cacheDir: DOWNLOAD_CACHE_DIR, + generateCacheKey, + getFromCacheNoExpiration, + saveToCache, + axiosDarkinoRequest, + refreshDarkinoSessionIfNeeded + }); if (result.payload) return res.status(200).json(result.payload); if (result.failed) { diff --git a/API/Mainapi/utils/hydrackerQueue.js b/API/Mainapi/utils/hydrackerQueue.js index 9d8a307..3d02c24 100644 --- a/API/Mainapi/utils/hydrackerQueue.js +++ b/API/Mainapi/utils/hydrackerQueue.js @@ -22,10 +22,17 @@ const WORKER_LOCK_KEY = 'hydracker:worker_lock'; const RATE_LIMIT_KEY = 'hydracker:rate_limited_until'; // Tunables +// HYDRACKER_BATCHING_ENABLED=false → bypass complet : la route /decode/:id +// utilise decodeRequestSync (POST direct, pas de Redis, pas de queue) et +// le drain timer dans app.js est skip. Comportement identique à l'ancien +// code synchrone, avant l'introduction de la queue. +const BATCHING_ENABLED = process.env.HYDRACKER_BATCHING_ENABLED !== 'false'; const BATCH_SIZE = 50; const WORKER_LOCK_TTL_SEC = 60; const FAILED_MARKER_TTL_MS = 2 * 60 * 60 * 1000; // 2h -const STALE_REVALIDATE_MS = 48 * 60 * 60 * 1000; // 48h +// Bumped 48h → 7d : un lien déjà en cache reste servi sans refetch pendant +// 7 jours, ce qui réduit la pression sur hydracker pour des contenus stables. +const STALE_REVALIDATE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days // --------------------------------------------------------------------------- // Pure helpers (no Redis, no fs side-effects) @@ -208,8 +215,7 @@ async function decodeRequest(id, deps) { redis, cacheDir, generateCacheKey, - getFromCacheNoExpiration, - shouldUpdateCache48h + getFromCacheNoExpiration } = deps; // 1. Read disk cache @@ -219,8 +225,9 @@ async function decodeRequest(id, deps) { return { failed: cached.payload }; } if (cached.payload.success === true) { - // Stale check — return immediately, optionally enqueue for refresh - const stale = await shouldUpdateCache48h(cacheDir, cached.cacheKey); + // Stale check (STALE_REVALIDATE_MS = 7d) — return immediately, optionally + // enqueue for refresh. mtimeMs vient de readDiskCache, pas de re-stat. + const stale = (Date.now() - cached.mtimeMs) >= STALE_REVALIDATE_MS; if (stale) { // Refresh asynchronously by adding to the queue. // No await — fire-and-forget, this is best-effort. @@ -253,6 +260,73 @@ async function decodeRequest(id, deps) { return { queued: true, queue_size }; } +// --------------------------------------------------------------------------- +// decodeRequestSync — bypass mode (HYDRACKER_BATCHING_ENABLED=false). +// POST hydracker directement pour un ID, pas de Redis, pas de queue. +// Comportement identique à l'ancien code synchrone d'avant la queue. +// Returns: +// { payload } — 200 OK +// { failed: } — 404 +// --------------------------------------------------------------------------- + +async function decodeRequestSync(id, deps) { + const { + cacheDir, + generateCacheKey, + getFromCacheNoExpiration, + saveToCache, + axiosDarkinoRequest, + refreshDarkinoSessionIfNeeded + } = deps; + + // 1. Read disk cache + const cached = await readDiskCache(id, { cacheDir, generateCacheKey, getFromCacheNoExpiration }); + if (cached) { + if (isFailedMarkerActive(cached.payload)) { + return { failed: cached.payload }; + } + if (cached.payload.success === true) { + return { payload: cached.payload }; + } + // Malformed cache — fall through to refetch + } + + const cacheKey = generateCacheKey(`darkiworld_decode_v2_${id}`); + + try { + await refreshDarkinoSessionIfNeeded(); + const resp = await axiosDarkinoRequest({ + method: 'post', + url: `/api/v1/download-premium/${id}` + }); + + const linkInfo = resp.data?.liens?.[0] || null; + if (!linkInfo) { + const marker = buildFailedMarker(id, 'Lien non trouvé', 'download-premium response empty'); + await saveToCache(cacheDir, cacheKey, marker).catch(() => {}); + return { failed: marker }; + } + + const payload = buildPayload(id, linkInfo); + if (!payload) { + const marker = buildFailedMarker(id, 'Lien d\'embed invalide', 'embed-NN.html shape detected'); + await saveToCache(cacheDir, cacheKey, marker).catch(() => {}); + return { failed: marker }; + } + + await saveToCache(cacheDir, cacheKey, payload).catch(() => {}); + return { payload }; + + } catch (err) { + const marker = buildFailedMarker(id, 'Erreur upstream hydracker', err?.message || String(err)); + // Ne pas écrire de marker si un cache existe déjà — évite de l'empoisonner. + if (!cached) { + await saveToCache(cacheDir, cacheKey, marker).catch(() => {}); + } + return { failed: marker }; + } +} + // --------------------------------------------------------------------------- // drainQueueOnce — one tick of the drain loop. Returns: // { drained: false, reason: 'rate_limited' | 'queue_too_small' | 'lock_taken' } @@ -364,6 +438,61 @@ async function drainQueueOnce(deps) { } } +// --------------------------------------------------------------------------- +// prewarmDecodeCache — call the new hydracker /download endpoint and seed the +// disk decode cache for every entry that carries a direct `lien`. Best-effort: +// silent on failure, never writes a failed marker. Triggered in parallel with +// the existing /content/liens fetch in routes/darkiworld.js (cache miss path). +// --------------------------------------------------------------------------- + +async function prewarmDecodeCache({ type, id, season, episode, deps }) { + const { + axiosDarkinoRequest, + refreshDarkinoSessionIfNeeded, + cacheDir, + generateCacheKey, + saveToCache, + blockedUsers + } = deps; + const blocked = blockedUsers instanceof Set ? blockedUsers : new Set(); + + try { await refreshDarkinoSessionIfNeeded(); } catch (_) { /* non-fatal */ } + + const url = type === 'movie' + ? `/api/v1/titles/${id}/download` + : `/api/v1/titles/${id}/season/${season}/episode/${episode}/download`; + + let resp; + try { + resp = await axiosDarkinoRequest({ method: 'get', url }); + } catch (e) { + console.warn(`[hydracker] prewarm upstream fail ${type}/${id}: ${e?.message || e}`); + return { warmed: 0, warmedIds: new Set() }; + } + + const entries = [ + resp.data?.video, + ...(resp.data?.alternative_videos || []) + ].filter(Boolean); + + const warmedIds = new Set(); + const seen = new Set(); + for (const entry of entries) { + if (entry.id == null) continue; + const idKey = String(entry.id); + if (seen.has(idKey)) continue; + if (typeof entry.lien !== 'string' || entry.lien.trim() === '') continue; + if (entry.id_user && blocked.has(entry.id_user)) continue; + seen.add(idKey); + const payload = buildPayload(entry.id, entry); + if (!payload) continue; + const cacheKey = generateCacheKey(`darkiworld_decode_v2_${entry.id}`); + await saveToCache(cacheDir, cacheKey, payload).catch(() => {}); + warmedIds.add(idKey); + } + return { warmed: warmedIds.size, warmedIds }; +} + module.exports = { // helpers chunk, @@ -383,7 +512,9 @@ module.exports = { releaseWorkerLock, // orchestrators decodeRequest, + decodeRequestSync, drainQueueOnce, + prewarmDecodeCache, // disk cache readDiskCache, // constants @@ -391,6 +522,7 @@ module.exports = { WORKER_LOCK_KEY, RATE_LIMIT_KEY, BATCH_SIZE, + BATCHING_ENABLED, WORKER_LOCK_TTL_SEC, FAILED_MARKER_TTL_MS, STALE_REVALIDATE_MS diff --git a/app/android/app/build.gradle b/app/android/app/build.gradle index b437b74..a7d800d 100644 --- a/app/android/app/build.gradle +++ b/app/android/app/build.gradle @@ -20,8 +20,8 @@ android { applicationId "com.movix.app" minSdk rootProject.ext.minSdkVersion targetSdk rootProject.ext.targetSdkVersion - versionCode 10 - versionName "2.5.1" + versionCode 11 + versionName "2.5.2" buildConfigField "int", "VERSION_CODE_INT", "${versionCode}" buildConfigField "String", "VERSION_NAME_STR", "\"${versionName}\"" diff --git a/app/movix-android.apk b/app/movix-android.apk index fe382f5..23543f6 100644 Binary files a/app/movix-android.apk and b/app/movix-android.apk differ diff --git a/app/version.json b/app/version.json index 14ac92b..3ab523c 100644 --- a/app/version.json +++ b/app/version.json @@ -1,13 +1,13 @@ { - "version": "2.5.1", - "buildNumber": 10, + "version": "2.5.2", + "buildNumber": 11, "apkUrl": "https://github.com/movixcorp/MovixOpenSource/raw/refs/heads/main/app/movix-android.apk", - "apkSizeBytes": 72040798, - "apkSha256": "715544f47ad1aa7525081e07e7dfc892e58ba57b8419e5ec9d00c524527004c4", + "apkSizeBytes": 72040918, + "apkSha256": "a9eea4793fbcd23476f0e1ec05fa4be64e7a752417686fff0e00bd4bc185bf53", "mandatory": false, - "releasedAt": "2026-04-25T16:26:33.137Z", + "releasedAt": "2026-05-06T20:49:37.532Z", "releaseNotes": { - "fr": "", + "fr": "Corrections de certaines sources de chaines télés", "en": "" } } diff --git a/extension/Chrome/background.js b/extension/Chrome/background.js index a234de4..5709751 100644 --- a/extension/Chrome/background.js +++ b/extension/Chrome/background.js @@ -1,8 +1,8 @@ const VAVOO_BASE_URL = "https://tvvoo.hayd.uk/cfg-fr"; const WITV_BASE_URL = "https://witv.team"; -const SOSPLAY_BASE_URL = "https://ligue1live.xyz"; -const LIVETV_BASE_URL = "https://livetv876.me/frx/"; -const LIVETV_EMBED_ORIGIN = "https://livetv876.me"; +const SOSPLAY_BASE_URL = "https://streamonsport.art"; +const LIVETV_BASE_URL = "https://livetv882.me/frx/"; +const LIVETV_EMBED_ORIGIN = "https://livetv882.me"; const LIVETV_EMBED_REFERER = LIVETV_BASE_URL; // Backend API URL for got-scraping based extraction const API_BASE_URL = "https://api.movix.cash"; @@ -1772,7 +1772,7 @@ function shouldIgnoreLiveTvIframeUrl(rawUrl) { const combined = `${hostname}${pathname}${search}`; if ( - hostname === "ads.livetv876.me" || + hostname === "ads.livetv882.me" || hostname.startsWith("ads.") || hostname.startsWith("ad.") ) { diff --git a/extension/Firefox/background.js b/extension/Firefox/background.js index 65c945b..c8bf17a 100644 --- a/extension/Firefox/background.js +++ b/extension/Firefox/background.js @@ -6,9 +6,9 @@ const browserAPI = typeof browser !== "undefined" ? browser : chrome; const VAVOO_BASE_URL = "https://tvvoo.hayd.uk/cfg-fr"; const WITV_BASE_URL = "https://witv.team"; -const SOSPLAY_BASE_URL = "https://ligue1live.xyz"; -const LIVETV_BASE_URL = "https://livetv876.me/frx/"; -const LIVETV_EMBED_ORIGIN = "https://livetv876.me"; +const SOSPLAY_BASE_URL = "https://streamonsport.art"; +const LIVETV_BASE_URL = "https://livetv882.me/frx/"; +const LIVETV_EMBED_ORIGIN = "https://livetv882.me"; const LIVETV_EMBED_REFERER = LIVETV_BASE_URL; // Backend API URL for got-scraping based extraction const API_BASE_URL = "https://api.movix.cash"; @@ -1804,7 +1804,7 @@ function shouldIgnoreLiveTvIframeUrl(rawUrl) { const combined = `${hostname}${pathname}${search}`; if ( - hostname === "ads.livetv876.me" || + hostname === "ads.livetv882.me" || hostname.startsWith("ads.") || hostname.startsWith("ad.") ) { diff --git a/src/App.tsx b/src/App.tsx index 82a85d7..8ddd0b4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -87,7 +87,7 @@ import ScreenSaver from './components/ScreenSaver'; import { useIdleTimer } from './hooks/useIdleTimer'; import { startVipVerification } from './utils/vipUtils'; import { broadcastAuthChange, clearStoredAuthSession, getResolvedAccountContext } from './utils/accountAuth'; -import { isSyncableStorageKey } from './utils/syncStorage'; +import { isSyncableStorageKey, SYNC_OUTBOX_STORAGE_KEY } from './utils/syncStorage'; import i18n, { detectInitialLanguage } from './i18n'; import { useTranslation } from 'react-i18next'; import { motion, AnimatePresence } from 'framer-motion'; @@ -544,6 +544,8 @@ const PersistenceManager = () => { React.useEffect(() => { (window as any).setProfileDataLoading = (loading: boolean) => { isProfileDataLoadingRef.current = loading; + const diag = (window as unknown as { __syncDiag?: Record }).__syncDiag; + if (diag) diag.gateLastSetTo = loading; debugAppLog('Profile data loading state changed:', loading); }; @@ -625,6 +627,41 @@ const PersistenceManager = () => { useEffect(() => { // Setup localStorage sync (now allowed on watch routes) + // Diagnostic counters (window.__syncDiag) — readable from remote inspector + // to pinpoint which guard silently drops sync ops on Firefox mobile. + const syncDiag = ((window as unknown as { __syncDiag?: Record }).__syncDiag = { + setItemIntercepted: 0, + removeItemIntercepted: 0, + diffsQueued: 0, + diffsDrained: 0, + skipSuppress: 0, + skipNotSyncable: 0, + skipNoop: 0, + skipForceClear: 0, + skipGateSet: 0, + skipGateRemove: 0, + skipGateEnqueue: 0, + opsEnqueued: 0, + flushGeneralCalled: 0, + flushGeneralBlockedGate: 0, + sendOpsCalled: 0, + sendOpsEmpty: 0, + sendOpsBlockedForceClear: 0, + sendOpsBlockedGate: 0, + sendOpsBlockedUserInfo: 0, + sendOpsBlockedNoToken: 0, + sendOpsAttempted: 0, + sendOpsSuccess: 0, + sendOpsError: 0, + lastSkippedKey: '', + lastUserInfo: '', + lastError: '', + gateInitialState: undefined as boolean | undefined, + gateLastSetTo: undefined as boolean | undefined, + profileLoadingExposed: typeof (window as unknown as { setProfileDataLoading?: unknown }).setProfileDataLoading === 'function' + }); + syncDiag.gateInitialState = isProfileDataLoadingRef.current; + // Initialize snapshot of current localStorage const prevValues = new Map(); for (let i = 0; i < localStorage.length; i++) { @@ -658,9 +695,10 @@ const PersistenceManager = () => { const enqueueGeneralOp = (op: any) => { // Skip sync during profile data loading (but allow on watch routes) - if (isProfileDataLoadingRef.current) return; + if (isProfileDataLoadingRef.current) { syncDiag.skipGateEnqueue++; return; } generalOpsRef.current.push(op); + syncDiag.opsEnqueued++; if (generalFlushTimeoutRef.current) return; generalFlushTimeoutRef.current = setTimeout(() => { flushGeneralOps(); @@ -669,29 +707,38 @@ const PersistenceManager = () => { }; const sendOps = async (ops: any[]) => { - if (!ops.length) return; + if (!ops.length) { syncDiag.sendOpsEmpty++; return; } + syncDiag.sendOpsCalled++; // Vérifier si un clear forcé est en cours (erreur 401) if ((window as any).__forceClearInProgress) { + syncDiag.sendOpsBlockedForceClear++; debugAppLog('Skipping sync - force clear in progress'); return; } // Skip sync during profile data loading (but allow on watch routes) if (isProfileDataLoadingRef.current) { + syncDiag.sendOpsBlockedGate++; debugAppLog('Skipping sync - profile data loading in progress'); return; } const userInfo = getUserInfo(); // Only sync for oauth and bip39 users with a selected profile - if (!userInfo.type || !userInfo.profileId || !['oauth', 'bip39'].includes(userInfo.type)) return; + if (!userInfo.type || !userInfo.profileId || !['oauth', 'bip39'].includes(userInfo.type)) { + syncDiag.sendOpsBlockedUserInfo++; + syncDiag.lastUserInfo = JSON.stringify(userInfo); + return; + } const authToken = localStorage.getItem('auth_token'); if (!authToken) { + syncDiag.sendOpsBlockedNoToken++; debugAppLog('Skipping sync - no auth token available'); return; } + syncDiag.sendOpsAttempted++; try { for (let index = 0; index < ops.length; index += MAX_SYNC_OPS_PER_REQUEST) { @@ -712,18 +759,35 @@ const PersistenceManager = () => { }); } + // NOTE: don't clear SYNC_OUTBOX_STORAGE_KEY here. The outbox holds + // un-replayed ops from previous sessions; in-session sendOps only + // sees current-session ops. The two sets are disjoint, so clearing + // here would silently drop a previous session's ops that hadn't yet + // succeeded a replay. ProfileContext.replayOutboxIfAny owns the + // outbox lifecycle; backend ops are idempotent so leaving stale + // outbox entries doesn't cause incorrect state, only one extra POST + // on next boot. + + syncDiag.sendOpsSuccess++; debugAppLog('Sync request successful'); window.dispatchEvent(new CustomEvent('sync_storage_updated')); } catch (e) { + syncDiag.sendOpsError++; + syncDiag.lastError = (e instanceof Error ? e.message : String(e)).slice(0, 200); console.error('Delta sync failed', e); } }; const flushGeneralOps = () => { + syncDiag.flushGeneralCalled++; const ops = generalOpsRef.current; generalOpsRef.current = []; // Skip sync during profile data loading (but allow on watch routes) - if (!isProfileDataLoadingRef.current && ops.length) sendOps(ops); + if (isProfileDataLoadingRef.current) { + syncDiag.flushGeneralBlockedGate++; + return; + } + if (ops.length) sendOps(ops); }; const flushProgressOps = () => { @@ -914,13 +978,13 @@ const PersistenceManager = () => { }; const processSet = (key: string, oldVal: string | null, newVal: string) => { - if (suppressSyncRef.current) return; - if (!isSyncableStorageKey(key)) return; - if (oldVal === newVal) return; + if (suppressSyncRef.current) { syncDiag.skipSuppress++; return; } + if (!isSyncableStorageKey(key)) { syncDiag.skipNotSyncable++; return; } + if (oldVal === newVal) { syncDiag.skipNoop++; return; } // Vérifier si un clear forcé est en cours (erreur 401) - if ((window as any).__forceClearInProgress) return; + if ((window as any).__forceClearInProgress) { syncDiag.skipForceClear++; return; } // Skip sync during profile data loading (but allow on watch routes) - if (isProfileDataLoadingRef.current) return; + if (isProfileDataLoadingRef.current) { syncDiag.skipGateSet++; syncDiag.lastSkippedKey = key; return; } if (isProgressKey(key)) { // Accumulate latest patch for progress keys const delta = computeObjectPatch(oldVal, newVal); @@ -953,12 +1017,12 @@ const PersistenceManager = () => { }; const processRemove = (key: string) => { - if (suppressSyncRef.current) return; - if (!isSyncableStorageKey(key)) return; + if (suppressSyncRef.current) { syncDiag.skipSuppress++; return; } + if (!isSyncableStorageKey(key)) { syncDiag.skipNotSyncable++; return; } // Vérifier si un clear forcé est en cours (erreur 401) - if ((window as any).__forceClearInProgress) return; + if ((window as any).__forceClearInProgress) { syncDiag.skipForceClear++; return; } // Skip sync during profile data loading (but allow on watch routes) - if (isProfileDataLoadingRef.current) return; + if (isProfileDataLoadingRef.current) { syncDiag.skipGateRemove++; syncDiag.lastSkippedKey = key; return; } enqueueGeneralOp({ op: 'remove', key }); if (isProgressKey(key)) { progressOpsMapRef.current.delete(key); @@ -976,6 +1040,7 @@ const PersistenceManager = () => { queueMicrotask(() => { diffQueueScheduled = false; const batch = pendingDiffs.splice(0); + syncDiag.diffsDrained += batch.length; for (const entry of batch) { if (entry.newVal === null) { processRemove(entry.key); @@ -1067,13 +1132,34 @@ const PersistenceManager = () => { } }, 2000) : null; // Poll every 2 seconds for Safari and Firefox/Librewolf without BroadcastChannel - const originalSetItem = localStorage.setItem; - const originalRemoveItem = localStorage.removeItem; - const originalClear = localStorage.clear; + // Patch Storage.prototype, NOT the localStorage instance. On Firefox, + // `localStorage` is a LegacyPlatformObject with a named-property setter: + // assigning `localStorage.setItem = fn` is interpreted as + // `setItem("setItem", fn.toString())` and stores the function as a + // localStorage entry — leaving the original prototype method in place, + // so writes are never intercepted and zero sync requests fire. Patching + // the prototype (a plain object, no named-property handler) works on + // every engine. The `this === localStorage` guard keeps sessionStorage + // writes on the unmodified path. + const originalSetItem = Storage.prototype.setItem; + const originalRemoveItem = Storage.prototype.removeItem; + const originalClear = Storage.prototype.clear; - localStorage.setItem = function (key: string, value: string) { + // Past versions of this code attempted `localStorage.setItem = fn` and + // accidentally seeded junk entries on Firefox users. Drop them once. + for (const junkKey of ['setItem', 'removeItem', 'clear']) { + const v = localStorage.getItem(junkKey); + if (v && v.startsWith('function')) { + originalRemoveItem.call(localStorage, junkKey); + prevValuesRefLocal.current.delete(junkKey); + } + } + + Storage.prototype.setItem = function (this: Storage, key: string, value: string) { + if (this !== localStorage) return originalSetItem.call(this, key, value); if (!isLocalStorageAvailable) return; + syncDiag.setItemIntercepted++; const oldVal = prevValuesRefLocal.current.get(key) ?? localStorage.getItem(key); originalSetItem.call(localStorage, key, value); prevValuesRefLocal.current.set(key, value); @@ -1084,13 +1170,19 @@ const PersistenceManager = () => { // Defer JSON-diff cost off the synchronous write path. if (!isProfileDataLoadingRef.current) { pendingDiffs.push({ key, oldVal, newVal: value }); + syncDiag.diffsQueued++; scheduleDiffDrain(); + } else { + syncDiag.skipGateSet++; + syncDiag.lastSkippedKey = key; } } as any; - localStorage.removeItem = function (key: string) { + Storage.prototype.removeItem = function (this: Storage, key: string) { + if (this !== localStorage) return originalRemoveItem.call(this, key); if (!isLocalStorageAvailable) return; + syncDiag.removeItemIntercepted++; const oldVal = prevValuesRefLocal.current.get(key) ?? null; originalRemoveItem.call(localStorage, key); prevValuesRefLocal.current.delete(key); @@ -1101,11 +1193,16 @@ const PersistenceManager = () => { // Defer downstream sync work into the microtask drain. if (!isProfileDataLoadingRef.current) { pendingDiffs.push({ key, oldVal, newVal: null }); + syncDiag.diffsQueued++; scheduleDiffDrain(); + } else { + syncDiag.skipGateRemove++; + syncDiag.lastSkippedKey = key; } } as any; - localStorage.clear = function () { + Storage.prototype.clear = function (this: Storage) { + if (this !== localStorage) return originalClear.call(this); if (!isLocalStorageAvailable) return; // Snapshot keys (with their previous values) before wiping localStorage. @@ -1157,6 +1254,27 @@ const PersistenceManager = () => { const flushPendingOpsSync = () => { if (isProfileDataLoadingRef.current) return; if ((window as unknown as { __forceClearInProgress?: boolean }).__forceClearInProgress) return; + + // Synchronously drain pendingDiffs FIRST. The microtask scheduled by + // the wrapped setItem/removeItem may not have run yet on Firefox: its + // unload sequencing can fire pagehide before the microtask checkpoint + // when the user refreshes immediately after a write (e.g., F5 right + // after submitting a VIP key). Without this explicit drain, in-flight + // diffs would never reach generalOpsRef and the outbox + keepalive + // path below would have nothing to flush — losing the user's write + // across reload. Chrome reliably drains microtasks before pagehide, + // which is why this manifests as "Firefox-only data loss". + if (pendingDiffs.length) { + const batch = pendingDiffs.splice(0); + for (const entry of batch) { + if (entry.newVal === null) { + processRemove(entry.key); + } else { + processSet(entry.key, entry.oldVal, entry.newVal); + } + } + } + if (generalFlushTimeoutRef.current) { clearTimeout(generalFlushTimeoutRef.current); generalFlushTimeoutRef.current = null; @@ -1182,6 +1300,61 @@ const PersistenceManager = () => { const authToken = localStorage.getItem('auth_token'); if (!authToken) return; + // Persist a recovery outbox to localStorage BEFORE the keepalive fetch. + // fetch keepalive is best-effort: on Firefox the Authorization header + // triggers a CORS preflight that the browser may drop on unload, and + // both engines cap inflight keepalive bytes. If the request never lands + // server-side, the next page load would otherwise wipe localStorage and + // restore stale backend state — losing the user's write. ProfileContext + // .replayOutboxIfAny reads this on boot and POSTs before the wipe runs. + // + // We MERGE with any existing outbox for the same user/profile rather + // than overwriting. Without merge, a chain of partial failures (replay + // 5xx → next-session writes → next unload) would silently drop the + // older un-replayed ops every cycle. A hard cap on op count prevents + // unbounded growth across many failed cycles; oldest ops are dropped + // first since newer ops reflect later state and ops are idempotent. + try { + const MAX_OUTBOX_OPS = 10000; + let mergedOps: Array> = pending; + try { + const existingRaw = localStorage.getItem(SYNC_OUTBOX_STORAGE_KEY); + if (existingRaw) { + const parsed = JSON.parse(existingRaw) as { + userType?: string; + profileId?: string; + ops?: unknown; + } | null; + if (parsed + && parsed.userType === userInfo.type + && parsed.profileId === userInfo.profileId + && Array.isArray(parsed.ops) + && parsed.ops.length > 0) { + mergedOps = [ + ...(parsed.ops as Array>), + ...pending + ]; + } + } + } catch { /* noop */ } + + if (mergedOps.length > MAX_OUTBOX_OPS) { + mergedOps = mergedOps.slice(mergedOps.length - MAX_OUTBOX_OPS); + } + + const outboxPayload = { + userType: userInfo.type, + profileId: userInfo.profileId, + userId: userInfo.id || undefined, + ops: mergedOps, + ts: Date.now() + }; + localStorage.setItem(SYNC_OUTBOX_STORAGE_KEY, JSON.stringify(outboxPayload)); + } catch (outboxErr) { + // Quota exceeded or other write error — proceed with keepalive anyway. + console.warn('[outbox] failed to persist outbox at unload:', outboxErr); + } + try { for (let index = 0; index < pending.length; index += MAX_SYNC_OPS_PER_REQUEST) { const batch = pending.slice(index, index + MAX_SYNC_OPS_PER_REQUEST); @@ -1225,9 +1398,9 @@ const PersistenceManager = () => { if (browserPollingInterval) clearInterval(browserPollingInterval); try { channel?.close(); } catch { /* noop */ } channel = null; - localStorage.setItem = originalSetItem as any; - localStorage.removeItem = originalRemoveItem as any; - localStorage.clear = originalClear as any; + Storage.prototype.setItem = originalSetItem; + Storage.prototype.removeItem = originalRemoveItem; + Storage.prototype.clear = originalClear; }; }, [isWatchRoute]); // Add isWatchRoute as dependency diff --git a/src/components/HLSPlayer.tsx b/src/components/HLSPlayer.tsx index 058aa82..6026212 100644 --- a/src/components/HLSPlayer.tsx +++ b/src/components/HLSPlayer.tsx @@ -2129,10 +2129,11 @@ const HLSPlayer = forwardRef(({ // Définir les lecteurs VO/VOSTFR à conserver (enlever 6, 4 et 2) if (showVostfrMenu) { const vostfrSources = [ - { id: 'vostfr', label: t('watch.voVostfrPlayer', { n: 1 }), url: '' }, // Videasy (priorité) - { id: 'vidlink', label: t('watch.voVostfrPlayer', { n: 2 }), url: '' }, // vidlink - { id: 'vidsrccc', label: t('watch.voVostfrPlayer', { n: 3 }), url: '' }, // vidsrc.io - { id: 'vidsrcwtf1', label: t('watch.voVostfrPlayer', { n: 4 }), url: '' } // vidsrc.wtf (v1) + { id: 'peachify', label: 'Peachify', url: '' }, // Peachify (priorité, FR subs + accent Movix) + { id: 'vostfr', label: 'Videasy', url: '' }, // Videasy + { id: 'vidlink', label: 'Vidlink', url: '' }, // vidlink + { id: 'vidsrccc', label: 'Vidsrc.io', url: '' }, // vidsrc.io + { id: 'vidsrcwtf1', label: 'Vidsrc.wtf 1', url: '' } // vidsrc.wtf (v1) ]; vostfrSources.forEach(source => { @@ -2144,13 +2145,15 @@ const HLSPlayer = forwardRef(({ // boutons Sources/Open dupliqués (rendus dans le parent ET dans l'iframe imbriqué). if (tvShowId != null && seasonNumber != null && episodeNumber != null) { // TV Show URLs - if (source.id === 'vidlink') finalUrl = `https://vidlink.pro/tv/${tvShowId}/${seasonNumber}/${episodeNumber}`; // vidlink.pro + if (source.id === 'peachify') finalUrl = `https://peachify.top/embed/tv/${tvShowId}/${seasonNumber}/${episodeNumber}?sub=French&accent=dc2626`; + else if (source.id === 'vidlink') finalUrl = `https://vidlink.pro/tv/${tvShowId}/${seasonNumber}/${episodeNumber}`; // vidlink.pro else if (source.id === 'vidsrccc') finalUrl = `https://vidsrc.io/embed/tv?tmdb=${tvShowId}&season=${seasonNumber}&episode=${episodeNumber}`; else if (source.id === 'vostfr') finalUrl = `https://player.videasy.net/tv/${tvShowId}/${seasonNumber}/${episodeNumber}`; // Videasy else if (source.id === 'vidsrcwtf1') finalUrl = `https://vidsrc.wtf/api/1/tv/?id=${tvShowId}&s=${seasonNumber}&e=${episodeNumber}`; // Assumed pattern } else if (movieId) { // Movie URLs (existing logic) - if (source.id === 'vidlink') finalUrl = `https://vidlink.pro/movie/${movieId}`; // vidlink.pro + if (source.id === 'peachify') finalUrl = `https://peachify.top/embed/movie/${movieId}?sub=French&accent=dc2626`; + else if (source.id === 'vidlink') finalUrl = `https://vidlink.pro/movie/${movieId}`; // vidlink.pro else if (source.id === 'vidsrccc') finalUrl = `https://vidsrc.io/embed/movie?tmdb=${movieId}`; else if (source.id === 'vostfr') finalUrl = `https://player.videasy.net/movie/${movieId}`; else if (source.id === 'vidsrcwtf1') finalUrl = `https://vidsrc.wtf/api/1/movie/?id=${movieId}`; @@ -7855,24 +7858,27 @@ const HLSPlayer = forwardRef(({ className="ml-4 pl-2 border-l-2 border-gray-700 mb-2" > {[ - { id: 'vostfr', label: t('watch.voVostfrPlayer', { n: 1 }) }, - { id: 'vidlink', label: t('watch.voVostfrPlayer', { n: 2 }) }, - { id: 'vidsrccc', label: t('watch.voVostfrPlayer', { n: 3 }) }, - { id: 'vidsrcwtf1', label: t('watch.voVostfrPlayer', { n: 4 }) } + { id: 'peachify', label: 'Peachify' }, + { id: 'vostfr', label: 'Videasy' }, + { id: 'vidlink', label: 'Vidlink' }, + { id: 'vidsrccc', label: 'Vidsrc.io' }, + { id: 'vidsrcwtf1', label: 'Vidsrc.wtf 1' } ].map((vostfrSource, index) => { // IMPORTANT: `!= null` au lieu de truthy check — sinon seasonNumber=0 // (épisode spécial / Spéciaux TMDB) tombe dans le fallback '#' qui fait // charger la page courante en boucle dans l'iframe. const sourceUrl = movieId ? - vostfrSource.id === 'vidlink' ? `https://vidlink.pro/movie/${movieId}` : - vostfrSource.id === 'vidsrccc' ? `https://vidsrc.io/embed/movie?tmdb=${movieId}` : - vostfrSource.id === 'vostfr' ? `https://player.videasy.net/movie/${movieId}` : - `https://vidsrc.wtf/api/1/movie/?id=${movieId}` : + vostfrSource.id === 'peachify' ? `https://peachify.top/embed/movie/${movieId}?sub=French&accent=dc2626` : + vostfrSource.id === 'vidlink' ? `https://vidlink.pro/movie/${movieId}` : + vostfrSource.id === 'vidsrccc' ? `https://vidsrc.io/embed/movie?tmdb=${movieId}` : + vostfrSource.id === 'vostfr' ? `https://player.videasy.net/movie/${movieId}` : + `https://vidsrc.wtf/api/1/movie/?id=${movieId}` : (tvShowId != null && seasonNumber != null && episodeNumber != null) ? - vostfrSource.id === 'vidlink' ? `https://vidlink.pro/tv/${tvShowId}/${seasonNumber}/${episodeNumber}` : - vostfrSource.id === 'vidsrccc' ? `https://vidsrc.io/embed/tv?tmdb=${tvShowId}&season=${seasonNumber}&episode=${episodeNumber}` : - vostfrSource.id === 'vostfr' ? `https://player.videasy.net/tv/${tvShowId}/${seasonNumber}/${episodeNumber}` : - `https://vidsrc.wtf/api/1/tv/?id=${tvShowId}&s=${seasonNumber}&e=${episodeNumber}` : + vostfrSource.id === 'peachify' ? `https://peachify.top/embed/tv/${tvShowId}/${seasonNumber}/${episodeNumber}?sub=French&accent=dc2626` : + vostfrSource.id === 'vidlink' ? `https://vidlink.pro/tv/${tvShowId}/${seasonNumber}/${episodeNumber}` : + vostfrSource.id === 'vidsrccc' ? `https://vidsrc.io/embed/tv?tmdb=${tvShowId}&season=${seasonNumber}&episode=${episodeNumber}` : + vostfrSource.id === 'vostfr' ? `https://player.videasy.net/tv/${tvShowId}/${seasonNumber}/${episodeNumber}` : + `https://vidsrc.wtf/api/1/tv/?id=${tvShowId}&s=${seasonNumber}&e=${episodeNumber}` : '#'; // Fallback if neither movie nor TV info is present // Active state check for VOSTFR sources in main menu diff --git a/src/components/HLSPlayerSettingsPanel.tsx b/src/components/HLSPlayerSettingsPanel.tsx index 74434dc..352e7e1 100644 --- a/src/components/HLSPlayerSettingsPanel.tsx +++ b/src/components/HLSPlayerSettingsPanel.tsx @@ -987,24 +987,27 @@ const HLSPlayerSettingsPanel = (props: HLSPlayerSettingsPanelProps) => { className="ml-4 pl-2 border-l-2 border-gray-700 mb-2" > {[ - { id: 'vostfr', label: t('watch.voVostfrPlayer', { n: 1 }) }, - { id: 'vidlink', label: t('watch.voVostfrPlayer', { n: 2 }) }, - { id: 'vidsrccc', label: t('watch.voVostfrPlayer', { n: 3 }) }, - { id: 'vidsrcwtf1', label: t('watch.voVostfrPlayer', { n: 4 }) } + { id: 'peachify', label: 'Peachify' }, + { id: 'vostfr', label: 'Videasy' }, + { id: 'vidlink', label: 'Vidlink' }, + { id: 'vidsrccc', label: 'Vidsrc.io' }, + { id: 'vidsrcwtf1', label: 'Vidsrc.wtf 1' } ].map((vostfrSource, index) => { // IMPORTANT: `!= null` au lieu de truthy check — sinon seasonNumber=0 // (épisode spécial / Spéciaux TMDB) tombe dans le fallback '#' qui fait // charger la page courante en boucle dans l'iframe. const sourceUrl = movieId ? - vostfrSource.id === 'vidlink' ? `https://vidlink.pro/movie/${movieId}` : - vostfrSource.id === 'vidsrccc' ? `https://vidsrc.io/embed/movie?tmdb=${movieId}` : - vostfrSource.id === 'vostfr' ? `https://player.videasy.net/movie/${movieId}` : - `https://vidsrc.wtf/api/1/movie/?id=${movieId}` : + vostfrSource.id === 'peachify' ? `https://peachify.top/embed/movie/${movieId}?sub=French&accent=dc2626` : + vostfrSource.id === 'vidlink' ? `https://vidlink.pro/movie/${movieId}` : + vostfrSource.id === 'vidsrccc' ? `https://vidsrc.io/embed/movie?tmdb=${movieId}` : + vostfrSource.id === 'vostfr' ? `https://player.videasy.net/movie/${movieId}` : + `https://vidsrc.wtf/api/1/movie/?id=${movieId}` : (tvShowId != null && seasonNumber != null && episodeNumber != null) ? - vostfrSource.id === 'vidlink' ? `https://vidlink.pro/tv/${tvShowId}/${seasonNumber}/${episodeNumber}` : - vostfrSource.id === 'vidsrccc' ? `https://vidsrc.io/embed/tv?tmdb=${tvShowId}&season=${seasonNumber}&episode=${episodeNumber}` : - vostfrSource.id === 'vostfr' ? `https://player.videasy.net/tv/${tvShowId}/${seasonNumber}/${episodeNumber}` : - `https://vidsrc.wtf/api/1/tv/?id=${tvShowId}&s=${seasonNumber}&e=${episodeNumber}` : + vostfrSource.id === 'peachify' ? `https://peachify.top/embed/tv/${tvShowId}/${seasonNumber}/${episodeNumber}?sub=French&accent=dc2626` : + vostfrSource.id === 'vidlink' ? `https://vidlink.pro/tv/${tvShowId}/${seasonNumber}/${episodeNumber}` : + vostfrSource.id === 'vidsrccc' ? `https://vidsrc.io/embed/tv?tmdb=${tvShowId}&season=${seasonNumber}&episode=${episodeNumber}` : + vostfrSource.id === 'vostfr' ? `https://player.videasy.net/tv/${tvShowId}/${seasonNumber}/${episodeNumber}` : + `https://vidsrc.wtf/api/1/tv/?id=${tvShowId}&s=${seasonNumber}&e=${episodeNumber}` : '#'; // Fallback if neither movie nor TV info is present // Active state check for VOSTFR sources in main menu diff --git a/src/context/ProfileContext.tsx b/src/context/ProfileContext.tsx index d1b3874..4c5b193 100644 --- a/src/context/ProfileContext.tsx +++ b/src/context/ProfileContext.tsx @@ -8,10 +8,117 @@ import { getSyncableLocalStorageEntries, hasSyncableLocalStorageData, isSyncableStorageKey, - shouldPreserveStorageKeyOnProfileLoad + shouldPreserveStorageKeyOnProfileLoad, + SYNC_OUTBOX_STORAGE_KEY } from '../utils/syncStorage'; import { checkVipStatus } from '../utils/vipUtils'; +// Pending sync ops persisted by App.tsx flushPendingOpsSync at unload time. +// We POST these to /api/sync BEFORE applyProfileEntriesToLocalStorage wipes +// localStorage — otherwise unsynced writes (Firefox keepalive flake, network +// hiccup) would be destroyed by the wipe-and-restore restoring stale backend +// state. On success the outbox is cleared; on transient failure it's kept for +// the next boot to retry; on 401 it's discarded (auth no longer matches). +const SYNC_OUTBOX_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + +interface SyncOutboxPayload { + userType: 'oauth' | 'bip39'; + profileId: string; + userId?: string; + ops: unknown[]; + ts: number; +} + +function isSyncOutboxPayload(value: unknown): value is SyncOutboxPayload { + if (!value || typeof value !== 'object') return false; + const candidate = value as Partial; + return ( + (candidate.userType === 'oauth' || candidate.userType === 'bip39') + && typeof candidate.profileId === 'string' + && candidate.profileId.length > 0 + && Array.isArray(candidate.ops) + && candidate.ops.length > 0 + ); +} + +async function replayOutboxIfAny(): Promise { + let raw: string | null = null; + try { raw = localStorage.getItem(SYNC_OUTBOX_STORAGE_KEY); } catch { return; } + if (!raw) return; + + let parsed: unknown = null; + try { parsed = JSON.parse(raw); } catch { + try { localStorage.removeItem(SYNC_OUTBOX_STORAGE_KEY); } catch { /* noop */ } + return; + } + + if (!isSyncOutboxPayload(parsed)) { + try { localStorage.removeItem(SYNC_OUTBOX_STORAGE_KEY); } catch { /* noop */ } + return; + } + + const payload = parsed; + const ageMs = Date.now() - (typeof payload.ts === 'number' ? payload.ts : 0); + if (ageMs > SYNC_OUTBOX_MAX_AGE_MS) { + try { localStorage.removeItem(SYNC_OUTBOX_STORAGE_KEY); } catch { /* noop */ } + return; + } + + const authToken = localStorage.getItem('auth_token'); + if (!authToken) { + // No auth yet (e.g., logged out). Keep the outbox for a future replay. + return; + } + + try { + await axios.post(`${API_URL}/api/sync`, { + userType: payload.userType, + profileId: payload.profileId, + userId: payload.userId, + ops: payload.ops + }, { + headers: { Authorization: `Bearer ${authToken}` } + }); + try { localStorage.removeItem(SYNC_OUTBOX_STORAGE_KEY); } catch { /* noop */ } + } catch (error: unknown) { + const response = (error as { response?: { status?: number; data?: { error?: string } } })?.response; + const status = response?.status; + const errMsg = response?.data?.error; + + // Decide whether the error is permanent (clear outbox) or transient (keep + // for retry). Permanent rejections will never succeed on retry and would + // otherwise sit in localStorage until the 7-day TTL evicts them, blocking + // every boot's replay attempt and (with the merge logic in App.tsx) + // potentially dropping legitimate ops that get appended each cycle. + // + // 401 + 'Unauthorized' → token expired or invalid; user can re-auth as + // the same account → keep, retry next boot. + // 401 + 'UserId mismatch' → different user logged in than the outbox's + // owner → permanent, clear. + // 400 / 403 / 404 / 413 → malformed op, missing profile, oversize + // payload — none recover on retry → clear. + // 408 / 429 → timeout / rate-limit → transient → keep. + // 5xx / network errors → server hiccup → transient → keep. + let isPermanent = false; + if (typeof status === 'number' && status >= 400 && status < 500) { + if (status === 408 || status === 429) { + isPermanent = false; + } else if (status === 401 && errMsg === 'Unauthorized') { + isPermanent = false; + } else { + isPermanent = true; + } + } + + if (isPermanent) { + try { localStorage.removeItem(SYNC_OUTBOX_STORAGE_KEY); } catch { /* noop */ } + console.warn('[outbox] replay rejected permanently, dropping outbox:', status, errMsg); + } else { + console.warn('[outbox] replay failed, will retry on next boot:', error); + } + } +} + const API_URL = import.meta.env.VITE_MAIN_API; const ProfileContext = createContext(undefined); @@ -259,6 +366,13 @@ export const ProfileProvider: React.FC = ({ children }) => (window as any).setProfileDataLoading(true); } + // Recovery flush: replay any sync ops persisted at the previous unload + // BEFORE the GET below pulls server state. Without this, a write that + // didn't reach the server (Firefox keepalive flake, network hiccup, + // browser crash) would be destroyed when applyProfileEntriesToLocalStorage + // wipes localStorage and restores stale backend data. + await replayOutboxIfAny(); + const response = await axios.get(`${API_URL}/api/profiles/${profileId}/data`, { headers: { Authorization: `Bearer ${authToken}` } }); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index acba2db..dadd8fc 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -519,7 +519,6 @@ "contactDiscord": "If no player works, you can contact us on Discord.", "playerVF": "VF Player", "playersVOSTFR": "VO/VOSTFR Players", - "playerVOSTFR": "VO/VOSTFR Player {{number}}", "playerMulti": "Multi Player", "playerOmega": "Omega Player", "noAds": "NO ADS", @@ -4157,7 +4156,15 @@ "debridError": "Error while debriding", "debridDirectLink": "Debrided direct link", "debridDownload": "Download", - "vipDebridPromo": "VIP members have access to the integrated debrider for 1fichier links" + "vipDebridPromo": "VIP members have access to the integrated debrider for 1fichier links", + "queueWaiting": "Queue: {{size}}/50", + "queueTimeout": "Decode took too long, try again in a few minutes", + "rateLimited": "Service rate-limited, retry after {{retryAt}}", + "rateLimitedLearnMore": "Learn more", + "rateLimitInfoTitle": "Why this delay?", + "rateLimitInfoBody": "Hydracker — the upstream source where Movix fetches your download links — caps decoding requests at 200 per day. To save that quota, Movix batches requests in groups of 50 links: that's what creates the queue you sometimes see. When the quota is reached, the service tells you when requests will resume. It's temporary and recovers automatically.", + "queueUnavailable": "Infrastructure unavailable, try again later", + "decodeFailed": "Link not found or inaccessible" }, "debrid": { "title": "Debrider", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 3c628a5..69f229e 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -519,7 +519,6 @@ "contactDiscord": "Si malgrès tout, aucun lecteur fonctionne, vous pouvez nous contacter sur le discord.", "playerVF": "Lecteur VF", "playersVOSTFR": "Lecteurs VO/VOSTFR", - "playerVOSTFR": "Lecteur VO/VOSTFR {{number}}", "playerMulti": "Lecteur Multi", "playerOmega": "Lecteur Omega", "noAds": "SANS PUBS", @@ -4157,7 +4156,15 @@ "debridError": "Erreur lors du débridage", "debridDirectLink": "Lien direct débridé", "debridDownload": "Télécharger", - "vipDebridPromo": "Les membres VIP ont accès au débrideur intégré pour les liens 1fichier" + "vipDebridPromo": "Les membres VIP ont accès au débrideur intégré pour les liens 1fichier", + "queueWaiting": "File d'attente : {{size}}/50", + "queueTimeout": "Décodage trop long, réessaie dans quelques minutes", + "rateLimited": "Service surchargé, retry après {{retryAt}}", + "rateLimitedLearnMore": "En savoir plus", + "rateLimitInfoTitle": "Pourquoi ce délai ?", + "rateLimitInfoBody": "Hydracker — la source de téléchargement où Movix récupère tes liens — limite à 200 requêtes de décodage par jour. Pour économiser ce quota, Movix regroupe les demandes par lots de 50 liens : c'est ce qui crée la file d'attente que tu vois parfois. Quand le quota est atteint, le service t'indique l'heure à laquelle les requêtes recommenceront. C'est temporaire et ça repart automatiquement.", + "queueUnavailable": "Infrastructure indisponible, réessaie plus tard", + "decodeFailed": "Lien non trouvé ou inaccessible" }, "debrid": { "title": "Débrideur", diff --git a/src/pages/DownloadPage.tsx b/src/pages/DownloadPage.tsx index 758fa5b..89ffd8b 100644 --- a/src/pages/DownloadPage.tsx +++ b/src/pages/DownloadPage.tsx @@ -7,6 +7,7 @@ import AdFreePlayerAds from '../components/AdFreePlayerAds'; import { toast } from 'sonner'; import { getTmdbLanguage } from '../i18n'; import { motion, AnimatePresence } from 'framer-motion'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; const MAIN_API = import.meta.env.VITE_MAIN_API; const TMDB_API_KEY = import.meta.env.VITE_TMDB_API_KEY || ''; @@ -513,6 +514,39 @@ const EpisodeDropdown: React.FC<{ ); }; +const RateLimitInfoButton: React.FC<{ error: string | null }> = ({ error }) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + const ratelimitedTemplate = t('download.rateLimited', { retryAt: '__X__' }); + const ratelimitedPrefix = ratelimitedTemplate.split('__X__')[0]; + const isRateLimited = !!error && error.startsWith(ratelimitedPrefix); + + if (!isRateLimited) return null; + + return ( + <> + + + + + {t('download.rateLimitInfoTitle')} + + {t('download.rateLimitInfoBody')} + + + + + + ); +}; + // Popup de sélection de liens (similaire à AvatarSelector) const LinkSelector: React.FC<{ isOpen: boolean; @@ -522,7 +556,8 @@ const LinkSelector: React.FC<{ isDecoding: boolean; decodedLink: DecodedLink | null; error: string | null; -}> = ({ isOpen, onClose, title, selectedLink, isDecoding, decodedLink, error }) => { + queueInfo?: { size: number } | null; +}> = ({ isOpen, onClose, title, selectedLink, isDecoding, decodedLink, error, queueInfo }) => { const { t, i18n } = useTranslation(); const [isClosing, setIsClosing] = useState(false); const isVipUser = localStorage.getItem('is_vip') === 'true'; @@ -643,11 +678,17 @@ const LinkSelector: React.FC<{ {t('download.decoding')} + {queueInfo != null && ( + {t('download.queueWaiting', { size: queueInfo.size })} + )} ) : error ? ( -
- - {error} +
+
+ + {error} +
+
) : decodedLink ? (
@@ -839,6 +880,7 @@ const DownloadPage: React.FC = () => { const [selectedLink, setSelectedLink] = useState(null); const [isDecoding, setIsDecoding] = useState(false); const [decodedLink, setDecodedLink] = useState(null); + const [queueInfo, setQueueInfo] = useState<{ size: number } | null>(null); const [showAdPopup, setShowAdPopup] = useState(false); const [pendingLinkToDecode, setPendingLinkToDecode] = useState(null); const [adUnlocked, setAdUnlocked] = useState(false); @@ -1303,6 +1345,7 @@ const DownloadPage: React.FC = () => { setSelectedLink(link); setError(null); setDecodedLink(null); + setQueueInfo(null); setShowLinkSelector(true); // Les liens Movix sont déjà des URLs directes (1fichier, Mega, …) ajoutées @@ -1346,10 +1389,55 @@ const DownloadPage: React.FC = () => { const params: Record = {}; if (currentDarkiWorldTitleId) params.title_id = currentDarkiWorldTitleId; - const response = await axios.get(`${MAIN_API}/api/darkiworld/decode/${link.id}`, { - params, - signal: abortController.signal - }); + const POLL_INTERVAL_MS = 5000; + const MAX_POLL_ATTEMPTS = 60; // ~5 min max + let attempts = 0; + let response: any = null; + + while (attempts < MAX_POLL_ATTEMPTS) { + if (abortController.signal.aborted) return; + response = await axios.get(`${MAIN_API}/api/darkiworld/decode/${link.id}`, { + params, + signal: abortController.signal, + validateStatus: () => true + }); + + if (response.status === 200) break; + + if (response.status === 202) { + // Queued — show user the queue position + setQueueInfo({ size: response.data?.queue_size ?? 0 }); + attempts += 1; + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + continue; + } + + if (response.status === 503 && response.data?.error === 'rate_limited') { + setError(t('download.rateLimited', { retryAt: new Date(response.data.retry_at).toLocaleTimeString() })); + return; + } + + if (response.status === 503 && response.data?.error === 'queue_unavailable') { + setError(response.data?.message || t('download.queueUnavailable')); + return; + } + + if (response.status === 404) { + setError(response.data?.error || t('download.decodeFailed')); + return; + } + + // Unknown status + setError(t('download.decodeFailed')); + return; + } + + if (!response || response.status !== 200) { + setError(t('download.queueTimeout')); + return; + } + + // At this point, response.data is the decoded payload setDecodedLink(response.data); } catch (err: any) { // Ne pas afficher d'erreur si la requête a été annulée @@ -1396,6 +1484,7 @@ const DownloadPage: React.FC = () => { setDecodedLink(null); setError(null); setSelectedLink(null); + setQueueInfo(null); }; const handleAdPopupAccept = async () => { @@ -1698,9 +1787,12 @@ const DownloadPage: React.FC = () => { {error && (
-
- - {error} +
+
+ + {error} +
+
)} @@ -1718,6 +1810,7 @@ const DownloadPage: React.FC = () => { isDecoding={isDecoding} decodedLink={decodedLink} error={error} + queueInfo={queueInfo} /> {showAdPopup && ( (null); const [customSources, setCustomSources] = useState([]); // Change l'état initial pour ne pas sélectionner de lecteur par défaut et corrige le type - type PlayerSourceType = 'primary' | 'vostfr' | 'videasy' | 'vidsrccc' | 'vidsrcsu' | 'vidsrcwtf1' | 'vidsrcwtf5' | 'adfree' | 'multi' | 'omega' | 'darkino' | 'mp4' | number; + type PlayerSourceType = 'primary' | 'peachify' | 'vostfr' | 'videasy' | 'vidsrccc' | 'vidsrcsu' | 'vidsrcwtf1' | 'vidsrcwtf5' | 'adfree' | 'multi' | 'omega' | 'darkino' | 'mp4' | number; const [selectedSource, setSelectedSource] = useState(null); const [frembedAvailable, setFrembedAvailable] = useState(true); const [adFreeSource, setAdFreeSource] = useState(null); @@ -1548,7 +1548,7 @@ const VideoPlayer = ({ movieId, backdropPath }: { movieId: string; backdropPath?
- - - - - + + + + + + +
)}
@@ -1907,6 +1908,7 @@ const VideoPlayer = ({ movieId, backdropPath }: { movieId: string; backdropPath? } src={ selectedSource === 'primary' ? `https://frembed.click/api/film.php?id=${movieId}` : + selectedSource === 'peachify' ? `https://peachify.top/embed/movie/${movieId}?sub=French&accent=dc2626` : selectedSource === 'vostfr' ? `https://vidsrc.wtf/api/3/movie/?id=${movieId}` : selectedSource === 'videasy' ? `https://vidlink.pro/movie/${movieId}?primaryColor=0278fd&secondaryColor=a2a2a2&iconColor=eefdec&icons=default&player=default&title=true&poster=true&autoplay=true&nextbutton=false` : selectedSource === 'vidsrccc' ? `https://vidsrc.io/embed/movie?tmdb=${movieId}` : diff --git a/src/pages/TVDetails.tsx b/src/pages/TVDetails.tsx index 0441922..ec393e2 100644 --- a/src/pages/TVDetails.tsx +++ b/src/pages/TVDetails.tsx @@ -1181,7 +1181,7 @@ const VideoPlayer = forwardRef(({ showId const { t } = useTranslation(); const [videoSource, setVideoSource] = useState(null); const [customSources, setCustomSources] = useState([]); - const [selectedSource, setSelectedSource] = useState<'primary' | 'vostfr' | 'multi' | 'videasy' | 'vidsrccc' | 'vidsrcsu' | 'vidsrcwtf1' | 'vidsrcwtf5' | 'omega' | 'darkino' | 'mp4' | number | null>(null); // Ajout de 'mp4' + const [selectedSource, setSelectedSource] = useState<'primary' | 'peachify' | 'vostfr' | 'multi' | 'videasy' | 'vidsrccc' | 'vidsrcsu' | 'vidsrcwtf1' | 'vidsrcwtf5' | 'omega' | 'darkino' | 'mp4' | number | null>(null); // Ajout de 'mp4' const [frembedAvailable, setFrembedAvailable] = useState(true); const [, setIsLoading] = useState(true); const [coflixData, setCoflixData] = useState(null); @@ -1502,10 +1502,13 @@ const VideoPlayer = forwardRef(({ showId // Handled by HLSPlayer component return; } - switch (selectedSource as 'primary' | 'vostfr' | 'multi' | 'videasy' | 'vidsrccc' | 'vidsrcsu' | 'vidsrcwtf1' | 'vidsrcwtf5' | 'omega' | 'darkino' | 'mp4' | number) { + switch (selectedSource as 'primary' | 'peachify' | 'vostfr' | 'multi' | 'videasy' | 'vidsrccc' | 'vidsrcsu' | 'vidsrcwtf1' | 'vidsrcwtf5' | 'omega' | 'darkino' | 'mp4' | number) { case 'primary': newSrc = `https://frembed.click/api/serie.php?id=${showId}&sa=${seasonNumber}&epi=${episodeNumber}`; break; + case 'peachify': + newSrc = `https://peachify.top/embed/tv/${showId}/${seasonNumber}/${episodeNumber}?sub=French&accent=dc2626`; + break; case 'vostfr': newSrc = `https://vidsrc.wtf/api/3/tv/?id=${showId}&s=${seasonNumber}&e=${episodeNumber}`; break; @@ -1737,7 +1740,7 @@ const VideoPlayer = forwardRef(({ showId
+
)} diff --git a/src/services/blockDetection.ts b/src/services/blockDetection.ts index 758db85..7c949a6 100644 --- a/src/services/blockDetection.ts +++ b/src/services/blockDetection.ts @@ -13,7 +13,7 @@ import type { AxiosInstance } from 'axios'; * RESET_MS sans nouvelle erreur. */ -const THRESHOLD = 3; +const THRESHOLD = 5; const RESET_MS = 30_000; // Fenêtre de grâce après un retour de visibilité ou un event 'online'. // Quand le téléphone sort de veille, le SPA refire en cascade plusieurs @@ -21,8 +21,15 @@ const RESET_MS = 30_000; // n'est pas encore prête, elles fail toutes en burst < 1s — sans cette // grâce, ça franchit le THRESHOLD et trigger une fausse redirection. const RESUME_GRACE_MS = 8_000; +// Étalement minimum entre la première et la dernière erreur avant de +// considérer le compte comme un signal d'outage. Sinon : 5 erreurs en +// 200ms, c'est un seul blip réseau amplifié par les retry/parallel-fetch +// d'axios, pas 5 events séparés. Un VRAI blocage FAI persiste sur la +// durée — il franchira facilement cet étalement. +const MIN_ERROR_SPAN_MS = 2_000; let consecutiveNetworkErrors = 0; +let firstErrorAt = 0; let lastErrorAt = 0; let graceUntil = 0; let redirecting = false; @@ -75,6 +82,7 @@ function ensureMessageListener() { function resetOnResume() { consecutiveNetworkErrors = 0; + firstErrorAt = 0; graceUntil = Date.now() + RESUME_GRACE_MS; } @@ -112,10 +120,19 @@ export function registerBlockDetection(axiosInstance: AxiosInstance): void { // de confirmation s'il reçoit un trigger, mais autant ne pas // spammer la chaîne en amont. if (now < graceUntil) return Promise.reject(error); - if (now - lastErrorAt > RESET_MS) consecutiveNetworkErrors = 0; + if (now - lastErrorAt > RESET_MS) { + consecutiveNetworkErrors = 0; + firstErrorAt = 0; + } + if (consecutiveNetworkErrors === 0) firstErrorAt = now; lastErrorAt = now; consecutiveNetworkErrors += 1; - if (consecutiveNetworkErrors >= THRESHOLD) { + // Double-condition : compteur ET étalement temporel. Évite qu'un + // burst sub-seconde de retry axios paralleles franchisse le seuil. + if ( + consecutiveNetworkErrors >= THRESHOLD && + now - firstErrorAt >= MIN_ERROR_SPAN_MS + ) { triggerRedirect(); } } diff --git a/src/utils/dnsErrorDetection.ts b/src/utils/dnsErrorDetection.ts index 24160b1..864a57c 100644 --- a/src/utils/dnsErrorDetection.ts +++ b/src/utils/dnsErrorDetection.ts @@ -13,8 +13,15 @@ export interface HlsErrorLike { error?: Error | null; } +// Regex resserrée : on ne garde que les marqueurs réellement spécifiques à +// une résolution DNS qui échoue. Les anciennes entrées (`Failed to fetch`, +// `NetworkError`, `ERR_CONNECTION_REFUSED`, `ERR_INTERNET_DISCONNECTED`, +// `ERR_ADDRESS_UNREACHABLE`) matchaient aussi pour : CORS, source morte, +// timeout, proxy down, port fermé — résultat : la popup "FAI bloque" sortait +// dès qu'une source vidéo tombait. On veut ne signaler le FAI qu'avec un +// signal vraiment DNS. const DNS_LIKE_MESSAGE_RE = - /ERR_NAME_NOT_RESOLVED|Failed to fetch|NetworkError|ERR_CONNECTION_REFUSED|ERR_INTERNET_DISCONNECTED|ERR_ADDRESS_UNREACHABLE/i; + /ERR_NAME_NOT_RESOLVED|DNS_PROBE_FINISHED_NXDOMAIN|getaddrinfo ENOTFOUND/i; const MANIFEST_OR_LEVEL_DETAILS = new Set([ 'manifestLoadError', @@ -38,15 +45,13 @@ export function isDnsLikeError( MANIFEST_OR_LEVEL_DETAILS.has(data.details); if (isManifestOrLevelError) { - const responseCode = data.response?.code; - const networkStatus = data.networkDetails?.status; const reason = data.reason ?? data.error?.message ?? ''; - - if (responseCode === 0 || responseCode === undefined) { - if (networkStatus === 0 || networkStatus === undefined) { - return true; - } - } + // Avant : retournait true dès que responseCode/networkStatus étaient 0 + // ou undefined — i.e. pour TOUTE erreur réseau sans réponse HTTP. C'était + // la cause principale des faux positifs (CORS, source morte, timeout + // étaient tous classés "FAI bloque"). On exige maintenant un marqueur + // DNS explicite dans le message d'erreur. Le faux-positif résiduel est + // attrapé par la gate de reachability dans `notifyDnsBlocked`. if (DNS_LIKE_MESSAGE_RE.test(reason)) { return true; } @@ -64,14 +69,80 @@ export function isDnsLikeError( return false; } +// ============================================================================ +// Reachability probe — gate de confirmation pour la popup +// ============================================================================ + +const ORIGIN_PROBE_PATH = '/movix.png'; +const ORIGIN_PROBE_TIMEOUT_MS = 4000; +// Cache du résultat : pendant un burst d'erreurs vidéo, on ne refait pas un +// probe pour chaque erreur. 30s = window assez longue pour absorber une +// rafale, assez courte pour redétecter rapidement un blocage qui démarre. +const PROBE_CACHE_MS = 30_000; + +let probeInFlight: Promise | null = null; +let lastProbeAt = 0; +let lastProbeResult: boolean | null = null; + +async function probeOrigin(): Promise { + if (typeof window === 'undefined') return false; + if (typeof navigator !== 'undefined' && navigator.onLine === false) return false; + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), ORIGIN_PROBE_TIMEOUT_MS); + const url = new URL( + `${ORIGIN_PROBE_PATH}?_dnsprobe=${Date.now()}`, + window.location.origin + ).href; + const res = await fetch(url, { + method: 'HEAD', + cache: 'no-store', + signal: controller.signal, + credentials: 'omit', + }); + clearTimeout(timer); + // 2xx/3xx/4xx = origine joignable (même un 404 confirme le serveur). Seul + // un échec réseau ou un 5xx massif compte comme injoignable. + return res.status < 500; + } catch { + return false; + } +} + +async function checkOriginReachable(): Promise { + const now = Date.now(); + if (lastProbeResult !== null && now - lastProbeAt < PROBE_CACHE_MS) { + return lastProbeResult; + } + if (probeInFlight) return probeInFlight; + probeInFlight = probeOrigin(); + try { + const result = await probeInFlight; + lastProbeResult = result; + lastProbeAt = Date.now(); + return result; + } finally { + probeInFlight = null; + } +} + // Déclencheur global. Idempotent : dispatch plusieurs fois est safe, le // banner gère lui-même le "1 fois par chargement". // `switched` indique si on a réussi à basculer automatiquement sur un embed // — le banner s'en sert pour afficher le message "on a changé de lecteur // pour toi automatiquement" uniquement quand c'est vrai. +// +// Gate : on ne dispatch QUE si l'origine Movix est elle-même injoignable. Si +// on arrive à charger /movix.png, l'erreur vidéo vient du host vidéo tiers +// (source morte, CORS, proxy down…) et pas d'un blocage FAI sur Movix — +// montrer "ton FAI bloque ce lecteur" serait un faux positif. export function notifyDnsBlocked( detail: { host?: string; details?: string; switched?: boolean } ): void { if (typeof window === 'undefined') return; - window.dispatchEvent(new CustomEvent('movix:dns-blocked', { detail })); + void (async () => { + const reachable = await checkOriginReachable(); + if (reachable) return; + window.dispatchEvent(new CustomEvent('movix:dns-blocked', { detail })); + })(); } diff --git a/src/utils/syncStorage.ts b/src/utils/syncStorage.ts index ed58d4c..1c35866 100644 --- a/src/utils/syncStorage.ts +++ b/src/utils/syncStorage.ts @@ -45,7 +45,14 @@ const SYNCABLE_PREFIXES = [ 'watched_', ]; +// Persistent outbox of pending sync ops. Survives refresh so a user write that +// didn't reach the server before unload (Firefox keepalive flake, network +// hiccup, browser crash) is replayed on next boot before loadProfileData wipes +// localStorage. See ProfileContext.replayOutboxIfAny. +export const SYNC_OUTBOX_STORAGE_KEY = '__movix_sync_outbox'; + const BLOCKED_SYNC_KEYS = new Set([ + SYNC_OUTBOX_STORAGE_KEY, 'access_token', 'auth', 'auth_method', @@ -81,6 +88,7 @@ const BLOCKED_SYNC_KEYS = new Set([ ]); const PROFILE_LOAD_PRESERVED_KEYS = new Set([ + SYNC_OUTBOX_STORAGE_KEY, 'access_token', 'auth', 'auth_method', diff --git a/userscript/movix.user.js b/userscript/movix.user.js index be23d8b..995a0db 100644 --- a/userscript/movix.user.js +++ b/userscript/movix.user.js @@ -2370,9 +2370,9 @@ // --- BEGIN extension/Chrome/background.js --- const VAVOO_BASE_URL = "https://tvvoo.hayd.uk/cfg-fr"; const WITV_BASE_URL = "https://witv.team"; - const SOSPLAY_BASE_URL = "https://ligue1live.xyz"; - const LIVETV_BASE_URL = "https://livetv876.me/frx/"; - const LIVETV_EMBED_ORIGIN = "https://livetv876.me"; + const SOSPLAY_BASE_URL = "https://streamonsport.art"; + const LIVETV_BASE_URL = "https://livetv882.me/frx/"; + const LIVETV_EMBED_ORIGIN = "https://livetv882.me"; const LIVETV_EMBED_REFERER = LIVETV_BASE_URL; // Backend API URL for got-scraping based extraction const API_BASE_URL = "https://api.movix.cash"; @@ -2390,11 +2390,21 @@ const DEFAULT_EXTRACTION_PREFS = { version: 1, m3u8: { - voe: true, fsvid: true, vidzy: true, vidmoly: true, - sibnet: true, uqload: true, doodstream: true, seekstreaming: true, + voe: true, + fsvid: true, + vidzy: true, + vidmoly: true, + sibnet: true, + uqload: true, + doodstream: true, + seekstreaming: true, }, livetv: { - linkzy: true, wiflix: true, sosplay: true, livetv: true, matches: true, + linkzy: true, + wiflix: true, + sosplay: true, + livetv: true, + matches: true, }, }; let extractionPrefs = DEFAULT_EXTRACTION_PREFS; @@ -2407,18 +2417,39 @@ extractions: 0, corsFixed: 0, cached: 0, - byType: { voe: 0, fsvid: 0, vidzy: 0, vidmoly: 0, sibnet: 0, uqload: 0, doodstream: 0, seekstreaming: 0 }, + byType: { + voe: 0, + fsvid: 0, + vidzy: 0, + vidmoly: 0, + sibnet: 0, + uqload: 0, + doodstream: 0, + seekstreaming: 0, + }, }; // Load initial state (async () => { - const storedEnabled = await gmGetValueCompat("movix_extensionEnabled", null); + const storedEnabled = await gmGetValueCompat( + "movix_extensionEnabled", + null, + ); if (storedEnabled !== null) extensionEnabled = storedEnabled !== false; const storedStats = await gmGetValueCompat("movix_stats", null); if (storedStats) { sessionStats = { ...sessionStats, ...storedStats }; if (!sessionStats.byType) { - sessionStats.byType = { voe: 0, fsvid: 0, vidzy: 0, vidmoly: 0, sibnet: 0, uqload: 0, doodstream: 0, seekstreaming: 0 }; + sessionStats.byType = { + voe: 0, + fsvid: 0, + vidzy: 0, + vidmoly: 0, + sibnet: 0, + uqload: 0, + doodstream: 0, + seekstreaming: 0, + }; } } const storedPrefs = await gmGetValueCompat("movix_extraction_prefs", null); @@ -2437,7 +2468,16 @@ extractions: 0, corsFixed: 0, cached: 0, - byType: { voe: 0, fsvid: 0, vidzy: 0, vidmoly: 0, sibnet: 0, uqload: 0, doodstream: 0, seekstreaming: 0 }, + byType: { + voe: 0, + fsvid: 0, + vidzy: 0, + vidmoly: 0, + sibnet: 0, + uqload: 0, + doodstream: 0, + seekstreaming: 0, + }, }; gmSetValueCompat("movix_stats", sessionStats); }); @@ -2481,17 +2521,17 @@ }, condition: { urlFilter: "*", - initiatorDomains: [ - "localhost", - "127.0.0.1", - "movix.cash", - "movix.club", - "movix.cash", - ], - resourceTypes: [ - "xmlhttprequest", - "other", - "media", + initiatorDomains: [ + "localhost", + "127.0.0.1", + "movix.cash", + "movix.club", + "movix.cash", + ], + resourceTypes: [ + "xmlhttprequest", + "other", + "media", "image", "script", "stylesheet", @@ -2512,12 +2552,12 @@ * Returns null if unknown — unknown keys are allowed by default. */ function getLiveTvSourceKey(catalogId) { - if (!catalogId || typeof catalogId !== 'string') return null; - if (catalogId.startsWith('linkzy_')) return 'linkzy'; - if (catalogId.startsWith('wiflix_')) return 'wiflix'; - if (catalogId.startsWith('sosplay_')) return 'sosplay'; - if (catalogId.startsWith('livetv_')) return 'livetv'; - if (catalogId.startsWith('matches_')) return 'matches'; + if (!catalogId || typeof catalogId !== "string") return null; + if (catalogId.startsWith("linkzy_")) return "linkzy"; + if (catalogId.startsWith("wiflix_")) return "wiflix"; + if (catalogId.startsWith("sosplay_")) return "sosplay"; + if (catalogId.startsWith("livetv_")) return "livetv"; + if (catalogId.startsWith("matches_")) return "matches"; return null; } @@ -2576,16 +2616,19 @@ case "GET_MANIFEST": return await getManifest(); case "GET_CATALOG": { - const catalogId = payload?.id || ''; + const catalogId = payload?.id || ""; if (!isLiveTvAllowed(catalogId)) { return { metas: [], disabled_by_user: true }; } return await getCatalog(payload.type, payload.id, payload?.accessKey); } case "GET_STREAM": { - const channelId = payload?.id || ''; + const channelId = payload?.id || ""; if (!isLiveTvAllowed(channelId)) { - return { error: "disabled_by_user", source: getLiveTvSourceKey(channelId) }; + return { + error: "disabled_by_user", + source: getLiveTvSourceKey(channelId), + }; } return await getStream( payload.type, @@ -2600,26 +2643,43 @@ // === Nexus M3U8 Extraction (runs locally in extension, no server needed) === case "EXTRACT_M3U8": { const { url: embedUrl, type: hintedType } = payload || {}; - const detectedType = hintedType || (Extractors.detectEmbedType ? Extractors.detectEmbedType(embedUrl) : null); + const detectedType = + hintedType || + (Extractors.detectEmbedType + ? Extractors.detectEmbedType(embedUrl) + : null); if (detectedType && !isEmbedAllowed(detectedType)) { - return { success: false, error: "disabled_by_user", type: detectedType }; + return { + success: false, + error: "disabled_by_user", + type: detectedType, + }; } sessionStats.extractions++; if (detectedType && sessionStats.byType) { - sessionStats.byType[detectedType] = (sessionStats.byType[detectedType] || 0) + 1; + sessionStats.byType[detectedType] = + (sessionStats.byType[detectedType] || 0) + 1; } await gmSetValueCompat("movix_stats", sessionStats); return await handleExtractM3u8(payload); } case "EXTRACT_ALL_M3U8": { const filteredSources = (payload?.sources || []).filter((source) => { - const srcUrl = typeof source === 'string' ? source : (source?.link || source?.url || ''); - const srcType = Extractors.detectEmbedType ? Extractors.detectEmbedType(srcUrl) : null; + const srcUrl = + typeof source === "string" + ? source + : source?.link || source?.url || ""; + const srcType = Extractors.detectEmbedType + ? Extractors.detectEmbedType(srcUrl) + : null; return !srcType || isEmbedAllowed(srcType); }); sessionStats.extractions++; await gmSetValueCompat("movix_stats", sessionStats); - return await handleExtractAllM3u8({ ...payload, sources: filteredSources }); + return await handleExtractAllM3u8({ + ...payload, + sources: filteredSources, + }); } case "DETECT_EMBEDS": return handleDetectEmbeds(payload); @@ -2641,7 +2701,12 @@ case "SET_EXTRACTION_PREFS": { const incoming = payload?.prefs; - if (incoming && incoming.version === 1 && incoming.m3u8 && incoming.livetv) { + if ( + incoming && + incoming.version === 1 && + incoming.m3u8 && + incoming.livetv + ) { extractionPrefs = { version: 1, m3u8: { ...DEFAULT_EXTRACTION_PREFS.m3u8, ...incoming.m3u8 }, @@ -2657,14 +2722,14 @@ return extractionPrefs; case "GET_CACHE_STATS": { - if (typeof Extractors.getCacheSizes === 'function') { + if (typeof Extractors.getCacheSizes === "function") { return Extractors.getCacheSizes(); } return {}; } case "CLEAR_EXTRACTION_CACHE": { - if (typeof Extractors.clearCaches === 'function') { + if (typeof Extractors.clearCaches === "function") { Extractors.clearCaches(payload?.type); return { success: true }; } @@ -2841,36 +2906,36 @@ return { embeds: Extractors.detectSupportedEmbeds(sources) }; } -// === API LOGIC === + // === API LOGIC === -function getMovixFrontendOrigin() { - try { - const currentOrigin = pageWindow.location?.origin; - const currentHostname = pageWindow.location?.hostname ?? ""; + function getMovixFrontendOrigin() { + try { + const currentOrigin = pageWindow.location?.origin; + const currentHostname = pageWindow.location?.hostname ?? ""; - if ( - currentHostname === "movix.cash" || - currentHostname.endsWith(".movix.cash") || - currentHostname === "movix.club" || - currentHostname.endsWith(".movix.club") || - currentHostname === "movix.cash" || - currentHostname.endsWith(".movix.cash") - ) { - return (currentOrigin || "https://movix.cash").replace(/\/$/, ""); - } - } catch {} + if ( + currentHostname === "movix.cash" || + currentHostname.endsWith(".movix.cash") || + currentHostname === "movix.club" || + currentHostname.endsWith(".movix.club") || + currentHostname === "movix.cash" || + currentHostname.endsWith(".movix.cash") + ) { + return (currentOrigin || "https://movix.cash").replace(/\/$/, ""); + } + } catch {} - return "https://movix.cash"; -} + return "https://movix.cash"; + } -function buildBackendApiHeaders(accessKey, extraHeaders = {}) { - const frontendOrigin = getMovixFrontendOrigin(); - const headers = { - Accept: "application/json", - Origin: frontendOrigin, - Referer: `${frontendOrigin}/`, - ...extraHeaders, - }; + function buildBackendApiHeaders(accessKey, extraHeaders = {}) { + const frontendOrigin = getMovixFrontendOrigin(); + const headers = { + Accept: "application/json", + Origin: frontendOrigin, + Referer: `${frontendOrigin}/`, + ...extraHeaders, + }; if (accessKey) { headers["x-access-key"] = accessKey; @@ -3955,7 +4020,9 @@ function buildBackendApiHeaders(accessKey, extraHeaders = {}) { console.log( `[LIVETV] Resolved ${uniqueStreams.length} direct stream(s) and ${uniqueEmbeds.length} embed(s) locally for ${channelId}`, ); - return { streams: uniqueStreams.length > 0 ? uniqueStreams : uniqueEmbeds }; + return { + streams: uniqueStreams.length > 0 ? uniqueStreams : uniqueEmbeds, + }; } console.log( @@ -4227,7 +4294,7 @@ function buildBackendApiHeaders(accessKey, extraHeaders = {}) { const combined = `${hostname}${pathname}${search}`; if ( - hostname === "ads.livetv876.me" || + hostname === "ads.livetv882.me" || hostname.startsWith("ads.") || hostname.startsWith("ad.") ) { @@ -4274,7 +4341,9 @@ function buildBackendApiHeaders(accessKey, extraHeaders = {}) { return false; } - return hostname !== fallbackHost && !hostname.endsWith(`.${fallbackHost}`); + return ( + hostname !== fallbackHost && !hostname.endsWith(`.${fallbackHost}`) + ); } catch (error) { return false; }