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>
473 lines
15 KiB
TypeScript
473 lines
15 KiB
TypeScript
// Lista tube źródeł — top-level tab obok Scenes/Movies. Tap → SiteScenes.
|
|
// Bug-report 2026-05-24 (ea6f05f9): user chce wybierać "pages" obok Scenes
|
|
// i Movies, widzieć najnowsze sceny z konkretnego scrapowanego site'u.
|
|
//
|
|
// Layout: chip-grid analogiczny do TagsScreen — krótkie nazwy (domena.tld)
|
|
// plus scene_count + relative-time "Xh temu" scraped, jeśli świeży.
|
|
import { useNavigation } from '@react-navigation/native';
|
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import React, { useMemo, useState } from 'react';
|
|
import {
|
|
ActivityIndicator,
|
|
FlatList,
|
|
Modal,
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
TextInput,
|
|
View,
|
|
} from 'react-native';
|
|
import { useClient } from '../ClientContext';
|
|
import type { RootStackParamList } from '../navigation';
|
|
import { theme } from '../theme';
|
|
import type { SourceOut, SourceRating } from '../types';
|
|
|
|
type Order = 'popular' | 'recent';
|
|
|
|
export function SitesScreen() {
|
|
const client = useClient();
|
|
const navigation =
|
|
useNavigation<NativeStackNavigationProp<RootStackParamList, 'Sites'>>();
|
|
const [q, setQ] = useState('');
|
|
const [debouncedQ, setDebouncedQ] = useState('');
|
|
const [order, setOrder] = useState<Order>('popular');
|
|
const [searchFocused, setSearchFocused] = useState(false);
|
|
// Źródło którego rozkład oceny pokazujemy w modalu (tap w gwiazdki).
|
|
const [detail, setDetail] = useState<SourceOut | null>(null);
|
|
|
|
React.useEffect(() => {
|
|
const t = setTimeout(() => setDebouncedQ(q), 250);
|
|
return () => clearTimeout(t);
|
|
}, [q]);
|
|
|
|
const { data, isLoading, error, refetch, isRefetching } = useQuery({
|
|
queryKey: ['sources'],
|
|
queryFn: () => client.listSources(),
|
|
staleTime: 60_000,
|
|
});
|
|
|
|
// Sort + filter client-side — lista ma <50 entries, nie warto roundtripować.
|
|
// Backend zwraca pre-sorted po scene_count DESC, więc dla 'popular' kolejność
|
|
// zachowana. Dla 'recent' sortujemy po last_scraped_at DESC.
|
|
const items = useMemo<SourceOut[]>(() => {
|
|
const all = data?.items ?? [];
|
|
const filtered = debouncedQ
|
|
? all.filter(
|
|
(s) =>
|
|
s.display_name.toLowerCase().includes(debouncedQ.toLowerCase()) ||
|
|
s.sitetag.toLowerCase().includes(debouncedQ.toLowerCase()),
|
|
)
|
|
: all;
|
|
if (order === 'recent') {
|
|
return [...filtered].sort((a, b) => {
|
|
if (!a.last_scraped_at && !b.last_scraped_at) return 0;
|
|
if (!a.last_scraped_at) return 1;
|
|
if (!b.last_scraped_at) return -1;
|
|
return b.last_scraped_at.localeCompare(a.last_scraped_at);
|
|
});
|
|
}
|
|
return filtered;
|
|
}, [data?.items, debouncedQ, order]);
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<View style={styles.headerRow}>
|
|
<Text style={styles.headerLabel}>Sites</Text>
|
|
<Text style={styles.headerCount}>{items.length}</Text>
|
|
</View>
|
|
<Text style={styles.hint}>tap a tube → newest scenes · tap the ★ → rating breakdown</Text>
|
|
|
|
<View style={styles.toolbar}>
|
|
<TextInput
|
|
style={[styles.search, searchFocused && styles.searchFocused]}
|
|
value={q}
|
|
onChangeText={setQ}
|
|
onFocus={() => setSearchFocused(true)}
|
|
onBlur={() => setSearchFocused(false)}
|
|
placeholder="search site…"
|
|
placeholderTextColor={theme.mutedDim}
|
|
autoCapitalize="none"
|
|
/>
|
|
</View>
|
|
|
|
<View style={styles.segment}>
|
|
<SegButton
|
|
active={order === 'popular'}
|
|
onPress={() => setOrder('popular')}
|
|
label="Top"
|
|
/>
|
|
<SegButton
|
|
active={order === 'recent'}
|
|
onPress={() => setOrder('recent')}
|
|
label="Recent"
|
|
/>
|
|
</View>
|
|
|
|
{isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />}
|
|
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
|
|
|
|
<FlatList
|
|
data={items}
|
|
keyExtractor={(s) => s.origin}
|
|
numColumns={2}
|
|
columnWrapperStyle={styles.gridRow}
|
|
renderItem={({ item }) => (
|
|
<SiteChip
|
|
source={item}
|
|
onPress={() =>
|
|
navigation.navigate('SiteScenes', {
|
|
origin: item.origin,
|
|
name: prettySiteName(item.display_name),
|
|
})
|
|
}
|
|
onShowRating={() => setDetail(item)}
|
|
/>
|
|
)}
|
|
refreshing={isRefetching}
|
|
onRefresh={refetch}
|
|
ListEmptyComponent={
|
|
!isLoading ? <Text style={styles.emptyText}>no sites</Text> : null
|
|
}
|
|
contentContainerStyle={{ paddingBottom: 24 }}
|
|
/>
|
|
|
|
<RatingModal source={detail} onClose={() => setDetail(null)} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function SegButton({
|
|
active,
|
|
onPress,
|
|
label,
|
|
}: {
|
|
active: boolean;
|
|
onPress: () => void;
|
|
label: string;
|
|
}) {
|
|
return (
|
|
<Pressable
|
|
onPress={onPress}
|
|
style={[styles.segButton, active && styles.segButtonActive]}
|
|
>
|
|
<Text style={[styles.segButtonText, active && styles.segButtonTextActive]}>
|
|
{label}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
// Lista Sites pokazuje display_name = domena (hqporner.com). User-report 18105d14:
|
|
// pozbyć się suffixów com/org/itp. Strip końcowego TLD → czysta nazwa.
|
|
function prettySiteName(name: string): string {
|
|
return name.replace(/\.[a-z]{2,5}$/i, '').trim() || name;
|
|
}
|
|
|
|
function formatRelativeTime(iso: string | null): string | null {
|
|
if (!iso) return null;
|
|
const ts = Date.parse(iso);
|
|
if (Number.isNaN(ts)) return null;
|
|
const diffSec = (Date.now() - ts) / 1000;
|
|
if (diffSec < 60) return 'just now';
|
|
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
|
|
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
|
|
const days = Math.floor(diffSec / 86400);
|
|
if (days < 30) return `${days}d ago`;
|
|
return null;
|
|
}
|
|
|
|
// Rząd gwiazdek 0-5. 0 = offline (czerwony label zamiast gwiazdek). Brak oceny → null.
|
|
function Stars({ value, size = 13 }: { value: number | null | undefined; size?: number }) {
|
|
if (value == null) return <Text style={[styles.starsDim, { fontSize: size }]}>— not rated</Text>;
|
|
if (value <= 0) return <Text style={[styles.starsOffline, { fontSize: size }]}>● OFFLINE</Text>;
|
|
const full = '★'.repeat(value);
|
|
const empty = '☆'.repeat(5 - value);
|
|
return (
|
|
<Text style={[styles.starsRow, { fontSize: size }]}>
|
|
<Text style={styles.starsFull}>{full}</Text>
|
|
<Text style={styles.starsEmpty}>{empty}</Text>
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
function SiteChip({
|
|
source,
|
|
onPress,
|
|
onShowRating,
|
|
}: {
|
|
source: SourceOut;
|
|
onPress: () => void;
|
|
onShowRating: () => void;
|
|
}) {
|
|
const rel = formatRelativeTime(source.last_scraped_at);
|
|
const stars = source.rating?.stars;
|
|
return (
|
|
<Pressable
|
|
style={({ pressed }) => [styles.chip, pressed && styles.chipPressed]}
|
|
onPress={onPress}
|
|
>
|
|
<View style={styles.chipTopRow}>
|
|
<View style={styles.chipMain}>
|
|
<Text style={styles.chipName} numberOfLines={1}>
|
|
{prettySiteName(source.display_name)}
|
|
</Text>
|
|
{rel ? <Text style={styles.chipRel}>{rel}</Text> : null}
|
|
</View>
|
|
<View style={styles.chipCountWrap}>
|
|
<Text style={styles.chipCount}>{source.scene_count}</Text>
|
|
</View>
|
|
</View>
|
|
<Pressable
|
|
onPress={onShowRating}
|
|
hitSlop={8}
|
|
style={({ pressed }) => [styles.starsTap, pressed && { opacity: 0.6 }]}
|
|
>
|
|
<Stars value={stars} />
|
|
</Pressable>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function AxisBar({ label, value }: { label: string; value: number | null }) {
|
|
const v = value ?? 0;
|
|
const pct = Math.max(0, Math.min(100, (v / 5) * 100));
|
|
return (
|
|
<View style={styles.axisRow}>
|
|
<Text style={styles.axisLabel}>{label}</Text>
|
|
<View style={styles.axisTrack}>
|
|
<View style={[styles.axisFill, { width: `${pct}%` }]} />
|
|
</View>
|
|
<Text style={styles.axisVal}>{value == null ? '—' : `${value}/5`}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const _PCT_LABELS: Record<string, string> = {
|
|
thumb: 'Thumbnails',
|
|
tag: 'Tags',
|
|
perf: 'Performers',
|
|
desc: 'Descriptions',
|
|
studio: 'Studio',
|
|
dur: 'Duration',
|
|
};
|
|
|
|
function RatingModal({ source, onClose }: { source: SourceOut | null; onClose: () => void }) {
|
|
const r: SourceRating | null | undefined = source?.rating;
|
|
const pct = r?.components?.pct ?? {};
|
|
const basis = r?.health_basis ?? r?.components?.health_basis;
|
|
const attempts = r?.components?.pb_attempts_7d ?? 0;
|
|
const successRate = r?.components?.pb_success_rate;
|
|
return (
|
|
<Modal visible={!!source} transparent animationType="fade" onRequestClose={onClose}>
|
|
<Pressable style={styles.modalBackdrop} onPress={onClose}>
|
|
<Pressable style={styles.modalCard} onPress={() => {}}>
|
|
<ScrollView>
|
|
<Text style={styles.modalTitle}>{source ? prettySiteName(source.display_name) : ''}</Text>
|
|
{!r ? (
|
|
<Text style={styles.modalMuted}>Not rated yet — check back soon.</Text>
|
|
) : (
|
|
<>
|
|
<View style={styles.modalStarsBig}>
|
|
<Stars value={r.stars} size={22} />
|
|
</View>
|
|
<AxisBar label="Freshness" value={r.freshness} />
|
|
<AxisBar label="Metadata" value={r.richness} />
|
|
<AxisBar label="Plays" value={r.health} />
|
|
|
|
<Text style={styles.modalSection}>Metadata coverage</Text>
|
|
{Object.keys(_PCT_LABELS).map((k) => (
|
|
<View key={k} style={styles.pctRow}>
|
|
<Text style={styles.pctLabel}>{_PCT_LABELS[k]}</Text>
|
|
<Text style={styles.pctVal}>{pct[k] != null ? `${pct[k]}%` : '—'}</Text>
|
|
</View>
|
|
))}
|
|
|
|
<Text style={styles.modalSection}>Playback</Text>
|
|
<Text style={styles.modalMuted}>
|
|
{basis === 'telemetry'
|
|
? `Measured from real playback: ${successRate ?? 0}% success over ${attempts} plays (7d).`
|
|
: 'Estimated from how this source streams (no playback data yet — collecting).'}
|
|
</Text>
|
|
</>
|
|
)}
|
|
<Pressable style={styles.modalClose} onPress={onClose}>
|
|
<Text style={styles.modalCloseText}>Close</Text>
|
|
</Pressable>
|
|
</ScrollView>
|
|
</Pressable>
|
|
</Pressable>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 16, paddingTop: 12 },
|
|
|
|
headerRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
paddingBottom: 4,
|
|
},
|
|
headerLabel: {
|
|
color: theme.muted,
|
|
fontSize: 12,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: 1.2,
|
|
fontWeight: '700',
|
|
},
|
|
headerCount: { color: theme.fg, fontSize: 22, fontWeight: '800' },
|
|
hint: { color: theme.mutedDim, fontSize: 11, marginBottom: 12 },
|
|
|
|
toolbar: { flexDirection: 'row', gap: 12, marginBottom: 10 },
|
|
search: {
|
|
flex: 1,
|
|
backgroundColor: theme.card,
|
|
borderColor: theme.border,
|
|
borderWidth: 1.5,
|
|
borderRadius: 12,
|
|
color: theme.fg,
|
|
padding: 12,
|
|
fontSize: 16,
|
|
},
|
|
searchFocused: { borderColor: theme.borderFocus },
|
|
|
|
segment: {
|
|
flexDirection: 'row',
|
|
backgroundColor: theme.bgElevated,
|
|
borderColor: theme.border,
|
|
borderWidth: 1,
|
|
borderRadius: 12,
|
|
padding: 4,
|
|
marginBottom: 14,
|
|
alignSelf: 'flex-start',
|
|
},
|
|
segButton: { paddingHorizontal: 14, paddingVertical: 6, borderRadius: 8 },
|
|
segButtonActive: {
|
|
backgroundColor: theme.accent,
|
|
shadowColor: theme.accent,
|
|
shadowOffset: { width: 0, height: 0 },
|
|
shadowOpacity: 0.4,
|
|
shadowRadius: 6,
|
|
elevation: 2,
|
|
},
|
|
segButtonText: { color: theme.muted, fontWeight: '700', fontSize: 13 },
|
|
segButtonTextActive: { color: theme.fg },
|
|
|
|
gridRow: { gap: 10, marginBottom: 10 },
|
|
chip: {
|
|
flex: 1,
|
|
gap: 8,
|
|
backgroundColor: theme.card,
|
|
borderColor: theme.border,
|
|
borderWidth: 1,
|
|
borderRadius: 12,
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 10,
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 1 },
|
|
shadowOpacity: 0.18,
|
|
shadowRadius: 3,
|
|
elevation: 2,
|
|
},
|
|
chipTopRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: 8,
|
|
},
|
|
chipPressed: { borderColor: theme.borderFocus, backgroundColor: theme.bgElevated },
|
|
chipMain: { flex: 1, gap: 2 },
|
|
chipName: {
|
|
color: theme.fg,
|
|
fontWeight: '600',
|
|
fontSize: 14,
|
|
},
|
|
chipRel: {
|
|
color: theme.mutedDim,
|
|
fontSize: 10,
|
|
},
|
|
chipCountWrap: {
|
|
backgroundColor: `${theme.accentSecondary}1F`,
|
|
borderColor: `${theme.accentSecondary}55`,
|
|
borderWidth: 1,
|
|
borderRadius: 8,
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 2,
|
|
minWidth: 36,
|
|
alignItems: 'center',
|
|
},
|
|
chipCount: {
|
|
color: theme.accentSecondary,
|
|
fontSize: 12,
|
|
fontWeight: '700',
|
|
},
|
|
|
|
emptyText: { color: theme.muted, textAlign: 'center', marginTop: 48, fontSize: 16 },
|
|
error: { color: theme.bad, padding: 16 },
|
|
|
|
starsTap: { alignSelf: 'flex-start', paddingVertical: 2, paddingRight: 8 },
|
|
starsRow: { letterSpacing: 1 },
|
|
starsFull: { color: '#f5c518' }, // złoto
|
|
starsEmpty: { color: theme.mutedDim },
|
|
starsDim: { color: theme.mutedDim, fontStyle: 'italic' },
|
|
starsOffline: { color: theme.bad, fontWeight: '700', fontSize: 11 },
|
|
|
|
modalBackdrop: {
|
|
flex: 1,
|
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
|
justifyContent: 'center',
|
|
padding: 24,
|
|
},
|
|
modalCard: {
|
|
backgroundColor: theme.card,
|
|
borderColor: theme.border,
|
|
borderWidth: 1,
|
|
borderRadius: 16,
|
|
padding: 18,
|
|
maxHeight: '80%',
|
|
},
|
|
modalTitle: { color: theme.fg, fontSize: 20, fontWeight: '800', marginBottom: 8 },
|
|
modalStarsBig: { marginBottom: 14 },
|
|
modalSection: {
|
|
color: theme.muted,
|
|
fontSize: 11,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: 1,
|
|
fontWeight: '700',
|
|
marginTop: 16,
|
|
marginBottom: 8,
|
|
},
|
|
modalMuted: { color: theme.mutedDim, fontSize: 13, lineHeight: 18 },
|
|
|
|
axisRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 8 },
|
|
axisLabel: { color: theme.fg, fontSize: 13, width: 80 },
|
|
axisTrack: {
|
|
flex: 1,
|
|
height: 8,
|
|
backgroundColor: theme.bgElevated,
|
|
borderRadius: 4,
|
|
overflow: 'hidden',
|
|
},
|
|
axisFill: { height: 8, backgroundColor: theme.accent, borderRadius: 4 },
|
|
axisVal: { color: theme.muted, fontSize: 12, width: 30, textAlign: 'right' },
|
|
|
|
pctRow: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
paddingVertical: 3,
|
|
},
|
|
pctLabel: { color: theme.muted, fontSize: 13 },
|
|
pctVal: { color: theme.fg, fontSize: 13, fontWeight: '600' },
|
|
|
|
modalClose: {
|
|
marginTop: 20,
|
|
backgroundColor: theme.accent,
|
|
borderRadius: 10,
|
|
paddingVertical: 10,
|
|
alignItems: 'center',
|
|
},
|
|
modalCloseText: { color: theme.fg, fontWeight: '700', fontSize: 14 },
|
|
});
|