From 2fad46f934ff0dfa83499ee92c21eb9b3f04c885 Mon Sep 17 00:00:00 2001 From: "https://github.com/goon-foss/goon" Date: Fri, 22 May 2026 13:18:26 +0200 Subject: [PATCH] filemoon: resurrect via mobile-side resolver (Byse SPA RE) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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 --- app/connectors/direct_scrapers/__init__.py | 11 +- app/extractors/hoster.py | 12 + app/extractors/tubes/_embed_iframe.py | 13 +- mobile/src/lib/aesGcm.ts | 244 +++++++++++++++++++++ mobile/src/lib/filemoonHoster.ts | 155 +++++++++++++ mobile/src/screens/PlayerScreen.tsx | 5 + 6 files changed, 428 insertions(+), 12 deletions(-) create mode 100644 mobile/src/lib/aesGcm.ts create mode 100644 mobile/src/lib/filemoonHoster.ts diff --git a/app/connectors/direct_scrapers/__init__.py b/app/connectors/direct_scrapers/__init__.py index 16bb444..f88b461 100644 --- a/app/connectors/direct_scrapers/__init__.py +++ b/app/connectors/direct_scrapers/__init__.py @@ -82,11 +82,12 @@ ALL_DIRECT_SCRAPERS: list[type[BaseDirectTubeScraper]] = [ # 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 # nie wspiera DoodStream ("Piracy"), własny resolver TBD jeśli warto. - # SiskaScraper — wyłączony 2026-05-16 (filemoon shutdown). Każda siska scena - # embeduje filemoon iframe; filemoon.to/sx/nl serwują od ~2026-05 placeholder - # "Byse Frontend" SPA bez player JS. 14,839 playback_sources mass-marked dead. - # Plik scrapera + extractor zostają (mobile spróbuje resolve → DEAD_HOSTER_RE - # filemoon blacklist → None → 503 — fine, te scenes są też dead_at-filtered). + # SiskaScraper — wyłączony 2026-05-16. Wyłączenie było oparte na błędnym + # założeniu "filemoon shutdown" — filemoon zrobił rebrand na SPA "Byse + # Frontend", a nie umarł (RE 2026-05-22, patrz mobile/src/lib/filemoonHoster.ts). + # Status do rewizji: (a) filemoon znów resolvuje się mobile-side, (b) siska + # 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, # 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 = diff --git a/app/extractors/hoster.py b/app/extractors/hoster.py index ad25c16..98118ee 100644 --- a/app/extractors/hoster.py +++ b/app/extractors/hoster.py @@ -221,6 +221,18 @@ def extract_stream_from_hoster( if sources: return sources[0].link 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 = { "User-Agent": _DEFAULT_UA, "Accept": "text/html,application/xhtml+xml", diff --git a/app/extractors/tubes/_embed_iframe.py b/app/extractors/tubes/_embed_iframe.py index 8e3fa7b..d7deca9 100644 --- a/app/extractors/tubes/_embed_iframe.py +++ b/app/extractors/tubes/_embed_iframe.py @@ -177,13 +177,12 @@ DEAD_HOSTER_RE = re.compile( r'|streamtape\.[a-z]+|streamta\.pe|streamtap\.com|streamcrypt\.net' # malware r'|scloud\.ninja|stape\.fun|tapecontent\.net|streamtapeadblock\.[a-z]+' # streamtape mirrors r'|openload\.co|openload\.io|oload\.[a-z]+' # openload (offline od 2019) - # filemoon.* — wszystkie mirrory (filemoon.to/sx/nl/in/ru/co + aliasy - # kerapoxy.cc, lvturbo.com) serwują od ~2026-05 ten sam SPA "Byse Frontend" - # placeholder bez player JS. Globalny shutdown. Siska/perverzija/xmoviesforyou - # mają filemoon jako default embed → wszystkie sceny przez ten path = dead - # iframe (bug-report 16966e77 2026-05-16 "Niby 404 ale graficzne"). Blacklist - # eliminuje próby + wymusza fallback na alt hostera / TubePageError None. - r'|filemoon\.[a-z]{2,4}|kerapoxy\.cc|lvturbo\.com|emturbovid\.com' # dead 2026-05 + # filemoon — NIE jest na blacklist. ~2026-05 rebrand na SPA "Byse Frontend" + # zabił stary P.A.C.K.E.R.-JWPlayer embed (stąd wcześniejsze błędne uznanie + # za "globalny shutdown"), ale video żyje za prywatnym JSON API. RE 2026-05-22: + # POST /api/videos//embed/playback {"fingerprint":{}} → AES-256-GCM → + # m3u8. URL jest IP-bound, więc resolver MUSI iść z urządzenia użytkownika: + # filemoon przelatuje jako type='hoster' → mobile/src/lib/filemoonHoster.ts. r')' r'(?:[:/]|$)', # port, path, lub end-of-string re.IGNORECASE, diff --git a/mobile/src/lib/aesGcm.ts b/mobile/src/lib/aesGcm.ts new file mode 100644 index 0000000..faa5d8e --- /dev/null +++ b/mobile/src/lib/aesGcm.ts @@ -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//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; +} diff --git a/mobile/src/lib/filemoonHoster.ts b/mobile/src/lib/filemoonHoster.ts new file mode 100644 index 0000000..b14a601 --- /dev/null +++ b/mobile/src/lib/filemoonHoster.ts @@ -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:///api/videos//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/, /d/, /download/, /dwn/, /f/. +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; + 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 { + 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 }, + }; +} diff --git a/mobile/src/screens/PlayerScreen.tsx b/mobile/src/screens/PlayerScreen.tsx index 1f37cf0..7d54aa9 100644 --- a/mobile/src/screens/PlayerScreen.tsx +++ b/mobile/src/screens/PlayerScreen.tsx @@ -888,6 +888,7 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) { // pełny HTML z player config, z którego liczymy direct m3u8/mp4 URL. // - DoodStream-variant (playmogo/dood*) — pass_md5 protokół (doodstream.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. React.useEffect(() => { if (resolveAttempted || skipResolve) return; @@ -895,6 +896,7 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) { const source = refererHost ? `https://${refererHost.replace(/^https?:\/\//, '')}/` : undefined; const { isDoodStream, resolveDoodStream } = await import('../lib/doodstream'); const { isPackerHoster, resolvePackerHoster } = await import('../lib/packerHoster'); + const { isFilemoonHoster, resolveFilemoonHoster } = await import('../lib/filemoonHoster'); let result: { url?: string; headers?: Record; error?: string } | null = null; if (isDoodStream(url)) { @@ -903,6 +905,9 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) { } else if (isPackerHoster(url)) { setResolveStatus('pending'); result = await resolvePackerHoster(url, source); + } else if (isFilemoonHoster(url)) { + setResolveStatus('pending'); + result = await resolveFilemoonHoster(url, source); } setResolveAttempted(true);