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:
https://github.com/goon-foss/goon 2026-05-22 13:18:26 +02:00
parent b6e3b1cbb5
commit 2fad46f934
6 changed files with 428 additions and 12 deletions

View file

@ -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 =

View file

@ -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",

View file

@ -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/<code>/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,

244
mobile/src/lib/aesGcm.ts Normal file
View 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;
}

View 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 },
};
}

View file

@ -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<string, string>; 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);