diff --git a/alembic/versions/20260705_0027_playback_event_error_detail.py b/alembic/versions/20260705_0027_playback_event_error_detail.py new file mode 100644 index 0000000..de2bfeb --- /dev/null +++ b/alembic/versions/20260705_0027_playback_event_error_detail.py @@ -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 ją +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") diff --git a/app/api/playback_events.py b/app/api/playback_events.py index cdaf2a5..a71184e 100644 --- a/app/api/playback_events.py +++ b/app/api/playback_events.py @@ -27,6 +27,9 @@ class PlaybackEventIn(BaseModel): status: Literal["success", "error"] scene_id: uuid.UUID | None = None 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) @@ -42,6 +45,7 @@ def post_playback_event( scene_id=body.scene_id, status=body.status, 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, device_id=device_id, ) diff --git a/app/models/playback_event.py b/app/models/playback_event.py index fd06a1a..683facd 100644 --- a/app/models/playback_event.py +++ b/app/models/playback_event.py @@ -37,6 +37,9 @@ class PlaybackEvent(Base): status: Mapped[str] = mapped_column(String(16), nullable=False) # np. 'no_source', 'resolve_failed', 'player_error', 'timeout' (tylko gdy error). 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). ttff_ms: Mapped[int | None] = mapped_column(Integer, nullable=True) device_id: Mapped[str | None] = mapped_column(String(64), nullable=True) diff --git a/mobile/App.tsx b/mobile/App.tsx index 20c67a0..7745614 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -176,10 +176,10 @@ export default function App() { // 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 - // Recents/screenshotów (user feedback). Bundle OTA jest wspólny, więc to dotyczy - // też emulatora — na czas debugu (adb screencap) flipnij tymczasowo na false - // lokalnie i NIE publikuj tej zmiany. - const SCREEN_CAPTURE_PROTECTION = true; + // Recents/screenshotów (user feedback). Gate na !__DEV__: RELEASE (userzy, też po OTA) + // ma ochronę, DEBUG (expo run:android na emulatorze) jej NIE ma → adb screencap działa + // do wizualnego debugu. Bezpieczne do publikacji (release nadal secure). + const SCREEN_CAPTURE_PROTECTION = !__DEV__; useEffect(() => { if (!SCREEN_CAPTURE_PROTECTION) return; ScreenCapture.preventScreenCaptureAsync('goon-applock').catch(() => {}); diff --git a/mobile/src/api.ts b/mobile/src/api.ts index f0a4580..dbdf576 100644 --- a/mobile/src/api.ts +++ b/mobile/src/api.ts @@ -295,6 +295,7 @@ export class GoonClient { status: 'success' | 'error'; scene_id?: string; error_kind?: string; + error_detail?: string; ttff_ms?: number; }): Promise { try { diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index 39d190e..11ef9c5 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -20,7 +20,7 @@ export const CHANGELOG: ChangelogEntry[] = [ id: '2026-07-05', date: 'July 2026', 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".', ], }, { diff --git a/mobile/src/screens/PlayerScreen.tsx b/mobile/src/screens/PlayerScreen.tsx index 09106e8..19132a4 100644 --- a/mobile/src/screens/PlayerScreen.tsx +++ b/mobile/src/screens/PlayerScreen.tsx @@ -213,6 +213,17 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) { // tubów BEZ resolvePageUrl (czyli zero wpływu na resztę). const didReResolveRef = React.useRef(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 // 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, @@ -233,21 +244,29 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) { // ustawia reResolveDone → odblokowuje łańcuch fallback gdy nie pomogło. React.useEffect(() => { if (status !== 'error' || loadedOnceRef.current) return; - if (didReResolveRef.current || !resolvePageUrl || !playOrigin) return; - if (isGoneError(playerError?.message)) return; // skasowany post → niech łańcuch oznaczy dead + if (didReResolveRef.current || !playOrigin) return; + 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; let cancelled = false; (async () => { try { let links: StreamLink[] = []; - if (playOrigin === 'tube:sxyprncom') { + if (resolvePageUrl && playOrigin === 'tube:sxyprncom') { 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); - } else if (playOrigin === 'tube:fpoxxx') { + } else if (resolvePageUrl && playOrigin === 'tube:fpoxxx') { 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; if (!cancelled && freshUrl && freshUrl !== url) { 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 } } catch { - // ignore → łańcuch fallback przejmie + // ignore → łańcuch fallback przejmie (proxy/embed/error) } finally { if (!cancelled) setReResolveDone(true); } @@ -263,14 +282,16 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) { return () => { 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 - // błędu (patrz warunki efektu wyżej). Gone / post-load error re-resolve się NIE uruchomi, - // więc ani łańcuch fallback (gate niżej), ani spinner recoveryPending nie mogą na niego - // czekać, inaczej deadlock: wieczny „Reconnecting", zero „Mark broken" (review 0/1/3). + // Re-resolve jest STOSOWALNY tylko na initial-load. Musi ODZWIERCIEDLAĆ dokładnie + // warunki efektu wyżej, inaczej gate łańcucha fallback (i spinner recoveryPending) + // czeka na re-resolve który nigdy nie ruszy → deadlock: wieczny „Reconnecting", zero + // „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 = - !!resolvePageUrl && !loadedOnceRef.current && !isGoneError(playerError?.message); + !loadedOnceRef.current && + ((!!resolvePageUrl && !isGoneError(playerError?.message)) || canBackendReresolve); React.useEffect(() => { if (status !== 'error') return; @@ -371,6 +392,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) { status: 'error', scene_id: sceneId, 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]); diff --git a/mobile/src/screens/SceneDetailScreen.tsx b/mobile/src/screens/SceneDetailScreen.tsx index 8ae4ae9..61f7ec0 100644 --- a/mobile/src/screens/SceneDetailScreen.tsx +++ b/mobile/src/screens/SceneDetailScreen.tsx @@ -688,13 +688,12 @@ function PlaybackButton({ // 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 - // jest, niezależnie od kolejności w `links`. Gdy źródło jest natywne-mp4 bez embedu - // (KVS: watchporn/porn00 itd.) i mimo to native+proxy padną na telefonie (report - // watchporn: „resolver nie dziala, a scena dziala w diagnostyce"), spadamy na - // page_url w WebView — strona tube gra po stronie telefonu (residential IP, 0 VPS - // bandwidth), tak jak diagnostyczny browser. Native/proxy dalej sa probowane pierwsze. + // jest, niezależnie od kolejności w `links`. Źródła natywne-mp4 bez embedu (KVS: + // watchporn) NIE spadają na page_url w WebView — zamiast tego Player re-resolwuje + // scenę świeżo przez backend (świeży żywy nod CDN + token), zostając przy natywnym + // direct streamie (patrz BACKEND_RERESOLVE_ORIGINS w PlayerScreen). 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); if (autoPick) {