mobile: let players identify & report 404/410 sources

When a host returns 404/410 at playback time (CDN gone, video removed) the
player previously showed only a raw error and a Back button — the user could
not tell it was a dead source or report it without going back to the detail
screen (bug a78cc3b6: "fpo i sxyprn to 404, którego apka nie potrafi
zidentyfikować").

- Thread playback_source.id into Player route params (scenes + movies).
- Native player error overlay: detect 404/410 in the ExoPlayer error, show
  "Source no longer available" and a "Mark broken" button that marks the
  source dead and returns. 403 is excluded (proxy/WebView fallback may save it).
- WebView player: add onHttpError; on a main-document 404/410 show the same
  overlay (Mark broken / Try anyway / Back) instead of the host's 404 page.
  Guarded to the loaded document (host+path) so same-host ad/subresource 404s
  don't false-trigger.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jtrzupek 2026-06-01 11:04:58 +02:00
parent 86c9bd438b
commit 967123d5d6
4 changed files with 123 additions and 7 deletions

View file

@ -57,6 +57,11 @@ export type RootStackParamList = {
// ma /movies/{id}/progress oddzielnie od /scenes/{id}/progress (2026-05-28).
// Default 'scene' dla back-compat z istniejącymi nav callami.
entityKind?: 'scene' | 'movie';
// playback_source.id — pozwala Playerowi oznaczyć źródło jako dead bez powrotu
// do SceneDetail (gdy CDN zwróci 404/410 przy odtwarzaniu — sxyprn/fpo „404
// którego apka nie potrafi zidentyfikować”, bug a78cc3b6). Undefined dla
// legacy/non-tube nav callów → przycisk „Mark broken” się nie pokazuje.
playbackId?: string;
durationSec?: number | null;
refererHost?: string;
title?: string;

View file

@ -208,6 +208,7 @@ function WatchChip({
navigation.navigate('Player', {
url: p.stream_url || p.embed_url || pb.page_url,
sceneId: movieId,
playbackId: pb.id,
entityKind: 'movie',
durationSec: pb.duration_sec ?? null,
title: `${title}${(p.raw as any).part_label ?? p.quality}`,
@ -231,6 +232,7 @@ function WatchChip({
// Bug-report b207ff17 2026-05-26 ("oznaczenie obejrzanych filmów") — backend
// dostał movie_play_progress 2026-05-28.
sceneId: movieId,
playbackId: pb.id,
entityKind: 'movie',
durationSec: pb.duration_sec ?? null,
title,

View file

@ -1,6 +1,7 @@
import type { RouteProp } from '@react-navigation/native';
import { useNavigation, useRoute } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useQueryClient } from '@tanstack/react-query';
import { useEvent } from 'expo';
import * as Haptics from 'expo-haptics';
import * as ScreenOrientation from 'expo-screen-orientation';
@ -31,6 +32,9 @@ interface RouteParams {
// progress zwracał 404 (silently caught). Po dodaniu /movies/{id}/progress
// (2026-05-28) mamy proper routing.
entityKind?: 'scene' | 'movie';
// playback_source.id — gdy podane, ekran błędu (native 404/410 lub WebView
// onHttpError) pokazuje „Mark broken” który oznacza źródło dead i wraca.
playbackId?: string;
durationSec?: number | null;
refererHost?: string;
title?: string;
@ -38,6 +42,48 @@ interface RouteParams {
fallbackEmbedUrl?: string;
}
// Wspólny helper dla native + WebView ekranu błędu: oznacza playback source jako
// dead (scene lub movie) i wraca do detalu. Bug a78cc3b6: gdy hoster zwraca 404
// przy odtwarzaniu (sxyprn .vid CDN gone, fpo video usunięte), user nie miał jak
// tego zidentyfikować/zgłosić z Playera — tylko „Back”. Teraz: jeden tap → dead.
function useMarkSourceBroken(params: RouteParams) {
const client = useClient();
const queryClient = useQueryClient();
const nav = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Player'>>();
const [busy, setBusy] = React.useState(false);
const canMark = !!params.playbackId;
const markBroken = React.useCallback(async () => {
if (!params.playbackId) {
nav.goBack();
return;
}
setBusy(true);
try {
if (params.entityKind === 'movie') {
await client.markMoviePlaybackDead(params.sceneId, params.playbackId);
queryClient.invalidateQueries({ queryKey: ['movie', params.sceneId] });
} else {
await client.markPlaybackDead(params.sceneId, params.playbackId);
queryClient.invalidateQueries({ queryKey: ['scene', params.sceneId] });
}
queryClient.invalidateQueries({ queryKey: ['scenes'] });
} catch {
// best-effort — i tak wracamy; źródło zostanie do następnej próby
} finally {
setBusy(false);
nav.goBack();
}
}, [client, queryClient, nav, params.playbackId, params.entityKind, params.sceneId]);
return { markBroken, canMark, busy };
}
// Czy komunikat błędu ExoPlayera wskazuje na trwale usunięte źródło (404/410)?
// 403 NIE liczy się (auth/IP-bound → fallback proxy/WebView może uratować).
function isGoneError(msg: string | undefined | null): boolean {
if (!msg) return false;
return /\b(404|410)\b|response code:\s*4(0[14])|not found|gone/i.test(msg);
}
const DEFAULT_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
@ -86,6 +132,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
const client = useClient();
const nav = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Player'>>();
const { url, sceneId, 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(
(body: { position_sec: number; duration_sec?: number; finished?: boolean }) =>
@ -604,13 +651,24 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
)}
{status === 'error' && !fallbackEmbedUrl && (
<View style={styles.overlay}>
<Text style={styles.errorTitle}>Playback failed</Text>
<Text style={styles.errorBody}>
{playerError?.message ?? 'The stream did not start.'}
<Text style={styles.errorTitle}>
{isGoneError(playerError?.message) ? 'Source no longer available' : 'Playback failed'}
</Text>
<Pressable style={styles.btn} onPress={() => nav.goBack()}>
<Text style={styles.btnText}>Back</Text>
</Pressable>
<Text style={styles.errorBody}>
{isGoneError(playerError?.message)
? 'The host returned 404/410 — this video was removed.'
: (playerError?.message ?? 'The stream did not start.')}
</Text>
<View style={styles.errorBtnRow}>
{canMark && (
<Pressable style={[styles.btn, styles.btnDanger]} onPress={markBroken} disabled={markBusy}>
<Text style={styles.btnText}>{markBusy ? 'Marking…' : 'Mark broken'}</Text>
</Pressable>
)}
<Pressable style={styles.btn} onPress={() => nav.goBack()}>
<Text style={styles.btnText}>Back</Text>
</Pressable>
</View>
</View>
)}
{status === 'error' && fallbackEmbedUrl && (
@ -1006,6 +1064,30 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
return () => clearTimeout(t);
}, [extractedUrl]);
const { markBroken, canMark, busy: markBusy } = useMarkSourceBroken(params);
// WebView main-document 404/410 → strona hostera usunięta (fpo/sxyprn „404
// którego apka nie potrafi zidentyfikować”, bug a78cc3b6). Pokazujemy overlay
// z opcją oznaczenia źródła jako broken zamiast surowej 404-strony hostera.
const [httpDead, setHttpDead] = React.useState<number | null>(null);
const onHttpError = React.useCallback(
(e: { nativeEvent?: { statusCode?: number; url?: string } }) => {
const ne = e?.nativeEvent;
const code = ne?.statusCode;
if (code !== 404 && code !== 410) return;
// Tylko główny dokument (ten sam host + ścieżka co załadowany URL) — nie
// false-trigger na 404 ad-subresource/thumbnail z tego samego hosta.
try {
const a = new URL(ne!.url || '');
const b = new URL(url);
if (a.hostname !== b.hostname || a.pathname !== b.pathname) return;
} catch {
if (ne?.url !== url) return;
}
setHttpDead(code);
},
[url],
);
// Stage 0.8: mobile-side hoster resolvery. Mobile IP usera unika Cloudflare
// Turnstile / CAPTCHA gate który blokuje Hetzner VPS — embed page renderuje
// pełny HTML z player config, z którego liczymy direct m3u8/mp4 URL.
@ -1207,6 +1289,7 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
injectedJavaScriptBeforeContentLoaded={INJECTED_JS}
onMessage={onMessage}
onShouldStartLoadWithRequest={onShouldStartLoad}
onHttpError={onHttpError}
setSupportMultipleWindows={false}
allowsInlineMediaPlayback
mediaPlaybackRequiresUserAction={false}
@ -1223,12 +1306,33 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
</View>
)}
/>
{!extractedUrl && !revealEmbed && (
{!extractedUrl && !revealEmbed && !httpDead && (
<View style={styles.coverOverlay}>
<ActivityIndicator color={theme.fg} size="large" />
<Text style={styles.overlayText}>Loading video</Text>
</View>
)}
{httpDead && (
<View style={[styles.coverOverlay, styles.overlay]}>
<Text style={styles.errorTitle}>Source no longer available</Text>
<Text style={styles.errorBody}>
The host returned {httpDead} this video was removed.
</Text>
<View style={styles.errorBtnRow}>
{canMark && (
<Pressable style={[styles.btn, styles.btnDanger]} onPress={markBroken} disabled={markBusy}>
<Text style={styles.btnText}>{markBusy ? 'Marking…' : 'Mark broken'}</Text>
</Pressable>
)}
<Pressable style={styles.btn} onPress={() => setHttpDead(null)}>
<Text style={styles.btnText}>Try anyway</Text>
</Pressable>
<Pressable style={styles.btn} onPress={() => nav.goBack()}>
<Text style={styles.btnText}>Back</Text>
</Pressable>
</View>
</View>
)}
{extractedUrl && (
<Pressable
style={styles.extractBanner}
@ -1288,6 +1392,8 @@ const styles = StyleSheet.create({
borderRadius: 10,
},
btnText: { color: theme.fg, fontWeight: '700' },
errorBtnRow: { flexDirection: 'row', gap: 12 },
btnDanger: { backgroundColor: theme.bad },
extractBanner: {
position: 'absolute',
bottom: 24,

View file

@ -479,6 +479,7 @@ function PlaybackButton({
nav.navigate('Player', {
url: initialUrl,
sceneId,
playbackId: source.id,
durationSec: sceneDurationSec,
refererHost,
title: source.origin,
@ -543,6 +544,7 @@ function PlaybackButton({
nav.navigate('Player', {
url: target,
sceneId,
playbackId: source.id,
durationSec: sceneDurationSec,
refererHost,
title: source.origin,
@ -618,6 +620,7 @@ function PlaybackButton({
nav.navigate('Player', {
url: link.embed_url,
sceneId,
playbackId: source.id,
durationSec: sceneDurationSec,
refererHost,
title: source.origin,