mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-07-27 05:02:08 +00:00
Merge branch 'movixcorp:main' into main
This commit is contained in:
commit
af7b2b3874
14 changed files with 2789 additions and 400 deletions
File diff suppressed because it is too large
Load diff
269
API/Mainapi/utils/daddylive.js
Normal file
269
API/Mainapi/utils/daddylive.js
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
// API/Mainapi/utils/daddylive.js
|
||||
// Pure logic for the Daddylive (dlhd.pk) live-TV source. No network / no I/O.
|
||||
|
||||
const cheerio = require('cheerio');
|
||||
|
||||
const DADDYLIVE_BASE_URL = 'https://dlhd.pk';
|
||||
const DADDYLIVE_REFERER = 'https://dlhd.pk/';
|
||||
const DADDYLIVE_ORIGIN = 'https://dlhd.pk';
|
||||
const DADDYLIVE_CHANNELS_PATH = '/24-7-channels.php';
|
||||
const DADDYLIVE_PLAYER_PREFIXES = ['stream', 'cast', 'watch', 'plus', 'casting', 'player'];
|
||||
// Cards carry no logo; empty poster -> frontend renders the channel name placeholder.
|
||||
const DADDYLIVE_PLACEHOLDER_POSTER = '';
|
||||
|
||||
// Keyed by ISO 3166-1 alpha-2 (lowercase) so the frontend can render a flag via
|
||||
// the react-country-flag module. `arabic`/`africa`/`other` are non-ISO buckets
|
||||
// (no single flag) and get an emoji fallback on the frontend.
|
||||
// Ordered: France first (French platform), big markets, then the rest, buckets last.
|
||||
const DADDYLIVE_COUNTRIES = {
|
||||
fr: { name: 'France' },
|
||||
us: { name: 'États-Unis' },
|
||||
gb: { name: 'Royaume-Uni' },
|
||||
es: { name: 'Espagne' },
|
||||
it: { name: 'Italie' },
|
||||
de: { name: 'Allemagne' },
|
||||
pt: { name: 'Portugal' },
|
||||
pl: { name: 'Pologne' },
|
||||
nl: { name: 'Pays-Bas' },
|
||||
gr: { name: 'Grèce' },
|
||||
tr: { name: 'Turquie' },
|
||||
br: { name: 'Brésil' },
|
||||
ar: { name: 'Argentine' },
|
||||
mx: { name: 'Mexique' },
|
||||
ca: { name: 'Canada' },
|
||||
au: { name: 'Australie' },
|
||||
nz: { name: 'Nouvelle-Zélande' },
|
||||
ro: { name: 'Roumanie' },
|
||||
bg: { name: 'Bulgarie' },
|
||||
rs: { name: 'Serbie' },
|
||||
hr: { name: 'Croatie' },
|
||||
ba: { name: 'Bosnie' },
|
||||
cz: { name: 'République tchèque' },
|
||||
sk: { name: 'Slovaquie' },
|
||||
dk: { name: 'Danemark' },
|
||||
se: { name: 'Suède' },
|
||||
ru: { name: 'Russie' },
|
||||
in: { name: 'Inde' },
|
||||
pk: { name: 'Pakistan' },
|
||||
my: { name: 'Malaisie' },
|
||||
il: { name: 'Israël' },
|
||||
ae: { name: 'Émirats arabes unis' },
|
||||
qa: { name: 'Qatar' },
|
||||
sa: { name: 'Arabie saoudite' },
|
||||
ie: { name: 'Irlande' },
|
||||
cy: { name: 'Chypre' },
|
||||
hu: { name: 'Hongrie' },
|
||||
bd: { name: 'Bangladesh' },
|
||||
uy: { name: 'Uruguay' },
|
||||
cl: { name: 'Chili' },
|
||||
co: { name: 'Colombie' },
|
||||
arabic: { name: 'Arabe / MENA' },
|
||||
africa: { name: 'Afrique' },
|
||||
other: { name: 'International' },
|
||||
};
|
||||
|
||||
// US networks that carry NO country token in their name.
|
||||
const US_NETWORK_ALLOWLIST = [
|
||||
'cartoon network', 'adult swim', 'animal planet', 'boomerang', 'comedy central',
|
||||
'discovery channel', 'discovery family', 'discovery life', 'discovery turbo',
|
||||
'disney channel', 'disney xd', 'disney jr', 'nick', 'nicktoons', 'teennick',
|
||||
'universal kids', 'national geographic', 'nat geo', 'science channel',
|
||||
'smithsonian', 'travel channel', 'tlc', 'investigation discovery', 'hgtv',
|
||||
'magnolia network', 'msnbc', 'telemundo', 'univision', 'unimas',
|
||||
'game show network', 'tennis channel', 'cooking channel', 'food network',
|
||||
'hallmark', 'oxygen', 'syfy', 'freeform', 'paramount network', 'bravo',
|
||||
'lifetime', 'fuse', 'vice tv', 'we tv', 'wetv', 'ion', 'reelz', 'tv one',
|
||||
];
|
||||
|
||||
// Ordered rules: first match wins. Values are ISO alpha-2 (or a bucket key).
|
||||
// Full country words match anywhere; ambiguous 2-letter codes are END-anchored.
|
||||
const COUNTRY_RULES = [
|
||||
// --- specific disambiguation first ---
|
||||
[/\bmena\b/, 'arabic'],
|
||||
[/\bbih\b/, 'ba'],
|
||||
[/abu dhabi|dubai/, 'ae'],
|
||||
[/\balkass\b/, 'qa'],
|
||||
[/\bssc\b/, 'sa'],
|
||||
[/movistar|laliga|la liga/, 'es'],
|
||||
// --- full country words (match anywhere) ---
|
||||
[/\busa\b|\bus\b|\bny\b|cbsny|foxny|nbcny/, 'us'],
|
||||
[/\bfrance\b|fran[cç]aise|\bfrench\b/, 'fr'],
|
||||
[/\bspain\b|espa[nñ]a/, 'es'],
|
||||
[/\bitaly\b|\bitalia\b/, 'it'],
|
||||
[/\bgermany\b|deutschland|fernsehen/, 'de'],
|
||||
[/\bportugal\b/, 'pt'],
|
||||
[/\bpoland\b/, 'pl'],
|
||||
[/netherland/, 'nl'],
|
||||
[/\bgreece\b/, 'gr'],
|
||||
[/\bturkey\b|turkish/, 'tr'],
|
||||
[/\bbrasil\b|\bbrazil\b/, 'br'],
|
||||
[/\bargentina\b/, 'ar'],
|
||||
[/\bmexico\b/, 'mx'],
|
||||
[/\bcanada\b/, 'ca'],
|
||||
[/\baustralia\b/, 'au'],
|
||||
[/new zealand|\btvnz\b/, 'nz'],
|
||||
[/\bromania\b/, 'ro'],
|
||||
[/\bbulgaria\b/, 'bg'],
|
||||
[/\bserbia\b/, 'rs'],
|
||||
[/\bcroatia\b/, 'hr'],
|
||||
[/\bbosnia\b/, 'ba'],
|
||||
[/\bczech\b/, 'cz'],
|
||||
[/\bslovakia\b|\bšport\b/, 'sk'],
|
||||
[/\bdenmark\b/, 'dk'],
|
||||
[/\bsweden\b/, 'se'],
|
||||
[/\brussia\b/, 'ru'],
|
||||
[/\bindia\b/, 'in'],
|
||||
[/\bpakistan\b/, 'pk'],
|
||||
[/\bmalaysia\b/, 'my'],
|
||||
[/\bisrael\b/, 'il'],
|
||||
[/\buae\b/, 'ae'],
|
||||
[/\bqatar\b/, 'qa'],
|
||||
[/\bsaudi\b/, 'sa'],
|
||||
[/\bireland\b/, 'ie'],
|
||||
[/\bcyprus\b/, 'cy'],
|
||||
[/\bhungary\b/, 'hu'],
|
||||
[/bangladesh/, 'bd'],
|
||||
[/\buruguay\b/, 'uy'],
|
||||
[/\bchile\b/, 'cl'],
|
||||
[/colombia|columbia/, 'co'],
|
||||
[/\barabic\b/, 'arabic'],
|
||||
[/afrique|africa/, 'africa'],
|
||||
// --- ambiguous 2-letter codes: END-anchored only ---
|
||||
[/\buk$/, 'gb'],
|
||||
[/\bde$/, 'de'],
|
||||
[/\bpt$/, 'pt'],
|
||||
[/\bnl$/, 'nl'],
|
||||
[/\btr$/, 'tr'],
|
||||
[/\bes$/, 'es'],
|
||||
[/\bmx$/, 'mx'],
|
||||
[/\bca$/, 'ca'],
|
||||
[/\bau$/, 'au'],
|
||||
[/\bnz$/, 'nz'],
|
||||
[/\bro$/, 'ro'],
|
||||
[/\bcz$/, 'cz'],
|
||||
[/\bsk$/, 'sk'],
|
||||
[/\bin$/, 'in'],
|
||||
[/\bpk$/, 'pk'],
|
||||
[/\bbd$/, 'bd'],
|
||||
];
|
||||
|
||||
function detectCountry(rawName) {
|
||||
const name = String(rawName || '').toLowerCase().trim();
|
||||
if (!name) return 'other';
|
||||
for (const [re, code] of COUNTRY_RULES) {
|
||||
if (re.test(name)) return code;
|
||||
}
|
||||
for (const needle of US_NETWORK_ALLOWLIST) {
|
||||
if (name.includes(needle)) return 'us';
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function isAdultChannel(rawId, name) {
|
||||
const idNum = Number.parseInt(rawId, 10);
|
||||
if (Number.isInteger(idNum) && idNum >= 501 && idNum <= 520) return true;
|
||||
return /^\s*18\+/.test(String(name || ''));
|
||||
}
|
||||
|
||||
// Parse the /24-7-channels.php grid into [{ rawId, name, country }], 18+ removed.
|
||||
function parseChannelsHtml(html) {
|
||||
if (!html) return [];
|
||||
const $ = cheerio.load(html);
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
$('a.card').each((_, el) => {
|
||||
const href = $(el).attr('href') || '';
|
||||
const m = href.match(/[?&]id=(\d+)/);
|
||||
if (!m) return;
|
||||
const rawId = m[1];
|
||||
const name = ($(el).find('.card__title').first().text() || '').trim();
|
||||
if (!name) return;
|
||||
if (isAdultChannel(rawId, name)) return;
|
||||
if (seen.has(rawId)) return;
|
||||
seen.add(rawId);
|
||||
out.push({ rawId, name, country: detectCountry(name) });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// Parse #playerBtns -> [{ title, dataUrl }].
|
||||
function parsePlayersHtml(html) {
|
||||
if (!html) return [];
|
||||
const $ = cheerio.load(html);
|
||||
const out = [];
|
||||
$('#playerBtns button[data-url]').each((i, el) => {
|
||||
const dataUrl = $(el).attr('data-url');
|
||||
if (!dataUrl) return;
|
||||
const text = ($(el).text() || '').trim();
|
||||
out.push({ title: text || `Player ${i + 1}`, dataUrl });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// Fallback when watch.php cannot be parsed: the 6 deterministic player paths.
|
||||
function buildDeterministicPlayers(rawId) {
|
||||
return DADDYLIVE_PLAYER_PREFIXES.map((prefix, i) => ({
|
||||
title: `Player ${i + 1}`,
|
||||
dataUrl: `${DADDYLIVE_BASE_URL}/${prefix}/stream-${rawId}.php`,
|
||||
}));
|
||||
}
|
||||
|
||||
function decodeBase64(b64) {
|
||||
try {
|
||||
return Buffer.from(b64, 'base64').toString('utf8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function isM3u8Url(s) {
|
||||
return /^https?:\/\//i.test(s) && s.toLowerCase().includes('m3u8');
|
||||
}
|
||||
|
||||
// Server-side fetch => the obfuscated anti-adblock JS does not run, so the
|
||||
// Clappr `source: window.atob('<b64>')` literal is present verbatim. Decode it.
|
||||
function extractM3u8FromPlayerHtml(html) {
|
||||
if (!html) return null;
|
||||
const direct = html.match(/source\s*:\s*window\.atob\(\s*['"]([A-Za-z0-9+/=]+)['"]\s*\)/i);
|
||||
if (direct) {
|
||||
const decoded = decodeBase64(direct[1]);
|
||||
if (isM3u8Url(decoded)) return decoded;
|
||||
}
|
||||
// Fallback: scan every loose base64 literal for a decoded m3u8 URL.
|
||||
const re = /['"]([A-Za-z0-9+/=]{24,})['"]/g;
|
||||
let m;
|
||||
while ((m = re.exec(html)) !== null) {
|
||||
const decoded = decodeBase64(m[1]);
|
||||
if (isM3u8Url(decoded)) return decoded;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract the first <iframe src>, absolutized against the page URL.
|
||||
function extractIframeSrc(html, baseUrl) {
|
||||
if (!html) return null;
|
||||
const m = html.match(/<iframe[^>]*\bsrc=["']([^"']+)["']/i);
|
||||
if (!m) return null;
|
||||
try {
|
||||
return new URL(m[1], baseUrl || DADDYLIVE_BASE_URL).href;
|
||||
} catch {
|
||||
return m[1];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DADDYLIVE_BASE_URL,
|
||||
DADDYLIVE_REFERER,
|
||||
DADDYLIVE_ORIGIN,
|
||||
DADDYLIVE_CHANNELS_PATH,
|
||||
DADDYLIVE_PLAYER_PREFIXES,
|
||||
DADDYLIVE_PLACEHOLDER_POSTER,
|
||||
DADDYLIVE_COUNTRIES,
|
||||
detectCountry,
|
||||
isAdultChannel,
|
||||
parseChannelsHtml,
|
||||
parsePlayersHtml,
|
||||
buildDeterministicPlayers,
|
||||
extractM3u8FromPlayerHtml,
|
||||
extractIframeSrc,
|
||||
};
|
||||
|
|
@ -1,11 +1,28 @@
|
|||
const VAVOO_BASE_URL = "https://tvvoo.hayd.uk/cfg-fr";
|
||||
const WITV_BASE_URL = "https://witv.team";
|
||||
const SOSPLAY_BASE_URL = "https://streamonsport.art";
|
||||
const LIVETV_BASE_URL = "https://livetv882.me/frx/";
|
||||
const LIVETV_EMBED_ORIGIN = "https://livetv882.me";
|
||||
const LIVETV_EMBED_REFERER = LIVETV_BASE_URL;
|
||||
// Backend API URL for got-scraping based extraction
|
||||
const API_BASE_URL = "https://api.movix.cash";
|
||||
// Backend API URL for got-scraping based extraction.
|
||||
// Dev override: when the requesting page is localhost (Vite dev on :3000),
|
||||
// talk to the local backend (:25565) instead of prod. Set per-message from
|
||||
// the sender origin (see maybeUseLocalApi in the onMessage listener below).
|
||||
const PROD_API_BASE_URL = "https://api.movix.chat";
|
||||
const LOCAL_API_BASE_URL = "http://localhost:25565";
|
||||
let API_BASE_URL = PROD_API_BASE_URL;
|
||||
|
||||
function maybeUseLocalApi(sender) {
|
||||
try {
|
||||
const u =
|
||||
sender && (sender.url || (sender.tab && sender.tab.url) || sender.origin);
|
||||
if (!u) return;
|
||||
const host = new URL(u).hostname;
|
||||
API_BASE_URL =
|
||||
host === "localhost" || host === "127.0.0.1"
|
||||
? LOCAL_API_BASE_URL
|
||||
: PROD_API_BASE_URL;
|
||||
} catch (e) {}
|
||||
}
|
||||
const STREAM_PROXY_USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
||||
|
||||
|
|
@ -116,6 +133,7 @@ async function setupRules() {
|
|||
"movix.cloud",
|
||||
"movix.tax",
|
||||
"movix.club",
|
||||
"movix.chat",
|
||||
"movix.golf",
|
||||
],
|
||||
resourceTypes: [
|
||||
|
|
@ -139,6 +157,7 @@ async function setupRules() {
|
|||
|
||||
// Handle messages
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
maybeUseLocalApi(sender);
|
||||
handleMessage(message)
|
||||
.then(sendResponse)
|
||||
.catch((err) => sendResponse({ error: err.message }));
|
||||
|
|
@ -264,6 +283,21 @@ async function handleMessage(message) {
|
|||
return { success: false, error: "Could not setup headers" };
|
||||
}
|
||||
|
||||
// FCTV (matches) native: inject the player Referer on the CDN segments so
|
||||
// free users play natively without the server proxy.
|
||||
case "SETUP_FCTV_HEADERS": {
|
||||
const okFctv = await setupFctvHeadersRule(payload?.referer);
|
||||
return { success: okFctv };
|
||||
}
|
||||
|
||||
// FCTV (matches) native: resolve ONE server locally (IP-bound) -> m3u8 url.
|
||||
// Also installs the Referer DNR rule for the segments.
|
||||
case "RESOLVE_FCTV": {
|
||||
const fctvUrl = await resolveFctvStream(payload || {});
|
||||
if (fctvUrl) return { success: true, url: fctvUrl };
|
||||
return { success: false, error: "fctv resolve failed" };
|
||||
}
|
||||
|
||||
case "SET_EXTRACTION_PREFS": {
|
||||
const incoming = payload?.prefs;
|
||||
if (incoming && incoming.version === 1 && incoming.m3u8 && incoming.livetv) {
|
||||
|
|
@ -462,8 +496,8 @@ function handleDetectEmbeds(payload) {
|
|||
function buildBackendApiHeaders(accessKey, extraHeaders = {}) {
|
||||
const headers = {
|
||||
Accept: "application/json",
|
||||
Origin: "https://movix.cash",
|
||||
Referer: "https://movix.cash/",
|
||||
Origin: "https://movix.chat",
|
||||
Referer: "https://movix.chat/",
|
||||
...extraHeaders,
|
||||
};
|
||||
|
||||
|
|
@ -475,9 +509,6 @@ function buildBackendApiHeaders(accessKey, extraHeaders = {}) {
|
|||
}
|
||||
|
||||
async function getManifest() {
|
||||
console.log("Fetching manifest from Vavoo...");
|
||||
const vavooData = await fetchSafe(`${VAVOO_BASE_URL}/manifest.json`, "Vavoo");
|
||||
|
||||
const manifest = {
|
||||
id: "org.stremio.merged",
|
||||
version: "1.0.0",
|
||||
|
|
@ -489,16 +520,6 @@ async function getManifest() {
|
|||
idPrefixes: [],
|
||||
};
|
||||
|
||||
// Add Vavoo catalogs
|
||||
if (vavooData) {
|
||||
if (vavooData.catalogs) manifest.catalogs.push(...vavooData.catalogs);
|
||||
if (vavooData.idPrefixes) {
|
||||
manifest.idPrefixes.push(...vavooData.idPrefixes);
|
||||
} else {
|
||||
manifest.idPrefixes.push("vavoo_");
|
||||
}
|
||||
}
|
||||
|
||||
// Add Wiflix (WITV) catalogs
|
||||
const wiflixCatalogs = [
|
||||
{ type: "tv", id: "wiflix_sport", name: "⚽ Sport" },
|
||||
|
|
@ -575,12 +596,16 @@ async function getCatalog(type, catalogId, accessKey = null) {
|
|||
return await response.json();
|
||||
}
|
||||
|
||||
// Default: Vavoo catalog
|
||||
const url = `${VAVOO_BASE_URL}/catalog/${type}/${catalogId}.json`;
|
||||
const catalog = await fetchSafe(url, "Catalog " + catalogId);
|
||||
if (!catalog) throw new Error("Catalog fetch failed");
|
||||
|
||||
return catalog;
|
||||
// Default: route to backend (handles matches_, TV Direct, etc.)
|
||||
console.log(`[CATALOG] Fetching catalog via Backend: ${catalogId}`);
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/livetv/catalog/tv/${catalogId}`,
|
||||
{
|
||||
headers: buildBackendApiHeaders(accessKey),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(`Backend API error: ${response.status}`);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// Wiflix catalog categories mapping (URL paths)
|
||||
|
|
@ -743,21 +768,16 @@ async function getStream(type, channelId, accessKey = null, options = {}) {
|
|||
return await getLiveTvStream(channelId, accessKey, options);
|
||||
}
|
||||
|
||||
// Default: Vavoo stream
|
||||
const url = `${VAVOO_BASE_URL}/stream/${type}/${channelId}.json`;
|
||||
const streamData = await fetchSafe(url, "Vavoo Stream");
|
||||
|
||||
if (streamData && streamData.streams) {
|
||||
for (const stream of streamData.streams) {
|
||||
if (stream.url) {
|
||||
await addUserAgentRule(stream.url, "VAVOO/2.6");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!streamData) throw new Error("Stream fetch failed");
|
||||
|
||||
return streamData;
|
||||
// Default: route to backend (handles matches_, TV Direct, etc.)
|
||||
console.log(`[STREAM] Fetching stream via Backend: ${channelId}`);
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/livetv/stream/tv/${channelId}`,
|
||||
{
|
||||
headers: buildBackendApiHeaders(accessKey),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(`Backend API error: ${response.status}`);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// Find the channel page URL from cache or by searching
|
||||
|
|
@ -2387,6 +2407,151 @@ async function addUserAgentRule(urlPattern, userAgent) {
|
|||
return addHeadersRule(urlPattern, { "User-Agent": userAgent });
|
||||
}
|
||||
|
||||
// FCTV (matches) native streams: the rotating segment CDN hosts are Referer-
|
||||
// gated to the current player origin. They all share a `/cfall/s.../v3b/` path,
|
||||
// so one rule injects the player Referer for every host + cdnSmartLink redirect.
|
||||
// Fixed id => replaced when the player domain rotates.
|
||||
const FCTV_HEADERS_RULE_ID = 60;
|
||||
async function setupFctvHeadersRule(referer) {
|
||||
if (!referer) return false;
|
||||
const ref = referer.endsWith("/") ? referer : referer + "/";
|
||||
let origin;
|
||||
try {
|
||||
origin = new URL(ref).origin;
|
||||
} catch {
|
||||
origin = ref.replace(/\/+$/, "");
|
||||
}
|
||||
try {
|
||||
await chrome.declarativeNetRequest.updateDynamicRules({
|
||||
removeRuleIds: [FCTV_HEADERS_RULE_ID],
|
||||
addRules: [
|
||||
{
|
||||
id: FCTV_HEADERS_RULE_ID,
|
||||
priority: 20,
|
||||
action: {
|
||||
type: "modifyHeaders",
|
||||
requestHeaders: [
|
||||
{ header: "Referer", operation: "set", value: ref },
|
||||
{ header: "Origin", operation: "set", value: origin },
|
||||
{ header: "User-Agent", operation: "set", value: STREAM_PROXY_USER_AGENT },
|
||||
],
|
||||
},
|
||||
condition: {
|
||||
urlFilter: "/cfall/s",
|
||||
resourceTypes: ["xmlhttprequest", "media", "other"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("[FCTV] Failed to add headers rule:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// === FCTV (matches) local resolver ==========================================
|
||||
// The stream is IP-locked: the token must be minted from the SAME IP that
|
||||
// fetches the segments. So free users resolve it here, in the browser, then
|
||||
// HLS plays the segments directly with the Referer injected by the DNR rule.
|
||||
const FCTV_API_BASE = "https://apis-data-defra10.tcore131ybdf.ru";
|
||||
const FCTV_TOKEN_KEYSTREAM_HEX =
|
||||
"15764bab80a419c6abdd5518f3db0ea95bb3b9a2e2b519ce5c159af6917e2000c2d680ae30706a3aba1c9c25786c7c28774eecf20450a3cf414ca17f6472798cfa557c7a8705b7861f06e84f827f8a24676eeab77ce504bfc335b79609b9";
|
||||
|
||||
function fctvRot47(s) {
|
||||
let out = "";
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const k = s.charCodeAt(i);
|
||||
out += k < 33 || k > 126 ? s[i] : String.fromCharCode(33 + ((k - 33 + 47) % 94));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function fctvReadVarint(buf, off) {
|
||||
let result = 0n, shift = 0n, cur = off;
|
||||
while (cur < buf.length) {
|
||||
const b = buf[cur++];
|
||||
result |= BigInt(b & 0x7f) << shift;
|
||||
if ((b & 0x80) === 0) return { value: result, offset: cur };
|
||||
shift += 7n;
|
||||
if (shift > 70n) break;
|
||||
}
|
||||
throw new Error("varint");
|
||||
}
|
||||
function fctvDecode(buf, depth = 0) {
|
||||
const fields = [];
|
||||
let off = 0;
|
||||
while (off < buf.length) {
|
||||
let tag;
|
||||
try { tag = fctvReadVarint(buf, off); } catch { break; }
|
||||
off = tag.offset;
|
||||
const field = Number(tag.value >> 3n);
|
||||
const wt = Number(tag.value & 7n);
|
||||
const e = { field, wireType: wt };
|
||||
if (wt === 0) {
|
||||
const p = fctvReadVarint(buf, off); off = p.offset;
|
||||
} else if (wt === 1) {
|
||||
off += 8;
|
||||
} else if (wt === 2) {
|
||||
const pl = fctvReadVarint(buf, off); off = pl.offset;
|
||||
const len = Number(pl.value);
|
||||
const bytes = buf.subarray(off, off + len);
|
||||
off += len;
|
||||
let text = "";
|
||||
try { text = new TextDecoder().decode(bytes); } catch {}
|
||||
let printable = 0;
|
||||
for (let i = 0; i < text.length; i++) { const c = text.charCodeAt(i); if ((c >= 32 && c <= 126) || c >= 160) printable++; }
|
||||
if (text && printable / text.length > 0.7) e.value = text;
|
||||
if (depth < 8 && bytes.length) { try { const ch = fctvDecode(bytes, depth + 1); if (ch.length) e.children = ch; } catch {} }
|
||||
} else if (wt === 5) {
|
||||
off += 4;
|
||||
} else break;
|
||||
fields.push(e);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
const fctvField = (fields, f) => (fields || []).find((e) => e.field === f);
|
||||
const fctvChildren = (fields, f) => { const x = fctvField(fields, f); return (x && x.children) || []; };
|
||||
function fctvMakeToken(rbSession) {
|
||||
const ks = [];
|
||||
for (let i = 0; i < FCTV_TOKEN_KEYSTREAM_HEX.length; i += 2) ks.push(parseInt(FCTV_TOKEN_KEYSTREAM_HEX.substr(i, 2), 16));
|
||||
const pt = new TextEncoder().encode(rbSession);
|
||||
const n = Math.min(pt.length, ks.length);
|
||||
let bin = "";
|
||||
for (let i = 0; i < n; i++) bin += String.fromCharCode(pt[i] ^ ks[i]);
|
||||
return encodeURIComponent(btoa(bin) + "a");
|
||||
}
|
||||
|
||||
// Resolve one server -> tokenised m3u8 url (IP-bound to THIS browser).
|
||||
async function resolveFctvStream(opts) {
|
||||
const { matchId, streamId, siteType, sportType, referer, apiBase } = opts || {};
|
||||
if (!matchId || !streamId) return null;
|
||||
const base = apiBase || FCTV_API_BASE;
|
||||
const u = new URL(base + "/api/stream/detail");
|
||||
u.searchParams.set("streamId", String(streamId));
|
||||
u.searchParams.set("siteType", String(siteType || 2001));
|
||||
u.searchParams.set("continent", "EU");
|
||||
u.searchParams.set("country", "FR");
|
||||
u.searchParams.set("digit", "seth");
|
||||
u.searchParams.set("matchId", String(matchId));
|
||||
u.searchParams.set("sportType", String(sportType || 1));
|
||||
// NB: a service worker can't set Referer on fetch — stream/detail returns the
|
||||
// rb-session header regardless. Only the segments are Referer-gated (DNR rule).
|
||||
const resp = await fetch(u.toString(), { headers: { Accept: "*/*" } });
|
||||
const rbSession = resp.headers.get("rb-session");
|
||||
const buf = new Uint8Array(await resp.arrayBuffer());
|
||||
const root = fctvDecode(buf);
|
||||
const body = fctvChildren(root, 10);
|
||||
const inner = fctvChildren(body, 2).length ? fctvChildren(body, 2) : body;
|
||||
const maskedField = fctvField(inner, 4);
|
||||
const masked = maskedField && typeof maskedField.value === "string" ? maskedField.value : "";
|
||||
if (!masked || !rbSession) return null;
|
||||
let parsed;
|
||||
try { parsed = new URL(fctvRot47(masked).slice(8)); } catch { return null; }
|
||||
const token = fctvMakeToken(rbSession);
|
||||
if (referer) await setupFctvHeadersRule(referer);
|
||||
return `${parsed.origin}/token-${token}${parsed.pathname}${parsed.search}`;
|
||||
}
|
||||
|
||||
async function addHeadersRule(urlPattern, headers) {
|
||||
const existingRules = await chrome.declarativeNetRequest.getDynamicRules();
|
||||
const existingIds = new Set(existingRules.map((r) => r.id));
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Movix Proxy Extension",
|
||||
"version": "1.3.3",
|
||||
"version": "1.3.7",
|
||||
"description": "Extension proxy pour Live TV Movix - Contourne CORS, injecte les headers et extrait les sources Nexus",
|
||||
"icons": {
|
||||
"16": "movix.png",
|
||||
|
|
@ -45,7 +45,9 @@
|
|||
"*://movix.club/*",
|
||||
"*://*.movix.club/*",
|
||||
"*://movix.golf/*",
|
||||
"*://*.movix.golf/*"
|
||||
"*://*.movix.golf/*",
|
||||
"*://movix.chat/*",
|
||||
"*://*.movix.chat/*"
|
||||
]
|
||||
},
|
||||
"web_accessible_resources": [
|
||||
|
|
|
|||
|
|
@ -424,7 +424,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 12px; text-align: center;">
|
||||
<a href="https://movix.golf/settings#extractions" target="_blank" style="color:#6366f1;text-decoration:none;font-size:12px;font-weight:600;">
|
||||
<a href="https://movix.chat/settings#extractions" target="_blank" style="color:#6366f1;text-decoration:none;font-size:12px;font-weight:600;">
|
||||
Configurer →
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -456,7 +456,7 @@
|
|||
|
||||
<!-- Footer -->
|
||||
<div class="footer fade-in fade-in-delay-6">
|
||||
<a href="https://movix.golf" target="_blank">
|
||||
<a href="https://movix.chat" target="_blank">
|
||||
Ouvrir Movix
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
|
|
|
|||
|
|
@ -4,14 +4,31 @@
|
|||
|
||||
const browserAPI = typeof browser !== "undefined" ? browser : chrome;
|
||||
|
||||
const VAVOO_BASE_URL = "https://tvvoo.hayd.uk/cfg-fr";
|
||||
const WITV_BASE_URL = "https://witv.team";
|
||||
const SOSPLAY_BASE_URL = "https://streamonsport.art";
|
||||
const LIVETV_BASE_URL = "https://livetv882.me/frx/";
|
||||
const LIVETV_EMBED_ORIGIN = "https://livetv882.me";
|
||||
const LIVETV_EMBED_REFERER = LIVETV_BASE_URL;
|
||||
// Backend API URL for got-scraping based extraction
|
||||
const API_BASE_URL = "https://api.movix.cash";
|
||||
// Backend API URL for got-scraping based extraction.
|
||||
// Dev override: when the requesting page is localhost (Vite dev on :3000),
|
||||
// talk to the local backend (:25565) instead of prod. Set per-message from
|
||||
// the sender origin (see maybeUseLocalApi in the onMessage listener below).
|
||||
const PROD_API_BASE_URL = "https://api.movix.chat";
|
||||
const LOCAL_API_BASE_URL = "http://localhost:25565";
|
||||
let API_BASE_URL = PROD_API_BASE_URL;
|
||||
|
||||
function maybeUseLocalApi(sender) {
|
||||
try {
|
||||
const u =
|
||||
sender && (sender.url || (sender.tab && sender.tab.url) || sender.origin);
|
||||
if (!u) return;
|
||||
const host = new URL(u).hostname;
|
||||
API_BASE_URL =
|
||||
host === "localhost" || host === "127.0.0.1"
|
||||
? LOCAL_API_BASE_URL
|
||||
: PROD_API_BASE_URL;
|
||||
} catch (e) {}
|
||||
}
|
||||
const STREAM_PROXY_USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
||||
|
||||
|
|
@ -122,6 +139,7 @@ async function setupRules() {
|
|||
"movix.cloud",
|
||||
"movix.tax",
|
||||
"movix.club",
|
||||
"movix.chat",
|
||||
"movix.golf",
|
||||
],
|
||||
resourceTypes: [
|
||||
|
|
@ -170,6 +188,7 @@ function isEmbedAllowed(type) {
|
|||
|
||||
// Handle messages
|
||||
browserAPI.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
maybeUseLocalApi(sender);
|
||||
handleMessage(message)
|
||||
.then(sendResponse)
|
||||
.catch((err) => sendResponse({ error: err.message }));
|
||||
|
|
@ -270,6 +289,20 @@ async function handleMessage(message) {
|
|||
return { success: false, error: "Could not setup headers" };
|
||||
}
|
||||
|
||||
// FCTV (matches) native: inject the player Referer on the CDN segments so
|
||||
// free users play natively without the server proxy.
|
||||
case "SETUP_FCTV_HEADERS": {
|
||||
const okFctv = await setupFctvHeadersRule(payload?.referer);
|
||||
return { success: okFctv };
|
||||
}
|
||||
|
||||
// FCTV (matches) native: resolve ONE server locally (IP-bound) -> m3u8 url.
|
||||
case "RESOLVE_FCTV": {
|
||||
const fctvUrl = await resolveFctvStream(payload || {});
|
||||
if (fctvUrl) return { success: true, url: fctvUrl };
|
||||
return { success: false, error: "fctv resolve failed" };
|
||||
}
|
||||
|
||||
case "SET_EXTRACTION_PREFS": {
|
||||
const incoming = payload?.prefs;
|
||||
if (incoming && incoming.version === 1 && incoming.m3u8 && incoming.livetv) {
|
||||
|
|
@ -468,8 +501,8 @@ function handleDetectEmbeds(payload) {
|
|||
function buildBackendApiHeaders(accessKey, extraHeaders = {}) {
|
||||
const headers = {
|
||||
Accept: "application/json",
|
||||
Origin: "https://movix.cash",
|
||||
Referer: "https://movix.cash/",
|
||||
Origin: "https://movix.chat",
|
||||
Referer: "https://movix.chat/",
|
||||
...extraHeaders,
|
||||
};
|
||||
|
||||
|
|
@ -481,9 +514,6 @@ function buildBackendApiHeaders(accessKey, extraHeaders = {}) {
|
|||
}
|
||||
|
||||
async function getManifest() {
|
||||
console.log("Fetching manifest from Vavoo...");
|
||||
const vavooData = await fetchSafe(`${VAVOO_BASE_URL}/manifest.json`, "Vavoo");
|
||||
|
||||
const manifest = {
|
||||
id: "org.stremio.merged",
|
||||
version: "1.0.0",
|
||||
|
|
@ -495,16 +525,6 @@ async function getManifest() {
|
|||
idPrefixes: [],
|
||||
};
|
||||
|
||||
// Add Vavoo catalogs
|
||||
if (vavooData) {
|
||||
if (vavooData.catalogs) manifest.catalogs.push(...vavooData.catalogs);
|
||||
if (vavooData.idPrefixes) {
|
||||
manifest.idPrefixes.push(...vavooData.idPrefixes);
|
||||
} else {
|
||||
manifest.idPrefixes.push("vavoo_");
|
||||
}
|
||||
}
|
||||
|
||||
// Add Wiflix (WITV) catalogs
|
||||
const wiflixCatalogs = [
|
||||
{ type: "tv", id: "wiflix_sport", name: "⚽ Sport" },
|
||||
|
|
@ -581,12 +601,16 @@ async function getCatalog(type, catalogId, accessKey = null) {
|
|||
return await response.json();
|
||||
}
|
||||
|
||||
// Default: Vavoo catalog
|
||||
const url = `${VAVOO_BASE_URL}/catalog/${type}/${catalogId}.json`;
|
||||
const catalog = await fetchSafe(url, "Catalog " + catalogId);
|
||||
if (!catalog) throw new Error("Catalog fetch failed");
|
||||
|
||||
return catalog;
|
||||
// Default: route to backend (handles matches_, TV Direct, etc.)
|
||||
console.log(`[CATALOG] Fetching catalog via Backend: ${catalogId}`);
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/livetv/catalog/tv/${catalogId}`,
|
||||
{
|
||||
headers: buildBackendApiHeaders(accessKey),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(`Backend API error: ${response.status}`);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// Wiflix catalog categories mapping (URL paths)
|
||||
|
|
@ -749,21 +773,16 @@ async function getStream(type, channelId, accessKey = null, options = {}) {
|
|||
return await getLiveTvStream(channelId, accessKey, options);
|
||||
}
|
||||
|
||||
// Default: Vavoo stream
|
||||
const url = `${VAVOO_BASE_URL}/stream/${type}/${channelId}.json`;
|
||||
const streamData = await fetchSafe(url, "Vavoo Stream");
|
||||
|
||||
if (streamData && streamData.streams) {
|
||||
for (const stream of streamData.streams) {
|
||||
if (stream.url) {
|
||||
await addUserAgentRule(stream.url, "VAVOO/2.6");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!streamData) throw new Error("Stream fetch failed");
|
||||
|
||||
return streamData;
|
||||
// Default: route to backend (handles matches_, TV Direct, etc.)
|
||||
console.log(`[STREAM] Fetching stream via Backend: ${channelId}`);
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/livetv/stream/tv/${channelId}`,
|
||||
{
|
||||
headers: buildBackendApiHeaders(accessKey),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(`Backend API error: ${response.status}`);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// Find the channel page URL from cache or by searching
|
||||
|
|
@ -2415,10 +2434,149 @@ async function saveToCache(key, data, ttlMinutes) {
|
|||
// DNR Helper
|
||||
let ruleIdCounter = 100;
|
||||
|
||||
// FCTV (matches) native streams: rotating segment CDN hosts are Referer-gated
|
||||
// to the current player origin; they share a `/cfall/s.../v3b/` path, so one
|
||||
// rule injects the player Referer for every host + cdnSmartLink redirect.
|
||||
const FCTV_HEADERS_RULE_ID = 60;
|
||||
async function setupFctvHeadersRule(referer) {
|
||||
if (!referer) return false;
|
||||
const ref = referer.endsWith("/") ? referer : referer + "/";
|
||||
let origin;
|
||||
try {
|
||||
origin = new URL(ref).origin;
|
||||
} catch {
|
||||
origin = ref.replace(/\/+$/, "");
|
||||
}
|
||||
try {
|
||||
await browserAPI.declarativeNetRequest.updateDynamicRules({
|
||||
removeRuleIds: [FCTV_HEADERS_RULE_ID],
|
||||
addRules: [
|
||||
{
|
||||
id: FCTV_HEADERS_RULE_ID,
|
||||
priority: 20,
|
||||
action: {
|
||||
type: "modifyHeaders",
|
||||
requestHeaders: [
|
||||
{ header: "Referer", operation: "set", value: ref },
|
||||
{ header: "Origin", operation: "set", value: origin },
|
||||
{ header: "User-Agent", operation: "set", value: STREAM_PROXY_USER_AGENT },
|
||||
],
|
||||
},
|
||||
condition: {
|
||||
urlFilter: "/cfall/s",
|
||||
resourceTypes: ["xmlhttprequest", "media", "other"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("[FCTV] Failed to add headers rule:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addUserAgentRule(urlPattern, userAgent) {
|
||||
return addHeadersRule(urlPattern, { "User-Agent": userAgent });
|
||||
}
|
||||
|
||||
// === FCTV (matches) local resolver ==========================================
|
||||
// IP-locked stream: the token must be minted from the SAME IP that fetches the
|
||||
// segments, so free users resolve it here in the browser.
|
||||
const FCTV_API_BASE = "https://apis-data-defra10.tcore131ybdf.ru";
|
||||
const FCTV_TOKEN_KEYSTREAM_HEX =
|
||||
"15764bab80a419c6abdd5518f3db0ea95bb3b9a2e2b519ce5c159af6917e2000c2d680ae30706a3aba1c9c25786c7c28774eecf20450a3cf414ca17f6472798cfa557c7a8705b7861f06e84f827f8a24676eeab77ce504bfc335b79609b9";
|
||||
|
||||
function fctvRot47(s) {
|
||||
let out = "";
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const k = s.charCodeAt(i);
|
||||
out += k < 33 || k > 126 ? s[i] : String.fromCharCode(33 + ((k - 33 + 47) % 94));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function fctvReadVarint(buf, off) {
|
||||
let result = 0n, shift = 0n, cur = off;
|
||||
while (cur < buf.length) {
|
||||
const b = buf[cur++];
|
||||
result |= BigInt(b & 0x7f) << shift;
|
||||
if ((b & 0x80) === 0) return { value: result, offset: cur };
|
||||
shift += 7n;
|
||||
if (shift > 70n) break;
|
||||
}
|
||||
throw new Error("varint");
|
||||
}
|
||||
function fctvDecode(buf, depth = 0) {
|
||||
const fields = [];
|
||||
let off = 0;
|
||||
while (off < buf.length) {
|
||||
let tag;
|
||||
try { tag = fctvReadVarint(buf, off); } catch { break; }
|
||||
off = tag.offset;
|
||||
const field = Number(tag.value >> 3n);
|
||||
const wt = Number(tag.value & 7n);
|
||||
const e = { field, wireType: wt };
|
||||
if (wt === 0) {
|
||||
const p = fctvReadVarint(buf, off); off = p.offset;
|
||||
} else if (wt === 1) {
|
||||
off += 8;
|
||||
} else if (wt === 2) {
|
||||
const pl = fctvReadVarint(buf, off); off = pl.offset;
|
||||
const len = Number(pl.value);
|
||||
const bytes = buf.subarray(off, off + len);
|
||||
off += len;
|
||||
let text = "";
|
||||
try { text = new TextDecoder().decode(bytes); } catch {}
|
||||
let printable = 0;
|
||||
for (let i = 0; i < text.length; i++) { const c = text.charCodeAt(i); if ((c >= 32 && c <= 126) || c >= 160) printable++; }
|
||||
if (text && printable / text.length > 0.7) e.value = text;
|
||||
if (depth < 8 && bytes.length) { try { const ch = fctvDecode(bytes, depth + 1); if (ch.length) e.children = ch; } catch {} }
|
||||
} else if (wt === 5) {
|
||||
off += 4;
|
||||
} else break;
|
||||
fields.push(e);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
const fctvField = (fields, f) => (fields || []).find((e) => e.field === f);
|
||||
const fctvChildren = (fields, f) => { const x = fctvField(fields, f); return (x && x.children) || []; };
|
||||
function fctvMakeToken(rbSession) {
|
||||
const ks = [];
|
||||
for (let i = 0; i < FCTV_TOKEN_KEYSTREAM_HEX.length; i += 2) ks.push(parseInt(FCTV_TOKEN_KEYSTREAM_HEX.substr(i, 2), 16));
|
||||
const pt = new TextEncoder().encode(rbSession);
|
||||
const n = Math.min(pt.length, ks.length);
|
||||
let bin = "";
|
||||
for (let i = 0; i < n; i++) bin += String.fromCharCode(pt[i] ^ ks[i]);
|
||||
return encodeURIComponent(btoa(bin) + "a");
|
||||
}
|
||||
async function resolveFctvStream(opts) {
|
||||
const { matchId, streamId, siteType, sportType, referer, apiBase } = opts || {};
|
||||
if (!matchId || !streamId) return null;
|
||||
const base = apiBase || FCTV_API_BASE;
|
||||
const u = new URL(base + "/api/stream/detail");
|
||||
u.searchParams.set("streamId", String(streamId));
|
||||
u.searchParams.set("siteType", String(siteType || 2001));
|
||||
u.searchParams.set("continent", "EU");
|
||||
u.searchParams.set("country", "FR");
|
||||
u.searchParams.set("digit", "seth");
|
||||
u.searchParams.set("matchId", String(matchId));
|
||||
u.searchParams.set("sportType", String(sportType || 1));
|
||||
const resp = await fetch(u.toString(), { headers: { Accept: "*/*" } });
|
||||
const rbSession = resp.headers.get("rb-session");
|
||||
const buf = new Uint8Array(await resp.arrayBuffer());
|
||||
const root = fctvDecode(buf);
|
||||
const body = fctvChildren(root, 10);
|
||||
const inner = fctvChildren(body, 2).length ? fctvChildren(body, 2) : body;
|
||||
const maskedField = fctvField(inner, 4);
|
||||
const masked = maskedField && typeof maskedField.value === "string" ? maskedField.value : "";
|
||||
if (!masked || !rbSession) return null;
|
||||
let parsed;
|
||||
try { parsed = new URL(fctvRot47(masked).slice(8)); } catch { return null; }
|
||||
const token = fctvMakeToken(rbSession);
|
||||
if (referer) await setupFctvHeadersRule(referer);
|
||||
return `${parsed.origin}/token-${token}${parsed.pathname}${parsed.search}`;
|
||||
}
|
||||
|
||||
async function addHeadersRule(urlPattern, headers) {
|
||||
const existingRules =
|
||||
await browserAPI.declarativeNetRequest.getDynamicRules();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Movix Proxy Extension",
|
||||
"version": "1.5.1",
|
||||
"version": "1.5.4",
|
||||
"description": "Extension proxy pour Live TV Movix - Contourne CORS, injecte les headers et extrait les sources Nexus",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
|
|
|
|||
|
|
@ -424,7 +424,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 12px; text-align: center;">
|
||||
<a href="https://movix.golf/settings#extractions" target="_blank" style="color:#6366f1;text-decoration:none;font-size:12px;font-weight:600;">
|
||||
<a href="https://movix.chat/settings#extractions" target="_blank" style="color:#6366f1;text-decoration:none;font-size:12px;font-weight:600;">
|
||||
Configurer →
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -456,7 +456,7 @@
|
|||
|
||||
<!-- Footer -->
|
||||
<div class="footer fade-in fade-in-delay-6">
|
||||
<a href="https://movix.golf" target="_blank">
|
||||
<a href="https://movix.chat" target="_blank">
|
||||
Ouvrir Movix
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
|
|
|
|||
|
|
@ -73,9 +73,16 @@ class ProxyLoader {
|
|||
abort() { this.delegate.abort(); }
|
||||
|
||||
load(context: any, config: any, callbacks: any) {
|
||||
const isManifest = context.type === 'manifest';
|
||||
const isDirectLevelReload = context.type === 'level' && ProxyLoader._forceLevelReloads;
|
||||
const url: string = context.url;
|
||||
const isManifest = context.type === 'manifest';
|
||||
// A child playlist already rewritten by proxiesembed uses the path form
|
||||
// `${base}/proxy/<token>.m3u8`, distinct from the manifest/direct query form
|
||||
// `${base}/proxy?url=...`. Such children are stable, independently reloadable
|
||||
// URLs — never force them back to the master, otherwise a single-variant
|
||||
// master loops forever: master → variant → forced to master → variant → ...
|
||||
const isRewrittenChild = url.includes('/proxy/') && !url.includes('/proxy?url=');
|
||||
const isDirectLevelReload =
|
||||
context.type === 'level' && ProxyLoader._forceLevelReloads && !isRewrittenChild;
|
||||
|
||||
if (isManifest || isDirectLevelReload) {
|
||||
// Keep only the top-level manifest anchored on the original proxy URL.
|
||||
|
|
@ -254,6 +261,14 @@ interface Stream {
|
|||
originalUrl?: string; // Original unproxied URL for extension use (Wiflix streams)
|
||||
referer?: string;
|
||||
userAgent?: string;
|
||||
_fctvReferer?: string; // FCTV native (free): player Referer for the extension to inject
|
||||
_fctvLocal?: { // FCTV native (free): resolve in-browser via the extension (IP-locked stream)
|
||||
matchId: string | number;
|
||||
streamId: string | number;
|
||||
siteType: string | number;
|
||||
sportType: string | number;
|
||||
apiBase: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface LiveTvSourceOption {
|
||||
|
|
@ -370,6 +385,28 @@ const LiveTVPlayer: React.FC<LiveTVPlayerProps> = ({
|
|||
const [sourceOptions, setSourceOptions] = useState<LiveTvSourceOption[]>([]);
|
||||
const [selectedSourceIndex, setSelectedSourceIndex] = useState<number | null>(null);
|
||||
const [currentStreamIndex, setCurrentStreamIndex] = useState(0);
|
||||
|
||||
// FCTV native (matches) is IP-locked: the token must be minted from the
|
||||
// user's IP. So free users resolve each server in the browser via the
|
||||
// extension/userscript (RESOLVE_FCTV), which also installs the Referer rule.
|
||||
// Stubs that can't be resolved (no extension) keep an empty url and get
|
||||
// filtered out below -> those users fall back to the embed player.
|
||||
const resolveFctvStubs = useCallback(async (rawStreams: Stream[]): Promise<Stream[]> => {
|
||||
if (!Array.isArray(rawStreams) || !rawStreams.some((s) => s._fctvLocal)) return rawStreams;
|
||||
if (!isExtensionAvailable()) return rawStreams;
|
||||
return Promise.all(rawStreams.map(async (s) => {
|
||||
if (!s._fctvLocal) return s;
|
||||
try {
|
||||
const res = await fetchFromExtension<{ url?: string }>('RESOLVE_FCTV', {
|
||||
...s._fctvLocal,
|
||||
referer: s._fctvReferer,
|
||||
});
|
||||
if (res?.url) return { ...s, url: res.url };
|
||||
} catch { /* keep stub -> filtered out */ }
|
||||
return s;
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
|
@ -574,21 +611,46 @@ const LiveTVPlayer: React.FC<LiveTVPlayerProps> = ({
|
|||
|
||||
// Initialize Cast APIs
|
||||
useEffect(() => {
|
||||
void initializeCastApi();
|
||||
// The Cast SDK loads async from index.html — at mount time
|
||||
// chrome.cast.isAvailable is often still false. Initializing once and
|
||||
// giving up left the cast button permanently dead on first visits, so
|
||||
// hook __onGCastApiAvailable to retry when the SDK announces itself.
|
||||
let restoreCastCallback: (() => void) | undefined;
|
||||
if ((window as any).chrome?.cast?.isAvailable) {
|
||||
void initializeCastApi();
|
||||
} else {
|
||||
const previousCallback = (window as any).__onGCastApiAvailable;
|
||||
(window as any).__onGCastApiAvailable = (isAvailable: boolean) => {
|
||||
if (typeof previousCallback === 'function') {
|
||||
previousCallback(isAvailable);
|
||||
}
|
||||
if (isAvailable) {
|
||||
void initializeCastApi();
|
||||
}
|
||||
};
|
||||
restoreCastCallback = () => {
|
||||
(window as any).__onGCastApiAvailable = previousCallback;
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize AirPlay
|
||||
let airplayCleanup: (() => void) | undefined;
|
||||
if (videoRef.current && isAirPlaySupported()) {
|
||||
const cleanup = initializeAirPlay(videoRef.current, (state) => {
|
||||
airplayCleanup = initializeAirPlay(videoRef.current, (state) => {
|
||||
setAirplayState(state);
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
return () => {
|
||||
restoreCastCallback?.();
|
||||
airplayCleanup?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
// API Base URL
|
||||
const API_BASE = import.meta.env.VITE_MAIN_API || 'http://localhost:25565';
|
||||
const isLiveTvChannel = channelId.startsWith('livetv_');
|
||||
const isLiveTvChannel = channelId.startsWith('livetv_') || channelId.startsWith('daddylive_');
|
||||
|
||||
const fetchChannelPayload = useCallback(async (requestOptions: StreamRequestOptions = {}) => {
|
||||
const isLinkzy = channelId.startsWith('linkzy');
|
||||
|
|
@ -640,7 +702,8 @@ const LiveTVPlayer: React.FC<LiveTVPlayerProps> = ({
|
|||
for (let attempt = 0; attempt < MAX_404_RETRIES && !loaded; attempt++) {
|
||||
try {
|
||||
const data = await fetchChannelPayload(requestOptions);
|
||||
const validStreams = (data.streams || []).filter((stream: Stream) =>
|
||||
const rawStreams = await resolveFctvStubs(data.streams || []);
|
||||
const validStreams = rawStreams.filter((stream: Stream) =>
|
||||
stream.url && !hasNonStandardPort(stream.url)
|
||||
);
|
||||
|
||||
|
|
@ -765,7 +828,8 @@ const LiveTVPlayer: React.FC<LiveTVPlayerProps> = ({
|
|||
}
|
||||
|
||||
// Streams are already pre-parsed by the backend (each source is a separate stream entry)
|
||||
const validStreams = (data.streams || []).filter((stream: Stream) =>
|
||||
const rawStreams = await resolveFctvStubs(data.streams || []);
|
||||
const validStreams = rawStreams.filter((stream: Stream) =>
|
||||
stream.url && !hasNonStandardPort(stream.url)
|
||||
);
|
||||
|
||||
|
|
@ -1676,6 +1740,14 @@ const LiveTVPlayer: React.FC<LiveTVPlayerProps> = ({
|
|||
const selectedStream = streams[currentStreamIndex];
|
||||
if (!selectedStream) return;
|
||||
|
||||
// Embed pages (iframe players) can't be loaded by the Default Media
|
||||
// Receiver — it only plays direct media URLs. Tell the user instead
|
||||
// of sending a text/html contentId that fails silently on the TV.
|
||||
if (selectedStream._isEmbed) {
|
||||
toast.error(t('watch.someSourcesIncompatible'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshot selected server at click time so async cast dialog can't drift to another server.
|
||||
const castUrl = currentCastUrlRef.current || selectedStream.url;
|
||||
const castTitle = selectedStream.title
|
||||
|
|
@ -1684,23 +1756,54 @@ const LiveTVPlayer: React.FC<LiveTVPlayerProps> = ({
|
|||
|
||||
try {
|
||||
const session = await requestCastSession();
|
||||
|
||||
// Live channels must be declared streamType LIVE — BUFFERED makes
|
||||
// receivers treat the stream as seekable VOD, which breaks or stalls
|
||||
// playback on several Chromecast generations. castUrl already reflects
|
||||
// the URL the player is using for the current server.
|
||||
const mediaInfo = prepareCastMediaInfo(
|
||||
castUrl,
|
||||
castTitle,
|
||||
channelPoster,
|
||||
0
|
||||
0,
|
||||
'LIVE'
|
||||
);
|
||||
await loadMediaOnCast(session, mediaInfo);
|
||||
|
||||
setIsCasting(true);
|
||||
} catch (err) {
|
||||
console.error('Cast error:', err);
|
||||
// "cancel" = user closed the device picker — not an error worth surfacing.
|
||||
const code = (err as any)?.code;
|
||||
if (code !== 'cancel') {
|
||||
toast.error(t('watch.castError'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAirPlay = async () => {
|
||||
if (!videoRef.current) return;
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
try {
|
||||
await requestAirPlay(videoRef.current);
|
||||
// Picker first, synchronously inside the user gesture (transient
|
||||
// activation) — same ordering fix as HLSPlayer.startAirPlay.
|
||||
await requestAirPlay(video);
|
||||
|
||||
// When playback runs through hls.js (MSE), the AirPlay target only
|
||||
// sees a blob: URL and renders nothing. Swap to Safari-native HLS
|
||||
// (same URL the engine was reading, proxied or not) so the device
|
||||
// streams the real manifest. mpegts/dash engines have no native
|
||||
// equivalent — leave them as-is, the system picker still offers
|
||||
// screen mirroring as a fallback.
|
||||
const nativeUrl = currentCastUrlRef.current;
|
||||
if (hlsRef.current && nativeUrl && isAirPlaySupported()) {
|
||||
console.log('[AirPlay] Swapping hls.js (MSE) to native HLS for AirPlay:', nativeUrl);
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
video.src = nativeUrl;
|
||||
video.load();
|
||||
video.play().catch(() => { /* autoplay may need the AirPlay connect to settle */ });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('AirPlay error:', err);
|
||||
}
|
||||
|
|
@ -1793,7 +1896,7 @@ const LiveTVPlayer: React.FC<LiveTVPlayerProps> = ({
|
|||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className={`fixed inset-0 z-[12000] bg-black flex items-center justify-center ${shouldHideCursor ? 'cursor-none' : ''}`}
|
||||
className={`fixed inset-0 z-[12000] flex items-center justify-center ${isEmbedStream ? 'bg-black/80 p-4 backdrop-blur-md sm:p-6' : 'bg-black'} ${shouldHideCursor ? 'cursor-none' : ''}`}
|
||||
ref={containerRef}
|
||||
onMouseMove={isEmbedStream ? undefined : handleMouseMove}
|
||||
onClick={isEmbedStream ? undefined : handleMouseMove}
|
||||
|
|
@ -1835,65 +1938,68 @@ const LiveTVPlayer: React.FC<LiveTVPlayerProps> = ({
|
|||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{isEmbedStream && hasActiveStream && !error && (
|
||||
<>
|
||||
<div className="absolute top-4 left-4 z-50 flex items-center gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 rounded-lg bg-black/75 px-3 py-2 text-white shadow-lg transition-colors hover:bg-black/90"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
<span className="hidden sm:inline">{t('common.back')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-4 left-1/2 z-40 -translate-x-1/2 rounded-lg bg-black/60 px-4 py-2 text-center text-white shadow-lg backdrop-blur-sm">
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-red-400">Embed</p>
|
||||
<p className="text-sm font-semibold whitespace-nowrap">
|
||||
{activeStream?.title || channelName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-4 right-4 z-50 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => window.open(activeEmbedUrl, '_blank', 'noopener,noreferrer')}
|
||||
className="rounded-lg bg-gray-800/90 p-2 text-white shadow-lg transition-colors hover:bg-gray-700/90"
|
||||
title={t('watch.openInNewPage')}
|
||||
>
|
||||
<ExternalLink size={18} />
|
||||
</button>
|
||||
|
||||
{(streams.length > 1 || (isLiveTvChannel && sourceOptions.length > 0)) && (
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
className="flex items-center gap-2 rounded-lg bg-black/75 px-3 py-2 text-white shadow-lg transition-colors hover:bg-black/90"
|
||||
title={t('watch.sources')}
|
||||
>
|
||||
<Settings size={18} />
|
||||
<span className="hidden sm:inline">{t('watch.sources')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => { void toggleFullscreen(); }}
|
||||
className="rounded-lg bg-black/75 p-2 text-white shadow-lg transition-colors hover:bg-black/90"
|
||||
title={isFullscreen ? t('watchParty.exitFullscreen') : t('watchParty.fullscreen')}
|
||||
>
|
||||
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Video */}
|
||||
{/* Video or Iframe */}
|
||||
{isEmbedStream ? (
|
||||
<iframe
|
||||
src={activeEmbedUrl}
|
||||
className="w-full h-full border-0 bg-black"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
<div className="relative w-full max-w-5xl">
|
||||
{/* Header bar (controls) above the centered iframe */}
|
||||
{hasActiveStream && !error && (
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 rounded-lg bg-white/10 px-3 py-2 text-white shadow-lg transition-colors hover:bg-white/20"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
<span className="hidden sm:inline">{t('common.back')}</span>
|
||||
</button>
|
||||
|
||||
<div className="min-w-0 text-center">
|
||||
<p className="text-[11px] uppercase tracking-[0.22em] text-red-400">Embed</p>
|
||||
<p className="truncate text-sm font-semibold text-white">
|
||||
{activeStream?.title || channelName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => window.open(activeEmbedUrl, '_blank', 'noopener,noreferrer')}
|
||||
className="rounded-lg bg-white/10 p-2 text-white shadow-lg transition-colors hover:bg-white/20"
|
||||
title={t('watch.openInNewPage')}
|
||||
>
|
||||
<ExternalLink size={18} />
|
||||
</button>
|
||||
|
||||
{(streams.length > 1 || (isLiveTvChannel && sourceOptions.length > 0)) && (
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
className="flex items-center gap-2 rounded-lg bg-white/10 px-3 py-2 text-white shadow-lg transition-colors hover:bg-white/20"
|
||||
title={t('watch.sources')}
|
||||
>
|
||||
<Settings size={18} />
|
||||
<span className="hidden sm:inline">{t('watch.sources')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => { void toggleFullscreen(); }}
|
||||
className="rounded-lg bg-white/10 p-2 text-white shadow-lg transition-colors hover:bg-white/20"
|
||||
title={isFullscreen ? t('watchParty.exitFullscreen') : t('watchParty.fullscreen')}
|
||||
>
|
||||
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Centered 16:9 iframe card */}
|
||||
<div className="relative aspect-video w-full overflow-hidden rounded-2xl border border-white/10 bg-black shadow-2xl">
|
||||
<iframe
|
||||
src={activeEmbedUrl}
|
||||
className="absolute inset-0 h-full w-full border-0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<video
|
||||
ref={videoRef}
|
||||
|
|
|
|||
|
|
@ -1043,8 +1043,8 @@
|
|||
"castUnavailable": "Casting unavailable",
|
||||
"castUnavailableNoDevices": "No receiver detected on your Wi-Fi network.",
|
||||
"castUnavailableUnsupportedBrowser": "Your browser doesn't support casting on this page.",
|
||||
"castUnavailableSdkBlocked": "The Google Cast SDK couldn't load (likely blocked by an ad blocker or your ISP). Disable your blocker on movix.tax and reload the page.",
|
||||
"castUnavailableHelpChromecast": "Chromecast: use Chrome or Edge on the same Wi-Fi as the TV. If you have an ad blocker, disable it on movix.tax (it often blocks Google's Cast SDK).",
|
||||
"castUnavailableSdkBlocked": "The Google Cast SDK couldn't load (likely blocked by an ad blocker or your ISP). Disable your blocker on movix.chat and reload the page.",
|
||||
"castUnavailableHelpChromecast": "Chromecast: use Chrome or Edge on the same Wi-Fi as the TV. If you have an ad blocker, disable it on movix.chat (it often blocks Google's Cast SDK).",
|
||||
"castUnavailableHelpAirPlay": "AirPlay: open the page in Safari on iPhone, iPad or Mac.",
|
||||
"castUnavailableSeeHelp": "View the Chromecast guide",
|
||||
"cast": "Cast",
|
||||
|
|
@ -1529,7 +1529,8 @@
|
|||
"privacy": "Privacy",
|
||||
"data": "Data",
|
||||
"extractions": "Extractions",
|
||||
"sourcePriority": "Priority"
|
||||
"sourcePriority": "Priority",
|
||||
"adPopup": "Ad popup"
|
||||
},
|
||||
"appearance": "Appearance",
|
||||
"appearanceDesc": "Customize the interface and animations",
|
||||
|
|
@ -1774,7 +1775,8 @@
|
|||
"wiflix": "Lynx",
|
||||
"sosplay": "SOSPLAY",
|
||||
"livetv": "LiveTV",
|
||||
"matches": "Sports matches"
|
||||
"matches": "Sports matches",
|
||||
"daddylive": "Daddylive"
|
||||
},
|
||||
"cache": {
|
||||
"title": "Extraction cache",
|
||||
|
|
@ -1794,6 +1796,28 @@
|
|||
"reset": "Reset to defaults",
|
||||
"resetConfirm": "Reset all extraction preferences?"
|
||||
},
|
||||
"adPopup": {
|
||||
"title": "Ad popup",
|
||||
"description": "Choose how the ad popup before each player behaves.",
|
||||
"modeTitle": "Popup behavior",
|
||||
"modeDesc": "\"Auto\" opens the ad immediately without showing the popup. \"Click anywhere\" hides the UI and opens the ad as soon as you click on the screen.",
|
||||
"modes": {
|
||||
"normal": {
|
||||
"label": "Normal",
|
||||
"desc": "Shows the popup and its \"View ad\" button (default)."
|
||||
},
|
||||
"auto": {
|
||||
"label": "Auto-open",
|
||||
"desc": "Opens the ad immediately when the popup is triggered, no UI."
|
||||
},
|
||||
"clickAnywhere": {
|
||||
"label": "Click anywhere",
|
||||
"desc": "Hides the UI; a click anywhere on the screen opens the ad."
|
||||
}
|
||||
},
|
||||
"adultTitle": "Adult (+18) ads",
|
||||
"adultDesc": "On by default: adult-themed (+18) ads are used. Turn off to use standard ads."
|
||||
},
|
||||
"sourcePriority": {
|
||||
"title": "Source priority",
|
||||
"description": "Reorder, disable or pin sources (Movies, Shows, Anime) and the hosters used for auto-select.",
|
||||
|
|
@ -2408,6 +2432,7 @@
|
|||
"castToChromecast": "Cast to Chromecast",
|
||||
"castViaAirplay": "Cast via AirPlay",
|
||||
"watchFavoriteChannels": "Watch your favorite channels live",
|
||||
"streamDisclaimer": "We don't host or control these streams — no need to yell at us if it lags 😅",
|
||||
"searchChannel": "Search a channel...",
|
||||
"extensionPromo": "For more channel sources, install the Movix extension!",
|
||||
"vipUnlockSources": "Unlock more sources with VIP!",
|
||||
|
|
@ -2420,6 +2445,11 @@
|
|||
"inDays": "In {{days}}d {{hours}}h",
|
||||
"inHours": "In {{hours}}h {{minutes}}min",
|
||||
"inMinutes": "In {{minutes}} min",
|
||||
"availableServers": "Available servers",
|
||||
"watchMatch": "Watch match",
|
||||
"serversAvailable": "{{count}} server(s) available",
|
||||
"noServersFound": "No servers available at the moment.",
|
||||
"upcomingServersNote": "Streaming servers will be available a few minutes before the match starts.",
|
||||
"freeSource": "🆓 Linkzy (Free)",
|
||||
"matchesCatalogSource": "👑 Matches catalog",
|
||||
"retry": "Retry",
|
||||
|
|
@ -2896,6 +2926,7 @@
|
|||
"getAccessClicks": "Get your access in a few clicks:",
|
||||
"joinTelegramForVip": "To get VIP access (5€/year), join our Telegram.",
|
||||
"contactTelegramForVip": "To get VIP access (5€ for life), contact mysticsaba on Telegram.",
|
||||
"donateForKey": "Make a donation to get your Movix VIP key, or contact us on Telegram.",
|
||||
"quality4k": "Quality up to 4K HDR",
|
||||
"multiLangSubtitles": "Multi-language subtitles",
|
||||
"multipleLanguages": "Multiple languages available",
|
||||
|
|
@ -3357,8 +3388,8 @@
|
|||
"seriesSingular": "Series",
|
||||
"inDetail": "In detail",
|
||||
"yearSummary": "Here's the full summary of your year on Movix.",
|
||||
"preparingWrapped": "Preparing your Wrapped...",
|
||||
"analyzingYear": "One moment, we're analyzing your year",
|
||||
"preparingWrapped": "Your Wrapped is coming",
|
||||
"analyzingYear": "Digging through your year…",
|
||||
"spentOnMovix": "You spent on Movix...",
|
||||
"hopeYouHadSnacks": "Hope you had snacks. 🍿",
|
||||
"dataCollectionDisabled": "Data collection disabled",
|
||||
|
|
@ -3405,20 +3436,20 @@
|
|||
"shareFooterTag": "Share your Movix year",
|
||||
"top3FocusSubtitle": "Your top 3",
|
||||
"top2FocusSubtitle": "Your top 2",
|
||||
"podiumFocusText": "{{watchTime}} spent on it. Clearly, this {{type}} mattered in your year.",
|
||||
"top3FocusSubtext": "Bronze medal, but still a major favorite.",
|
||||
"top2FocusSubtext": "Just one step away from first place.",
|
||||
"podiumFocusText": "{{watchTime}} on it. This {{type}} clearly defined your year.",
|
||||
"top3FocusSubtext": "Bronze, but with main-character energy.",
|
||||
"top2FocusSubtext": "One step from the top.",
|
||||
"ratingLabel": "Rating {{rating}}",
|
||||
"sessionSummaryTitle": "Your viewing rhythm",
|
||||
"sessionSummarySubtitle": "How you really watch",
|
||||
"sessionSummaryText": "On average, one session lasted {{average}}, spread across {{activeDays}} active days.",
|
||||
"sessionSummaryText": "{{average}} per session on average, across {{activeDays}} active days. Consistency is your thing.",
|
||||
"avgSessionLabel": "Average session",
|
||||
"activeDaysLabel": "Active days",
|
||||
"percentileLabel": "Ranking",
|
||||
"percentileValue": "Top {{percent}}%",
|
||||
"watchBookendsTitle": "From your first watch to your last",
|
||||
"watchBookendsSubtitle": "The bookends of your year",
|
||||
"watchBookendsText": "You started with \"{{first}}\" and ended with \"{{last}}\". A pretty good way to frame the year.",
|
||||
"watchBookendsText": "You opened the year with \"{{first}}\" and closed it with \"{{last}}\". Nicely bookended.",
|
||||
"firstWatchLabel": "First watch",
|
||||
"lastWatchLabel": "Last watch",
|
||||
"unknownDate": "Unknown date",
|
||||
|
|
@ -3477,7 +3508,38 @@
|
|||
"missingSessions_one": "{{count}} session",
|
||||
"missingSessions_other": "{{count}} sessions",
|
||||
"missingActiveDays_one": "{{count}} active day",
|
||||
"missingActiveDays_other": "{{count}} active days"
|
||||
"missingActiveDays_other": "{{count}} active days",
|
||||
"shareTop3Label": "Your top 3",
|
||||
"quizTitle": "Guess your #1",
|
||||
"quizSubtitle": "One of these three ate your year. Which one?",
|
||||
"quizCorrect": "Nailed it.",
|
||||
"quizWrong": "Nope!",
|
||||
"quizRevealSubtitle": "Here's your real podium.",
|
||||
"quizSkip": "Skip the quiz",
|
||||
"quizContinue": "See the podium",
|
||||
"creditsDirectedBy": "Directed by",
|
||||
"creditsYou": "YOU",
|
||||
"creditsStarring": "Starring",
|
||||
"creditsGenre": "Genre of the year",
|
||||
"creditsRuntime": "Total runtime",
|
||||
"creditsProducedBy": "Produced by",
|
||||
"shareStreakLabel": "Day streak",
|
||||
"shareStreakValue": "{{days}}-day streak",
|
||||
"shareTopPercentLabel": "Ranking",
|
||||
"shareFormatStory": "Story",
|
||||
"shareFormatPoster": "Poster",
|
||||
"shareFormatTicket": "Ticket",
|
||||
"shareFormatsTitle": "Pick your format",
|
||||
"pageNames": {
|
||||
"home": "Home",
|
||||
"movies": "Movies",
|
||||
"tv-shows": "TV Shows",
|
||||
"movie-details": "Movie pages",
|
||||
"tv-details": "TV pages",
|
||||
"wishboard": "Wishboard",
|
||||
"watchparty": "WatchParty",
|
||||
"anime": "Anime"
|
||||
}
|
||||
},
|
||||
"roulette": {
|
||||
"title": "Movie Roulette",
|
||||
|
|
@ -3738,6 +3800,7 @@
|
|||
"adInfoWindow": "Ad information window",
|
||||
"doNotDo": "What you should NEVER do",
|
||||
"doNotClickAnywhere": "DO NOT click anywhere on the ad page",
|
||||
"clickAnywhereLabel": "Click anywhere to open the ad",
|
||||
"doNotScanQr": "DO NOT scan any QR code",
|
||||
"doNotDownloadAnything": "DO NOT download anything",
|
||||
"thanksUnlockedDownload": "Thanks, download unlocked! 🙏",
|
||||
|
|
@ -3876,7 +3939,7 @@
|
|||
"banError": "Error while banning",
|
||||
"unbanError": "Error while unbanning",
|
||||
"genericError": "Error",
|
||||
"generatedByGemini": "by Gemini AI",
|
||||
"generatedByGemini": "by AI",
|
||||
"vipLabel": "VIP",
|
||||
"adminLabel": "Admin",
|
||||
"ipUnavailable": "IP unavailable",
|
||||
|
|
@ -3928,7 +3991,7 @@
|
|||
"movies": "Movies",
|
||||
"tvShows": "TV Shows",
|
||||
"autoModeration": "Auto moderation",
|
||||
"geminiModeration": "Gemini moderation",
|
||||
"geminiModeration": "AI moderation",
|
||||
"searchByTextUsernameId": "Search by text, username, ID...",
|
||||
"contentType": "Content type",
|
||||
"allTypes": "All types",
|
||||
|
|
@ -4162,6 +4225,10 @@
|
|||
"fetchingLinks": "Fetching links...",
|
||||
"fetchDownloadLinks": "Fetch download links",
|
||||
"betaWarning": "⚠️ Beta Version - Important Warning",
|
||||
"adTitle": "Discover Loadix.fun",
|
||||
"adDescription": "Our own platform dedicated to DDL where uploaders directly share their download links.",
|
||||
"adButton": "Visit Loadix.fun",
|
||||
"adTag": "Our Platform",
|
||||
"loadingMore": "Loading...",
|
||||
"scrollToLoadMore": "Scroll to load more...",
|
||||
"episodesCount": "episodes",
|
||||
|
|
@ -4203,11 +4270,9 @@
|
|||
"queueWaiting": "Queue: {{size}}/50",
|
||||
"queueTimeout": "Decode took too long, try again in a few minutes",
|
||||
"rateLimited": "Service rate-limited, retry after {{retryAt}}",
|
||||
"rateLimitedLearnMore": "Learn more",
|
||||
"rateLimitInfoTitle": "Why this delay?",
|
||||
"rateLimitInfoBody": "Hydracker — the upstream source where Movix fetches your download links — caps decoding requests at 200 per day. To save that quota, Movix batches requests in groups of 50 links: that's what creates the queue you sometimes see. When the quota is reached, the service tells you when requests will resume. It's temporary and recovers automatically.",
|
||||
"queueUnavailable": "Infrastructure unavailable, try again later",
|
||||
"decodeFailed": "Link not found or inaccessible"
|
||||
"decodeFailed": "Link not found or inaccessible",
|
||||
"sourceBannerBody": "All links come from hydracker/darkiworld and the data stops at April 29, 2026. Enjoy their full catalog through Movix's infrastructure. Links after that date are fetched live, which can take longer."
|
||||
},
|
||||
"debrid": {
|
||||
"title": "Debrider",
|
||||
|
|
@ -5248,7 +5313,7 @@
|
|||
"TMDB for metadata, posters, logos and other catalog assets.",
|
||||
"Cloudflare Turnstile for anti-bot verification.",
|
||||
"Vercel Analytics for traffic and usage measurements on the site.",
|
||||
"OpenRouter and Gemini for some automated moderation of text content.",
|
||||
"OpenRouter for some automated moderation of text content.",
|
||||
"Coinbase, CoinGecko and BlockCypher for pricing and VIP payment verification.",
|
||||
"External hosts, CDNs, proxies and media sources that receive the normal technical metadata of a request when playback or extraction is started."
|
||||
],
|
||||
|
|
@ -5703,6 +5768,20 @@
|
|||
"creating": "Creating…",
|
||||
"saving": "Saving…"
|
||||
},
|
||||
"requireUsernameChange": {
|
||||
"title": "Choose a new nickname",
|
||||
"description": "Your Discord/Google nickname doesn't meet our criteria ({{max}} characters max, no invisible special characters). Pick a new one to continue.",
|
||||
"currentLabel": "Current nickname",
|
||||
"currentLength": "{{count}} characters",
|
||||
"inputLabel": "New nickname",
|
||||
"placeholder": "Your Movix nickname",
|
||||
"hint": "Between 1 and {{max}} characters",
|
||||
"submit": "Save nickname",
|
||||
"submitting": "Saving…",
|
||||
"success": "Nickname updated",
|
||||
"errorEmpty": "Nickname cannot be empty",
|
||||
"errorTooLong": "Maximum {{max}} characters"
|
||||
},
|
||||
"oauthAuthorize": {
|
||||
"eyebrow": "Movix external login",
|
||||
"title": "Authorize an external app",
|
||||
|
|
@ -5951,7 +6030,7 @@
|
|||
"blocked": "🚫 Access Blocked 🚫",
|
||||
"unauthorized": "Embed Not Authorized",
|
||||
"message": "This site cannot be displayed in an embed. Please visit our official site directly.",
|
||||
"goToSite": "Go to movix.tax"
|
||||
"goToSite": "Go to movix.chat"
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "🔧 Our services are temporarily unavailable 🔧",
|
||||
|
|
@ -5997,7 +6076,7 @@
|
|||
"otherCfSetup": "Official setup guide (all OS)",
|
||||
"otherChangeTonDns": "changetondns.fr — step-by-step tutorial (FR)",
|
||||
"otherMirrors": "Active mirrors — list on rentry.co",
|
||||
"otherMirrorsMovixHealth": "Active mirrors — also listed on movix.health",
|
||||
"otherMirrorsMovixHealth": "Active mirrors — also listed on movix.online",
|
||||
"otherTelegram": "Telegram support @movix_site",
|
||||
"backCta": "Back"
|
||||
},
|
||||
|
|
@ -6160,7 +6239,7 @@
|
|||
"whyTitle": "Why a 12-word seed?",
|
||||
"whyBody": "Movix has no email or password: your identity is a <1>12-word phrase</1> (<2>BIP39</2> standard, the same as Bitcoin). No data collection, no password reset, no phishing emails. You own your account.",
|
||||
"stepsTitle": "How to create",
|
||||
"step1": "Go to movix.tax → Create account.",
|
||||
"step1": "Go to movix.chat → Create account.",
|
||||
"step2": "Movix generates 12 random words — shown ONLY ONCE on screen.",
|
||||
"step3": "Save those 12 words in a password manager, an encrypted file, or on paper. This is your one-and-only key.",
|
||||
"step4": "Confirm by retyping the words. Your account is created, you're logged in.",
|
||||
|
|
@ -6233,9 +6312,9 @@
|
|||
"heroSub": "Add Movix to your home screen for quick access, no app store.",
|
||||
"introBody": "Movix is a <1>PWA</1> (Progressive Web App): you can install it like a real app from your browser. No Play Store or App Store needed. Works on iOS, Android, Windows, macOS, Linux.",
|
||||
"iosTitle": "iOS (Safari)",
|
||||
"iosBody": "Open movix.tax in Safari. Tap the share icon (square with an up arrow) → 'Add to Home Screen' → Add. The Movix icon appears on your home screen, fullscreen when you open it.",
|
||||
"iosBody": "Open movix.chat in Safari. Tap the share icon (square with an up arrow) → 'Add to Home Screen' → Add. The Movix icon appears on your home screen, fullscreen when you open it.",
|
||||
"androidTitle": "Android (Chrome)",
|
||||
"androidBody": "Open movix.tax in Chrome. An 'Install app' popup appears at the bottom. Otherwise, menu (⋮) → 'Install app' or 'Add to home screen'.",
|
||||
"androidBody": "Open movix.chat in Chrome. An 'Install app' popup appears at the bottom. Otherwise, menu (⋮) → 'Install app' or 'Add to home screen'.",
|
||||
"desktopTitle": "Desktop (Chrome / Edge / Brave)",
|
||||
"desktopBody": "In the URL bar, look for the install icon (monitor with a down arrow, right of the URL). Click it to install Movix as a standalone app.",
|
||||
"advantagesTitle": "Upsides",
|
||||
|
|
@ -6263,7 +6342,7 @@
|
|||
"whyTitle": "Why open-source?",
|
||||
"whyBody": "Transparency first: you can verify what your browser runs and what the server stores. Your BIP39 account (12-word seed) is handled client-side — the server never sees your seed, and you can go read the code to prove it. Resilience next: if the main domain goes down or if Movix disappears, anyone can clone, build, and host. The project doesn't depend on any proprietary service to run. Trust last: many streaming platforms are closed and opaque — here everything is auditable, and every commit is public.",
|
||||
"selfhostTitle": "Self-host your own instance",
|
||||
"selfhostBody": "You can clone the <1>repo</1> and run Movix locally or on your own server. The README and CLAUDE.md at the root document the stack, env vars (~100 for the main API), commands (npm run dev for the frontend on :3000, node server.js for the main API on :25565, python server.py for the embed proxies on :25569, etc.) and the service architecture. You'll need MySQL, Redis, and TMDB / Turnstile / Discord&Google OAuth keys depending on what you enable. The instance you spin up is fully independent of movix.tax — your users, your database, your control.",
|
||||
"selfhostBody": "You can clone the <1>repo</1> and run Movix locally or on your own server. The README and CLAUDE.md at the root document the stack, env vars (~100 for the main API), commands (npm run dev for the frontend on :3000, node server.js for the main API on :25565, python server.py for the embed proxies on :25569, etc.) and the service architecture. You'll need MySQL, Redis, and TMDB / Turnstile / Discord&Google OAuth keys depending on what you enable. The instance you spin up is fully independent of movix.chat — your users, your database, your control.",
|
||||
"contribTitle": "Contribute",
|
||||
"contribBody": "PRs are welcome on the <1>GitHub repo</1>. Bugs, translations (especially English — French is the primary language, many EN strings still need polish), new sources, UX improvements, new tutorials to add to the hub: all helpful. For small fixes, PR directly. For big architecture changes, open an issue first so we can talk it through before you spend three days on it.",
|
||||
"limitsTitle": "What the license does NOT allow",
|
||||
|
|
@ -6292,12 +6371,12 @@
|
|||
"cause1Body": "French ISPs (Orange, SFR, Free, Bouygues) regularly block our domains and streaming sources by court order (<1>ARCOM</1>). Most of the time, this is the culprit.",
|
||||
"cause1Cta": "How to bypass",
|
||||
"cause2Title": "Overzealous adblocker",
|
||||
"cause2Body": "Some adblockers kill extraction scripts or block source domains. Try whitelisting movix.tax and disable temporarily to test. We recommend Brave + uBlock Origin, the most stable combo on Movix.",
|
||||
"cause2Body": "Some adblockers kill extraction scripts or block source domains. Try whitelisting movix.chat and disable temporarily to test. We recommend Brave + uBlock Origin, the most stable combo on Movix.",
|
||||
"cause2CtaBrave": "Download Brave",
|
||||
"cause2CtaUblock": "Install uBlock Origin",
|
||||
"cause3Title": "Movix domain unreachable",
|
||||
"cause3Body": "The main domain can be down briefly. The live mirror list is published both on our own domain movix.health and on rentry.co/movix.",
|
||||
"cause3CtaMovixHealth": "View on movix.health",
|
||||
"cause3Body": "The main domain can be down briefly. The live mirror list is published both on our own domain movix.online and on rentry.co/movix.",
|
||||
"cause3CtaMovixHealth": "View on movix.online",
|
||||
"cause3CtaRentry": "View on rentry.co",
|
||||
"cause4Title": "Bug or broken source",
|
||||
"cause4Body": "If nothing works despite the above, it's likely a Movix bug or a source down on the provider side. Report it on Telegram.",
|
||||
|
|
@ -6427,15 +6506,15 @@
|
|||
"heroSub": "How Movix redirects you to an alive domain when the main one is blocked, and where to find the official list.",
|
||||
"introBody": "French ISPs (Orange, SFR, Free, Bouygues) regularly block Movix domains on <1>ARCOM</1> orders. To keep you connected through blocks, Movix installs a service worker in your browser that detects network failure and automatically redirects you to a still-alive mirror — no manual URL lookup needed.",
|
||||
"howTitle": "How the redirect works",
|
||||
"howBody": "When you visit movix.tax, a service worker (a silent piece of code) installs in your browser. If movix.tax later becomes unreachable (3-second timeout or DNS error), the service worker fetches the alive-mirror list from an external non-blocked source (rentry.co/movix, a pastebin service hosted outside France) and redirects you to the first available mirror. Transparent, instant, zero config.",
|
||||
"howBody": "When you visit movix.chat, a service worker (a silent piece of code) installs in your browser. If movix.chat later becomes unreachable (3-second timeout or DNS error), the service worker fetches the alive-mirror list from an external non-blocked source (rentry.co/movix, a pastebin service hosted outside France) and redirects you to the first available mirror. Transparent, instant, zero config.",
|
||||
"officialListTitle": "Where to see the official list",
|
||||
"officialListBody": "Two places publish the canonical alive-mirror list: <1>movix.health</1> (domain status with real-time up/down indicator) and <2>rentry.co/movix</2> (raw list, edited by the Movix team, always current). If you doubt a URL claiming to be Movix, check it against one of these — anything NOT on the list is a fake (sacrificial domains designed to fool anti-piracy bots, or phishing attempts).",
|
||||
"officialListBody": "Two places publish the canonical alive-mirror list: <1>movix.online</1> (domain status with real-time up/down indicator) and <2>rentry.co/movix</2> (raw list, edited by the Movix team, always current). If you doubt a URL claiming to be Movix, check it against one of these — anything NOT on the list is a fake (sacrificial domains designed to fool anti-piracy bots, or phishing attempts).",
|
||||
"newDeviceTitle": "New phone / new browser — the redirect doesn't kick in",
|
||||
"newDeviceBody": "The service worker can only redirect you if it's been installed at least once before the block. On a brand-new device that has never visited movix.tax, if the domain is already ISP-blocked, you'll hit an ISP error page with no redirect. Solution: open movix.health or rentry.co/movix from any browser (those pages are hosted outside the ISP block), grab an alive mirror, and visit it. Or join <1>Telegram @movix_site</1> where the list is always up to date.",
|
||||
"newDeviceBody": "The service worker can only redirect you if it's been installed at least once before the block. On a brand-new device that has never visited movix.chat, if the domain is already ISP-blocked, you'll hit an ISP error page with no redirect. Solution: open movix.online or rentry.co/movix from any browser (those pages are hosted outside the ISP block), grab an alive mirror, and visit it. Or join <1>Telegram @movix_site</1> where the list is always up to date.",
|
||||
"reliabilityTitle": "How often things change",
|
||||
"reliabilityBody": "A domain can go down overnight (new ARCOM order) or stay stable for months. The team rotates multiple domains in parallel — when one goes down, another takes over automatically. The rentry.co list is updated within 24h of any change.",
|
||||
"limitsTitle": "Limits",
|
||||
"limitsBody": "The service worker caches its internal config for 24h — so if you add a brand-new mirror on rentry, existing clients see it within 24h max. If your ISP ALSO blocks rentry.co or movix.health (rare but possible), the fallback breaks entirely: route through Telegram or use an alternate DNS like 1.1.1.1 (see the DNS tutorial). The service worker doesn't always work in private browsing — use a regular tab."
|
||||
"limitsBody": "The service worker caches its internal config for 24h — so if you add a brand-new mirror on rentry, existing clients see it within 24h max. If your ISP ALSO blocks rentry.co or movix.online (rare but possible), the fallback breaks entirely: route through Telegram or use an alternate DNS like 1.1.1.1 (see the DNS tutorial). The service worker doesn't always work in private browsing — use a regular tab."
|
||||
},
|
||||
"liveTv": {
|
||||
"title": "Live TV — channels and live sports",
|
||||
|
|
|
|||
|
|
@ -1043,8 +1043,8 @@
|
|||
"castUnavailable": "Diffusion non disponible",
|
||||
"castUnavailableNoDevices": "Aucun récepteur détecté sur ton réseau Wi-Fi.",
|
||||
"castUnavailableUnsupportedBrowser": "Ton navigateur ne gère pas la diffusion sur cette page.",
|
||||
"castUnavailableSdkBlocked": "Le SDK Google Cast n'a pas pu charger (probablement bloqué par un bloqueur de pubs ou ton FAI). Désactive ton bloqueur sur movix.tax et recharge la page.",
|
||||
"castUnavailableHelpChromecast": "Chromecast : utilise Chrome ou Edge sur le même Wi-Fi que la TV. Si tu as un bloqueur de pubs, désactive-le sur movix.tax (il bloque souvent le SDK Cast de Google).",
|
||||
"castUnavailableSdkBlocked": "Le SDK Google Cast n'a pas pu charger (probablement bloqué par un bloqueur de pubs ou ton FAI). Désactive ton bloqueur sur movix.chat et recharge la page.",
|
||||
"castUnavailableHelpChromecast": "Chromecast : utilise Chrome ou Edge sur le même Wi-Fi que la TV. Si tu as un bloqueur de pubs, désactive-le sur movix.chat (il bloque souvent le SDK Cast de Google).",
|
||||
"castUnavailableHelpAirPlay": "AirPlay : ouvre la page dans Safari sur iPhone, iPad ou Mac.",
|
||||
"castUnavailableSeeHelp": "Voir le guide Chromecast",
|
||||
"cast": "Caster",
|
||||
|
|
@ -1529,7 +1529,8 @@
|
|||
"privacy": "Confidentialité",
|
||||
"data": "Données",
|
||||
"extractions": "Extractions",
|
||||
"sourcePriority": "Priorité"
|
||||
"sourcePriority": "Priorité",
|
||||
"adPopup": "Popup pub"
|
||||
},
|
||||
"appearance": "Apparence",
|
||||
"appearanceDesc": "Personnalisez l'interface et les animations",
|
||||
|
|
@ -1774,7 +1775,8 @@
|
|||
"wiflix": "Lynx",
|
||||
"sosplay": "SOSPLAY",
|
||||
"livetv": "LiveTV",
|
||||
"matches": "Matchs sportifs"
|
||||
"matches": "Matchs sportifs",
|
||||
"daddylive": "Daddylive"
|
||||
},
|
||||
"cache": {
|
||||
"title": "Cache d'extraction",
|
||||
|
|
@ -1794,6 +1796,28 @@
|
|||
"reset": "Réinitialiser aux valeurs par défaut",
|
||||
"resetConfirm": "Réinitialiser toutes les préférences d'extraction ?"
|
||||
},
|
||||
"adPopup": {
|
||||
"title": "Popup publicité",
|
||||
"description": "Choisis comment la fenêtre publicitaire avant chaque lecteur se comporte.",
|
||||
"modeTitle": "Comportement de la popup",
|
||||
"modeDesc": "Le mode « Auto » ouvre la pub immédiatement sans afficher la popup. Le mode « Clic n'importe où » masque l'interface et ouvre la pub dès que tu cliques sur l'écran.",
|
||||
"modes": {
|
||||
"normal": {
|
||||
"label": "Normal",
|
||||
"desc": "Affiche la popup et son bouton « Voir la publicité » (par défaut)."
|
||||
},
|
||||
"auto": {
|
||||
"label": "Ouverture auto",
|
||||
"desc": "Ouvre directement la pub à l'apparition de la popup, sans interface."
|
||||
},
|
||||
"clickAnywhere": {
|
||||
"label": "Clic n'importe où",
|
||||
"desc": "Masque l'interface ; un clic n'importe où sur l'écran ouvre la pub."
|
||||
}
|
||||
},
|
||||
"adultTitle": "Publicités +18",
|
||||
"adultDesc": "Activé par défaut : les publicités à caractère adulte (+18) sont utilisées. Désactive pour passer aux publicités standards."
|
||||
},
|
||||
"sourcePriority": {
|
||||
"title": "Priorité des sources",
|
||||
"description": "Ordonner, désactiver ou épingler les sources (Films, Séries, Animes) et les hosters utilisés pour l'auto-select.",
|
||||
|
|
@ -2408,6 +2432,7 @@
|
|||
"castToChromecast": "Diffuser sur Chromecast",
|
||||
"castViaAirplay": "Diffuser via AirPlay",
|
||||
"watchFavoriteChannels": "Regardez vos chaînes préférées en direct",
|
||||
"streamDisclaimer": "On n'héberge pas et on ne contrôle pas ces flux — pas la peine de nous hurler dessus si ça lag 😅",
|
||||
"searchChannel": "Rechercher une chaîne...",
|
||||
"extensionPromo": "Pour plus de sources de chaînes, installez l'extension Movix !",
|
||||
"vipUnlockSources": "Débloquez plus de sources avec le VIP !",
|
||||
|
|
@ -2420,6 +2445,11 @@
|
|||
"inDays": "Dans {{days}}j {{hours}}h",
|
||||
"inHours": "Dans {{hours}}h {{minutes}}min",
|
||||
"inMinutes": "Dans {{minutes}} min",
|
||||
"availableServers": "Serveurs disponibles",
|
||||
"watchMatch": "Regarder le match",
|
||||
"serversAvailable": "{{count}} serveur·s disponible·s",
|
||||
"noServersFound": "Aucun serveur disponible pour le moment.",
|
||||
"upcomingServersNote": "Les serveurs de diffusion seront disponibles quelques minutes avant le début de la rencontre.",
|
||||
"freeSource": "🆓 Linkzy (Gratuit)",
|
||||
"matchesCatalogSource": "👑 Catalogue matchs",
|
||||
"retry": "Réessayer",
|
||||
|
|
@ -2896,6 +2926,7 @@
|
|||
"getAccessClicks": "Obtenez votre accès en quelques clics :",
|
||||
"joinTelegramForVip": "Pour obtenir un accès VIP (5€/an), rejoignez notre Telegram.",
|
||||
"contactTelegramForVip": "Pour obtenir un accès VIP (5€ à vie), contactez mysticsaba sur Telegram.",
|
||||
"donateForKey": "Fais un don pour obtenir ta clé Movix VIP, ou contacte-nous sur Telegram.",
|
||||
"quality4k": "Qualité jusqu'à 4K HDR",
|
||||
"multiLangSubtitles": "Sous-titres multi-langues",
|
||||
"multipleLanguages": "Plusieurs langues disponibles",
|
||||
|
|
@ -3357,8 +3388,8 @@
|
|||
"seriesSingular": "Série",
|
||||
"inDetail": "En détail",
|
||||
"yearSummary": "Voici le résumé complet de ton année sur Movix.",
|
||||
"preparingWrapped": "Préparation de ton Wrapped...",
|
||||
"analyzingYear": "Un instant, on analyse ton année",
|
||||
"preparingWrapped": "Ton Wrapped arrive",
|
||||
"analyzingYear": "On fouille ton année…",
|
||||
"spentOnMovix": "Tu as passé sur Movix...",
|
||||
"hopeYouHadSnacks": "On espère que t'avais des snacks. 🍿",
|
||||
"dataCollectionDisabled": "Collecte de données désactivée",
|
||||
|
|
@ -3396,7 +3427,7 @@
|
|||
"topContentBadge": "TOP CONTENU",
|
||||
"topContentFallbackAlt": "Top 1",
|
||||
"shareImageTopMeta": "{{watchTime}} | {{type}}",
|
||||
"shareSnapshotTitle": "Ton ann\u00E9e en un coup d'oeil",
|
||||
"shareSnapshotTitle": "Ton ann\u00E9e en un coup d'\u0153il",
|
||||
"sharePersonaLabel": "Persona",
|
||||
"shareGenreLabel": "Genre #1",
|
||||
"shareGenreFallback": "Aucun genre",
|
||||
|
|
@ -3405,20 +3436,20 @@
|
|||
"shareFooterTag": "Partage ton ann\u00E9e Movix",
|
||||
"top3FocusSubtitle": "Ton top 3",
|
||||
"top2FocusSubtitle": "Ton top 2",
|
||||
"podiumFocusText": "{{watchTime}} pass\u00E9es dessus. Clairement, ce {{type}} a compt\u00E9 dans ton ann\u00E9e.",
|
||||
"podiumFocusText": "{{watchTime}} dessus. Clairement, ce {{type}} a marqu\u00E9 ton ann\u00E9e.",
|
||||
"top3FocusSubtext": "Le bronze, mais avec de vraies vibes de favori.",
|
||||
"top2FocusSubtext": "A deux pas de la premi\u00E8re place.",
|
||||
"top2FocusSubtext": "\u00C0 une marche du sommet.",
|
||||
"ratingLabel": "Note {{rating}}",
|
||||
"sessionSummaryTitle": "Ton rythme de visionnage",
|
||||
"sessionSummarySubtitle": "Comment tu consommes vraiment",
|
||||
"sessionSummaryText": "En moyenne, une session durait {{average}}, r\u00E9partie sur {{activeDays}} jours actifs.",
|
||||
"sessionSummaryText": "{{average}} par session en moyenne, sur {{activeDays}} jours actifs. La r\u00E9gularit\u00E9, c'est toi.",
|
||||
"avgSessionLabel": "Session moyenne",
|
||||
"activeDaysLabel": "Jours actifs",
|
||||
"percentileLabel": "Classement",
|
||||
"percentileValue": "Top {{percent}}%",
|
||||
"watchBookendsTitle": "Du premier au dernier visionnage",
|
||||
"watchBookendsSubtitle": "Les bornes de ton ann\u00E9e",
|
||||
"watchBookendsText": "Tu as commenc\u00E9 avec \"{{first}}\" et termin\u00E9 avec \"{{last}}\". Belle mani\u00E8re d'encadrer l'ann\u00E9e.",
|
||||
"watchBookendsText": "T'as ouvert l'ann\u00E9e avec \u00AB {{first}} \u00BB et referm\u00E9 avec \u00AB {{last}} \u00BB. Joliment encadr\u00E9.",
|
||||
"firstWatchLabel": "Premier visionnage",
|
||||
"lastWatchLabel": "Dernier visionnage",
|
||||
"unknownDate": "Date inconnue",
|
||||
|
|
@ -3477,7 +3508,38 @@
|
|||
"missingSessions_one": "{{count}} session",
|
||||
"missingSessions_other": "{{count}} sessions",
|
||||
"missingActiveDays_one": "{{count}} jour actif",
|
||||
"missingActiveDays_other": "{{count}} jours actifs"
|
||||
"missingActiveDays_other": "{{count}} jours actifs",
|
||||
"shareTop3Label": "Ton top 3",
|
||||
"quizTitle": "Devine ton top 1",
|
||||
"quizSubtitle": "Un de ces trois a dévoré ton année. Lequel ?",
|
||||
"quizCorrect": "Bien vu.",
|
||||
"quizWrong": "Raté !",
|
||||
"quizRevealSubtitle": "Voilà ton podium, en vrai.",
|
||||
"quizSkip": "Passer le quiz",
|
||||
"quizContinue": "Voir le podium",
|
||||
"creditsDirectedBy": "Réalisé par",
|
||||
"creditsYou": "TOI",
|
||||
"creditsStarring": "Avec",
|
||||
"creditsGenre": "Genre de l'année",
|
||||
"creditsRuntime": "Durée totale",
|
||||
"creditsProducedBy": "Produit par",
|
||||
"shareStreakLabel": "Série de jours",
|
||||
"shareStreakValue": "{{days}} j d'affilée",
|
||||
"shareTopPercentLabel": "Classement",
|
||||
"shareFormatStory": "Story",
|
||||
"shareFormatPoster": "Affiche",
|
||||
"shareFormatTicket": "Billet",
|
||||
"shareFormatsTitle": "Choisis ton format",
|
||||
"pageNames": {
|
||||
"home": "Accueil",
|
||||
"movies": "Films",
|
||||
"tv-shows": "Séries",
|
||||
"movie-details": "Fiches films",
|
||||
"tv-details": "Fiches séries",
|
||||
"wishboard": "Wishboard",
|
||||
"watchparty": "WatchParty",
|
||||
"anime": "Animés"
|
||||
}
|
||||
},
|
||||
"roulette": {
|
||||
"title": "Roulette Ciné",
|
||||
|
|
@ -3738,6 +3800,7 @@
|
|||
"adInfoWindow": "Fenêtre d'information publicité",
|
||||
"doNotDo": "Ce qu'il NE FAUT SURTOUT PAS FAIRE",
|
||||
"doNotClickAnywhere": "NE CLIQUE PAS n'importe où sur la page de pub",
|
||||
"clickAnywhereLabel": "Clique n'importe où pour ouvrir la publicité",
|
||||
"doNotScanQr": "NE SCANNE AUCUN QR code",
|
||||
"doNotDownloadAnything": "NE TÉLÉCHARGE RIEN",
|
||||
"thanksUnlockedDownload": "Merci, téléchargement débloqué ! 🙏",
|
||||
|
|
@ -3876,7 +3939,7 @@
|
|||
"banError": "Erreur lors du bannissement",
|
||||
"unbanError": "Erreur lors du débannissement",
|
||||
"genericError": "Erreur",
|
||||
"generatedByGemini": "par Gemini AI",
|
||||
"generatedByGemini": "par l'IA",
|
||||
"vipLabel": "VIP",
|
||||
"adminLabel": "Admin",
|
||||
"ipUnavailable": "IP non disponible",
|
||||
|
|
@ -3928,7 +3991,7 @@
|
|||
"movies": "Films",
|
||||
"tvShows": "Séries",
|
||||
"autoModeration": "Modération auto",
|
||||
"geminiModeration": "Modération Gemini",
|
||||
"geminiModeration": "Modération IA",
|
||||
"searchByTextUsernameId": "Rechercher par texte, nom d'utilisateur, ID...",
|
||||
"contentType": "Type de contenu",
|
||||
"allTypes": "Tous les types",
|
||||
|
|
@ -4162,6 +4225,10 @@
|
|||
"fetchingLinks": "Récupération des liens...",
|
||||
"fetchDownloadLinks": "Récupérer les liens de téléchargement",
|
||||
"betaWarning": "⚠️ Version Bêta - Avertissement important",
|
||||
"adTitle": "Découvrez Loadix.fun",
|
||||
"adDescription": "Notre propre plateforme dédiée au DDL où les uploaders partagent directement leurs liens de téléchargement.",
|
||||
"adButton": "Visiter Loadix.fun",
|
||||
"adTag": "Notre Plateforme",
|
||||
"loadingMore": "Chargement...",
|
||||
"scrollToLoadMore": "Faites défiler pour charger plus...",
|
||||
"episodesCount": "épisodes",
|
||||
|
|
@ -4203,11 +4270,9 @@
|
|||
"queueWaiting": "File d'attente : {{size}}/50",
|
||||
"queueTimeout": "Décodage trop long, réessaie dans quelques minutes",
|
||||
"rateLimited": "Service surchargé, retry après {{retryAt}}",
|
||||
"rateLimitedLearnMore": "En savoir plus",
|
||||
"rateLimitInfoTitle": "Pourquoi ce délai ?",
|
||||
"rateLimitInfoBody": "Hydracker — la source de téléchargement où Movix récupère tes liens — limite à 200 requêtes de décodage par jour. Pour économiser ce quota, Movix regroupe les demandes par lots de 50 liens : c'est ce qui crée la file d'attente que tu vois parfois. Quand le quota est atteint, le service t'indique l'heure à laquelle les requêtes recommenceront. C'est temporaire et ça repart automatiquement.",
|
||||
"queueUnavailable": "Infrastructure indisponible, réessaie plus tard",
|
||||
"decodeFailed": "Lien non trouvé ou inaccessible"
|
||||
"decodeFailed": "Lien non trouvé ou inaccessible",
|
||||
"sourceBannerBody": "Tous les liens proviennent de hydracker/darkiworld et les données s'arrêtent au 29 avril 2026. Profite de tous leurs liens grâce à l'infrastructure de Movix. Les liens après cette date sont récupérés en direct, ce qui peut prendre plus de temps."
|
||||
},
|
||||
"debrid": {
|
||||
"title": "Débrideur",
|
||||
|
|
@ -5248,7 +5313,7 @@
|
|||
"TMDB pour les métadonnées, affiches, logos et autres éléments de catalogue.",
|
||||
"Cloudflare Turnstile pour la vérification anti-bot.",
|
||||
"Vercel Analytics pour des mesures de fréquentation et d'usage du site.",
|
||||
"OpenRouter et Gemini pour certaines modérations automatisées de contenus textuels.",
|
||||
"OpenRouter pour certaines modérations automatisées de contenus textuels.",
|
||||
"Coinbase, CoinGecko et BlockCypher pour le pricing et la vérification des paiements VIP.",
|
||||
"Des hosters, CDNs, proxies et sources média externes qui reçoivent les métadonnées techniques normales d'une requête lorsqu'une lecture ou une extraction est lancée."
|
||||
],
|
||||
|
|
@ -5703,6 +5768,20 @@
|
|||
"creating": "Création…",
|
||||
"saving": "Enregistrement…"
|
||||
},
|
||||
"requireUsernameChange": {
|
||||
"title": "Choisis un nouveau pseudo",
|
||||
"description": "Ton pseudo Discord/Google ne respecte pas nos critères ({{max}} caractères max, pas de caractères spéciaux invisibles). Choisis-en un nouveau pour continuer.",
|
||||
"currentLabel": "Pseudo actuel",
|
||||
"currentLength": "{{count}} caractères",
|
||||
"inputLabel": "Nouveau pseudo",
|
||||
"placeholder": "Ton pseudo Movix",
|
||||
"hint": "Entre 1 et {{max}} caractères",
|
||||
"submit": "Enregistrer le pseudo",
|
||||
"submitting": "Enregistrement…",
|
||||
"success": "Pseudo mis à jour",
|
||||
"errorEmpty": "Le pseudo ne peut pas être vide",
|
||||
"errorTooLong": "Maximum {{max}} caractères"
|
||||
},
|
||||
"oauthAuthorize": {
|
||||
"eyebrow": "Connexion externe Movix",
|
||||
"title": "Autoriser une application externe",
|
||||
|
|
@ -5951,7 +6030,7 @@
|
|||
"blocked": "🚫 Accès bloqué 🚫",
|
||||
"unauthorized": "Embed non autorisé",
|
||||
"message": "Ce site ne peut pas être affiché dans un embed. Veuillez visiter directement notre site officiel.",
|
||||
"goToSite": "Aller sur movix.tax"
|
||||
"goToSite": "Aller sur movix.chat"
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "🔧 Nos services sont momentanément indisponibles 🔧",
|
||||
|
|
@ -5997,7 +6076,7 @@
|
|||
"otherCfSetup": "Guide setup officiel (tous OS)",
|
||||
"otherChangeTonDns": "changetondns.fr — tuto français pas à pas",
|
||||
"otherMirrors": "Miroirs actifs — liste sur rentry.co",
|
||||
"otherMirrorsMovixHealth": "Miroirs actifs — aussi listés sur movix.health",
|
||||
"otherMirrorsMovixHealth": "Miroirs actifs — aussi listés sur movix.online",
|
||||
"otherTelegram": "Support Telegram @movix_site",
|
||||
"backCta": "Retour"
|
||||
},
|
||||
|
|
@ -6160,7 +6239,7 @@
|
|||
"whyTitle": "Pourquoi une seed de 12 mots ?",
|
||||
"whyBody": "Movix n'a ni mail ni mot de passe : ton identité est une <1>phrase de 12 mots</1> (standard <2>BIP39</2>, celui de Bitcoin). Pas de collecte de données, pas de reset de mot de passe, pas d'email de phishing. Tu es maître de ton compte.",
|
||||
"stepsTitle": "Comment créer",
|
||||
"step1": "Va sur movix.tax → Créer un compte.",
|
||||
"step1": "Va sur movix.chat → Créer un compte.",
|
||||
"step2": "Movix génère 12 mots aléatoires — affichés UNE SEULE FOIS à l'écran.",
|
||||
"step3": "Sauvegarde ces 12 mots dans un gestionnaire de mots de passe, un fichier chiffré, ou sur papier. C'est ta clé unique.",
|
||||
"step4": "Confirme en retapant les mots. Ton compte est créé, tu es connecté.",
|
||||
|
|
@ -6233,9 +6312,9 @@
|
|||
"heroSub": "Ajouter Movix à ton écran d'accueil pour un accès rapide, sans app store.",
|
||||
"introBody": "Movix est une <1>PWA</1> (Progressive Web App) : tu peux l'installer comme une vraie app depuis ton navigateur. Pas besoin du Play Store ou App Store. Marche sur iOS, Android, Windows, macOS, Linux.",
|
||||
"iosTitle": "iOS (Safari)",
|
||||
"iosBody": "Ouvre movix.tax dans Safari. Touche l'icône de partage (carré avec flèche vers le haut) → « Sur l'écran d'accueil » → Ajouter. L'icône Movix apparaît sur ton écran d'accueil, plein écran quand tu l'ouvres.",
|
||||
"iosBody": "Ouvre movix.chat dans Safari. Touche l'icône de partage (carré avec flèche vers le haut) → « Sur l'écran d'accueil » → Ajouter. L'icône Movix apparaît sur ton écran d'accueil, plein écran quand tu l'ouvres.",
|
||||
"androidTitle": "Android (Chrome)",
|
||||
"androidBody": "Ouvre movix.tax dans Chrome. Un popup « Installer l'app » apparaît en bas. Sinon, menu (⋮) → « Installer l'application » ou « Ajouter à l'écran d'accueil ».",
|
||||
"androidBody": "Ouvre movix.chat dans Chrome. Un popup « Installer l'app » apparaît en bas. Sinon, menu (⋮) → « Installer l'application » ou « Ajouter à l'écran d'accueil ».",
|
||||
"desktopTitle": "Desktop (Chrome / Edge / Brave)",
|
||||
"desktopBody": "Dans la barre d'URL, cherche l'icône d'installation (monitor avec flèche vers le bas, à droite de l'URL). Clique dessus pour installer Movix comme une vraie app standalone.",
|
||||
"advantagesTitle": "Avantages",
|
||||
|
|
@ -6263,7 +6342,7 @@
|
|||
"whyTitle": "Pourquoi open-source ?",
|
||||
"whyBody": "Transparence d'abord : tu peux vérifier ce que ton navigateur exécute et ce que le serveur stocke. Ton compte BIP39 (12 mots seed) est géré côté client — le serveur ne connaît pas ta seed, et tu peux aller lire le code pour le prouver. Résilience ensuite : si le domaine principal tombe ou si Movix disparaît, n'importe qui peut cloner, compiler et héberger. Le projet ne dépend d'aucun service propriétaire pour tourner. Confiance enfin : beaucoup de plateformes streaming sont fermées et opaques — ici tout est auditable, et chaque commit est public.",
|
||||
"selfhostTitle": "Self-hoster sa propre instance",
|
||||
"selfhostBody": "Tu peux cloner le <1>repo</1> et faire tourner Movix localement ou sur ton propre serveur. Le README et CLAUDE.md à la racine documentent la stack, les variables d'env (~100 pour le main API), les commandes (npm run dev pour le front sur :3000, node server.js pour le main API sur :25565, python server.py pour les proxies embed sur :25569, etc.) et l'architecture des services. Prévoir MySQL, Redis, et des clés TMDB / Turnstile / OAuth Discord&Google selon ce que tu actives. L'instance que tu montes est totalement indépendante de movix.tax — tes utilisateurs, ta base, ton contrôle.",
|
||||
"selfhostBody": "Tu peux cloner le <1>repo</1> et faire tourner Movix localement ou sur ton propre serveur. Le README et CLAUDE.md à la racine documentent la stack, les variables d'env (~100 pour le main API), les commandes (npm run dev pour le front sur :3000, node server.js pour le main API sur :25565, python server.py pour les proxies embed sur :25569, etc.) et l'architecture des services. Prévoir MySQL, Redis, et des clés TMDB / Turnstile / OAuth Discord&Google selon ce que tu actives. L'instance que tu montes est totalement indépendante de movix.chat — tes utilisateurs, ta base, ton contrôle.",
|
||||
"contribTitle": "Contribuer",
|
||||
"contribBody": "Les PRs sont les bienvenues sur le <1>repo GitHub</1>. Bugs, traductions (surtout anglais — le français est la langue principale, beaucoup de strings EN sont encore à polir), nouvelles sources, améliorations UX, tuto à ajouter dans le hub : tout est utile. Pour les petits fix, PR direct. Pour les gros changements d'architecture, ouvre une issue d'abord pour qu'on en parle avant que tu passes trois jours dessus.",
|
||||
"limitsTitle": "Ce que la licence NE permet PAS",
|
||||
|
|
@ -6292,12 +6371,12 @@
|
|||
"cause1Body": "Les FAI français (Orange, SFR, Free, Bouygues) bloquent régulièrement nos domaines et nos sources de streaming sur décision <1>ARCOM</1>. C'est le cas 8 fois sur 10.",
|
||||
"cause1Cta": "Comment contourner",
|
||||
"cause2Title": "Adblocker trop agressif",
|
||||
"cause2Body": "Certains adblockers coupent les scripts d'extraction ou bloquent des domaines de sources. Essaie de whitelist movix.tax et désactive temporairement pour tester. On recommande Brave + uBlock Origin, le combo le plus stable sur Movix.",
|
||||
"cause2Body": "Certains adblockers coupent les scripts d'extraction ou bloquent des domaines de sources. Essaie de whitelist movix.chat et désactive temporairement pour tester. On recommande Brave + uBlock Origin, le combo le plus stable sur Movix.",
|
||||
"cause2CtaBrave": "Télécharger Brave",
|
||||
"cause2CtaUblock": "Installer uBlock Origin",
|
||||
"cause3Title": "Domaine Movix injoignable",
|
||||
"cause3Body": "Le domaine principal peut être down ponctuellement. La liste des miroirs alive est publiée à la fois sur notre domaine movix.health et sur rentry.co/movix.",
|
||||
"cause3CtaMovixHealth": "Voir sur movix.health",
|
||||
"cause3Body": "Le domaine principal peut être down ponctuellement. La liste des miroirs alive est publiée à la fois sur notre domaine movix.online et sur rentry.co/movix.",
|
||||
"cause3CtaMovixHealth": "Voir sur movix.online",
|
||||
"cause3CtaRentry": "Voir sur rentry.co",
|
||||
"cause4Title": "Bug ou source cassée",
|
||||
"cause4Body": "Si rien ne marche malgré les étapes précédentes, c'est probablement un bug côté Movix ou une source down côté provider. Signale-nous ça sur Telegram.",
|
||||
|
|
@ -6427,15 +6506,15 @@
|
|||
"heroSub": "Comment Movix te redirige vers un domaine alive quand le principal est bloqué, et où trouver la liste officielle.",
|
||||
"introBody": "Les FAI français (Orange, SFR, Free, Bouygues) bloquent régulièrement les domaines Movix sur décision <1>ARCOM</1>. Pour éviter que tu perdes l'accès à chaque blocage, Movix installe un service worker dans ton navigateur qui détecte l'échec réseau et te redirige automatiquement vers un miroir encore alive — sans que tu aies besoin de connaître les URLs à la main.",
|
||||
"howTitle": "Comment la redirection marche",
|
||||
"howBody": "Quand tu visites movix.tax, un service worker (un petit bout de code silencieux) se met en place dans ton navigateur. Si un jour movix.tax devient injoignable (timeout 3 secondes ou erreur DNS), le service worker va chercher la liste des miroirs alive sur une source externe non-bloquée (rentry.co/movix, un service de pastebin hébergé hors de France), et te redirige vers le premier miroir disponible. Transparent, instantané, sans config.",
|
||||
"howBody": "Quand tu visites movix.chat, un service worker (un petit bout de code silencieux) se met en place dans ton navigateur. Si un jour movix.chat devient injoignable (timeout 3 secondes ou erreur DNS), le service worker va chercher la liste des miroirs alive sur une source externe non-bloquée (rentry.co/movix, un service de pastebin hébergé hors de France), et te redirige vers le premier miroir disponible. Transparent, instantané, sans config.",
|
||||
"officialListTitle": "Où voir la liste officielle",
|
||||
"officialListBody": "Deux endroits publient la liste canonique des miroirs alive : <1>movix.health</1> (statut des domaines avec indicateur up/down en temps réel) et <2>rentry.co/movix</2> (liste brute, éditée par l'équipe Movix, toujours à jour). Si tu doutes d'une URL qui prétend être Movix, vérifie-la sur un de ces deux endroits — tout ce qui n'y figure PAS est un faux (domaines sacrificiels pour tromper les bots anti-piratage ou tentatives de phishing).",
|
||||
"officialListBody": "Deux endroits publient la liste canonique des miroirs alive : <1>movix.online</1> (statut des domaines avec indicateur up/down en temps réel) et <2>rentry.co/movix</2> (liste brute, éditée par l'équipe Movix, toujours à jour). Si tu doutes d'une URL qui prétend être Movix, vérifie-la sur un de ces deux endroits — tout ce qui n'y figure PAS est un faux (domaines sacrificiels pour tromper les bots anti-piratage ou tentatives de phishing).",
|
||||
"newDeviceTitle": "Nouveau téléphone / nouveau navigateur — la redirection marche pas",
|
||||
"newDeviceBody": "Le service worker ne peut te rediriger QUE s'il a été installé au moins une fois avant le blocage. Sur un appareil neuf qui n'a jamais visité movix.tax, si le domaine est déjà bloqué par ton FAI, tu arriveras sur une page d'erreur du FAI sans aucune redirection. Solution : ouvre movix.health ou rentry.co/movix depuis n'importe quel navigateur (ces pages sont hébergées hors bloc FAI), récupère un miroir alive, et visite-le. Ou rejoins <1>Telegram @movix_site</1> où la liste est toujours à jour.",
|
||||
"newDeviceBody": "Le service worker ne peut te rediriger QUE s'il a été installé au moins une fois avant le blocage. Sur un appareil neuf qui n'a jamais visité movix.chat, si le domaine est déjà bloqué par ton FAI, tu arriveras sur une page d'erreur du FAI sans aucune redirection. Solution : ouvre movix.online ou rentry.co/movix depuis n'importe quel navigateur (ces pages sont hébergées hors bloc FAI), récupère un miroir alive, et visite-le. Ou rejoins <1>Telegram @movix_site</1> où la liste est toujours à jour.",
|
||||
"reliabilityTitle": "Fréquence des changements",
|
||||
"reliabilityBody": "Un domaine peut tomber du jour au lendemain (nouvelle décision ARCOM) ou rester stable des mois. L'équipe tourne plusieurs domaines en parallèle — quand un tombe, un autre prend le relais automatiquement. La liste sur rentry.co est mise à jour dans la journée qui suit un changement.",
|
||||
"limitsTitle": "Limites",
|
||||
"limitsBody": "Le service worker a un TTL de 24h sur sa config interne — donc si tu ajoutes un miroir tout neuf sur rentry, les clients existants le verront dans les 24h max. Si ton FAI bloque AUSSI rentry.co ou movix.health (rare mais possible), le fallback casse complètement : passe par Telegram ou utilise un DNS alternatif comme 1.1.1.1 (voir le tuto DNS). Le service worker ne marche pas en navigation privée sur certains navigateurs — utilise un onglet normal."
|
||||
"limitsBody": "Le service worker a un TTL de 24h sur sa config interne — donc si tu ajoutes un miroir tout neuf sur rentry, les clients existants le verront dans les 24h max. Si ton FAI bloque AUSSI rentry.co ou movix.online (rare mais possible), le fallback casse complètement : passe par Telegram ou utilise un DNS alternatif comme 1.1.1.1 (voir le tuto DNS). Le service worker ne marche pas en navigation privée sur certains navigateurs — utilise un onglet normal."
|
||||
},
|
||||
"liveTv": {
|
||||
"title": "Live TV — chaînes et sport en direct",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback, memo } from 'react';
|
||||
import ReactCountryFlag from 'react-country-flag';
|
||||
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';
|
||||
|
|
@ -49,6 +50,21 @@ interface Channel {
|
|||
_sportKey?: string;
|
||||
_score?: string;
|
||||
_emoji?: string;
|
||||
_serverCount?: number;
|
||||
_homeTeam?: string;
|
||||
_awayTeam?: string;
|
||||
_homeLogo?: string;
|
||||
_awayLogo?: string;
|
||||
_leagueLogo?: string;
|
||||
_country?: string;
|
||||
_countryLogo?: string;
|
||||
_pageUrl?: string;
|
||||
_servers?: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
siteType?: number;
|
||||
sportType?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface IptvCategory {
|
||||
|
|
@ -181,20 +197,22 @@ const livetvStatusOptions = [
|
|||
// Source display names for dropdown
|
||||
const sourceDisplayNames: { [key: string]: string } = {
|
||||
'linkzy': 'liveTV.freeSource',
|
||||
'matches': 'liveTV.matchesCatalogSource',
|
||||
'fctv': 'FCTV33',
|
||||
'wiflix': 'Landscape',
|
||||
'sosplay': 'Bolaloca',
|
||||
'livetv': 'LiveTV',
|
||||
'daddylive': 'Daddylive',
|
||||
'iptv': 'liveTV.iptvWebSource',
|
||||
};
|
||||
|
||||
// Get source key from catalog ID
|
||||
const getSourceKey = (catalogId: string): string => {
|
||||
if (catalogId.startsWith('linkzy_')) return 'linkzy';
|
||||
if (catalogId.startsWith('matches_')) return 'matches';
|
||||
if (catalogId.startsWith('matches_')) return 'fctv';
|
||||
if (catalogId.startsWith('wiflix_')) return 'wiflix';
|
||||
if (catalogId.startsWith('sosplay_')) return 'sosplay';
|
||||
if (catalogId.startsWith('livetv_')) return 'livetv';
|
||||
if (catalogId.startsWith('daddylive_')) return 'daddylive';
|
||||
return 'other';
|
||||
};
|
||||
|
||||
|
|
@ -466,10 +484,11 @@ const LiveTV: React.FC = () => {
|
|||
const [catalogs, setCatalogs] = useState<Catalog[]>([]);
|
||||
const [channels, setChannels] = useState<Channel[]>([]);
|
||||
const [selectedCatalog, setSelectedCatalog] = useState<string>('');
|
||||
const [selectedSource, setSelectedSource] = useState<string>('matches'); // Default source
|
||||
const [selectedSource, setSelectedSource] = useState<string>('fctv'); // Default source
|
||||
const [loadingCatalogs, setLoadingCatalogs] = useState(true);
|
||||
const [loadingChannels, setLoadingChannels] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [collapsedMatches, setCollapsedMatches] = useState<Record<string, boolean>>({});
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [livetvStatusFilter, setLivetvStatusFilter] = useState<'playable' | 'live' | 'upcoming' | 'all'>('playable');
|
||||
const [livetvSportFilter, setLivetvSportFilter] = useState<string>('all');
|
||||
|
|
@ -529,8 +548,10 @@ const LiveTV: React.FC = () => {
|
|||
const hasExtension = isExtensionAvailable();
|
||||
const hasFullAccess = isVip || hasExtension;
|
||||
|
||||
// Matches & IPTV require VIP specifically (not just extension)
|
||||
const isVipOnlySource = selectedSource === 'matches' || selectedSource === 'iptv';
|
||||
// IPTV requires VIP specifically. Matches (fctv) now works for free users via
|
||||
// the extension/userscript (local resolution) or the embed player, so it only
|
||||
// needs full access (VIP OR extension), not VIP.
|
||||
const isVipOnlySource = selectedSource === 'iptv';
|
||||
const hasAccess = isVipOnlySource ? isVip : hasFullAccess;
|
||||
|
||||
const filteredChannels = channels.filter(channel => {
|
||||
|
|
@ -830,7 +851,7 @@ const LiveTV: React.FC = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
const sourceNeedsVip = launchTarget.source === 'matches' || launchTarget.source === 'iptv';
|
||||
const sourceNeedsVip = launchTarget.source === 'iptv';
|
||||
const sourceAccessible = sourceNeedsVip ? isVip : hasFullAccess;
|
||||
if (!sourceAccessible) {
|
||||
return;
|
||||
|
|
@ -958,7 +979,10 @@ const LiveTV: React.FC = () => {
|
|||
};
|
||||
|
||||
const isPlayableEventChannel = (channel: Channel) =>
|
||||
!isTimedEventChannel(channel) || Boolean(channel._isLive) || isImminentEventChannel(channel);
|
||||
!isTimedEventChannel(channel)
|
||||
|| Boolean(channel._isLive)
|
||||
|| isImminentEventChannel(channel)
|
||||
|| (channel.id.startsWith('match_') && (channel._serverCount || 0) > 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSource !== 'livetv') {
|
||||
|
|
@ -1172,7 +1196,7 @@ const LiveTV: React.FC = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
const sourceNeedsVip = launchTarget.source === 'matches' || launchTarget.source === 'iptv';
|
||||
const sourceNeedsVip = launchTarget.source === 'iptv';
|
||||
const sourceAccessible = sourceNeedsVip ? isVip : hasFullAccess;
|
||||
|
||||
if (!sourceAccessible) {
|
||||
|
|
@ -1247,7 +1271,7 @@ const LiveTV: React.FC = () => {
|
|||
let name = catalog.name;
|
||||
|
||||
// 1. Enlever les préfixes de source connus
|
||||
const prefixes = ['Linkzy', 'Wiflix', 'Sosplay', 'Bolaloca', 'LiveTV', 'Matches'];
|
||||
const prefixes = ['Linkzy', 'Wiflix', 'Sosplay', 'Bolaloca', 'LiveTV', 'FCTV'];
|
||||
for (const prefix of prefixes) {
|
||||
if (name.toLowerCase().startsWith(prefix.toLowerCase() + ' ')) {
|
||||
name = name.slice(prefix.length + 1);
|
||||
|
|
@ -1290,18 +1314,39 @@ const LiveTV: React.FC = () => {
|
|||
if (lowerId.includes('movie') || lowerId.includes('film') || lowerId.includes('cinema') || lowerName.includes('film') || lowerName.includes('ciné')) return '🎬';
|
||||
if (lowerId.includes('news') || lowerId.includes('info') || lowerName.includes('info')) return '📰';
|
||||
if (lowerId.includes('kid') || lowerId.includes('enfant') || lowerName.includes('enfant')) return '👶';
|
||||
if (lowerId.includes('music') || lowerName.includes('musi')) return '🎵';
|
||||
if (lowerId.includes('music') || lowerId.includes('musi')) return '🎵';
|
||||
if (lowerId.includes('docu') || lowerName.includes('docu')) return '🌍';
|
||||
if (lowerId.includes('general') || lowerName.includes('general')) return '📺';
|
||||
|
||||
return '📺';
|
||||
};
|
||||
|
||||
// Non-ISO buckets that have no country flag -> emoji fallback.
|
||||
const daddyliveBucketEmoji: Record<string, string> = { arabic: '🌐', africa: '🌍', other: '🌎' };
|
||||
|
||||
// Catalog icon: daddylive country catalogs render a flag via react-country-flag.
|
||||
const getCatalogIcon = (catalog: Catalog): React.ReactNode => {
|
||||
if (catalog.id.startsWith('daddylive_')) {
|
||||
const code = catalog.id.slice('daddylive_'.length);
|
||||
if (code.length === 2) {
|
||||
return (
|
||||
<ReactCountryFlag
|
||||
countryCode={code.toUpperCase()}
|
||||
svg
|
||||
style={{ width: '1.15em', height: '1.15em', borderRadius: '2px' }}
|
||||
aria-label={code.toUpperCase()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <span className="text-base">{daddyliveBucketEmoji[code] || '🌎'}</span>;
|
||||
}
|
||||
return <span className="text-base">{getCatalogEmoji(catalog)}</span>;
|
||||
};
|
||||
|
||||
// Source icons mapping
|
||||
const sourceIcons: Record<string, React.ReactNode> = {
|
||||
'linkzy': <Zap className="w-3.5 h-3.5" />,
|
||||
'matches': <span className="text-sm leading-none">⚽</span>,
|
||||
'fctv': <span className="text-sm leading-none">⚽</span>,
|
||||
'wiflix': <Tv className="w-3.5 h-3.5" />,
|
||||
'sosplay': <Radio className="w-3.5 h-3.5" />,
|
||||
'livetv': <Radio className="w-3.5 h-3.5" />,
|
||||
|
|
@ -1316,7 +1361,7 @@ const LiveTV: React.FC = () => {
|
|||
}, [availableSources, isVip]);
|
||||
|
||||
const handleSourceChange = (newSource: string) => {
|
||||
const srcIsVipOnly = newSource === 'matches' || newSource === 'iptv';
|
||||
const srcIsVipOnly = newSource === 'iptv';
|
||||
if (srcIsVipOnly && !isVip) return;
|
||||
if (!srcIsVipOnly && !hasFullAccess) return;
|
||||
|
||||
|
|
@ -1512,6 +1557,171 @@ const LiveTV: React.FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const toggleMatchCollapse = (matchId: string) => {
|
||||
setCollapsedMatches((prev) => ({
|
||||
...prev,
|
||||
[matchId]: !prev[matchId],
|
||||
}));
|
||||
};
|
||||
|
||||
const renderMatchAccordion = (channel: Channel, index: number) => {
|
||||
const isExpanded = !collapsedMatches[channel.id];
|
||||
const isFavorite = isFavoriteChannel(selectedSource, channel.id);
|
||||
const isLive = Boolean(channel._isLive);
|
||||
const hasServers = (channel._servers && channel._servers.length > 0) || (channel._serverCount || 0) > 0;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={channel.id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: Math.min(index * 0.015, 0.3) }}
|
||||
className="w-full bg-white/[0.015] border border-white/[0.04] rounded-2xl overflow-hidden backdrop-blur-md transition-all duration-300 hover:border-white/[0.08] hover:bg-white/[0.02]"
|
||||
>
|
||||
{/* Header Block */}
|
||||
<div
|
||||
onClick={() => toggleMatchCollapse(channel.id)}
|
||||
className="flex flex-col sm:flex-row sm:items-center justify-between p-4 sm:p-5 gap-4 cursor-pointer select-none"
|
||||
>
|
||||
{/* Left info: League, status/timer, names */}
|
||||
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||||
{/* Sport/League Logo */}
|
||||
<div className="w-10 h-10 rounded-full bg-white/[0.03] border border-white/[0.08] flex items-center justify-center flex-shrink-0">
|
||||
{channel._leagueLogo ? (
|
||||
<img src={channel._leagueLogo} alt="League" className="w-6 h-6 object-contain" />
|
||||
) : (
|
||||
<span className="text-xl">⚽</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Match info and teams */}
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
{/* League & Country */}
|
||||
<div className="flex items-center gap-2 text-white/45 text-[11px] font-medium tracking-wide">
|
||||
{channel._countryLogo && (
|
||||
<img src={channel._countryLogo} alt="" className="w-3.5 h-2.5 object-cover rounded-[1px]" />
|
||||
)}
|
||||
<span className="truncate">{channel._competition || channel._sport}</span>
|
||||
</div>
|
||||
|
||||
{/* Team Matchup with Logos */}
|
||||
<div className="flex flex-wrap items-center gap-2.5 text-white/95 font-semibold text-sm sm:text-base">
|
||||
{/* Home Team */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{channel._homeLogo && (
|
||||
<img src={channel._homeLogo} alt="" className="w-5 h-5 object-contain flex-shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{channel._homeTeam || channel.name}</span>
|
||||
</div>
|
||||
|
||||
<span className="text-white/30 text-xs font-normal">vs</span>
|
||||
|
||||
{/* Away Team */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{channel._awayLogo && (
|
||||
<img src={channel._awayLogo} alt="" className="w-5 h-5 object-contain flex-shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{channel._awayTeam || ""}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right controls: Time/Score, Favorites button, expand/collapse indicator */}
|
||||
<div className="flex items-center justify-between sm:justify-end gap-3 sm:gap-4 border-t border-white/[0.04] pt-3 sm:pt-0 sm:border-0">
|
||||
{/* Live badge or timer */}
|
||||
<div className="flex-shrink-0">
|
||||
{isLive ? (
|
||||
<div className="flex items-center gap-1.5 bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-2.5 py-1 rounded-full text-xs font-semibold">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full animate-pulse" />
|
||||
{channel._score ? `LIVE ${channel._score}` : "LIVE"}
|
||||
</div>
|
||||
) : channel._timestamp ? (
|
||||
<TimeRemaining timestamp={channel._timestamp} t={t} />
|
||||
) : channel._timeText ? (
|
||||
<span className="text-xs font-medium text-amber-400/80 bg-amber-500/10 px-2.5 py-1 rounded-full border border-amber-500/10">
|
||||
{channel._timeText}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Favorite and Chevron buttons */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<FavoriteChannelButton
|
||||
active={isFavorite}
|
||||
activeLabel={t('liveTV.removeFromFavorites')}
|
||||
inactiveLabel={t('liveTV.addToFavorites')}
|
||||
onToggle={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleFavoriteChannel(event, {
|
||||
source: selectedSource,
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
poster: channel.poster,
|
||||
kind: 'channel',
|
||||
catalogId: selectedCatalog,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Chevron icon */}
|
||||
<div className="w-8 h-8 rounded-full bg-white/[0.03] border border-white/[0.06] flex items-center justify-center text-white/50 transition-colors hover:text-white/80">
|
||||
<ChevronDown className={cn("w-4 h-4 transition-transform duration-300", isExpanded ? "rotate-180" : "")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Block (collapsible accordion) */}
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
>
|
||||
<div className="px-5 pb-5 pt-1 border-t border-white/[0.03] bg-white/[0.005] space-y-4">
|
||||
{/* Servers title */}
|
||||
<div className="flex items-center gap-2 text-white/45 text-[11px] font-semibold uppercase tracking-wider">
|
||||
<Wifi className="w-3.5 h-3.5" />
|
||||
<span>{t('liveTV.availableServers')}</span>
|
||||
</div>
|
||||
|
||||
{/* Watch action — opens the player picker (Lecteur intégré ⭐ + serveurs natifs) */}
|
||||
{hasServers ? (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleChannelClick(channel);
|
||||
}}
|
||||
className="w-full sm:w-auto px-5 py-3 rounded-xl text-sm font-semibold bg-emerald-500/15 border border-emerald-500/25 text-emerald-300 hover:bg-emerald-500/25 hover:text-emerald-200 active:scale-95 transition-all duration-200 cursor-pointer flex items-center justify-center gap-2 shadow-sm"
|
||||
>
|
||||
<Tv className="w-4 h-4" />
|
||||
{t('liveTV.watchMatch')}
|
||||
</button>
|
||||
{(channel._serverCount || 0) > 0 && (
|
||||
<p className="text-white/35 text-[11px]">
|
||||
{t('liveTV.serversAvailable', { count: channel._serverCount })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-white/35 text-xs italic">
|
||||
{isLive
|
||||
? t('liveTV.noServersFound')
|
||||
: t('liveTV.upcomingServersNote')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderChannelCard = (channel: Channel, index: number) => {
|
||||
const isWiflix = selectedCatalog.startsWith('wiflix_');
|
||||
const isSosplay = selectedCatalog.startsWith('sosplay_');
|
||||
|
|
@ -1664,11 +1874,17 @@ const LiveTV: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── STREAM DISCLAIMER ── */}
|
||||
<div className="flex items-start gap-2.5 px-3.5 py-2.5 bg-white/[0.03] border border-white/[0.06] rounded-xl">
|
||||
<Wifi className="w-4 h-4 text-white/35 shrink-0 mt-0.5" />
|
||||
<p className="text-white/45 text-xs sm:text-sm leading-snug">{t('liveTV.streamDisclaimer')}</p>
|
||||
</div>
|
||||
|
||||
{/* ── SOURCE PILL TABS ── */}
|
||||
{!loadingCatalogs && hasFullAccess && allSources.length > 0 && (
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1 scrollbar-none -mx-1 px-1">
|
||||
{allSources.map((source) => {
|
||||
const srcIsVipOnly = source === 'matches' || source === 'iptv';
|
||||
const srcIsVipOnly = source === 'iptv';
|
||||
const isLocked = srcIsVipOnly ? !isVip : !hasFullAccess;
|
||||
const isActive = selectedSource === source;
|
||||
const label = sourceDisplayNames[source]?.startsWith('liveTV.') ? t(sourceDisplayNames[source]) : (sourceDisplayNames[source] || source);
|
||||
|
|
@ -1878,7 +2094,7 @@ const LiveTV: React.FC = () => {
|
|||
: 'bg-white/[0.02] text-white/50 border-transparent hover:bg-white/[0.05] hover:text-white/70'
|
||||
)}
|
||||
>
|
||||
<span className="text-base">{getCatalogEmoji(catalog)}</span>
|
||||
{getCatalogIcon(catalog)}
|
||||
{formatCatalogName(catalog)}
|
||||
</button>
|
||||
);
|
||||
|
|
@ -2074,26 +2290,50 @@ const LiveTV: React.FC = () => {
|
|||
transition={{ duration: 0.25 }}
|
||||
className="space-y-5"
|
||||
>
|
||||
{favoriteDisplayedChannels.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<LiveTVSectionDivider title={t('liveTV.favorites')} count={favoriteDisplayedChannels.length} />
|
||||
<div className={channelGridClassName}>
|
||||
{favoriteDisplayedChannels.map((channel, index) => renderChannelCard(channel, index))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{regularDisplayedChannels.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{selectedCatalog.startsWith('matches_') ? (
|
||||
<div className="space-y-4">
|
||||
{favoriteDisplayedChannels.length > 0 && (
|
||||
<LiveTVSectionDivider title={t('liveTV.otherChannels')} count={regularDisplayedChannels.length} />
|
||||
<div className="space-y-3">
|
||||
<LiveTVSectionDivider title={t('liveTV.favorites')} count={favoriteDisplayedChannels.length} />
|
||||
<div className="flex flex-col gap-4">
|
||||
{favoriteDisplayedChannels.map((channel, index) => renderMatchAccordion(channel, index))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{regularDisplayedChannels.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{favoriteDisplayedChannels.length > 0 && (
|
||||
<LiveTVSectionDivider title={t('liveTV.otherChannels')} count={regularDisplayedChannels.length} />
|
||||
)}
|
||||
<div className="flex flex-col gap-4">
|
||||
{regularDisplayedChannels.map((channel, index) => renderMatchAccordion(channel, favoriteDisplayedChannels.length + index))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={channelGridClassName}>
|
||||
{regularDisplayedChannels.map((channel, index) => renderChannelCard(channel, favoriteDisplayedChannels.length + index))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<>
|
||||
{favoriteDisplayedChannels.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<LiveTVSectionDivider title={t('liveTV.favorites')} count={favoriteDisplayedChannels.length} />
|
||||
<div className={channelGridClassName}>
|
||||
{favoriteDisplayedChannels.map((channel, index) => renderChannelCard(channel, index))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{regularDisplayedChannels.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{favoriteDisplayedChannels.length > 0 && (
|
||||
<LiveTVSectionDivider title={t('liveTV.otherChannels')} count={regularDisplayedChannels.length} />
|
||||
)}
|
||||
<div className={channelGridClassName}>
|
||||
{regularDisplayedChannels.map((channel, index) => renderChannelCard(channel, favoriteDisplayedChannels.length + index))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
39
src/utils/adScriptMode.ts
Normal file
39
src/utils/adScriptMode.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// Ad-network <script> mode for the "Voir une pub" button popup (normal mode).
|
||||
//
|
||||
// When enabled, the normal-mode popup loads this ad-network script instead of
|
||||
// opening the direct link (utils/adAdultMode). The script is pre-configured on
|
||||
// the ad-network side to deliver the ad on the button click, so the button only
|
||||
// advances the popup flow — it does not open the direct link. The other popup
|
||||
// modes (auto / click-anywhere) keep using the direct link.
|
||||
//
|
||||
// This is a build-time switch (code constant), not a user setting. Flip
|
||||
// SCRIPT_AD_MODE_ENABLED to false to revert the button to direct-link behaviour.
|
||||
|
||||
export const SCRIPT_AD_MODE_ENABLED = false;
|
||||
|
||||
// Ad-network script src, protocol-relative as delivered by the network.
|
||||
export const AD_SCRIPT_SRC = '//vf.amildarrobomb.com/r7gP5R6D5YTruZr/142815';
|
||||
|
||||
// Marker attribute used to keep injection idempotent.
|
||||
const AD_SCRIPT_MARKER = 'data-movix-ad-script';
|
||||
|
||||
export const isScriptAdModeEnabled = (): boolean => SCRIPT_AD_MODE_ENABLED;
|
||||
|
||||
// Inject the ad-network script once. Idempotent: re-calls are no-ops while the
|
||||
// tag is present in the document. data-cfasync="false" keeps Cloudflare Rocket
|
||||
// Loader from deferring it; async lets it load without blocking the popup.
|
||||
export const loadAdScript = (): void => {
|
||||
if (!SCRIPT_AD_MODE_ENABLED) return;
|
||||
try {
|
||||
if (document.querySelector(`script[${AD_SCRIPT_MARKER}]`)) return;
|
||||
const s = document.createElement('script');
|
||||
s.src = AD_SCRIPT_SRC;
|
||||
s.async = true;
|
||||
s.type = 'text/javascript';
|
||||
s.setAttribute('data-cfasync', 'false');
|
||||
s.setAttribute(AD_SCRIPT_MARKER, '');
|
||||
document.body.appendChild(s);
|
||||
} catch {
|
||||
/* document unavailable (SSR / privacy) */
|
||||
}
|
||||
};
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// ==UserScript==
|
||||
// @name Movix Proxy Extension (Tampermonkey)
|
||||
// @namespace https://movix.cash
|
||||
// @version 1.4.0
|
||||
// @version 1.4.3
|
||||
// @description Extension proxy pour Live TV Movix - Contourne CORS, injecte les headers et extrait les sources Nexus - version userscript Tampermonkey
|
||||
// @author Movix
|
||||
// @match http://localhost/*
|
||||
|
|
@ -17,6 +17,8 @@
|
|||
// @match https://movix.tax/*
|
||||
// @match https://*.movix.tax/*
|
||||
// @match https://movix.golf/*
|
||||
// @match https://movix.chat/*
|
||||
// @match https://*.movix.chat/*
|
||||
// @match https://*.movix.golf/*
|
||||
// @grant GM_xmlhttpRequest
|
||||
// @grant GM_getValue
|
||||
|
|
@ -1031,7 +1033,7 @@
|
|||
*/
|
||||
|
||||
// ===== Configuration =====
|
||||
const PROXY_BASE = "https://proxiesembed.movix.cash";
|
||||
const PROXY_BASE = "https://proxiesembed.movix.chat";
|
||||
|
||||
// AES constants for SeekStreaming (embed4me)
|
||||
const SEEKSTREAMING_AES_KEY_HEX =
|
||||
|
|
@ -2381,14 +2383,18 @@
|
|||
// --- END extension/Chrome/extractors.js ---
|
||||
|
||||
// --- BEGIN extension/Chrome/background.js ---
|
||||
const VAVOO_BASE_URL = "https://tvvoo.hayd.uk/cfg-fr";
|
||||
const WITV_BASE_URL = "https://witv.team";
|
||||
const SOSPLAY_BASE_URL = "https://streamonsport.art";
|
||||
const LIVETV_BASE_URL = "https://livetv882.me/frx/";
|
||||
const LIVETV_EMBED_ORIGIN = "https://livetv882.me";
|
||||
const LIVETV_EMBED_REFERER = LIVETV_BASE_URL;
|
||||
// Backend API URL for got-scraping based extraction
|
||||
const API_BASE_URL = "https://api.movix.cash";
|
||||
// Backend API URL for got-scraping based extraction.
|
||||
// Dev override: on localhost (Vite dev on :3000) hit the local backend (:25565).
|
||||
const API_BASE_URL =
|
||||
typeof location !== "undefined" &&
|
||||
(location.hostname === "localhost" || location.hostname === "127.0.0.1")
|
||||
? "http://localhost:25565"
|
||||
: "https://api.movix.chat";
|
||||
const STREAM_PROXY_USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
||||
|
||||
|
|
@ -2542,6 +2548,7 @@
|
|||
"movix.cloud",
|
||||
"movix.tax",
|
||||
"movix.golf",
|
||||
"movix.chat",
|
||||
],
|
||||
resourceTypes: [
|
||||
"xmlhttprequest",
|
||||
|
|
@ -2714,6 +2721,19 @@
|
|||
return { success: false, error: "Could not setup headers" };
|
||||
}
|
||||
|
||||
// FCTV (matches) native: inject the player Referer on the CDN segments.
|
||||
case "SETUP_FCTV_HEADERS": {
|
||||
const okFctv = await setupFctvHeadersRule(payload?.referer);
|
||||
return { success: okFctv };
|
||||
}
|
||||
|
||||
// FCTV (matches) native: resolve ONE server locally (IP-bound) -> m3u8.
|
||||
case "RESOLVE_FCTV": {
|
||||
const fctvUrl = await resolveFctvStream(payload || {});
|
||||
if (fctvUrl) return { success: true, url: fctvUrl };
|
||||
return { success: false, error: "fctv resolve failed" };
|
||||
}
|
||||
|
||||
case "SET_EXTRACTION_PREFS": {
|
||||
const incoming = payload?.prefs;
|
||||
if (
|
||||
|
|
@ -2938,13 +2958,15 @@
|
|||
currentHostname === "movix.tax" ||
|
||||
currentHostname.endsWith(".movix.tax") ||
|
||||
currentHostname === "movix.golf" ||
|
||||
currentHostname === "movix.chat" ||
|
||||
currentHostname.endsWith(".movix.chat") ||
|
||||
currentHostname.endsWith(".movix.golf")
|
||||
) {
|
||||
return (currentOrigin || "https://movix.cash").replace(/\/$/, "");
|
||||
return (currentOrigin || "https://movix.chat").replace(/\/$/, "");
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return "https://movix.cash";
|
||||
return "https://movix.chat";
|
||||
}
|
||||
|
||||
function buildBackendApiHeaders(accessKey, extraHeaders = {}) {
|
||||
|
|
@ -2964,12 +2986,6 @@
|
|||
}
|
||||
|
||||
async function getManifest() {
|
||||
console.log("Fetching manifest from Vavoo...");
|
||||
const vavooData = await fetchSafe(
|
||||
`${VAVOO_BASE_URL}/manifest.json`,
|
||||
"Vavoo",
|
||||
);
|
||||
|
||||
const manifest = {
|
||||
id: "org.stremio.merged",
|
||||
version: "1.0.0",
|
||||
|
|
@ -2981,16 +2997,6 @@
|
|||
idPrefixes: [],
|
||||
};
|
||||
|
||||
// Add Vavoo catalogs
|
||||
if (vavooData) {
|
||||
if (vavooData.catalogs) manifest.catalogs.push(...vavooData.catalogs);
|
||||
if (vavooData.idPrefixes) {
|
||||
manifest.idPrefixes.push(...vavooData.idPrefixes);
|
||||
} else {
|
||||
manifest.idPrefixes.push("vavoo_");
|
||||
}
|
||||
}
|
||||
|
||||
// Add Wiflix (WITV) catalogs
|
||||
const wiflixCatalogs = [
|
||||
{ type: "tv", id: "wiflix_sport", name: "⚽ Sport" },
|
||||
|
|
@ -3069,12 +3075,17 @@
|
|||
return await response.json();
|
||||
}
|
||||
|
||||
// Default: Vavoo catalog
|
||||
const url = `${VAVOO_BASE_URL}/catalog/${type}/${catalogId}.json`;
|
||||
const catalog = await fetchSafe(url, "Catalog " + catalogId);
|
||||
if (!catalog) throw new Error("Catalog fetch failed");
|
||||
|
||||
return catalog;
|
||||
// Default: route to backend (handles matches_, TV Direct, etc.)
|
||||
console.log(`[CATALOG] Fetching catalog via Backend: ${catalogId}`);
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/livetv/catalog/tv/${catalogId}`,
|
||||
{
|
||||
headers: buildBackendApiHeaders(accessKey),
|
||||
},
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new Error(`Backend API error: ${response.status}`);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// Wiflix catalog categories mapping (URL paths)
|
||||
|
|
@ -3237,21 +3248,17 @@
|
|||
return await getLiveTvStream(channelId, accessKey, options);
|
||||
}
|
||||
|
||||
// Default: Vavoo stream
|
||||
const url = `${VAVOO_BASE_URL}/stream/${type}/${channelId}.json`;
|
||||
const streamData = await fetchSafe(url, "Vavoo Stream");
|
||||
|
||||
if (streamData && streamData.streams) {
|
||||
for (const stream of streamData.streams) {
|
||||
if (stream.url) {
|
||||
await addUserAgentRule(stream.url, "VAVOO/2.6");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!streamData) throw new Error("Stream fetch failed");
|
||||
|
||||
return streamData;
|
||||
// Default: route to backend (handles matches_, TV Direct, etc.)
|
||||
console.log(`[STREAM] Fetching stream via Backend: ${channelId}`);
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/livetv/stream/tv/${channelId}`,
|
||||
{
|
||||
headers: buildBackendApiHeaders(accessKey),
|
||||
},
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new Error(`Backend API error: ${response.status}`);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// Find the channel page URL from cache or by searching
|
||||
|
|
@ -4953,6 +4960,135 @@
|
|||
return addHeadersRule(urlPattern, { "User-Agent": userAgent });
|
||||
}
|
||||
|
||||
// === FCTV (matches): Referer rule + IP-bound local resolver ===============
|
||||
const FCTV_HEADERS_RULE_ID = 60;
|
||||
async function setupFctvHeadersRule(referer) {
|
||||
if (!referer) return false;
|
||||
const ref = referer.endsWith("/") ? referer : referer + "/";
|
||||
let origin;
|
||||
try { origin = new URL(ref).origin; } catch { origin = ref.replace(/\/+$/, ""); }
|
||||
try {
|
||||
await chrome.declarativeNetRequest.updateDynamicRules({
|
||||
removeRuleIds: [FCTV_HEADERS_RULE_ID],
|
||||
addRules: [
|
||||
{
|
||||
id: FCTV_HEADERS_RULE_ID,
|
||||
priority: 20,
|
||||
action: {
|
||||
type: "modifyHeaders",
|
||||
requestHeaders: [
|
||||
{ header: "Referer", operation: "set", value: ref },
|
||||
{ header: "Origin", operation: "set", value: origin },
|
||||
{ header: "User-Agent", operation: "set", value: STREAM_PROXY_USER_AGENT },
|
||||
],
|
||||
},
|
||||
condition: {
|
||||
urlFilter: "/cfall/s",
|
||||
resourceTypes: ["xmlhttprequest", "media", "other"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("[FCTV] Failed to add headers rule:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const FCTV_API_BASE = "https://apis-data-defra10.tcore131ybdf.ru";
|
||||
const FCTV_TOKEN_KEYSTREAM_HEX =
|
||||
"15764bab80a419c6abdd5518f3db0ea95bb3b9a2e2b519ce5c159af6917e2000c2d680ae30706a3aba1c9c25786c7c28774eecf20450a3cf414ca17f6472798cfa557c7a8705b7861f06e84f827f8a24676eeab77ce504bfc335b79609b9";
|
||||
function fctvRot47(s) {
|
||||
let out = "";
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const k = s.charCodeAt(i);
|
||||
out += k < 33 || k > 126 ? s[i] : String.fromCharCode(33 + ((k - 33 + 47) % 94));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function fctvReadVarint(buf, off) {
|
||||
let result = 0n, shift = 0n, cur = off;
|
||||
while (cur < buf.length) {
|
||||
const b = buf[cur++];
|
||||
result |= BigInt(b & 0x7f) << shift;
|
||||
if ((b & 0x80) === 0) return { value: result, offset: cur };
|
||||
shift += 7n;
|
||||
if (shift > 70n) break;
|
||||
}
|
||||
throw new Error("varint");
|
||||
}
|
||||
function fctvDecode(buf, depth = 0) {
|
||||
const fields = [];
|
||||
let off = 0;
|
||||
while (off < buf.length) {
|
||||
let tag;
|
||||
try { tag = fctvReadVarint(buf, off); } catch { break; }
|
||||
off = tag.offset;
|
||||
const field = Number(tag.value >> 3n);
|
||||
const wt = Number(tag.value & 7n);
|
||||
const e = { field, wireType: wt };
|
||||
if (wt === 0) {
|
||||
const p = fctvReadVarint(buf, off); off = p.offset;
|
||||
} else if (wt === 1) {
|
||||
off += 8;
|
||||
} else if (wt === 2) {
|
||||
const pl = fctvReadVarint(buf, off); off = pl.offset;
|
||||
const len = Number(pl.value);
|
||||
const bytes = buf.subarray(off, off + len);
|
||||
off += len;
|
||||
let text = "";
|
||||
try { text = new TextDecoder().decode(bytes); } catch {}
|
||||
let printable = 0;
|
||||
for (let i = 0; i < text.length; i++) { const c = text.charCodeAt(i); if ((c >= 32 && c <= 126) || c >= 160) printable++; }
|
||||
if (text && printable / text.length > 0.7) e.value = text;
|
||||
if (depth < 8 && bytes.length) { try { const ch = fctvDecode(bytes, depth + 1); if (ch.length) e.children = ch; } catch {} }
|
||||
} else if (wt === 5) {
|
||||
off += 4;
|
||||
} else break;
|
||||
fields.push(e);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
const fctvField = (fields, f) => (fields || []).find((e) => e.field === f);
|
||||
const fctvChildren = (fields, f) => { const x = fctvField(fields, f); return (x && x.children) || []; };
|
||||
function fctvMakeToken(rbSession) {
|
||||
const ks = [];
|
||||
for (let i = 0; i < FCTV_TOKEN_KEYSTREAM_HEX.length; i += 2) ks.push(parseInt(FCTV_TOKEN_KEYSTREAM_HEX.substr(i, 2), 16));
|
||||
const pt = new TextEncoder().encode(rbSession);
|
||||
const n = Math.min(pt.length, ks.length);
|
||||
let bin = "";
|
||||
for (let i = 0; i < n; i++) bin += String.fromCharCode(pt[i] ^ ks[i]);
|
||||
return encodeURIComponent(btoa(bin) + "a");
|
||||
}
|
||||
async function resolveFctvStream(opts) {
|
||||
const { matchId, streamId, siteType, sportType, referer, apiBase } = opts || {};
|
||||
if (!matchId || !streamId) return null;
|
||||
const base = apiBase || FCTV_API_BASE;
|
||||
const u = new URL(base + "/api/stream/detail");
|
||||
u.searchParams.set("streamId", String(streamId));
|
||||
u.searchParams.set("siteType", String(siteType || 2001));
|
||||
u.searchParams.set("continent", "EU");
|
||||
u.searchParams.set("country", "FR");
|
||||
u.searchParams.set("digit", "seth");
|
||||
u.searchParams.set("matchId", String(matchId));
|
||||
u.searchParams.set("sportType", String(sportType || 1));
|
||||
const resp = await fetch(u.toString(), { headers: { Accept: "*/*" } });
|
||||
const rbSession = resp.headers.get("rb-session");
|
||||
const buf = new Uint8Array(await resp.arrayBuffer());
|
||||
const root = fctvDecode(buf);
|
||||
const body = fctvChildren(root, 10);
|
||||
const inner = fctvChildren(body, 2).length ? fctvChildren(body, 2) : body;
|
||||
const maskedField = fctvField(inner, 4);
|
||||
const masked = maskedField && typeof maskedField.value === "string" ? maskedField.value : "";
|
||||
if (!masked || !rbSession) return null;
|
||||
let parsed;
|
||||
try { parsed = new URL(fctvRot47(masked).slice(8)); } catch { return null; }
|
||||
const token = fctvMakeToken(rbSession);
|
||||
if (referer) await setupFctvHeadersRule(referer);
|
||||
return `${parsed.origin}/token-${token}${parsed.pathname}${parsed.search}`;
|
||||
}
|
||||
|
||||
async function addHeadersRule(urlPattern, headers) {
|
||||
const existingRules = await chrome.declarativeNetRequest.getDynamicRules();
|
||||
const existingIds = new Set(existingRules.map((r) => r.id));
|
||||
|
|
|
|||
Loading…
Reference in a new issue