diff --git a/mobile/src/lib/epornerResolver.ts b/mobile/src/lib/epornerResolver.ts new file mode 100644 index 0000000..c1bdd4f --- /dev/null +++ b/mobile/src/lib/epornerResolver.ts @@ -0,0 +1,88 @@ +/** + * Mobile-side eporner.com resolver. + * + * eporner XHR zwraca gvideo.eporner.com mp4 URL-e podpisane krótkoterminowym tokenem + * BOUND DO IP requestera (URL ma `__` w ścieżce — audit 2026-06-11 pokazał + * `..._46.62.219.154_...` z VPS). Backend resolvuje z VPS → telefon dostaje URL bound + * do IP VPS → direct 403 → fallback proxy → wideo przez Hetzner. + * + * Fix: telefon sam pobiera stronę + XHR (phone IP) → URL bound do telefonu → gra direct, + * zero VPS. Port z app/extractors/tubes/eporner.py (vid+hash → /xhr/video → mp4 dict). + */ +import type { StreamLink } from '../types'; + +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'; + +const _VID_RE = /EP\.video\.player\.vid\s*=\s*'([^']+)'/; +const _HASH_RE = /EP\.video\.player\.hash\s*=\s*'([0-9a-fA-F]{32})'/; + +/** True dla page_url eporner (origin tube:epornercom). */ +export function isEpornerUrl(url: string | null | undefined): boolean { + return !!url && /(?:^|\/\/|\.)eporner\.com\//i.test(url); +} + +/** 32-hex hash → base36 w 4 chunkach po 8 hex (port _hash_to_b36). */ +function hashToB36(hexHash: string): string { + let out = ''; + for (const i of [0, 8, 16, 24]) { + const n = parseInt(hexHash.slice(i, i + 8), 16); + out += n.toString(36); + } + return out; +} + +/** + * Pobiera eporner stronę + XHR NA TELEFONIE → mp4 StreamLink-i (phone-IP-bound, grają + * direct). Zwraca [] gdy brak (caller spada na backend). Mirror eporner.py. + */ +export async function resolveEpornerPage(pageUrl: string): Promise { + let html: string; + try { + const r = await fetch(pageUrl, { headers: { 'User-Agent': UA, Accept: 'text/html' } }); + if (!r.ok) return []; + html = await r.text(); + } catch { + return []; + } + const mVid = _VID_RE.exec(html); + const mHash = _HASH_RE.exec(html); + if (!mVid || !mHash) return []; + const vid = mVid[1]; + const hashB36 = hashToB36(mHash[1]); + const xhrUrl = + `https://www.eporner.com/xhr/video/${vid}?hash=${hashB36}` + + '&domain=www.eporner.com&pixelRatio=1&playerWidth=0&playerHeight=0' + + '&fallback=false&embed=false&supportedFormats=mp4'; + let data: any; + try { + const r = await fetch(xhrUrl, { + headers: { + 'User-Agent': UA, + Referer: pageUrl, + Accept: '*/*', + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + if (!r.ok) return []; + data = await r.json(); + } catch { + return []; + } + if (data && data.available === false) return []; + const mp4 = (data && data.sources && data.sources.mp4) || {}; + const links: StreamLink[] = []; + for (const [label, info] of Object.entries(mp4)) { + const src = info && typeof info === 'object' ? (info as any).src : null; + if (!src) continue; + links.push({ + stream_url: src, + direct_url: src, + headers: { Referer: pageUrl, 'User-Agent': UA }, + quality: label, + type: 'mp4', + }); + } + links.sort((a, b) => (parseInt(b.quality || '0', 10) || 0) - (parseInt(a.quality || '0', 10) || 0)); + return links; +} diff --git a/mobile/src/lib/sxyprnResolver.ts b/mobile/src/lib/sxyprnResolver.ts new file mode 100644 index 0000000..2f0404a --- /dev/null +++ b/mobile/src/lib/sxyprnResolver.ts @@ -0,0 +1,104 @@ +/** + * Mobile-side sxyprn.com resolver. + * + * Token wideo sxyprn jest bound do IP które POBRAŁO stronę /post/.html (audit + * 2026-06-11: ten sam signed URL → 1024B realnego wideo z IP VPS, 10B placeholder z + * innego IP). Backend resolvuje z VPS → telefon dostaje URL bound do IP VPS → direct + * daje 10-bajtowy placeholder → ExoPlayer fail → fallback na proxy → CAŁE wideo (setki + * MB) przez Hetzner przy KAŻDYM playbacku. + * + * Fix: telefon sam pobiera stronę (phone IP) → `data-vnfo` token bound do telefonu → + * transform boo/ssut51 (port z app/extractors/tubes/sxyprn.py) → gra direct, zero VPS. + * Ten sam wzorzec co pornxpResolver/getfileResolver (resolve od strony, na urządzeniu). + */ +import type { StreamLink } from '../types'; + +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'; + +const _VNFO_RE = /data-vnfo='([^']+)'/; +const _B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +/** True dla page_url sxyprn (origin tube:sxyprncom). */ +export function isSxyprnUrl(url: string | null | undefined): boolean { + return !!url && /(?:^|\/\/|\.)sxyprn\.com\//i.test(url); +} + +/** Suma cyfr w stringu (ssut51 z main2.js). */ +function ssut51(s: string): number { + let sum = 0; + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + if (c >= 48 && c <= 57) sum += c - 48; + } + return sum; +} + +/** base64-encode ASCII string (self-contained, bez atob/Buffer). */ +function b64encode(str: string): string { + let out = ''; + for (let i = 0; i < str.length; i += 3) { + const a = str.charCodeAt(i); + const b = i + 1 < str.length ? str.charCodeAt(i + 1) : NaN; + const c = i + 2 < str.length ? str.charCodeAt(i + 2) : NaN; + out += _B64[a >> 2]; + out += _B64[((a & 3) << 4) | (isNaN(b) ? 0 : b >> 4)]; + out += isNaN(b) ? '=' : _B64[((b & 15) << 2) | (isNaN(c) ? 0 : c >> 6)]; + out += isNaN(c) ? '=' : _B64[c & 63]; + } + return out; +} + +/** base64url-safe `-sxyprn.com-` z `+`→`-`, `/`→`_`, `=`→`.` (boo z main2.js). */ +function boo(ss: number, es: number): string { + return b64encode(`${ss}-sxyprn.com-${es}`) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '.'); +} + +/** + * Pobiera stronę sxyprn NA TELEFONIE i transformuje `data-vnfo` URL-e na grywalne + * .vid mp4 (phone-IP-bound, grają direct). Zwraca [] gdy brak/dead (caller spada na + * backend/WebView). Mirror app/extractors/tubes/sxyprn.py. + */ +export async function resolveSxyprnPage(pageUrl: string): Promise { + let html: string; + try { + const r = await fetch(pageUrl, { headers: { 'User-Agent': UA, Accept: 'text/html' } }); + if (!r.ok) return []; + html = await r.text(); + } catch { + return []; + } + if (html.includes('Post Not Found')) return []; // usunięty post + const m = _VNFO_RE.exec(html); + if (!m) return []; + let vnfo: Record; + try { + vnfo = JSON.parse(m[1]); + } catch { + return []; + } + const links: StreamLink[] = []; + for (const src of Object.values(vnfo)) { + if (typeof src !== 'string' || !src.startsWith('/cdn/')) continue; + const tmp = src.split('/'); + if (tmp.length < 8) continue; + const s6 = ssut51(tmp[6]); + const s7 = ssut51(tmp[7]); + const ts = parseInt(tmp[5], 10); + if (Number.isNaN(ts)) continue; + tmp[1] = tmp[1] + '8' + '/' + boo(s6, s7); + tmp[5] = String(ts - s6 - s7); + const full = 'https://sxyprn.com' + tmp.join('/'); + links.push({ + stream_url: full, + direct_url: full, + headers: { Referer: 'https://sxyprn.com/', 'User-Agent': UA }, + quality: 'auto', + type: 'mp4', + }); + } + return links; +} diff --git a/mobile/src/lib/voeResolver.ts b/mobile/src/lib/voeResolver.ts new file mode 100644 index 0000000..c090e46 --- /dev/null +++ b/mobile/src/lib/voeResolver.ts @@ -0,0 +1,162 @@ +/** + * Mobile-side voe.sx resolver. + * + * VOE CDN URL (cloudwindow-route.com) ma `i=<2-oktetowy prefix IP>` — token bound do /16 + * IP które POBRAŁO embed (audit 2026-06-11: z VPS `i=46.62`, master.m3u8 z innego /16 → + * 403). Backend resolvuje z VPS → telefon dostaje URL bound do /16 VPS → direct 403 → + * pełny proxy → CAŁE wideo przez Hetzner (131 hitów/48h). + * + * Fix: telefon sam pobiera embed (phone IP) → token bound do jego /16 → gra direct, + * zero VPS. Port 7-stopniowego dekodera z app/extractors/hosters/voe.py (rot13 → replace + * 7 magic-sepów → strip _ → atob → shift -3 → reverse → atob → JSON.parse). + */ +import type { StreamLink } from '../types'; + +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'; + +const _REDIRECT_RE = /window\.location\.href\s*=\s*['"]([^'"]+)['"]/; +const _PAYLOAD_RE = /(\[[\s\S]+?\])<\/script>/; +const _MAGIC_SEPS = ['@$', '^^', '~@', '%?', '*~', '!!', '#&']; +const _B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +/** True dla embed/page voe.sx (origin *:voe). */ +export function isVoeUrl(url: string | null | undefined): boolean { + return !!url && /(?:^|\/\/|\.)voe\.sx\//i.test(url); +} + +function rot13(s: string): string { + let out = ''; + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + if (c >= 0x41 && c <= 0x5a) out += String.fromCharCode(((c - 0x41 + 13) % 26) + 0x41); + else if (c >= 0x61 && c <= 0x7a) out += String.fromCharCode(((c - 0x61 + 13) % 26) + 0x61); + else out += s[i]; + } + return out; +} + +/** Standard base64 → bytes (self-contained, bez atob/Buffer). */ +function b64ToBytes(b64: string): Uint8Array { + const lookup = new Int16Array(128).fill(-1); + for (let i = 0; i < _B64.length; i++) lookup[_B64.charCodeAt(i)] = i; + const clean = b64.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) continue; // pomiń whitespace/nieznane + acc = (acc << 6) | v; + bits += 6; + if (bits >= 8) { + bits -= 8; + out[oi++] = (acc >>> bits) & 0xff; + } + } + return out.subarray(0, oi); +} + +/** bytes → UTF-8 string (minimalny dekoder, bez TextDecoder). */ +function utf8Decode(bytes: Uint8Array): string { + let out = ''; + let i = 0; + while (i < bytes.length) { + const b = bytes[i++]; + if (b < 0x80) out += String.fromCharCode(b); + else if (b >= 0xc0 && b < 0xe0) out += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i++] & 0x3f)); + else if (b >= 0xe0 && b < 0xf0) + out += String.fromCharCode(((b & 0x0f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f)); + else { + const cp = ((b & 0x07) << 18) | ((bytes[i++] & 0x3f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f); + const c = cp - 0x10000; + out += String.fromCharCode(0xd800 + (c >> 10), 0xdc00 + (c & 0x3ff)); + } + } + return out; +} + +/** 7-stopniowy dekoder payloadu (port _decode_payload z voe.py). */ +function decodePayload(payload: string): any | null { + try { + let s = rot13(payload); + for (const sep of _MAGIC_SEPS) s = s.split(sep).join('_'); + s = s.split('_').join(''); + const bytes1 = b64ToBytes(s); // 1st atob (latin-1) + let shifted = ''; + for (let i = 0; i < bytes1.length; i++) shifted += String.fromCharCode(bytes1[i] - 3); + const reversed = shifted.split('').reverse().join(''); + const bytes2 = b64ToBytes(reversed); // 2nd atob + return JSON.parse(utf8Decode(bytes2)); + } catch { + return null; + } +} + +/** + * Pobiera voe.sx embed NA TELEFONIE → m3u8 (+ mp4 fallback) StreamLink-i (phone-IP-bound, + * grają direct). Zwraca [] gdy brak/dead (caller spada na backend). Mirror voe.py. + */ +export async function resolveVoePage(embedUrl: string): Promise { + const referer = 'https://voe.sx/'; + let html: string; + try { + const r = await fetch(embedUrl, { headers: { 'User-Agent': UA, Accept: 'text/html' } }); + if (!r.ok) return []; + html = await r.text(); + } catch { + return []; + } + // Stage 1: JS redirect do losowego mirroru. + if (html.includes('window.location.href')) { + const m = _REDIRECT_RE.exec(html); + if (!m) return []; + try { + const r2 = await fetch(m[1], { headers: { 'User-Agent': UA, Accept: 'text/html' } }); + if (!r2.ok) return []; + html = await r2.text(); + } catch { + return []; + } + } + // Stage 2: extract + decode JSON payload. + const pm = _PAYLOAD_RE.exec(html); + if (!pm) return []; + let payload: string | null = null; + try { + const arr = JSON.parse(pm[1]); + payload = Array.isArray(arr) && arr.length ? arr[0] : null; + } catch { + return []; + } + if (typeof payload !== 'string') return []; + const config = decodePayload(payload); + if (!config) return []; + + const links: StreamLink[] = []; + const source = (config.source || '').trim(); + if (source) { + links.push({ + stream_url: source, + direct_url: source, + headers: { Referer: referer, 'User-Agent': UA }, + quality: 'auto', + type: 'm3u8', + }); + } + let fallback = config.fallback || []; + if (fallback && !Array.isArray(fallback)) fallback = [fallback]; + for (const fb of fallback) { + if (fb && typeof fb === 'object' && fb.file) { + links.push({ + stream_url: fb.file, + direct_url: fb.file, + headers: { Referer: referer, 'User-Agent': UA }, + quality: fb.label || 'auto', + type: 'mp4', + }); + } + } + return links; +} diff --git a/mobile/src/screens/MovieDetailScreen.tsx b/mobile/src/screens/MovieDetailScreen.tsx index 3e1c9fe..02eecd7 100644 --- a/mobile/src/screens/MovieDetailScreen.tsx +++ b/mobile/src/screens/MovieDetailScreen.tsx @@ -18,6 +18,7 @@ import { useClient } from '../ClientContext'; import type { RootStackParamList } from '../navigation'; import { theme } from '../theme'; import type { PlaybackSource, StreamLink } from '../types'; +import { resolveVoePage } from '../lib/voeResolver'; import { PlaybackQualityModal } from './PlaybackQualityModal'; export function MovieDetailScreen() { @@ -211,6 +212,38 @@ function WatchChip({ [navigation, pb, movieId, title], ); + // VOE: token CDN bound do /16 IP które pobrało embed (z VPS = i=46.62 → telefon 403 + // → pełny proxy → wideo przez Hetzner, 131 hitów/48h). Telefon sam pobiera embed → + // token bound do jego /16 → gra direct, zero VPS. [] → spadnij na backend resolve. + const [voeResolving, setVoeResolving] = React.useState(false); + const playVoe = React.useCallback(async () => { + setVoeResolving(true); + try { + const links = await resolveVoePage(pb.page_url || pb.embed_url || ''); + const best = links[0]; + if (best && (best.direct_url || best.stream_url)) { + navigation.navigate('Player', { + url: best.direct_url || best.stream_url!, + sceneId: movieId, + playbackId: pb.id, + entityKind: 'movie', + durationSec: pb.duration_sec ?? null, + title, + headers: best.headers ?? undefined, + // Zero VPS: bez fallbackProxyUrl (proxy = IP-bound do VPS, bez sensu). Na błąd + // → WebView z embed page (voe player sam pobierze m3u8 w swoim kontekście). + fallbackEmbedUrl: pb.page_url || pb.embed_url || undefined, + }); + return; + } + } catch { + // ignore → backend fallback + } finally { + setVoeResolving(false); + } + resolveMutation.mutate(); + }, [pb, movieId, title, navigation]); + const resolveMutation = useMutation({ mutationFn: () => client.resolveMoviePlayback(movieId, pb.id), onSuccess: (res) => { @@ -301,11 +334,11 @@ function WatchChip({ return ( <> resolveMutation.mutate()} + style={[styles.watchChip, (resolveMutation.isPending || voeResolving) && styles.watchChipLoading]} + onPress={() => (pb.origin.endsWith(':voe') ? playVoe() : resolveMutation.mutate())} onLongPress={onLongPress} delayLongPress={500} - disabled={resolveMutation.isPending} + disabled={resolveMutation.isPending || voeResolving} > ▶ {pb.origin} diff --git a/mobile/src/screens/SceneDetailScreen.tsx b/mobile/src/screens/SceneDetailScreen.tsx index 747cde7..fb4a0d3 100644 --- a/mobile/src/screens/SceneDetailScreen.tsx +++ b/mobile/src/screens/SceneDetailScreen.tsx @@ -22,6 +22,8 @@ import { LinearGradient } from 'expo-linear-gradient'; import { useClient } from '../ClientContext'; import { isGetFileUrl, resolveGetFilePage } from '../lib/getfileResolver'; import { resolvePornxpPage } from '../lib/pornxpResolver'; +import { resolveSxyprnPage } from '../lib/sxyprnResolver'; +import { resolveEpornerPage } from '../lib/epornerResolver'; import type { RootStackParamList } from '../navigation'; import { theme } from '../theme'; import type { PlaybackSource, SceneOut, StreamLink } from '../types'; @@ -540,6 +542,35 @@ function PlaybackButton({ } } + // sxyprn / eporner: CDN token IP-bound do tego KTO POBRAŁ STRONĘ (audit 2026-06-11). + // Backend resolvuje z VPS → telefon dostaje URL bound do IP VPS → direct daje 403 / + // 10B placeholder → fallback na proxy → CAŁE wideo przez Hetzner. Telefon sam pobiera + // stronę (phone IP) → token bound do telefonu → gra direct, zero VPS. [] → backend niżej. + const phoneResolver = + source.origin === 'tube:sxyprncom' + ? resolveSxyprnPage + : source.origin === 'tube:epornercom' + ? resolveEpornerPage + : null; + if (phoneResolver) { + setResolving(true); + try { + const links = await phoneResolver(source.page_url); + if (links.length > 0) { + markStarted(); + if (links.length === 1) await openAsVideo(links[0], source.page_url); + else setQualityLinks(links); + return; + } + // pusto → spadnij na backend resolve poniżej (HosterDead → mark dead dla + // skasowanego sxyprn posta; backend i tak ma proxy fallback). + } catch { + // ignore → backend fallback + } finally { + setResolving(false); + } + } + // Tube origin: backend resolve → lista linków: część direct video (stream_url), // część hoster embed (embed_url, np. StreamWish/doodporn HTML page). // - direct → MX Player (intent video/*)