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"]
|
||||
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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(() => {});
|
||||
|
|
|
|||
|
|
@ -295,6 +295,7 @@ export class GoonClient {
|
|||
status: 'success' | 'error';
|
||||
scene_id?: string;
|
||||
error_kind?: string;
|
||||
error_detail?: string;
|
||||
ttff_ms?: number;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -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".',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue