From 3349700f72192c82a724b209c8903ee627b81a41 Mon Sep 17 00:00:00 2001 From: goon-foss Date: Tue, 22 Sep 2026 17:13:39 +0200 Subject: [PATCH] fix(api): szukanie w serwisie i sortowanie po studiu z rzadka fraza - Szukanie w jednym serwisie do 60 tys. scen zaczyna od jego scen (scenes.id = ANY), gdy fraza jest popularna albo bez trigramu: "sel" w hqporner 3,4 s -> 0,1 s, "brat" w fullmoviesxxx 2,9 s -> 0,15 s. Rzadkie frazy dalej trigramami (GOON-75). - Poprawka regresji z c08abf6: fraza bez trigramu przy zawezeniu po 3 s oddawala pusta liste ("ai" w fullmoviesxxx: 0 wynikow). Teraz od serwisu albo limit 10 s. - sort=studio z rzadka fraza: JOIN ze studiami i sort samych trafien zamiast przejscia po 33 tys. studiow ("sunny leone" 928 s -> 0,26 s, "savanna" 20 s -> 0,17 s). Porownanie ze starym kodem na 100 wyszukiwaniach w serwisach i 12 z sortem po studiu: wyniki identyczne poza naprawionymi pustymi listami. Co-Authored-By: Claude Opus 5 --- app/api/scenes.py | 91 +++++++++++++++++++++++++++++++++++++-- tests/test_search_walk.py | 32 +++++++++++++- 2 files changed, 119 insertions(+), 4 deletions(-) diff --git a/app/api/scenes.py b/app/api/scenes.py index 27af4f8..f746a1d 100644 --- a/app/api/scenes.py +++ b/app/api/scenes.py @@ -14,6 +14,8 @@ from pydantic import BaseModel from sqlalchemy import ( Date, and_, + any_, + bindparam, distinct, exists, false, @@ -25,6 +27,8 @@ from sqlalchemy import ( union, ) from psycopg.errors import QueryCanceled +from sqlalchemy.dialects.postgresql import ARRAY +from sqlalchemy.dialects.postgresql import UUID as PG_UUID from sqlalchemy.exc import IntegrityError, OperationalError from sqlalchemy.orm import Session, aliased @@ -228,8 +232,25 @@ def _blacklists_empty(session: Session, device_id: str) -> bool: _SEARCH_WALK_BUDGET_MS = 300 _SEARCH_WALK_MAX_SCAN = 30_000 # Fraza bez ciagu 3 liter/cyfr nie ma trigramu, wiec awaryjnego planu nie ma. Dluzszy -# limit, a po nim pusta lista: to zwykle stan w trakcie pisania ("q", "xz"). +# limit, a po nim pusta lista: to zwykle stan w trakcie pisania ("q", "xz"). Przy +# zawezeniu (tag itp.) trafienia sa rzadsze, wiec limit wiekszy, zeby nie oddac pustej +# listy tam, gdzie wyniki sa. _SEARCH_WALK_BUDGET_NO_TRGM_MS = 3000 +_SEARCH_WALK_BUDGET_NO_TRGM_NARROWED_MS = 10_000 +# Szukanie w jednym serwisie (ekran serwisu w apce, GOON-75): dla serwisu do tylu scen +# zaczynamy od jego scen (id z playback_sources, potem scenes.id = ANY), zamiast od +# calego katalogu. Czas zalezy od wielkosci serwisu, nie od frazy, a z trigramem +# Postgres laczy oba indeksy (BitmapAnd). Pomiar 2026-09-22: "sel" w hqporner 3,4 s -> +# 0,11 s, "brat" w fullmoviesxxx 2,9 s -> 0,15 s, "ai" 8,6 s -> 2,2 s. W duzym serwisie +# (sxyprn 108 tys., xvideos 444 tys.) przejscie od najnowszych trafia szybko, a samo +# pobranie i przeslanie tablicy id kosztuje 0,5-0,9 s (sxyprn "sislovesme" 0,12 -> 1,65 s). +_ORIGIN_DRIVEN_MAX_SCENES = 60_000 +# Rzadka fraza (nazwisko): mniej niz tyle trafien w probce tytulow, czyli ponizej ~0,4% +# katalogu. Jej sceny najszybciej znajduja trigramy, bo jest ich malo. W serwisie +# (0,02-0,36 s) sciezka od serwisu placilaby tylko staly narzut tablicy id (~0,5 s), +# a przy sort=studio przejscie po studiach szuka trafien w kazdym z 33 tys. studiow +# ("sunny leone" 15 min), podczas gdy trigramy + sort trafien to 0,37 s. +_RARE_PHRASE_HITS = 4 _TRGM_RUN_RE = re.compile(r"[^\W_]{3}") _TITLE_SAMPLE_TTL = 3600.0 _TITLE_SAMPLE: dict = {"ts": 0.0, "vals": ()} @@ -291,7 +312,7 @@ def _search_walk_budget(q: str, off: int, want: int, narrowed: bool, sample) -> `sample` to funkcja zwracajaca probke tytulow, wolana tylko gdy potrzebna. """ if not _TRGM_RUN_RE.search(q): - return _SEARCH_WALK_BUDGET_NO_TRGM_MS + return _SEARCH_WALK_BUDGET_NO_TRGM_NARROWED_MS if narrowed else _SEARCH_WALK_BUDGET_NO_TRGM_MS if narrowed: return None vals = sample() @@ -319,6 +340,44 @@ def _search_walk_rows( return [by_id[i] for i in ids if i in by_id] +def _rare_title_phrase(q: str, sample) -> bool: + """Fraza z trigramem, ktora w probce tytulow trafia rzadko (patrz _RARE_PHRASE_HITS). + + `sample` to funkcja zwracajaca probke tytulow, wolana tylko gdy potrzebna. + """ + if not _TRGM_RUN_RE.search(q): + return False + needle = q.lower() + return sum(needle in v for v in sample()) < _RARE_PHRASE_HITS + + +def _origin_driven_worth(q: str, sample) -> bool: + """Czy szukanie w serwisie zaczac od jego scen: fraza bez trigramu albo popularna.""" + return not _rare_title_phrase(q, sample) + + +def _origin_scene_ids(session: Session, origin: str) -> list[uuid.UUID] | None: + """Id scen z zywym zrodlem serwisu albo None, gdy serwis jest za duzy na te sciezke. + + Wielkosc bierzemy z source_stats (liczone co 6 h), zeby dla xvideos nie ciagnac + setek tysiecy id tylko po to, zeby je wyrzucic. + """ + from app.models.source_stats import SourceStats + + size = session.execute(select(SourceStats.scenes).where(SourceStats.origin == origin)).scalar() + if size is not None and size > _ORIGIN_DRIVEN_MAX_SCENES: + return None + ids = list( + session.execute( + select(PlaybackSource.scene_id) + .where(PlaybackSource.origin == origin, PlaybackSource.dead_at.is_(None)) + .distinct() + .limit(_ORIGIN_DRIVEN_MAX_SCENES + 1) + ).scalars() + ) + return ids if len(ids) <= _ORIGIN_DRIVEN_MAX_SCENES else None + + def _split_csv(raw: str | None) -> list[str]: if not raw: return [] @@ -850,6 +909,18 @@ def list_scenes( ordered = base.order_by( Scene.title_normalized.asc(), Scene.created_at.desc(), Scene.id.desc() ) + elif sort == "studio" and q and _rare_title_phrase(q, lambda: _title_sample(session)): + # Rzadka fraza: trafien jest malo, wiec zwykly JOIN ze studiami i sort samych + # trafien (trigramy) zamiast przejscia po studiach, ktore szukaloby ich w kazdym + # studiu z osobna ("sunny leone" 15 min -> 0,37 s, "savannah" 20 s -> 0,88 s). + # Ten sam porzadek: studia po nazwie, w studiu najswiezsze, bez studia na koncu. + sst = aliased(Studio, name="sort_studio") + ordered = base.outerjoin(sst, sst.id == Scene.studio_id).order_by( + sst.name_normalized.asc().nullslast(), + _effective_date_desc(), + Scene.created_at.desc(), + Scene.id.desc(), + ) elif sort == "studio": # Sceny bez studio na końcu; w obrębie studio, najświeższe pierwsze. Fetch idzie # osobna sciezka (LATERAL po studiach), bo jeden SELECT z LEFT JOIN nie da sie tu @@ -952,7 +1023,21 @@ def list_scenes( has_more = page < len(feed_pages) or len(cand) >= cand_n else: rows = studio_rows - if rows is None and q and ( + origin_ids = None + if ( + rows is None + and q + and origin + and sort in ("created_at", "release_date") + and _origin_driven_worth(q, lambda: _title_sample(session)) + ): + origin_ids = _origin_scene_ids(session, origin) + if origin_ids is not None: + ordered = ordered.where( + Scene.id + == any_(bindparam("origin_scene_ids", origin_ids, type_=ARRAY(PG_UUID(as_uuid=True)))) + ) + if rows is None and origin_ids is None and q and ( sort == "created_at" or (sort == "release_date" and tag_sort_id is None) ): rows = _search_walk_rows( diff --git a/tests/test_search_walk.py b/tests/test_search_walk.py index a6fd5a3..22340b9 100644 --- a/tests/test_search_walk.py +++ b/tests/test_search_walk.py @@ -1,6 +1,9 @@ from app.api.scenes import ( _SEARCH_WALK_BUDGET_MS, _SEARCH_WALK_BUDGET_NO_TRGM_MS, + _SEARCH_WALK_BUDGET_NO_TRGM_NARROWED_MS, + _origin_driven_worth, + _rare_title_phrase, _search_walk_budget, ) @@ -40,13 +43,40 @@ def test_narrowed_search_goes_to_trigrams(): def test_short_phrase_always_walks(): # bez trigramu zwykly plan czyta caly indeks; tez przy zawezeniu i poza probka for q in ("vo", "q", "xz", " a "): - assert _budget(q, narrowed=True) == _SEARCH_WALK_BUDGET_NO_TRGM_MS + assert _budget(q) == _SEARCH_WALK_BUDGET_NO_TRGM_MS assert _budget("zq", off=5000) == _SEARCH_WALK_BUDGET_NO_TRGM_MS +def test_short_phrase_narrowed_gets_longer_budget(): + # z tagiem trafienia sa rzadsze; po 3 s oddawalismy pusta liste mimo wynikow + assert _budget("ai", narrowed=True) == _SEARCH_WALK_BUDGET_NO_TRGM_NARROWED_MS + + def test_sample_not_read_when_not_needed(): def boom(): raise AssertionError("probka niepotrzebna") assert _search_walk_budget("vo", 0, 51, False, boom) == _SEARCH_WALK_BUDGET_NO_TRGM_MS + assert _search_walk_budget("vo", 0, 51, True, boom) == _SEARCH_WALK_BUDGET_NO_TRGM_NARROWED_MS assert _search_walk_budget("anal", 0, 51, True, boom) is None + + +def test_origin_driven_for_common_and_short_phrases(): + # "anal" 20/1000 w probce: popularna, w malym serwisie zaczynamy od jego scen + assert _origin_driven_worth("anal", lambda: SAMPLE) + # bez trigramu zawsze od serwisu (inaczej przejscie po calym katalogu) + assert _origin_driven_worth("ai", lambda: SAMPLE) + + +def test_origin_driven_skipped_for_rare_phrases(): + # nazwisko: trigramy znajda kilka scen szybciej niz tablica id serwisu + assert not _origin_driven_worth("hailey rose", lambda: SAMPLE) + assert not _origin_driven_worth("jackerman", lambda: SAMPLE) + + +def test_rare_phrase_detection(): + assert _rare_title_phrase("hailey rose", lambda: SAMPLE) + assert _rare_title_phrase("sunny leone", lambda: SAMPLE) + assert not _rare_title_phrase("anal", lambda: SAMPLE) + # bez trigramu nie jest "rzadka": trigramy i tak by jej nie znalazly + assert not _rare_title_phrase("ai", lambda: SAMPLE)