goon/mobile/src/lib/doodstream.ts
goon-foss eead9f159c i18n(mobile): polish UI strings → English
Tłumaczenie wszystkich user-facing stringów PL→EN (bug-report 2026-05-31
"dalej wszystko po polsku"). Alerty, przyciski, placeholdery, labelki w 12
ekranach/komponentach: BugReportFAB, AppLock(Screen/Settings/PinEntry),
applock biometric prompts, doodstream error msgs, MovieDetail, PlaybackQuality,
Player, SceneDetail, ScenesFilter, SiteScenes. Komentarze w kodzie zostają PL.

Zmiany były WIP drugiego okna (uncommitted); wjechały do bundla 0.2.1 przy
buildzie (były w working tree) — apka zainstalowana już ma EN. Ten commit
utrwala je w gicie żeby nie zginęły. Czysto stringi, zero zmian logiki.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 16:27:55 +02:00

206 lines
7.7 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;
}
export async function resolveDoodStream(
embedUrl: string,
sourceUrl?: string,
): Promise<ResolveResult> {
if (!isDoodStream(embedUrl)) return { error: 'not_doodstream' };
// /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' };
} 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,
},
};
}