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:
parent
3818cd6141
commit
f48fbbd4ad
6 changed files with 97 additions and 11 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue