filemoon: resurrect via mobile-side resolver (Byse SPA RE)
filemoon (+ mirrory kerapoxy/lvturbo/emturbovid/bysezoxexe/bysezejataos)
nie umarł — ~2026-05 zrobił rebrand na Vite SPA "Byse Frontend". Stary
P.A.C.K.E.R.-JWPlayer embed zniknął, więc backend uznał go za martwego i
wpisał na DEAD_HOSTER_RE. RE bundla index-ChwZgmXV.js (2026-05-22):
POST /api/videos/<code>/embed/playback body {"fingerprint":{}}
→ {"playback":{"key_parts":[..],"iv":..,"payload":..}}
→ key=concat(b64url(key_parts)); AES-256-GCM(key,iv,payload) → JSON
→ sources[*].url = HLS master.m3u8
Browser-attestation jest opcjonalny — pusty fingerprint wystarcza.
Stream URL jest IP-bound (token wiąże się z IP requestera), więc resolve
musi iść z urządzenia użytkownika (jak doodstream.ts / packerHoster.ts).
- mobile/src/lib/aesGcm.ts — pure-JS AES-256-GCM decrypt (RN/Hermes nie
ma Web Crypto); S-box liczony z GF(2^8), GHASH weryfikuje tag.
Zweryfikowane przeciw cryptography (Python) na 2 payloadach.
- mobile/src/lib/filemoonHoster.ts — resolver: POST playback → decrypt →
pick best source. E2E test: filemoon.to/e + /d + bysezoxexe.com mirror.
- PlayerScreen: filemoon w resolve useEffect obok doodstream/packer.
- backend: filemoon poza DEAD_HOSTER_RE; hoster.py early-return → przelot
jako type='hoster' do mobile resolvera (server-side resolve bezcelowy,
bo URL IP-bound do VPS).
- direct_scrapers: poprawiony błędny komentarz "filemoon shutdown".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
b6e3b1cbb5
commit
2fad46f934
6 changed files with 428 additions and 12 deletions
|
|
@ -82,11 +82,12 @@ ALL_DIRECT_SCRAPERS: list[type[BaseDirectTubeScraper]] = [
|
||||||
# mobile = black screen (player JS nie inicjalizuje się przez Turnstile). 16%
|
# mobile = black screen (player JS nie inicjalizuje się przez Turnstile). 16%
|
||||||
# scen solo (no backup tube), 84% multi-source — user może użyć innego tube. yt-dlp
|
# scen solo (no backup tube), 84% multi-source — user może użyć innego tube. yt-dlp
|
||||||
# nie wspiera DoodStream ("Piracy"), własny resolver TBD jeśli warto.
|
# nie wspiera DoodStream ("Piracy"), własny resolver TBD jeśli warto.
|
||||||
# SiskaScraper — wyłączony 2026-05-16 (filemoon shutdown). Każda siska scena
|
# SiskaScraper — wyłączony 2026-05-16. Wyłączenie było oparte na błędnym
|
||||||
# embeduje filemoon iframe; filemoon.to/sx/nl serwują od ~2026-05 placeholder
|
# założeniu "filemoon shutdown" — filemoon zrobił rebrand na SPA "Byse
|
||||||
# "Byse Frontend" SPA bez player JS. 14,839 playback_sources mass-marked dead.
|
# Frontend", a nie umarł (RE 2026-05-22, patrz mobile/src/lib/filemoonHoster.ts).
|
||||||
# Plik scrapera + extractor zostają (mobile spróbuje resolve → DEAD_HOSTER_RE
|
# Status do rewizji: (a) filemoon znów resolvuje się mobile-side, (b) siska
|
||||||
# filemoon blacklist → None → 503 — fine, te scenes są też dead_at-filtered).
|
# od ~2026-05 i tak przeszła w dużej części na playmogo. Re-enable wymaga
|
||||||
|
# sprawdzenia aktualnego mixu hosterów na siska.video.
|
||||||
# SiskaScraper,
|
# SiskaScraper,
|
||||||
# Porn4DaysScraper — wyłączony 2026-05-12 (post audit fix). 100% scen na streamtape
|
# Porn4DaysScraper — wyłączony 2026-05-12 (post audit fix). 100% scen na streamtape
|
||||||
# only (DEAD_HOSTER_RE blacklist - malware drive-by .reg downloads). SERVER1_URL =
|
# only (DEAD_HOSTER_RE blacklist - malware drive-by .reg downloads). SERVER1_URL =
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,18 @@ def extract_stream_from_hoster(
|
||||||
if sources:
|
if sources:
|
||||||
return sources[0].link
|
return sources[0].link
|
||||||
return None
|
return None
|
||||||
|
# filemoon "Byse" SPA — server-side resolve jest bezcelowy: stream URL z API
|
||||||
|
# jest IP-bound do requestera, więc VPS dostałby URL działający tylko z VPS.
|
||||||
|
# Zwracamy None od razu → _embed_iframe Stage 2 → type='hoster' → mobile
|
||||||
|
# filemoonHoster.ts robi POST /playback + AES-256-GCM z IP użytkownika.
|
||||||
|
if re.search(
|
||||||
|
r"//(?:[a-z0-9-]+\.)?(?:filemoon|kerapoxy|lvturbo|emturbovid|"
|
||||||
|
r"bysezoxexe|bysezejataos|moonseries)\.[a-z]{2,4}/",
|
||||||
|
iframe_url,
|
||||||
|
re.IGNORECASE,
|
||||||
|
):
|
||||||
|
log.debug("hoster %s: filemoon SPA → type=hoster (mobile-side resolve)", iframe_url)
|
||||||
|
return None
|
||||||
headers = {
|
headers = {
|
||||||
"User-Agent": _DEFAULT_UA,
|
"User-Agent": _DEFAULT_UA,
|
||||||
"Accept": "text/html,application/xhtml+xml",
|
"Accept": "text/html,application/xhtml+xml",
|
||||||
|
|
|
||||||
|
|
@ -177,13 +177,12 @@ DEAD_HOSTER_RE = re.compile(
|
||||||
r'|streamtape\.[a-z]+|streamta\.pe|streamtap\.com|streamcrypt\.net' # malware
|
r'|streamtape\.[a-z]+|streamta\.pe|streamtap\.com|streamcrypt\.net' # malware
|
||||||
r'|scloud\.ninja|stape\.fun|tapecontent\.net|streamtapeadblock\.[a-z]+' # streamtape mirrors
|
r'|scloud\.ninja|stape\.fun|tapecontent\.net|streamtapeadblock\.[a-z]+' # streamtape mirrors
|
||||||
r'|openload\.co|openload\.io|oload\.[a-z]+' # openload (offline od 2019)
|
r'|openload\.co|openload\.io|oload\.[a-z]+' # openload (offline od 2019)
|
||||||
# filemoon.* — wszystkie mirrory (filemoon.to/sx/nl/in/ru/co + aliasy
|
# filemoon — NIE jest na blacklist. ~2026-05 rebrand na SPA "Byse Frontend"
|
||||||
# kerapoxy.cc, lvturbo.com) serwują od ~2026-05 ten sam SPA "Byse Frontend"
|
# zabił stary P.A.C.K.E.R.-JWPlayer embed (stąd wcześniejsze błędne uznanie
|
||||||
# placeholder bez player JS. Globalny shutdown. Siska/perverzija/xmoviesforyou
|
# za "globalny shutdown"), ale video żyje za prywatnym JSON API. RE 2026-05-22:
|
||||||
# mają filemoon jako default embed → wszystkie sceny przez ten path = dead
|
# POST /api/videos/<code>/embed/playback {"fingerprint":{}} → AES-256-GCM →
|
||||||
# iframe (bug-report 16966e77 2026-05-16 "Niby 404 ale graficzne"). Blacklist
|
# m3u8. URL jest IP-bound, więc resolver MUSI iść z urządzenia użytkownika:
|
||||||
# eliminuje próby + wymusza fallback na alt hostera / TubePageError None.
|
# filemoon przelatuje jako type='hoster' → mobile/src/lib/filemoonHoster.ts.
|
||||||
r'|filemoon\.[a-z]{2,4}|kerapoxy\.cc|lvturbo\.com|emturbovid\.com' # dead 2026-05
|
|
||||||
r')'
|
r')'
|
||||||
r'(?:[:/]|$)', # port, path, lub end-of-string
|
r'(?:[:/]|$)', # port, path, lub end-of-string
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
|
|
|
||||||
244
mobile/src/lib/aesGcm.ts
Normal file
244
mobile/src/lib/aesGcm.ts
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
/**
|
||||||
|
* Minimal pure-JS AES-256-GCM decryption.
|
||||||
|
*
|
||||||
|
* Powód: React Native (Hermes) nie ma Web Crypto (`crypto.subtle`), a projekt
|
||||||
|
* nie linkuje natywnego crypto modułu. `filemoonHoster.ts` musi rozszyfrować
|
||||||
|
* AES-256-GCM payload zwracany przez filemoon `/api/videos/<code>/embed/playback`.
|
||||||
|
* Czysty JS = zmiana ships przez OTA update (bez rebuilda APK).
|
||||||
|
*
|
||||||
|
* Zakres: tylko DECRYPT, klucz 256-bit, IV 96-bit (GCM standard), 128-bit tag
|
||||||
|
* doklejony na końcu ciphertextu (konwencja Web Crypto / Go `crypto/cipher`).
|
||||||
|
* Tablica S-box liczona z GF(2^8) przy load module — zero hardkodowanych
|
||||||
|
* 256-elementowych literałów (mniej miejsca na literówkę). GHASH weryfikuje tag.
|
||||||
|
*
|
||||||
|
* Referencje: NIST SP 800-38D (GCM), FIPS-197 (AES).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---- GF(2^8) — xtime + tablice exp/log + S-box ----
|
||||||
|
|
||||||
|
function xtime(b: number): number {
|
||||||
|
return ((b << 1) ^ (b & 0x80 ? 0x1b : 0)) & 0xff;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SBOX = new Uint8Array(256);
|
||||||
|
|
||||||
|
(function initSbox() {
|
||||||
|
// log/antilog w GF(2^8) z generatorem 3 (0x03).
|
||||||
|
const exp = new Uint8Array(256);
|
||||||
|
const log = new Uint8Array(256);
|
||||||
|
let x = 1;
|
||||||
|
for (let i = 0; i < 255; i++) {
|
||||||
|
exp[i] = x;
|
||||||
|
log[x] = i;
|
||||||
|
x ^= xtime(x); // x = x*2 ^ x = x*3
|
||||||
|
}
|
||||||
|
// S-box: odwrotność multiplikatywna + transformacja afiniczna.
|
||||||
|
for (let a = 0; a < 256; a++) {
|
||||||
|
let inv = a === 0 ? 0 : exp[(255 - log[a]) % 255];
|
||||||
|
let s = inv;
|
||||||
|
let r = inv;
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
s = ((s << 1) | (s >>> 7)) & 0xff;
|
||||||
|
r ^= s;
|
||||||
|
}
|
||||||
|
SBOX[a] = r ^ 0x63;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ---- AES-256 key schedule (Nk=8, Nr=14 → 240 bajtów round keys) ----
|
||||||
|
|
||||||
|
const NR = 14;
|
||||||
|
|
||||||
|
function expandKey(key: Uint8Array): Uint8Array {
|
||||||
|
if (key.length !== 32) throw new Error('aesGcm: key must be 32 bytes (AES-256)');
|
||||||
|
const Nk = 8;
|
||||||
|
const total = 4 * (NR + 1); // 60 słów
|
||||||
|
const w = new Uint8Array(total * 4); // 240 bajtów
|
||||||
|
w.set(key);
|
||||||
|
let rc = 1;
|
||||||
|
for (let i = Nk; i < total; i++) {
|
||||||
|
let t0 = w[(i - 1) * 4];
|
||||||
|
let t1 = w[(i - 1) * 4 + 1];
|
||||||
|
let t2 = w[(i - 1) * 4 + 2];
|
||||||
|
let t3 = w[(i - 1) * 4 + 3];
|
||||||
|
if (i % Nk === 0) {
|
||||||
|
// RotWord + SubWord + Rcon
|
||||||
|
const tmp = t0;
|
||||||
|
t0 = SBOX[t1] ^ rc;
|
||||||
|
t1 = SBOX[t2];
|
||||||
|
t2 = SBOX[t3];
|
||||||
|
t3 = SBOX[tmp];
|
||||||
|
rc = xtime(rc);
|
||||||
|
} else if (i % Nk === 4) {
|
||||||
|
// AES-256: dodatkowy SubWord co 4 słowa
|
||||||
|
t0 = SBOX[t0];
|
||||||
|
t1 = SBOX[t1];
|
||||||
|
t2 = SBOX[t2];
|
||||||
|
t3 = SBOX[t3];
|
||||||
|
}
|
||||||
|
w[i * 4] = w[(i - Nk) * 4] ^ t0;
|
||||||
|
w[i * 4 + 1] = w[(i - Nk) * 4 + 1] ^ t1;
|
||||||
|
w[i * 4 + 2] = w[(i - Nk) * 4 + 2] ^ t2;
|
||||||
|
w[i * 4 + 3] = w[(i - Nk) * 4 + 3] ^ t3;
|
||||||
|
}
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
function m3(b: number): number {
|
||||||
|
return xtime(b) ^ b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Szyfrowanie pojedynczego 16-bajtowego bloku. GCM używa tylko AES-encrypt
|
||||||
|
// (zarówno do keystreamu CTR jak i do hashowania) — AES-decrypt nie jest potrzebny.
|
||||||
|
function encryptBlock(rk: Uint8Array, input: Uint8Array): Uint8Array {
|
||||||
|
const s = new Uint8Array(16);
|
||||||
|
s.set(input);
|
||||||
|
// AddRoundKey (runda 0)
|
||||||
|
for (let i = 0; i < 16; i++) s[i] ^= rk[i];
|
||||||
|
for (let round = 1; round <= NR; round++) {
|
||||||
|
// SubBytes
|
||||||
|
for (let i = 0; i < 16; i++) s[i] = SBOX[s[i]];
|
||||||
|
// ShiftRows (state column-major: bajt (row r, col c) = s[r + 4c])
|
||||||
|
let t = s[1];
|
||||||
|
s[1] = s[5]; s[5] = s[9]; s[9] = s[13]; s[13] = t;
|
||||||
|
t = s[2]; s[2] = s[10]; s[10] = t;
|
||||||
|
t = s[6]; s[6] = s[14]; s[14] = t;
|
||||||
|
t = s[15]; s[15] = s[11]; s[11] = s[7]; s[7] = s[3]; s[3] = t;
|
||||||
|
// MixColumns (pomijane w ostatniej rundzie)
|
||||||
|
if (round < NR) {
|
||||||
|
for (let c = 0; c < 4; c++) {
|
||||||
|
const a0 = s[4 * c], a1 = s[4 * c + 1], a2 = s[4 * c + 2], a3 = s[4 * c + 3];
|
||||||
|
s[4 * c] = xtime(a0) ^ m3(a1) ^ a2 ^ a3;
|
||||||
|
s[4 * c + 1] = a0 ^ xtime(a1) ^ m3(a2) ^ a3;
|
||||||
|
s[4 * c + 2] = a0 ^ a1 ^ xtime(a2) ^ m3(a3);
|
||||||
|
s[4 * c + 3] = m3(a0) ^ a1 ^ a2 ^ xtime(a3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// AddRoundKey
|
||||||
|
for (let i = 0; i < 16; i++) s[i] ^= rk[round * 16 + i];
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- GCM ----
|
||||||
|
|
||||||
|
// Mnożenie w GF(2^128) wg konwencji GCM (bit-reversed, wielomian R = 0xe1<<120).
|
||||||
|
function gmult(X: Uint8Array, Y: Uint8Array): Uint8Array {
|
||||||
|
const Z = new Uint8Array(16);
|
||||||
|
const V = Y.slice();
|
||||||
|
for (let i = 0; i < 128; i++) {
|
||||||
|
const bit = (X[i >>> 3] >>> (7 - (i & 7))) & 1;
|
||||||
|
if (bit) for (let j = 0; j < 16; j++) Z[j] ^= V[j];
|
||||||
|
const lsb = V[15] & 1;
|
||||||
|
for (let j = 15; j > 0; j--) V[j] = ((V[j] >>> 1) | ((V[j - 1] & 1) << 7)) & 0xff;
|
||||||
|
V[0] = V[0] >>> 1;
|
||||||
|
if (lsb) V[0] ^= 0xe1;
|
||||||
|
}
|
||||||
|
return Z;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GHASH: X ← (X ^ block) · H, blok po bloku (16 bajtów, padding zerami).
|
||||||
|
function ghashUpdate(X: Uint8Array, H: Uint8Array, block: Uint8Array): Uint8Array {
|
||||||
|
const t = new Uint8Array(16);
|
||||||
|
for (let i = 0; i < 16; i++) t[i] = X[i] ^ (block[i] ?? 0);
|
||||||
|
return gmult(t, H);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inc32(block: Uint8Array): void {
|
||||||
|
// inkrementacja ostatnich 4 bajtów jako big-endian uint32
|
||||||
|
for (let i = 15; i >= 12; i--) {
|
||||||
|
block[i] = (block[i] + 1) & 0xff;
|
||||||
|
if (block[i] !== 0) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ct16Eq(a: Uint8Array, b: Uint8Array): boolean {
|
||||||
|
// porównanie w stałym czasie (16 bajtów)
|
||||||
|
let d = 0;
|
||||||
|
for (let i = 0; i < 16; i++) d |= a[i] ^ b[i];
|
||||||
|
return d === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rozszyfruj AES-256-GCM. `ciphertextWithTag` = ciphertext || 16-bajtowy tag.
|
||||||
|
* Rzuca wyjątek przy niezgodności tagu albo złych długościach.
|
||||||
|
*/
|
||||||
|
export function aesGcmDecrypt(
|
||||||
|
key: Uint8Array,
|
||||||
|
iv: Uint8Array,
|
||||||
|
ciphertextWithTag: Uint8Array,
|
||||||
|
): Uint8Array {
|
||||||
|
if (iv.length !== 12) throw new Error('aesGcm: IV must be 12 bytes (96-bit)');
|
||||||
|
if (ciphertextWithTag.length < 16) throw new Error('aesGcm: payload shorter than tag');
|
||||||
|
|
||||||
|
const rk = expandKey(key);
|
||||||
|
const ctLen = ciphertextWithTag.length - 16;
|
||||||
|
const ct = ciphertextWithTag.subarray(0, ctLen);
|
||||||
|
const tag = ciphertextWithTag.subarray(ctLen);
|
||||||
|
|
||||||
|
// H = AES_K(0^128)
|
||||||
|
const H = encryptBlock(rk, new Uint8Array(16));
|
||||||
|
|
||||||
|
// J0 = IV || 0x00000001
|
||||||
|
const J0 = new Uint8Array(16);
|
||||||
|
J0.set(iv);
|
||||||
|
J0[15] = 1;
|
||||||
|
|
||||||
|
// GHASH nad ciphertextem (AAD puste).
|
||||||
|
let X = new Uint8Array(16);
|
||||||
|
for (let off = 0; off < ctLen; off += 16) {
|
||||||
|
const block = ct.subarray(off, Math.min(off + 16, ctLen));
|
||||||
|
X = ghashUpdate(X, H, block);
|
||||||
|
}
|
||||||
|
// Blok długości: lenAAD (64-bit) || lenC (64-bit), oba w bitach.
|
||||||
|
const lenBlock = new Uint8Array(16);
|
||||||
|
const ctBits = ctLen * 8;
|
||||||
|
// lenAAD = 0 → bajty 0..7 zostają zerowe. lenC w bajtach 8..15 (big-endian).
|
||||||
|
lenBlock[15] = ctBits & 0xff;
|
||||||
|
lenBlock[14] = (ctBits >>> 8) & 0xff;
|
||||||
|
lenBlock[13] = (ctBits >>> 16) & 0xff;
|
||||||
|
lenBlock[12] = (ctBits >>> 24) & 0xff;
|
||||||
|
// ctBits > 2^32 nie wystąpi dla payloadów filemoon (kilka KB) — wyższe bajty 0.
|
||||||
|
X = ghashUpdate(X, H, lenBlock);
|
||||||
|
|
||||||
|
// Tag oczekiwany T = GHASH ^ AES_K(J0)
|
||||||
|
const ej0 = encryptBlock(rk, J0);
|
||||||
|
const expTag = new Uint8Array(16);
|
||||||
|
for (let i = 0; i < 16; i++) expTag[i] = X[i] ^ ej0[i];
|
||||||
|
if (!ct16Eq(expTag, tag)) throw new Error('aesGcm: authentication tag mismatch');
|
||||||
|
|
||||||
|
// Deszyfracja CTR — licznik startuje od inc32(J0).
|
||||||
|
const plain = new Uint8Array(ctLen);
|
||||||
|
const counter = J0.slice();
|
||||||
|
for (let off = 0; off < ctLen; off += 16) {
|
||||||
|
inc32(counter);
|
||||||
|
const ks = encryptBlock(rk, counter);
|
||||||
|
const n = Math.min(16, ctLen - off);
|
||||||
|
for (let i = 0; i < n; i++) plain[off + i] = ct[off + i] ^ ks[i];
|
||||||
|
}
|
||||||
|
return plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dekoduje base64url (bez paddingu OK) do Uint8Array. Self-contained — nie
|
||||||
|
* zależy od `atob`/`Buffer` które w RN/Hermes bywają niedostępne. */
|
||||||
|
export function b64urlDecode(input: string): Uint8Array {
|
||||||
|
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
||||||
|
const lookup = new Int16Array(128).fill(-1);
|
||||||
|
for (let i = 0; i < alphabet.length; i++) lookup[alphabet.charCodeAt(i)] = i;
|
||||||
|
const clean = input.replace(/=+$/, '');
|
||||||
|
const out = new Uint8Array(Math.floor((clean.length * 6) / 8));
|
||||||
|
let bits = 0;
|
||||||
|
let acc = 0;
|
||||||
|
let oi = 0;
|
||||||
|
for (let i = 0; i < clean.length; i++) {
|
||||||
|
const v = lookup[clean.charCodeAt(i) & 0x7f];
|
||||||
|
if (v < 0) throw new Error('b64urlDecode: invalid character');
|
||||||
|
acc = (acc << 6) | v;
|
||||||
|
bits += 6;
|
||||||
|
if (bits >= 8) {
|
||||||
|
bits -= 8;
|
||||||
|
out[oi++] = (acc >>> bits) & 0xff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
155
mobile/src/lib/filemoonHoster.ts
Normal file
155
mobile/src/lib/filemoonHoster.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
/**
|
||||||
|
* Mobile-side filemoon resolver.
|
||||||
|
*
|
||||||
|
* filemoon (i jego mirrory: filemoon.sx/to/in/nl, kerapoxy.cc, lvturbo.com,
|
||||||
|
* bysezoxexe.com, bysezejataos.com, ...) przeszedł ~2026-05 rebrand na SPA
|
||||||
|
* "Byse Frontend" (Vite). Stary P.A.C.K.E.R.-JWPlayer embed zniknął, więc
|
||||||
|
* `extract_stream_from_hoster` na backendzie zwraca pustkę → wcześniej filemoon
|
||||||
|
* był na DEAD_HOSTER_RE blacklist (błędnie uznany za "globalny shutdown").
|
||||||
|
*
|
||||||
|
* RE bundla `index-ChwZgmXV.js` / `videoPagesBundle`: SPA pobiera stream przez
|
||||||
|
* prywatne JSON API (potwierdzone 2026-05-22):
|
||||||
|
*
|
||||||
|
* POST https://<host>/api/videos/<code>/embed/playback
|
||||||
|
* body: {"fingerprint":{}} ← pusty obiekt wystarcza,
|
||||||
|
* browser-attestation jest opcjonalny
|
||||||
|
* → {"playback":{"key_parts":[b64url,b64url],"iv":b64url,"payload":b64url}}
|
||||||
|
*
|
||||||
|
* key = concat(b64urlDecode(p) for p in key_parts) → 32 bajty (AES-256)
|
||||||
|
* plaintext = AES-256-GCM(key, iv, payload) → JSON
|
||||||
|
* plaintext.sources[*].url → HLS master.m3u8
|
||||||
|
*
|
||||||
|
* Stream URL (sprintcdn `…/hls2/…/master.m3u8?t=…&asn=…`) jest TIME+IP-bound:
|
||||||
|
* token wiąże się z IP które zrobiło POST /playback. Dlatego resolve MUSI iść
|
||||||
|
* z urządzenia użytkownika (jak doodstream.ts / packerHoster.ts) — VPS-side
|
||||||
|
* resolve dałby URL działający tylko z VPS IP (= VPS-proxied bandwidth).
|
||||||
|
*
|
||||||
|
* AES-GCM liczone w czystym JS (`aesGcm.ts`) — RN/Hermes nie ma Web Crypto.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { aesGcmDecrypt, b64urlDecode } from './aesGcm';
|
||||||
|
|
||||||
|
// Znane domeny rodziny filemoon. Rebrand rotuje domeny — gdy pojawi się nowa,
|
||||||
|
// dopisać tutaj (i usunąć z backendowego DEAD_HOSTER_RE jeśli tam wpadła).
|
||||||
|
const FILEMOON_RE =
|
||||||
|
/(?:^|\.)(?:filemoon\.[a-z]{2,4}|kerapoxy\.[a-z]{2,4}|lvturbo\.[a-z]{2,4}|emturbovid\.[a-z]{2,4}|bysezoxexe\.[a-z]{2,4}|bysezejataos\.[a-z]{2,4}|moonseries\.[a-z]{2,4})$/i;
|
||||||
|
|
||||||
|
// Kod video w ścieżce: /e/<code>, /d/<code>, /download/<code>, /dwn/<code>, /f/<code>.
|
||||||
|
const CODE_RE = /\/(?:e|d|f|v|download|dwn)\/([a-zA-Z0-9]{6,})/;
|
||||||
|
|
||||||
|
const UA =
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
||||||
|
|
||||||
|
export interface ResolveResult {
|
||||||
|
url?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilemoonSource {
|
||||||
|
url?: string;
|
||||||
|
label?: string;
|
||||||
|
height?: number;
|
||||||
|
mime_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFilemoonHoster(url: string): boolean {
|
||||||
|
if (!url) return false;
|
||||||
|
try {
|
||||||
|
const host = new URL(url).hostname.toLowerCase().replace(/^www\./, '');
|
||||||
|
return FILEMOON_RE.test(host);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePlaybackKey(parts: string[]): Uint8Array {
|
||||||
|
const decoded = parts.map(b64urlDecode);
|
||||||
|
const total = decoded.reduce((acc, p) => acc + p.length, 0);
|
||||||
|
const key = new Uint8Array(total);
|
||||||
|
let off = 0;
|
||||||
|
for (const p of decoded) {
|
||||||
|
key.set(p, off);
|
||||||
|
off += p.length;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveFilemoonHoster(
|
||||||
|
embedUrl: string,
|
||||||
|
sourceUrl?: string,
|
||||||
|
): Promise<ResolveResult> {
|
||||||
|
if (!isFilemoonHoster(embedUrl)) return { error: 'not_filemoon_hoster' };
|
||||||
|
|
||||||
|
let host: string;
|
||||||
|
try {
|
||||||
|
host = new URL(embedUrl).hostname;
|
||||||
|
} catch {
|
||||||
|
return { error: 'bad_embed_url' };
|
||||||
|
}
|
||||||
|
const codeMatch = embedUrl.match(CODE_RE);
|
||||||
|
if (!codeMatch) return { error: 'no_code_in_url' };
|
||||||
|
const code = codeMatch[1];
|
||||||
|
|
||||||
|
const origin = `https://${host}`;
|
||||||
|
const playbackApi = `${origin}/api/videos/${encodeURIComponent(code)}/embed/playback`;
|
||||||
|
|
||||||
|
// 1) POST /playback — pusty fingerprint omija browser-attestation.
|
||||||
|
let payload: { key_parts?: string[]; iv?: string; payload?: string } | undefined;
|
||||||
|
try {
|
||||||
|
const r = await fetch(playbackApi, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': UA,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
Origin: origin,
|
||||||
|
Referer: `${origin}/e/${code}/`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ fingerprint: {} }),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const body = await r.text();
|
||||||
|
if (/not found/i.test(body)) return { error: 'video_deleted' };
|
||||||
|
return { error: `playback http ${r.status}` };
|
||||||
|
}
|
||||||
|
const json = await r.json();
|
||||||
|
payload = json?.playback;
|
||||||
|
} catch (e: any) {
|
||||||
|
return { error: `playback fetch fail: ${e?.message || e}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload || !Array.isArray(payload.key_parts) || !payload.iv || !payload.payload) {
|
||||||
|
return { error: 'no_playback_payload' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Rozszyfruj AES-256-GCM.
|
||||||
|
let sources: FilemoonSource[];
|
||||||
|
try {
|
||||||
|
const key = parsePlaybackKey(payload.key_parts);
|
||||||
|
const plain = aesGcmDecrypt(
|
||||||
|
key,
|
||||||
|
b64urlDecode(payload.iv),
|
||||||
|
b64urlDecode(payload.payload),
|
||||||
|
);
|
||||||
|
const decoded = JSON.parse(new TextDecoder().decode(plain));
|
||||||
|
sources = Array.isArray(decoded?.sources) ? decoded.sources : [];
|
||||||
|
} catch (e: any) {
|
||||||
|
return { error: `decrypt fail: ${e?.message || e}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Wybierz najlepsze źródło (najwyższy `height`). Każdy URL to master.m3u8
|
||||||
|
// z wariantami w środku — ExoPlayer i tak zrobi adaptację.
|
||||||
|
const valid = sources.filter((s) => typeof s.url === 'string' && s.url.length > 0);
|
||||||
|
if (valid.length === 0) return { error: 'no_sources' };
|
||||||
|
valid.sort((a, b) => (b.height || 0) - (a.height || 0));
|
||||||
|
const best = valid[0];
|
||||||
|
|
||||||
|
// Stream sprintcdn nie wymaga Referera (token jest IP-bound) — przekazujemy
|
||||||
|
// tylko UA. sourceUrl celowo ignorowany: CDN nie sprawdza pochodzenia.
|
||||||
|
void sourceUrl;
|
||||||
|
return {
|
||||||
|
url: best.url,
|
||||||
|
headers: { 'User-Agent': UA },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -888,6 +888,7 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
|
||||||
// pełny HTML z player config, z którego liczymy direct m3u8/mp4 URL.
|
// pełny HTML z player config, z którego liczymy direct m3u8/mp4 URL.
|
||||||
// - DoodStream-variant (playmogo/dood*) — pass_md5 protokół (doodstream.ts)
|
// - DoodStream-variant (playmogo/dood*) — pass_md5 protokół (doodstream.ts)
|
||||||
// - P.A.C.K.E.R.-JWPlayer (luluvid/streamwish) — eval-unpack (packerHoster.ts)
|
// - P.A.C.K.E.R.-JWPlayer (luluvid/streamwish) — eval-unpack (packerHoster.ts)
|
||||||
|
// - filemoon "Byse" SPA — JSON API + AES-256-GCM (filemoonHoster.ts)
|
||||||
// Sukces → NativeVideoPlayer (bez reklam/cookie). Fail → fallback do WebView.
|
// Sukces → NativeVideoPlayer (bez reklam/cookie). Fail → fallback do WebView.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (resolveAttempted || skipResolve) return;
|
if (resolveAttempted || skipResolve) return;
|
||||||
|
|
@ -895,6 +896,7 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
|
||||||
const source = refererHost ? `https://${refererHost.replace(/^https?:\/\//, '')}/` : undefined;
|
const source = refererHost ? `https://${refererHost.replace(/^https?:\/\//, '')}/` : undefined;
|
||||||
const { isDoodStream, resolveDoodStream } = await import('../lib/doodstream');
|
const { isDoodStream, resolveDoodStream } = await import('../lib/doodstream');
|
||||||
const { isPackerHoster, resolvePackerHoster } = await import('../lib/packerHoster');
|
const { isPackerHoster, resolvePackerHoster } = await import('../lib/packerHoster');
|
||||||
|
const { isFilemoonHoster, resolveFilemoonHoster } = await import('../lib/filemoonHoster');
|
||||||
|
|
||||||
let result: { url?: string; headers?: Record<string, string>; error?: string } | null = null;
|
let result: { url?: string; headers?: Record<string, string>; error?: string } | null = null;
|
||||||
if (isDoodStream(url)) {
|
if (isDoodStream(url)) {
|
||||||
|
|
@ -903,6 +905,9 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
|
||||||
} else if (isPackerHoster(url)) {
|
} else if (isPackerHoster(url)) {
|
||||||
setResolveStatus('pending');
|
setResolveStatus('pending');
|
||||||
result = await resolvePackerHoster(url, source);
|
result = await resolvePackerHoster(url, source);
|
||||||
|
} else if (isFilemoonHoster(url)) {
|
||||||
|
setResolveStatus('pending');
|
||||||
|
result = await resolveFilemoonHoster(url, source);
|
||||||
}
|
}
|
||||||
|
|
||||||
setResolveAttempted(true);
|
setResolveAttempted(true);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue