feat(mobile): phone-side resolvers for IP-bound tubes (sxyprn, eporner, voe)
These CDNs bind their signed video URL to the IP that fetched the page, so a server-side resolve hands the phone a URL bound to the server IP -- the device then gets a placeholder/403 and falls back through the proxy, streaming the whole video through the server. Resolve on the device instead (token binds to the phone IP) so playback goes direct with zero proxy bandwidth. Ports of the existing backend extractors: - sxyprnResolver.ts: data-vnfo + boo/ssut51 transform - epornerResolver.ts: vid+hash -> /xhr/video mp4 sources - voeResolver.ts: mirror redirect + 7-step payload decoder Wired into SceneDetailScreen.onPress (sxyprn/eporner) and MovieDetailScreen.playVoe (voe). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
aa05ce2647
commit
16eb633bde
5 changed files with 421 additions and 3 deletions
88
mobile/src/lib/epornerResolver.ts
Normal file
88
mobile/src/lib/epornerResolver.ts
Normal file
|
|
@ -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 `<ts>_<ip>_<n>` 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<StreamLink[]> {
|
||||
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;
|
||||
}
|
||||
104
mobile/src/lib/sxyprnResolver.ts
Normal file
104
mobile/src/lib/sxyprnResolver.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* Mobile-side sxyprn.com resolver.
|
||||
*
|
||||
* Token wideo sxyprn jest bound do IP które POBRAŁO stronę /post/<id>.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 `<ss>-sxyprn.com-<es>` 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<StreamLink[]> {
|
||||
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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
162
mobile/src/lib/voeResolver.ts
Normal file
162
mobile/src/lib/voeResolver.ts
Normal file
|
|
@ -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 = /<script\s+type=["']application\/json["']>(\[[\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<StreamLink[]> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<>
|
||||
<Pressable
|
||||
style={[styles.watchChip, resolveMutation.isPending && styles.watchChipLoading]}
|
||||
onPress={() => 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}
|
||||
>
|
||||
<Text style={styles.watchChipText}>▶ {pb.origin}</Text>
|
||||
</Pressable>
|
||||
|
|
|
|||
|
|
@ -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/*)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue