diff --git a/mobile/src/lib/packerHoster.ts b/mobile/src/lib/packerHoster.ts new file mode 100644 index 0000000..9282b35 --- /dev/null +++ b/mobile/src/lib/packerHoster.ts @@ -0,0 +1,136 @@ +/** + * Mobile-side P.A.C.K.E.R. hoster resolver. + * + * Powód: hostery typu luluvid / streamwish-forki pakują JWPlayer config + * (`sources:[{file:"https://...m3u8"}]`) w `eval(function(p,a,c,k,e,d){...})` + * P.A.C.K.E.R. obfuskację. Backend (VPS Hetzner IP) dostaje od nich CAPTCHA / + * pustą stronę, więc `try_extract` zwraca type='hoster' → WebView fallback, + * który pokazuje stronę hostera z reklamami. + * + * Mobile IP usera (residential / komórka) zwykle NIE triggeruje CAPTCHA — + * embed page renderuje pełny HTML z packed JWPlayer config. Rozpakowujemy + * go tutaj i zwracamy direct m3u8/mp4 do natywnego ExoPlayera. + * + * Port `unpack_packer` z app/extractors/hoster.py (ten sam regex + base-N). + * Komplementarny do doodstream.ts (pass_md5 protokół) — tamten dla DoodStream, + * ten dla P.A.C.K.E.R.-JWPlayer hosterów. + */ + +const PACKER_HOSTS = new Set([ + 'luluvid.com', 'luluvdo.com', 'lulustream.com', 'lulu.st', + // StreamWish family — ten sam P.A.C.K.E.R.-JWPlayer wzorzec + 'streamwish.com', 'streamwish.to', 'embedwish.com', 'wishembed.pro', + 'awish.pro', 'dwish.pro', 'mwish.pro', 'swishsrv.com', + 'streamwish.site', 'hlswish.com', 'kswplayer.info', +]); + +const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'; + +export interface ResolveResult { + url?: string; + headers?: Record; + error?: string; +} + +export function isPackerHoster(url: string): boolean { + if (!url) return false; + try { + const host = new URL(url).hostname.toLowerCase().replace(/^www\./, ''); + if (PACKER_HOSTS.has(host)) return true; + for (const h of PACKER_HOSTS) { + if (host.endsWith('.' + h)) return true; + } + } catch {} + return false; +} + +// Port `_base_n` z hoster.py — token w bazie `base` (max 62, a-zA-Z0-9). +function baseN(token: string, base: number): number | null { + let result = 0; + for (const ch of token) { + let d: number; + if (ch >= '0' && ch <= '9') d = ch.charCodeAt(0) - 48; + else if (ch >= 'a' && ch <= 'z') d = ch.charCodeAt(0) - 97 + 10; + else if (ch >= 'A' && ch <= 'Z') d = ch.charCodeAt(0) - 65 + 36; + else return null; + if (d >= base) return null; + result = result * base + d; + } + return result; +} + +// Port `unpack_packer` — odwraca eval(function(p,a,c,k,e,d){...}('PAYLOAD',BASE,COUNT,'kw'.split('|'))). +export function unpackPacker(js: string): string | null { + const m = js.match( + /\}\s*\(\s*'((?:\\'|[^'])+)'\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*'((?:\\'|[^'])*)'\s*\.split\('\|'\)/s, + ); + if (!m) return null; + let payload = m[1]; + const base = parseInt(m[2], 10); + const count = parseInt(m[3], 10); + const keywords = m[4].split('|'); + payload = payload.replace(/\\'/g, "'").replace(/\\"/g, '"').replace(/\\\\/g, '\\'); + return payload.replace(/\b\w+\b/g, (token) => { + const idx = baseN(token, base); + if (idx === null || idx >= count || idx >= keywords.length) return token; + return keywords[idx] || token; + }); +} + +// JWPlayer `sources:[{file:"URL"}]` lub `file:"URL"` — m3u8/mp4/mpd. +const FILE_RE = /(?:["']?file["']?|sources?)\s*[:=]\s*["'](https?:\/\/[^"']+\.(?:m3u8|mp4|mpd)[^"']*)["']/i; +const AD_RE = /\/(?:preroll|midroll|postroll|preplay|ads?|advert|promo)\d*\.(?:mp4|m3u8|webm)/i; + +export async function resolvePackerHoster( + embedUrl: string, + sourceUrl?: string, +): Promise { + if (!isPackerHoster(embedUrl)) return { error: 'not_packer_hoster' }; + + // /d/ (download) → /e/ (embed player) — embed ma packed JWPlayer config. + const url = embedUrl.replace('/d/', '/e/'); + const host = (() => { try { return new URL(url).hostname; } catch { return ''; } })(); + const referer = sourceUrl || `https://${host}/`; + + let html: string; + try { + const r = await fetch(url, { + method: 'GET', + headers: { 'User-Agent': UA, 'Referer': referer }, + }); + if (!r.ok) return { error: `embed http ${r.status}` }; + html = await r.text(); + } catch (e: any) { + return { error: `embed fetch fail: ${e?.message || e}` }; + } + + if (/video not found|file (?:was )?deleted|not found/i.test(html.slice(0, 4000))) { + return { error: 'video_deleted' }; + } + + // Najpierw raw HTML (czasem file: jest poza packerem), potem unpacked. + let videoUrl: string | null = null; + const rawMatch = html.match(FILE_RE); + if (rawMatch && !AD_RE.test(rawMatch[1])) { + videoUrl = rawMatch[1]; + } + if (!videoUrl) { + const unpacked = unpackPacker(html); + if (!unpacked) { + // Brak packera = CAPTCHA gate (mobile IP też zablokowane?) lub pusta strona. + if (html.length < 2000 || /challenge-platform|turnstile/i.test(html)) { + return { error: 'captcha_gate' }; + } + return { error: 'no_packer_in_html' }; + } + const um = unpacked.match(FILE_RE); + if (um && !AD_RE.test(um[1])) videoUrl = um[1]; + } + + if (!videoUrl) return { error: 'no_video_url' }; + + return { + url: videoUrl, + headers: { 'User-Agent': UA, 'Referer': referer }, + }; +} diff --git a/mobile/src/screens/PlayerScreen.tsx b/mobile/src/screens/PlayerScreen.tsx index d6f4d58..1f37cf0 100644 --- a/mobile/src/screens/PlayerScreen.tsx +++ b/mobile/src/screens/PlayerScreen.tsx @@ -883,22 +883,32 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) { const [skipResolve, setSkipResolve] = React.useState(false); const [resolveAttempted, setResolveAttempted] = React.useState(false); - // Stage 0.8: DoodStream-variant resolver (porn-app rs0.java port). Mobile IP - // może uniknąć Cloudflare Turnstile gate który blokuje Hetzner VPS. Fetch - // embed page, parse pass_md5 + splash_error, construct direct mp4 URL. - // Sukces → NativeVideoPlayer. Fail → fallback do WebView. + // Stage 0.8: mobile-side hoster resolvery. Mobile IP usera unika Cloudflare + // Turnstile / CAPTCHA gate który blokuje Hetzner VPS — embed page renderuje + // 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) + // Sukces → NativeVideoPlayer (bez reklam/cookie). Fail → fallback do WebView. React.useEffect(() => { if (resolveAttempted || skipResolve) return; (async () => { - const { isDoodStream, resolveDoodStream } = await import('../lib/doodstream'); - if (!isDoodStream(url)) { - setResolveAttempted(true); - return; - } - setResolveStatus('pending'); const source = refererHost ? `https://${refererHost.replace(/^https?:\/\//, '')}/` : undefined; - const result = await resolveDoodStream(url, source); + const { isDoodStream, resolveDoodStream } = await import('../lib/doodstream'); + const { isPackerHoster, resolvePackerHoster } = await import('../lib/packerHoster'); + + let result: { url?: string; headers?: Record; error?: string } | null = null; + if (isDoodStream(url)) { + setResolveStatus('pending'); + result = await resolveDoodStream(url, source); + } else if (isPackerHoster(url)) { + setResolveStatus('pending'); + result = await resolvePackerHoster(url, source); + } + setResolveAttempted(true); + if (!result) { + return; // nie nasz hoster — WebView fallback (INJECTED_JS) zajmie się resztą + } if (result.url) { setResolveStatus('idle'); nav.replace('Player', {