fix(favorites): +N new count = what the list actually shows
The "+N new" badge counted every live-playback scene with created_at > last_seen_at,
but the performer/studio list the user opens applies the device blacklist AND only shows
the first page (per_page=200, sort release_date desc) — so a freshly-ingested scene with
an old release date, or a blacklisted one, inflated "+N" while being invisible ("+6, no
new scenes"). Count now mirrors the list: same visibility filters (live playback +
blacklist + stub-exclusion for studios) and the same top-200 window under release_date
desc. On the main device this dropped the naive total 1663 -> 1406 (phantom off-page
scenes), and blacklisted content no longer inflates counts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c073acff03
commit
82da87457a
1 changed files with 135 additions and 45 deletions
|
|
@ -39,13 +39,98 @@ from app.models.favorite_studio import FavoriteStudio
|
||||||
from app.models.movie import Movie
|
from app.models.movie import Movie
|
||||||
from app.models.performer import Performer
|
from app.models.performer import Performer
|
||||||
from app.models.playback_source import PlaybackSource
|
from app.models.playback_source import PlaybackSource
|
||||||
from app.models.scene import Scene, ScenePerformer
|
from app.models.scene import Scene, ScenePerformer, SceneTag
|
||||||
from app.models.studio import Studio
|
from app.models.studio import Studio
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/favorites", tags=["favorites"], dependencies=[Depends(require_api_key)]
|
prefix="/favorites", tags=["favorites"], dependencies=[Depends(require_api_key)]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Licznik "+N nowych" MUSI liczyć to samo, co user zobaczy po wejściu na listę scen
|
||||||
|
# performerki/studia — inaczej pokazuje +6 a lista ma 0 (report: „+6 a nic nowego").
|
||||||
|
# Rozjazdy które to powodowały: (1) licznik nie nakładał blacklist device (gay-filter/
|
||||||
|
# ukryte tagi) którą lista nakłada, (2) nie ograniczał do pierwszej strony listy
|
||||||
|
# (per_page=200, sort release_date desc) — nowa scena o starej dacie wydania wpadała
|
||||||
|
# poza top-200 albo w ogóle poza to co widać, (3) dla studiów nie odsiewał stub-scen.
|
||||||
|
# Poniższe replikuje filtry i okno listy (app/api/scenes.py list_scenes, domyślne paramy
|
||||||
|
# mobile: has_playback=true, include_stubs=false). Trzymane ręcznie w zgodzie z tamtym.
|
||||||
|
_FAVORITES_PAGE_CAP = 200 # == per_page w PerformerScenesScreen/StudioScenesScreen
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_scene_clauses(session: Session, device_id: str, *, apply_stub: bool) -> list:
|
||||||
|
"""Klauzule WHERE = filtry widoczności listy scen: żywy playback_source +
|
||||||
|
blacklisty device + (opcjonalnie) odsianie stub-scen. Aplikowane na zapytanie
|
||||||
|
z Scene w FROM."""
|
||||||
|
from sqlalchemy import exists
|
||||||
|
|
||||||
|
clauses = [
|
||||||
|
exists(
|
||||||
|
select(1).where(
|
||||||
|
PlaybackSource.scene_id == Scene.id,
|
||||||
|
PlaybackSource.dead_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
from app.api.scenes import _blacklists_empty
|
||||||
|
|
||||||
|
if not _blacklists_empty(session, device_id):
|
||||||
|
from app.models.blacklist import (
|
||||||
|
BlacklistedPerformer,
|
||||||
|
BlacklistedStudio,
|
||||||
|
BlacklistedTag,
|
||||||
|
)
|
||||||
|
|
||||||
|
clauses.append(
|
||||||
|
~exists(
|
||||||
|
select(1)
|
||||||
|
.select_from(ScenePerformer)
|
||||||
|
.join(
|
||||||
|
BlacklistedPerformer,
|
||||||
|
(BlacklistedPerformer.performer_id == ScenePerformer.performer_id)
|
||||||
|
& (BlacklistedPerformer.device_id == device_id),
|
||||||
|
)
|
||||||
|
.where(ScenePerformer.scene_id == Scene.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
clauses.append(
|
||||||
|
~Scene.studio_id.in_(
|
||||||
|
select(BlacklistedStudio.studio_id).where(
|
||||||
|
BlacklistedStudio.device_id == device_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
clauses.append(
|
||||||
|
~exists(
|
||||||
|
select(1)
|
||||||
|
.select_from(SceneTag)
|
||||||
|
.join(
|
||||||
|
BlacklistedTag,
|
||||||
|
(BlacklistedTag.tag_id == SceneTag.tag_id)
|
||||||
|
& (BlacklistedTag.device_id == device_id),
|
||||||
|
)
|
||||||
|
.where(SceneTag.scene_id == Scene.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if apply_stub:
|
||||||
|
# Stub = tube-only scena bez release_date AND bez canonical (TPDB/StashDB) AND
|
||||||
|
# bez performera. Dla widoku performerki nigdy nie zachodzi (ma performera), więc
|
||||||
|
# apply_stub=False tam; dla studiów tak. Lustro scenes.py:348-367.
|
||||||
|
from app.models.scene import SceneExternalRef
|
||||||
|
from app.models.source import Source, SourceKind
|
||||||
|
|
||||||
|
canonical_exists = exists(
|
||||||
|
select(1)
|
||||||
|
.select_from(SceneExternalRef)
|
||||||
|
.join(Source, Source.id == SceneExternalRef.source_id)
|
||||||
|
.where(SceneExternalRef.scene_id == Scene.id)
|
||||||
|
.where(Source.kind.in_([SourceKind.tpdb, SourceKind.stashdb]))
|
||||||
|
)
|
||||||
|
has_performer = exists(select(1).where(ScenePerformer.scene_id == Scene.id))
|
||||||
|
clauses.append(
|
||||||
|
Scene.release_date.is_not(None) | canonical_exists | has_performer
|
||||||
|
)
|
||||||
|
return clauses
|
||||||
|
|
||||||
|
|
||||||
class FavoriteOut(BaseModel):
|
class FavoriteOut(BaseModel):
|
||||||
performer_id: uuid.UUID
|
performer_id: uuid.UUID
|
||||||
|
|
@ -85,39 +170,36 @@ def list_favorites(
|
||||||
# playback). Wcześniej grouped count z EXISTS playback per-request. Migracja 0019.
|
# playback). Wcześniej grouped count z EXISTS playback per-request. Migracja 0019.
|
||||||
scene_counts: dict = {perf.id: perf.scene_count for _, perf in rows}
|
scene_counts: dict = {perf.id: perf.scene_count for _, perf in rows}
|
||||||
|
|
||||||
# Batch: new_count per performer — sceny z created_at > last_seen_at favorite'a.
|
# new_count per performer = sceny created_at > last_seen_at, ale liczone TYLKO wśród
|
||||||
# Każda performerka ma INNY last_seen_at, więc warunek per-row. Trick: GREATEST jest
|
# tych, które user faktycznie zobaczy na liście: te same filtry (żywy playback +
|
||||||
# nieważny — robimy CASE per row z mapowaniem perf_id → last_seen przez VALUES list.
|
# blacklist) i to samo okno (top-_FAVORITES_PAGE_CAP pod release_date desc) co
|
||||||
# Prościej: jeden join + WHERE z OR po wszystkich (perf_id=X AND created_at>ts_X) —
|
# PerformerScenes. apply_stub=False — widok performerki i tak ma performera (nie-stub).
|
||||||
# ale to N OR-ów. Najczystsze rozwiązanie: zapytaj per-row ale wszystkie naraz w
|
|
||||||
# SQL używając IN tuple lub sub-query. Tu korzystamy z faktu że N=14 typowo, więc
|
|
||||||
# robimy unionall albo prosty (perf_id, last_seen_at) JOIN.
|
|
||||||
new_counts: dict = {}
|
new_counts: dict = {}
|
||||||
if perf_ids:
|
if perf_ids:
|
||||||
# Liczymy TYLKO sceny z żywym playback_source (has_live_playback). Powód:
|
from sqlalchemy import func
|
||||||
# TPDB/StashDB sync wstawia metadata-only stubs (52 scen Danielle Renae jednego
|
|
||||||
# dnia z 0 playback) — bumpują created_at, badge `+N`, ale w PerformerScenes
|
clauses = _visible_scene_clauses(session, device_id, apply_stub=False)
|
||||||
# mobile filtruje `has_playback=true` → 0 widocznych. Result: user widzi +48
|
rn = func.row_number().over(
|
||||||
# ale w profilu nic nowego. Filter aligns count z faktycznie oglądalnym
|
partition_by=ScenePerformer.performer_id,
|
||||||
# contentem ("new znalezisko" = scena którą da się odtworzyć).
|
order_by=(Scene.release_date.desc().nullslast(), Scene.created_at.desc()),
|
||||||
from sqlalchemy import and_, exists
|
).label("rn")
|
||||||
live_playback = exists().where(
|
inner = (
|
||||||
and_(
|
select(
|
||||||
PlaybackSource.scene_id == Scene.id,
|
ScenePerformer.performer_id.label("gid"),
|
||||||
PlaybackSource.dead_at.is_(None),
|
Scene.created_at.label("created_at"),
|
||||||
|
rn,
|
||||||
)
|
)
|
||||||
)
|
|
||||||
per_scene_rows = session.execute(
|
|
||||||
select(ScenePerformer.performer_id, Scene.created_at)
|
|
||||||
.join(Scene, Scene.id == ScenePerformer.scene_id)
|
.join(Scene, Scene.id == ScenePerformer.scene_id)
|
||||||
.where(ScenePerformer.performer_id.in_(perf_ids))
|
.where(ScenePerformer.performer_id.in_(perf_ids))
|
||||||
.where(live_playback)
|
.where(*clauses)
|
||||||
).all()
|
.subquery()
|
||||||
for pid, created_at in per_scene_rows:
|
)
|
||||||
if created_at is None:
|
for gid, created_at in session.execute(
|
||||||
continue
|
select(inner.c.gid, inner.c.created_at).where(inner.c.rn <= _FAVORITES_PAGE_CAP)
|
||||||
if created_at > last_seen_by_perf.get(pid):
|
):
|
||||||
new_counts[pid] = new_counts.get(pid, 0) + 1
|
ls = last_seen_by_perf.get(gid)
|
||||||
|
if created_at is not None and ls is not None and created_at > ls:
|
||||||
|
new_counts[gid] = new_counts.get(gid, 0) + 1
|
||||||
|
|
||||||
items: list[FavoriteOut] = []
|
items: list[FavoriteOut] = []
|
||||||
new_total = 0
|
new_total = 0
|
||||||
|
|
@ -236,26 +318,34 @@ def list_favorite_studios(
|
||||||
# scene_count: zdenormalizowany Studio.scene_count (refresh w tle, migracja 0019).
|
# scene_count: zdenormalizowany Studio.scene_count (refresh w tle, migracja 0019).
|
||||||
scene_counts: dict = {st.id: st.scene_count for _, st in rows}
|
scene_counts: dict = {st.id: st.scene_count for _, st in rows}
|
||||||
|
|
||||||
|
# new_count per studio — jak dla performerów (patrz list_favorites): te same filtry
|
||||||
|
# + okno co lista StudioScenes. apply_stub=True: studio-scena bez performera/release/
|
||||||
|
# canonical to stub który lista odsiewa, więc licznik też musi.
|
||||||
new_counts: dict = {}
|
new_counts: dict = {}
|
||||||
if studio_ids:
|
if studio_ids:
|
||||||
# has_live_playback filter — patrz `list_favorites` (performers) wyżej.
|
from sqlalchemy import func
|
||||||
from sqlalchemy import and_, exists
|
|
||||||
live_playback = exists().where(
|
clauses = _visible_scene_clauses(session, device_id, apply_stub=True)
|
||||||
and_(
|
rn = func.row_number().over(
|
||||||
PlaybackSource.scene_id == Scene.id,
|
partition_by=Scene.studio_id,
|
||||||
PlaybackSource.dead_at.is_(None),
|
order_by=(Scene.release_date.desc().nullslast(), Scene.created_at.desc()),
|
||||||
|
).label("rn")
|
||||||
|
inner = (
|
||||||
|
select(
|
||||||
|
Scene.studio_id.label("gid"),
|
||||||
|
Scene.created_at.label("created_at"),
|
||||||
|
rn,
|
||||||
)
|
)
|
||||||
)
|
|
||||||
per_scene_rows = session.execute(
|
|
||||||
select(Scene.studio_id, Scene.created_at)
|
|
||||||
.where(Scene.studio_id.in_(studio_ids))
|
.where(Scene.studio_id.in_(studio_ids))
|
||||||
.where(live_playback)
|
.where(*clauses)
|
||||||
).all()
|
.subquery()
|
||||||
for sid, created_at in per_scene_rows:
|
)
|
||||||
if created_at is None:
|
for gid, created_at in session.execute(
|
||||||
continue
|
select(inner.c.gid, inner.c.created_at).where(inner.c.rn <= _FAVORITES_PAGE_CAP)
|
||||||
if created_at > last_seen_by_studio.get(sid):
|
):
|
||||||
new_counts[sid] = new_counts.get(sid, 0) + 1
|
ls = last_seen_by_studio.get(gid)
|
||||||
|
if created_at is not None and ls is not None and created_at > ls:
|
||||||
|
new_counts[gid] = new_counts.get(gid, 0) + 1
|
||||||
|
|
||||||
items: list[FavoriteStudioOut] = []
|
items: list[FavoriteStudioOut] = []
|
||||||
new_total = 0
|
new_total = 0
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue