goon/mobile/src/lib/filemoonHoster.ts
goon-foss f2737d7e60 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>
2026-05-22 13:18:26 +02:00

155 lines
5.5 KiB
TypeScript

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