feat(verticals): animacja odgrodzona od glownego feedu + filmy na ekranie studia
1. WERTYKALY. Hentai i 3D szly do glownego feedu wymieszane z zachodnim katalogiem, mimo ze argument za odgrodzeniem byl ten sam co przy JAV. Zamiast drugiej kopii mechanizmu JAV wprowadzam wspolne pojecie wertykalu (app/verticals.py): mapa nazwa -> originy, jeden predykat, `?vertical=`. Brama po originie jest DOKLADNA, zmierzone: 9507 scen animowanych, ZERO z originem spoza listy (nie maja sie z czym zdeduplikowac). Dlatego osobna baza nie jest potrzebna, rozdzielam logicznie. Tagi to samodzielny powod: z 3116 tagow przy scenach animowanych tylko 452 wystepuja tez poza animacja, czyli 85% to slownictwo wylacznie animacyjne. `/tags?vertical=` liczy je NA ZYWO (male zbiory, zero migracji), a zdenormalizowany Tag.scene_count opisuje juz WYLACZNIE glowny feed. Przy okazji naprawione zasmiecenie JAV-em istniejace od lipca: taxonomy_counts wliczal wertykaly do licznikow, ktore opisuja feed ich nie pokazujacy. Refresh: 1476 tagow, 1402 performerow, 154 studia. Stary `?jav=true` DZIALA dalej (instalacje sprzed sierpnia go wysylaja). 2. FILMY NA EKRANIE STUDIA. Zgloszenie: polubione studio od filmow nie pokazuje nowych filmow i nie ma "+1". Backend liczyl je poprawnie (_merged_new_counts scala sceny i filmy od 2026-07-28). Blad byl w apce: tap na ulubione studio ZEROWAL licznik (markStudioSeen) i wchodzil na ekran wylacznie ze scenami, wiec "+N" z nowych filmow znikalo, a filmy nie mialy sie gdzie pokazac. StudioMoviesScreen istnial, ale wchodzilo sie do niego tylko z detalu filmu. StudioScenes dostaje zakladki Scenes/Movies, jak PerformerScenes. Pasek zakladek w headerze przewija sie w poziomie, bo piata pozycja (Anime) nie miescila sie na waskich telefonach.
This commit is contained in:
parent
bb1b502e9a
commit
ff898b0783
11 changed files with 424 additions and 59 deletions
|
|
@ -29,6 +29,7 @@ from app.models.favorite_scene import FavoriteScene
|
||||||
from app.models.performer import Performer, PerformerExternalRef
|
from app.models.performer import Performer, PerformerExternalRef
|
||||||
from app.models.play_progress import ScenePlayProgress
|
from app.models.play_progress import ScenePlayProgress
|
||||||
from app.models.playback_source import PlaybackSource
|
from app.models.playback_source import PlaybackSource
|
||||||
|
from app import verticals
|
||||||
from app.models.scene import Scene, SceneExternalRef, ScenePerformer, SceneTag
|
from app.models.scene import Scene, SceneExternalRef, ScenePerformer, SceneTag
|
||||||
from app.models.source import Source, SourceKind
|
from app.models.source import Source, SourceKind
|
||||||
from app.models.studio import Studio
|
from app.models.studio import Studio
|
||||||
|
|
@ -60,8 +61,8 @@ def _default_scene_count(session: Session) -> int:
|
||||||
PlaybackSource.dead_at.is_(None),
|
PlaybackSource.dead_at.is_(None),
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
# Domyślny feed wyklucza JAV (osobna sekcja) → licznik też.
|
# Domyślny feed wyklucza wertykały (JAV, animacja) → licznik też.
|
||||||
~_jav_source_exists(),
|
~_vertical_source_exists(),
|
||||||
).subquery()
|
).subquery()
|
||||||
)
|
)
|
||||||
total = session.execute(count_query).scalar_one()
|
total = session.execute(count_query).scalar_one()
|
||||||
|
|
@ -70,23 +71,29 @@ def _default_scene_count(session: Session) -> int:
|
||||||
return total
|
return total
|
||||||
|
|
||||||
|
|
||||||
# JAV vertical, osobna sekcja. Sceny z tych originów NIE wchodzą do głównego feedu
|
# Wertykały (JAV, animacja) mają własne sekcje w apce i NIE wchodzą do głównego feedu.
|
||||||
# (domyślnie wykluczone), tylko do zakładki JAV (?jav=true). JAV to osobny namespace
|
# Definicje i uzasadnienie: `app/verticals.py`. Tu zostaje tylko budowa predykatu.
|
||||||
# (kody typu BKD-368, azjatyckie tytuły), nie deduplikuje się z zachodnim katalogiem,
|
# `JAV_ORIGINS` re-eksportowane, bo importują je inne moduły i skrypty.
|
||||||
# więc scena JAV ma WYŁĄCZNIE origin JAV → wykluczenie/inkluzja po tym originie jest pewna.
|
JAV_ORIGINS = verticals.JAV_ORIGINS
|
||||||
JAV_ORIGINS = ("tube:javflix", "tube:javguru", "tube:vjav", "tube:supjav")
|
|
||||||
|
|
||||||
|
|
||||||
def _jav_source_exists():
|
def _vertical_source_exists(vertical: str | None = None):
|
||||||
|
"""EXISTS żywego źródła z originem wertykału. `None` → DOWOLNY wertykał, czyli
|
||||||
|
predykat, którym główny feed odcina je wszystkie naraz."""
|
||||||
return exists(
|
return exists(
|
||||||
select(1).where(
|
select(1).where(
|
||||||
PlaybackSource.scene_id == Scene.id,
|
PlaybackSource.scene_id == Scene.id,
|
||||||
PlaybackSource.dead_at.is_(None),
|
PlaybackSource.dead_at.is_(None),
|
||||||
PlaybackSource.origin.in_(JAV_ORIGINS),
|
PlaybackSource.origin.in_(verticals.origins_for(vertical)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _jav_source_exists():
|
||||||
|
"""Zachowane pod starą nazwą, używane przez `/scenes/{id}/similar` i testy."""
|
||||||
|
return _vertical_source_exists("jav")
|
||||||
|
|
||||||
|
|
||||||
# Blacklisty (performer/studio/tag) są zwykle PUSTE (self-hosted, single-user). Mimo to
|
# Blacklisty (performer/studio/tag) są zwykle PUSTE (self-hosted, single-user). Mimo to
|
||||||
# 3 NOT EXISTS klauzule doklejały się do KAŻDEJ filtrowanej listy scen i były ewaluowane
|
# 3 NOT EXISTS klauzule doklejały się do KAŻDEJ filtrowanej listy scen i były ewaluowane
|
||||||
# per-row, przy filtrze typu duży-tag/has_playback planer chodzi po ~176k scen, więc te
|
# per-row, przy filtrze typu duży-tag/has_playback planer chodzi po ~176k scen, więc te
|
||||||
|
|
@ -241,6 +248,14 @@ def list_scenes(
|
||||||
"z jedynym playback z hqporner (~7-min Brazzers trailer clipy zalewają katalog)."
|
"z jedynym playback z hqporner (~7-min Brazzers trailer clipy zalewają katalog)."
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
vertical: str | None = Query(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Wertykał do pokazania: 'jav' albo 'animation'. Brak → główny feed, "
|
||||||
|
"czyli WSZYSTKIE wertykały wykluczone. Zastępuje starsze `jav=true`, "
|
||||||
|
"które zostaje dla instalacji sprzed sierpnia 2026."
|
||||||
|
),
|
||||||
|
),
|
||||||
jav: bool = Query(
|
jav: bool = Query(
|
||||||
default=False,
|
default=False,
|
||||||
description=(
|
description=(
|
||||||
|
|
@ -348,14 +363,16 @@ def list_scenes(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# JAV vertical gate: domyślnie JAV wykluczone z głównego feedu; ?jav=true → TYLKO JAV.
|
# Brama wertykałów: domyślnie WSZYSTKIE wykluczone z głównego feedu, `?vertical=X`
|
||||||
# ALE gdy user browse'uje KONKRETNY origin (Sites → javflix/vjav/supjav → SiteScenes),
|
# → TYLKO ten wertykał. ALE gdy user browse'uje KONKRETNY origin (Sites → javflix/
|
||||||
# bramy nie stosujemy, jawnie chce ten origin, więc pokazujemy (regresja "No videos"
|
# vjav/hentaigasm → SiteScenes), bramy nie stosujemy, bo jawnie chce ten origin
|
||||||
# na stronach JAV, bug-report 2026-07-19). Brama dotyczy tylko głównego feedu (origin=None).
|
# (regresja "No videos" na stronach JAV, bug-report 2026-07-19). Brama dotyczy tylko
|
||||||
if jav:
|
# głównego feedu (origin=None).
|
||||||
base = base.where(_jav_source_exists())
|
wanted = vertical if vertical in verticals.VERTICALS else ("jav" if jav else None)
|
||||||
|
if wanted:
|
||||||
|
base = base.where(_vertical_source_exists(wanted))
|
||||||
elif origin is None:
|
elif origin is None:
|
||||||
base = base.where(~_jav_source_exists())
|
base = base.where(~_vertical_source_exists())
|
||||||
|
|
||||||
# Blacklisty device (performer/studio/tag), globalne wykluczenia, współdzielone z
|
# Blacklisty device (performer/studio/tag), globalne wykluczenia, współdzielone z
|
||||||
# licznikiem +N ulubionych. Puste blacklisty → [] (typowy single-user, zero kosztu).
|
# licznikiem +N ulubionych. Puste blacklisty → [] (typowy single-user, zero kosztu).
|
||||||
|
|
@ -408,7 +425,7 @@ def list_scenes(
|
||||||
and not perf_id_strings and origin is None and has_playback is None
|
and not perf_id_strings and origin is None and has_playback is None
|
||||||
and min_duration_sec is None
|
and min_duration_sec is None
|
||||||
and max_duration_sec is None and released_within_days is None
|
and max_duration_sec is None and released_within_days is None
|
||||||
and min_quality_p is None and not jav
|
and min_quality_p is None and not jav and vertical is None
|
||||||
)
|
)
|
||||||
# Count strategy:
|
# Count strategy:
|
||||||
# - PURE default: cached pełny licznik katalogu (TTL 10 min).
|
# - PURE default: cached pełny licznik katalogu (TTL 10 min).
|
||||||
|
|
@ -602,19 +619,24 @@ def similar_scenes(
|
||||||
if not cand_ids:
|
if not cand_ids:
|
||||||
return _empty
|
return _empty
|
||||||
|
|
||||||
# Filtr widoczności (ta sama definicja co lista): grywalne + zgodne z bramą JAV +
|
# Filtr widoczności (ta sama definicja co lista): grywalne + ten sam wertykał +
|
||||||
# blacklist + nie-stub + nie-self.
|
# blacklist + nie-stub + nie-self.
|
||||||
target_is_jav = bool(
|
# Podobne muszą zostać w tym samym wertykale co scena-cel: dla sceny animowanej
|
||||||
session.execute(
|
# nie proponujemy zachodnich i odwrotnie (inne namespace'y, zero dedupu).
|
||||||
select(_jav_source_exists()).select_from(Scene).where(Scene.id == scene_id)
|
target_vertical: str | None = None
|
||||||
).scalar()
|
for _name in verticals.VERTICALS:
|
||||||
)
|
if session.execute(
|
||||||
|
select(_vertical_source_exists(_name)).select_from(Scene).where(Scene.id == scene_id)
|
||||||
|
).scalar():
|
||||||
|
target_vertical = _name
|
||||||
|
break
|
||||||
filt = select(Scene.id).where(
|
filt = select(Scene.id).where(
|
||||||
Scene.id.in_(cand_ids),
|
Scene.id.in_(cand_ids),
|
||||||
Scene.id != scene_id,
|
Scene.id != scene_id,
|
||||||
live_playback_exists(),
|
live_playback_exists(),
|
||||||
stub_exclusion_clause(),
|
stub_exclusion_clause(),
|
||||||
_jav_source_exists() if target_is_jav else ~_jav_source_exists(),
|
_vertical_source_exists(target_vertical) if target_vertical
|
||||||
|
else ~_vertical_source_exists(),
|
||||||
)
|
)
|
||||||
for _bl in blacklist_clauses(session, device_id):
|
for _bl in blacklist_clauses(session, device_id):
|
||||||
filt = filt.where(_bl)
|
filt = filt.where(_bl)
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ from app.models.playback_source import PlaybackSource
|
||||||
from app.models.scene import ScenePerformer, SceneTag
|
from app.models.scene import ScenePerformer, SceneTag
|
||||||
from app.models.studio import Studio
|
from app.models.studio import Studio
|
||||||
from app.models.tag import Tag
|
from app.models.tag import Tag
|
||||||
|
from app.verticals import VERTICALS
|
||||||
|
|
||||||
router = APIRouter(tags=["taxonomies"], dependencies=[Depends(require_api_key)])
|
router = APIRouter(tags=["taxonomies"], dependencies=[Depends(require_api_key)])
|
||||||
|
|
||||||
|
|
@ -102,6 +103,13 @@ def list_tags(
|
||||||
" Filtruje krótkie listy filtrów żeby nie pokazywać tagów-sierot."
|
" Filtruje krótkie listy filtrów żeby nie pokazywać tagów-sierot."
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
vertical: str | None = Query(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Wertykał ('jav'/'animation'): licz i pokazuj TYLKO tagi jego scen. "
|
||||||
|
"Brak → główny feed, bez tagów wertykałowych."
|
||||||
|
),
|
||||||
|
),
|
||||||
) -> TagListOut:
|
) -> TagListOut:
|
||||||
if order not in ("popular", "scene_count", "name"):
|
if order not in ("popular", "scene_count", "name"):
|
||||||
raise HTTPException(status_code=400, detail="order must be 'popular' or 'name'")
|
raise HTTPException(status_code=400, detail="order must be 'popular' or 'name'")
|
||||||
|
|
@ -146,6 +154,54 @@ def list_tags(
|
||||||
]
|
]
|
||||||
return TagListOut(items=items, total=total, page=page, per_page=per_page)
|
return TagListOut(items=items, total=total, page=page, per_page=per_page)
|
||||||
|
|
||||||
|
if vertical in VERTICALS:
|
||||||
|
# Wertykał liczymy NA ŻYWO, bez denormalizacji: to małe zbiory (animacja ~9,5 tys.
|
||||||
|
# scen wobec 3,8 mln całości), więc agregat jest tani, a dokładanie kolumny
|
||||||
|
# `scene_count_<wertykał>` do `tags` kosztowałoby migrację przy każdym nowym
|
||||||
|
# wertykale. Zdenormalizowany `Tag.scene_count` opisuje WYŁĄCZNIE główny feed.
|
||||||
|
v_scenes = (
|
||||||
|
select(PlaybackSource.scene_id)
|
||||||
|
.where(
|
||||||
|
PlaybackSource.dead_at.is_(None),
|
||||||
|
PlaybackSource.origin.in_(VERTICALS[vertical]),
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
v_count = (
|
||||||
|
select(SceneTag.tag_id, func.count().label("c"))
|
||||||
|
.join(v_scenes, v_scenes.c.scene_id == SceneTag.scene_id)
|
||||||
|
.group_by(SceneTag.tag_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
base = (
|
||||||
|
select(Tag, func.coalesce(v_count.c.c, 0).label("scene_count"))
|
||||||
|
.outerjoin(v_count, v_count.c.tag_id == Tag.id)
|
||||||
|
)
|
||||||
|
if q:
|
||||||
|
base = base.where(Tag.name.ilike(f"%{q}%"))
|
||||||
|
# Tag spoza wertykału ma tu 0, więc `only_with_content` sam odsiewa zachodnie
|
||||||
|
# słownictwo. Dla wertykału trzymamy to WŁĄCZONE zawsze: lista bez tego byłaby
|
||||||
|
# w 99% tagami, których w tej sekcji nie ma czym wypełnić.
|
||||||
|
base = base.where(v_count.c.tag_id.is_not(None))
|
||||||
|
total = session.execute(
|
||||||
|
select(func.count()).select_from(base.subquery())
|
||||||
|
).scalar_one()
|
||||||
|
if order in ("popular", "scene_count"):
|
||||||
|
ordered = base.order_by(func.coalesce(v_count.c.c, 0).desc(), Tag.name.asc())
|
||||||
|
else:
|
||||||
|
ordered = base.order_by(Tag.name.asc())
|
||||||
|
rows = session.execute(
|
||||||
|
ordered.offset((page - 1) * per_page).limit(per_page)
|
||||||
|
).all()
|
||||||
|
return TagListOut(
|
||||||
|
items=[
|
||||||
|
TagCount(id=t.id, name=t.name, slug=t.slug, scene_count=int(c))
|
||||||
|
for t, c in rows
|
||||||
|
],
|
||||||
|
total=total, page=page, per_page=per_page,
|
||||||
|
)
|
||||||
|
|
||||||
# Scenes path, czyta zdenormalizowany Tag.scene_count (refresh w tle przez
|
# Scenes path, czyta zdenormalizowany Tag.scene_count (refresh w tle przez
|
||||||
# _job_refresh_taxonomy_counts). Wcześniej liczył agregat 6.3M scene_tags +
|
# _job_refresh_taxonomy_counts). Wcześniej liczył agregat 6.3M scene_tags +
|
||||||
# EXISTS playback per-request (~4.3s ×2). Patrz migracja 0019.
|
# EXISTS playback per-request (~4.3s ×2). Patrz migracja 0019.
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import logging
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from app.db import session_scope
|
from app.db import session_scope
|
||||||
|
from app.verticals import ALL_VERTICAL_ORIGINS
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -35,6 +36,16 @@ _LIVE = (
|
||||||
"WHERE ps.scene_id = {scene_col} AND ps.dead_at IS NULL)"
|
"WHERE ps.scene_id = {scene_col} AND ps.dead_at IS NULL)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Sceny z wertykałów (JAV, animacja) NIE wchodzą do głównego feedu, więc nie mogą
|
||||||
|
# wchodzić do licznika, który ten feed opisuje. Bez tego lista tagów obiecywała treść,
|
||||||
|
# której filtr i tak nie pokaże: 2664 tagi wyłącznie animacyjne (nazwy postaci, serie)
|
||||||
|
# i cały słownik JAV. To druga strona bramy z `app/api/scenes.py`, musi się z nią zgadzać.
|
||||||
|
_NOT_VERTICAL = (
|
||||||
|
"NOT EXISTS (SELECT 1 FROM playback_sources pv "
|
||||||
|
"WHERE pv.scene_id = {scene_col} AND pv.dead_at IS NULL "
|
||||||
|
"AND pv.origin = ANY(:vertical_origins))"
|
||||||
|
)
|
||||||
|
|
||||||
# Duration check po scene_id (dla tags/performers gdzie nie mamy scene-row w scope).
|
# Duration check po scene_id (dla tags/performers gdzie nie mamy scene-row w scope).
|
||||||
_DUR = "(SELECT s2.duration_sec FROM scenes s2 WHERE s2.id = {scene_col}) >= " + str(_MIN_DURATION_SEC)
|
_DUR = "(SELECT s2.duration_sec FROM scenes s2 WHERE s2.id = {scene_col}) >= " + str(_MIN_DURATION_SEC)
|
||||||
|
|
||||||
|
|
@ -47,6 +58,7 @@ _TAGS_SQL = text(
|
||||||
FROM scene_tags st
|
FROM scene_tags st
|
||||||
WHERE {_LIVE.format(scene_col="st.scene_id")}
|
WHERE {_LIVE.format(scene_col="st.scene_id")}
|
||||||
AND {_DUR.format(scene_col="st.scene_id")}
|
AND {_DUR.format(scene_col="st.scene_id")}
|
||||||
|
AND {_NOT_VERTICAL.format(scene_col="st.scene_id")}
|
||||||
GROUP BY st.tag_id
|
GROUP BY st.tag_id
|
||||||
) a ON a.tag_id = base.id
|
) a ON a.tag_id = base.id
|
||||||
WHERE t.id = base.id AND t.scene_count IS DISTINCT FROM COALESCE(a.c, 0)
|
WHERE t.id = base.id AND t.scene_count IS DISTINCT FROM COALESCE(a.c, 0)
|
||||||
|
|
@ -62,6 +74,7 @@ _PERFORMERS_SQL = text(
|
||||||
FROM scene_performers sp
|
FROM scene_performers sp
|
||||||
WHERE {_LIVE.format(scene_col="sp.scene_id")}
|
WHERE {_LIVE.format(scene_col="sp.scene_id")}
|
||||||
AND {_DUR.format(scene_col="sp.scene_id")}
|
AND {_DUR.format(scene_col="sp.scene_id")}
|
||||||
|
AND {_NOT_VERTICAL.format(scene_col="sp.scene_id")}
|
||||||
GROUP BY sp.performer_id
|
GROUP BY sp.performer_id
|
||||||
) a ON a.performer_id = base.id
|
) a ON a.performer_id = base.id
|
||||||
WHERE p.id = base.id AND p.scene_count IS DISTINCT FROM COALESCE(a.c, 0)
|
WHERE p.id = base.id AND p.scene_count IS DISTINCT FROM COALESCE(a.c, 0)
|
||||||
|
|
@ -77,6 +90,7 @@ _STUDIOS_SQL = text(
|
||||||
FROM scenes sc
|
FROM scenes sc
|
||||||
WHERE sc.studio_id IS NOT NULL AND {_LIVE.format(scene_col="sc.id")}
|
WHERE sc.studio_id IS NOT NULL AND {_LIVE.format(scene_col="sc.id")}
|
||||||
AND sc.duration_sec >= {_MIN_DURATION_SEC}
|
AND sc.duration_sec >= {_MIN_DURATION_SEC}
|
||||||
|
AND {_NOT_VERTICAL.format(scene_col="sc.id")}
|
||||||
GROUP BY sc.studio_id
|
GROUP BY sc.studio_id
|
||||||
) a ON a.studio_id = base.id
|
) a ON a.studio_id = base.id
|
||||||
WHERE s.id = base.id AND s.scene_count IS DISTINCT FROM COALESCE(a.c, 0)
|
WHERE s.id = base.id AND s.scene_count IS DISTINCT FROM COALESCE(a.c, 0)
|
||||||
|
|
@ -94,7 +108,7 @@ def refresh_taxonomy_counts() -> dict[str, int]:
|
||||||
("performers", _PERFORMERS_SQL),
|
("performers", _PERFORMERS_SQL),
|
||||||
("studios", _STUDIOS_SQL),
|
("studios", _STUDIOS_SQL),
|
||||||
):
|
):
|
||||||
res = session.execute(stmt)
|
res = session.execute(stmt, {"vertical_origins": list(ALL_VERTICAL_ORIGINS)})
|
||||||
out[name] = res.rowcount or 0
|
out[name] = res.rowcount or 0
|
||||||
# Commit per-tabela, długie transakcje trzymałyby locki na hot tables.
|
# Commit per-tabela, długie transakcje trzymałyby locki na hot tables.
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
|
||||||
57
app/verticals.py
Normal file
57
app/verticals.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
"""Wertykały: treść, która żyje w osobnej sekcji apki, nie w głównym feedzie.
|
||||||
|
|
||||||
|
Wertykał to zbiór originów, których treść **nie miesza się** z zachodnim katalogiem:
|
||||||
|
osobny namespace tytułów, osobne słownictwo tagów, brak dedupu między wertykałem
|
||||||
|
a resztą. Dlatego nie chcemy ich w jednym strumieniu, tak samo jak nie chcemy
|
||||||
|
mieszać JAV z zachodnimi scenami.
|
||||||
|
|
||||||
|
**Dlaczego brama po originie jest pewna, a nie heurystyczna.** Scena z wertykału ma
|
||||||
|
WYŁĄCZNIE originy tego wertykału, bo nie ma się z czym zdeduplikować. Zmierzone
|
||||||
|
2026-08-13: 9507 scen animowanych, **zero** z originem spoza listy. To znaczy, że
|
||||||
|
`EXISTS(origin IN ...)` dzieli katalog dokładnie, bez fałszywych trafień w obie strony.
|
||||||
|
|
||||||
|
**Dlaczego nie osobna baza.** Rozważane i odrzucone 2026-08-13: skoro rozdzielenie po
|
||||||
|
originie jest dokładne, osobna baza kosztowałaby rozjazd migracji, backupów i wspólnych
|
||||||
|
tabel performerów/tagów/dedupu, nie dając nic ponad to, co daje ta lista.
|
||||||
|
|
||||||
|
**Tagi to samodzielny powód rozdzielenia.** Z 3116 tagów występujących przy scenach
|
||||||
|
animowanych tylko 452 pojawiają się też poza animacją, czyli **85% to słownictwo
|
||||||
|
wyłącznie animacyjne** (nazwy postaci, serie, gatunki rysunkowe). Bez rozdzielenia
|
||||||
|
lista tagów w głównym feedzie ciągnie 2664 pozycje, których nie da się tam użyć.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
#: JAV: kody typu BKD-368, tytuły po japońsku. Osobna zakładka od 2026-07.
|
||||||
|
JAV_ORIGINS = (
|
||||||
|
"tube:javflix",
|
||||||
|
"tube:javguru",
|
||||||
|
"tube:vjav",
|
||||||
|
"tube:supjav",
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Hentai i 3D. Dla animacji nie istnieje kanon (TPDB/StashDB nie mają rysowanych
|
||||||
|
#: postaci), więc te sceny nie mają obsady i nigdy nie zmatchują się z zachodnimi.
|
||||||
|
ANIMATION_ORIGINS = (
|
||||||
|
"tube:hentaimama",
|
||||||
|
"tube:xanimeporn",
|
||||||
|
"tube:hentaigasm",
|
||||||
|
"tube:hentaiplay",
|
||||||
|
"tube:rule34video",
|
||||||
|
"tube:naughtymachinima",
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Nazwa wertykału → originy. Klucze są częścią kontraktu API (`?vertical=`).
|
||||||
|
VERTICALS: dict[str, tuple[str, ...]] = {
|
||||||
|
"jav": JAV_ORIGINS,
|
||||||
|
"animation": ANIMATION_ORIGINS,
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Wszystko, co ma NIE wchodzić do głównego feedu.
|
||||||
|
ALL_VERTICAL_ORIGINS: tuple[str, ...] = JAV_ORIGINS + ANIMATION_ORIGINS
|
||||||
|
|
||||||
|
|
||||||
|
def origins_for(vertical: str | None) -> tuple[str, ...]:
|
||||||
|
"""Originy wertykału. `None` → wszystkie originy wertykałowe (do wykluczania)."""
|
||||||
|
if vertical is None:
|
||||||
|
return ALL_VERTICAL_ORIGINS
|
||||||
|
return VERTICALS.get(vertical, ())
|
||||||
|
|
@ -143,6 +143,8 @@ export class GoonClient {
|
||||||
if (params.min_quality_p !== undefined) qs.set('min_quality_p', String(params.min_quality_p));
|
if (params.min_quality_p !== undefined) qs.set('min_quality_p', String(params.min_quality_p));
|
||||||
if (params.include_stubs !== undefined) qs.set('include_stubs', String(params.include_stubs));
|
if (params.include_stubs !== undefined) qs.set('include_stubs', String(params.include_stubs));
|
||||||
if (params.jav) qs.set('jav', 'true');
|
if (params.jav) qs.set('jav', 'true');
|
||||||
|
// Wertykał (animation/jav): backend odcina go od głównego feedu i odwrotnie.
|
||||||
|
if (params.vertical) qs.set('vertical', params.vertical);
|
||||||
if (params.origin) qs.set('origin', params.origin);
|
if (params.origin) qs.set('origin', params.origin);
|
||||||
if (params.sort) qs.set('sort', params.sort);
|
if (params.sort) qs.set('sort', params.sort);
|
||||||
qs.set('page', String(params.page ?? 1));
|
qs.set('page', String(params.page ?? 1));
|
||||||
|
|
@ -220,6 +222,9 @@ export class GoonClient {
|
||||||
per_page?: number;
|
per_page?: number;
|
||||||
for_movies?: boolean;
|
for_movies?: boolean;
|
||||||
only_with_content?: boolean;
|
only_with_content?: boolean;
|
||||||
|
// Wertykał: lista tagów TEJ sekcji. Bez tego zakładka animacji dostawała
|
||||||
|
// zachodnie słownictwo, a główny feed 2664 tagi, których nie ma czym wypełnić.
|
||||||
|
vertical?: string;
|
||||||
} = {}): Promise<TagListOut> {
|
} = {}): Promise<TagListOut> {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (params.q) qs.set('q', params.q);
|
if (params.q) qs.set('q', params.q);
|
||||||
|
|
@ -228,6 +233,7 @@ export class GoonClient {
|
||||||
qs.set('per_page', String(params.per_page ?? 200));
|
qs.set('per_page', String(params.per_page ?? 200));
|
||||||
if (params.for_movies) qs.set('for_movies', 'true');
|
if (params.for_movies) qs.set('for_movies', 'true');
|
||||||
if (params.only_with_content) qs.set('only_with_content', 'true');
|
if (params.only_with_content) qs.set('only_with_content', 'true');
|
||||||
|
if (params.vertical) qs.set('vertical', params.vertical);
|
||||||
return this.request(`/tags?${qs.toString()}`);
|
return this.request(`/tags?${qs.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,14 @@ export type ChangelogEntry = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: ChangelogEntry[] = [
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
id: '2026-08-13b',
|
||||||
|
date: 'August 2026',
|
||||||
|
items: [
|
||||||
|
'Animated and 3D content has moved into its own Anime tab and no longer shows up in the main scene feed. Its tag list is separate too, so you no longer scroll past thousands of tags that only exist for animation.',
|
||||||
|
'Followed studios now show their movies. Opening a studio you follow gives you a Movies tab next to Scenes, so a "+1" that came from a new movie actually leads somewhere.',
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: '2026-08-13',
|
id: '2026-08-13',
|
||||||
date: 'August 2026',
|
date: 'August 2026',
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { GoonMark } from './components/GoonWordmark';
|
||||||
import { GoonClient } from './api';
|
import { GoonClient } from './api';
|
||||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Pressable, Text, View } from 'react-native';
|
import { Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
import { AppLockSettingsScreen } from './screens/AppLockSettingsScreen';
|
import { AppLockSettingsScreen } from './screens/AppLockSettingsScreen';
|
||||||
import { BlacklistScreen } from './screens/BlacklistScreen';
|
import { BlacklistScreen } from './screens/BlacklistScreen';
|
||||||
import { DiagnosticBrowserScreen } from './screens/DiagnosticBrowserScreen';
|
import { DiagnosticBrowserScreen } from './screens/DiagnosticBrowserScreen';
|
||||||
|
|
@ -35,6 +35,10 @@ export type RootStackParamList = {
|
||||||
Sites: undefined;
|
Sites: undefined;
|
||||||
// JAV: osobna sekcja, ScenesScreen z paramem jav → backend ?jav=true.
|
// JAV: osobna sekcja, ScenesScreen z paramem jav → backend ?jav=true.
|
||||||
JAV: { jav: true } | undefined;
|
JAV: { jav: true } | undefined;
|
||||||
|
// Anime: hentai i 3D, ScenesScreen z paramem vertical → backend ?vertical=animation.
|
||||||
|
// Osobna sekcja z tego samego powodu co JAV: inny namespace tytułów, inne słownictwo
|
||||||
|
// tagów (85% tagów animacji nie występuje nigdzie indziej), zero dedupu z resztą.
|
||||||
|
Anime: { vertical: 'animation' } | undefined;
|
||||||
// `origin`: raw playback_source.origin (np. 'tube:hqpornercom'). Idzie do
|
// `origin`: raw playback_source.origin (np. 'tube:hqpornercom'). Idzie do
|
||||||
// listScenes({origin}), backend robi substring match. `name`: display name
|
// listScenes({origin}), backend robi substring match. `name`: display name
|
||||||
// do title bara (np. 'hqporner.com').
|
// do title bara (np. 'hqporner.com').
|
||||||
|
|
@ -103,7 +107,7 @@ export type RootStackParamList = {
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||||
|
|
||||||
type TopTab = 'Scenes' | 'Movies' | 'Sites' | 'JAV';
|
type TopTab = 'Scenes' | 'Movies' | 'Sites' | 'JAV' | 'Anime';
|
||||||
|
|
||||||
function TopTabs({
|
function TopTabs({
|
||||||
current,
|
current,
|
||||||
|
|
@ -112,9 +116,17 @@ function TopTabs({
|
||||||
current: TopTab;
|
current: TopTab;
|
||||||
onNavigate: (tab: TopTab) => void;
|
onNavigate: (tab: TopTab) => void;
|
||||||
}) {
|
}) {
|
||||||
const tabs: TopTab[] = ['Scenes', 'Movies', 'Sites', 'JAV'];
|
const tabs: TopTab[] = ['Scenes', 'Movies', 'Sites', 'JAV', 'Anime'];
|
||||||
|
// Piąta zakładka (Anime) nie mieści się na wąskich telefonach obok akcji po prawej,
|
||||||
|
// więc pasek przewija się w poziomie zamiast nachodzić. `flexShrink` trzyma go w swojej
|
||||||
|
// połowie headera, bez tego ScrollView rozpycha się na całą szerokość.
|
||||||
return (
|
return (
|
||||||
<View style={{ flexDirection: 'row', gap: 10, paddingHorizontal: 10, alignItems: 'center' }}>
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={{ flexGrow: 0, flexShrink: 1 }}
|
||||||
|
contentContainerStyle={{ flexDirection: 'row', gap: 10, paddingHorizontal: 10, alignItems: 'center' }}
|
||||||
|
>
|
||||||
{/* Logo (mark), spójne z ekranem logowania; węższe od wordmarku, więc Scenes/Movies/Sites
|
{/* Logo (mark), spójne z ekranem logowania; węższe od wordmarku, więc Scenes/Movies/Sites
|
||||||
+ akcje po prawej mieszczą się bez nachodzenia na wąskich telefonach. */}
|
+ akcje po prawej mieszczą się bez nachodzenia na wąskich telefonach. */}
|
||||||
<GoonMark size={30} />
|
<GoonMark size={30} />
|
||||||
|
|
@ -136,7 +148,7 @@ function TopTabs({
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -269,6 +281,28 @@ export function AppNavigator({ onLogout, client, appVersion }: AppNavigatorProps
|
||||||
),
|
),
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="Anime"
|
||||||
|
component={ScenesScreen}
|
||||||
|
initialParams={{ vertical: 'animation' }}
|
||||||
|
options={({ navigation }) => ({
|
||||||
|
title: '',
|
||||||
|
headerBackVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<TopTabs
|
||||||
|
current="Anime"
|
||||||
|
onNavigate={(t) => navigation.replace(t)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
headerRight: () => (
|
||||||
|
<View style={{ flexDirection: 'row', gap: 14, alignItems: 'center' }}>
|
||||||
|
<Pressable onPress={onLogout} hitSlop={12}>
|
||||||
|
<Text style={{ fontSize: 17 }}>🚪</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="SiteScenes"
|
name="SiteScenes"
|
||||||
component={SiteScenesScreen}
|
component={SiteScenesScreen}
|
||||||
|
|
|
||||||
|
|
@ -61,11 +61,15 @@ export function ScenesFilterModal({
|
||||||
initial,
|
initial,
|
||||||
onApply,
|
onApply,
|
||||||
onClose,
|
onClose,
|
||||||
|
vertical,
|
||||||
}: {
|
}: {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
initial: FilterState;
|
initial: FilterState;
|
||||||
onApply: (next: FilterState) => void;
|
onApply: (next: FilterState) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
/** Wertykał ekranu ('animation'/'jav'); filtruje listę tagów do jego słownika.
|
||||||
|
* Bez tego sekcja animacji dostawała zachodnie tagi, których tam nie ma czym wypełnić. */
|
||||||
|
vertical?: string;
|
||||||
}) {
|
}) {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const [draft, setDraft] = useState<FilterState>(initial);
|
const [draft, setDraft] = useState<FilterState>(initial);
|
||||||
|
|
@ -79,8 +83,16 @@ export function ScenesFilterModal({
|
||||||
}, [visible, initial]);
|
}, [visible, initial]);
|
||||||
|
|
||||||
const tagsQuery = useQuery({
|
const tagsQuery = useQuery({
|
||||||
queryKey: ['filter-tags', tagQ],
|
queryKey: ['filter-tags', tagQ, vertical],
|
||||||
queryFn: () => client.listTags({ q: tagQ || undefined, per_page: 200 }),
|
queryFn: () =>
|
||||||
|
client.listTags({
|
||||||
|
q: tagQ || undefined,
|
||||||
|
per_page: 200,
|
||||||
|
vertical,
|
||||||
|
// Wertykał liczy tagi na żywo i zwraca tylko swoje; w głównym feedzie
|
||||||
|
// zostawiamy dotychczasowe zachowanie.
|
||||||
|
only_with_content: vertical ? true : undefined,
|
||||||
|
}),
|
||||||
enabled: visible,
|
enabled: visible,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,14 @@ export function ScenesScreen() {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const { gridColumns } = usePreferences();
|
const { gridColumns } = usePreferences();
|
||||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Scenes'>>();
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Scenes'>>();
|
||||||
// Zakładka JAV reużywa ten ekran z paramem jav → backend ?jav=true (osobny vertical).
|
// Zakładki wertykałów reużywają ten ekran: JAV przez stary param `jav`, Anime przez
|
||||||
|
// `vertical='animation'`. Brak obu → główny feed, z którego backend odcina wertykały.
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const jav = (route.params as { jav?: boolean } | undefined)?.jav === true;
|
const params = route.params as { jav?: boolean; vertical?: string } | undefined;
|
||||||
|
const jav = params?.jav === true;
|
||||||
|
const vertical = params?.vertical;
|
||||||
|
// Wspólna nazwa wertykału do zapytań o tagi (stary `jav` też ma swój słownik).
|
||||||
|
const verticalKey = vertical ?? (jav ? 'jav' : undefined);
|
||||||
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);
|
||||||
|
|
@ -54,7 +59,7 @@ export function ScenesScreen() {
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
} = useInfiniteQuery({
|
} = useInfiniteQuery({
|
||||||
queryKey: ['scenes', debouncedQ, filter, jav],
|
queryKey: ['scenes', debouncedQ, filter, jav, vertical],
|
||||||
queryFn: ({ pageParam = 1 }) =>
|
queryFn: ({ pageParam = 1 }) =>
|
||||||
client.listScenes({
|
client.listScenes({
|
||||||
q: debouncedQ || undefined,
|
q: debouncedQ || undefined,
|
||||||
|
|
@ -66,10 +71,17 @@ export function ScenesScreen() {
|
||||||
include_stubs: filter.includeStubs || undefined,
|
include_stubs: filter.includeStubs || undefined,
|
||||||
origin: filter.origin.trim() || undefined,
|
origin: filter.origin.trim() || undefined,
|
||||||
jav: jav || undefined,
|
jav: jav || undefined,
|
||||||
|
vertical,
|
||||||
// 0 = "Any" → undefined → backend stosuje bazowy próg 60s. >0 podnosi minimum.
|
// 0 = "Any" → undefined → backend stosuje bazowy próg 60s. >0 podnosi minimum.
|
||||||
// JAV nie ma duration (javflix nie podaje) → w sekcji JAV wyłączamy próg 60s
|
// JAV nie ma duration (javflix nie podaje) → w sekcji JAV wyłączamy próg 60s
|
||||||
// (explicit 0), inaczej NULL >= 60 = false i cała sekcja byłaby pusta.
|
// (explicit 0), inaczej NULL >= 60 = false i cała sekcja byłaby pusta.
|
||||||
min_duration_sec: jav ? 0 : filter.minDurationSec > 0 ? filter.minDurationSec : undefined,
|
// JAV i animacja: część źródeł nie podaje długości (javflix, hentaigasm,
|
||||||
|
// hentaiplay) → NULL >= 60 = false wycięłoby całą sekcję. Explicit 0 wyłącza próg.
|
||||||
|
min_duration_sec: verticalKey
|
||||||
|
? 0
|
||||||
|
: filter.minDurationSec > 0
|
||||||
|
? filter.minDurationSec
|
||||||
|
: undefined,
|
||||||
page: pageParam,
|
page: pageParam,
|
||||||
per_page: PER_PAGE,
|
per_page: PER_PAGE,
|
||||||
}),
|
}),
|
||||||
|
|
@ -252,6 +264,7 @@ export function ScenesScreen() {
|
||||||
<ScenesFilterModal
|
<ScenesFilterModal
|
||||||
visible={filterOpen}
|
visible={filterOpen}
|
||||||
initial={filter}
|
initial={filter}
|
||||||
|
vertical={verticalKey}
|
||||||
onApply={(next) => {
|
onApply={(next) => {
|
||||||
setFilter(next);
|
setFilter(next);
|
||||||
setFilterOpen(false);
|
setFilterOpen(false);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,11 @@
|
||||||
// Sceny dla wybranego studia (filter studio_slugs=<slug>). Mirror PerformerScenesScreen.
|
// Sceny I FILMY wybranego studia (filter studio_slugs=<slug>). Mirror PerformerScenesScreen.
|
||||||
|
//
|
||||||
|
// Zakładka Movies dodana 2026-08-13 (zgłoszenie: "polubione studio od filmów nie pokazuje
|
||||||
|
// nowych filmów i nie ma +1"). Backend liczył je poprawnie (`_merged_new_counts` scala
|
||||||
|
// sceny i filmy od 2026-07-28), ale tapnięcie ulubionego studia robiło dwie rzeczy naraz:
|
||||||
|
// `markStudioSeen` ZEROWAŁO licznik i wchodziło na ekran WYŁĄCZNIE ze scenami. Gdy "+N"
|
||||||
|
// pochodziło z nowych filmów, badge znikał, a filmy nie miały się gdzie pokazać.
|
||||||
|
// `StudioMoviesScreen` istniał, ale dało się do niego wejść tylko z detalu filmu.
|
||||||
// Studios używają slug filter (nie id), backend ScenesListParams ma `studio_slugs`,
|
// Studios używają slug filter (nie id), backend ScenesListParams ma `studio_slugs`,
|
||||||
// nie `studio_ids`. Dlatego nav param dla StudioScenes wymaga slug, przekazujemy go
|
// nie `studio_ids`. Dlatego nav param dla StudioScenes wymaga slug, przekazujemy go
|
||||||
// z FavoritesScreen (mamy w FavoriteStudioOut.slug).
|
// z FavoritesScreen (mamy w FavoriteStudioOut.slug).
|
||||||
|
|
@ -15,7 +22,9 @@ import {
|
||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import { MoviePosterCard } from '../components/MoviePosterCard';
|
||||||
import { SceneTile, sceneGridProps } from '../components/SceneTile';
|
import { SceneTile, sceneGridProps } from '../components/SceneTile';
|
||||||
|
import { ErrorBoundary } from '../ErrorBoundary';
|
||||||
import { sortNewFirst } from '../lib/newScenes';
|
import { sortNewFirst } from '../lib/newScenes';
|
||||||
import { useClient } from '../ClientContext';
|
import { useClient } from '../ClientContext';
|
||||||
import { usePreferences } from '../PreferencesContext';
|
import { usePreferences } from '../PreferencesContext';
|
||||||
|
|
@ -23,6 +32,10 @@ import type { RootStackParamList } from '../navigation';
|
||||||
import { theme } from '../theme';
|
import { theme } from '../theme';
|
||||||
import type { SceneOut } from '../types';
|
import type { SceneOut } from '../types';
|
||||||
|
|
||||||
|
type Tab = 'scenes' | 'movies';
|
||||||
|
|
||||||
|
const MOVIE_COLS = 2;
|
||||||
|
|
||||||
export function StudioScenesScreen() {
|
export function StudioScenesScreen() {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const { gridColumns } = usePreferences();
|
const { gridColumns } = usePreferences();
|
||||||
|
|
@ -35,6 +48,7 @@ export function StudioScenesScreen() {
|
||||||
// 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 } = route.params;
|
||||||
|
const [tab, setTab] = React.useState<Tab>('scenes');
|
||||||
|
|
||||||
const favoritesQuery = useQuery({
|
const favoritesQuery = useQuery({
|
||||||
queryKey: ['favorites-studios'],
|
queryKey: ['favorites-studios'],
|
||||||
|
|
@ -116,6 +130,23 @@ export function StudioScenesScreen() {
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Lazy jak u performera: fetch dopiero po przełączeniu, żeby wejście na studio
|
||||||
|
// nie ciągnęło dwóch list naraz.
|
||||||
|
const moviesQuery = useQuery({
|
||||||
|
queryKey: ['studio-movies', slug],
|
||||||
|
queryFn: () =>
|
||||||
|
client.listMovies({
|
||||||
|
studio_slugs: [slug],
|
||||||
|
sort: 'release_date',
|
||||||
|
per_page: 100,
|
||||||
|
has_playback: true,
|
||||||
|
}),
|
||||||
|
enabled: tab === 'movies',
|
||||||
|
});
|
||||||
|
|
||||||
|
const movies = moviesQuery.data?.items ?? [];
|
||||||
|
const moviesTotal = moviesQuery.data?.total ?? 0;
|
||||||
|
|
||||||
const sortedItems = React.useMemo<SceneOut[]>(
|
const sortedItems = React.useMemo<SceneOut[]>(
|
||||||
() => sortNewFirst(data?.items ?? [], seenSince),
|
() => sortNewFirst(data?.items ?? [], seenSince),
|
||||||
[data?.items, seenSince],
|
[data?.items, seenSince],
|
||||||
|
|
@ -123,11 +154,32 @@ export function StudioScenesScreen() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
|
{/* Pasek zakładek ZAWSZE widoczny, też podczas ładowania: gdyby siedział w
|
||||||
|
ListHeaderComponent, pierwsze wejście na Movies dałoby pusty ekran bez
|
||||||
|
drogi powrotnej (ta sama pułapka co w PerformerScenesScreen). */}
|
||||||
|
<View style={styles.tabRow}>
|
||||||
|
<TabButton
|
||||||
|
label="Scenes"
|
||||||
|
count={data?.total ?? 0}
|
||||||
|
active={tab === 'scenes'}
|
||||||
|
onPress={() => setTab('scenes')}
|
||||||
|
/>
|
||||||
|
<TabButton
|
||||||
|
label="Movies"
|
||||||
|
count={moviesTotal}
|
||||||
|
active={tab === 'movies'}
|
||||||
|
onPress={() => setTab('movies')}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{tab === 'scenes' ? (
|
||||||
|
<>
|
||||||
{isLoading && <ActivityIndicator color={theme.fg} />}
|
{isLoading && <ActivityIndicator color={theme.fg} />}
|
||||||
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
|
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
|
||||||
|
|
||||||
<FlatList
|
<FlatList
|
||||||
{...sceneGridProps(gridColumns)}
|
{...sceneGridProps(gridColumns)}
|
||||||
|
key={`studio-scenes-${gridColumns}`}
|
||||||
data={sortedItems}
|
data={sortedItems}
|
||||||
keyExtractor={(s) => s.id}
|
keyExtractor={(s) => s.id}
|
||||||
removeClippedSubviews={false}
|
removeClippedSubviews={false}
|
||||||
|
|
@ -146,7 +198,75 @@ export function StudioScenesScreen() {
|
||||||
ListEmptyComponent={!isLoading ? <Text style={styles.muted}>no scenes</Text> : null}
|
ListEmptyComponent={!isLoading ? <Text style={styles.muted}>no scenes</Text> : null}
|
||||||
contentContainerStyle={{ paddingBottom: 24 }}
|
contentContainerStyle={{ paddingBottom: 24 }}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Boundary jak przy performerach: krach w drzewie Movies (malformed poster_url,
|
||||||
|
// VirtualizedList z numColumns=2) nie ma wywalać całego ekranu.
|
||||||
|
<ErrorBoundary scope="studioscenes-movies">
|
||||||
|
{moviesQuery.isLoading && <ActivityIndicator color={theme.fg} />}
|
||||||
|
{moviesQuery.error instanceof Error && (
|
||||||
|
<Text style={styles.error}>{moviesQuery.error.message}</Text>
|
||||||
|
)}
|
||||||
|
<FlatList
|
||||||
|
// Explicit key wymusza czysty remount przy zmianie zakładki: scenes ma inne
|
||||||
|
// numColumns niż movies, a reuse VirtualizedList potrafi wywalić natywny
|
||||||
|
// lifecycle na Androidzie.
|
||||||
|
key="studio-movies-list"
|
||||||
|
data={movies}
|
||||||
|
numColumns={MOVIE_COLS}
|
||||||
|
removeClippedSubviews={false}
|
||||||
|
keyExtractor={(m) => m.id}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<MoviePosterCard
|
||||||
|
movie={item}
|
||||||
|
onPress={() => navigation.navigate('MovieDetail', { id: item.id })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
refreshing={moviesQuery.isRefetching}
|
||||||
|
onRefresh={moviesQuery.refetch}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
{moviesQuery.data
|
||||||
|
? `${moviesTotal} ${moviesTotal === 1 ? 'film' : 'films'}`
|
||||||
|
: ' '}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
!moviesQuery.isLoading ? (
|
||||||
|
<Text style={styles.muted}>no movies for this studio</Text>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
columnWrapperStyle={{ gap: 10 }}
|
||||||
|
contentContainerStyle={{ paddingBottom: 24 }}
|
||||||
|
/>
|
||||||
|
</ErrorBoundary>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabButton({
|
||||||
|
label,
|
||||||
|
count,
|
||||||
|
active,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
active: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable style={[styles.tabBtn, active && styles.tabBtnActive]} onPress={onPress}>
|
||||||
|
<Text style={[styles.tabBtnText, active && styles.tabBtnTextActive]}>{label}</Text>
|
||||||
|
{count > 0 ? (
|
||||||
|
<View style={styles.tabCount}>
|
||||||
|
<Text style={styles.tabCountText}>{count}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -161,6 +281,27 @@ const styles = StyleSheet.create({
|
||||||
paddingHorizontal: 4,
|
paddingHorizontal: 4,
|
||||||
},
|
},
|
||||||
subtitle: { color: theme.muted },
|
subtitle: { color: theme.muted },
|
||||||
|
tabRow: { flexDirection: 'row', gap: 8, marginBottom: 10, paddingHorizontal: 4 },
|
||||||
|
tabBtn: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
paddingVertical: 6,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
borderRadius: 999,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.border,
|
||||||
|
},
|
||||||
|
tabBtnActive: { borderColor: theme.accent },
|
||||||
|
tabBtnText: { color: theme.muted, fontSize: 13 },
|
||||||
|
tabBtnTextActive: { color: theme.accent, fontWeight: '700' },
|
||||||
|
tabCount: {
|
||||||
|
backgroundColor: theme.border,
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 1,
|
||||||
|
},
|
||||||
|
tabCountText: { color: theme.muted, fontSize: 11 },
|
||||||
muted: { color: theme.muted, textAlign: 'center', marginTop: 24 },
|
muted: { color: theme.muted, textAlign: 'center', marginTop: 24 },
|
||||||
error: { color: theme.bad, padding: 12 },
|
error: { color: theme.bad, padding: 12 },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,8 @@ export interface ScenesListParams {
|
||||||
include_stubs?: boolean;
|
include_stubs?: boolean;
|
||||||
origin?: string;
|
origin?: string;
|
||||||
jav?: boolean;
|
jav?: boolean;
|
||||||
|
/** Wertykał do pokazania: 'animation' | 'jav'. Brak → główny feed (wertykały odcięte). */
|
||||||
|
vertical?: string;
|
||||||
sort?: ScenesSort;
|
sort?: ScenesSort;
|
||||||
page?: number;
|
page?: number;
|
||||||
per_page?: number;
|
per_page?: number;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue