goon/app/api/schemas.py
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

138 lines
4.1 KiB
Python

"""Pydantic schemas eksportowane przez API."""
from __future__ import annotations
import uuid
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict
class StudioOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
slug: str
network: str | None = None
class PerformerOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
canonical_name: str
slug: str
gender: str | None = None
as_alias: str | None = None
class TagOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
slug: str
class ExternalRefOut(BaseModel):
source: str
external_id: str
url: str | None = None
last_seen: datetime | None = None
class PlaybackSourceOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
origin: str
page_url: str
embed_url: str | None = None
stream_url: str | None = None
quality: str | None = None
duration_sec: int | None = None
thumbnail_url: str | None = None
animated_thumbnail_url: str | None = None
class SceneOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
title: str
slug: str | None = None
release_date: date | None = None
duration_sec: int | None = None
description: str | None = None
code: str | None = None
director: str | None = None
studio: StudioOut | None = None
performers: list[PerformerOut] = []
tags: list[TagOut] = []
external_refs: list[ExternalRefOut] = []
playback_sources: list[PlaybackSourceOut] = []
# Kiedy scena trafiła do bazy (ingest). Używane przez mobile do oznaczenia
# "NEW" na karcie scen w PerformerScenesScreen / StudioScenesScreen — gdy
# `created_at > last_seen_at` (favorite) → badge.
created_at: datetime | None = None
# Watched indicator (z `scene_play_progress`): mobile dim'uje kafelek gdy
# `finished=True`, pokazuje progress bar gdy `position_sec > 0`.
last_played_at: datetime | None = None
finished: bool = False
position_sec: int = 0
is_favorite: bool = False
class SceneListOut(BaseModel):
items: list[SceneOut]
total: int
page: int
per_page: int
# has_more: czy istnieje kolejna strona. Liczone z fetcha per_page+1 (≈darmowe),
# NIE z `total` — bo dla filtrowanych list `total` jest bounded ("1000+") żeby
# uniknąć ~5s exhaustive count. Mobile paginuje po has_more, nie po total.
has_more: bool = False
# total_capped: True gdy `total` to bounded cap (są >total wyników). UI: "{total}+".
total_capped: bool = False
class MovieChapterOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
chapter_index: int
title: str | None = None
start_sec: int | None = None
end_sec: int | None = None
scene_id: uuid.UUID | None = None
class MovieOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
title: str
slug: str | None = None
release_year: int | None = None
release_date: date | None = None
duration_sec: int | None = None
description: str | None = None
director: str | None = None
country: str | None = None
rating: float | None = None
poster_url: str | None = None
backdrop_url: str | None = None
studio: StudioOut | None = None
performers: list[PerformerOut] = []
tags: list[TagOut] = []
chapters: list[MovieChapterOut] = []
external_refs: list[ExternalRefOut] = []
playback_sources: list[PlaybackSourceOut] = []
# Used by mobile MoviesScreen NEW badge (created_at > client-stored seenSince)
# and MovieDetail favorite star.
created_at: datetime | None = None
is_favorite: bool = False
# Watched / continue-watching state (mirror SceneOut, bug-report b207ff17
# 2026-05-26 "przydałoby się oznaczenie filmów już obejrzanych").
last_played_at: datetime | None = None
finished: bool = False
position_sec: int = 0
class MovieListOut(BaseModel):
items: list[MovieOut]
total: int
page: int
per_page: int