fix(watchporn): re-resolve stale CDN URL in-player, drop WebView page fallback
Some checks are pending
Backend tests / test (push) Waiting to run
Some checks are pending
Backend tests / test (push) Waiting to run
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:
parent
426b49fbe9
commit
ad43f4f20d
8 changed files with 86 additions and 24 deletions
|
|
@ -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")
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(() => {});
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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".',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue