/** * 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 }, }; }