Rates each source on three axes the user asked for: - freshness: how recently/often new content arrives (newest age + 7d volume) - richness: metadata coverage (thumbnail/tags/performers/description/studio/duration) - plays: does it actually play — from real playback telemetry when available, else a proxy from the resolve mechanism. 0★ = offline (gates the overall stars, so a fresh+rich source that doesn't play still ranks bottom — the hqfap/4k69 case) Backend: - playback_events: fire-and-forget telemetry POST from the app per playback attempt (origin + success/error + time-to-first-frame), append-only, 30d retention - source_stats: per-origin computed scores, refreshed by a scheduler job (6h); /sources joins it and sorts by stars - models + local migration 0025; new GOON_SCHED_SOURCE_STATS_HOURS setting Mobile: - Sites rows show ★ rating; tap the stars for a breakdown (axes + metadata %, plus whether "plays" is measured or estimated) - PlayerScreen reports playback success/failure per source (native path only — symmetric, conservative); origin threaded through Scene/Movie play callsites Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
414 lines
15 KiB
TypeScript
414 lines
15 KiB
TypeScript
// Detail filmu — plakat, opis, cast, chaptery, mirrory.
|
|
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
|
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { Image } from 'expo-image';
|
|
import React from 'react';
|
|
import {
|
|
ActivityIndicator,
|
|
Alert,
|
|
Linking,
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
View,
|
|
} from 'react-native';
|
|
import { useClient } from '../ClientContext';
|
|
import type { RootStackParamList } from '../navigation';
|
|
import { theme } from '../theme';
|
|
import type { PlaybackSource, StreamLink } from '../types';
|
|
import { resolveVoePage } from '../lib/voeResolver';
|
|
import { PlaybackQualityModal } from './PlaybackQualityModal';
|
|
|
|
export function MovieDetailScreen() {
|
|
const client = useClient();
|
|
const queryClient = useQueryClient();
|
|
const route = useRoute<RouteProp<RootStackParamList, 'MovieDetail'>>();
|
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'MovieDetail'>>();
|
|
const { id } = route.params;
|
|
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: ['movie', id],
|
|
queryFn: () => client.getMovie(id),
|
|
});
|
|
|
|
const isFav = data?.is_favorite ?? false;
|
|
const favMutation = useMutation({
|
|
mutationFn: () =>
|
|
isFav ? client.removeFavoriteMovie(id) : client.addFavoriteMovie(id),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['movie', id] });
|
|
queryClient.invalidateQueries({ queryKey: ['favorites-movies'] });
|
|
},
|
|
});
|
|
|
|
React.useLayoutEffect(() => {
|
|
navigation.setOptions({
|
|
title: data?.title ?? 'Movie',
|
|
headerRight: () => (
|
|
<Pressable
|
|
onPress={() => favMutation.mutate()}
|
|
hitSlop={12}
|
|
disabled={favMutation.isPending}
|
|
>
|
|
<Text style={{ color: isFav ? theme.accent : theme.muted, fontSize: 22 }}>
|
|
{isFav ? '★' : '☆'}
|
|
</Text>
|
|
</Pressable>
|
|
),
|
|
});
|
|
}, [navigation, data?.title, isFav, favMutation]);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<View style={styles.center}>
|
|
<ActivityIndicator color={theme.fg} />
|
|
</View>
|
|
);
|
|
}
|
|
if (error instanceof Error) {
|
|
return (
|
|
<View style={styles.center}>
|
|
<Text style={styles.errorText}>{error.message}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
if (!data) return null;
|
|
|
|
const dur = data.duration_sec
|
|
? `${Math.floor(data.duration_sec / 3600) > 0 ? `${Math.floor(data.duration_sec / 3600)}h ` : ''}${Math.floor((data.duration_sec % 3600) / 60)}min`
|
|
: null;
|
|
|
|
return (
|
|
<ScrollView style={styles.container} contentContainerStyle={{ paddingBottom: 32 }}>
|
|
<View style={styles.heroRow}>
|
|
{data.poster_url ? (
|
|
<Image source={{ uri: data.poster_url }} style={styles.poster} contentFit="cover" />
|
|
) : (
|
|
<View style={[styles.poster, styles.posterPlaceholder]} />
|
|
)}
|
|
<View style={styles.heroMeta}>
|
|
<Text style={styles.title}>{data.title}</Text>
|
|
<Text style={styles.subtitle}>
|
|
{data.release_year ?? '—'}
|
|
{data.studio?.name ? ` · ${data.studio.name}` : ''}
|
|
{dur ? ` · ${dur}` : ''}
|
|
</Text>
|
|
{data.director ? <Text style={styles.subtitle}>dir. {data.director}</Text> : null}
|
|
{data.country ? <Text style={styles.subtitle}>{data.country}</Text> : null}
|
|
{data.rating != null ? (
|
|
<Text style={styles.rating}>★ {data.rating.toFixed(1)}/10</Text>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
|
|
{data.description ? (
|
|
<View style={styles.section}>
|
|
<Text style={styles.body}>{data.description}</Text>
|
|
</View>
|
|
) : null}
|
|
|
|
{data.performers.length > 0 ? (
|
|
<View style={styles.section}>
|
|
<Text style={styles.sectionTitle}>Cast</Text>
|
|
<View style={styles.chipRow}>
|
|
{data.performers.map((p) => (
|
|
<Pressable
|
|
key={p.id}
|
|
style={styles.chip}
|
|
onPress={() =>
|
|
navigation.navigate('PerformerScenes', { id: p.id, name: p.canonical_name })
|
|
}
|
|
>
|
|
<Text style={styles.chipText}>{p.canonical_name}</Text>
|
|
</Pressable>
|
|
))}
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
|
|
{data.tags.length > 0 ? (
|
|
<View style={styles.section}>
|
|
<Text style={styles.sectionTitle}>Genres</Text>
|
|
<View style={styles.chipRow}>
|
|
{data.tags.map((t) => (
|
|
<View key={t.id} style={styles.tagChip}>
|
|
<Text style={styles.chipText}>{t.name}</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
|
|
{data.chapters.length > 0 ? (
|
|
<View style={styles.section}>
|
|
<Text style={styles.sectionTitle}>Chapters ({data.chapters.length})</Text>
|
|
{data.chapters.map((c) => (
|
|
<View key={c.chapter_index} style={styles.chapterRow}>
|
|
<Text style={styles.chapterIndex}>{c.chapter_index + 1}.</Text>
|
|
<Text style={styles.chapterTitle}>{c.title ?? `Part ${c.chapter_index + 1}`}</Text>
|
|
{c.start_sec != null ? (
|
|
<Text style={styles.chapterTime}>{formatTime(c.start_sec)}</Text>
|
|
) : null}
|
|
</View>
|
|
))}
|
|
</View>
|
|
) : null}
|
|
|
|
{data.playback_sources.length > 0 ? (
|
|
<View style={styles.section}>
|
|
<Text style={styles.sectionTitle}>
|
|
Watch on ({data.playback_sources.length})
|
|
</Text>
|
|
<View style={styles.chipRow}>
|
|
{data.playback_sources.map((p) => (
|
|
<WatchChip key={p.id} pb={p} movieId={data.id} title={data.title} />
|
|
))}
|
|
</View>
|
|
<Text style={styles.chipHint}>long-press: open in browser / mark as broken</Text>
|
|
</View>
|
|
) : null}
|
|
</ScrollView>
|
|
);
|
|
}
|
|
|
|
function WatchChip({
|
|
pb,
|
|
movieId,
|
|
title,
|
|
}: {
|
|
pb: PlaybackSource;
|
|
movieId: string;
|
|
title: string;
|
|
}) {
|
|
const client = useClient();
|
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'MovieDetail'>>();
|
|
|
|
const queryClient = useQueryClient();
|
|
// Multipart picker — paradisehill movie z N częściami. Modal zamiast Alert.alert,
|
|
// bo Androidowy AlertDialog renderuje max 3 buttony (bug-report 2ebd0690: "35 parts,
|
|
// 3 w popup"). PlaybackQualityModal jest przewijalny i pokazuje wszystkie.
|
|
const [partsPicker, setPartsPicker] = React.useState<StreamLink[] | null>(null);
|
|
|
|
const playPart = React.useCallback(
|
|
(p: StreamLink) => {
|
|
setPartsPicker(null);
|
|
const pDirect = p.direct_url;
|
|
const pIsDirect = !!pDirect && pDirect !== p.stream_url;
|
|
navigation.navigate('Player', {
|
|
url: pDirect || p.stream_url || p.embed_url || pb.page_url,
|
|
origin: pb.origin,
|
|
sceneId: movieId,
|
|
playbackId: pb.id,
|
|
entityKind: 'movie',
|
|
durationSec: pb.duration_sec ?? null,
|
|
title: `${title} — ${(p.raw as any)?.part_label ?? p.quality}`,
|
|
mode: (p.direct_url || p.stream_url) ? 'video' : 'webview',
|
|
headers: pIsDirect && p.headers ? p.headers : undefined,
|
|
fallbackProxyUrl: pIsDirect ? p.stream_url || undefined : undefined,
|
|
fallbackEmbedUrl: p.embed_url || pb.embed_url || pb.page_url,
|
|
});
|
|
},
|
|
[navigation, pb, movieId, title],
|
|
);
|
|
|
|
// VOE: token CDN bound do /16 IP które pobrało embed (z VPS = i=46.62 → telefon 403
|
|
// → pełny proxy → wideo przez Hetzner, 131 hitów/48h). Telefon sam pobiera embed →
|
|
// token bound do jego /16 → gra direct, zero VPS. [] → spadnij na backend resolve.
|
|
const [voeResolving, setVoeResolving] = React.useState(false);
|
|
const playVoe = React.useCallback(async () => {
|
|
setVoeResolving(true);
|
|
try {
|
|
const links = await resolveVoePage(pb.page_url || pb.embed_url || '');
|
|
const best = links[0];
|
|
if (best && (best.direct_url || best.stream_url)) {
|
|
navigation.navigate('Player', {
|
|
url: best.direct_url || best.stream_url!,
|
|
origin: pb.origin,
|
|
sceneId: movieId,
|
|
playbackId: pb.id,
|
|
entityKind: 'movie',
|
|
durationSec: pb.duration_sec ?? null,
|
|
title,
|
|
headers: best.headers ?? undefined,
|
|
// Zero VPS: bez fallbackProxyUrl (proxy = IP-bound do VPS, bez sensu). Na błąd
|
|
// → WebView z embed page (voe player sam pobierze m3u8 w swoim kontekście).
|
|
fallbackEmbedUrl: pb.page_url || pb.embed_url || undefined,
|
|
});
|
|
return;
|
|
}
|
|
} catch {
|
|
// ignore → backend fallback
|
|
} finally {
|
|
setVoeResolving(false);
|
|
}
|
|
resolveMutation.mutate();
|
|
}, [pb, movieId, title, navigation]);
|
|
|
|
const resolveMutation = useMutation({
|
|
mutationFn: () => client.resolveMoviePlayback(movieId, pb.id),
|
|
onSuccess: (res) => {
|
|
// best.stream_url to backend proxy URL gdy direct video się udało wyciągnąć,
|
|
// lub embed_url gdy hoster nieudany — Player handler-uje obie ścieżki.
|
|
// _absolutizeProxyUrls w GoonClient już prefixuje /proxy/... baseUrl-em.
|
|
|
|
// Multipart: paradisehill movies mają `videoList` z N MP4 parts. Backend
|
|
// zwraca każdy jako StreamLink z `quality = "Part N"` + `raw.part_label`.
|
|
// Bez part-picker mobile używałby tylko best (Part 1) — user nie miałby
|
|
// dostępu do reszty (bug-reports `c5693926`/`418270e4` 2026-05-21).
|
|
const links = res.links ?? [];
|
|
const parts = links.filter((l) => l.raw && typeof l.raw === 'object' && (l.raw as any).part_label);
|
|
if (parts.length > 1) {
|
|
// Scrollowalny modal (nie Alert.alert — Android capuje do 3 buttonów).
|
|
setPartsPicker(parts);
|
|
return;
|
|
}
|
|
|
|
// Preferuj direct CDN URL (0 VPS bandwidth) → fallback proxy gdy direct fails.
|
|
// seekplayer-engine (#hash family, ~322k źródeł) zwraca master.m3u8 na raw-IP CDN
|
|
// z VALID ZeroSSL IP-SAN cert + time-bound token — zweryfikowane na emulatorze
|
|
// (ExoPlayer gra direct, PLAYING, zero VPS proxy). Wcześniej movie path szedł
|
|
// ZAWSZE przez proxy (używał stream_url jako primary). Mirror SceneDetailScreen.
|
|
const bestDirect = res.best?.direct_url;
|
|
const isDirect = !!bestDirect && bestDirect !== res.best?.stream_url;
|
|
const target = bestDirect || res.best?.stream_url || res.best?.embed_url || pb.page_url;
|
|
const fallbackEmbed = res.best?.embed_url || pb.embed_url || pb.page_url;
|
|
navigation.navigate('Player', {
|
|
url: target,
|
|
origin: pb.origin,
|
|
// sceneId pozostaje nazwą param-u (legacy z kiedy Player obsługiwał tylko sceny),
|
|
// ale dla entityKind='movie' Player rzutuje to do /movies/{id}/progress.
|
|
// 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,
|
|
mode: (res.best?.direct_url || res.best?.stream_url) ? 'video' : 'webview',
|
|
headers: isDirect && res.best?.headers ? res.best.headers : undefined,
|
|
fallbackProxyUrl: isDirect ? res.best?.stream_url || undefined : undefined,
|
|
fallbackEmbedUrl: fallbackEmbed,
|
|
});
|
|
},
|
|
onError: (e: any) => {
|
|
Alert.alert('Resolve failed', e?.message ?? 'unknown error');
|
|
},
|
|
});
|
|
|
|
const onLongPress = () => {
|
|
Alert.alert(
|
|
pb.origin,
|
|
'What do you want to do with this link?',
|
|
[
|
|
{
|
|
text: 'Open in browser (diagnostics)',
|
|
onPress: async () => {
|
|
try {
|
|
const url = pb.page_url || pb.embed_url;
|
|
if (url) {
|
|
await Linking.openURL(url);
|
|
} else {
|
|
Alert.alert('No URL', 'This playback has no page_url to open.');
|
|
}
|
|
} catch (e) {
|
|
Alert.alert('Could not open', e instanceof Error ? e.message : String(e));
|
|
}
|
|
},
|
|
},
|
|
{
|
|
text: 'Mark as invalid',
|
|
style: 'destructive',
|
|
onPress: async () => {
|
|
try {
|
|
await client.markMoviePlaybackDead(movieId, pb.id);
|
|
queryClient.invalidateQueries({ queryKey: ['movie', movieId] });
|
|
} catch (e) {
|
|
Alert.alert('Failed', e instanceof Error ? e.message : String(e));
|
|
}
|
|
},
|
|
},
|
|
{ text: 'Cancel', style: 'cancel' },
|
|
],
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Pressable
|
|
style={[styles.watchChip, (resolveMutation.isPending || voeResolving) && styles.watchChipLoading]}
|
|
onPress={() => (pb.origin.endsWith(':voe') ? playVoe() : resolveMutation.mutate())}
|
|
onLongPress={onLongPress}
|
|
delayLongPress={500}
|
|
disabled={resolveMutation.isPending || voeResolving}
|
|
>
|
|
<Text style={styles.watchChipText}>▶ {pb.origin}</Text>
|
|
</Pressable>
|
|
<PlaybackQualityModal
|
|
visible={!!partsPicker}
|
|
links={partsPicker ?? []}
|
|
title="Select part"
|
|
preserveOrder
|
|
onSelect={playPart}
|
|
onCancel={() => setPartsPicker(null)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function formatTime(s: number): string {
|
|
const total = Math.floor(s);
|
|
const m = Math.floor(total / 60);
|
|
const sec = total % 60;
|
|
return `${m}:${String(sec).padStart(2, '0')}`;
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: { flex: 1, backgroundColor: theme.bg },
|
|
center: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
|
errorText: { color: theme.bad },
|
|
|
|
heroRow: { flexDirection: 'row', padding: 12, gap: 12 },
|
|
poster: { width: 120, aspectRatio: 2 / 3, borderRadius: 8, backgroundColor: theme.card },
|
|
posterPlaceholder: { width: 120 },
|
|
heroMeta: { flex: 1, gap: 4 },
|
|
title: { color: theme.fg, fontSize: 18, fontWeight: '700' },
|
|
subtitle: { color: theme.muted, fontSize: 13 },
|
|
rating: { color: theme.accent, fontSize: 14, fontWeight: '700', marginTop: 4 },
|
|
|
|
section: { paddingHorizontal: 14, paddingVertical: 10 },
|
|
sectionTitle: { color: theme.fg, fontSize: 14, fontWeight: '700', marginBottom: 8 },
|
|
body: { color: theme.fg, fontSize: 13, lineHeight: 19 },
|
|
|
|
chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
|
|
chip: {
|
|
backgroundColor: theme.card,
|
|
borderColor: theme.border,
|
|
borderWidth: 1,
|
|
borderRadius: 14,
|
|
paddingHorizontal: 10,
|
|
paddingVertical: 5,
|
|
},
|
|
tagChip: {
|
|
backgroundColor: 'rgba(255,255,255,0.05)',
|
|
borderRadius: 12,
|
|
paddingHorizontal: 9,
|
|
paddingVertical: 4,
|
|
},
|
|
chipText: { color: theme.fg, fontSize: 12 },
|
|
watchChip: {
|
|
backgroundColor: theme.accent,
|
|
borderRadius: 14,
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 7,
|
|
},
|
|
watchChipLoading: { opacity: 0.5 },
|
|
watchChipText: { color: theme.fg, fontSize: 12, fontWeight: '700' },
|
|
chipHint: { color: theme.muted, fontSize: 11, marginTop: 8, fontStyle: 'italic' },
|
|
|
|
chapterRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 6, gap: 8 },
|
|
chapterIndex: { color: theme.muted, fontSize: 13, width: 28 },
|
|
chapterTitle: { color: theme.fg, fontSize: 14, flex: 1 },
|
|
chapterTime: { color: theme.muted, fontSize: 12 },
|
|
});
|