diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index 504bb16..67874e4 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -16,6 +16,13 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + id: '2026-07-14', + date: 'July 2026', + items: [ + 'hqfap: videos play reliably again. The site started blocking our server, so the app now resolves hqfap links directly on your device, fixing the cases that only showed ads or failed to start.', + ], + }, { id: '2026-07-11', date: 'July 2026', diff --git a/mobile/src/lib/hqfapResolver.ts b/mobile/src/lib/hqfapResolver.ts new file mode 100644 index 0000000..05dc938 --- /dev/null +++ b/mobile/src/lib/hqfapResolver.ts @@ -0,0 +1,52 @@ +/** + * Mobile-side hqfap.com resolver. + * + * hqfap.com Cloudflare blokuje IP VPS (Hetzner: timeout, 0 bajtów) ale strona odpowiada + * 200 z residential IP (audit 2026-07-14: local 200/0.7s, VPS + Bright Data proxy oba + * timeout). Backend resolve więc pada (None → 503) albo oddaje URL zbindowany do kontekstu + * VPS → player_error. Strumień (okcdn.ru / vstor.top / cdnde, direct mp4 z JSON-LD + * contentUrl) jest PORTABLE cross-IP (srcIp NIEegzekwowany, zweryfikowane 206 z residential). + * + * Fix: telefon sam pobiera stronę (residential IP) → JSON-LD contentUrl → gra direct, zero + * VPS. Mirror app/extractors/tubes/hqfap.py. Zwraca [] gdy brak (caller spada na backend). + */ +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 _CONTENT_URL_RE = /"contentUrl"\s*:\s*"([^"]+)"/; +const _QUALITY_RE = /_(\d{3,4})p\.mp4/i; + +/** True dla page_url hqfap (origin tube:hqfapcom). */ +export function isHqfapUrl(url: string | null | undefined): boolean { + return !!url && /(?:^|\/\/|\.)hqfap\.com\//i.test(url); +} + +export async function resolveHqfapPage(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 m = _CONTENT_URL_RE.exec(html); + let contentUrl = m ? m[1].trim() : ''; + contentUrl = contentUrl.replace(/\\\//g, '/'); // JSON-LD bywa z escaped slash + if (!contentUrl.startsWith('http')) return []; + // Stub guard: `/upload/videos/video_down.mp4` = stały "server down" placeholder + // (powód usunięcia strony 2026-06-25). Lepiej żadne źródło niż stub. + if (contentUrl.includes('/upload/videos/video_down.mp4')) return []; + const qm = _QUALITY_RE.exec(contentUrl); + return [ + { + stream_url: contentUrl, + direct_url: contentUrl, + headers: { Referer: 'https://hqfap.com/', 'User-Agent': UA }, + quality: qm ? `${qm[1]}p` : 'auto', + type: 'mp4', + }, + ]; +} diff --git a/mobile/src/screens/PlayerScreen.tsx b/mobile/src/screens/PlayerScreen.tsx index 70c82d6..0162340 100644 --- a/mobile/src/screens/PlayerScreen.tsx +++ b/mobile/src/screens/PlayerScreen.tsx @@ -261,6 +261,8 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) { links = await (await import('../lib/epornerResolver')).resolveEpornerPage(resolvePageUrl); } else if (resolvePageUrl && playOrigin === 'tube:fpoxxx') { links = await (await import('../lib/fpoxxxResolver')).resolveFpoxxxPage(resolvePageUrl); + } else if (resolvePageUrl && playOrigin === 'tube:hqfapcom') { + links = await (await import('../lib/hqfapResolver')).resolveHqfapPage(resolvePageUrl); } else if (canBackendReresolve && sceneId && params.playbackId) { // Świeży resolve przez backend (VPS re-runuje extractor KVS → nowy nod + token). const res = await client.resolvePlayback(sceneId, params.playbackId); diff --git a/mobile/src/screens/SceneDetailScreen.tsx b/mobile/src/screens/SceneDetailScreen.tsx index 3d632e0..6d36580 100644 --- a/mobile/src/screens/SceneDetailScreen.tsx +++ b/mobile/src/screens/SceneDetailScreen.tsx @@ -25,6 +25,7 @@ import { resolvePornxpPage } from '../lib/pornxpResolver'; import { resolveSxyprnPage } from '../lib/sxyprnResolver'; import { resolveEpornerPage } from '../lib/epornerResolver'; import { resolveFpoxxxPage } from '../lib/fpoxxxResolver'; +import { resolveHqfapPage } from '../lib/hqfapResolver'; import type { RootStackParamList } from '../navigation'; import { theme } from '../theme'; import type { PlaybackSource, SceneOut, StreamLink } from '../types'; @@ -570,7 +571,7 @@ function PlaybackButton({ // IP-bound tuby resolwowane phone-side: przekaż page_url, by Player mógł // re-resolve'ować świeży token gdy native padnie na initial-load (zmiana IP / TTL). - const PHONE_RESOLVE_ORIGINS = ['tube:sxyprncom', 'tube:epornercom', 'tube:fpoxxx']; + const PHONE_RESOLVE_ORIGINS = ['tube:sxyprncom', 'tube:epornercom', 'tube:fpoxxx', 'tube:hqfapcom']; nav.navigate('Player', { url: initialUrl, sceneId, @@ -613,7 +614,9 @@ function PlaybackButton({ ? resolveFpoxxxPage : source.origin === 'tube:pornxpph' ? resolvePornxpPage - : null; + : source.origin === 'tube:hqfapcom' + ? resolveHqfapPage + : null; if (phoneResolver) { setResolving(true); try {