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:
parent
585e5d59f5
commit
813bf741b9
3 changed files with 64 additions and 2 deletions
|
|
@ -16,6 +16,13 @@ export type ChangelogEntry = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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',
|
id: '2026-06-22b',
|
||||||
date: 'June 2026',
|
date: 'June 2026',
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import { WebView, type WebViewMessageEvent } from 'react-native-webview';
|
||||||
import { useClient } from '../ClientContext';
|
import { useClient } from '../ClientContext';
|
||||||
import type { RootStackParamList } from '../navigation';
|
import type { RootStackParamList } from '../navigation';
|
||||||
import { theme } from '../theme';
|
import { theme } from '../theme';
|
||||||
|
import type { StreamLink } from '../types';
|
||||||
|
|
||||||
interface RouteParams {
|
interface RouteParams {
|
||||||
url: string;
|
url: string;
|
||||||
|
|
@ -145,7 +146,7 @@ export function PlayerScreen() {
|
||||||
function NativeVideoPlayer({ params }: { params: RouteParams }) {
|
function NativeVideoPlayer({ params }: { params: RouteParams }) {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const nav = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Player'>>();
|
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);
|
const { markBroken, canMark, busy: markBusy } = useMarkSourceBroken(params);
|
||||||
// 'movie' → /movies/{id}/progress, 'scene' (default) → /scenes/{id}/progress.
|
// 'movie' → /movies/{id}/progress, 'scene' (default) → /scenes/{id}/progress.
|
||||||
const upsertProgress = React.useCallback(
|
const upsertProgress = React.useCallback(
|
||||||
|
|
@ -204,6 +205,14 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
|
||||||
// Każdy step ma osobną ref żeby nie loopować.
|
// Każdy step ma osobną ref żeby nie loopować.
|
||||||
const didFallbackProxyRef = React.useRef(false);
|
const didFallbackProxyRef = React.useRef(false);
|
||||||
const didFallbackWebViewRef = 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
|
// 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,
|
||||||
|
|
@ -217,6 +226,45 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (status === 'readyToPlay') loadedOnceRef.current = true;
|
if (status === 'readyToPlay') loadedOnceRef.current = true;
|
||||||
}, [status]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
if (status !== 'error') return;
|
if (status !== 'error') return;
|
||||||
// Step 0: post-load decode/seek error → recover in-place (przed proxy/WebView,
|
// Step 0: post-load decode/seek error → recover in-place (przed proxy/WebView,
|
||||||
|
|
@ -243,6 +291,9 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
|
||||||
}
|
}
|
||||||
return;
|
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.
|
// Step 1 → 2: direct fail (403/410/etc), spróbuj proxy URL.
|
||||||
if (fallbackProxyUrl && !didFallbackProxyRef.current && url !== fallbackProxyUrl) {
|
if (fallbackProxyUrl && !didFallbackProxyRef.current && url !== fallbackProxyUrl) {
|
||||||
didFallbackProxyRef.current = true;
|
didFallbackProxyRef.current = true;
|
||||||
|
|
@ -277,7 +328,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
|
||||||
mode: 'webview',
|
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
|
// Telemetria odtwarzania (ranking źródeł). Tylko native-player path (WebView mode
|
||||||
// ma osobny komponent, nie umiemy tam wykryć sukcesu → pomijamy, fair). Jeden ping
|
// ma osobny komponent, nie umiemy tam wykryć sukcesu → pomijamy, fair). Jeden ping
|
||||||
|
|
|
||||||
|
|
@ -548,10 +548,14 @@ function PlaybackButton({
|
||||||
if (resolved) initialUrl = resolved;
|
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', {
|
nav.navigate('Player', {
|
||||||
url: initialUrl,
|
url: initialUrl,
|
||||||
sceneId,
|
sceneId,
|
||||||
origin: source.origin,
|
origin: source.origin,
|
||||||
|
resolvePageUrl: PHONE_RESOLVE_ORIGINS.includes(source.origin) ? source.page_url : undefined,
|
||||||
playbackId: source.id,
|
playbackId: source.id,
|
||||||
durationSec: sceneDurationSec,
|
durationSec: sceneDurationSec,
|
||||||
refererHost,
|
refererHost,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue