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.play_progress import ScenePlayProgress
|
||||
from app.models.playback_source import PlaybackSource
|
||||
from app import verticals
|
||||
from app.models.scene import Scene, SceneExternalRef, ScenePerformer, SceneTag
|
||||
from app.models.source import Source, SourceKind
|
||||
from app.models.studio import Studio
|
||||
|
|
@ -60,8 +61,8 @@ def _default_scene_count(session: Session) -> int:
|
|||
PlaybackSource.dead_at.is_(None),
|
||||
)
|
||||
),
|
||||
# Domyślny feed wyklucza JAV (osobna sekcja) → licznik też.
|
||||
~_jav_source_exists(),
|
||||
# Domyślny feed wyklucza wertykały (JAV, animacja) → licznik też.
|
||||
~_vertical_source_exists(),
|
||||
).subquery()
|
||||
)
|
||||
total = session.execute(count_query).scalar_one()
|
||||
|
|
@ -70,23 +71,29 @@ def _default_scene_count(session: Session) -> int:
|
|||
return total
|
||||
|
||||
|
||||
# JAV vertical, osobna sekcja. Sceny z tych originów NIE wchodzą do głównego feedu
|
||||
# (domyślnie wykluczone), tylko do zakładki JAV (?jav=true). JAV to osobny namespace
|
||||
# (kody typu BKD-368, azjatyckie tytuły), nie deduplikuje się z zachodnim katalogiem,
|
||||
# więc scena JAV ma WYŁĄCZNIE origin JAV → wykluczenie/inkluzja po tym originie jest pewna.
|
||||
JAV_ORIGINS = ("tube:javflix", "tube:javguru", "tube:vjav", "tube:supjav")
|
||||
# Wertykały (JAV, animacja) mają własne sekcje w apce i NIE wchodzą do głównego feedu.
|
||||
# Definicje i uzasadnienie: `app/verticals.py`. Tu zostaje tylko budowa predykatu.
|
||||
# `JAV_ORIGINS` re-eksportowane, bo importują je inne moduły i skrypty.
|
||||
JAV_ORIGINS = verticals.JAV_ORIGINS
|
||||
|
||||
|
||||
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(
|
||||
select(1).where(
|
||||
PlaybackSource.scene_id == Scene.id,
|
||||
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
|
||||
# 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
|
||||
|
|
@ -241,6 +248,14 @@ def list_scenes(
|
|||
"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(
|
||||
default=False,
|
||||
description=(
|
||||
|
|
@ -348,14 +363,16 @@ def list_scenes(
|
|||
)
|
||||
)
|
||||
|
||||
# JAV vertical gate: domyślnie JAV wykluczone z głównego feedu; ?jav=true → TYLKO JAV.
|
||||
# ALE gdy user browse'uje KONKRETNY origin (Sites → javflix/vjav/supjav → SiteScenes),
|
||||
# bramy nie stosujemy, jawnie chce ten origin, więc pokazujemy (regresja "No videos"
|
||||
# na stronach JAV, bug-report 2026-07-19). Brama dotyczy tylko głównego feedu (origin=None).
|
||||
if jav:
|
||||
base = base.where(_jav_source_exists())
|
||||
# Brama wertykałów: domyślnie WSZYSTKIE wykluczone z głównego feedu, `?vertical=X`
|
||||
# → TYLKO ten wertykał. ALE gdy user browse'uje KONKRETNY origin (Sites → javflix/
|
||||
# vjav/hentaigasm → SiteScenes), bramy nie stosujemy, bo jawnie chce ten origin
|
||||
# (regresja "No videos" na stronach JAV, bug-report 2026-07-19). Brama dotyczy tylko
|
||||
# głównego feedu (origin=None).
|
||||
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:
|
||||
base = base.where(~_jav_source_exists())
|
||||
base = base.where(~_vertical_source_exists())
|
||||
|
||||
# Blacklisty device (performer/studio/tag), globalne wykluczenia, współdzielone z
|
||||
# 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 min_duration_sec 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:
|
||||
# - PURE default: cached pełny licznik katalogu (TTL 10 min).
|
||||
|
|
@ -602,19 +619,24 @@ def similar_scenes(
|
|||
if not cand_ids:
|
||||
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.
|
||||
target_is_jav = bool(
|
||||
session.execute(
|
||||
select(_jav_source_exists()).select_from(Scene).where(Scene.id == scene_id)
|
||||
).scalar()
|
||||
)
|
||||
# Podobne muszą zostać w tym samym wertykale co scena-cel: dla sceny animowanej
|
||||
# nie proponujemy zachodnich i odwrotnie (inne namespace'y, zero dedupu).
|
||||
target_vertical: str | None = None
|
||||
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(
|
||||
Scene.id.in_(cand_ids),
|
||||
Scene.id != scene_id,
|
||||
live_playback_exists(),
|
||||
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):
|
||||
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.studio import Studio
|
||||
from app.models.tag import Tag
|
||||
from app.verticals import VERTICALS
|
||||
|
||||
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."
|
||||
),
|
||||
),
|
||||
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:
|
||||
if order not in ("popular", "scene_count", "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)
|
||||
|
||||
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
|
||||
# _job_refresh_taxonomy_counts). Wcześniej liczył agregat 6.3M scene_tags +
|
||||
# EXISTS playback per-request (~4.3s ×2). Patrz migracja 0019.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import logging
|
|||
from sqlalchemy import text
|
||||
|
||||
from app.db import session_scope
|
||||
from app.verticals import ALL_VERTICAL_ORIGINS
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -35,6 +36,16 @@ _LIVE = (
|
|||
"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).
|
||||
_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
|
||||
WHERE {_LIVE.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
|
||||
) a ON a.tag_id = base.id
|
||||
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
|
||||
WHERE {_LIVE.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
|
||||
) a ON a.performer_id = base.id
|
||||
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
|
||||
WHERE sc.studio_id IS NOT NULL AND {_LIVE.format(scene_col="sc.id")}
|
||||
AND sc.duration_sec >= {_MIN_DURATION_SEC}
|
||||
AND {_NOT_VERTICAL.format(scene_col="sc.id")}
|
||||
GROUP BY sc.studio_id
|
||||
) a ON a.studio_id = base.id
|
||||
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),
|
||||
("studios", _STUDIOS_SQL),
|
||||
):
|
||||
res = session.execute(stmt)
|
||||
res = session.execute(stmt, {"vertical_origins": list(ALL_VERTICAL_ORIGINS)})
|
||||
out[name] = res.rowcount or 0
|
||||
# Commit per-tabela, długie transakcje trzymałyby locki na hot tables.
|
||||
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.include_stubs !== undefined) qs.set('include_stubs', String(params.include_stubs));
|
||||
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.sort) qs.set('sort', params.sort);
|
||||
qs.set('page', String(params.page ?? 1));
|
||||
|
|
@ -220,6 +222,9 @@ export class GoonClient {
|
|||
per_page?: number;
|
||||
for_movies?: 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> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.q) qs.set('q', params.q);
|
||||
|
|
@ -228,6 +233,7 @@ export class GoonClient {
|
|||
qs.set('per_page', String(params.per_page ?? 200));
|
||||
if (params.for_movies) qs.set('for_movies', '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()}`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,14 @@ export type 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',
|
||||
date: 'August 2026',
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { GoonMark } from './components/GoonWordmark';
|
|||
import { GoonClient } from './api';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
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 { BlacklistScreen } from './screens/BlacklistScreen';
|
||||
import { DiagnosticBrowserScreen } from './screens/DiagnosticBrowserScreen';
|
||||
|
|
@ -35,6 +35,10 @@ export type RootStackParamList = {
|
|||
Sites: undefined;
|
||||
// JAV: osobna sekcja, ScenesScreen z paramem jav → backend ?jav=true.
|
||||
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
|
||||
// listScenes({origin}), backend robi substring match. `name`: display name
|
||||
// do title bara (np. 'hqporner.com').
|
||||
|
|
@ -103,7 +107,7 @@ export type RootStackParamList = {
|
|||
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
|
||||
type TopTab = 'Scenes' | 'Movies' | 'Sites' | 'JAV';
|
||||
type TopTab = 'Scenes' | 'Movies' | 'Sites' | 'JAV' | 'Anime';
|
||||
|
||||
function TopTabs({
|
||||
current,
|
||||
|
|
@ -112,9 +116,17 @@ function TopTabs({
|
|||
current: TopTab;
|
||||
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 (
|
||||
<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
|
||||
+ akcje po prawej mieszczą się bez nachodzenia na wąskich telefonach. */}
|
||||
<GoonMark size={30} />
|
||||
|
|
@ -136,7 +148,7 @@ function TopTabs({
|
|||
</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
|
||||
name="SiteScenes"
|
||||
component={SiteScenesScreen}
|
||||
|
|
|
|||
|
|
@ -61,11 +61,15 @@ export function ScenesFilterModal({
|
|||
initial,
|
||||
onApply,
|
||||
onClose,
|
||||
vertical,
|
||||
}: {
|
||||
visible: boolean;
|
||||
initial: FilterState;
|
||||
onApply: (next: FilterState) => 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 [draft, setDraft] = useState<FilterState>(initial);
|
||||
|
|
@ -79,8 +83,16 @@ export function ScenesFilterModal({
|
|||
}, [visible, initial]);
|
||||
|
||||
const tagsQuery = useQuery({
|
||||
queryKey: ['filter-tags', tagQ],
|
||||
queryFn: () => client.listTags({ q: tagQ || undefined, per_page: 200 }),
|
||||
queryKey: ['filter-tags', tagQ, vertical],
|
||||
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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -30,9 +30,14 @@ export function ScenesScreen() {
|
|||
const client = useClient();
|
||||
const { gridColumns } = usePreferences();
|
||||
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 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 [debouncedQ, setDebouncedQ] = useState('');
|
||||
const [filter, setFilter] = useState<FilterState>(DEFAULT_FILTER);
|
||||
|
|
@ -54,7 +59,7 @@ export function ScenesScreen() {
|
|||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ['scenes', debouncedQ, filter, jav],
|
||||
queryKey: ['scenes', debouncedQ, filter, jav, vertical],
|
||||
queryFn: ({ pageParam = 1 }) =>
|
||||
client.listScenes({
|
||||
q: debouncedQ || undefined,
|
||||
|
|
@ -66,10 +71,17 @@ export function ScenesScreen() {
|
|||
include_stubs: filter.includeStubs || undefined,
|
||||
origin: filter.origin.trim() || undefined,
|
||||
jav: jav || undefined,
|
||||
vertical,
|
||||
// 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
|
||||
// (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,
|
||||
per_page: PER_PAGE,
|
||||
}),
|
||||
|
|
@ -252,6 +264,7 @@ export function ScenesScreen() {
|
|||
<ScenesFilterModal
|
||||
visible={filterOpen}
|
||||
initial={filter}
|
||||
vertical={verticalKey}
|
||||
onApply={(next) => {
|
||||
setFilter(next);
|
||||
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`,
|
||||
// nie `studio_ids`. Dlatego nav param dla StudioScenes wymaga slug, przekazujemy go
|
||||
// z FavoritesScreen (mamy w FavoriteStudioOut.slug).
|
||||
|
|
@ -15,7 +22,9 @@ import {
|
|||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { MoviePosterCard } from '../components/MoviePosterCard';
|
||||
import { SceneTile, sceneGridProps } from '../components/SceneTile';
|
||||
import { ErrorBoundary } from '../ErrorBoundary';
|
||||
import { sortNewFirst } from '../lib/newScenes';
|
||||
import { useClient } from '../ClientContext';
|
||||
import { usePreferences } from '../PreferencesContext';
|
||||
|
|
@ -23,6 +32,10 @@ import type { RootStackParamList } from '../navigation';
|
|||
import { theme } from '../theme';
|
||||
import type { SceneOut } from '../types';
|
||||
|
||||
type Tab = 'scenes' | 'movies';
|
||||
|
||||
const MOVIE_COLS = 2;
|
||||
|
||||
export function StudioScenesScreen() {
|
||||
const client = useClient();
|
||||
const { gridColumns } = usePreferences();
|
||||
|
|
@ -35,6 +48,7 @@ export function StudioScenesScreen() {
|
|||
// PerformerScenes, ale w handlerach favorite/blacklist używamy studioId (UUID)
|
||||
// który dodajemy jako kolejny param.
|
||||
const { id: slug, name, seenSince, studioId } = route.params;
|
||||
const [tab, setTab] = React.useState<Tab>('scenes');
|
||||
|
||||
const favoritesQuery = useQuery({
|
||||
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[]>(
|
||||
() => sortNewFirst(data?.items ?? [], seenSince),
|
||||
[data?.items, seenSince],
|
||||
|
|
@ -123,11 +154,32 @@ export function StudioScenesScreen() {
|
|||
|
||||
return (
|
||||
<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} />}
|
||||
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
|
||||
|
||||
<FlatList
|
||||
{...sceneGridProps(gridColumns)}
|
||||
key={`studio-scenes-${gridColumns}`}
|
||||
data={sortedItems}
|
||||
keyExtractor={(s) => s.id}
|
||||
removeClippedSubviews={false}
|
||||
|
|
@ -146,7 +198,75 @@ export function StudioScenesScreen() {
|
|||
ListEmptyComponent={!isLoading ? <Text style={styles.muted}>no scenes</Text> : null}
|
||||
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>
|
||||
}
|
||||
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,
|
||||
},
|
||||
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 },
|
||||
error: { color: theme.bad, padding: 12 },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -152,6 +152,8 @@ export interface ScenesListParams {
|
|||
include_stubs?: boolean;
|
||||
origin?: string;
|
||||
jav?: boolean;
|
||||
/** Wertykał do pokazania: 'animation' | 'jav'. Brak → główny feed (wertykały odcięte). */
|
||||
vertical?: string;
|
||||
sort?: ScenesSort;
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue