porndish scenes resolve only to playmogo.com embeds, which are DoodStream clones (doodcdn.io + pass_md5 + Cloudflare Turnstile). The mobile resolver already supported playmogo, but DoodStream is flaky from a single shot: the embed is sometimes Turnstile-gated (no pass_md5), and the pass_md5 endpoint intermittently returns the literal string "RELOAD" (stale/consumed token) instead of a base URL. The old code built "RELOAD<suffix>?token=..." -> ExoPlayer "no extractors" -> WebView -> loading forever (bug 62e78c9a). Wrap resolveDoodStream in a 3-attempt retry that re-fetches the embed (fresh token) on retryable failures (gate / RELOAD / empty / stale token), and reject a non-http pass_md5 body as retryable instead of building a garbage URL. Verified cross-IP that the pass_md5 -> base -> final flow yields 206 video/mp4 when not gated; real carrier IPs are gated far less than the test proxy. Strict improvement: worst case is the existing WebView fallback, best case native play. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
242 lines
9.4 KiB
TypeScript
242 lines
9.4 KiB
TypeScript
/**
|
||
* Mobile-side DoodStream resolver.
|
||
*
|
||
* Powód: VPS Hetzner IP dostaje Cloudflare Turnstile gate dla playmogo/dood
|
||
* embed pages (5KB challenge response). Mobile IP user'a (T-Mobile/PLAY/wifi)
|
||
* pewnie nie triggers CAPTCHA — embed page renderuje pełny HTML z player JS,
|
||
* w którym jest `pass_md5` token + `splash_error` key. To wystarczy do
|
||
* obliczenia direct mp4 URL.
|
||
*
|
||
* Algorithm (reverse-engineered z porn-app APK, com.streamdev.aiostreamer
|
||
* via jadx — class `defpackage/rs0.java`):
|
||
*
|
||
* 1. Replace /d/ → /e/ w URL (playmogo używa obu)
|
||
* 2. Fetch embed page z Chrome UA + Referer = source page
|
||
* 3. Regex w HTML:
|
||
* token = $.get('/pass_md5/<TOKEN>', ← pierwszy parametr
|
||
* key = $.get('/?op=splash_error', '<KEY>'); ← drugi parametr
|
||
* 4. Fetch https://<host>/pass_md5/<TOKEN> → response body = base URL
|
||
* 5. Final URL = base + key (od porn-app: append key to base)
|
||
* + ?token=<KEY> jak w starszym format też się próbuje
|
||
* 6. ExoPlayer plays z headers Referer = embed page
|
||
*
|
||
* Wspierane DoodStream variants (sync z mobile/src/lib/realdebrid.ts):
|
||
* playmogo, doodstream, dood.*, d0000d, dooood, do0od, do7go, ds2play
|
||
*
|
||
* Returns: { url, headers } gdy sukces, lub { error } gdy fail. Fail może
|
||
* znaczyć CAPTCHA gate dla user IP — wtedy fallback do WebView.
|
||
*/
|
||
|
||
const DOOD_HOSTS = new Set([
|
||
'playmogo.com',
|
||
// doply.net 301 → playmogo.com (verified 2026-05-15 — doodcdn.io scripts +
|
||
// pass_md5 endpoint w body). 205 mango movies origin='mangoporn:doply'.
|
||
'doply.net',
|
||
'doodstream.com', 'doodporn.com',
|
||
'dood.la', 'dood.li', 'dood.ws', 'dood.so', 'dood.to',
|
||
'dood.watch', 'dood.work', 'dood.yt', 'dood.re',
|
||
'ds2play.com', 'ds2video.com',
|
||
'd000d.com', 'd0o0d.com', 'do0od.com', 'do7go.com',
|
||
'dooood.com', 'd0000d.com',
|
||
]);
|
||
|
||
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 function isDoodStream(url: string): boolean {
|
||
if (!url) return false;
|
||
try {
|
||
const u = new URL(url);
|
||
const host = u.hostname.toLowerCase().replace(/^www\./, '');
|
||
if (DOOD_HOSTS.has(host)) return true;
|
||
for (const h of DOOD_HOSTS) {
|
||
if (host.endsWith('.' + h)) return true;
|
||
}
|
||
} catch {}
|
||
return false;
|
||
}
|
||
|
||
export interface ResolveResult {
|
||
url?: string;
|
||
headers?: Record<string, string>;
|
||
error?: string;
|
||
}
|
||
|
||
function randomString(n: number): string {
|
||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||
let out = '';
|
||
for (let i = 0; i < n; i++) out += chars[Math.floor(Math.random() * chars.length)];
|
||
return out;
|
||
}
|
||
|
||
// Błędy które warto powtórzyć (świeży fetch embeda = nowy token / inny CF routing).
|
||
// playmogo/dood są FLAKY: ten sam URL daje raz Turnstile-gate (brak pass_md5), raz
|
||
// pass_md5='RELOAD' (token zużyty/wygasł), raz pełny player. Zweryfikowane 2026-06-06
|
||
// cross-IP (Bright Data): 5 prób = 2× gate, 1× RELOAD, 1× zły token (200 text/html),
|
||
// 1× 206 video/mp4. Bez retry mobile trafiał na fail → WebView „loading w nieskończoność"
|
||
// (bug-report 62e78c9a porndish). Realne IP telefonu (carrier) gating'uje rzadziej niż
|
||
// Bright Data, więc 3 próby zwykle łapią sukces.
|
||
const _RETRYABLE = new Set([
|
||
'no_pass_md5_in_html',
|
||
'pass_md5_empty_response',
|
||
'pass_md5_reload',
|
||
'no_splash_error_or_token',
|
||
'captcha_gate (mobile IP also blocked?)',
|
||
]);
|
||
|
||
function _isRetryable(err?: string): boolean {
|
||
return !!err && _RETRYABLE.has(err);
|
||
}
|
||
|
||
export async function resolveDoodStream(
|
||
embedUrl: string,
|
||
sourceUrl?: string,
|
||
): Promise<ResolveResult> {
|
||
if (!isDoodStream(embedUrl)) return { error: 'not_doodstream' };
|
||
let last: ResolveResult = { error: 'no_attempt' };
|
||
for (let attempt = 0; attempt < 3; attempt++) {
|
||
last = await _resolveDoodStreamOnce(embedUrl, sourceUrl);
|
||
if (last.url) return last; // sukces
|
||
if (!_isRetryable(last.error)) return last; // permanentny błąd (deleted, http 4xx/5xx) — nie retry
|
||
// retryable (gate/RELOAD/stale token) → kolejna próba pobiera embed na nowo (nowy token)
|
||
}
|
||
return last;
|
||
}
|
||
|
||
async function _resolveDoodStreamOnce(
|
||
embedUrl: string,
|
||
sourceUrl?: string,
|
||
): Promise<ResolveResult> {
|
||
// /d/ (download page) → /e/ (embed player page). Embed ma JS z pass_md5,
|
||
// download page ma countdown + button. Porn-app rs0.java: linia 38.
|
||
const url = embedUrl.replace('/d/', '/e/');
|
||
const initialHost = new URL(url).hostname;
|
||
const referer = sourceUrl || `https://${initialHost}/`;
|
||
|
||
// Fetch embed page. WAŻNE: d0000d.com (i kilka innych dood mirrorów) robi
|
||
// server-side 30x redirect do playmogo.com gdy CF challenge solved — wtedy
|
||
// pass_md5 endpoint musi iść na host PO redirekcie (playmogo), nie na
|
||
// original (d0000d). Bug-report e9ba57d9 + 001a72c7 2026-05-16: scena
|
||
// "przekierowuje na stronę" bo resolver kierował pass_md5 na d0000d/
|
||
// post-redirect 404.
|
||
//
|
||
// RN/Hermes quirk: `Response.url` często jest pustym stringiem na Android
|
||
// po follow-redirects (bug w native networking stack). Manualnie chodzimy
|
||
// po Location headerach żeby host PO redirekcie był deterministyczny.
|
||
let html: string;
|
||
let host = initialHost;
|
||
let currentUrl = url;
|
||
try {
|
||
for (let hops = 0; hops < 5; hops++) {
|
||
const r = await fetch(currentUrl, {
|
||
method: 'GET',
|
||
headers: { 'User-Agent': UA, 'Referer': referer },
|
||
redirect: 'manual',
|
||
});
|
||
// 30x → resolve Location, kontynuuj. Inaczej traktuj jako final.
|
||
if (r.status >= 300 && r.status < 400) {
|
||
const loc = r.headers.get('location');
|
||
if (!loc) return { error: `redirect ${r.status} without Location` };
|
||
try {
|
||
currentUrl = new URL(loc, currentUrl).toString();
|
||
} catch {
|
||
return { error: `bad Location url: ${loc}` };
|
||
}
|
||
continue;
|
||
}
|
||
if (!r.ok) return { error: `embed http ${r.status}` };
|
||
html = await r.text();
|
||
// Final host = post-redirect URL. r.url też preferujemy jeśli niepuste
|
||
// (na iOS bywa wypełnione), inaczej parse currentUrl.
|
||
const finalUrl = r.url || currentUrl;
|
||
try {
|
||
host = new URL(finalUrl).hostname;
|
||
} catch {}
|
||
return await _continueAfterFetch(html, host, referer);
|
||
}
|
||
return { error: 'too many redirects (5)' };
|
||
} catch (e: any) {
|
||
return { error: `embed fetch fail: ${e?.message || e}` };
|
||
}
|
||
}
|
||
|
||
async function _continueAfterFetch(
|
||
html: string,
|
||
host: string,
|
||
referer: string,
|
||
): Promise<ResolveResult> {
|
||
|
||
if (html.toLowerCase().includes('video not found')) {
|
||
return { error: 'video_deleted' };
|
||
}
|
||
|
||
// pass_md5: '$.get('/pass_md5/<HASH>/<KEY>', ...)'. KEY = last path segment;
|
||
// identical do `?token=<KEY>` w makePlay/splash_error. 2026-05-15 playmogo
|
||
// przeszło z 2-arg splash_error (key jako drugi parametr) na 1-arg z token
|
||
// inside URL — trzymamy KEY z path zamiast osobnego regex matchu.
|
||
const passMatch = html.match(/\$\.get\(\s*['"]\/(pass_md5\/[^'"]+)['"]/);
|
||
if (!passMatch) {
|
||
// Brak pass_md5 = pełny captcha gate (5KB body z VPS IP), pusta strona, etc.
|
||
// Tu odsyłamy do WebView fallback. Sam `turnstile` w HTML nie wystarcza —
|
||
// pełna strona playera ZAWIERA opcjonalny turnstile container.
|
||
if (html.length < 2000 || /challenge-platform/i.test(html)) {
|
||
return { error: 'captcha_gate (mobile IP also blocked?)' };
|
||
}
|
||
return { error: 'no_pass_md5_in_html' };
|
||
}
|
||
|
||
// Extract KEY = last segment of pass_md5 path: "pass_md5/<HASH>/<KEY>"
|
||
const passPath = passMatch[1];
|
||
const keyFromPath = passPath.split('/').pop();
|
||
if (keyFromPath) {
|
||
return await fetchFinalUrl(host, passPath, keyFromPath, referer);
|
||
}
|
||
|
||
// Legacy fallback: 2-arg splash_error (stary format, jeszcze może być na dood mirrorach)
|
||
const splashMatch = html.match(/\$\.get\(\s*['"]\/\?op=splash_error[^'"]*['"]\s*,\s*['"]([^'"]+)['"]/);
|
||
if (splashMatch) {
|
||
return await fetchFinalUrl(host, passPath, splashMatch[1], referer);
|
||
}
|
||
const tokenMatch = html.match(/\?token=([^&"']+)/);
|
||
if (!tokenMatch) return { error: 'no_splash_error_or_token' };
|
||
return await fetchFinalUrl(host, passPath, tokenMatch[1], referer);
|
||
}
|
||
|
||
async function fetchFinalUrl(
|
||
host: string,
|
||
passMd5Path: string,
|
||
key: string,
|
||
referer: string,
|
||
): Promise<ResolveResult> {
|
||
const passUrl = `https://${host}/${passMd5Path}`;
|
||
|
||
let baseUrl: string;
|
||
try {
|
||
const r = await fetch(passUrl, {
|
||
method: 'GET',
|
||
headers: { 'User-Agent': UA, 'Referer': referer },
|
||
});
|
||
if (!r.ok) return { error: `pass_md5 http ${r.status}` };
|
||
baseUrl = (await r.text()).trim();
|
||
if (!baseUrl) return { error: 'pass_md5_empty_response' };
|
||
// DoodStream zwraca literalnie "RELOAD" (lub inny nie-URL) gdy token pass_md5
|
||
// jest zużyty/wygasł — trzeba przeładować embed po świeży token. Bez tej walidacji
|
||
// budowaliśmy `RELOAD<suffix>?token=...` → ExoPlayer „no extractors" → WebView.
|
||
if (!/^https?:\/\//i.test(baseUrl)) return { error: 'pass_md5_reload' };
|
||
} catch (e: any) {
|
||
return { error: `pass_md5 fetch fail: ${e?.message || e}` };
|
||
}
|
||
|
||
// Final URL construction. Porn-app format (z rs0.java): base + randomChars + ?token=<key>&expiry=<timestamp>
|
||
// (animdl używa randomChars per dood spec; key idzie jako token query param).
|
||
const randomSuffix = randomString(10);
|
||
const expiry = Date.now();
|
||
const finalUrl = `${baseUrl}${randomSuffix}?token=${key}&expiry=${expiry}`;
|
||
|
||
return {
|
||
url: finalUrl,
|
||
headers: {
|
||
'User-Agent': UA,
|
||
'Referer': referer,
|
||
},
|
||
};
|
||
}
|