fix(watchporn): re-resolve stale CDN URL in-player, drop WebView page fallback

The zload CDN URL (remote_control.php) is a valid faststart progressive mp4 that
ExoPlayer plays fine when fresh: 206, no IP/referer binding, ~2h token TTL,
verified from a residential IP. Real-device failures (player_error/gone
telemetry) come from the static URL going stale: a rotated or dead CDN node, or
an expired token.

Instead of falling back to the tube page in a WebView (removed, it papered over
the real issue), PlayerScreen now re-resolves the scene fresh via the backend on
initial-load error for backend-native KVS tubes (watchporn). That yields a live
node plus a fresh token and stays on the native direct stream (0 VPS bandwidth).
For these origins a CDN 'gone' (404) means stale URL, not deleted, so we
re-resolve on gone too; a genuinely deleted post raises HosterDead (410) and
falls through to the normal fallback chain.

Also: add playback_events.error_detail (raw ExoPlayer message) to pin down the
exact failure of tubes we cannot reproduce on the emulator. Keep FLAG_SECURE on
release builds only (!__DEV__) so debug builds stay screenshottable for local UI
verification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-07-05 01:18:08 +02:00
parent 3eb57dd61d
commit e215b48255
8 changed files with 86 additions and 24 deletions

View file

@ -0,0 +1,33 @@
"""playback_events.error_detail: raw native-player error message
Revision ID: 0027_playback_event_error_detail
Revises: 0026_scene_backfill_flag
Create Date: 2026-07-05
`error_kind` grupuje błędy odtwarzania zgrubnie ('player_error'/'gone'/...), ale do
diagnozy realnych padów niereprodukowalnych na emulatorze potrzebna jest surowa treść
błędu z native playera (ExoPlayer message). `error_detail` (ucięte do 512) zbiera
z urządzeń przy status='error'.
Idempotentne (IF NOT EXISTS): prod dostał kolumnę ręcznym ALTER-em zanim ta migracja
powstała, a deploy nie odpala alembic; guard chroni przed DuplicateColumn gdyby
`alembic upgrade` puszczono na prodzie albo na dumpie z prod.
"""
from collections.abc import Sequence
from alembic import op
revision: str = "0027_playback_event_error_detail"
down_revision: str | None = "0026_scene_backfill_flag"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"ALTER TABLE playback_events ADD COLUMN IF NOT EXISTS error_detail varchar(512)"
)
def downgrade() -> None:
op.execute("ALTER TABLE playback_events DROP COLUMN IF EXISTS error_detail")

View file

@ -27,6 +27,9 @@ class PlaybackEventIn(BaseModel):
status: Literal["success", "error"] status: Literal["success", "error"]
scene_id: uuid.UUID | None = None scene_id: uuid.UUID | None = None
error_kind: str | None = Field(default=None, max_length=64) error_kind: str | None = Field(default=None, max_length=64)
# Surowa treść błędu z native playera (ExoPlayer message) — diagnostyka realnych
# padów których nie da się odtworzyć na emulatorze (np. watchporn player_error).
error_detail: str | None = Field(default=None, max_length=512)
ttff_ms: int | None = Field(default=None, ge=0, le=600_000) ttff_ms: int | None = Field(default=None, ge=0, le=600_000)
@ -42,6 +45,7 @@ def post_playback_event(
scene_id=body.scene_id, scene_id=body.scene_id,
status=body.status, status=body.status,
error_kind=body.error_kind, error_kind=body.error_kind,
error_detail=body.error_detail if body.status == "error" else None,
ttff_ms=body.ttff_ms if body.status == "success" else None, ttff_ms=body.ttff_ms if body.status == "success" else None,
device_id=device_id, device_id=device_id,
) )

View file

@ -37,6 +37,9 @@ class PlaybackEvent(Base):
status: Mapped[str] = mapped_column(String(16), nullable=False) status: Mapped[str] = mapped_column(String(16), nullable=False)
# np. 'no_source', 'resolve_failed', 'player_error', 'timeout' (tylko gdy error). # np. 'no_source', 'resolve_failed', 'player_error', 'timeout' (tylko gdy error).
error_kind: Mapped[str | None] = mapped_column(String(64), nullable=True) error_kind: Mapped[str | None] = mapped_column(String(64), nullable=True)
# Surowa treść błędu z native playera (ExoPlayer message, ucięta) — dokładna
# diagnostyka realnych padów niereprodukowalnych na emulatorze.
error_detail: Mapped[str | None] = mapped_column(String(512), nullable=True)
# time-to-first-frame [ms] — "szybkość" (tylko przy success; None gdy nie zmierzono). # time-to-first-frame [ms] — "szybkość" (tylko przy success; None gdy nie zmierzono).
ttff_ms: Mapped[int | None] = mapped_column(Integer, nullable=True) ttff_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
device_id: Mapped[str | None] = mapped_column(String(64), nullable=True) device_id: Mapped[str | None] = mapped_column(String(64), nullable=True)

View file

@ -176,10 +176,10 @@ export default function App() {
// FLAG_SECURE — blocks screenshots and hides app preview from app switcher. // FLAG_SECURE — blocks screenshots and hides app preview from app switcher.
// WŁĄCZONE 2026-06-16: realni userzy (mobilism) — NSFW treść nie ma wyciekać do // WŁĄCZONE 2026-06-16: realni userzy (mobilism) — NSFW treść nie ma wyciekać do
// Recents/screenshotów (user feedback). Bundle OTA jest wspólny, więc to dotyczy // Recents/screenshotów (user feedback). Gate na !__DEV__: RELEASE (userzy, też po OTA)
// też emulatora — na czas debugu (adb screencap) flipnij tymczasowo na false // ma ochronę, DEBUG (expo run:android na emulatorze) jej NIE ma → adb screencap działa
// lokalnie i NIE publikuj tej zmiany. // do wizualnego debugu. Bezpieczne do publikacji (release nadal secure).
const SCREEN_CAPTURE_PROTECTION = true; const SCREEN_CAPTURE_PROTECTION = !__DEV__;
useEffect(() => { useEffect(() => {
if (!SCREEN_CAPTURE_PROTECTION) return; if (!SCREEN_CAPTURE_PROTECTION) return;
ScreenCapture.preventScreenCaptureAsync('goon-applock').catch(() => {}); ScreenCapture.preventScreenCaptureAsync('goon-applock').catch(() => {});

View file

@ -295,6 +295,7 @@ export class GoonClient {
status: 'success' | 'error'; status: 'success' | 'error';
scene_id?: string; scene_id?: string;
error_kind?: string; error_kind?: string;
error_detail?: string;
ttff_ms?: number; ttff_ms?: number;
}): Promise<void> { }): Promise<void> {
try { try {

View file

@ -20,7 +20,7 @@ export const CHANGELOG: ChangelogEntry[] = [
id: '2026-07-05', id: '2026-07-05',
date: 'July 2026', date: 'July 2026',
items: [ items: [
'If a video source cannot play in the built-in player, the app now falls back to the site page so it still plays (helps watchporn and similar).', 'watchporn: if a video link has gone stale (its host node rotated or the link expired), the app now automatically grabs a fresh link and keeps playing in the built-in player, no more "resolver broken".',
], ],
}, },
{ {

View file

@ -213,6 +213,17 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
// tubów BEZ resolvePageUrl (czyli zero wpływu na resztę). // tubów BEZ resolvePageUrl (czyli zero wpływu na resztę).
const didReResolveRef = React.useRef(false); const didReResolveRef = React.useRef(false);
const [reResolveDone, setReResolveDone] = React.useState(false); const [reResolveDone, setReResolveDone] = React.useState(false);
// Backend-native tuby (KVS: watchporn) resolwują się na VPS i dają statyczny direct
// CDN URL. Gdy ten URL zwietrzeje (nod CDN padł/rotował, albo token po TTL ~2h),
// ExoPlayer pada na initial-load — a stream jest OK (faststart mp4, gra z residential
// IP, zweryfikowane). Zamiast łatać WebView-em strony (leniwe, odrzucone), Player
// re-resolwuje scenę PONOWNIE przez backend → świeży żywy nod + świeży token, dalej
// natywny direct stream (0 VPS bandwidth). Dla tych tubów `gone` (404 z CDN) znaczy
// "URL zwietrzał", NIE "scena skasowana" (backend rzuciłby HosterDead→410), więc
// re-resolvujemy też na gone.
const BACKEND_RERESOLVE_ORIGINS = ['tube:watchporn'];
const canBackendReresolve =
!!sceneId && !!params.playbackId && BACKEND_RERESOLVE_ORIGINS.includes(playOrigin ?? '');
// Seek/decode recovery (bug f6c86847: doply/playmogo „invalid NAL length” przy // Seek/decode recovery (bug f6c86847: doply/playmogo „invalid NAL length” przy
// przewijaniu). Stream jest poprawny — faststart MP4, CDN wspiera Range 206 // przewijaniu). Stream jest poprawny — faststart MP4, CDN wspiera Range 206
// (zweryfikowane 2026-06-01 cross-IP) — więc to wewnętrzny błąd seeka ExoPlayera, // (zweryfikowane 2026-06-01 cross-IP) — więc to wewnętrzny błąd seeka ExoPlayera,
@ -233,21 +244,29 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
// ustawia reResolveDone → odblokowuje łańcuch fallback gdy nie pomogło. // ustawia reResolveDone → odblokowuje łańcuch fallback gdy nie pomogło.
React.useEffect(() => { React.useEffect(() => {
if (status !== 'error' || loadedOnceRef.current) return; if (status !== 'error' || loadedOnceRef.current) return;
if (didReResolveRef.current || !resolvePageUrl || !playOrigin) return; if (didReResolveRef.current || !playOrigin) return;
if (isGoneError(playerError?.message)) return; // skasowany post → niech łańcuch oznaczy dead const phoneSide = !!resolvePageUrl;
if (!phoneSide && !canBackendReresolve) return; // brak drogi re-resolve → łańcuch fallback
// Phone-side (sxyprn/eporner/fpoxxx): gone = skasowany post → nie re-resolve, łańcuch
// oznaczy dead. Backend-native (watchporn): gone = zwietrzały URL → re-resolve TAK.
if (phoneSide && isGoneError(playerError?.message)) return;
didReResolveRef.current = true; didReResolveRef.current = true;
let cancelled = false; let cancelled = false;
(async () => { (async () => {
try { try {
let links: StreamLink[] = []; let links: StreamLink[] = [];
if (playOrigin === 'tube:sxyprncom') { if (resolvePageUrl && playOrigin === 'tube:sxyprncom') {
links = await (await import('../lib/sxyprnResolver')).resolveSxyprnPage(resolvePageUrl); links = await (await import('../lib/sxyprnResolver')).resolveSxyprnPage(resolvePageUrl);
} else if (playOrigin === 'tube:epornercom') { } else if (resolvePageUrl && playOrigin === 'tube:epornercom') {
links = await (await import('../lib/epornerResolver')).resolveEpornerPage(resolvePageUrl); links = await (await import('../lib/epornerResolver')).resolveEpornerPage(resolvePageUrl);
} else if (playOrigin === 'tube:fpoxxx') { } else if (resolvePageUrl && playOrigin === 'tube:fpoxxx') {
links = await (await import('../lib/fpoxxxResolver')).resolveFpoxxxPage(resolvePageUrl); links = await (await import('../lib/fpoxxxResolver')).resolveFpoxxxPage(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);
links = (res.links || []).filter((l) => !!l.direct_url || !!l.stream_url);
} }
const fresh = links?.[0]; const fresh = links.find((l) => !!l.direct_url) || links[0];
const freshUrl = fresh?.direct_url || fresh?.stream_url; const freshUrl = fresh?.direct_url || fresh?.stream_url;
if (!cancelled && freshUrl && freshUrl !== url) { if (!cancelled && freshUrl && freshUrl !== url) {
player.replace(fresh?.headers ? { uri: freshUrl, headers: fresh.headers } : freshUrl); player.replace(fresh?.headers ? { uri: freshUrl, headers: fresh.headers } : freshUrl);
@ -255,7 +274,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
return; // sukces → status zmieni się z 'error', łańcuch fallback nie ruszy return; // sukces → status zmieni się z 'error', łańcuch fallback nie ruszy
} }
} catch { } catch {
// ignore → łańcuch fallback przejmie // ignore → łańcuch fallback przejmie (proxy/embed/error)
} finally { } finally {
if (!cancelled) setReResolveDone(true); if (!cancelled) setReResolveDone(true);
} }
@ -263,14 +282,16 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [status, resolvePageUrl, playOrigin, playerError, player, url]); }, [status, resolvePageUrl, canBackendReresolve, playOrigin, playerError, player, url, client, sceneId, params.playbackId]);
// Re-resolve (IP-bound tube) jest STOSOWALNY tylko na initial-load i tylko dla nie-gone // Re-resolve jest STOSOWALNY tylko na initial-load. Musi ODZWIERCIEDLAĆ dokładnie
// błędu (patrz warunki efektu wyżej). Gone / post-load error re-resolve się NIE uruchomi, // warunki efektu wyżej, inaczej gate łańcucha fallback (i spinner recoveryPending)
// więc ani łańcuch fallback (gate niżej), ani spinner recoveryPending nie mogą na niego // czeka na re-resolve który nigdy nie ruszy → deadlock: wieczny „Reconnecting", zero
// czekać, inaczej deadlock: wieczny „Reconnecting", zero „Mark broken" (review 0/1/3). // „Mark broken" (review 0/1/3). Phone-side (IP-bound): pomijamy gone (skasowany post).
// Backend-native (watchporn): re-resolvujemy też na gone (zwietrzały URL, nie kasacja).
const reResolveApplicable = const reResolveApplicable =
!!resolvePageUrl && !loadedOnceRef.current && !isGoneError(playerError?.message); !loadedOnceRef.current &&
((!!resolvePageUrl && !isGoneError(playerError?.message)) || canBackendReresolve);
React.useEffect(() => { React.useEffect(() => {
if (status !== 'error') return; if (status !== 'error') return;
@ -371,6 +392,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
status: 'error', status: 'error',
scene_id: sceneId, scene_id: sceneId,
error_kind: isGoneError(playerError?.message) ? 'gone' : 'player_error', error_kind: isGoneError(playerError?.message) ? 'gone' : 'player_error',
error_detail: playerError?.message ? String(playerError.message).slice(0, 512) : undefined,
}); });
}, [status, playOrigin, fallbackProxyUrl, fallbackEmbedUrl, url, playerError, client, sceneId]); }, [status, playOrigin, fallbackProxyUrl, fallbackEmbedUrl, url, playerError, client, sceneId]);

View file

@ -688,13 +688,12 @@ function PlaybackButton({
// Fallback embed do Playera — gdy ExoPlayer dostanie 403 (IP-bound CDN), apka // Fallback embed do Playera — gdy ExoPlayer dostanie 403 (IP-bound CDN), apka
// przełączy się na WebView z tym embed URL'em. Bierzemy pierwszy embed jaki // przełączy się na WebView z tym embed URL'em. Bierzemy pierwszy embed jaki
// jest, niezależnie od kolejności w `links`. Gdy źródło jest natywne-mp4 bez embedu // jest, niezależnie od kolejności w `links`. Źródła natywne-mp4 bez embedu (KVS:
// (KVS: watchporn/porn00 itd.) i mimo to native+proxy padną na telefonie (report // watchporn) NIE spadają na page_url w WebView — zamiast tego Player re-resolwuje
// watchporn: „resolver nie dziala, a scena dziala w diagnostyce"), spadamy na // scenę świeżo przez backend (świeży żywy nod CDN + token), zostając przy natywnym
// page_url w WebView — strona tube gra po stronie telefonu (residential IP, 0 VPS // direct streamie (patrz BACKEND_RERESOLVE_ORIGINS w PlayerScreen).
// bandwidth), tak jak diagnostyczny browser. Native/proxy dalej sa probowane pierwsze.
const fallbackEmbedUrl = const fallbackEmbedUrl =
embedLinks[0]?.embed_url || res.best?.embed_url || source.page_url || undefined; embedLinks[0]?.embed_url || res.best?.embed_url || undefined;
const autoPick = pickAuto(directLinks); const autoPick = pickAuto(directLinks);
if (autoPick) { if (autoPick) {