mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-07-27 00:52:08 +00:00
Majs de urls + corrections scrapers + nouvelle source
Maj url dans l'app mobile, extension et userscript et backend : streamonsport, livetv. Amélioration du scraper darkiworld/hydracker Nouvelle source vo/vostfr qui est plutôt pas mal Renommages des sources vo/vostfr avec le nom du doamine pour mieux se repérer Correction de la synchronisation des données qui étais buggé sur certains navigateurs autre que chrome sur certaines versions Amélioration de la détection du blocage dns
This commit is contained in:
parent
02d8361d06
commit
7059124b24
23 changed files with 1043 additions and 228 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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: <marker> } — 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
|
||||
|
|
|
|||
|
|
@ -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}\""
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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": ""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
) {
|
||||
|
|
|
|||
219
src/App.tsx
219
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<string, unknown> }).__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<string, unknown> }).__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<string, string | null>();
|
||||
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<Record<string, unknown>> = 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<Record<string, unknown>>),
|
||||
...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
|
||||
|
||||
|
|
|
|||
|
|
@ -2129,10 +2129,11 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({
|
|||
// 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<HLSPlayerRef, HLSPlayerProps>(({
|
|||
// 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<HLSPlayerRef, HLSPlayerProps>(({
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<SyncOutboxPayload>;
|
||||
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<void> {
|
||||
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<ProfileContextType | undefined>(undefined);
|
||||
|
|
@ -259,6 +366,13 @@ export const ProfileProvider: React.FC<ProfileProviderProps> = ({ 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}` }
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="self-start text-xs sm:text-sm text-blue-400 hover:text-blue-300 underline underline-offset-2 transition-colors mt-1"
|
||||
>
|
||||
{t('download.rateLimitedLearnMore')}
|
||||
</button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('download.rateLimitInfoTitle')}</DialogTitle>
|
||||
<DialogDescription className="text-sm sm:text-base text-gray-300 leading-relaxed pt-2">
|
||||
{t('download.rateLimitInfoBody')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// 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<{
|
|||
<Loader className="w-6 h-6 sm:w-8 sm:h-8 animate-spin text-blue-500" />
|
||||
<span className="ml-2 text-white text-sm sm:text-base">{t('download.decoding')}</span>
|
||||
</div>
|
||||
{queueInfo != null && (
|
||||
<span className="text-gray-400 text-xs sm:text-sm">{t('download.queueWaiting', { size: queueInfo.size })}</span>
|
||||
)}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center text-red-400 text-sm sm:text-base">
|
||||
<AlertCircle className="w-4 h-4 sm:w-5 sm:h-5 mr-2 flex-shrink-0" />
|
||||
<span className="break-words">{error}</span>
|
||||
<div className="flex flex-col text-red-400 text-sm sm:text-base">
|
||||
<div className="flex items-center">
|
||||
<AlertCircle className="w-4 h-4 sm:w-5 sm:h-5 mr-2 flex-shrink-0" />
|
||||
<span className="break-words">{error}</span>
|
||||
</div>
|
||||
<RateLimitInfoButton error={error} />
|
||||
</div>
|
||||
) : decodedLink ? (
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
|
|
@ -839,6 +880,7 @@ const DownloadPage: React.FC = () => {
|
|||
const [selectedLink, setSelectedLink] = useState<DownloadLink | null>(null);
|
||||
const [isDecoding, setIsDecoding] = useState(false);
|
||||
const [decodedLink, setDecodedLink] = useState<DecodedLink | null>(null);
|
||||
const [queueInfo, setQueueInfo] = useState<{ size: number } | null>(null);
|
||||
const [showAdPopup, setShowAdPopup] = useState(false);
|
||||
const [pendingLinkToDecode, setPendingLinkToDecode] = useState<DownloadLink | null>(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<string, string> = {};
|
||||
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 && (
|
||||
<div className="mt-4 p-3 sm:p-4 bg-red-900/20 border border-red-500/50 rounded-xl backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2 text-red-400 text-sm sm:text-base">
|
||||
<AlertCircle className="w-4 h-4 sm:w-5 sm:h-5 flex-shrink-0" />
|
||||
<span className="break-words">{error}</span>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 text-red-400 text-sm sm:text-base">
|
||||
<AlertCircle className="w-4 h-4 sm:w-5 sm:h-5 flex-shrink-0" />
|
||||
<span className="break-words">{error}</span>
|
||||
</div>
|
||||
<RateLimitInfoButton error={error} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1718,6 +1810,7 @@ const DownloadPage: React.FC = () => {
|
|||
isDecoding={isDecoding}
|
||||
decodedLink={decodedLink}
|
||||
error={error}
|
||||
queueInfo={queueInfo}
|
||||
/>
|
||||
{showAdPopup && (
|
||||
<AdFreePlayerAds
|
||||
|
|
|
|||
|
|
@ -996,7 +996,7 @@ const VideoPlayer = ({ movieId, backdropPath }: { movieId: string; backdropPath?
|
|||
const [videoSource, setVideoSource] = useState<string | null>(null);
|
||||
const [customSources, setCustomSources] = useState<string[]>([]);
|
||||
// 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<PlayerSourceType | null>(null);
|
||||
const [frembedAvailable, setFrembedAvailable] = useState(true);
|
||||
const [adFreeSource, setAdFreeSource] = useState<string | null>(null);
|
||||
|
|
@ -1548,7 +1548,7 @@ const VideoPlayer = ({ movieId, backdropPath }: { movieId: string; backdropPath?
|
|||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowVostfrOptions(!showVostfrOptions)}
|
||||
className={`px-4 py-2 rounded flex items-center gap-2 ${(selectedSource === 'vostfr' || selectedSource === 'videasy' || selectedSource === 'vidsrccc' || selectedSource === 'vidsrcsu' || selectedSource === 'vidsrcwtf1' || selectedSource === 'vidsrcwtf5')
|
||||
className={`px-4 py-2 rounded flex items-center gap-2 ${(selectedSource === 'peachify' || selectedSource === 'vostfr' || selectedSource === 'videasy' || selectedSource === 'vidsrccc' || selectedSource === 'vidsrcsu' || selectedSource === 'vidsrcwtf1' || selectedSource === 'vidsrcwtf5')
|
||||
? 'bg-red-600 text-white'
|
||||
: 'bg-gray-700 hover:bg-gray-600'
|
||||
}`}
|
||||
|
|
@ -1567,12 +1567,13 @@ const VideoPlayer = ({ movieId, backdropPath }: { movieId: string; backdropPath?
|
|||
|
||||
{showVostfrOptions && (
|
||||
<div className="absolute z-50 top-full left-0 mt-1 bg-gray-800 rounded-lg shadow-lg overflow-hidden min-w-[200px]">
|
||||
<button onClick={() => handleSelectSource('vostfr')} className={`w-full px-4 py-2 text-left ${selectedSource === 'vostfr' ? 'bg-red-600/70 text-white' : 'hover:bg-gray-700'}`}>{t('details.playerVOSTFR', { number: 1 })}</button>
|
||||
<button onClick={() => handleSelectSource('videasy')} className={`w-full px-4 py-2 text-left ${selectedSource === 'videasy' ? 'bg-red-600/70 text-white' : 'hover:bg-gray-700'}`}>{t('details.playerVOSTFR', { number: 2 })}</button>
|
||||
<button onClick={() => handleSelectSource('vidsrccc')} className={`w-full px-4 py-2 text-left ${selectedSource === 'vidsrccc' ? 'bg-red-600/70 text-white' : 'hover:bg-gray-700'}`}>{t('details.playerVOSTFR', { number: 3 })}</button>
|
||||
<button onClick={() => handleSelectSource('vidsrcsu')} className={`w-full px-4 py-2 text-left ${selectedSource === 'vidsrcsu' ? 'bg-red-600/70 text-white' : 'hover:bg-gray-700'}`}>{t('details.playerVOSTFR', { number: 4 })}</button>
|
||||
<button onClick={() => handleSelectSource('vidsrcwtf1')} className={`w-full px-4 py-2 text-left ${selectedSource === 'vidsrcwtf1' ? 'bg-red-600/70 text-white' : 'hover:bg-gray-700'}`}>{t('details.playerVOSTFR', { number: 5 })}</button>
|
||||
<button onClick={() => handleSelectSource('vidsrcwtf5')} className={`w-full px-4 py-2 text-left ${selectedSource === 'vidsrcwtf5' ? 'bg-red-600/70 text-white' : 'hover:bg-gray-700'}`}>{t('details.playerVOSTFR', { number: 6 })}</button>
|
||||
<button onClick={() => handleSelectSource('peachify')} className={`w-full px-4 py-2 text-left ${selectedSource === 'peachify' ? 'bg-red-600/70 text-white' : 'hover:bg-gray-700'}`}>Peachify</button>
|
||||
<button onClick={() => handleSelectSource('vostfr')} className={`w-full px-4 py-2 text-left ${selectedSource === 'vostfr' ? 'bg-red-600/70 text-white' : 'hover:bg-gray-700'}`}>Vidsrc.wtf 3</button>
|
||||
<button onClick={() => handleSelectSource('videasy')} className={`w-full px-4 py-2 text-left ${selectedSource === 'videasy' ? 'bg-red-600/70 text-white' : 'hover:bg-gray-700'}`}>Vidlink</button>
|
||||
<button onClick={() => handleSelectSource('vidsrccc')} className={`w-full px-4 py-2 text-left ${selectedSource === 'vidsrccc' ? 'bg-red-600/70 text-white' : 'hover:bg-gray-700'}`}>Vidsrc.io</button>
|
||||
<button onClick={() => handleSelectSource('vidsrcsu')} className={`w-full px-4 py-2 text-left ${selectedSource === 'vidsrcsu' ? 'bg-red-600/70 text-white' : 'hover:bg-gray-700'}`}>Vidsrc.su</button>
|
||||
<button onClick={() => handleSelectSource('vidsrcwtf1')} className={`w-full px-4 py-2 text-left ${selectedSource === 'vidsrcwtf1' ? 'bg-red-600/70 text-white' : 'hover:bg-gray-700'}`}>Vidsrc.wtf 1</button>
|
||||
<button onClick={() => handleSelectSource('vidsrcwtf5')} className={`w-full px-4 py-2 text-left ${selectedSource === 'vidsrcwtf5' ? 'bg-red-600/70 text-white' : 'hover:bg-gray-700'}`}>Vidsrc.wtf 5</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -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}` :
|
||||
|
|
|
|||
|
|
@ -1181,7 +1181,7 @@ const VideoPlayer = forwardRef<VideoPlayerRefHandle, VideoPlayerProps>(({ showId
|
|||
const { t } = useTranslation();
|
||||
const [videoSource, setVideoSource] = useState<string | null>(null);
|
||||
const [customSources, setCustomSources] = useState<string[]>([]);
|
||||
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<boolean>(true);
|
||||
const [, setIsLoading] = useState(true);
|
||||
const [coflixData, setCoflixData] = useState<CoflixResponse | null>(null);
|
||||
|
|
@ -1502,10 +1502,13 @@ const VideoPlayer = forwardRef<VideoPlayerRefHandle, VideoPlayerProps>(({ 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<VideoPlayerRefHandle, VideoPlayerProps>(({ showId
|
|||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowVostfrOptions(!showVostfrOptions)}
|
||||
className={`px-4 py-2 rounded flex items-center gap-2 ${(selectedSource === 'vostfr' || selectedSource === 'videasy' || selectedSource === 'vidsrccc' || selectedSource === 'vidsrcsu' || selectedSource === 'vidsrcwtf1' || selectedSource === 'vidsrcwtf5')
|
||||
className={`px-4 py-2 rounded flex items-center gap-2 ${(selectedSource === 'peachify' || selectedSource === 'vostfr' || selectedSource === 'videasy' || selectedSource === 'vidsrccc' || selectedSource === 'vidsrcsu' || selectedSource === 'vidsrcwtf1' || selectedSource === 'vidsrcwtf5')
|
||||
? 'bg-red-600 text-white'
|
||||
: 'bg-gray-700 hover:bg-gray-600'
|
||||
}`}
|
||||
|
|
@ -1756,6 +1759,16 @@ const VideoPlayer = forwardRef<VideoPlayerRefHandle, VideoPlayerProps>(({ showId
|
|||
|
||||
{showVostfrOptions && (
|
||||
<div className="absolute z-50 top-full left-0 mt-1 bg-gray-800 rounded-lg shadow-lg overflow-hidden min-w-[200px]">
|
||||
<button
|
||||
onClick={() => handleSelectSource('peachify')}
|
||||
className={`w-full px-4 py-2 text-left ${selectedSource === 'peachify'
|
||||
? 'bg-red-600/70 text-white'
|
||||
: 'hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
Peachify
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleSelectSource('vostfr')}
|
||||
className={`w-full px-4 py-2 text-left ${selectedSource === 'vostfr'
|
||||
|
|
@ -1763,7 +1776,7 @@ const VideoPlayer = forwardRef<VideoPlayerRefHandle, VideoPlayerProps>(({ showId
|
|||
: 'hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{t('details.playerVOSTFR', { number: 1 })}
|
||||
Vidsrc.wtf 3
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
|
@ -1773,7 +1786,7 @@ const VideoPlayer = forwardRef<VideoPlayerRefHandle, VideoPlayerProps>(({ showId
|
|||
: 'hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{t('details.playerVOSTFR', { number: 2 })}
|
||||
Vidlink
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
|
@ -1783,7 +1796,7 @@ const VideoPlayer = forwardRef<VideoPlayerRefHandle, VideoPlayerProps>(({ showId
|
|||
: 'hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{t('details.playerVOSTFR', { number: 3 })}
|
||||
Vidsrc.io
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
|
@ -1793,7 +1806,7 @@ const VideoPlayer = forwardRef<VideoPlayerRefHandle, VideoPlayerProps>(({ showId
|
|||
: 'hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{t('details.playerVOSTFR', { number: 4 })}
|
||||
Vidsrc.su
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
|
@ -1803,7 +1816,7 @@ const VideoPlayer = forwardRef<VideoPlayerRefHandle, VideoPlayerProps>(({ showId
|
|||
: 'hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{t('details.playerVOSTFR', { number: 5 })}
|
||||
Vidsrc.wtf 1
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
|
@ -1813,7 +1826,7 @@ const VideoPlayer = forwardRef<VideoPlayerRefHandle, VideoPlayerProps>(({ showId
|
|||
: 'hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{t('details.playerVOSTFR', { number: 6 })}
|
||||
Vidsrc.wtf 5
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<boolean> | null = null;
|
||||
let lastProbeAt = 0;
|
||||
let lastProbeResult: boolean | null = null;
|
||||
|
||||
async function probeOrigin(): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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 }));
|
||||
})();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue