diff --git a/app/api/taxonomies.py b/app/api/taxonomies.py index f625eb8..1d334a1 100644 --- a/app/api/taxonomies.py +++ b/app/api/taxonomies.py @@ -238,10 +238,72 @@ def list_performers( order: str = Query(default="scene_count", description="scene_count|name"), page: int = Query(default=1, ge=1), per_page: int = Query(default=50, ge=1, le=500), + vertical: str | None = Query( + default=None, + description=( + "Wertykał ('jav'): licz i pokazuj TYLKO obsadę jego scen. Brak → główny feed. " + "Dla 'animation' zwraca pusto, bo animacja z definicji nie ma obsady." + ), + ), ) -> PerformerListOut: if order not in ("scene_count", "popular", "name"): raise HTTPException(status_code=400, detail="order must be 'scene_count' or 'name'") + if vertical in VERTICALS: + # Jak przy tagach: wertykał liczymy NA ŻYWO, bo `Performer.scene_count` z założenia + # opisuje wyłącznie główny feed (job taxonomy_counts wyklucza originy wertykałowe). + # Bez tego zakładka JAV pokazywała zachodnią obsadę, a jej własne 1392 nazwiska + # miały licznik 0 i lądowały na końcu listy. + # + # Animacja NIE MA obsady i to nie jest brak danych, tylko wlasciwosc: dla postaci + # rysowanych nie istnieje kanon (TPDB/StashDB ich nie maja), patrz app/verticals.py. + # Zmierzone 2026-08-14: 12 698 scen animowanych, 0 performerów. Zapytanie odda + # wiec pusto i tak ma byc; apka chowa tam ten przycisk. + v_scenes = ( + select(PlaybackSource.scene_id) + .where( + PlaybackSource.dead_at.is_(None), + PlaybackSource.origin.in_(VERTICALS[vertical]), + ) + .distinct() + .subquery() + ) + v_count = ( + select(ScenePerformer.performer_id, func.count().label("c")) + .join(v_scenes, v_scenes.c.scene_id == ScenePerformer.scene_id) + .group_by(ScenePerformer.performer_id) + .subquery() + ) + base = ( + select(Performer, func.coalesce(v_count.c.c, 0).label("scene_count")) + .join(v_count, v_count.c.performer_id == Performer.id) + ) + if q: + base = base.where(Performer.name_normalized.ilike(f"%{q.lower()}%")) + total = session.execute( + select(func.count()).select_from(base.subquery()) + ).scalar_one() + if order in ("scene_count", "popular"): + ordered = base.order_by(v_count.c.c.desc(), Performer.canonical_name.asc()) + else: + ordered = base.order_by(Performer.canonical_name.asc()) + rows = session.execute( + ordered.offset((page - 1) * per_page).limit(per_page) + ).all() + return PerformerListOut( + items=[ + PerformerCount( + id=p.id, + canonical_name=p.canonical_name, + slug=p.slug, + gender=p.gender.value if p.gender else None, + scene_count=int(c), + ) + for p, c in rows + ], + total=total, page=page, per_page=per_page, + ) + # Czyta zdenormalizowany Performer.scene_count (refresh w tle). Wcześniej agregat # 3M scene_performers + EXISTS playback per-request. Patrz migracja 0019. base = select(Performer) diff --git a/mobile/src/api.ts b/mobile/src/api.ts index c20b893..012c92f 100644 --- a/mobile/src/api.ts +++ b/mobile/src/api.ts @@ -242,12 +242,15 @@ export class GoonClient { return this.request(`/performers/${performerId}/tags?limit=${limit}`); } - async listPerformers(params: { q?: string; order?: 'scene_count' | 'name'; page?: number; per_page?: number } = {}): Promise { + async listPerformers(params: { q?: string; order?: 'scene_count' | 'name'; page?: number; per_page?: number; vertical?: string } = {}): Promise { const qs = new URLSearchParams(); if (params.q) qs.set('q', params.q); qs.set('order', params.order ?? 'scene_count'); qs.set('page', String(params.page ?? 1)); qs.set('per_page', String(params.per_page ?? 100)); + // Bez tego zakladka JAV pokazywala zachodnia obsade: `performers.scene_count` liczy + // wylacznie glowny feed, wiec 1392 nazwiska JAV mialy tam 0 i szly na koniec listy. + if (params.vertical) qs.set('vertical', params.vertical); return this.request(`/performers?${qs.toString()}`); } diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index 6751f2d..56c287a 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -16,6 +16,14 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + id: '2026-08-14e', + date: 'August 2026', + items: [ + 'JAV now has its own performer list. Opening Performers from that tab used to show the western cast; it now shows the 1392 names that actually appear in JAV scenes.', + 'The Anime tab no longer offers a Performers button. Animation has no cast to list, so the button only ever led to an empty screen.', + ], + }, { id: '2026-08-14d', date: 'August 2026', diff --git a/mobile/src/navigation.tsx b/mobile/src/navigation.tsx index a2eeaaf..dcb15b9 100644 --- a/mobile/src/navigation.tsx +++ b/mobile/src/navigation.tsx @@ -47,7 +47,7 @@ export type RootStackParamList = { MovieDetail: { id: string }; // autoplay: wejście z ▶ na kafelku (quick-play), SceneDetail sam odpala najlepsze źródło. SceneDetail: { id: string; autoplay?: boolean }; - Performers: undefined; + Performers: { vertical?: string } | undefined; // `seenSince` (ISO timestamp): jeśli ten performer jest favorited, FavoritesScreen // przekazuje `last_seen_at` SPRZED markFavoriteSeen. Sceny z `created_at > seenSince` // dostają NEW badge. diff --git a/mobile/src/screens/PerformersScreen.tsx b/mobile/src/screens/PerformersScreen.tsx index 24bbee5..57cec9a 100644 --- a/mobile/src/screens/PerformersScreen.tsx +++ b/mobile/src/screens/PerformersScreen.tsx @@ -1,6 +1,7 @@ // Lista performerów + tap → PerformerScenes. // Domyślny order: scene_count desc (najpierw popularni); search po name_normalized. -import { useNavigation } from '@react-navigation/native'; +import { useNavigation, useRoute } from '@react-navigation/native'; +import type { RouteProp } from '@react-navigation/native'; import { formatCount } from '../lib/formatCount'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useQuery } from '@tanstack/react-query'; @@ -29,6 +30,8 @@ export function PerformersScreen() { const [debouncedQ, setDebouncedQ] = useState(''); const [order, setOrder] = useState('scene_count'); const [searchFocused, setSearchFocused] = useState(false); + const route = useRoute>(); + const vertical = route.params?.vertical; React.useEffect(() => { const t = setTimeout(() => setDebouncedQ(q), 350); @@ -36,8 +39,9 @@ export function PerformersScreen() { }, [q]); const { data, isLoading, error, refetch, isRefetching } = useQuery({ - queryKey: ['performers', debouncedQ, order], - queryFn: () => client.listPerformers({ q: debouncedQ || undefined, order, per_page: 200 }), + queryKey: ['performers', debouncedQ, order, vertical ?? null], + queryFn: () => + client.listPerformers({ q: debouncedQ || undefined, order, per_page: 200, vertical }), }); const total = data?.items.length ?? 0; @@ -89,6 +93,7 @@ export function PerformersScreen() { navigation.navigate('PerformerScenes', { id: item.id, name: item.canonical_name, + vertical, }) } /> diff --git a/mobile/src/screens/ScenesScreen.tsx b/mobile/src/screens/ScenesScreen.tsx index c644ee3..dcc30fd 100644 --- a/mobile/src/screens/ScenesScreen.tsx +++ b/mobile/src/screens/ScenesScreen.tsx @@ -38,6 +38,10 @@ export function ScenesScreen() { const vertical = params?.vertical; // Wspólna nazwa wertykału do zapytań o tagi (stary `jav` też ma swój słownik). const verticalKey = vertical ?? (jav ? 'jav' : undefined); + // Animacja NIE MA obsady i to nie brak danych, tylko wlasciwosc tresci: dla postaci + // rysowanych nie istnieje kanon, wiec zaden scraper nie ma czego przypisac (0 performerow + // na 12 698 scen, pomiar 2026-08-14). Przycisk prowadzilby do gwarantowanie pustej listy. + const hasCast = verticalKey !== 'animation'; const [q, setQ] = useState(''); const [debouncedQ, setDebouncedQ] = useState(''); const [filter, setFilter] = useState(DEFAULT_FILTER); @@ -211,12 +215,16 @@ export function ScenesScreen() { ) : null} - navigation.navigate('Performers')} - > - Performers - + {hasCast ? ( + + navigation.navigate('Performers', verticalKey ? { vertical: verticalKey } : undefined) + } + > + Performers + + ) : null} navigation.navigate('Tags', verticalKey ? { vertical: verticalKey } : undefined)}