fix(performers): wlasna obsada dla JAV, brak przycisku dla animacji

Domkniecie tej samej rodziny bledu co przy tagach i studiach. Pomiar rozstrzygnal,
ze oba wertykaly wymagaja INNEGO rozwiazania, wiec nie ma tu jednego filtra:

  animacja   12 698 scen ->     0 performerow
  JAV        54 888 scen -> 1 392 performerow

JAV: /performers dostaje parametr `vertical`, liczony na zywo, bo zdenormalizowany
`performers.scene_count` z zalozenia opisuje wylacznie glowny feed (job taxonomy_counts
wyklucza originy wertykalowe). Bez tego zakladka pokazywala zachodnia obsade, a wlasne
nazwiska mialy licznik 0 i ladowaly na koncu listy. Po zmianie: Satomi Suzuki (137),
Kei Mizushima (128), yui hatano (68).

Animacja: przycisk Performers jest chowany. Zero obsady to nie brak danych, tylko
wlasciwosc tresci: dla postaci rysowanych nie istnieje kanon (TPDB/StashDB ich nie maja),
wiec zaden scraper nie ma czego przypisac i zaden nigdy nie bedzie mial. Filtrowanie
dawaloby gwarantowanie pusta liste, czyli dokladnie ten sam objaw, ktory naprawiamy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-08-14 12:51:39 +02:00
parent 3818cd6141
commit f48fbbd4ad
6 changed files with 97 additions and 11 deletions

View file

@ -238,10 +238,72 @@ def list_performers(
order: str = Query(default="scene_count", description="scene_count|name"), order: str = Query(default="scene_count", description="scene_count|name"),
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
per_page: int = Query(default=50, ge=1, le=500), 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: ) -> PerformerListOut:
if order not in ("scene_count", "popular", "name"): if order not in ("scene_count", "popular", "name"):
raise HTTPException(status_code=400, detail="order must be 'scene_count' or '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 # Czyta zdenormalizowany Performer.scene_count (refresh w tle). Wcześniej agregat
# 3M scene_performers + EXISTS playback per-request. Patrz migracja 0019. # 3M scene_performers + EXISTS playback per-request. Patrz migracja 0019.
base = select(Performer) base = select(Performer)

View file

@ -242,12 +242,15 @@ export class GoonClient {
return this.request(`/performers/${performerId}/tags?limit=${limit}`); return this.request(`/performers/${performerId}/tags?limit=${limit}`);
} }
async listPerformers(params: { q?: string; order?: 'scene_count' | 'name'; page?: number; per_page?: number } = {}): Promise<PerformerListOut> { async listPerformers(params: { q?: string; order?: 'scene_count' | 'name'; page?: number; per_page?: number; vertical?: string } = {}): Promise<PerformerListOut> {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (params.q) qs.set('q', params.q); if (params.q) qs.set('q', params.q);
qs.set('order', params.order ?? 'scene_count'); qs.set('order', params.order ?? 'scene_count');
qs.set('page', String(params.page ?? 1)); qs.set('page', String(params.page ?? 1));
qs.set('per_page', String(params.per_page ?? 100)); 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()}`); return this.request(`/performers?${qs.toString()}`);
} }

View file

@ -16,6 +16,14 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: 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', id: '2026-08-14d',
date: 'August 2026', date: 'August 2026',

View file

@ -47,7 +47,7 @@ export type RootStackParamList = {
MovieDetail: { id: string }; MovieDetail: { id: string };
// autoplay: wejście z ▶ na kafelku (quick-play), SceneDetail sam odpala najlepsze źródło. // autoplay: wejście z ▶ na kafelku (quick-play), SceneDetail sam odpala najlepsze źródło.
SceneDetail: { id: string; autoplay?: boolean }; SceneDetail: { id: string; autoplay?: boolean };
Performers: undefined; Performers: { vertical?: string } | undefined;
// `seenSince` (ISO timestamp): jeśli ten performer jest favorited, FavoritesScreen // `seenSince` (ISO timestamp): jeśli ten performer jest favorited, FavoritesScreen
// przekazuje `last_seen_at` SPRZED markFavoriteSeen. Sceny z `created_at > seenSince` // przekazuje `last_seen_at` SPRZED markFavoriteSeen. Sceny z `created_at > seenSince`
// dostają NEW badge. // dostają NEW badge.

View file

@ -1,6 +1,7 @@
// Lista performerów + tap → PerformerScenes. // Lista performerów + tap → PerformerScenes.
// Domyślny order: scene_count desc (najpierw popularni); search po name_normalized. // 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 { formatCount } from '../lib/formatCount';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
@ -29,6 +30,8 @@ export function PerformersScreen() {
const [debouncedQ, setDebouncedQ] = useState(''); const [debouncedQ, setDebouncedQ] = useState('');
const [order, setOrder] = useState<Order>('scene_count'); const [order, setOrder] = useState<Order>('scene_count');
const [searchFocused, setSearchFocused] = useState(false); const [searchFocused, setSearchFocused] = useState(false);
const route = useRoute<RouteProp<RootStackParamList, 'Performers'>>();
const vertical = route.params?.vertical;
React.useEffect(() => { React.useEffect(() => {
const t = setTimeout(() => setDebouncedQ(q), 350); const t = setTimeout(() => setDebouncedQ(q), 350);
@ -36,8 +39,9 @@ export function PerformersScreen() {
}, [q]); }, [q]);
const { data, isLoading, error, refetch, isRefetching } = useQuery({ const { data, isLoading, error, refetch, isRefetching } = useQuery({
queryKey: ['performers', debouncedQ, order], queryKey: ['performers', debouncedQ, order, vertical ?? null],
queryFn: () => client.listPerformers({ q: debouncedQ || undefined, order, per_page: 200 }), queryFn: () =>
client.listPerformers({ q: debouncedQ || undefined, order, per_page: 200, vertical }),
}); });
const total = data?.items.length ?? 0; const total = data?.items.length ?? 0;
@ -89,6 +93,7 @@ export function PerformersScreen() {
navigation.navigate('PerformerScenes', { navigation.navigate('PerformerScenes', {
id: item.id, id: item.id,
name: item.canonical_name, name: item.canonical_name,
vertical,
}) })
} }
/> />

View file

@ -38,6 +38,10 @@ export function ScenesScreen() {
const vertical = params?.vertical; const vertical = params?.vertical;
// Wspólna nazwa wertykału do zapytań o tagi (stary `jav` też ma swój słownik). // Wspólna nazwa wertykału do zapytań o tagi (stary `jav` też ma swój słownik).
const verticalKey = vertical ?? (jav ? 'jav' : undefined); 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 [q, setQ] = useState('');
const [debouncedQ, setDebouncedQ] = useState(''); const [debouncedQ, setDebouncedQ] = useState('');
const [filter, setFilter] = useState<FilterState>(DEFAULT_FILTER); const [filter, setFilter] = useState<FilterState>(DEFAULT_FILTER);
@ -211,12 +215,16 @@ export function ScenesScreen() {
) : null} ) : null}
<View style={styles.subToolbar}> <View style={styles.subToolbar}>
<Pressable {hasCast ? (
style={styles.subButton} <Pressable
onPress={() => navigation.navigate('Performers')} style={styles.subButton}
> onPress={() =>
<Text style={styles.subButtonText}>Performers</Text> navigation.navigate('Performers', verticalKey ? { vertical: verticalKey } : undefined)
</Pressable> }
>
<Text style={styles.subButtonText}>Performers</Text>
</Pressable>
) : null}
<Pressable <Pressable
style={styles.subButton} style={styles.subButton}
onPress={() => navigation.navigate('Tags', verticalKey ? { vertical: verticalKey } : undefined)} onPress={() => navigation.navigate('Tags', verticalKey ? { vertical: verticalKey } : undefined)}