fix(verticals): przenos wertykal przy przejsciu do tagu/studia/performera

Zgloszenia 195ae42e i e9de414c: klikniecie studia w anime nic nie robilo, a tag
kliniety w anime pokazywal sceny spoza anime.

Jedna przyczyna: TagScenes/StudioScenes/PerformerScenes wolaly listScenes BEZ
parametru vertical, a backend bez niego wyklucza wszystkie wertykaly. Studio
animacyjne mialo wiec zero scen (pusty ekran = wyglada na martwy klik), a tag
zwracal wylacznie sceny glownego katalogu.

SceneOut dostaje pole "vertical" liczone z originow zrodel, czyli jedno zrodlo
prawdy w app/verticals.py zamiast dublowania listy originow w apce. W trybie
light dociagamy origin z tego samego wiersza co miniaturka, wiec zero dodatkowych
zapytan. SceneDetail przekazuje wartosc dalej w paramach nawigacji.

Dotyczy tak samo sekcji JAV, ktora miala ten sam blad.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-08-14 11:41:40 +02:00
parent d9177febf7
commit a1f604a7a4
10 changed files with 68 additions and 12 deletions

View file

@ -856,6 +856,8 @@ def _build_scenes_out_batch(
# 5) Playback sources. Light mode: tylko miniaturka (jedna na scenę), kafelek # 5) Playback sources. Light mode: tylko miniaturka (jedna na scenę), kafelek
# czyta wyłącznie playback_sources[].thumbnail_url / animated_thumbnail_url. # czyta wyłącznie playback_sources[].thumbnail_url / animated_thumbnail_url.
pb_by_scene: dict = defaultdict(list) pb_by_scene: dict = defaultdict(list)
# origin per scena, wyłącznie do policzenia `vertical` (obie ścieżki go wypełniają).
origins_by_scene: dict = {}
if light: if light:
pb_light = session.execute( pb_light = session.execute(
select( select(
@ -863,6 +865,10 @@ def _build_scenes_out_batch(
PlaybackSource.thumbnail_url, PlaybackSource.thumbnail_url,
PlaybackSource.animated_thumbnail_url, PlaybackSource.animated_thumbnail_url,
PlaybackSource.page_url, PlaybackSource.page_url,
# origin dociągamy wyłącznie po to, żeby policzyć `vertical` (tania kolumna
# z tego samego wiersza, zero dodatkowych zapytań). Do samego kafelka
# nieużywany, slim wpis niżej dostaje origin="".
PlaybackSource.origin,
) )
.where( .where(
PlaybackSource.scene_id.in_(scene_ids), PlaybackSource.scene_id.in_(scene_ids),
@ -878,7 +884,8 @@ def _build_scenes_out_batch(
thumb_by_scene: dict = {} thumb_by_scene: dict = {}
thumb_fallback: dict = {} thumb_fallback: dict = {}
anim_by_scene: dict = {} anim_by_scene: dict = {}
for sid, thumb, anim, page_url in pb_light: for sid, thumb, anim, page_url, origin in pb_light:
origins_by_scene.setdefault(sid, []).append(origin)
sxy = _sxyprn_thumb_url(page_url) sxy = _sxyprn_thumb_url(page_url)
yp = _youporn_thumb_url(page_url, thumb) yp = _youporn_thumb_url(page_url, thumb)
if sxy: if sxy:
@ -936,6 +943,7 @@ def _build_scenes_out_batch(
.order_by(PlaybackSource.origin.asc()) .order_by(PlaybackSource.origin.asc())
).scalars().all() ).scalars().all()
for p in pb_rows: for p in pb_rows:
origins_by_scene.setdefault(p.scene_id, []).append(p.origin)
out = PlaybackSourceOut.model_validate(p) out = PlaybackSourceOut.model_validate(p)
# sxyprn/trafficdeposit stored thumb rotuje (404 po ~tygodniach). SceneDetail # sxyprn/trafficdeposit stored thumb rotuje (404 po ~tygodniach). SceneDetail
# bierze PIERWSZE źródło z thumbem (origin ASC → sxyprncom często pierwsze), więc # bierze PIERWSZE źródło z thumbem (origin ASC → sxyprncom często pierwsze), więc
@ -997,6 +1005,7 @@ def _build_scenes_out_batch(
tags=tags_by_scene.get(scene.id, []), tags=tags_by_scene.get(scene.id, []),
external_refs=refs_by_scene.get(scene.id, []), external_refs=refs_by_scene.get(scene.id, []),
playback_sources=pb_by_scene.get(scene.id, []), playback_sources=pb_by_scene.get(scene.id, []),
vertical=verticals.vertical_for_origins(origins_by_scene.get(scene.id, ())),
created_at=scene.created_at, created_at=scene.created_at,
backfill=scene.backfill, backfill=scene.backfill,
last_played_at=progress.last_played_at if progress else None, last_played_at=progress.last_played_at if progress else None,
@ -1145,6 +1154,7 @@ def _build_scene_out(session: Session, scene: Scene, *, device_id: str = LEGACY_
tags=tags_out, tags=tags_out,
external_refs=refs_out, external_refs=refs_out,
playback_sources=playback_out, playback_sources=playback_out,
vertical=verticals.vertical_for_origins(p.origin for p in playback_rows),
created_at=scene.created_at, created_at=scene.created_at,
backfill=scene.backfill, backfill=scene.backfill,
last_played_at=progress.last_played_at if progress else None, last_played_at=progress.last_played_at if progress else None,

View file

@ -66,6 +66,12 @@ class SceneOut(BaseModel):
tags: list[TagOut] = [] tags: list[TagOut] = []
external_refs: list[ExternalRefOut] = [] external_refs: list[ExternalRefOut] = []
playback_sources: list[PlaybackSourceOut] = [] playback_sources: list[PlaybackSourceOut] = []
# Wertykał sceny ('jav' / 'animation') albo None dla głównego katalogu. Liczony
# z originów źródeł, patrz `app/verticals.py`. Mobile MUSI go przekazywać dalej przy
# nawigacji do tagu/studia/performera: bez `?vertical=` backend wyklucza wszystkie
# wertykały, więc tag z anime pokazywał sceny spoza anime, a studio animacyjne
# wyglądało na martwe (zgłoszenia 195ae42e / e9de414c, 2026-08-14).
vertical: str | None = None
# Kiedy scena trafiła do bazy (ingest). Używane przez mobile do oznaczenia # Kiedy scena trafiła do bazy (ingest). Używane przez mobile do oznaczenia
# "NEW" na karcie scen w PerformerScenesScreen / StudioScenesScreen, gdy # "NEW" na karcie scen w PerformerScenesScreen / StudioScenesScreen, gdy
# `created_at > last_seen_at` (favorite) → badge. # `created_at > last_seen_at` (favorite) → badge.

View file

@ -55,3 +55,24 @@ def origins_for(vertical: str | None) -> tuple[str, ...]:
if vertical is None: if vertical is None:
return ALL_VERTICAL_ORIGINS return ALL_VERTICAL_ORIGINS
return VERTICALS.get(vertical, ()) return VERTICALS.get(vertical, ())
#: Odwrotna mapa origin → wertykał. Budowana raz, bo `vertical_for_origins` leci
#: dla każdej sceny w odpowiedzi.
_ORIGIN_TO_VERTICAL: dict[str, str] = {
origin: name for name, origins in VERTICALS.items() for origin in origins
}
def vertical_for_origins(origins) -> str | None:
"""Wertykał sceny na podstawie originów jej źródeł, albo None dla głównego katalogu.
Scena wertykałowa ma WYŁĄCZNIE originy swojego wertykału (patrz docstring modułu),
więc wystarczy pierwszy trafiony. Kolejność wyniku jest deterministyczna, bo pytamy
o konkretną scenę, a nie o zbiór.
"""
for origin in origins:
vertical = _ORIGIN_TO_VERTICAL.get(origin)
if vertical is not None:
return vertical
return None

View file

@ -16,6 +16,13 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-08-14c',
date: 'August 2026',
items: [
'Anime: tapping a tag or a studio now stays inside the Anime section. Before, a tag opened from an anime scene showed everything except anime, and an anime studio looked like it did nothing because its page came up empty. The JAV section had the same bug and is fixed too.',
],
},
{ {
id: '2026-08-14b', id: '2026-08-14b',
date: 'August 2026', date: 'August 2026',

View file

@ -51,16 +51,16 @@ export type RootStackParamList = {
// `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.
PerformerScenes: { id: string; name: string; seenSince?: string }; PerformerScenes: { id: string; name: string; seenSince?: string; vertical?: string | null };
// StudioScenes: `id` to studio.slug (do filter scen `studio_slugs`), // StudioScenes: `id` to studio.slug (do filter scen `studio_slugs`),
// `studioId` to UUID (do favorite/blacklist API). Backend filtruje scen po slug, // `studioId` to UUID (do favorite/blacklist API). Backend filtruje scen po slug,
// ale favorites + blacklist używają UUID. // ale favorites + blacklist używają UUID.
StudioScenes: { id: string; name: string; studioId: string; seenSince?: string }; StudioScenes: { id: string; name: string; studioId: string; seenSince?: string; vertical?: string | null };
// StudioMovies: `slug` do filter movies `studio_slugs`, `studioId` (UUID) do favorite. // StudioMovies: `slug` do filter movies `studio_slugs`, `studioId` (UUID) do favorite.
StudioMovies: { slug: string; name: string; studioId: string }; StudioMovies: { slug: string; name: string; studioId: string };
Favorites: undefined; Favorites: undefined;
Tags: undefined; Tags: undefined;
TagScenes: { slug: string; name: string }; TagScenes: { slug: string; name: string; vertical?: string | null };
AppLockSettings: undefined; AppLockSettings: undefined;
Blacklist: undefined; Blacklist: undefined;
Donate: undefined; Donate: undefined;

View file

@ -39,7 +39,7 @@ export function PerformerScenesScreen() {
const navigation = const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList, 'PerformerScenes'>>(); useNavigation<NativeStackNavigationProp<RootStackParamList, 'PerformerScenes'>>();
const route = useRoute<RouteProp<RootStackParamList, 'PerformerScenes'>>(); const route = useRoute<RouteProp<RootStackParamList, 'PerformerScenes'>>();
const { id, name, seenSince } = route.params; const { id, name, seenSince, vertical } = route.params;
const [tab, setTab] = React.useState<Tab>('scenes'); const [tab, setTab] = React.useState<Tab>('scenes');
@ -143,10 +143,11 @@ export function PerformerScenesScreen() {
const [selectedTag, setSelectedTag] = React.useState<{ slug: string; name: string } | null>(null); const [selectedTag, setSelectedTag] = React.useState<{ slug: string; name: string } | null>(null);
const scenesQuery = useQuery({ const scenesQuery = useQuery({
queryKey: ['performer-scenes', id, selectedTag?.slug ?? null], queryKey: ['performer-scenes', id, selectedTag?.slug ?? null, vertical ?? null],
queryFn: () => queryFn: () =>
client.listScenes({ client.listScenes({
performer_ids: [id], performer_ids: [id],
vertical: vertical ?? undefined,
tags: selectedTag ? [selectedTag.slug] : undefined, tags: selectedTag ? [selectedTag.slug] : undefined,
sort: 'release_date', sort: 'release_date',
per_page: 200, per_page: 200,

View file

@ -280,7 +280,7 @@ export function SceneDetailScreen() {
key={p.id} key={p.id}
style={styles.pillPrimary} style={styles.pillPrimary}
onPress={() => onPress={() =>
nav.push('PerformerScenes', { id: p.id, name: p.canonical_name }) nav.push('PerformerScenes', { id: p.id, name: p.canonical_name, vertical: data.vertical })
} }
onLongPress={() => onPerformerLongPress(p.id, p.canonical_name)} onLongPress={() => onPerformerLongPress(p.id, p.canonical_name)}
delayLongPress={400} delayLongPress={400}
@ -306,6 +306,9 @@ export function SceneDetailScreen() {
id: data.studio!.slug, id: data.studio!.slug,
studioId: data.studio!.id, studioId: data.studio!.id,
name: data.studio!.name, name: data.studio!.name,
// Bez tego backend odcina wertykał i studio animacyjne wychodzi
// puste, co wyglądało jakby kliknięcie nic nie robiło (195ae42e).
vertical: data.vertical,
}) })
} }
> >
@ -325,7 +328,7 @@ export function SceneDetailScreen() {
<Pressable <Pressable
key={t.id} key={t.id}
style={styles.pillSecondary} style={styles.pillSecondary}
onPress={() => nav.push('TagScenes', { slug: t.slug, name: t.name })} onPress={() => nav.push('TagScenes', { slug: t.slug, name: t.name, vertical: data.vertical })}
onLongPress={() => onTagLongPress(t.id, t.name)} onLongPress={() => onTagLongPress(t.id, t.name)}
delayLongPress={500} delayLongPress={500}
> >

View file

@ -47,7 +47,7 @@ export function StudioScenesScreen() {
// backend ScenesListParams.studio_slugs. Trzymamy tę nazwę dla spójności z // backend ScenesListParams.studio_slugs. Trzymamy tę nazwę dla spójności z
// PerformerScenes, ale w handlerach favorite/blacklist używamy studioId (UUID) // PerformerScenes, ale w handlerach favorite/blacklist używamy studioId (UUID)
// który dodajemy jako kolejny param. // który dodajemy jako kolejny param.
const { id: slug, name, seenSince, studioId } = route.params; const { id: slug, name, seenSince, studioId, vertical } = route.params;
const [tab, setTab] = React.useState<Tab>('scenes'); const [tab, setTab] = React.useState<Tab>('scenes');
const favoritesQuery = useQuery({ const favoritesQuery = useQuery({
@ -120,10 +120,11 @@ export function StudioScenesScreen() {
}, [navigation, name, isFavorite, addMutation.isPending, removeMutation.isPending]); }, [navigation, name, isFavorite, addMutation.isPending, removeMutation.isPending]);
const { data, isLoading, error, refetch, isRefetching } = useQuery({ const { data, isLoading, error, refetch, isRefetching } = useQuery({
queryKey: ['studio-scenes', slug], queryKey: ['studio-scenes', slug, vertical ?? null],
queryFn: () => queryFn: () =>
client.listScenes({ client.listScenes({
studio_slugs: [slug], studio_slugs: [slug],
vertical: vertical ?? undefined,
sort: 'release_date', sort: 'release_date',
per_page: 200, per_page: 200,
has_playback: true, has_playback: true,

View file

@ -24,17 +24,20 @@ export function TagScenesScreen() {
const navigation = const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList, 'TagScenes'>>(); useNavigation<NativeStackNavigationProp<RootStackParamList, 'TagScenes'>>();
const route = useRoute<RouteProp<RootStackParamList, 'TagScenes'>>(); const route = useRoute<RouteProp<RootStackParamList, 'TagScenes'>>();
const { slug, name } = route.params; const { slug, name, vertical } = route.params;
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
navigation.setOptions({ title: name }); navigation.setOptions({ title: name });
}, [navigation, name]); }, [navigation, name]);
const { data, isLoading, error, refetch, isRefetching } = useQuery({ const { data, isLoading, error, refetch, isRefetching } = useQuery({
queryKey: ['tag-scenes', slug], queryKey: ['tag-scenes', slug, vertical ?? null],
queryFn: () => queryFn: () =>
client.listScenes({ client.listScenes({
tags: [slug], tags: [slug],
// Wertykał przyniesiony ze sceny. Bez niego backend wycina wszystkie sekcje
// poboczne, więc tag kliknięty w anime pokazywał sceny spoza anime (e9de414c).
vertical: vertical ?? undefined,
sort: 'release_date', sort: 'release_date',
has_playback: true, has_playback: true,
per_page: 200, per_page: 200,

View file

@ -173,6 +173,10 @@ export interface SceneOut {
tags: TagOut[]; tags: TagOut[];
external_refs: ExternalRef[]; external_refs: ExternalRef[];
playback_sources: PlaybackSource[]; playback_sources: PlaybackSource[];
/** Sekcja, do której należy scena ('animation' | 'jav'), albo brak dla głównego
* katalogu. Przekazuj dalej przy przejściu do tagu/studia/performera, inaczej
* backend odetnie wertykał i lista wyjdzie pusta albo z obcymi scenami. */
vertical?: string | null;
// Kiedy scena trafiła do bazy (ingest). Używane do oznaczenia "NEW", gdy // Kiedy scena trafiła do bazy (ingest). Używane do oznaczenia "NEW", gdy
// `created_at > favoriteSeenSince` (param przekazany z FavoritesScreen). // `created_at > favoriteSeenSince` (param przekazany z FavoritesScreen).
created_at?: string | null; created_at?: string | null;