mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-08-06 19:08:48 +00:00
Merge depuis le repo privé après tests
Corrections de vulnérabilités sur les commentaires (IDOR) Rajouts de ratelimit sur les commentaires Correction de pas de son sur hlsplayer Livetvplayer détecte le type du flux automatiquement Amélioration du scraper darkiworld (très chiant) Correction du détection de l'extension Correction de la vérification du VIP sur server.py (proxy) Correction du scraper seekstreaming proxy Nouvelle url streamonsport et coflix Correction du scraper fstream (get seasons) car la route fonctionne plus sur leur site Correction du scraper francetv Ajout de hydrackerBatch (c'étais pour des tests)
This commit is contained in:
parent
ac0e29bbb4
commit
cbc3f1dad9
25 changed files with 1781 additions and 484 deletions
|
|
@ -31,6 +31,8 @@ const {
|
|||
shouldUpdateCacheFrenchStream,
|
||||
shouldUpdateCacheLecteurVideo,
|
||||
shouldUpdateCache24h,
|
||||
shouldUpdateCache48h,
|
||||
generateCacheKey,
|
||||
CACHE_DIR,
|
||||
} = require("./utils/cacheManager");
|
||||
|
||||
|
|
@ -64,6 +66,17 @@ const DARKIWORLD_BASE_URL = normalizeBaseUrl(
|
|||
const cookieJar = new tough.CookieJar();
|
||||
|
||||
// === Darkino session & headers setup ===
|
||||
// UA + client hints stables (Chrome/Brave 148 Windows). darkiworld a une
|
||||
// limite de session par client, donc TOUTES les requêtes (refresh `/`,
|
||||
// /api/v1/titles/.../content/liens, /api/v1/download-premium/...,
|
||||
// seasons/episodes) doivent partager exactement ce fingerprint pour rester
|
||||
// sur une seule session côté upstream.
|
||||
//
|
||||
// Cookies + x-xsrf-token : viennent de l'env (DARKIWORLD_COOKIES /
|
||||
// DARKIWORLD_XSRF_TOKEN). cf_clearance volontairement absent de l'env :
|
||||
// Cloudflare le renouvelle régulièrement, le set-cookie de réponse arrive
|
||||
// dans le tough-cookie jar et `mergeCookieHeaders(jarState, configured)`
|
||||
// ajoute les cookies du jar absents de la string env sans écraser ceux fixés.
|
||||
const darkiHeaders = {
|
||||
accept: "application/json",
|
||||
"accept-encoding": "gzip, deflate, br",
|
||||
|
|
@ -72,18 +85,31 @@ const darkiHeaders = {
|
|||
cookie: process.env.DARKIWORLD_COOKIES || "",
|
||||
pragma: "no-cache",
|
||||
priority: "u=1, i",
|
||||
"sec-ch-ua":
|
||||
'"Chromium";v="148", "Brave";v="148", "Not/A)Brand";v="99"',
|
||||
"sec-ch-ua-arch": '"x86"',
|
||||
"sec-ch-ua-bitness": '"64"',
|
||||
"sec-ch-ua-full-version-list":
|
||||
'"Chromium";v="148.0.0.0", "Brave";v="148.0.0.0", "Not/A)Brand";v="99.0.0.0"',
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-model": '""',
|
||||
"sec-ch-ua-platform": '"Windows"',
|
||||
"sec-ch-ua-platform-version": '"19.0.0"',
|
||||
"sec-fetch-dest": "empty",
|
||||
"sec-fetch-mode": "cors",
|
||||
"sec-fetch-site": "same-origin",
|
||||
"sec-gpc": "1",
|
||||
"user-agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36",
|
||||
"x-xsrf-token": process.env.DARKIWORLD_XSRF_TOKEN || "",
|
||||
};
|
||||
|
||||
// Coflix config
|
||||
const COFLIX_BASE_URL = "https://coflix.click";
|
||||
const COFLIX_BASE_URL = "https://coflix.date";
|
||||
const coflixHeaders = {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
|
||||
Referer: "https://coflix.click",
|
||||
Referer: "https://coflix.date",
|
||||
};
|
||||
|
||||
// === Axios instances for each source ===
|
||||
|
|
@ -132,16 +158,32 @@ const axiosFStream = axios.create({
|
|||
decompress: true,
|
||||
});
|
||||
|
||||
// Darkino session refresh
|
||||
let lastDarkinoHomeRequest = 0;
|
||||
const DARKINO_SESSION_REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
// Darkino session refresh — coordonné via Redis pour qu'un SEUL worker du
|
||||
// cluster pinge l'upstream toutes les 10 minutes (et pas N workers en parallèle,
|
||||
// ce qui ferait exploser la limite de session côté darkiworld).
|
||||
const DARKINO_SESSION_REFRESH_INTERVAL = 10 * 60 * 1000; // 10 minutes
|
||||
const DARKINO_REFRESH_LAST_KEY = "darkino:lastRefreshAt";
|
||||
const DARKINO_REFRESH_LOCK_KEY = "darkino:refreshLock";
|
||||
const DARKINO_REFRESH_LOCK_TTL_MS = 30 * 1000; // filet si le worker crash pendant le GET (axios timeout = 5s)
|
||||
|
||||
const refreshDarkinoSessionIfNeeded = async () => {
|
||||
const now = Date.now();
|
||||
if (now - lastDarkinoHomeRequest > DARKINO_SESSION_REFRESH_INTERVAL) {
|
||||
try {
|
||||
const last = Number(await redis.get(DARKINO_REFRESH_LAST_KEY)) || 0;
|
||||
if (Date.now() - last <= DARKINO_SESSION_REFRESH_INTERVAL) return;
|
||||
|
||||
// SET NX PX : seul le worker qui acquiert exécute le refresh, les autres no-op.
|
||||
const acquired = await redis.set(
|
||||
DARKINO_REFRESH_LOCK_KEY,
|
||||
String(process.pid),
|
||||
"PX",
|
||||
DARKINO_REFRESH_LOCK_TTL_MS,
|
||||
"NX",
|
||||
);
|
||||
if (!acquired) return;
|
||||
|
||||
try {
|
||||
await axiosHelpers.axiosDarkinoRequest({ method: "get", url: "/" });
|
||||
lastDarkinoHomeRequest = now;
|
||||
await redis.set(DARKINO_REFRESH_LAST_KEY, String(Date.now()));
|
||||
console.log("[DARKINO] Session refreshed");
|
||||
} catch (error) {
|
||||
if (
|
||||
|
|
@ -150,7 +192,12 @@ const refreshDarkinoSessionIfNeeded = async () => {
|
|||
) {
|
||||
console.error("[DARKINO] Failed to refresh session:", error.message);
|
||||
}
|
||||
} finally {
|
||||
await redis.del(DARKINO_REFRESH_LOCK_KEY).catch(() => {});
|
||||
}
|
||||
} catch (_) {
|
||||
// Redis down → skip silently. Pas de fallback in-memory : ce serait
|
||||
// réintroduire le bug N-workers-refreshent-en-parallèle.
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -359,7 +406,9 @@ darkiworldRouter.configure({
|
|||
saveToCache,
|
||||
shouldUpdateCache,
|
||||
shouldUpdateCache24h,
|
||||
shouldUpdateCache48h,
|
||||
refreshDarkinoSessionIfNeeded,
|
||||
redis,
|
||||
});
|
||||
|
||||
// --- Configure voirdrama ---
|
||||
|
|
@ -589,6 +638,35 @@ function getAppPool() {
|
|||
return getPool();
|
||||
}
|
||||
|
||||
// === Hydracker queue drain timer ===
|
||||
// Every worker fires the tick. drainQueueOnce uses Redis worker_lock so only
|
||||
// one worker actually drains; the others get { drained: false, reason: 'lock_taken' }
|
||||
// and skip. This means the drain runs in a worker process which has full
|
||||
// darkiworld auth context (cookies, XSRF, darkiHeaders, axiosDarkinoRequest
|
||||
// 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})`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[hydracker] drain tick threw:`, e?.message || e);
|
||||
}
|
||||
}, DRAIN_INTERVAL_MS);
|
||||
|
||||
// === Unified error handler ===
|
||||
app.use((err, req, res, next) => {
|
||||
if (err.message !== "Not allowed by CORS") {
|
||||
|
|
|
|||
|
|
@ -299,6 +299,77 @@ const requireAuth = async (req, res, next) => {
|
|||
}
|
||||
};
|
||||
|
||||
// Rate limit pour les actions d'écriture (commentaires/réponses/réactions/notifications)
|
||||
// - Clef = userId post-auth (fallback IP CF/X-Forwarded-For derrière Cloudflare)
|
||||
// - Store Redis partagé entre workers du cluster (sinon chaque worker compte indépendamment)
|
||||
// - passOnStoreError: si Redis tombe, fail-open au lieu de bloquer toutes les requêtes
|
||||
const rateLimit = require("express-rate-limit");
|
||||
const { ipKeyGenerator } = require("express-rate-limit");
|
||||
const { createRedisRateLimitStore } = require("./utils/redisRateLimitStore");
|
||||
const writeRateLimit = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 100,
|
||||
store: createRedisRateLimitStore({
|
||||
prefix: "rate-limit:comments:write:",
|
||||
windowMs: 60 * 1000,
|
||||
}),
|
||||
passOnStoreError: true,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: "Trop de requêtes. Réessayez dans une minute." },
|
||||
keyGenerator: (req) => {
|
||||
if (req.user) return `u:${req.user.userType}:${req.user.userId}`;
|
||||
return (
|
||||
req.headers["cf-connecting-ip"] ||
|
||||
req.headers["x-forwarded-for"]?.split(",")[0].trim() ||
|
||||
ipKeyGenerator(req.ip)
|
||||
);
|
||||
},
|
||||
validate: {
|
||||
xForwardedForHeader: false,
|
||||
ip: false,
|
||||
keyGeneratorIpFallback: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Init paresseux des tables notifs/push (évite un DDL par requête, idempotent en cas de redémarrage)
|
||||
let _notificationTablesInitialized = false;
|
||||
let _notificationTablesInitPromise = null;
|
||||
async function ensureNotificationTables() {
|
||||
if (_notificationTablesInitialized) return;
|
||||
if (_notificationTablesInitPromise) return _notificationTablesInitPromise;
|
||||
_notificationTablesInitPromise = (async () => {
|
||||
const pool = getCachedPool();
|
||||
await pool.execute(
|
||||
`CREATE TABLE IF NOT EXISTS user_notification_preferences (
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
user_type VARCHAR(50) NOT NULL,
|
||||
notifications_disabled TINYINT(1) DEFAULT 0,
|
||||
updated_at BIGINT,
|
||||
PRIMARY KEY (user_id, user_type)
|
||||
)`
|
||||
);
|
||||
await pool.execute(
|
||||
`CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
user_type VARCHAR(50) NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
created_at BIGINT,
|
||||
INDEX idx_user_push (user_id, user_type)
|
||||
)`
|
||||
);
|
||||
_notificationTablesInitialized = true;
|
||||
})();
|
||||
try {
|
||||
await _notificationTablesInitPromise;
|
||||
} finally {
|
||||
_notificationTablesInitPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to get allowed profile IDs (security check)
|
||||
async function getProfileIds(userId, userType) {
|
||||
try {
|
||||
|
|
@ -1086,6 +1157,15 @@ router.get("/notifications", requireAuth, async (req, res) => {
|
|||
return res.status(400).json({ error: "profileId requis" });
|
||||
}
|
||||
|
||||
// Vérifier que le profileId appartient bien à l'utilisateur authentifié
|
||||
const userProfileIds = await getProfileIds(
|
||||
req.user.userId,
|
||||
req.user.userType,
|
||||
);
|
||||
if (!userProfileIds.includes(profileId)) {
|
||||
return res.status(403).json({ error: "Profil non autorisé" });
|
||||
}
|
||||
|
||||
let query =
|
||||
"SELECT * FROM notifications WHERE user_id = ? AND user_type = ? AND profile_id = ?";
|
||||
const params = [req.user.userId, req.user.userType, profileId];
|
||||
|
|
@ -1107,7 +1187,7 @@ router.get("/notifications", requireAuth, async (req, res) => {
|
|||
});
|
||||
|
||||
// PUT /api/comments/notifications/:id/read - Marquer une notification comme lue
|
||||
router.put("/notifications/:id/read", requireAuth, async (req, res) => {
|
||||
router.put("/notifications/:id/read", requireAuth, writeRateLimit, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { profileId } = req.body;
|
||||
|
|
@ -1117,6 +1197,15 @@ router.put("/notifications/:id/read", requireAuth, async (req, res) => {
|
|||
return res.status(400).json({ error: "profileId requis" });
|
||||
}
|
||||
|
||||
// Vérifier que le profileId appartient bien à l'utilisateur authentifié
|
||||
const userProfileIds = await getProfileIds(
|
||||
req.user.userId,
|
||||
req.user.userType,
|
||||
);
|
||||
if (!userProfileIds.includes(profileId)) {
|
||||
return res.status(403).json({ error: "Profil non autorisé" });
|
||||
}
|
||||
|
||||
await dbRun(
|
||||
"UPDATE notifications SET is_read = 1 WHERE id = ? AND user_id = ? AND user_type = ? AND profile_id = ?",
|
||||
[id, req.user.userId, req.user.userType, profileId],
|
||||
|
|
@ -1130,7 +1219,7 @@ router.put("/notifications/:id/read", requireAuth, async (req, res) => {
|
|||
});
|
||||
|
||||
// PUT /api/comments/notifications/read-all - Marquer toutes les notifications comme lues
|
||||
router.put("/notifications/read-all", requireAuth, async (req, res) => {
|
||||
router.put("/notifications/read-all", requireAuth, writeRateLimit, async (req, res) => {
|
||||
try {
|
||||
const { profileId } = req.body;
|
||||
|
||||
|
|
@ -1139,6 +1228,15 @@ router.put("/notifications/read-all", requireAuth, async (req, res) => {
|
|||
return res.status(400).json({ error: "profileId requis" });
|
||||
}
|
||||
|
||||
// Vérifier que le profileId appartient bien à l'utilisateur authentifié
|
||||
const userProfileIds = await getProfileIds(
|
||||
req.user.userId,
|
||||
req.user.userType,
|
||||
);
|
||||
if (!userProfileIds.includes(profileId)) {
|
||||
return res.status(403).json({ error: "Profil non autorisé" });
|
||||
}
|
||||
|
||||
await dbRun(
|
||||
"UPDATE notifications SET is_read = 1 WHERE user_id = ? AND user_type = ? AND profile_id = ?",
|
||||
[req.user.userId, req.user.userType, profileId],
|
||||
|
|
@ -1154,7 +1252,7 @@ router.put("/notifications/read-all", requireAuth, async (req, res) => {
|
|||
});
|
||||
|
||||
// DELETE /api/comments/notifications/:id - Supprimer une notification
|
||||
router.delete("/notifications/:id", requireAuth, async (req, res) => {
|
||||
router.delete("/notifications/:id", requireAuth, writeRateLimit, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { profileId } = req.query;
|
||||
|
|
@ -1164,6 +1262,15 @@ router.delete("/notifications/:id", requireAuth, async (req, res) => {
|
|||
return res.status(400).json({ error: "profileId requis" });
|
||||
}
|
||||
|
||||
// Vérifier que le profileId appartient bien à l'utilisateur authentifié
|
||||
const userProfileIds = await getProfileIds(
|
||||
req.user.userId,
|
||||
req.user.userType,
|
||||
);
|
||||
if (!userProfileIds.includes(profileId)) {
|
||||
return res.status(403).json({ error: "Profil non autorisé" });
|
||||
}
|
||||
|
||||
// Vérifier que la notification appartient à l'utilisateur et au profil
|
||||
const notification = await dbGet(
|
||||
"SELECT * FROM notifications WHERE id = ? AND user_id = ? AND user_type = ? AND profile_id = ?",
|
||||
|
|
@ -1190,17 +1297,8 @@ router.delete("/notifications/:id", requireAuth, async (req, res) => {
|
|||
// GET /api/comments/notifications/preferences - Récupérer les préférences de notifications
|
||||
router.get("/notifications/preferences", requireAuth, async (req, res) => {
|
||||
try {
|
||||
const pool = getPool();
|
||||
await pool.execute(
|
||||
`CREATE TABLE IF NOT EXISTS user_notification_preferences (
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
user_type VARCHAR(50) NOT NULL,
|
||||
notifications_disabled TINYINT(1) DEFAULT 0,
|
||||
updated_at BIGINT,
|
||||
PRIMARY KEY (user_id, user_type)
|
||||
)`
|
||||
);
|
||||
|
||||
await ensureNotificationTables();
|
||||
const pool = getCachedPool();
|
||||
const [rows] = await pool.execute(
|
||||
'SELECT notifications_disabled FROM user_notification_preferences WHERE user_id = ? AND user_type = ? LIMIT 1',
|
||||
[req.user.userId, req.user.userType]
|
||||
|
|
@ -1217,21 +1315,11 @@ router.get("/notifications/preferences", requireAuth, async (req, res) => {
|
|||
});
|
||||
|
||||
// PUT /api/comments/notifications/preferences - Mettre à jour les préférences de notifications
|
||||
router.put("/notifications/preferences", requireAuth, async (req, res) => {
|
||||
router.put("/notifications/preferences", requireAuth, writeRateLimit, async (req, res) => {
|
||||
try {
|
||||
await ensureNotificationTables();
|
||||
const disabled = req.body?.notificationsDisabled === true;
|
||||
const pool = getPool();
|
||||
|
||||
await pool.execute(
|
||||
`CREATE TABLE IF NOT EXISTS user_notification_preferences (
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
user_type VARCHAR(50) NOT NULL,
|
||||
notifications_disabled TINYINT(1) DEFAULT 0,
|
||||
updated_at BIGINT,
|
||||
PRIMARY KEY (user_id, user_type)
|
||||
)`
|
||||
);
|
||||
|
||||
const pool = getCachedPool();
|
||||
await pool.execute(
|
||||
`INSERT INTO user_notification_preferences (user_id, user_type, notifications_disabled, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
|
|
@ -1247,31 +1335,68 @@ router.put("/notifications/preferences", requireAuth, async (req, res) => {
|
|||
});
|
||||
|
||||
// POST /api/comments/notifications/push/subscribe - Enregistrer une subscription push
|
||||
router.post("/notifications/push/subscribe", requireAuth, async (req, res) => {
|
||||
router.post("/notifications/push/subscribe", requireAuth, writeRateLimit, async (req, res) => {
|
||||
try {
|
||||
const { subscription } = req.body;
|
||||
if (!subscription || !subscription.endpoint) {
|
||||
// Validation stricte: endpoint + keys.p256dh + keys.auth requis
|
||||
if (
|
||||
!subscription ||
|
||||
typeof subscription.endpoint !== "string" ||
|
||||
!subscription.endpoint ||
|
||||
!subscription.keys ||
|
||||
typeof subscription.keys.p256dh !== "string" ||
|
||||
!subscription.keys.p256dh ||
|
||||
typeof subscription.keys.auth !== "string" ||
|
||||
!subscription.keys.auth
|
||||
) {
|
||||
return res.status(400).json({ error: "Subscription invalide" });
|
||||
}
|
||||
const pool = getPool();
|
||||
await pool.execute(
|
||||
`CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
user_type VARCHAR(50) NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
created_at BIGINT,
|
||||
INDEX idx_user_push (user_id, user_type)
|
||||
)`
|
||||
);
|
||||
// Supprimer les anciennes subscriptions du même endpoint
|
||||
await pool.execute('DELETE FROM push_subscriptions WHERE endpoint = ?', [subscription.endpoint]);
|
||||
await pool.execute(
|
||||
'INSERT INTO push_subscriptions (user_id, user_type, endpoint, p256dh, auth, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[req.user.userId, req.user.userType, subscription.endpoint, subscription.keys.p256dh, subscription.keys.auth, Date.now()]
|
||||
|
||||
await ensureNotificationTables();
|
||||
const pool = getCachedPool();
|
||||
|
||||
// Vérifier si l'endpoint existe déjà — refuser le hijack cross-user
|
||||
const [existing] = await pool.execute(
|
||||
'SELECT user_id, user_type FROM push_subscriptions WHERE endpoint = ? LIMIT 1',
|
||||
[subscription.endpoint]
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
const owner = existing[0];
|
||||
if (
|
||||
String(owner.user_id) !== String(req.user.userId) ||
|
||||
String(owner.user_type) !== String(req.user.userType)
|
||||
) {
|
||||
// Endpoint appartient à un autre compte — refuser (anti-hijack push)
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: "Endpoint déjà associé à un autre compte" });
|
||||
}
|
||||
// Même owner: rotate les clés (cas normal, le browser peut renouveler les clés)
|
||||
await pool.execute(
|
||||
'UPDATE push_subscriptions SET p256dh = ?, auth = ?, created_at = ? WHERE endpoint = ? AND user_id = ? AND user_type = ?',
|
||||
[
|
||||
subscription.keys.p256dh,
|
||||
subscription.keys.auth,
|
||||
Date.now(),
|
||||
subscription.endpoint,
|
||||
req.user.userId,
|
||||
req.user.userType,
|
||||
]
|
||||
);
|
||||
} else {
|
||||
await pool.execute(
|
||||
'INSERT INTO push_subscriptions (user_id, user_type, endpoint, p256dh, auth, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
req.user.userId,
|
||||
req.user.userType,
|
||||
subscription.endpoint,
|
||||
subscription.keys.p256dh,
|
||||
subscription.keys.auth,
|
||||
Date.now(),
|
||||
]
|
||||
);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de l'enregistrement push:", error);
|
||||
|
|
@ -1280,11 +1405,12 @@ router.post("/notifications/push/subscribe", requireAuth, async (req, res) => {
|
|||
});
|
||||
|
||||
// DELETE /api/comments/notifications/push/unsubscribe - Supprimer une subscription push
|
||||
router.delete("/notifications/push/unsubscribe", requireAuth, async (req, res) => {
|
||||
router.delete("/notifications/push/unsubscribe", requireAuth, writeRateLimit, async (req, res) => {
|
||||
try {
|
||||
const { endpoint } = req.body;
|
||||
if (!endpoint) return res.status(400).json({ error: "Endpoint manquant" });
|
||||
const pool = getPool();
|
||||
await ensureNotificationTables();
|
||||
const pool = getCachedPool();
|
||||
await pool.execute('DELETE FROM push_subscriptions WHERE endpoint = ? AND user_id = ? AND user_type = ?', [endpoint, req.user.userId, req.user.userType]);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
|
|
@ -1301,10 +1427,15 @@ router.get("/notifications/push/vapid-key", (req, res) => {
|
|||
// ==================== ROUTES RÉACTIONS ====================
|
||||
|
||||
// POST /api/comments/react - Ajouter/retirer une réaction
|
||||
router.post("/react", requireAuth, async (req, res) => {
|
||||
router.post("/react", requireAuth, writeRateLimit, async (req, res) => {
|
||||
try {
|
||||
const { targetType, targetId, profileId } = req.body; // targetType: 'comment' ou 'reply'
|
||||
|
||||
// Whitelist targetType pour éviter pollution de la table comment_reactions
|
||||
if (!["comment", "reply"].includes(targetType)) {
|
||||
return res.status(400).json({ error: "targetType invalide" });
|
||||
}
|
||||
|
||||
// Verify profile ownership
|
||||
const userProfileIds = await getProfileIds(
|
||||
req.user.userId,
|
||||
|
|
@ -1448,6 +1579,11 @@ router.get(
|
|||
const { targetType, targetId } = req.params;
|
||||
const { profileId } = req.query;
|
||||
|
||||
// Whitelist targetType
|
||||
if (!["comment", "reply"].includes(targetType)) {
|
||||
return res.status(400).json({ error: "targetType invalide" });
|
||||
}
|
||||
|
||||
const reaction = await dbGet(
|
||||
"SELECT * FROM comment_reactions WHERE target_type = ? AND target_id = ? AND user_id = ? AND user_type = ? AND profile_id = ?",
|
||||
[targetType, targetId, req.user.userId, req.user.userType, profileId],
|
||||
|
|
@ -1468,8 +1604,9 @@ router.get(
|
|||
router.get("/:commentId/replies", async (req, res) => {
|
||||
try {
|
||||
const { commentId } = req.params;
|
||||
const { page = 1, limit = 3 } = req.query;
|
||||
const offset = (page - 1) * limit;
|
||||
const safePage = Math.max(1, Math.min(parseInt(req.query.page) || 1, 1000));
|
||||
const safeLimit = Math.max(1, Math.min(parseInt(req.query.limit) || 3, 50));
|
||||
const offset = (safePage - 1) * safeLimit;
|
||||
|
||||
// Tenter de récupérer l'utilisateur connecté (optionnel)
|
||||
let currentUser = null;
|
||||
|
|
@ -1516,8 +1653,8 @@ router.get("/:commentId/replies", async (req, res) => {
|
|||
currentUser.userType,
|
||||
profileId,
|
||||
commentId,
|
||||
parseInt(limit),
|
||||
parseInt(offset),
|
||||
safeLimit,
|
||||
offset,
|
||||
];
|
||||
} else {
|
||||
repliesQuery = `
|
||||
|
|
@ -1527,7 +1664,7 @@ router.get("/:commentId/replies", async (req, res) => {
|
|||
WHERE cr.comment_id = ? AND cr.deleted = 0
|
||||
ORDER BY cr.hierarchical_path ASC
|
||||
LIMIT ? OFFSET ?`;
|
||||
repliesParams = [commentId, parseInt(limit), parseInt(offset)];
|
||||
repliesParams = [commentId, safeLimit, offset];
|
||||
}
|
||||
|
||||
const replies = await dbAll(repliesQuery, repliesParams);
|
||||
|
|
@ -1565,8 +1702,8 @@ router.get("/:commentId/replies", async (req, res) => {
|
|||
res.json({
|
||||
replies: repliesWithDetails,
|
||||
total: totalResult.total,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
page: safePage,
|
||||
limit: safeLimit,
|
||||
hasMore: offset + repliesWithDetails.length < totalResult.total,
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -1576,7 +1713,7 @@ router.get("/:commentId/replies", async (req, res) => {
|
|||
});
|
||||
|
||||
// POST /api/comments/:commentId/replies - Créer une réponse
|
||||
router.post("/:commentId/replies", requireAuth, async (req, res) => {
|
||||
router.post("/:commentId/replies", requireAuth, writeRateLimit, async (req, res) => {
|
||||
try {
|
||||
const { commentId } = req.params;
|
||||
let {
|
||||
|
|
@ -1827,10 +1964,10 @@ router.post("/:commentId/replies", requireAuth, async (req, res) => {
|
|||
});
|
||||
|
||||
// PUT /api/comments/replies/:id - Éditer une réponse
|
||||
router.put("/replies/:id", requireAuth, async (req, res) => {
|
||||
router.put("/replies/:id", requireAuth, writeRateLimit, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
let { content, isSpoiler } = req.body;
|
||||
let { content, isSpoiler, profileId } = req.body;
|
||||
|
||||
// Normalize content while preserving the original characters
|
||||
content = normalizeCommentContent(content);
|
||||
|
|
@ -1857,6 +1994,16 @@ router.put("/replies/:id", requireAuth, async (req, res) => {
|
|||
return res.status(403).json({ error: "Non autorisé" });
|
||||
}
|
||||
|
||||
// Si la réponse a un profile_id, exiger que l'éditeur passe le même profileId
|
||||
// (empêche un autre profil du même compte d'éditer)
|
||||
if (reply.profile_id) {
|
||||
if (!profileId || String(reply.profile_id) !== String(profileId)) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Seul le profil auteur peut éditer cette réponse" });
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour la réponse
|
||||
await dbRun(
|
||||
"UPDATE comment_replies SET content = ?, is_spoiler = ?, is_edited = 1, updated_at = ? WHERE id = ?",
|
||||
|
|
@ -1880,7 +2027,7 @@ router.put("/replies/:id", requireAuth, async (req, res) => {
|
|||
});
|
||||
|
||||
// DELETE /api/comments/replies/:id - Supprimer une réponse (admin ou auteur)
|
||||
router.delete("/replies/:id", requireAuth, async (req, res) => {
|
||||
router.delete("/replies/:id", requireAuth, writeRateLimit, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { profileId } = req.query;
|
||||
|
|
@ -1899,10 +2046,13 @@ router.delete("/replies/:id", requireAuth, async (req, res) => {
|
|||
const userMatch =
|
||||
String(reply.user_id) === String(req.user.userId) &&
|
||||
String(reply.user_type) === String(req.user.userType);
|
||||
const profileMatch =
|
||||
!reply.profile_id ||
|
||||
!profileId ||
|
||||
String(reply.profile_id) === String(profileId);
|
||||
// Si la réponse a un profile_id, le profileId fourni doit matcher exactement
|
||||
// (empêche un kid profile de supprimer la réponse d'un adult profile du même compte)
|
||||
let profileMatch = true;
|
||||
if (reply.profile_id) {
|
||||
profileMatch =
|
||||
!!profileId && String(reply.profile_id) === String(profileId);
|
||||
}
|
||||
const isOwner = userMatch && profileMatch;
|
||||
|
||||
if (!userData.isAdmin && !isOwner) {
|
||||
|
|
@ -2927,18 +3077,21 @@ router.get("/limits", requireAuth, async (req, res) => {
|
|||
|
||||
// ==================== ROUTES REPORTS (avant les routes dynamiques) ====================
|
||||
|
||||
const rateLimit = require("express-rate-limit");
|
||||
|
||||
const reportRateLimit = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
store: createRedisRateLimitStore({
|
||||
prefix: "rate-limit:comments:report:",
|
||||
windowMs: 15 * 60 * 1000,
|
||||
}),
|
||||
passOnStoreError: true,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: "Trop de signalements. Réessayez dans 15 minutes." },
|
||||
keyGenerator: (req) =>
|
||||
req.headers["cf-connecting-ip"] ||
|
||||
req.headers["x-forwarded-for"]?.split(",")[0].trim() ||
|
||||
req.ip,
|
||||
ipKeyGenerator(req.ip),
|
||||
validate: {
|
||||
xForwardedForHeader: false,
|
||||
ip: false,
|
||||
|
|
@ -3364,8 +3517,9 @@ router.put("/admin/reports/:id/dismiss", requireAuth, async (req, res) => {
|
|||
router.get("/:contentType/:contentId", async (req, res) => {
|
||||
try {
|
||||
const { contentType, contentId } = req.params;
|
||||
const { page = 1, limit = 20 } = req.query;
|
||||
const offset = (page - 1) * limit;
|
||||
const safePage = Math.max(1, Math.min(parseInt(req.query.page) || 1, 1000));
|
||||
const safeLimit = Math.max(1, Math.min(parseInt(req.query.limit) || 20, 50));
|
||||
const offset = (safePage - 1) * safeLimit;
|
||||
|
||||
// Tenter de récupérer l'utilisateur connecté (optionnel)
|
||||
let currentUser = null;
|
||||
|
|
@ -3414,8 +3568,8 @@ router.get("/:contentType/:contentId", async (req, res) => {
|
|||
profileId,
|
||||
contentType,
|
||||
contentId,
|
||||
parseInt(limit),
|
||||
parseInt(offset),
|
||||
safeLimit,
|
||||
offset,
|
||||
];
|
||||
} else {
|
||||
commentsQuery = `
|
||||
|
|
@ -3429,8 +3583,8 @@ router.get("/:contentType/:contentId", async (req, res) => {
|
|||
commentsParams = [
|
||||
contentType,
|
||||
contentId,
|
||||
parseInt(limit),
|
||||
parseInt(offset),
|
||||
safeLimit,
|
||||
offset,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -3470,8 +3624,8 @@ router.get("/:contentType/:contentId", async (req, res) => {
|
|||
res.json({
|
||||
comments: commentsWithDetails,
|
||||
total: totalResult.total,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
page: safePage,
|
||||
limit: safeLimit,
|
||||
hasMore: offset + commentsWithDetails.length < totalResult.total,
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -3481,8 +3635,26 @@ router.get("/:contentType/:contentId", async (req, res) => {
|
|||
});
|
||||
|
||||
// POST /api/comments - Créer un commentaire
|
||||
router.post("/", requireAuth, async (req, res) => {
|
||||
router.post("/", requireAuth, writeRateLimit, async (req, res) => {
|
||||
// Lock Redis par user pour sérialiser les créations concurrentes
|
||||
// (évite la race count >=3 / count >=10 entre SELECT et INSERT)
|
||||
const userLockKey = `comments:create:lock:${req.user.userType}:${req.user.userId}`;
|
||||
let lockAcquired = null;
|
||||
let lockHeld = false;
|
||||
try {
|
||||
try {
|
||||
lockAcquired = await redis.set(userLockKey, "1", "EX", 10, "NX");
|
||||
} catch {
|
||||
// Redis indisponible — on accepte le risque de race (best effort)
|
||||
lockAcquired = "OK";
|
||||
}
|
||||
if (!lockAcquired) {
|
||||
return res
|
||||
.status(429)
|
||||
.json({ error: "Une création est déjà en cours, réessayez." });
|
||||
}
|
||||
lockHeld = true;
|
||||
|
||||
let {
|
||||
contentType,
|
||||
contentId,
|
||||
|
|
@ -3640,14 +3812,22 @@ router.post("/", requireAuth, async (req, res) => {
|
|||
} catch (error) {
|
||||
console.error("Erreur lors de la création du commentaire:", error);
|
||||
res.status(500).json({ error: "Erreur serveur" });
|
||||
} finally {
|
||||
if (lockHeld) {
|
||||
try {
|
||||
await redis.del(userLockKey);
|
||||
} catch {
|
||||
/* Redis indisponible — le lock expirera via TTL */
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/comments/:id - Éditer un commentaire
|
||||
router.put("/:id", requireAuth, async (req, res) => {
|
||||
router.put("/:id", requireAuth, writeRateLimit, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
let { content, isSpoiler } = req.body;
|
||||
let { content, isSpoiler, profileId } = req.body;
|
||||
|
||||
// Normalize content while preserving the original characters
|
||||
content = normalizeCommentContent(content);
|
||||
|
|
@ -3674,6 +3854,16 @@ router.put("/:id", requireAuth, async (req, res) => {
|
|||
return res.status(403).json({ error: "Non autorisé" });
|
||||
}
|
||||
|
||||
// Si le commentaire a un profile_id, exiger que l'éditeur passe le même profileId
|
||||
// (empêche un autre profil du même compte d'éditer)
|
||||
if (comment.profile_id) {
|
||||
if (!profileId || String(comment.profile_id) !== String(profileId)) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Seul le profil auteur peut éditer ce commentaire" });
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour le commentaire
|
||||
await dbRun(
|
||||
"UPDATE comments SET content = ?, is_spoiler = ?, is_edited = 1, updated_at = ? WHERE id = ?",
|
||||
|
|
@ -3696,7 +3886,7 @@ router.put("/:id", requireAuth, async (req, res) => {
|
|||
});
|
||||
|
||||
// DELETE /api/comments/:id - Supprimer un commentaire (admin ou auteur)
|
||||
router.delete("/:id", requireAuth, async (req, res) => {
|
||||
router.delete("/:id", requireAuth, writeRateLimit, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { profileId } = req.query;
|
||||
|
|
@ -3715,11 +3905,13 @@ router.delete("/:id", requireAuth, async (req, res) => {
|
|||
const userMatch =
|
||||
String(comment.user_id) === String(req.user.userId) &&
|
||||
String(comment.user_type) === String(req.user.userType);
|
||||
// Vérifier le profile_id si le commentaire en a un
|
||||
const profileMatch =
|
||||
!comment.profile_id ||
|
||||
!profileId ||
|
||||
String(comment.profile_id) === String(profileId);
|
||||
// Si le commentaire a un profile_id, le profileId fourni doit matcher exactement
|
||||
// (empêche un kid profile de supprimer le commentaire d'un adult profile du même compte)
|
||||
let profileMatch = true;
|
||||
if (comment.profile_id) {
|
||||
profileMatch =
|
||||
!!profileId && String(comment.profile_id) === String(profileId);
|
||||
}
|
||||
const isOwner = userMatch && profileMatch;
|
||||
|
||||
if (!userData.isAdmin && !isOwner) {
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ const WITV_CATEGORIES = {
|
|||
};
|
||||
|
||||
// URL de base pour Sosplay
|
||||
const SOSPLAY_BASE_URL = "https://ligue1live.xyz";
|
||||
const SOSPLAY_BASE_URL = "https://streamonsport.art";
|
||||
|
||||
// Source Bolaloca/Elitegol (remplace l'ancien catalogue Sosplay pour les chaines)
|
||||
const BOLALOCA_BASE_URL = "https://bolaloca.my";
|
||||
|
|
@ -3571,7 +3571,6 @@ router.get("/manifest", async (req, res) => {
|
|||
manifest.catalogs = [...matchesCatalogs, ...manifest.catalogs];
|
||||
manifest.idPrefixes.push("match_");
|
||||
|
||||
|
||||
// [DÉSACTIVÉ] Linkzy temporairement désactivé
|
||||
// for (const [catId, config] of Object.entries(LINKZY_CATEGORIES)) {
|
||||
// manifest.catalogs.push({
|
||||
|
|
@ -3762,8 +3761,7 @@ router.get("/stream/:type/:channelId", async (req, res) => {
|
|||
|
||||
// Block VIP-only sources immediately if not VIP
|
||||
if (
|
||||
(channelId.startsWith("matches_") ||
|
||||
channelId.startsWith("iptv_")) &&
|
||||
(channelId.startsWith("matches_") || channelId.startsWith("iptv_")) &&
|
||||
!isVip.vip
|
||||
) {
|
||||
return res.status(403).json({ error: "Réservé aux membres VIP" });
|
||||
|
|
@ -4328,8 +4326,8 @@ router.delete("/cache", async (req, res) => {
|
|||
const XTREAM_URL = (process.env.XTREAM_URL || "").replace(/\/+$/, "");
|
||||
const XTREAM_USER = process.env.XTREAM_USER || "";
|
||||
const XTREAM_PASS = process.env.XTREAM_PASS || "";
|
||||
const IPTV_IMAGE_PROXY = "https://proxy.movix.blog/proxy";
|
||||
const IPTV_STREAM_PROXY = "https://proxiesembed.movix.blog/proxy";
|
||||
const IPTV_IMAGE_PROXY = "https://proxy.movix.cash/proxy";
|
||||
const IPTV_STREAM_PROXY = "https://proxiesembed.movix.cash/proxy";
|
||||
|
||||
// Cache catégories IPTV en mémoire (change rarement)
|
||||
let iptvCategoriesCache = null;
|
||||
|
|
@ -4390,7 +4388,7 @@ router.get("/iptv/categories", requireVip, async (req, res) => {
|
|||
/**
|
||||
* GET /api/livetv/iptv/streams/:categoryId
|
||||
* Récupère les chaînes d'une catégorie Xtream (VIP only)
|
||||
* Images proxifiées via proxy.movix.blog
|
||||
* Images proxifiées via proxy.movix.cash
|
||||
*/
|
||||
router.get("/iptv/streams/:categoryId", requireVip, async (req, res) => {
|
||||
const { categoryId } = req.params;
|
||||
|
|
|
|||
|
|
@ -680,7 +680,7 @@ async function getTvDataFromCoflix(url, seasonNumber, episodeNumber) {
|
|||
(ep) => parseInt(ep.number) === parseInt(episodeNumber),
|
||||
);
|
||||
if (episode && episode.links) {
|
||||
episodeUrl = episode.links.startsWith("https://coflix.click")
|
||||
episodeUrl = episode.links.startsWith("https://coflix.date")
|
||||
? `${episode.links}`
|
||||
: episode.links;
|
||||
}
|
||||
|
|
@ -688,7 +688,7 @@ async function getTvDataFromCoflix(url, seasonNumber, episodeNumber) {
|
|||
}
|
||||
|
||||
if (!episodeUrl) {
|
||||
episodeUrl = `https://coflix.click/episode/${seriesSlug}-${seasonNumber}x${episodeNumber}/`;
|
||||
episodeUrl = `https://coflix.date/episode/${seriesSlug}-${seasonNumber}x${episodeNumber}/`;
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,13 @@ const fsp = require('fs').promises;
|
|||
const { generateCacheKey } = require('../utils/cacheManager');
|
||||
const { getAuthIfValid } = require('../middleware/auth');
|
||||
const { getPool: getMovixPool } = require('../mysqlPool');
|
||||
const hydrackerQueue = require('../utils/hydrackerQueue');
|
||||
|
||||
// TTL pour les échecs de /decode (ex. "Lien d'embed invalide" persistant côté
|
||||
// upstream). On stocke un marker `{ failed: true, failedAt }` au lieu du
|
||||
// cachedData habituel, pour servir directement un 404 pendant ce délai sans
|
||||
// re-taper hydracker.com à chaque clic.
|
||||
const DECODE_FAILED_TTL_MS = 2 * 60 * 60 * 1000;
|
||||
|
||||
const HOST_ICON_MAP = {
|
||||
'1fichier': '/hosts/1fichier.svg',
|
||||
|
|
@ -102,6 +109,8 @@ let saveToCache;
|
|||
let shouldUpdateCache;
|
||||
let shouldUpdateCache24h;
|
||||
let refreshDarkinoSessionIfNeeded;
|
||||
let redis;
|
||||
let shouldUpdateCache48h;
|
||||
|
||||
/**
|
||||
* Inject runtime dependencies that still live in server.js.
|
||||
|
|
@ -116,6 +125,8 @@ function configure(deps) {
|
|||
if (deps.shouldUpdateCache) shouldUpdateCache = deps.shouldUpdateCache;
|
||||
if (deps.shouldUpdateCache24h) shouldUpdateCache24h = deps.shouldUpdateCache24h;
|
||||
if (deps.refreshDarkinoSessionIfNeeded) refreshDarkinoSessionIfNeeded = deps.refreshDarkinoSessionIfNeeded;
|
||||
if (deps.redis) redis = deps.redis;
|
||||
if (deps.shouldUpdateCache48h) shouldUpdateCache48h = deps.shouldUpdateCache48h;
|
||||
}
|
||||
|
||||
function parsePositiveInt(value, fallback) {
|
||||
|
|
@ -372,16 +383,9 @@ router.get('/download/:type/:id', async (req, res) => {
|
|||
|
||||
const hostInfo = entry.host;
|
||||
const provider = hostInfo?.name || 'unknown';
|
||||
// Pour darkibox, le `lien` direct n'est pas exploitable, on garde
|
||||
// null afin que le frontend déclenche /decode pour construire
|
||||
// l'URL d'embed. Pour les autres providers (1fichier, sendcm, …),
|
||||
// `entry.lien` est l'URL de téléchargement directe.
|
||||
const directLien = provider.toLowerCase() === 'darkibox' ? null : (entry.lien || null);
|
||||
|
||||
return {
|
||||
id: entry.id,
|
||||
lien: directLien,
|
||||
id_user: entry.id_user || undefined,
|
||||
language: (entry?.langues_compact && entry.langues_compact.length > 0)
|
||||
? entry.langues_compact.map(l => l.name).join(', ')
|
||||
: undefined,
|
||||
|
|
@ -420,15 +424,9 @@ router.get('/download/:type/:id', async (req, res) => {
|
|||
|
||||
const hostInfo = entry.host;
|
||||
const provider = hostInfo?.name || 'unknown';
|
||||
// Pour darkibox, on laisse le frontend appeler /decode (URL d'embed
|
||||
// construite côté serveur). Pour les autres providers, le `lien`
|
||||
// est l'URL directe de téléchargement.
|
||||
const directLien = provider.toLowerCase() === 'darkibox' ? null : (entry.lien || null);
|
||||
|
||||
return {
|
||||
id: entry.id,
|
||||
lien: directLien,
|
||||
id_user: entry.id_user || undefined,
|
||||
language: (entry?.langues_compact && entry.langues_compact.length > 0)
|
||||
? entry.langues_compact.map(l => l.name).join(', ')
|
||||
: undefined,
|
||||
|
|
@ -534,189 +532,54 @@ router.get('/download/:type/:id', async (req, res) => {
|
|||
router.get('/decode/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { title_id: titleIdParam } = req.query;
|
||||
const titleId = titleIdParam ? String(titleIdParam) : null;
|
||||
if (!id) return res.status(400).json({ success: false, error: 'ID du lien requis' });
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'ID du lien requis'
|
||||
});
|
||||
}
|
||||
|
||||
// Cache key v2 — invalide les anciens caches qui ont stocké des
|
||||
// URLs d'embed invalides retournées par /api/v1/liens/{id}/download.
|
||||
const cacheKey = generateCacheKey(`darkiworld_decode_v2_${id}`);
|
||||
|
||||
// Check if results are in cache without expiration (stale-while-revalidate)
|
||||
const cachedData = await getFromCacheNoExpiration(DOWNLOAD_CACHE_DIR, cacheKey);
|
||||
let dataReturned = false;
|
||||
let shouldDoBackgroundUpdate = false;
|
||||
|
||||
if (cachedData) {
|
||||
// Vérifier si le lien en cache est un lien d'embed invalide
|
||||
const embedUrl = cachedData.embed_url || '';
|
||||
const isInvalidEmbedLink = /\/embed-\d+\.html$/i.test(embedUrl);
|
||||
|
||||
if (isInvalidEmbedLink) {
|
||||
// Cache invalide (lien d'embed `/embed-NN.html`), refetch silencieusement
|
||||
} else {
|
||||
// Cache valide, le retourner immédiatement
|
||||
res.status(200).json(cachedData);
|
||||
dataReturned = true;
|
||||
|
||||
// Vérifier si on doit faire un background update (fichier modifié il y a plus de 24h)
|
||||
shouldDoBackgroundUpdate = await shouldUpdateCache24h(DOWNLOAD_CACHE_DIR, cacheKey);
|
||||
|
||||
// Si pas besoin de background update, on s'arrête là
|
||||
if (!shouldDoBackgroundUpdate) {
|
||||
return;
|
||||
}
|
||||
// Sinon on continue pour faire le fetch en arrière-plan
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Si pas de cache valide
|
||||
// Si en maintenance
|
||||
if (DARKINO_MAINTENANCE) {
|
||||
if (!dataReturned) {
|
||||
return res.status(200).json({ error: 'Service Darkino temporairement indisponible (maintenance)' });
|
||||
}
|
||||
return; // Si on a déjà retourné des données, on arrête juste le traitement (pas de background update)
|
||||
return res.status(200).json({ error: 'Service Darkino temporairement indisponible (maintenance)' });
|
||||
}
|
||||
|
||||
// Fonction pour récupérer les données fraîches via l'ancien endpoint
|
||||
// `/api/v1/liens/{id}/download`. Le `title_id` est ignoré côté backend
|
||||
// pour le moment (le nouvel endpoint content/liens requiert une session
|
||||
// authentifiée), mais reste accepté en query pour compat future.
|
||||
void titleId;
|
||||
const fetchFreshData = async () => {
|
||||
let linkInfo = null;
|
||||
let embedUrl = null;
|
||||
let provider = 'unknown';
|
||||
let retryCount = 0;
|
||||
const maxRetries = 2;
|
||||
const result = await hydrackerQueue.decodeRequest(id, {
|
||||
redis,
|
||||
cacheDir: DOWNLOAD_CACHE_DIR,
|
||||
generateCacheKey,
|
||||
getFromCacheNoExpiration,
|
||||
shouldUpdateCache48h
|
||||
});
|
||||
|
||||
while (retryCount < maxRetries) {
|
||||
try {
|
||||
await refreshDarkinoSessionIfNeeded();
|
||||
|
||||
const linkResp = await axiosDarkinoRequest({
|
||||
method: 'get',
|
||||
url: `/api/v1/liens/${id}/download`
|
||||
});
|
||||
|
||||
linkInfo = linkResp.data;
|
||||
provider = linkInfo?.host?.name || 'unknown';
|
||||
|
||||
if (provider === 'darkibox') {
|
||||
const rawDarkiboxLink = typeof linkInfo?.lien === 'string' ? linkInfo.lien : '';
|
||||
const darkiboxCodeMatch = rawDarkiboxLink.match(/darkibox\.com\/(?:embed-)?([a-z0-9]{12,})(?:\.html)?/i);
|
||||
const darkiboxCode = darkiboxCodeMatch ? darkiboxCodeMatch[1] : null;
|
||||
embedUrl = darkiboxCode
|
||||
? `https://darkibox.com/embed-${darkiboxCode}.html`
|
||||
: (rawDarkiboxLink || `https://darkibox.com/embed-${id}.html`);
|
||||
} else {
|
||||
embedUrl = linkInfo?.lien || `https://darkibox.com/embed-${id}.html`;
|
||||
}
|
||||
|
||||
const isInvalidEmbedLink = /\/embed-\d+\.html$/i.test(embedUrl);
|
||||
|
||||
if (isInvalidEmbedLink && retryCount < maxRetries - 1) {
|
||||
retryCount++;
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
continue;
|
||||
} else if (isInvalidEmbedLink) {
|
||||
throw new Error('Lien d\'embed invalide');
|
||||
}
|
||||
|
||||
break;
|
||||
} catch (err) {
|
||||
if (retryCount < maxRetries - 1) {
|
||||
retryCount++;
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
id: id,
|
||||
provider: provider,
|
||||
embed_url: embedUrl,
|
||||
metadata: linkInfo ? {
|
||||
language: (linkInfo?.langues_compact && linkInfo.langues_compact.length > 0)
|
||||
? linkInfo.langues_compact.map(l => l.name).join(', ')
|
||||
: undefined,
|
||||
quality: linkInfo?.qual?.qual,
|
||||
sub: (linkInfo?.subs_compact && linkInfo.subs_compact.length > 0)
|
||||
? linkInfo.subs_compact.map(s => s.name).join(', ')
|
||||
: undefined,
|
||||
size: linkInfo?.size,
|
||||
upload_date: linkInfo?.created_at
|
||||
} : null
|
||||
};
|
||||
};
|
||||
|
||||
// Si on fait un background update (données déjà retournées au client)
|
||||
if (dataReturned && shouldDoBackgroundUpdate) {
|
||||
// Background update du cache
|
||||
(async () => {
|
||||
try {
|
||||
const freshData = await fetchFreshData();
|
||||
if (freshData && freshData.embed_url) {
|
||||
await saveToCache(DOWNLOAD_CACHE_DIR, cacheKey, freshData);
|
||||
}
|
||||
} catch (bgError) {
|
||||
// Silent fail on background update
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
// Si on n'a pas encore retourné de données, faire le fetch normal
|
||||
try {
|
||||
const responseData = await fetchFreshData();
|
||||
|
||||
// Retourner les données
|
||||
res.json(responseData);
|
||||
|
||||
// Mise à jour du cache
|
||||
if (responseData.embed_url) {
|
||||
try {
|
||||
await saveToCache(DOWNLOAD_CACHE_DIR, cacheKey, responseData);
|
||||
} catch (cacheError) {
|
||||
// Silent fail on cache save
|
||||
}
|
||||
}
|
||||
} catch (fetchError) {
|
||||
if (result.payload) return res.status(200).json(result.payload);
|
||||
if (result.failed) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Lien non trouvé ou inaccessible',
|
||||
id: id,
|
||||
debug: fetchError.message
|
||||
error: result.failed.error || 'Lien non trouvé ou inaccessible',
|
||||
id: result.failed.id || id,
|
||||
debug: result.failed.debug || ''
|
||||
});
|
||||
}
|
||||
if (result.queued) {
|
||||
return res.status(202).json({
|
||||
status: 'queued',
|
||||
queue_size: result.queue_size,
|
||||
id
|
||||
});
|
||||
}
|
||||
if (result.rateLimited) {
|
||||
return res.status(503).json({
|
||||
success: false,
|
||||
error: 'rate_limited',
|
||||
retry_at: result.retryAt
|
||||
});
|
||||
}
|
||||
if (result.unavailable) {
|
||||
return res.status(503).json({
|
||||
success: false,
|
||||
error: 'queue_unavailable',
|
||||
message: 'Infrastructure indisponible, réessaie plus tard'
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({ success: false, error: 'Unknown decode result' });
|
||||
|
||||
} catch (error) {
|
||||
if (res.headersSent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Si Darkino retourne 500, ne pas créer/mettre à jour le cache et renvoyer le cache existant si présent
|
||||
if (error.response && error.response.status >= 500) {
|
||||
try {
|
||||
const fallbackCache = await getFromCacheNoExpiration(DOWNLOAD_CACHE_DIR, cacheKey);
|
||||
if (fallbackCache) {
|
||||
return res.status(200).json(fallbackCache);
|
||||
}
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
|
|
|
|||
|
|
@ -58,16 +58,18 @@ function configure(deps) {
|
|||
// getFtvNextActionHash -- dynamically retrieve the Next.js server action hash
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Récupère dynamiquement le hash next-action depuis la page /recherche/
|
||||
* Récupère dynamiquement le hash next-action depuis la page /recherche/.
|
||||
* Ce hash change à chaque redéploiement de france.tv (Next.js Server Actions).
|
||||
*
|
||||
* Étapes :
|
||||
* 1. GET https://www.france.tv/recherche/
|
||||
* 2. Trouver le <script src="/_next/static/chunks/app/recherche/page-XXXX.js">
|
||||
* 3. GET ce fichier JS
|
||||
* 4. Extraire le hash de createServerReference("HASH", ...)
|
||||
* 2. Fallback rapide : essayer d'extraire $ACTION_ID_HASH inliné dans le HTML
|
||||
* 3. Sinon, scanner TOUS les chunks /_next/static/chunks/*.js en parallèle
|
||||
* et chercher createServerReference("HASH", ..., "searchAction") dans chacun.
|
||||
* On exige le marqueur "searchAction" pour ne pas confondre avec les autres
|
||||
* Server Actions que Turbopack peut grouper dans le même chunk.
|
||||
*
|
||||
* Retente jusqu'à 3 fois en cas d'échec (connexion directe, sans proxy).
|
||||
* Retente jusqu'à 3 fois en cas d'échec réseau.
|
||||
*/
|
||||
async function getFtvNextActionHash() {
|
||||
const now = Date.now();
|
||||
|
|
@ -76,12 +78,12 @@ async function getFtvNextActionHash() {
|
|||
}
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
const SEARCH_ACTION_RE = /createServerReference\)?\s*\(\s*"([a-f0-9]{40,})"[^)]*?"searchAction"/;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
console.log(`[FTV] Fetching next-action hash (attempt ${attempt}/${MAX_RETRIES}, direct) ...`);
|
||||
|
||||
try {
|
||||
// Étape 1: Charger la page /recherche/
|
||||
const pageResponse = await axios.get(`${FTV_BASE}/recherche/`, {
|
||||
headers: { ...FTV_BROWSER_HEADERS },
|
||||
proxy: false,
|
||||
|
|
@ -92,17 +94,23 @@ async function getFtvNextActionHash() {
|
|||
const html = pageResponse.data;
|
||||
let hash = null;
|
||||
|
||||
// Étape 2: Trouver le script chunk de la page recherche
|
||||
// Pattern: <script src="/_next/static/chunks/app/recherche/page-XXXX.js" async="">
|
||||
const scriptMatch = html.match(/<script[^>]+src="(\/_next\/static\/chunks\/app\/recherche\/page-[^"]+\.js)"/);
|
||||
// Fast path: hash inliné dans le HTML (certains layouts Next.js le font)
|
||||
const inlineMatch = html.match(/\$ACTION_ID_([a-f0-9]{40,})/);
|
||||
if (inlineMatch) {
|
||||
hash = inlineMatch[1];
|
||||
console.log(`[FTV] Found next-action hash inline in HTML: ${hash}`);
|
||||
}
|
||||
|
||||
if (scriptMatch) {
|
||||
const scriptUrl = `${FTV_BASE}${scriptMatch[1]}`;
|
||||
console.log(`[FTV] Found recherche chunk: ${scriptUrl}`);
|
||||
if (!hash) {
|
||||
// Lister TOUS les chunks JS — Turbopack ne respecte plus la structure /app/PAGE/page-HASH.js
|
||||
const chunkUrls = [...new Set(
|
||||
[...html.matchAll(/<script[^>]+src="(\/_next\/static\/chunks\/[^"]+\.js)"/g)]
|
||||
.map((m) => m[1])
|
||||
)];
|
||||
console.log(`[FTV] Scanning ${chunkUrls.length} chunks for searchAction reference...`);
|
||||
|
||||
// Étape 3: Télécharger le fichier JS
|
||||
try {
|
||||
const jsResponse = await axios.get(scriptUrl, {
|
||||
const results = await Promise.allSettled(chunkUrls.map(async (url) => {
|
||||
const res = await axios.get(`${FTV_BASE}${url}`, {
|
||||
headers: {
|
||||
'User-Agent': FTV_BROWSER_HEADERS['User-Agent'],
|
||||
'Accept': '*/*',
|
||||
|
|
@ -117,39 +125,18 @@ async function getFtvNextActionHash() {
|
|||
'Sec-Fetch-Site': 'same-origin',
|
||||
},
|
||||
proxy: false,
|
||||
timeout: 15000,
|
||||
timeout: 10000,
|
||||
});
|
||||
const m = (typeof res.data === 'string' ? res.data : '').match(SEARCH_ACTION_RE);
|
||||
return m ? m[1] : null;
|
||||
}));
|
||||
|
||||
const jsCode = jsResponse.data;
|
||||
|
||||
// Étape 4: Extraire le hash de createServerReference("HASH", ...)
|
||||
const serverRefMatch = jsCode.match(/createServerReference\)\s*\(\s*"([a-f0-9]{40,})"/);
|
||||
if (serverRefMatch) {
|
||||
hash = serverRefMatch[1];
|
||||
console.log(`[FTV] Found next-action hash via createServerReference: ${hash}`);
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled' && r.value) {
|
||||
hash = r.value;
|
||||
console.log(`[FTV] Found next-action hash in chunk: ${hash}`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Fallback: chercher aussi le pattern searchAction
|
||||
if (!hash) {
|
||||
const searchActionMatch = jsCode.match(/"([a-f0-9]{40,})"[^]*?"searchAction"/);
|
||||
if (searchActionMatch) {
|
||||
hash = searchActionMatch[1];
|
||||
console.log(`[FTV] Found next-action hash via searchAction: ${hash}`);
|
||||
}
|
||||
}
|
||||
} catch (jsErr) {
|
||||
console.error(`[FTV] Error fetching JS chunk: ${jsErr.message}`);
|
||||
}
|
||||
} else {
|
||||
console.warn('[FTV] Could not find recherche page chunk script tag');
|
||||
}
|
||||
|
||||
// Fallback: chercher directement dans le HTML
|
||||
if (!hash) {
|
||||
const actionIdMatch = html.match(/\$ACTION_ID_([a-f0-9]{40,})/);
|
||||
if (actionIdMatch) {
|
||||
hash = actionIdMatch[1];
|
||||
console.log(`[FTV] Found next-action hash via $ACTION_ID_ fallback: ${hash}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -159,12 +146,11 @@ async function getFtvNextActionHash() {
|
|||
return hash;
|
||||
}
|
||||
|
||||
console.warn(`[FTV] Attempt ${attempt}: could not extract hash from page content`);
|
||||
console.warn(`[FTV] Attempt ${attempt}: could not extract hash from page or chunks`);
|
||||
} catch (err) {
|
||||
console.error(`[FTV] Attempt ${attempt} failed (direct): ${err.response?.status || err.message}`);
|
||||
if (attempt < MAX_RETRIES) {
|
||||
// Petit délai avant de retry avec un autre proxy
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ const { PROXIES, DARKINO_PROXIES, getProxyAgent, getDarkinoHttpProxyAgent } = re
|
|||
// === FStream Configuration ===
|
||||
const TMDB_API_KEY = process.env.TMDB_API_KEY || '';
|
||||
const TMDB_API_URL = 'https://api.themoviedb.org/3';
|
||||
const FSTREAM_BASE_URL = 'https://french-stream.one/';
|
||||
const FSTREAM_BASE_URL = 'https://french-stream.one';
|
||||
const FSTREAM_SEARCH_URL = `${FSTREAM_BASE_URL}/engine/ajax/search.php`;
|
||||
|
||||
// === FStream Authentication (disabled) ===
|
||||
|
|
@ -281,14 +281,18 @@ async function searchFStreamDirect(query, page = 1) {
|
|||
}
|
||||
}
|
||||
|
||||
// Fallback "fuzzy" : search.php avec titre nu + filtre permissif (pas de filtre annee).
|
||||
// Remplace l'ancien get_seasons.php (mort cote upstream depuis ~2026-05) qui prenait
|
||||
// un TMDB id ; on simule le meme role en listant toutes les saisons matchant le titre.
|
||||
async function fetchFStreamSeasonSearchResults(tmdbId, serieTitle) {
|
||||
if (!serieTitle) return [];
|
||||
try {
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('serie_tag', `s-${tmdbId}`);
|
||||
formData.append('query', serieTitle);
|
||||
|
||||
const response = await axiosFStreamRequest({
|
||||
method: 'post',
|
||||
url: `${FSTREAM_BASE_URL}/engine/ajax/get_seasons.php`,
|
||||
url: FSTREAM_SEARCH_URL,
|
||||
data: formData,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||
|
|
@ -297,53 +301,55 @@ async function fetchFStreamSeasonSearchResults(tmdbId, serieTitle) {
|
|||
timeout: 6000
|
||||
});
|
||||
|
||||
let seasonsData = response.data;
|
||||
if (typeof seasonsData === 'string') {
|
||||
const trimmed = seasonsData.trim();
|
||||
if (!trimmed) return [];
|
||||
try { seasonsData = JSON.parse(trimmed); }
|
||||
catch (parseError) {
|
||||
console.error(`[FSTREAM TV] Impossible de parser les saisons pour ${tmdbId}: ${parseError.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
const html = typeof response.data === 'string' ? response.data : '';
|
||||
if (!html.trim()) return [];
|
||||
|
||||
if (!Array.isArray(seasonsData)) return [];
|
||||
const $ = cheerio.load(html);
|
||||
const normalize = (s) => (s || '').toLowerCase().normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '').replace(/[''`´]/g, '')
|
||||
.replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const normalizedSerie = normalize(serieTitle);
|
||||
if (!normalizedSerie) return [];
|
||||
|
||||
const normalizedSerieTitle = serieTitle || '';
|
||||
const results = [];
|
||||
$('div.search-item').each((_, element) => {
|
||||
const $el = $(element);
|
||||
const rawTitle = $el.find('.search-title').text().trim();
|
||||
const onclickAttr = $el.attr('onclick') || '';
|
||||
const linkMatch = onclickAttr.match(/location\.href=['"]([^'"]+)['"]/);
|
||||
const link = linkMatch ? linkMatch[1] : null;
|
||||
if (!rawTitle || !link) return;
|
||||
|
||||
return seasonsData
|
||||
.map((season) => {
|
||||
if (!season) return null;
|
||||
const rawTitle = season.title || '';
|
||||
const altName = season.alt_name || '';
|
||||
const seasonMatch = rawTitle.match(/Saison\s+(\d+)/i) || altName.match(/saison-(\d+)/i);
|
||||
if (!seasonMatch) return null;
|
||||
const seasonMatch = rawTitle.match(/Saison\s+(\d+)/i);
|
||||
if (!seasonMatch) return;
|
||||
const seasonNumber = parseInt(seasonMatch[1], 10);
|
||||
if (Number.isNaN(seasonNumber)) return;
|
||||
|
||||
const seasonNumber = parseInt(seasonMatch[1], 10);
|
||||
if (Number.isNaN(seasonNumber)) return null;
|
||||
const baseTitle = rawTitle.replace(/\s*-\s*Saison\s+\d+.*$/i, '').replace(/\s*\(\d{4}\)\s*$/, '').trim();
|
||||
const normalizedBase = normalize(baseTitle);
|
||||
if (!normalizedBase) return;
|
||||
if (!normalizedBase.includes(normalizedSerie) && !normalizedSerie.includes(normalizedBase)) return;
|
||||
|
||||
const rawYear = season.serie_anne;
|
||||
const year = rawYear && /^\d{4}$/.test(String(rawYear)) ? parseInt(rawYear, 10) : null;
|
||||
const titleYearMatch = rawTitle.match(/\((\d{4})\)/);
|
||||
const urlYearMatch = link.match(/-(\d{4})\.html/);
|
||||
const year = titleYearMatch ? parseInt(titleYearMatch[1], 10)
|
||||
: urlYearMatch ? parseInt(urlYearMatch[1], 10) : null;
|
||||
|
||||
const fullUrl = (season.full_url || '').replace(/\\/g, '/');
|
||||
if (!fullUrl) return null;
|
||||
const normalizedLink = fullUrl.startsWith('http')
|
||||
? fullUrl
|
||||
: `${FSTREAM_BASE_URL}${fullUrl.startsWith('/') ? '' : '/'}${fullUrl}`;
|
||||
const cleanTitle = baseTitle ? `${baseTitle} - Saison ${seasonNumber}` : rawTitle;
|
||||
const normalizedLink = link.startsWith('http')
|
||||
? link
|
||||
: `${FSTREAM_BASE_URL}${link.startsWith('/') ? '' : '/'}${link}`;
|
||||
|
||||
const baseTitle = rawTitle || `Saison ${seasonNumber}`;
|
||||
const combinedTitle = normalizedSerieTitle ? `${normalizedSerieTitle} - ${baseTitle}` : baseTitle;
|
||||
results.push({
|
||||
title: cleanTitle,
|
||||
originalTitle: rawTitle,
|
||||
link: normalizedLink,
|
||||
seasonNumber,
|
||||
year
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
title: combinedTitle,
|
||||
originalTitle: rawTitle || combinedTitle,
|
||||
link: normalizedLink,
|
||||
seasonNumber,
|
||||
year
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error(`[FSTREAM TV] Erreur lors de la recuperation des saisons pour ${tmdbId}: ${error.message}`);
|
||||
return [];
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ router.get(/^\/(.*)/, async (req, res) => {
|
|||
'Cache-Control': 'no-cache',
|
||||
'Pragma': 'no-cache',
|
||||
'Priority': 'u=0, i',
|
||||
'Referer': 'https://coflix.click/',
|
||||
'Referer': 'https://coflix.date/',
|
||||
'Sec-CH-UA': '"Brave";v="141", "Not?A_Brand";v="8", "Chromium";v="141"',
|
||||
'Sec-CH-UA-Mobile': '?0',
|
||||
'Sec-CH-UA-Platform': '"Windows"',
|
||||
|
|
|
|||
|
|
@ -117,6 +117,18 @@ async function fetchAddableContent(title, category) {
|
|||
}
|
||||
}
|
||||
|
||||
// Garde-fou : si TOUTES les requêtes upstream traînent, on coupe pour éviter
|
||||
// que /api/search reste pending côté frontend. Chaque axios a son propre
|
||||
// timeout 5s, mais si plusieurs proxies retry-loop, l'agrégat peut dépasser.
|
||||
const SEARCH_UPSTREAM_TIMEOUT_MS = 5000;
|
||||
|
||||
function withTimeout(promise, ms, label) {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error(`${label} timeout (${ms}ms)`)), ms))
|
||||
]);
|
||||
}
|
||||
|
||||
async function fetchMergedSearchData(title) {
|
||||
let searchData = null;
|
||||
let titlesData = null;
|
||||
|
|
@ -126,18 +138,18 @@ async function fetchMergedSearchData(title) {
|
|||
let titlesError = null;
|
||||
|
||||
const [searchResult, titlesResult, addable15Result, addable2Result] = await Promise.allSettled([
|
||||
axiosDarkinoRequest({
|
||||
withTimeout(axiosDarkinoRequest({
|
||||
method: 'get',
|
||||
url: `/api/v1/search/${encodeURIComponent(title)}`,
|
||||
params: { loader: 'searchPage' }
|
||||
}),
|
||||
axiosDarkinoRequest({
|
||||
}), SEARCH_UPSTREAM_TIMEOUT_MS, 'search'),
|
||||
withTimeout(axiosDarkinoRequest({
|
||||
method: 'get',
|
||||
url: '/api/v1/titles',
|
||||
params: { perPage: 15, query: title }
|
||||
}),
|
||||
fetchAddableContent(title, 15),
|
||||
fetchAddableContent(title, 2)
|
||||
}), SEARCH_UPSTREAM_TIMEOUT_MS, 'titles'),
|
||||
withTimeout(fetchAddableContent(title, 15), SEARCH_UPSTREAM_TIMEOUT_MS, 'addable15'),
|
||||
withTimeout(fetchAddableContent(title, 2), SEARCH_UPSTREAM_TIMEOUT_MS, 'addable2')
|
||||
]);
|
||||
|
||||
if (searchResult.status === 'fulfilled') {
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ if (cluster.isPrimary ?? cluster.isMaster) {
|
|||
╚═══════════════════════════════════════════════════════╝
|
||||
`);
|
||||
|
||||
// Le master ne fait RIEN d'autre — pas de require express, mysql, etc.
|
||||
// Le master ne fait RIEN d'autre — pas de require express, mysql, redis, etc.
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -254,16 +254,12 @@ async function axiosDarkinoRequest(config) {
|
|||
const requestUrl = `${DARKIWORLD_BASE_URL}${config.url}`;
|
||||
const darkinoRequestHeaders = await buildDarkinoRequestHeaders(config);
|
||||
|
||||
const debugDarkino = process.env.DEBUG_DARKINO !== 'false';
|
||||
const darkinoAuthSnapshot = {
|
||||
referer: darkinoRequestHeaders.referer,
|
||||
xsrfToken: truncateForLog(darkinoRequestHeaders['x-xsrf-token'] || '', 200),
|
||||
cookie: truncateForLog(darkinoRequestHeaders.cookie || '', 500),
|
||||
cookieLength: (darkinoRequestHeaders.cookie || '').length,
|
||||
};
|
||||
if (debugDarkino) {
|
||||
console.log(`[DARKINO REQUEST][DEBUG] ${String(config.method || 'get').toUpperCase()} ${requestUrl}`, darkinoAuthSnapshot);
|
||||
}
|
||||
|
||||
if (!ENABLE_DARKINO_PROXY) {
|
||||
// Si le proxy est d\u00e9sactiv\u00e9, faire la requ\u00eate directe
|
||||
|
|
@ -592,7 +588,17 @@ async function axiosFStreamRequest(config) {
|
|||
return response;
|
||||
} catch (error) {
|
||||
const status = error.response?.status;
|
||||
console.error(`[FSTREAM REQUEST] Erreur ${status || error.code || 'unknown'} avec proxy ${proxyLabel}: ${error.message}`);
|
||||
const method = (config.method || 'GET').toUpperCase();
|
||||
const rawUrl = config.url || '';
|
||||
const isAbs = /^https?:\/\//i.test(rawUrl);
|
||||
const fullUrl = isAbs
|
||||
? rawUrl
|
||||
: (config.baseURL ? `${config.baseURL.replace(/\/$/, '')}/${rawUrl.replace(/^\//, '')}` : rawUrl);
|
||||
let qs = '';
|
||||
if (config.params && typeof config.params === 'object') {
|
||||
try { qs = '?' + new URLSearchParams(config.params).toString(); } catch (_) {}
|
||||
}
|
||||
console.error(`[FSTREAM REQUEST] Erreur ${status || error.code || 'unknown'} avec proxy ${proxyLabel}: ${error.message} | ${method} ${fullUrl}${qs}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -407,6 +407,23 @@ const shouldUpdateCache24h = async (cacheDir, cacheKey) => {
|
|||
}
|
||||
};
|
||||
|
||||
const shouldUpdateCache48h = async (cacheDir, cacheKey) => {
|
||||
const cacheFilePath = path.join(cacheDir, `${cacheKey}.json`);
|
||||
try {
|
||||
const stats = await fsp.stat(cacheFilePath);
|
||||
const now = Date.now();
|
||||
const fileAge = now - stats.mtime.getTime();
|
||||
const fortyEightHours = 48 * 60 * 60 * 1000;
|
||||
|
||||
if (fileAge < fortyEightHours) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
CACHE_DIR,
|
||||
ANIME_SAMA_CACHE_DIR,
|
||||
|
|
@ -421,6 +438,7 @@ module.exports = {
|
|||
shouldUpdateCacheFrenchStream,
|
||||
shouldUpdateCacheLecteurVideo,
|
||||
shouldUpdateCache24h,
|
||||
shouldUpdateCache48h,
|
||||
ongoingFStreamRequests,
|
||||
getOrCreateFStreamRequest,
|
||||
saveFStreamToCache,
|
||||
|
|
|
|||
461
API/Mainapi/utils/hydrackerBatch.js
Normal file
461
API/Mainapi/utils/hydrackerBatch.js
Normal file
|
|
@ -0,0 +1,461 @@
|
|||
/**
|
||||
* Hydracker batch decoding orchestrator (predecode-on-page-load).
|
||||
*
|
||||
* /decode/:id at click = cache hit OR direct POST single (cache miss fallback).
|
||||
* /download/:type/:id triggers predecodePage(entries) in setImmediate after
|
||||
* res.json — that batches all uncached IDs of a download page into one upstream
|
||||
* POST. Result: most user clicks land on cache hits; the upstream quota is
|
||||
* burned ~once per page open instead of once per click.
|
||||
*
|
||||
* Cache lives ONLY on disk (DOWNLOAD_CACHE_DIR). Redis carries:
|
||||
* - hydracker:lock:{id} (string, 60s TTL) — single-flight per ID
|
||||
* - hydracker:predecode_lock:{tid}:{S}:{E} (string, 30s TTL) — one worker per page
|
||||
* - hydracker:rate_limited_until (string, EXPIREAT) — kill-switch quota
|
||||
*
|
||||
* Pivoted on 2026-05-02 from the queue-based design (cf. spec
|
||||
* docs/superpowers/specs/2026-05-01-hydracker-queue-decoding-design.md, now
|
||||
* superseded). Kept the pure helpers and Redis primitives from that work; the
|
||||
* orchestrators (decodeRequest, drainQueueOnce) were replaced by decodeSingle
|
||||
* and predecodePage.
|
||||
*/
|
||||
|
||||
const fsp = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
// Redis keys
|
||||
const LOCK_KEY = (id) => `hydracker:lock:${id}`;
|
||||
const PREDECODE_LOCK_KEY = (titleId, season, episode) =>
|
||||
`hydracker:predecode_lock:${titleId}:${season || 0}:${episode || 0}`;
|
||||
const RATE_LIMIT_KEY = 'hydracker:rate_limited_until';
|
||||
|
||||
// Tunables
|
||||
const BATCH_CHUNK_SIZE = 50; // hydracker accepts up to 50 IDs per POST
|
||||
const LOCK_TTL_SEC = 60; // per-ID single-flight lock TTL
|
||||
const PREDECODE_LOCK_TTL_SEC = 30; // page-level predecode lock TTL
|
||||
const FAILED_MARKER_TTL_MS = 2 * 60 * 60 * 1000; // 2h
|
||||
const STALE_REVALIDATE_MS = 48 * 60 * 60 * 1000; // 48h
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helpers (no Redis, no fs side-effects)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function chunk(arr, size) {
|
||||
if (!Array.isArray(arr) || size <= 0) return [];
|
||||
const out = [];
|
||||
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildPayload(id, linkInfo) {
|
||||
const rawLien = typeof linkInfo?.lien === 'string' ? linkInfo.lien : '';
|
||||
const provider = /darkibox\.com/i.test(rawLien) ? 'darkibox' : 'direct';
|
||||
|
||||
let resolvedUrl;
|
||||
if (provider === 'darkibox') {
|
||||
const m = rawLien.match(/darkibox\.com\/(?:embed-)?([a-z0-9]{12,})(?:\.html)?/i);
|
||||
const code = m ? m[1] : null;
|
||||
resolvedUrl = code
|
||||
? `https://darkibox.com/embed-${code}.html`
|
||||
: (rawLien || `https://darkibox.com/embed-${id}.html`);
|
||||
} else {
|
||||
resolvedUrl = rawLien || `https://darkibox.com/embed-${id}.html`;
|
||||
}
|
||||
|
||||
// Sparse linkInfo (empty lien AND no taille) → fallback URL is /embed-{id}.html
|
||||
// which matches the invalid embed pattern. Return null to signal upstream-failure
|
||||
// path to callers — distinguishes truly sparse linkInfo from a real file with
|
||||
// empty lien that carries metadata.
|
||||
const isInvalidEmbed = /\/embed-\d+\.html$/i.test(resolvedUrl);
|
||||
if (isInvalidEmbed && !rawLien && linkInfo?.taille === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const embedUrlPayload = linkInfo
|
||||
? { ...linkInfo, lien: resolvedUrl }
|
||||
: resolvedUrl;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
id: String(id),
|
||||
provider,
|
||||
embed_url: embedUrlPayload,
|
||||
metadata: linkInfo ? {
|
||||
language: undefined,
|
||||
quality: undefined,
|
||||
sub: undefined,
|
||||
size: linkInfo?.taille,
|
||||
upload_date: linkInfo?.created_at
|
||||
} : null
|
||||
};
|
||||
}
|
||||
|
||||
function buildFailedMarker(id, errorMsg, debugMsg) {
|
||||
return {
|
||||
failed: true,
|
||||
failedAt: Date.now(),
|
||||
id: String(id),
|
||||
error: errorMsg || 'Lien non trouvé ou inaccessible',
|
||||
debug: debugMsg || ''
|
||||
};
|
||||
}
|
||||
|
||||
function parseRateLimitError(error) {
|
||||
const data = error?.response?.data;
|
||||
if (!data || data.error !== 'daily_api_limit_exceeded') {
|
||||
return { isRateLimit: false, resetsAt: null };
|
||||
}
|
||||
const resetsAtIso = data.resets_at;
|
||||
const resetsAt = resetsAtIso ? Date.parse(resetsAtIso) : null;
|
||||
return { isRateLimit: true, resetsAt: Number.isFinite(resetsAt) ? resetsAt : null };
|
||||
}
|
||||
|
||||
function isFailedMarkerActive(payload) {
|
||||
return Boolean(
|
||||
payload?.failed === true &&
|
||||
typeof payload.failedAt === 'number' &&
|
||||
(Date.now() - payload.failedAt < FAILED_MARKER_TTL_MS)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Redis primitives — coordination only, NEVER cache data here.
|
||||
// All functions tolerate Redis errors silently so the system degrades
|
||||
// gracefully when Redis is unavailable.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function acquireLock(redis, key, ttlSec) {
|
||||
if (!redis) return false;
|
||||
try {
|
||||
const result = await redis.set(key, String(process.pid), 'NX', 'EX', ttlSec);
|
||||
return result === 'OK';
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
async function releaseLock(redis, key) {
|
||||
if (!redis) return;
|
||||
try { await redis.del(key); }
|
||||
catch (e) { /* silent */ }
|
||||
}
|
||||
|
||||
async function isRateLimited(redis) {
|
||||
if (!redis) return false;
|
||||
try { return Boolean(await redis.exists(RATE_LIMIT_KEY)); }
|
||||
catch (e) { return false; }
|
||||
}
|
||||
|
||||
async function getRateLimitedUntil(redis) {
|
||||
if (!redis) return null;
|
||||
try {
|
||||
const v = await redis.get(RATE_LIMIT_KEY);
|
||||
return v ? Number(v) : null;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
async function armRateLimit(redis, resetsAtMs) {
|
||||
if (!redis || !resetsAtMs) return;
|
||||
try {
|
||||
const ttlSec = Math.max(1, Math.ceil((resetsAtMs - Date.now()) / 1000));
|
||||
await redis.set(RATE_LIMIT_KEY, String(resetsAtMs), 'EX', ttlSec);
|
||||
console.warn(`[hydracker] daily quota exhausted, kill-switch armed until ${new Date(resetsAtMs).toISOString()}`);
|
||||
} catch (e) { /* silent */ }
|
||||
}
|
||||
|
||||
async function readDiskCache(id, { cacheDir, generateCacheKey, getFromCacheNoExpiration }) {
|
||||
const cacheKey = generateCacheKey(`darkiworld_decode_v2_${id}`);
|
||||
try {
|
||||
const payload = await getFromCacheNoExpiration(cacheDir, cacheKey);
|
||||
if (!payload) return null;
|
||||
const filePath = path.join(cacheDir, `${cacheKey}.json`);
|
||||
const stats = await fsp.stat(filePath);
|
||||
return { payload, mtimeMs: stats.mtime.getTime(), cacheKey };
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// decodeSingle — handles a single /decode/:id click. Cache hit returns 200;
|
||||
// cache miss does a single POST upstream (small fallback for IDs that the
|
||||
// predecode missed or that are stale). Returns:
|
||||
// { payload } — 200 OK
|
||||
// { failed: <marker> } — 404
|
||||
// { rateLimited: true, retryAt } — 503 rate_limited
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function decodeSingle(id, deps) {
|
||||
const {
|
||||
redis,
|
||||
cacheDir,
|
||||
generateCacheKey,
|
||||
getFromCacheNoExpiration,
|
||||
shouldUpdateCache48h
|
||||
} = 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) {
|
||||
const stale = await shouldUpdateCache48h(cacheDir, cached.cacheKey);
|
||||
if (stale) {
|
||||
// Background refresh — gate behind per-id lock so concurrent stale reads
|
||||
// from N workers fire only 1 upstream POST instead of N.
|
||||
setImmediate(async () => {
|
||||
const lockKey = LOCK_KEY(id);
|
||||
const gotLock = await acquireLock(redis, lockKey, LOCK_TTL_SEC);
|
||||
if (!gotLock) return;
|
||||
try {
|
||||
await postSingleAndPersist(id, deps);
|
||||
} finally {
|
||||
await releaseLock(redis, lockKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
return { payload: cached.payload };
|
||||
}
|
||||
// Malformed cache — fall through to refetch
|
||||
}
|
||||
|
||||
// 2. Kill-switch
|
||||
if (await isRateLimited(redis)) {
|
||||
const retryAt = await getRateLimitedUntil(redis);
|
||||
if (retryAt) return { rateLimited: true, retryAt };
|
||||
// retryAt unavailable — fall through to attempt POST
|
||||
}
|
||||
|
||||
// 3. Acquire single-flight lock; if taken, poll cache for the holder's result
|
||||
const lockKey = LOCK_KEY(id);
|
||||
const gotLock = await acquireLock(redis, lockKey, LOCK_TTL_SEC);
|
||||
if (!gotLock) {
|
||||
// Another worker (or the predecode batch) is decoding this ID. Poll the
|
||||
// disk cache for up to 30s; the holding worker will write the result.
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const recheck = await readDiskCache(id, { cacheDir, generateCacheKey, getFromCacheNoExpiration });
|
||||
if (recheck) {
|
||||
if (isFailedMarkerActive(recheck.payload)) return { failed: recheck.payload };
|
||||
if (recheck.payload.success === true) return { payload: recheck.payload };
|
||||
}
|
||||
}
|
||||
// Timeout — fallthrough; lock should have expired by now (60s TTL).
|
||||
}
|
||||
|
||||
// 4. POST single upstream
|
||||
try {
|
||||
return await postSingleAndPersist(id, deps);
|
||||
} finally {
|
||||
if (gotLock) await releaseLock(redis, lockKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper — POSTs a single ID upstream, persists the result to disk,
|
||||
* returns the decodeSingle result shape. Used by decodeSingle (path 4) and by
|
||||
* the SWR background refresh (caller already holds the lock there).
|
||||
*/
|
||||
async function postSingleAndPersist(id, deps) {
|
||||
const {
|
||||
redis,
|
||||
cacheDir,
|
||||
generateCacheKey,
|
||||
getFromCacheNoExpiration,
|
||||
saveToCache,
|
||||
axiosDarkinoRequest,
|
||||
refreshDarkinoSessionIfNeeded
|
||||
} = deps;
|
||||
|
||||
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;
|
||||
const payload = buildPayload(id, linkInfo);
|
||||
|
||||
if (!payload) {
|
||||
const marker = buildFailedMarker(id, "Lien d'embed invalide", 'embed-NN.html shape detected');
|
||||
const existing = await readDiskCache(id, { cacheDir, generateCacheKey, getFromCacheNoExpiration });
|
||||
if (!existing) {
|
||||
await saveToCache(cacheDir, cacheKey, marker).catch((cacheErr) => {
|
||||
console.warn(`[hydracker] failure marker write failed for ${id}:`, cacheErr?.message);
|
||||
});
|
||||
}
|
||||
return { failed: marker };
|
||||
}
|
||||
|
||||
await saveToCache(cacheDir, cacheKey, payload).catch((cacheErr) => {
|
||||
console.warn(`[hydracker] payload write failed for ${id}:`, cacheErr?.message);
|
||||
});
|
||||
return { payload };
|
||||
|
||||
} catch (err) {
|
||||
const rl = parseRateLimitError(err);
|
||||
if (rl.isRateLimit) {
|
||||
await armRateLimit(redis, rl.resetsAt);
|
||||
return { rateLimited: true, retryAt: rl.resetsAt };
|
||||
}
|
||||
const marker = buildFailedMarker(id, 'Lien non trouvé ou inaccessible', err?.message || '');
|
||||
const existing = await readDiskCache(id, { cacheDir, generateCacheKey, getFromCacheNoExpiration });
|
||||
if (!existing) {
|
||||
await saveToCache(cacheDir, cacheKey, marker).catch((cacheErr) => {
|
||||
console.warn(`[hydracker] failure marker write failed for ${id}:`, cacheErr?.message);
|
||||
});
|
||||
}
|
||||
return { failed: marker };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// predecodePage — fire-and-forget batch decoding for a download page.
|
||||
//
|
||||
// Called from /download/:type/:id route AFTER res.json() has been sent.
|
||||
// Errors are logged but never propagate. Designed to be wrapped in
|
||||
// setImmediate(...) so it never blocks the response.
|
||||
//
|
||||
// Skips IDs already in disk cache (success or active failed marker), already
|
||||
// in-flight (per-id lock taken), or upstream-rate-limited. Reserves the
|
||||
// remaining IDs via per-id locks, batches them in chunks of 50, POSTs to
|
||||
// hydracker, distributes results to disk, releases locks.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function predecodePage({ entries, titleId, season, episode }, deps) {
|
||||
const {
|
||||
redis,
|
||||
cacheDir,
|
||||
generateCacheKey,
|
||||
getFromCacheNoExpiration,
|
||||
saveToCache,
|
||||
shouldUpdateCache48h,
|
||||
axiosDarkinoRequest,
|
||||
refreshDarkinoSessionIfNeeded
|
||||
} = deps;
|
||||
|
||||
if (!Array.isArray(entries) || entries.length === 0) return;
|
||||
if (!titleId) return;
|
||||
|
||||
// 1. Page-level lock — prevents 2 workers from pre-decoding the same page
|
||||
const pageLockKey = PREDECODE_LOCK_KEY(titleId, season, episode);
|
||||
const gotPageLock = await acquireLock(redis, pageLockKey, PREDECODE_LOCK_TTL_SEC);
|
||||
if (!gotPageLock) return;
|
||||
|
||||
try {
|
||||
// 2. Kill-switch — skip if upstream is rate-limited
|
||||
if (await isRateLimited(redis)) return;
|
||||
|
||||
// 3. Filter IDs needing decode + reserve them via per-id locks
|
||||
const idsToBatch = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry || entry.id == null) continue;
|
||||
// Skip if entry already has a direct lien (resolved by /content/liens)
|
||||
if (typeof entry.lien === 'string' && entry.lien.length > 0) continue;
|
||||
// Skip if cache hit AND fresh AND not a failed marker
|
||||
const cached = await readDiskCache(entry.id, { cacheDir, generateCacheKey, getFromCacheNoExpiration });
|
||||
if (cached) {
|
||||
if (isFailedMarkerActive(cached.payload)) continue;
|
||||
if (cached.payload.success === true) {
|
||||
const stale = await shouldUpdateCache48h(cacheDir, cached.cacheKey);
|
||||
if (!stale) continue;
|
||||
// Stale, include in batch to refresh
|
||||
}
|
||||
}
|
||||
// Reserve via per-id lock
|
||||
const got = await acquireLock(redis, LOCK_KEY(entry.id), LOCK_TTL_SEC);
|
||||
if (!got) continue; // another worker holds this id
|
||||
idsToBatch.push(String(entry.id));
|
||||
}
|
||||
|
||||
if (idsToBatch.length === 0) return;
|
||||
|
||||
// 4. Chunked batch POST (50 IDs per POST per upstream limit)
|
||||
const chunks = chunk(idsToBatch, BATCH_CHUNK_SIZE);
|
||||
for (const ids of chunks) {
|
||||
try {
|
||||
await refreshDarkinoSessionIfNeeded();
|
||||
const resp = await axiosDarkinoRequest({
|
||||
method: 'post',
|
||||
url: `/api/v1/download-premium/${ids.join(',')}`
|
||||
});
|
||||
|
||||
const liens = Array.isArray(resp.data?.liens) ? resp.data.liens : [];
|
||||
const byId = new Map();
|
||||
for (const li of liens) {
|
||||
if (li && li.id != null) byId.set(String(li.id), li);
|
||||
}
|
||||
|
||||
for (const id of ids) {
|
||||
const linkInfo = byId.get(String(id)) || null;
|
||||
const cacheKey = generateCacheKey(`darkiworld_decode_v2_${id}`);
|
||||
|
||||
if (!linkInfo) {
|
||||
const marker = buildFailedMarker(id, 'Absent de la réponse batch hydracker', '');
|
||||
await saveToCache(cacheDir, cacheKey, marker).catch((cacheErr) => {
|
||||
console.warn(`[hydracker] failure marker write failed for ${id}:`, cacheErr?.message);
|
||||
});
|
||||
} else {
|
||||
const payload = buildPayload(id, linkInfo);
|
||||
if (!payload) {
|
||||
const marker = buildFailedMarker(id, "Lien d'embed invalide", 'embed-NN.html shape detected');
|
||||
const existing = await readDiskCache(id, { cacheDir, generateCacheKey, getFromCacheNoExpiration });
|
||||
if (!existing) {
|
||||
await saveToCache(cacheDir, cacheKey, marker).catch((cacheErr) => {
|
||||
console.warn(`[hydracker] failure marker write failed for ${id}:`, cacheErr?.message);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await saveToCache(cacheDir, cacheKey, payload).catch((cacheErr) => {
|
||||
console.warn(`[hydracker] payload write failed for ${id}:`, cacheErr?.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
await releaseLock(redis, LOCK_KEY(id));
|
||||
}
|
||||
} catch (err) {
|
||||
const rl = parseRateLimitError(err);
|
||||
if (rl.isRateLimit) {
|
||||
await armRateLimit(redis, rl.resetsAt);
|
||||
for (const id of ids) await releaseLock(redis, LOCK_KEY(id));
|
||||
return; // skip remaining chunks
|
||||
}
|
||||
console.warn(`[hydracker] predecode chunk failed (${ids.length} ids):`, err?.message || err);
|
||||
for (const id of ids) await releaseLock(redis, LOCK_KEY(id));
|
||||
// continue with next chunk
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await releaseLock(redis, pageLockKey);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
// pure helpers
|
||||
chunk,
|
||||
buildPayload,
|
||||
buildFailedMarker,
|
||||
parseRateLimitError,
|
||||
isFailedMarkerActive,
|
||||
// Redis primitives
|
||||
acquireLock,
|
||||
releaseLock,
|
||||
isRateLimited,
|
||||
getRateLimitedUntil,
|
||||
armRateLimit,
|
||||
// disk cache
|
||||
readDiskCache,
|
||||
// orchestrators
|
||||
decodeSingle,
|
||||
predecodePage,
|
||||
postSingleAndPersist,
|
||||
// constants
|
||||
LOCK_KEY,
|
||||
PREDECODE_LOCK_KEY,
|
||||
RATE_LIMIT_KEY,
|
||||
BATCH_CHUNK_SIZE,
|
||||
LOCK_TTL_SEC,
|
||||
PREDECODE_LOCK_TTL_SEC,
|
||||
FAILED_MARKER_TTL_MS,
|
||||
STALE_REVALIDATE_MS
|
||||
};
|
||||
397
API/Mainapi/utils/hydrackerQueue.js
Normal file
397
API/Mainapi/utils/hydrackerQueue.js
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
/**
|
||||
* Hydracker queue-based decoding orchestrator.
|
||||
*
|
||||
* /decode/:id at click = cache hit OR SADD queue + 202 (frontend retry).
|
||||
* Drain worker in worker process: when queue size >= 50, SPOP 50 atomically,
|
||||
* batch POST hydracker, persist results to disk cache, release lock.
|
||||
*
|
||||
* Cache lives ONLY on disk (DOWNLOAD_CACHE_DIR). Redis carries:
|
||||
* - hydracker:queue:pending (SET, dedup-safe)
|
||||
* - hydracker:worker_lock (string, 60s TTL)
|
||||
* - hydracker:rate_limited_until (string, EXPIREAT)
|
||||
*
|
||||
* See: docs/superpowers/specs/2026-05-01-hydracker-queue-decoding-design.md
|
||||
*/
|
||||
|
||||
const fsp = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
// Redis keys
|
||||
const QUEUE_KEY = 'hydracker:queue:pending';
|
||||
const WORKER_LOCK_KEY = 'hydracker:worker_lock';
|
||||
const RATE_LIMIT_KEY = 'hydracker:rate_limited_until';
|
||||
|
||||
// Tunables
|
||||
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
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helpers (no Redis, no fs side-effects)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function chunk(arr, size) {
|
||||
if (!Array.isArray(arr) || size <= 0) return [];
|
||||
const out = [];
|
||||
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildPayload(id, linkInfo) {
|
||||
const rawLien = typeof linkInfo?.lien === 'string' ? linkInfo.lien : '';
|
||||
const provider = /darkibox\.com/i.test(rawLien) ? 'darkibox' : 'direct';
|
||||
|
||||
let resolvedUrl;
|
||||
if (provider === 'darkibox') {
|
||||
const m = rawLien.match(/darkibox\.com\/(?:embed-)?([a-z0-9]{12,})(?:\.html)?/i);
|
||||
const code = m ? m[1] : null;
|
||||
resolvedUrl = code
|
||||
? `https://darkibox.com/embed-${code}.html`
|
||||
: (rawLien || `https://darkibox.com/embed-${id}.html`);
|
||||
} else {
|
||||
resolvedUrl = rawLien || `https://darkibox.com/embed-${id}.html`;
|
||||
}
|
||||
|
||||
// Sparse linkInfo (empty lien AND no taille) → fallback URL is /embed-{id}.html
|
||||
// which matches the invalid embed pattern. Return null to signal upstream-failure
|
||||
// path to callers — distinguishes truly sparse linkInfo from a real file with
|
||||
// empty lien that carries metadata.
|
||||
const isInvalidEmbed = /\/embed-\d+\.html$/i.test(resolvedUrl);
|
||||
if (isInvalidEmbed && !rawLien && linkInfo?.taille === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const embedUrlPayload = linkInfo
|
||||
? { ...linkInfo, lien: resolvedUrl }
|
||||
: resolvedUrl;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
id: String(id),
|
||||
provider,
|
||||
embed_url: embedUrlPayload,
|
||||
metadata: linkInfo ? {
|
||||
language: undefined,
|
||||
quality: undefined,
|
||||
sub: undefined,
|
||||
size: linkInfo?.taille,
|
||||
upload_date: linkInfo?.created_at
|
||||
} : null
|
||||
};
|
||||
}
|
||||
|
||||
function buildFailedMarker(id, errorMsg, debugMsg) {
|
||||
return {
|
||||
failed: true,
|
||||
failedAt: Date.now(),
|
||||
id: String(id),
|
||||
error: errorMsg || 'Lien non trouvé ou inaccessible',
|
||||
debug: debugMsg || ''
|
||||
};
|
||||
}
|
||||
|
||||
function parseRateLimitError(error) {
|
||||
const data = error?.response?.data;
|
||||
if (!data || data.error !== 'daily_api_limit_exceeded') {
|
||||
return { isRateLimit: false, resetsAt: null };
|
||||
}
|
||||
const resetsAtIso = data.resets_at;
|
||||
const resetsAt = resetsAtIso ? Date.parse(resetsAtIso) : null;
|
||||
return { isRateLimit: true, resetsAt: Number.isFinite(resetsAt) ? resetsAt : null };
|
||||
}
|
||||
|
||||
function isFailedMarkerActive(payload) {
|
||||
return Boolean(
|
||||
payload?.failed === true &&
|
||||
typeof payload.failedAt === 'number' &&
|
||||
(Date.now() - payload.failedAt < FAILED_MARKER_TTL_MS)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Redis primitives — coordination only, NEVER cache data here.
|
||||
// All functions tolerate Redis errors silently so the system degrades
|
||||
// gracefully when Redis is unavailable.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function enqueueId(redis, id) {
|
||||
if (!redis) return false;
|
||||
const idStr = id == null ? '' : String(id);
|
||||
if (!idStr) return false;
|
||||
try {
|
||||
await redis.sadd(QUEUE_KEY, idStr);
|
||||
return true;
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
async function getQueueSize(redis) {
|
||||
if (!redis) return 0;
|
||||
try { return await redis.scard(QUEUE_KEY); }
|
||||
catch (e) { return 0; }
|
||||
}
|
||||
|
||||
async function popBatch(redis, size) {
|
||||
if (!redis) return [];
|
||||
try {
|
||||
const ids = await redis.spop(QUEUE_KEY, size);
|
||||
return Array.isArray(ids) ? ids : [];
|
||||
} catch (e) { return []; }
|
||||
}
|
||||
|
||||
async function requeueIds(redis, ids) {
|
||||
if (!redis || !Array.isArray(ids) || ids.length === 0) return;
|
||||
try { await redis.sadd(QUEUE_KEY, ...ids.map(String)); }
|
||||
catch (e) { /* silent */ }
|
||||
}
|
||||
|
||||
async function isRateLimited(redis) {
|
||||
if (!redis) return false;
|
||||
try { return Boolean(await redis.exists(RATE_LIMIT_KEY)); }
|
||||
catch (e) { return false; }
|
||||
}
|
||||
|
||||
async function getRateLimitedUntil(redis) {
|
||||
if (!redis) return null;
|
||||
try {
|
||||
const v = await redis.get(RATE_LIMIT_KEY);
|
||||
return v ? Number(v) : null;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
async function armRateLimit(redis, resetsAtMs) {
|
||||
if (!redis || !resetsAtMs) return;
|
||||
try {
|
||||
const ttlSec = Math.max(1, Math.ceil((resetsAtMs - Date.now()) / 1000));
|
||||
await redis.set(RATE_LIMIT_KEY, String(resetsAtMs), 'EX', ttlSec);
|
||||
console.warn(`[hydracker] daily quota exhausted, kill-switch armed until ${new Date(resetsAtMs).toISOString()}`);
|
||||
} catch (e) { /* silent */ }
|
||||
}
|
||||
|
||||
async function acquireWorkerLock(redis) {
|
||||
if (!redis) return false;
|
||||
try {
|
||||
const result = await redis.set(WORKER_LOCK_KEY, String(process.pid), 'NX', 'EX', WORKER_LOCK_TTL_SEC);
|
||||
return result === 'OK';
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
async function releaseWorkerLock(redis) {
|
||||
if (!redis) return;
|
||||
try { await redis.del(WORKER_LOCK_KEY); }
|
||||
catch (e) { /* silent */ }
|
||||
}
|
||||
|
||||
async function readDiskCache(id, { cacheDir, generateCacheKey, getFromCacheNoExpiration }) {
|
||||
const cacheKey = generateCacheKey(`darkiworld_decode_v2_${id}`);
|
||||
try {
|
||||
const payload = await getFromCacheNoExpiration(cacheDir, cacheKey);
|
||||
if (!payload) return null;
|
||||
const filePath = path.join(cacheDir, `${cacheKey}.json`);
|
||||
const stats = await fsp.stat(filePath);
|
||||
return { payload, mtimeMs: stats.mtime.getTime(), cacheKey };
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// decodeRequest — handles a single /decode/:id click from the route handler.
|
||||
// Never POSTs upstream; just reads cache or enqueues. Returns:
|
||||
// { payload } — 200 OK
|
||||
// { failed: <marker> } — 404
|
||||
// { queued: true, queue_size: N } — 202 (frontend retries)
|
||||
// { rateLimited: true, retryAt } — 503 rate_limited
|
||||
// { unavailable: true } — 503 queue_unavailable (Redis down at enqueue)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function decodeRequest(id, deps) {
|
||||
const {
|
||||
redis,
|
||||
cacheDir,
|
||||
generateCacheKey,
|
||||
getFromCacheNoExpiration,
|
||||
shouldUpdateCache48h
|
||||
} = 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) {
|
||||
// Stale check — return immediately, optionally enqueue for refresh
|
||||
const stale = await shouldUpdateCache48h(cacheDir, cached.cacheKey);
|
||||
if (stale) {
|
||||
// Refresh asynchronously by adding to the queue.
|
||||
// No await — fire-and-forget, this is best-effort.
|
||||
enqueueId(redis, id).catch(() => {});
|
||||
}
|
||||
return { payload: cached.payload };
|
||||
}
|
||||
// Malformed cache — fall through to enqueue
|
||||
}
|
||||
|
||||
// 2. Kill-switch check
|
||||
if (await isRateLimited(redis)) {
|
||||
const retryAt = await getRateLimitedUntil(redis);
|
||||
if (retryAt) {
|
||||
return { rateLimited: true, retryAt };
|
||||
}
|
||||
// retryAt unavailable (Redis flake, key just expired) — fall through to enqueue
|
||||
// rather than serving an undefined retry hint to the user.
|
||||
}
|
||||
|
||||
// 3. Enqueue (or fail fast if Redis is unavailable)
|
||||
const enqueued = await enqueueId(redis, id);
|
||||
if (!enqueued) {
|
||||
// Redis unreachable — caller can't queue, so polling would never succeed.
|
||||
// Surface as a 503 with a distinct error code so the frontend can show a
|
||||
// clear "infra issue, retry later" message rather than spinning 5 min.
|
||||
return { unavailable: true };
|
||||
}
|
||||
const queue_size = await getQueueSize(redis);
|
||||
return { queued: true, queue_size };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// drainQueueOnce — one tick of the drain loop. Returns:
|
||||
// { drained: false, reason: 'rate_limited' | 'queue_too_small' | 'lock_taken' }
|
||||
// { drained: true, batchSize: N }
|
||||
// { drained: false, error: <msg> }
|
||||
//
|
||||
// Caller (timer in every HTTP worker process) invokes this every ~5s; only one worker drains per tick via Redis lock.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function drainQueueOnce(deps) {
|
||||
const {
|
||||
redis,
|
||||
cacheDir,
|
||||
generateCacheKey,
|
||||
getFromCacheNoExpiration,
|
||||
saveToCache,
|
||||
axiosDarkinoRequest,
|
||||
refreshDarkinoSessionIfNeeded
|
||||
} = deps;
|
||||
|
||||
// 1. Skip if rate-limited
|
||||
if (await isRateLimited(redis)) {
|
||||
return { drained: false, reason: 'rate_limited' };
|
||||
}
|
||||
|
||||
// 2. Skip if queue too small
|
||||
const sizeBefore = await getQueueSize(redis);
|
||||
if (sizeBefore < BATCH_SIZE) {
|
||||
return { drained: false, reason: 'queue_too_small', queueSize: sizeBefore };
|
||||
}
|
||||
|
||||
// 3. Try to acquire worker lock
|
||||
const gotLock = await acquireWorkerLock(redis);
|
||||
if (!gotLock) return { drained: false, reason: 'lock_taken' };
|
||||
|
||||
let popped = [];
|
||||
try {
|
||||
// 4. Re-check size after lock (anti-race)
|
||||
const sizeAfter = await getQueueSize(redis);
|
||||
if (sizeAfter < BATCH_SIZE) {
|
||||
return { drained: false, reason: 'queue_too_small', queueSize: sizeAfter };
|
||||
}
|
||||
|
||||
// 5. SPOP 50 atomically
|
||||
popped = await popBatch(redis, BATCH_SIZE);
|
||||
if (popped.length === 0) {
|
||||
return { drained: false, reason: 'queue_empty_after_pop' };
|
||||
}
|
||||
|
||||
// 6. POST hydracker
|
||||
await refreshDarkinoSessionIfNeeded();
|
||||
const resp = await axiosDarkinoRequest({
|
||||
method: 'post',
|
||||
url: `/api/v1/download-premium/${popped.join(',')}`
|
||||
});
|
||||
|
||||
// 7. Distribute results to disk cache
|
||||
const liens = Array.isArray(resp.data?.liens) ? resp.data.liens : [];
|
||||
const byId = new Map();
|
||||
for (const li of liens) {
|
||||
if (li && li.id != null) byId.set(String(li.id), li);
|
||||
}
|
||||
|
||||
for (const id of popped) {
|
||||
const cacheKey = generateCacheKey(`darkiworld_decode_v2_${id}`);
|
||||
const linkInfo = byId.get(String(id)) || null;
|
||||
|
||||
if (!linkInfo) {
|
||||
// Absent from response → write failure marker
|
||||
const marker = buildFailedMarker(id, 'Absent de la réponse batch hydracker', '');
|
||||
await saveToCache(cacheDir, cacheKey, marker).catch((cacheErr) => {
|
||||
console.warn(`[hydracker] failure marker write failed for ${id}:`, cacheErr?.message);
|
||||
});
|
||||
} else {
|
||||
const payload = buildPayload(id, linkInfo);
|
||||
if (!payload) {
|
||||
// Invalid embed shape → write failure marker (only if no prior cache)
|
||||
const existing = await readDiskCache(id, { cacheDir, generateCacheKey, getFromCacheNoExpiration });
|
||||
if (!existing) {
|
||||
const marker = buildFailedMarker(id, 'Lien d\'embed invalide', 'embed-NN.html shape detected');
|
||||
await saveToCache(cacheDir, cacheKey, marker).catch((cacheErr) => {
|
||||
console.warn(`[hydracker] failure marker write failed for ${id}:`, cacheErr?.message);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await saveToCache(cacheDir, cacheKey, payload).catch((cacheErr) => {
|
||||
console.warn(`[hydracker] payload write failed for ${id}:`, cacheErr?.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { drained: true, batchSize: popped.length };
|
||||
|
||||
} catch (err) {
|
||||
console.warn(`[hydracker] drain failed (popped=${popped.length}):`, err?.message || err);
|
||||
// 8. Error handling — requeue popped IDs so nothing is lost
|
||||
const rl = parseRateLimitError(err);
|
||||
if (rl.isRateLimit) {
|
||||
await armRateLimit(redis, rl.resetsAt);
|
||||
await requeueIds(redis, popped);
|
||||
return { drained: false, reason: 'rate_limited', requeued: popped.length };
|
||||
}
|
||||
// Other error
|
||||
await requeueIds(redis, popped);
|
||||
return { drained: false, error: err?.message || String(err), requeued: popped.length };
|
||||
} finally {
|
||||
await releaseWorkerLock(redis);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
// helpers
|
||||
chunk,
|
||||
buildPayload,
|
||||
buildFailedMarker,
|
||||
parseRateLimitError,
|
||||
isFailedMarkerActive,
|
||||
// Redis primitives
|
||||
enqueueId,
|
||||
getQueueSize,
|
||||
popBatch,
|
||||
requeueIds,
|
||||
isRateLimited,
|
||||
getRateLimitedUntil,
|
||||
armRateLimit,
|
||||
acquireWorkerLock,
|
||||
releaseWorkerLock,
|
||||
// orchestrators
|
||||
decodeRequest,
|
||||
drainQueueOnce,
|
||||
// disk cache
|
||||
readDiskCache,
|
||||
// constants
|
||||
QUEUE_KEY,
|
||||
WORKER_LOCK_KEY,
|
||||
RATE_LIMIT_KEY,
|
||||
BATCH_SIZE,
|
||||
WORKER_LOCK_TTL_SEC,
|
||||
FAILED_MARKER_TTL_MS,
|
||||
STALE_REVALIDATE_MS
|
||||
};
|
||||
|
|
@ -664,8 +664,8 @@ async function makeLecteurVideoRequest(targetUrl, options = {}) {
|
|||
ja3: CHROME_JA3,
|
||||
userAgent: CHROME_UA,
|
||||
headers: {
|
||||
Referer: "https://coflix.click/",
|
||||
Origin: "https://coflix.click",
|
||||
Referer: "https://coflix.date/",
|
||||
Origin: "https://coflix.date",
|
||||
Accept:
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "fr-FR,fr;q=0.9",
|
||||
|
|
@ -714,8 +714,8 @@ async function makeLecteurVideoRequest(targetUrl, options = {}) {
|
|||
userAgent: CHROME_UA,
|
||||
proxy: proxyUrl,
|
||||
headers: {
|
||||
Referer: "https://coflix.click/",
|
||||
Origin: "https://coflix.click",
|
||||
Referer: "https://coflix.date/",
|
||||
Origin: "https://coflix.date",
|
||||
Accept:
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "fr-FR,fr;q=0.9",
|
||||
|
|
|
|||
|
|
@ -938,7 +938,7 @@ class ProxyServer:
|
|||
RE_NUMERIC_CDN = re.compile(r'([a-z0-9]+\.\d+\.net|epicquest|questher|hero.*\.com|trainer\.net|dishtrainer)', re.IGNORECASE)
|
||||
RE_DOODSTREAM = re.compile(r'd0000d\.com|doodstream\.com|dood\.(cx|la|pm|sh|so|to|watch|wf|yt|re)|cloudatacdn\.com|dsvplay\.com|doply\.net', re.IGNORECASE)
|
||||
RE_DOODSTREAM_PASS = re.compile(r'/pass_md5/[\w-]+/(?P<token>[\w-]+)')
|
||||
RE_SEEKSTREAMING = re.compile(r'embed4me\.com|lpayer\.embed4me\.com|servicecatalog\.site|embedseek\.com', re.IGNORECASE)
|
||||
RE_SEEKSTREAMING = re.compile(r'embed4me\.com|lpayer\.embed4me\.com|servicecatalog\.site|technicalcatalog\.site|embedseek\.com|seekplayer\.me', re.IGNORECASE)
|
||||
RE_RANGE = re.compile(r'bytes=(\d+)-(\d*)')
|
||||
RE_M3U8_URI_DQ = re.compile(r'URI="([^"]+)"', re.IGNORECASE)
|
||||
RE_M3U8_URI_SQ = re.compile(r"URI='([^']+)'", re.IGNORECASE)
|
||||
|
|
@ -2746,27 +2746,32 @@ class ProxyServer:
|
|||
self.vip_cache.set(access_key, False)
|
||||
return False
|
||||
|
||||
# Check expiration (if set)
|
||||
# Check expiration (if set). access_keys.expires_at is BIGINT (Unix epoch ms),
|
||||
# but legacy/admin paths could still produce DATETIME or ISO strings — handle all three.
|
||||
# Note: do NOT fall back to datetime.fromisoformat(str(int)) — Python 3.11+ parses
|
||||
# e.g. "1808043002043" as year 1808, marking every non-null key as expired.
|
||||
if expires_at is not None:
|
||||
now = datetime.now(timezone.utc)
|
||||
if isinstance(expires_at, datetime):
|
||||
# MySQL returns naive datetimes (no tz) – assume UTC
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
if expires_at < now:
|
||||
self.vip_cache.set(access_key, False)
|
||||
return False
|
||||
else:
|
||||
# expires_at might be a string
|
||||
exp = None
|
||||
if isinstance(expires_at, bool):
|
||||
pass # bool is an int subclass — ignore
|
||||
elif isinstance(expires_at, (int, float)):
|
||||
try:
|
||||
exp = datetime.fromisoformat(str(expires_at))
|
||||
if exp.tzinfo is None:
|
||||
exp = exp.replace(tzinfo=timezone.utc)
|
||||
if exp < now:
|
||||
self.vip_cache.set(access_key, False)
|
||||
return False
|
||||
exp = datetime.fromtimestamp(expires_at / 1000, tz=timezone.utc)
|
||||
except (ValueError, OSError, OverflowError):
|
||||
exp = None
|
||||
elif isinstance(expires_at, datetime):
|
||||
exp = expires_at if expires_at.tzinfo else expires_at.replace(tzinfo=timezone.utc)
|
||||
elif isinstance(expires_at, str):
|
||||
try:
|
||||
parsed = datetime.fromisoformat(expires_at)
|
||||
exp = parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
exp = None
|
||||
|
||||
if exp is not None and exp < now:
|
||||
self.vip_cache.set(access_key, False)
|
||||
return False
|
||||
|
||||
# Key is valid
|
||||
self.vip_cache.set(access_key, True)
|
||||
|
|
@ -3239,15 +3244,19 @@ class ProxyServer:
|
|||
return full_url
|
||||
|
||||
async def _extract_uqload_mp4_url(self, embed_url: str) -> str:
|
||||
"""Extract MP4 URL from UQLOAD embed"""
|
||||
"""Extract video URL from UQLOAD embed.
|
||||
|
||||
Prefers HLS master.m3u8 (audio + video) over the v.mp4 fallback,
|
||||
because Uqload's v.mp4 is currently a video-only track.
|
||||
"""
|
||||
validated = self._validate_uqload_url(embed_url)
|
||||
urls = [validated, validated.replace('embed-', '')]
|
||||
|
||||
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 Chrome/91.0.0.0',
|
||||
'Accept': 'text/html,*/*'
|
||||
}
|
||||
|
||||
|
||||
html = None
|
||||
for url in urls:
|
||||
try:
|
||||
|
|
@ -3259,18 +3268,22 @@ class ProxyServer:
|
|||
break
|
||||
except:
|
||||
continue
|
||||
|
||||
|
||||
if not html:
|
||||
raise ValueError('No content from UQLOAD')
|
||||
|
||||
|
||||
if 'File was deleted' in html:
|
||||
raise ValueError('Video deleted')
|
||||
|
||||
matches = re.findall(r'https?://.+/v\.mp4', html)
|
||||
if not matches:
|
||||
raise ValueError('MP4 URL not found')
|
||||
|
||||
return matches[0]
|
||||
|
||||
m3u8_matches = re.findall(r'https?://[^\s"\'<>]+\.m3u8[^\s"\'<>]*', html)
|
||||
if m3u8_matches:
|
||||
return m3u8_matches[0]
|
||||
|
||||
mp4_matches = re.findall(r'https?://[^\s"\'<>]+/v\.mp4', html)
|
||||
if not mp4_matches:
|
||||
raise ValueError('Video URL not found')
|
||||
|
||||
return mp4_matches[0]
|
||||
|
||||
async def uqload_extract_handler(self, request: Request) -> Response:
|
||||
"""UQLOAD extraction"""
|
||||
|
|
@ -3392,7 +3405,20 @@ class ProxyServer:
|
|||
return web.json_response({'error': str(e)}, status=500, headers=CORS_HEADERS)
|
||||
|
||||
# ===== SeekStreaming (Embed4me) Extraction =====
|
||||
|
||||
|
||||
async def _check_url_alive(self, url: str, headers: Dict, timeout_s: int = 4) -> bool:
|
||||
"""Quick liveness check: True if upstream returns 2xx within timeout."""
|
||||
try:
|
||||
async with self.sessions['no_ssl'].get(
|
||||
url,
|
||||
headers=headers,
|
||||
timeout=ClientTimeout(total=timeout_s),
|
||||
allow_redirects=True,
|
||||
) as r:
|
||||
return 200 <= r.status < 300
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _decrypt_seekstreaming_data(self, hex_str: str) -> Optional[str]:
|
||||
"""Decrypt AES-CBC encrypted data from seekstreaming/embed4me API"""
|
||||
try:
|
||||
|
|
@ -3494,13 +3520,34 @@ class ProxyServer:
|
|||
# Pass the correct origin/referer to the proxy
|
||||
proxy_queries = f"&referer=https%3A//{api_domain}/&origin=https%3A//{api_domain}"
|
||||
|
||||
if raw_cf:
|
||||
result['url'] = f"{PROXY_BASE}/seekstreaming-proxy?url={urllib.parse.quote(raw_cf)}{proxy_queries}"
|
||||
if raw_source:
|
||||
result['ip_url'] = f"{PROXY_BASE}/seekstreaming-proxy?url={urllib.parse.quote(raw_source)}{proxy_queries}"
|
||||
|
||||
if not raw_cf and not raw_source:
|
||||
return web.json_response({'error': 'No video source found'}, status=404, headers=CORS_HEADERS)
|
||||
|
||||
# Liveness check: probe both upstreams in parallel and only return
|
||||
# one that actually responds. CF-fronted hosts (technicalcatalog.site,
|
||||
# servicecatalog.site, ...) can get flagged as phishing and 403
|
||||
# globally; the IP-direct URL (signed token + expiry) keeps working.
|
||||
# Reuse the player domain from the request as Referer/Origin.
|
||||
upstream_headers = {
|
||||
'Accept': '*/*',
|
||||
'Referer': f'https://{api_domain}/',
|
||||
'Origin': f'https://{api_domain}',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36',
|
||||
}
|
||||
source_alive, cf_alive = await asyncio.gather(
|
||||
self._check_url_alive(raw_source, upstream_headers) if raw_source else asyncio.sleep(0, result=False),
|
||||
self._check_url_alive(raw_cf, upstream_headers) if raw_cf else asyncio.sleep(0, result=False),
|
||||
)
|
||||
|
||||
if not source_alive and not cf_alive:
|
||||
return web.json_response(
|
||||
{'error': 'All upstream sources failed liveness check (likely 403/blocked)'},
|
||||
status=502,
|
||||
headers=CORS_HEADERS,
|
||||
)
|
||||
|
||||
chosen = raw_source if source_alive else raw_cf
|
||||
result['url'] = f"{PROXY_BASE}/seekstreaming-proxy?url={urllib.parse.quote(chosen)}{proxy_queries}"
|
||||
|
||||
self.seekstreaming_cache.set(cache_key, result)
|
||||
resp = web.json_response(result)
|
||||
|
|
|
|||
25
package-lock.json
generated
25
package-lock.json
generated
|
|
@ -24,6 +24,7 @@
|
|||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-hook/resize-observer": "^2.0.2",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"axios": "^1.13.2",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"chart.js": "^4.4.8",
|
||||
|
|
@ -59,6 +60,7 @@
|
|||
"react-i18next": "^16.5.4",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-input-mask": "^2.0.4",
|
||||
"react-loading-skeleton": "^3.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"react-snowfall": "^2.3.0",
|
||||
|
|
@ -4159,12 +4161,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.13.13",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.13.tgz",
|
||||
"integrity": "sha512-4o6oPMDvQv+9gMi8rE6gWmsOjtUZUYIJHv7EB+GblyYdi8U6OqLl8rhHWIUZSL1dUU2dPwTdTgybCKf9EjIrQg==",
|
||||
"version": "3.13.24",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz",
|
||||
"integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.13.13"
|
||||
"@tanstack/virtual-core": "3.14.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
|
|
@ -4176,9 +4178,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.13.13",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.13.tgz",
|
||||
"integrity": "sha512-uQFoSdKKf5S8k51W5t7b2qpfkyIbdHMzAn+AMQvHPxKUPeo1SsGaA4JRISQT87jm28b7z8OEqPcg1IOZagQHcA==",
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz",
|
||||
"integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
|
|
@ -9505,6 +9507,15 @@
|
|||
"react": ">=16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-loading-skeleton": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-loading-skeleton/-/react-loading-skeleton-3.5.0.tgz",
|
||||
"integrity": "sha512-gxxSyLbrEAdXTKgfbpBEFZCO/P153DnqSCQau2+o6lNy1jgMRr2MmRmOzMmyrwSaSYLRB8g7b0waYPmUjz7IhQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-markdown": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-hook/resize-observer": "^2.0.2",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"axios": "^1.13.2",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"chart.js": "^4.4.8",
|
||||
|
|
@ -70,6 +71,7 @@
|
|||
"react-i18next": "^16.5.4",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-input-mask": "^2.0.4",
|
||||
"react-loading-skeleton": "^3.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"react-snowfall": "^2.3.0",
|
||||
|
|
|
|||
24
src/App.tsx
24
src/App.tsx
|
|
@ -986,8 +986,13 @@ const PersistenceManager = () => {
|
|||
});
|
||||
};
|
||||
|
||||
// BroadcastChannel for cross-tab sync (replaces 2s/5s polling on Safari/Firefox).
|
||||
// Other tabs receive { key, value } and reconcile against their own prevValuesRefLocal.
|
||||
// BroadcastChannel for cross-tab sync. Receivers ONLY refresh prev so
|
||||
// future local diffs are correct against shared localStorage state — they
|
||||
// must NOT enqueue sync ops here. The originating tab handles its own
|
||||
// sync; re-syncing in receivers races with this tab's pending 1s flush
|
||||
// and can interleave a `remove` between a user's `arrayAdd X` and the
|
||||
// backing `arrayAdd A,B,C,D` (when another tab's loadProfileData wipes
|
||||
// and re-applies the syncable keys), losing X from the backend.
|
||||
const supportsBroadcastChannel = typeof BroadcastChannel !== 'undefined';
|
||||
let channel: BroadcastChannel | null = null;
|
||||
if (supportsBroadcastChannel && isLocalStorageAvailable) {
|
||||
|
|
@ -998,12 +1003,11 @@ const PersistenceManager = () => {
|
|||
const key = data.key;
|
||||
const value = data.value as string | null | undefined;
|
||||
if (typeof key !== 'string') return;
|
||||
const oldVal = prevValuesRefLocal.current.get(key) ?? null;
|
||||
const newVal = (value === undefined ? null : value) as string | null;
|
||||
if (oldVal !== newVal) {
|
||||
if (newVal === null) {
|
||||
prevValuesRefLocal.current.delete(key);
|
||||
} else {
|
||||
prevValuesRefLocal.current.set(key, newVal);
|
||||
pendingDiffs.push({ key, oldVal, newVal });
|
||||
scheduleDiffDrain();
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
|
|
@ -1127,16 +1131,14 @@ const PersistenceManager = () => {
|
|||
prevValuesRefLocal.current.clear();
|
||||
} as any;
|
||||
|
||||
// Same rule as the BroadcastChannel handler: refresh prev only, never
|
||||
// enqueue sync ops. See the channel.onmessage comment above for the
|
||||
// race that re-syncing here re-introduces.
|
||||
const storageListener = (e: StorageEvent) => {
|
||||
if (!e.key) return;
|
||||
// Skip sync during profile data loading (but allow on watch routes)
|
||||
if (isProfileDataLoadingRef.current) return;
|
||||
|
||||
if (e.newValue === null) {
|
||||
processRemove(e.key);
|
||||
prevValuesRefLocal.current.delete(e.key);
|
||||
} else {
|
||||
processSet(e.key, e.oldValue, e.newValue);
|
||||
prevValuesRefLocal.current.set(e.key, e.newValue);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1484,7 +1484,8 @@ const CommentsSection: React.FC<CommentsSectionProps> = ({ contentType, contentI
|
|||
`${MAIN_API}/api/comments/${commentId}`,
|
||||
{
|
||||
content: editContent,
|
||||
isSpoiler: editIsSpoiler
|
||||
isSpoiler: editIsSpoiler,
|
||||
profileId
|
||||
},
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
|
|
@ -1516,7 +1517,8 @@ const CommentsSection: React.FC<CommentsSectionProps> = ({ contentType, contentI
|
|||
`${MAIN_API}/api/comments/replies/${replyId}`,
|
||||
{
|
||||
content: editContent,
|
||||
isSpoiler: editIsSpoiler
|
||||
isSpoiler: editIsSpoiler,
|
||||
profileId
|
||||
},
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9112,6 +9112,10 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({
|
|||
transition: 'filter 0.5s ease'
|
||||
}}
|
||||
playsInline
|
||||
// Required so MediaElementAudioSourceNode (volume booster + audio enhancer)
|
||||
// doesn't output silence on cross-origin proxied media. All proxies return
|
||||
// Access-Control-Allow-Origin: *, so the request still succeeds.
|
||||
crossOrigin="anonymous"
|
||||
{...{ referrerPolicy: "strict-origin-when-cross-origin" } as React.VideoHTMLAttributes<HTMLVideoElement>}
|
||||
poster={poster}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,61 @@ import {
|
|||
} from '../utils/castUtils';
|
||||
import { PROXIES_EMBED_API } from '../config/runtime';
|
||||
|
||||
type ProbedStreamFormat = 'hls' | 'mpegts' | 'dash' | 'unknown';
|
||||
|
||||
/**
|
||||
* Pre-flight a stream URL: read first chunk + Content-Type to detect format.
|
||||
* Used for /proxy/ URLs that can redirect to extensionless raw streams (e.g.,
|
||||
* raw MPEG-TS served as application/octet-stream). hls.js would hang forever
|
||||
* on such streams because xhr.onload never fires for an infinite response.
|
||||
*
|
||||
* Returns 'unknown' on timeout/error/CORS so callers fall back to extension-based detection.
|
||||
*/
|
||||
const probeStreamFormat = async (url: string, timeoutMs = 3000): Promise<ProbedStreamFormat> => {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
method: 'GET',
|
||||
mode: 'cors',
|
||||
credentials: 'omit',
|
||||
});
|
||||
|
||||
const contentType = (response.headers.get('content-type') || '').toLowerCase();
|
||||
if (contentType.includes('mpegurl') || contentType.includes('m3u8')) return 'hls';
|
||||
if (contentType.includes('dash+xml')) return 'dash';
|
||||
if (contentType.includes('mp2t') || contentType.includes('mpegts')) return 'mpegts';
|
||||
|
||||
if (!response.body) return 'unknown';
|
||||
reader = response.body.getReader();
|
||||
const { value } = await reader.read();
|
||||
if (!value || value.length === 0) return 'unknown';
|
||||
|
||||
const head = value.subarray(0, Math.min(256, value.length));
|
||||
const text = new TextDecoder('utf-8', { fatal: false }).decode(head);
|
||||
|
||||
if (text.startsWith('#EXTM3U')) return 'hls';
|
||||
if (text.includes('<MPD ') || text.includes('<MPD>')) return 'dash';
|
||||
|
||||
// Raw MPEG-TS: 0x47 sync at offset 0; verify at 188 if available.
|
||||
if (head[0] === 0x47) {
|
||||
if (value.length >= 189) return value[188] === 0x47 ? 'mpegts' : 'unknown';
|
||||
return 'mpegts';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
} catch {
|
||||
return 'unknown';
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
try { reader?.cancel(); } catch { /* ignore */ }
|
||||
try { controller.abort(); } catch { /* ignore */ }
|
||||
}
|
||||
};
|
||||
|
||||
// Custom Loader that keeps top-level manifest requests on the proxy URL.
|
||||
// Child playlists rewritten by proxiesembed already use stable proxied URLs;
|
||||
// forcing them back to the root manifest can break live sequence tracking.
|
||||
|
|
@ -336,6 +391,7 @@ const LiveTVPlayer: React.FC<LiveTVPlayerProps> = ({
|
|||
const bufferAppendRetryRef = useRef<number>(0);
|
||||
const hlsRecoveryTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const hlsRecoveryInFlightRef = useRef(false);
|
||||
const mpegtsFallbackTriedRef = useRef(false);
|
||||
const userPausedRef = useRef(false);
|
||||
|
||||
const [streams, setStreams] = useState<Stream[]>([]);
|
||||
|
|
@ -809,9 +865,12 @@ const LiveTVPlayer: React.FC<LiveTVPlayerProps> = ({
|
|||
if (!videoRef.current) return;
|
||||
const video = videoRef.current;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
clearHlsRecoveryTimeout();
|
||||
hlsRecoveryInFlightRef.current = false;
|
||||
bufferAppendRetryRef.current = 0;
|
||||
mpegtsFallbackTriedRef.current = false;
|
||||
resetPauseState();
|
||||
|
||||
// Cleanup previous instances
|
||||
|
|
@ -944,12 +1003,11 @@ const LiveTVPlayer: React.FC<LiveTVPlayerProps> = ({
|
|||
|
||||
console.log('Player selection:', { isMpegTs, isDash, finalUrl });
|
||||
|
||||
if (isMpegTs && mpegts.isSupported()) {
|
||||
console.log('Initializing MPEG-TS player for:', finalUrl);
|
||||
const initMpegtsPlayer = (url: string) => {
|
||||
const player = mpegts.createPlayer({
|
||||
type: 'mpegts', // could also be 'mse' type if content type is correct, but 'mpegts' is specific
|
||||
isLive: true,
|
||||
url: finalUrl,
|
||||
url,
|
||||
cors: true, // Important for proxy
|
||||
}, {
|
||||
enableWorker: true,
|
||||
|
|
@ -972,7 +1030,6 @@ const LiveTVPlayer: React.FC<LiveTVPlayerProps> = ({
|
|||
|
||||
player.on(mpegts.Events.ERROR, (type: any, details: any) => {
|
||||
console.error('MPEG-TS Error', type, details);
|
||||
// Fallback logic could go here
|
||||
if (type === mpegts.ErrorTypes.NETWORK_ERROR) {
|
||||
setError(t('liveTV.networkErrorMpegTs'));
|
||||
setIsLoading(false);
|
||||
|
|
@ -983,10 +1040,19 @@ const LiveTVPlayer: React.FC<LiveTVPlayerProps> = ({
|
|||
}
|
||||
});
|
||||
|
||||
// Loading handling
|
||||
video.addEventListener('playing', () => setIsLoading(false), { once: true });
|
||||
};
|
||||
|
||||
} else if (isDash) {
|
||||
const startEngine = (probedFormat: ProbedStreamFormat) => {
|
||||
if (cancelled) return;
|
||||
const useMpegts = isMpegTs || probedFormat === 'mpegts';
|
||||
const useDash = isDash || probedFormat === 'dash';
|
||||
|
||||
if (useMpegts && mpegts.isSupported()) {
|
||||
console.log('Initializing MPEG-TS player for:', finalUrl);
|
||||
initMpegtsPlayer(finalUrl);
|
||||
|
||||
} else if (useDash) {
|
||||
// Initialize Dash Player
|
||||
const player = MediaPlayer().create();
|
||||
dashRef.current = player;
|
||||
|
|
@ -1203,6 +1269,26 @@ const LiveTVPlayer: React.FC<LiveTVPlayerProps> = ({
|
|||
hls458RetryRef.current = 0;
|
||||
}
|
||||
|
||||
// Raw MPEG-TS served as application/octet-stream (extensionless proxy URLs):
|
||||
// hls.js can't parse it — switch engine to mpegts.js once.
|
||||
if (
|
||||
details === 'manifestParsingError' &&
|
||||
!mpegtsFallbackTriedRef.current &&
|
||||
mpegts.isSupported() &&
|
||||
hlsRef.current === hls
|
||||
) {
|
||||
mpegtsFallbackTriedRef.current = true;
|
||||
console.warn('[LiveTV] HLS manifestParsingError — falling back to mpegts.js for raw MPEG-TS stream');
|
||||
try {
|
||||
hls.destroy();
|
||||
} catch (e) {
|
||||
console.error('[LiveTV] hls.destroy() failed before mpegts fallback:', e);
|
||||
}
|
||||
hlsRef.current = null;
|
||||
initMpegtsPlayer(finalUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// 404 ou réponse vide — retriable
|
||||
if (status === 404 || isLikelyEmpty200) {
|
||||
if (hls404RetryRef.current < MAX_404_RETRIES) {
|
||||
|
|
@ -1332,8 +1418,26 @@ const LiveTVPlayer: React.FC<LiveTVPlayerProps> = ({
|
|||
video.play().catch(console.error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Probe URLs going through /proxy/: they can redirect to extensionless raw
|
||||
// streams (e.g., MPEG-TS served as application/octet-stream). hls.js's
|
||||
// manifest XHR would hang forever on a never-ending response — pre-flight the
|
||||
// first chunk to pick the right engine before initializing.
|
||||
if (finalUrl.includes('/proxy/')) {
|
||||
probeStreamFormat(finalUrl).then(format => {
|
||||
if (cancelled) return;
|
||||
if (format !== 'unknown') {
|
||||
console.log('[LiveTV] Probed stream format:', format);
|
||||
}
|
||||
startEngine(format);
|
||||
});
|
||||
} else {
|
||||
startEngine('unknown');
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
// Reset watchdog state on stream change
|
||||
stallCountRef.current = 0;
|
||||
lastTimeRef.current = 0;
|
||||
|
|
|
|||
|
|
@ -638,9 +638,11 @@ const LinkSelector: React.FC<{
|
|||
</h3>
|
||||
|
||||
{isDecoding ? (
|
||||
<div className="flex items-center justify-center py-6 sm:py-8">
|
||||
<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 className="flex flex-col items-center justify-center py-6 sm:py-8 gap-2">
|
||||
<div className="flex items-center">
|
||||
<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>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center text-red-400 text-sm sm:text-base">
|
||||
|
|
@ -706,21 +708,19 @@ const LinkSelector: React.FC<{
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
{decodedLink.metadata && (
|
||||
<>
|
||||
{decodedLink.metadata.language && (
|
||||
<div>
|
||||
<span className="text-gray-400">{t('download.languageLabel')}</span>
|
||||
<span className="text-white ml-2">{decodedLink.metadata.language}</span>
|
||||
</div>
|
||||
)}
|
||||
{decodedLink.metadata.sub && (
|
||||
<div>
|
||||
<span className="text-gray-400">{t('download.subtitlesLabel')}</span>
|
||||
<span className="text-white ml-2">{decodedLink.metadata.sub}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
{/* Langue / sous-titres : download-premium ne les retourne pas,
|
||||
on retombe sur selectedLink (issu de /download/:type/:id). */}
|
||||
{(decodedLink.metadata?.language || selectedLink?.language) && (
|
||||
<div>
|
||||
<span className="text-gray-400">{t('download.languageLabel')}</span>
|
||||
<span className="text-white ml-2">{decodedLink.metadata?.language || selectedLink?.language}</span>
|
||||
</div>
|
||||
)}
|
||||
{(decodedLink.metadata?.sub || selectedLink?.sub) && (
|
||||
<div>
|
||||
<span className="text-gray-400">{t('download.subtitlesLabel')}</span>
|
||||
<span className="text-white ml-2">{decodedLink.metadata?.sub || selectedLink?.sub}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react';
|
||||
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback, memo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useWindowVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Tv, Loader2, Radio, Search, Crown, Puzzle, ChevronDown, Lock, Zap, Wifi, Star } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
|
|
@ -326,6 +327,98 @@ const LiveTVSectionDivider: React.FC<{ title: string; count: number }> = ({ titl
|
|||
</div>
|
||||
);
|
||||
|
||||
// Tailwind-aligned breakpoints for the IPTV grid. Mirrors the className
|
||||
// `grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6`.
|
||||
const IPTV_GRID_GAP_PX = 12;
|
||||
const useResponsiveIptvColumns = () => {
|
||||
const compute = () => {
|
||||
if (typeof window === 'undefined') return 2;
|
||||
const w = window.innerWidth;
|
||||
if (w >= 1280) return 6;
|
||||
if (w >= 1024) return 5;
|
||||
if (w >= 768) return 4;
|
||||
if (w >= 640) return 3;
|
||||
return 2;
|
||||
};
|
||||
const [columns, setColumns] = useState(compute);
|
||||
useEffect(() => {
|
||||
const handler = () => setColumns(compute());
|
||||
window.addEventListener('resize', handler);
|
||||
return () => window.removeEventListener('resize', handler);
|
||||
}, []);
|
||||
return columns;
|
||||
};
|
||||
|
||||
// Window-scrolled virtualizer for IPTV regular grid. Renders only visible
|
||||
// rows; without this, a category like France (~3000 streams) creates ~45k DOM
|
||||
// nodes and crashes the tab. — perf
|
||||
const VirtualizedIptvGrid = <T,>({
|
||||
items,
|
||||
columns,
|
||||
getKey,
|
||||
renderItem,
|
||||
}: {
|
||||
items: T[];
|
||||
columns: number;
|
||||
getKey: (item: T, index: number) => React.Key;
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const offsetRef = useRef(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (containerRef.current) {
|
||||
offsetRef.current = containerRef.current.getBoundingClientRect().top + window.scrollY;
|
||||
}
|
||||
});
|
||||
|
||||
const rowCount = Math.ceil(items.length / Math.max(columns, 1));
|
||||
const virtualizer = useWindowVirtualizer({
|
||||
count: rowCount,
|
||||
estimateSize: () => 220,
|
||||
overscan: 4,
|
||||
scrollMargin: offsetRef.current,
|
||||
});
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ position: 'relative', height: `${virtualizer.getTotalSize()}px`, width: '100%' }}
|
||||
>
|
||||
{virtualItems.map((virtualRow) => {
|
||||
const startIdx = virtualRow.index * columns;
|
||||
const rowItems = items.slice(startIdx, startIdx + columns);
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualRow.start - virtualizer.options.scrollMargin}px)`,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
||||
gap: `${IPTV_GRID_GAP_PX}px`,
|
||||
paddingBottom: `${IPTV_GRID_GAP_PX}px`,
|
||||
}}
|
||||
>
|
||||
{rowItems.map((item, colIdx) => (
|
||||
<React.Fragment key={getKey(item, startIdx + colIdx)}>
|
||||
{renderItem(item, startIdx + colIdx)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Live-updating "time until kickoff" label, isolated from the parent LiveTV
|
||||
// component so a 1s tick re-renders only this leaf instead of the whole
|
||||
// channel grid (potentially hundreds of motion.div cards). Replaces a global
|
||||
|
|
@ -1316,6 +1409,8 @@ const LiveTV: React.FC = () => {
|
|||
[filteredIptvStreams, isFavoriteChannel]
|
||||
);
|
||||
|
||||
const iptvColumns = useResponsiveIptvColumns();
|
||||
|
||||
const channelGridClassName = cn(
|
||||
'grid gap-3',
|
||||
(selectedCatalog.startsWith('matches_') || selectedCatalog.startsWith('livetv_'))
|
||||
|
|
@ -1323,15 +1418,15 @@ const LiveTV: React.FC = () => {
|
|||
: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6'
|
||||
);
|
||||
|
||||
const renderIptvCard = (stream: IptvStream, index: number) => {
|
||||
// No per-card framer-motion — at ~3000 cards (e.g. France IPTV category) the
|
||||
// animation pipeline + DOM overwhelmed the tab. Plain div + CSS opacity
|
||||
// transition on hover is the budget. — perf
|
||||
const renderIptvCard = (stream: IptvStream, _index: number) => {
|
||||
const isFavorite = isFavoriteChannel('iptv', stream.stream_id);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
<div
|
||||
key={stream.stream_id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: Math.min(index * 0.01, 0.3) }}
|
||||
onClick={() => handleIptvChannelClick(stream)}
|
||||
className="group cursor-pointer"
|
||||
>
|
||||
|
|
@ -1373,7 +1468,7 @@ const LiveTV: React.FC = () => {
|
|||
<p className="text-[11px] font-medium text-white/90 truncate">{stream.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -1945,9 +2040,12 @@ const LiveTV: React.FC = () => {
|
|||
{favoriteIptvStreams.length > 0 && (
|
||||
<LiveTVSectionDivider title={t('liveTV.otherChannels')} count={regularIptvStreams.length} />
|
||||
)}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
|
||||
{regularIptvStreams.map((stream, index) => renderIptvCard(stream, favoriteIptvStreams.length + index))}
|
||||
</div>
|
||||
<VirtualizedIptvGrid
|
||||
items={regularIptvStreams}
|
||||
columns={iptvColumns}
|
||||
getKey={(stream) => stream.stream_id}
|
||||
renderItem={(stream, index) => renderIptvCard(stream, favoriteIptvStreams.length + index)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,17 @@ interface ExtensionResponse<T = unknown> {
|
|||
}
|
||||
|
||||
export const isExtensionAvailable = (): boolean => {
|
||||
return (window as Window & { hasMovixExtension?: boolean }).hasMovixExtension === true;
|
||||
const w = window as Window & {
|
||||
hasMovixExtension?: boolean;
|
||||
__MOVIX_EXTENSION_INSTALLED?: boolean;
|
||||
hasMovixUserscript?: boolean;
|
||||
};
|
||||
return (
|
||||
w.hasMovixExtension === true ||
|
||||
w.__MOVIX_EXTENSION_INSTALLED === true ||
|
||||
w.hasMovixUserscript === true ||
|
||||
document.documentElement?.dataset.movixExtension === 'true'
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchFromExtension = <T = unknown>(
|
||||
|
|
|
|||
Loading…
Reference in a new issue