mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-08-09 12:57:38 +00:00
Gros lot de travail synchronisé depuis le repo privé. Cette mise à jour regroupe la refonte du lecteur et du Cast, de nouvelles sources VOD/Live TV, le durcissement de l’API et des proxies, ainsi qu’une nouvelle version de l’application mobile. Lecteur, sources et Live TV - Refonte du lecteur HLS avec sélection et mémorisation de la qualité, détection des résolutions, scan des liens, choix des pistes audio et fallback contrôlé de 1080p vers 720p. - Ajoute une garde contre les boucles de changement de source, notamment lors des erreurs vidéo et des réponses 429. - Revoit SeekStreaming de bout en bout : validation des embeds, prise en charge de plusieurs candidats, regroupement par serveur, règles de headers persistantes et cache d’extraction raccourci. - Ajoute SwiftFlow pour les films et séries, avec lecteurs VF/VOSTFR, cache stale-while-revalidate, déduplication des requêtes et limite partagée côté API. - Adapte Coflix à son nouveau domaine et à son nouveau thème, ajoute un kill switch, développe les wrappers 1jour1film et actualise le domaine Wiflix. - Ajoute NorthLive et Vavoo à la Live TV, regroupe les variantes d’une même chaîne et permet de choisir le serveur ou la qualité. - Enrichit les Top 10 avec des filtres par période et par algorithme, ainsi que de nouvelles métriques de visionnage. Cast et application mobile - Unifie le contrôle Cast entre le Web Cast SDK et le pont Android : état distant, lecture, pause, seek, arrêt et reprise après reconnexion. - Ajoute sur Android un relais média LAN sécurisé, limité au Chromecast et au réseau Wi-Fi sélectionnés, avec sessions opaques et service foreground. - Prépare les sources avant diffusion et prend en charge les playlists HLS TS/fMP4, les flux progressifs, les sous-titres VTT, SeekStreaming et les segments MPEG-TS encapsulés dans des PNG. - Renforce le proxy média local : support Range, validation des redirections, liaison DNS/socket au réseau Cast et meilleure gestion des headers Fsvid. - Sécurise le bridge WebView avec contrôle de l’origine top-frame, capacités propres à chaque document et invalidation lors des navigations. - Ajoute le Picture-in-Picture Android avec lecture/pause et seek de 10 secondes depuis la fenêtre flottante. - Maintient désormais l’écran allumé uniquement pendant une lecture active. - Publie l’application Android 2.5.7, build 16, avec le nouvel APK et ses métadonnées d’intégrité. API, base de données et paiements - Remplace 13 migrations SQL isolées par un manifeste de schéma MySQL centralisé, additif et capable de détecter les divergences de structure. - Ajoute le workflow db:init avec prévisualisation, dry-run, verrou MySQL, confirmations pour les tables Wrapped et couverture de tests dédiée. - Rend configurables les limites du pool MySQL et fiabilise les advisory locks. - Revoit entièrement PayGate : références de callback séparées, nonces, validation des wallets et transactions, états monotones, déduplication des paiements et protection contre les courses ou réutilisations de txid. - Renforce l’annulation, la réconciliation et la livraison idempotente des factures VIP, avec réponses no-store sur les routes sensibles. - Distingue les JWT invalides des indisponibilités MySQL afin de renvoyer correctement 401 ou 503 sans provoquer de déconnexions inutiles. - Utilise ZZAPI/Claude Haiku pour la modération, avec OpenRouter/DeepSeek en solution de secours. - Améliore la gestion HLS et Range dans proxiesembed. Frontend, profils et infrastructure - Protège la sélection et l’hydratation des profils contre les réponses concurrentes, les intentions obsolètes et l’effacement accidentel du stockage local. - Rend VITE_SITE_URL obligatoire et l’injecte dans le runtime, les données structurées, le service worker, robots.txt et sitemap.xml. - Optimise les probes réseau du service worker avec cache court et single-flight. - Charge dynamiquement les traductions anglaises, plusieurs routes secondaires et les données Search/TMDB uniquement lorsqu’elles deviennent nécessaires. - Ajoute des caches mémoire/session pour les images et contenus TMDB et réduit les animations, calculs et rendus hors écran. - Ajoute la page publique des frais, les coins carrés persistants, une nouvelle intro Movix, les avertissements de publicités adultes et de nombreux textes Cast, qualité, Live TV et sources. - Met à jour les extensions Chrome 1.3.12 et Firefox 1.5.9, ainsi que le userscript, avec la nouvelle extraction SeekStreaming et la préparation Cast. - Ajoute une large couverture de tests Kotlin et JavaScript pour le Cast, le proxy média, le PiP, le bridge, la base de données, PayGate et le lecteur. - Ajoute bootstrap-icons et patch-package, régénère les lockfiles npm et actualise la documentation d’installation et d’initialisation MySQL. Co-authored-by: GPT-5.6 Sol <noreply@openai.com>
474 lines
18 KiB
JavaScript
474 lines
18 KiB
JavaScript
/**
|
|
* 1jour1film (1J1F) source — Dooplay/WordPress scraper.
|
|
* Mount point: app.use('/api/j1f', require('./routes/j1f'))
|
|
*
|
|
* Endpoints (TMDB id in, players out — same contract as fstream/wiflix):
|
|
* GET /movie/:id
|
|
* GET /tv/:id/season/:season (optional ?episode=N)
|
|
*
|
|
* Flow
|
|
* ----
|
|
* 0. Domain rotates. Resolve the live base from the stable /go/ entry
|
|
* (TARGET_URL = "..."), cached + env-overridable (J1F_BASE_URL).
|
|
* 1. Search via the nonce-free WP listing {base}/?s={query} -> /films/ +
|
|
* /tvshows/ links (slug carries the year). Matched against TMDB title+year.
|
|
* 2. The real source list is NOT in the plain HTML — it ships base64-encoded
|
|
* inside <script defer src="data:text/javascript;base64,...">. Decode those:
|
|
* - Movies: `var J1F_SRV = [{label,url,type,source}, ...]`
|
|
* - Series: `var j1fEpsData = [{num,label,servers:[{label,url,type}], ...}]`
|
|
* (the season's /saisons/{slug}/ page; pick the link whose slug carries the
|
|
* season number).
|
|
* 3. Each source has a `source` tag:
|
|
* - "manual" -> 1J1F's own players (totocoutouno, bysezoxexe, ...) — UNIQUE.
|
|
* - "frembed"/"vidsrc"/"videasy" -> generic TMDB-id aggregators that Movix
|
|
* already exposes as their own sources. We DROP these (J1F_DROP_SOURCES)
|
|
* so 1J1F doesn't just duplicate frembed; only the unique players remain.
|
|
* Kept players are split VF/VOSTFR by label.
|
|
*
|
|
* When a title yields no unique (manual) player, we still cache the negative
|
|
* result (sentinel) so the frontend marks 1J1F "checked, nothing extra" and we
|
|
* don't re-scrape on every hit.
|
|
*
|
|
* All fetches go through make1j1fRequest (CycleTLS JA3-Chrome + ProxyScrape
|
|
* rotation) which clears the Cloudflare shield. Works from datacenter IPs —
|
|
* verified: the J1F_SRV/j1fEpsData blobs are in the page for any client, just
|
|
* base64-wrapped.
|
|
*/
|
|
|
|
const express = require('express');
|
|
const router = express.Router();
|
|
const cheerio = require('cheerio');
|
|
|
|
const { fetchTmdbDetails } = require('../utils/tmdbCache');
|
|
const { make1j1fRequest } = require('../utils/proxyManager');
|
|
const {
|
|
CACHE_DIR,
|
|
generateCacheKey,
|
|
getFromCacheNoExpiration,
|
|
saveToCache,
|
|
} = require('../utils/cacheManager');
|
|
const { calculateTitleSimilarity } = require('./coflix'); // pure fn, no configure() needed
|
|
|
|
const TMDB_API_KEY = process.env.TMDB_API_KEY || '';
|
|
const TMDB_API_URL = 'https://api.themoviedb.org/3';
|
|
|
|
// --- Env-overridable knobs (domain rotates; markup may drift) ---
|
|
const GO_URL = process.env.J1F_GO_URL || 'https://1jour1film2026.site/go/';
|
|
const BASE_OVERRIDE = (process.env.J1F_BASE_URL || '').trim().replace(/\/+$/, '');
|
|
// Source tags to DROP — generic aggregators Movix already has as separate
|
|
// sources. Anything not listed (e.g. "manual") is kept as a unique 1J1F player.
|
|
const DROP_SOURCES = new Set(
|
|
(process.env.J1F_DROP_SOURCES || 'frembed,vidsrc,videasy')
|
|
.split(',')
|
|
.map((s) => s.trim().toLowerCase())
|
|
.filter(Boolean),
|
|
);
|
|
// Dropped by host at SCRAPE time (never cached): generic aggregators with no
|
|
// `source` tag (vsembed=vidsrc, videasy, frembed).
|
|
const DROP_HOST_RE = new RegExp(process.env.J1F_DROP_HOSTS || 'vsembed|videasy|frembed|vidsrc', 'i');
|
|
// Hidden AFTER cache: these stay IN the cache but are stripped from the response.
|
|
// Flip J1F_HIDE_HOSTS to un-hide instantly — no re-scrape. ezplayer (doremifasol)
|
|
// + uns.bio (dismoiceline) + bysezoxexe.com, per request.
|
|
const HIDE_HOST_RE = new RegExp(
|
|
process.env.J1F_HIDE_HOSTS || 'ezplayer|uns\\.bio|bysezoxexe\\.com',
|
|
'i',
|
|
);
|
|
// 1J1F's own player host (onregardeou.site/video/{slug}) is a WRAPPER page, not
|
|
// an embed — unwrap it into its real nested servers. Host rotates → env-override.
|
|
const WRAP_HOST_RE = new RegExp(process.env.J1F_WRAP_HOSTS || 'onregardeou', 'i');
|
|
const SRV_VAR = process.env.J1F_SRV_VAR || 'J1F_SRV'; // movie source array
|
|
const EPS_VAR = process.env.J1F_EPS_VAR || 'j1fEpsData'; // series episodes array
|
|
const SIMILARITY_THRESHOLD = parseFloat(process.env.J1F_SIMILARITY || '0.7');
|
|
const BASE_TTL_MS = 6 * 60 * 60 * 1000; // re-resolve domain every 6h
|
|
const REFRESH_MS = 40 * 60 * 1000; // serve cache fresh within this window
|
|
|
|
const hostOf = (u) => {
|
|
const m = (u || '').match(/^https?:\/\/(?:www\.)?([^/]+)/);
|
|
return m ? m[1] : u || 'embed';
|
|
};
|
|
const toBody = (r) => (typeof r.data === 'string' ? r.data : JSON.stringify(r.data));
|
|
// J1F now base64-wraps the `url` field inside J1F_SRV / servers. Decode it back
|
|
// to the real embed URL; leave already-plain http(s) URLs untouched (backward-compat).
|
|
const decodeUrl = (u) => {
|
|
if (typeof u !== 'string' || /^https?:\/\//i.test(u)) return u;
|
|
try {
|
|
const dec = Buffer.from(u, 'base64').toString('utf-8');
|
|
return /^https?:\/\//i.test(dec) ? dec : u;
|
|
} catch {
|
|
return u;
|
|
}
|
|
};
|
|
// VOSTFR only when the label is VOSTFR-only; "VF + VOSTFR" (dual) stays VF.
|
|
const langOf = (label) =>
|
|
/vostfr/i.test(label || '') && !/\bvf\b/i.test(label || '') ? 'VOSTFR' : 'VF';
|
|
const yearFromSlug = (slug) => {
|
|
const m = slug.match(/-(\d{4})(?:[-/]|$)/);
|
|
return m ? parseInt(m[1], 10) : null;
|
|
};
|
|
|
|
// === Domain resolution (cached) ===
|
|
let cachedBase = null;
|
|
let cachedBaseAt = 0;
|
|
async function resolveBase() {
|
|
if (BASE_OVERRIDE) return BASE_OVERRIDE;
|
|
if (cachedBase && Date.now() - cachedBaseAt < BASE_TTL_MS) return cachedBase;
|
|
const res = await make1j1fRequest(GO_URL, { timeout: 20 });
|
|
const m = toBody(res).match(/TARGET_URL\s*=\s*"([^"]+)"/);
|
|
if (!m) {
|
|
if (cachedBase) return cachedBase; // keep last good on a bad /go/ fetch
|
|
throw new Error('[1J1F] TARGET_URL introuvable sur /go/');
|
|
}
|
|
cachedBase = m[1].replace(/\\\//g, '/').replace(/\/+$/, '');
|
|
cachedBaseAt = Date.now();
|
|
return cachedBase;
|
|
}
|
|
|
|
// In-flight scrapes keyed by cache key, so concurrent cold requests for the same
|
|
// title kick off only ONE background scrape.
|
|
const inFlight = new Map();
|
|
|
|
// Non-blocking cache — never make the user wait on the slow CycleTLS+proxy scrape:
|
|
// - fresh cache -> return it
|
|
// - stale cache -> return stale now, refresh in background (stale-while-revalidate)
|
|
// - cold (no cache) -> return { pending:true } immediately, scrape in background
|
|
// The background job caches the real result; the client gets it on a subsequent
|
|
// request (the frontend retries shortly after a `pending` response). Negative
|
|
// results are cached too (sentinel) so frembed-only titles don't re-scrape.
|
|
function startBackgroundScrape(key, fetcher) {
|
|
if (inFlight.has(key)) return inFlight.get(key);
|
|
const job = (async () => {
|
|
try {
|
|
const fresh = await fetcher();
|
|
fresh._ts = Date.now();
|
|
await saveToCache(CACHE_DIR.J1F, key, fresh);
|
|
return fresh;
|
|
} finally {
|
|
inFlight.delete(key);
|
|
}
|
|
})();
|
|
inFlight.set(key, job);
|
|
job.catch(() => {}); // next request retries; avoid unhandled rejection
|
|
return job;
|
|
}
|
|
|
|
async function withCache(key, fetcher) {
|
|
const cached = await getFromCacheNoExpiration(CACHE_DIR.J1F, key);
|
|
if (cached && cached._ts && Date.now() - cached._ts < REFRESH_MS) return cached;
|
|
|
|
// Stale or cold: trigger a background refresh/scrape (deduped), don't await it.
|
|
startBackgroundScrape(key, fetcher);
|
|
|
|
if (cached) return cached; // stale-while-revalidate: serve stale immediately
|
|
return { success: false, pending: true, tmdb_id: undefined }; // cold: tell client to retry shortly
|
|
}
|
|
|
|
// Decode every <script ... src="data:text/javascript;base64,...">. The real
|
|
// player data (J1F_SRV / j1fEpsData) lives base64-wrapped in these.
|
|
function decodeDataScripts(html) {
|
|
const out = [];
|
|
const re = /data:text\/javascript;base64,([A-Za-z0-9+/=]+)/g;
|
|
let m;
|
|
while ((m = re.exec(html)) !== null) {
|
|
try {
|
|
out.push(Buffer.from(m[1], 'base64').toString('utf-8'));
|
|
} catch {
|
|
/* skip undecodable */
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Pull `var <name> = [ ... ];` (a JSON array) out of the decoded data: scripts.
|
|
function extractJsArray(html, varName) {
|
|
const re = new RegExp(`(?:var|let|const)\\s+${varName}\\s*=\\s*(\\[[\\s\\S]*?\\])\\s*;`);
|
|
for (const js of decodeDataScripts(html)) {
|
|
const m = js.match(re);
|
|
if (m) {
|
|
try {
|
|
return JSON.parse(m[1]);
|
|
} catch {
|
|
/* malformed — try the next script */
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Keep only unique (non-generic) servers, split VF/VOSTFR by label.
|
|
function splitUniqueServers(servers) {
|
|
const vf = [];
|
|
const vostfr = [];
|
|
for (const s of servers || []) {
|
|
if (!s || !s.url) continue;
|
|
if (DROP_SOURCES.has(String(s.source || '').toLowerCase())) continue; // drop by `source` tag (movies)
|
|
const url = decodeUrl(s.url);
|
|
if (DROP_HOST_RE.test(url)) continue; // drop by host (series servers have no `source` tag)
|
|
const entry = {
|
|
name: hostOf(url),
|
|
url,
|
|
type: s.type === 'mp4' ? 'mp4' : 'iframe',
|
|
label: s.label || '',
|
|
source: s.source || 'manual',
|
|
};
|
|
(langOf(s.label) === 'VOSTFR' ? vostfr : vf).push(entry);
|
|
}
|
|
return { vf, vostfr };
|
|
}
|
|
|
|
// onregardeou wrapper HTML ships its real embed list inline as
|
|
// `const videoData = { ..., "servers":[{name,url,type}, ...] }`. Pull that array.
|
|
function parseWrapperServers(html) {
|
|
const m = html.match(/"servers"\s*:\s*(\[[\s\S]*?\])/);
|
|
if (!m) return null;
|
|
try {
|
|
const arr = JSON.parse(m[1]); // JSON.parse un-escapes the \/ in the urls
|
|
return Array.isArray(arr) ? arr : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Replace any wrapper entry (onregardeou.site) with its nested servers. The bare
|
|
// wrapper URL 301s, so the trailing slash is required. Nested servers inherit the
|
|
// wrapper's VF/VOSTFR label. On fetch/parse failure the wrapper is kept as-is
|
|
// (still playable as an iframe). Order-preserving; wrapper fetches run in parallel.
|
|
async function expandWrappers(servers) {
|
|
const out = [];
|
|
await Promise.all(
|
|
(servers || []).map(async (s, i) => {
|
|
const url = decodeUrl(s && s.url);
|
|
if (!s || !url || !WRAP_HOST_RE.test(url)) {
|
|
out[i] = s ? [s] : [];
|
|
return;
|
|
}
|
|
const wrapUrl = `${url.split('#')[0].split('?')[0].replace(/\/+$/, '')}/`;
|
|
try {
|
|
const res = await make1j1fRequest(wrapUrl, { timeout: 15 });
|
|
const nested = parseWrapperServers(toBody(res));
|
|
if (nested && nested.length) {
|
|
out[i] = nested.map((n) => ({
|
|
label: s.label || '', // keep wrapper's VF/VOSTFR lang
|
|
url: n.url,
|
|
type: /\.(mp4|m3u8|webm)(\?|$)/i.test(n.url || '') ? 'mp4' : 'iframe',
|
|
source: s.source || 'manual',
|
|
}));
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
console.log(`[1J1F WRAP] ${wrapUrl}: ${e.message}`);
|
|
}
|
|
out[i] = [s]; // fetch/parse failed -> keep wrapper iframe
|
|
}),
|
|
);
|
|
return out.flat();
|
|
}
|
|
|
|
// === Search: {base}/?s={query} -> [{url, slug, type, year}] ===
|
|
async function searchJ1F(base, query) {
|
|
const url = `${base}/?s=${encodeURIComponent(query)}`;
|
|
const res = await make1j1fRequest(url, { timeout: 15 });
|
|
const $ = cheerio.load(toBody(res));
|
|
const seen = new Set();
|
|
const out = [];
|
|
$('a[href*="/films/"], a[href*="/tvshows/"]').each((_, a) => {
|
|
const href = $(a).attr('href') || '';
|
|
const m = href.match(/\/(films|tvshows)\/([^/"?#]+)\/?/);
|
|
if (!m) return;
|
|
const slug = m[2];
|
|
if (seen.has(slug)) return;
|
|
seen.add(slug);
|
|
out.push({
|
|
url: href.split('#')[0],
|
|
slug,
|
|
type: m[1] === 'films' ? 'movie' : 'tv',
|
|
year: yearFromSlug(slug),
|
|
});
|
|
});
|
|
return out;
|
|
}
|
|
|
|
// Best search hit for a TMDB title/year. Title is reconstructed from the slug.
|
|
function pickBest(results, mediaType, titles, year) {
|
|
let best = null;
|
|
let bestScore = 0;
|
|
for (const r of results.filter((x) => x.type === mediaType)) {
|
|
const slugTitle = r.slug
|
|
.replace(/-(streaming|vf|vostfr|hd|fhd|complete?|netflix|serie|saison|film|episode|\d{4}|[a-z]\d+)\b/gi, ' ')
|
|
.replace(/-/g, ' ')
|
|
.trim();
|
|
let score = 0;
|
|
for (const t of titles) score = Math.max(score, calculateTitleSimilarity(t, slugTitle));
|
|
if (year && r.year === year) score += 0.15;
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
best = r;
|
|
}
|
|
}
|
|
return bestScore >= SIMILARITY_THRESHOLD ? best : null;
|
|
}
|
|
|
|
async function findOnJ1F(base, mediaType, tmdb) {
|
|
const titles = [
|
|
mediaType === 'movie' ? tmdb.title : tmdb.name,
|
|
mediaType === 'movie' ? tmdb.original_title : tmdb.original_name,
|
|
].filter((t, i, a) => t && a.indexOf(t) === i);
|
|
const dateStr = mediaType === 'movie' ? tmdb.release_date : tmdb.first_air_date;
|
|
const year = dateStr ? parseInt(String(dateStr).slice(0, 4), 10) : null;
|
|
|
|
for (const t of titles) {
|
|
let results = [];
|
|
try {
|
|
results = await searchJ1F(base, t);
|
|
} catch (e) {
|
|
console.log(`[1J1F SEARCH] "${t}": ${e.message}`);
|
|
continue;
|
|
}
|
|
const hit = pickBest(results, mediaType, titles, year);
|
|
if (hit) return hit;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// === Movie ===
|
|
async function fetchMovie(base, tmdbId) {
|
|
const tmdb = await fetchTmdbDetails(TMDB_API_URL, TMDB_API_KEY, tmdbId, 'movie', 'fr-FR');
|
|
if (!tmdb) return { success: false, error: 'Film non trouve sur TMDB', tmdb_id: tmdbId };
|
|
|
|
const hit = await findOnJ1F(base, 'movie', tmdb);
|
|
if (!hit) return { success: false, error: 'Film non trouve sur 1jour1film', tmdb_id: tmdbId };
|
|
|
|
const res = await make1j1fRequest(hit.url, { timeout: 15 });
|
|
const srv = extractJsArray(toBody(res), SRV_VAR);
|
|
if (!srv) {
|
|
console.warn(`[1J1F MOVIE] ${tmdbId}: ${SRV_VAR} introuvable sur ${hit.url}`);
|
|
return { success: false, error: 'Sources introuvables', tmdb_id: tmdbId, j1f_url: hit.url };
|
|
}
|
|
|
|
const expanded = await expandWrappers(srv); // unwrap onregardeou -> real embeds
|
|
const { vf, vostfr } = splitUniqueServers(expanded);
|
|
if (vf.length === 0 && vostfr.length === 0) {
|
|
// Only generic aggregators (frembed/vidsrc/videasy) — nothing 1J1F adds.
|
|
return { success: false, error: 'Aucune source unique (generiques uniquement)', tmdb_id: tmdbId, j1f_url: hit.url };
|
|
}
|
|
return {
|
|
success: true,
|
|
tmdb_id: tmdbId,
|
|
title: tmdb.title,
|
|
original_title: tmdb.original_title,
|
|
source: '1jour1film',
|
|
j1f_url: hit.url,
|
|
players: { vf, vostfr },
|
|
cache_timestamp: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
// === Series ===
|
|
async function fetchSeason(base, tmdbId, seasonNum, episodeNum) {
|
|
const tmdb = await fetchTmdbDetails(TMDB_API_URL, TMDB_API_KEY, tmdbId, 'tv', 'fr-FR');
|
|
if (!tmdb) return { success: false, error: 'Serie non trouvee sur TMDB', tmdb_id: tmdbId };
|
|
|
|
const hit = await findOnJ1F(base, 'tv', tmdb);
|
|
if (!hit) return { success: false, error: 'Serie non trouvee sur 1jour1film', tmdb_id: tmdbId };
|
|
|
|
// tvshow page -> the /saisons/ link whose slug carries this season number.
|
|
const showRes = await make1j1fRequest(hit.url, { timeout: 15 });
|
|
const $show = cheerio.load(toBody(showRes));
|
|
let seasonUrl = null;
|
|
$show('a[href*="/saisons/"]').each((_, a) => {
|
|
const href = $show(a).attr('href') || '';
|
|
const m = href.match(/saison-(\d+)/i);
|
|
if (m && parseInt(m[1], 10) === Number(seasonNum)) seasonUrl = href.split('#')[0];
|
|
});
|
|
if (!seasonUrl) {
|
|
return { success: false, error: `Saison ${seasonNum} introuvable`, tmdb_id: tmdbId, j1f_url: hit.url };
|
|
}
|
|
|
|
const seasonRes = await make1j1fRequest(seasonUrl, { timeout: 15 });
|
|
const eps = extractJsArray(toBody(seasonRes), EPS_VAR);
|
|
if (!eps || !Array.isArray(eps)) {
|
|
console.warn(`[1J1F TV] ${tmdbId} S${seasonNum}: ${EPS_VAR} introuvable sur ${seasonUrl}`);
|
|
return { success: false, error: 'Episodes introuvables', tmdb_id: tmdbId, j1f_url: seasonUrl };
|
|
}
|
|
|
|
// Shape matches wiflix's TV response: `episodes` keyed by episode number,
|
|
// each { vf:[{name,url,...}], vostfr:[...] } — the whole season is returned
|
|
// and the frontend slices the current episode. `?episode=` still narrows it.
|
|
const wanted = episodeNum ? eps.filter((e) => Number(e.num) === Number(episodeNum)) : eps;
|
|
const episodes = {};
|
|
await Promise.all(
|
|
wanted.map(async (e) => {
|
|
const expanded = await expandWrappers(e.servers); // unwrap onregardeou -> real embeds
|
|
const { vf, vostfr } = splitUniqueServers(expanded);
|
|
if (!vf.length && !vostfr.length) return; // drop generic-only episodes
|
|
episodes[String(e.num)] = { vf, vostfr, label: e.label || '' };
|
|
}),
|
|
);
|
|
|
|
if (Object.keys(episodes).length === 0) {
|
|
return { success: false, error: 'Aucune source unique (generiques uniquement)', tmdb_id: tmdbId, j1f_url: seasonUrl };
|
|
}
|
|
return {
|
|
success: true,
|
|
tmdb_id: tmdbId,
|
|
title: tmdb.name,
|
|
source: '1jour1film',
|
|
j1f_url: seasonUrl,
|
|
season: Number(seasonNum),
|
|
episodes,
|
|
cache_timestamp: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
// Strip HIDE_HOST_RE players from a (cached) response on the way out — the cache
|
|
// keeps them, the client never sees them. Non-mutating; handles movie/series/
|
|
// pending/error shapes.
|
|
const keepVisible = (arr) =>
|
|
Array.isArray(arr) ? arr.filter((p) => !HIDE_HOST_RE.test((p && p.url) || '')) : arr;
|
|
function hideHosts(data) {
|
|
if (!data || !data.success) return data;
|
|
if (data.players) {
|
|
return {
|
|
...data,
|
|
players: { vf: keepVisible(data.players.vf), vostfr: keepVisible(data.players.vostfr) },
|
|
};
|
|
}
|
|
if (data.episodes) {
|
|
const episodes = {};
|
|
for (const [num, ep] of Object.entries(data.episodes)) {
|
|
episodes[num] = { ...ep, vf: keepVisible(ep.vf), vostfr: keepVisible(ep.vostfr) };
|
|
}
|
|
return { ...data, episodes };
|
|
}
|
|
return data;
|
|
}
|
|
|
|
// === Routes ===
|
|
router.get('/movie/:id', async (req, res) => {
|
|
const { id } = req.params;
|
|
try {
|
|
const base = await resolveBase();
|
|
const data = await withCache(generateCacheKey({ src: 'j1f', t: 'movie', id }), () =>
|
|
fetchMovie(base, id),
|
|
);
|
|
res.json(hideHosts(data));
|
|
} catch (err) {
|
|
console.error(`[1J1F MOVIE] ${id}: ${err.message}`);
|
|
res.status(200).json({ success: false, error: 'Erreur 1jour1film', tmdb_id: id });
|
|
}
|
|
});
|
|
|
|
router.get('/tv/:id/season/:season', async (req, res) => {
|
|
const { id, season } = req.params;
|
|
const { episode } = req.query;
|
|
try {
|
|
const base = await resolveBase();
|
|
const key = generateCacheKey({ src: 'j1f', t: 'tv', id, season, episode: episode || '' });
|
|
const data = await withCache(key, () => fetchSeason(base, id, season, episode));
|
|
res.json(hideHosts(data));
|
|
} catch (err) {
|
|
console.error(`[1J1F TV] ${id} S${season}: ${err.message}`);
|
|
res.status(200).json({ success: false, error: 'Erreur 1jour1film', tmdb_id: id });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|