goon/mobile/src/screens/StudioScenesScreen.tsx
jtrzupek 5ae5dbb201 perf(scenes): bounded count + has_more for filtered scene lists
Filtered /scenes (tag/origin/q/studio/performer) ran exhaustive COUNT with
stub-filter EXISTS over 1.7M rows: TAG 5.1s, ORIGIN 4.9s, SEARCH 3.1s.
Mobile relied on `loaded < total` for infinite-scroll, making exact count
mandatory and ruling out approximate shortcuts.

Backend:
- SceneListOut gains has_more (bool) and total_capped (bool), both optional
  for backward compat with old mobile
- Filtered count uses LIMIT _COUNT_CAP+1 (1000) subquery — cost is
  O(min(matches, cap)) instead of O(all). Measured: TAG 5.1s→664ms,
  SEARCH 3.1s→138ms, ORIGIN 4.9s→1.07s (also fixes SiteScenes showing
  global count ~1M instead of per-site count)
- has_more from fetching per_page+1 rows (essentially free); extra row
  stripped before serialisation
- Pure-default list (no filters at all) keeps TTL-cached full count

Mobile:
- getNextPageParam uses has_more ?? fallback to loaded<total
- Display shows "{total}+" when total_capped=true (5 screens)

Verified on emulator: tag "Big Tits" → "1000 scenes" loaded, no 500s,
backward compat confirmed (old APK works against new backend).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 19:24:26 +02:00

174 lines
6.2 KiB
TypeScript

// Sceny dla wybranego studia (filter studio_slugs=<slug>). Mirror PerformerScenesScreen.
// Studios używają slug filter (nie id) — backend ScenesListParams ma `studio_slugs`,
// nie `studio_ids`. Dlatego nav param dla StudioScenes wymaga slug — przekazujemy go
// z FavoritesScreen (mamy w FavoriteStudioOut.slug).
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 React from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import { FavoriteSceneRow } from '../components/FavoriteSceneRow';
import { SceneTile } from '../components/SceneTile';
import { useClient } from '../ClientContext';
import type { RootStackParamList } from '../navigation';
import { theme } from '../theme';
import type { SceneOut } from '../types';
export function StudioScenesScreen() {
const client = useClient();
const queryClient = useQueryClient();
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList, 'StudioScenes'>>();
const route = useRoute<RouteProp<RootStackParamList, 'StudioScenes'>>();
// `id` w nav params dla StudioScenes to studio.slug (nie UUID), żeby pasowało do
// backend ScenesListParams.studio_slugs. Trzymamy tę nazwę dla spójności z
// PerformerScenes — ale w handlerach favorite/blacklist używamy studioId (UUID)
// który dodajemy jako kolejny param.
const { id: slug, name, seenSince, studioId } = route.params;
const favoritesQuery = useQuery({
queryKey: ['favorites-studios'],
queryFn: () => client.listFavoriteStudios(),
staleTime: 30_000,
});
const isFavorite = !!favoritesQuery.data?.items.find((f) => f.studio_id === studioId);
const addMutation = useMutation({
mutationFn: () => client.addFavoriteStudio(studioId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['favorites-studios'] }),
});
const removeMutation = useMutation({
mutationFn: () => client.removeFavoriteStudio(studioId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['favorites-studios'] }),
});
const blacklistMutation = useMutation({
mutationFn: () => client.addBlacklist('studio', studioId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['scenes'] });
queryClient.invalidateQueries({ queryKey: ['studio-scenes', slug] });
navigation.goBack();
},
});
const onHide = () => {
Alert.alert(
'Hide studio',
`Hide all scenes from ${name}? You can undo this from Settings → Blacklist.`,
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Hide', style: 'destructive', onPress: () => blacklistMutation.mutate() },
],
);
};
// Mark seen przy wejściu (jeśli już ulubiony) — zerujemy badge nowych scen
React.useEffect(() => {
if (isFavorite) {
client.markFavoriteStudioSeen(studioId).then(() => {
queryClient.invalidateQueries({ queryKey: ['favorites-studios'] });
}).catch(() => {});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isFavorite, studioId]);
React.useLayoutEffect(() => {
navigation.setOptions({
title: name,
headerRight: () => (
<View style={{ flexDirection: 'row', gap: 16, alignItems: 'center' }}>
<Pressable onPress={onHide} hitSlop={10}>
<Text style={{ color: theme.mutedDim, fontSize: 18 }}>🚫</Text>
</Pressable>
<Pressable
onPress={() => (isFavorite ? removeMutation.mutate() : addMutation.mutate())}
hitSlop={12}
disabled={addMutation.isPending || removeMutation.isPending}
>
<Text style={{ color: isFavorite ? theme.accent : theme.muted, fontSize: 22 }}>
{isFavorite ? '★' : '☆'}
</Text>
</Pressable>
</View>
),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navigation, name, isFavorite, addMutation.isPending, removeMutation.isPending]);
const { data, isLoading, error, refetch, isRefetching } = useQuery({
queryKey: ['studio-scenes', slug],
queryFn: () =>
client.listScenes({
studio_slugs: [slug],
sort: 'release_date',
per_page: 200,
has_playback: true,
}),
});
const sortedItems = React.useMemo<SceneOut[]>(() => {
const items = data?.items ?? [];
if (!seenSince) return items;
const newOnes: SceneOut[] = [];
const rest: SceneOut[] = [];
for (const s of items) {
if (s.created_at && s.created_at > seenSince) {
newOnes.push(s);
} else {
rest.push(s);
}
}
return [...newOnes, ...rest];
}, [data?.items, seenSince]);
return (
<View style={styles.container}>
{isLoading && <ActivityIndicator color={theme.fg} />}
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
<FlatList
data={sortedItems}
keyExtractor={(s) => s.id}
numColumns={2}
renderItem={({ item }) => (
<SceneTile scene={item} seenSince={seenSince} secondLine="performers" />
)}
columnWrapperStyle={styles.gridRow}
refreshing={isRefetching}
onRefresh={refetch}
ListHeaderComponent={
<View style={styles.header}>
<Text style={styles.subtitle}>
{data ? `${data.total}${data.total_capped ? '+' : ''} ${data.total === 1 ? 'scene' : 'scenes'}` : ' '}
</Text>
</View>
}
ListEmptyComponent={!isLoading ? <Text style={styles.muted}>no scenes</Text> : null}
contentContainerStyle={{ paddingBottom: 24 }}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 12, paddingTop: 8 },
gridRow: { gap: 10, marginBottom: 14 },
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
paddingHorizontal: 4,
},
subtitle: { color: theme.muted },
muted: { color: theme.muted, textAlign: 'center', marginTop: 24 },
error: { color: theme.bad, padding: 12 },
});