goon/mobile/src/lib/doodstream.ts
goon-foss 3928301f04 fix(playback): retry DoodStream/playmogo resolve, handle "RELOAD" token response
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>
2026-06-06 21:14:26 +02:00

242 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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,
},
};
}