/** * 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', // luluvid/streamwish forki rotują TLD — `luluvids.top` + cdnstream/cdnvids.top // potwierdzone P.A.C.K.E.R.-JWPlayer (mypornerleak embeds, bug-report 2026-06-07: // bez tego `isPackerHoster` zwracał false → WebView na luluvids.top → "disable // Adblock and enable popup" wall zamiast natywnego playera). 'luluvids.top', 'cdnstream.top', 'cdnvids.top', // 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 }, }; }