goon/mobile/src/screens/MovieDetailScreen.tsx
goon-foss 0f1f25393e feat(sources): 0-5★ ranking on Sites (freshness/metadata/plays) + playback telemetry
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>
2026-06-22 10:00:59 +02:00

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 },
});