fix(mobile): re-resolve IP-bound tubes on playback error (sxyprn/eporner/fpoxxx)

sxyprn's video token is bound to the IP that fetched the post page; on mobile the
phone resolver works ~74% but ~26% fail when the egress IP shifts (CGNAT / network
switch) or the token goes stale → native player hung on a dead URL (18 reports, 26%
error rate in telemetry). Now on an initial-load error for these phone-resolved
tubes, the player re-fetches the page fresh (new token bound to the current IP) and
swaps the source before falling through to the proxy/WebView chain. Zero VPS
bandwidth. Gated by resolvePageUrl so other tubes are completely unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jtrzupek 2026-06-25 11:11:21 +02:00
parent 585e5d59f5
commit 813bf741b9
3 changed files with 64 additions and 2 deletions

View file

@ -16,6 +16,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-06-25',
date: 'June 2026',
items: [
'sxyprn and a few other sites: if a video fails to start, the app now grabs a fresh link and retries automatically instead of just hanging.',
],
},
{
id: '2026-06-22b',
date: 'June 2026',

View file

@ -23,6 +23,7 @@ import { WebView, type WebViewMessageEvent } from 'react-native-webview';
import { useClient } from '../ClientContext';
import type { RootStackParamList } from '../navigation';
import { theme } from '../theme';
import type { StreamLink } from '../types';
interface RouteParams {
url: string;
@ -145,7 +146,7 @@ export function PlayerScreen() {
function NativeVideoPlayer({ params }: { params: RouteParams }) {
const client = useClient();
const nav = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Player'>>();
const { url, sceneId, origin: playOrigin, entityKind, durationSec, refererHost, title, fallbackEmbedUrl, headers: paramHeaders, fallbackProxyUrl } = params;
const { url, sceneId, origin: playOrigin, resolvePageUrl, entityKind, durationSec, refererHost, title, fallbackEmbedUrl, headers: paramHeaders, fallbackProxyUrl } = params;
const { markBroken, canMark, busy: markBusy } = useMarkSourceBroken(params);
// 'movie' → /movies/{id}/progress, 'scene' (default) → /scenes/{id}/progress.
const upsertProgress = React.useCallback(
@ -204,6 +205,14 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
// Każdy step ma osobną ref żeby nie loopować.
const didFallbackProxyRef = React.useRef(false);
const didFallbackWebViewRef = React.useRef(false);
// Re-resolve dla IP-bound tubów (sxyprn/eporner/fpoxxx): token jest bound do IP
// które pobrało stronę; jeśli IP się zmieniło (CGNAT/przełączenie sieci) albo token
// wygasł, native player pada na initial-load. Zamiast retry martwego URL-a pobieramy
// stronę ŚWIEŻO (bieżący IP) i podmieniamy źródło. Flaga `reResolveDone` gate'uje
// łańcuch fallback (proxy/WebView) póki re-resolve nie skończy — i jest no-op dla
// tubów BEZ resolvePageUrl (czyli zero wpływu na resztę).
const didReResolveRef = React.useRef(false);
const [reResolveDone, setReResolveDone] = React.useState(false);
// 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,
@ -217,6 +226,45 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
React.useEffect(() => {
if (status === 'readyToPlay') loadedOnceRef.current = true;
}, [status]);
// Re-resolve IP-bound tubów (sxyprn/eporner/fpoxxx) na initial-load error: pobierz
// stronę ŚWIEŻO z urządzenia (token bound do bieżącego IP) i podmień źródło. Tylko
// 1× / mount. No-op gdy brak resolvePageUrl. Po zakończeniu (sukces lub nie)
// 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
didReResolveRef.current = true;
let cancelled = false;
(async () => {
try {
let links: StreamLink[] = [];
if (playOrigin === 'tube:sxyprncom') {
links = await (await import('../lib/sxyprnResolver')).resolveSxyprnPage(resolvePageUrl);
} else if (playOrigin === 'tube:epornercom') {
links = await (await import('../lib/epornerResolver')).resolveEpornerPage(resolvePageUrl);
} else if (playOrigin === 'tube:fpoxxx') {
links = await (await import('../lib/fpoxxxResolver')).resolveFpoxxxPage(resolvePageUrl);
}
const fresh = 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);
player.play();
return; // sukces → status zmieni się z 'error', łańcuch fallback nie ruszy
}
} catch {
// ignore → łańcuch fallback przejmie
} finally {
if (!cancelled) setReResolveDone(true);
}
})();
return () => {
cancelled = true;
};
}, [status, resolvePageUrl, playOrigin, playerError, player, url]);
React.useEffect(() => {
if (status !== 'error') return;
// Step 0: post-load decode/seek error → recover in-place (przed proxy/WebView,
@ -243,6 +291,9 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
}
return;
}
// Gate: dla IP-bound tubów (resolvePageUrl) poczekaj aż re-resolve się zakończy
// zanim ruszysz proxy/WebView. No-op gdy brak resolvePageUrl (reszta tubów).
if (resolvePageUrl && !reResolveDone) return;
// Step 1 → 2: direct fail (403/410/etc), spróbuj proxy URL.
if (fallbackProxyUrl && !didFallbackProxyRef.current && url !== fallbackProxyUrl) {
didFallbackProxyRef.current = true;
@ -277,7 +328,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
mode: 'webview',
});
}
}, [status, fallbackProxyUrl, fallbackEmbedUrl, url, nav, sceneId, durationSec, refererHost, title, player, source, playerError]);
}, [status, fallbackProxyUrl, fallbackEmbedUrl, url, nav, sceneId, durationSec, refererHost, title, player, source, playerError, resolvePageUrl, reResolveDone, playOrigin]);
// Telemetria odtwarzania (ranking źródeł). Tylko native-player path (WebView mode
// ma osobny komponent, nie umiemy tam wykryć sukcesu → pomijamy, fair). Jeden ping

View file

@ -548,10 +548,14 @@ function PlaybackButton({
if (resolved) initialUrl = resolved;
}
// 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'];
nav.navigate('Player', {
url: initialUrl,
sceneId,
origin: source.origin,
resolvePageUrl: PHONE_RESOLVE_ORIGINS.includes(source.origin) ? source.page_url : undefined,
playbackId: source.id,
durationSec: sceneDurationSec,
refererHost,