diff --git a/alembic/versions/20260818_0029_scenes_max_quality.py b/alembic/versions/20260818_0029_scenes_max_quality.py new file mode 100644 index 0000000..bb1e394 --- /dev/null +++ b/alembic/versions/20260818_0029_scenes_max_quality.py @@ -0,0 +1,50 @@ +"""scenes.max_quality_p: zdenormalizowana najwyzsza znana jakosc zrodel sceny + +Po co, skoro filtr dalo sie napisac zapytaniem: NIE dalo sie go napisac szybko. +Zmierzone na produkcji 2026-08-18 (EXPLAIN ANALYZE, prog 1080p): + + EXISTS na playback_sources 32 s (3 556 612 odwiedzonych scen) + + indeks funkcyjny per-scena 24 s + JOIN z DISTINCT po jakosci 1,1 s W IZOLACJI, ale + ten sam JOIN + reszta filtrow 52 s (planer wraca do przechodzenia scen) + +Ostatni wiersz jest sednem: w polaczeniu z bramka wertykalow, heurystyka szkieletow +i progiem dlugosci planer i tak wybiera przejscie `scenes` po `created_at` i testowanie +warunku scena po scenie. Kaztaltem zapytania sie tego nie ominie, bo koszt to LICZBA +odwiedzonych scen, a nie pojedynczy test. Skalarna kolumna zdejmuje problem: staje sie +zwyklym filtrem, takim jak `duration_sec`, ktory jest szybki. + +NULL = nie znamy jakosci zadnego zywego zrodla (93,5% katalogu: `quality` zapisujemy +oportunistycznie, tylko gdy listing zrodla ja podaje). Filtr celowo takie sceny ODSIEWA, +patrz komentarz przy QUALITY_OPTIONS w mobile/src/screens/ScenesFilterModal.tsx. + +Revision ID: 0029_scenes_max_quality +Revises: 0028_phash_backfill_queue +Create Date: 2026-08-18 +""" +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision: str = "0029_scenes_max_quality" +down_revision: str | None = "0028_phash_backfill_queue" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("scenes", sa.Column("max_quality_p", sa.SmallInteger(), nullable=True)) + # (max_quality_p, created_at DESC): filtr to zakres po pierwszej kolumnie, a sort + # feedu idzie po drugiej. Partial, bo 93,5% scen ma NULL i nie ma po co ich indeksowac. + op.create_index( + "ix_scenes_max_quality_created", + "scenes", + ["max_quality_p", sa.text("created_at DESC")], + postgresql_where=sa.text("max_quality_p IS NOT NULL"), + ) + + +def downgrade() -> None: + op.drop_index("ix_scenes_max_quality_created", table_name="scenes") + op.drop_column("scenes", "max_quality_p") diff --git a/alembic/versions/20260818_0030_scenes_quality_threshold_idx.py b/alembic/versions/20260818_0030_scenes_quality_threshold_idx.py new file mode 100644 index 0000000..1587cf1 --- /dev/null +++ b/alembic/versions/20260818_0030_scenes_quality_threshold_idx.py @@ -0,0 +1,50 @@ +"""Indeksy czesciowe pod progi filtra jakosci (720/1080/2160) + +Dlaczego osobne indeksy na prog, a nie jeden na (max_quality_p, created_at): +tamten nie da sie uzyc do `ORDER BY created_at DESC LIMIT n`, bo wynik nie jest +posortowany po created_at w obrebie ZAKRESU wartosci max_quality_p. Planer go +ignorowal i wracal do przejscia `scenes` po dacie z filtrem. + +Indeks czesciowy z predykatem `max_quality_p >= N`, posortowany po created_at DESC, +jest dla progu N dokladnie tym, czego zapytanie potrzebuje: Postgres uzywa go, gdy +warunek zapytania implikuje predykat indeksu. Zmierzone na produkcji 2026-08-18: + + 0029 (sama kolumna) 2,0-2,6 s planer szedl ix_scenes_created_at_desc + 0030 (te indeksy) 0,09-0,11 s Index Scan using ix_scenes_created_q1080 + +Progi sa ZWIAZANE Z UI: `QUALITY_OPTIONS` w mobile/src/screens/ScenesFilterModal.tsx +oferuje dokladnie 720/1080/2160. Inna wartosc w `?min_quality_p=` nadal dziala, tylko +wolniej (fallback do skanu po dacie). Dodajac opcje w UI, dodaj tu indeks. + +Rozmiary sa pomijalne (4,3 MB / 2,7 MB / 0,3 MB), bo jakosc zna tylko 6,5% katalogu. + +Revision ID: 0030_scenes_quality_idx +Revises: 0029_scenes_max_quality +Create Date: 2026-08-18 +""" +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision: str = "0030_scenes_quality_idx" +down_revision: str | None = "0029_scenes_max_quality" +branch_labels = None +depends_on = None + +_THRESHOLDS = (720, 1080, 2160) + + +def upgrade() -> None: + for q in _THRESHOLDS: + op.create_index( + f"ix_scenes_created_q{q}", + "scenes", + [sa.text("created_at DESC")], + postgresql_where=sa.text(f"max_quality_p >= {q}"), + ) + + +def downgrade() -> None: + for q in _THRESHOLDS: + op.drop_index(f"ix_scenes_created_q{q}", table_name="scenes") diff --git a/app/api/scenes.py b/app/api/scenes.py index 9ffe92e..dc6e312 100644 --- a/app/api/scenes.py +++ b/app/api/scenes.py @@ -390,28 +390,14 @@ def list_scenes( base = base.where(Scene.release_date >= cutoff) if min_quality_p is not None: - # PlaybackSource.quality to wolny string, szukamy liczb w prefixie ('1080p', - # '1080p Full HD', '2160p'). Heurystyka: wystarczy że scena ma JEDEN żywy - # playback z quality liczbą >= min. '4K'/'UHD' aliasujemy na 2160. - from sqlalchemy import Integer, cast, or_ - numeric_q = cast( - func.coalesce(func.substring(PlaybackSource.quality, r"\d+"), "0"), - Integer, - ) - conds = [numeric_q >= min_quality_p] - if min_quality_p <= 2160: - conds.append(PlaybackSource.quality.ilike("%4k%")) - conds.append(PlaybackSource.quality.ilike("%uhd%")) - base = base.where( - exists( - select(1).where( - PlaybackSource.scene_id == Scene.id, - PlaybackSource.dead_at.is_(None), - PlaybackSource.quality.isnot(None), - or_(*conds), - ) - ) - ) + # Skalarny filtr po zdenormalizowanej kolumnie. Wcześniejsze podejścia (EXISTS + # na playback_sources, potem JOIN z DISTINCT) dawały 32-52 s na produkcji, bo + # planer i tak przechodził `scenes` po `created_at` scena po scenie. Pomiary + # i uzasadnienie: migracja 0029_scenes_max_quality. + # + # NULL (93,5% katalogu) NIE przechodzi, i tak ma być: filtr obiecuje jakość + # potwierdzoną, a nie zgadywaną. UI mówi o tym wprost pod przełącznikiem. + base = base.where(Scene.max_quality_p >= min_quality_p) if not include_stubs: # Stub scene heuristic (współdzielona z licznikiem +N ulubionych): tube-only scena diff --git a/app/models/scene.py b/app/models/scene.py index f401e75..07bdf3d 100644 --- a/app/models/scene.py +++ b/app/models/scene.py @@ -10,6 +10,7 @@ from sqlalchemy import ( Float, ForeignKey, Integer, + SmallInteger, String, Text, UniqueConstraint, @@ -50,6 +51,13 @@ class Scene(UUIDPKMixin, TimestampMixin, Base): Boolean, nullable=False, server_default="false", index=True ) + # Najwyzsza ZNANA jakosc sposrod zywych zrodel sceny (720, 1080, 2160...). + # NULL = zadne zywe zrodlo nie podalo jakosci, czyli 93,5% katalogu; filtr jakosci + # takie sceny odsiewa. Odswiezane w `app/scheduler/taxonomy_counts.py`. + # Denormalizacja jest tu KONIECZNA, nie wygodna: uzasadnienie i pomiary w migracji + # 0029_scenes_max_quality. + max_quality_p: Mapped[int | None] = mapped_column(SmallInteger) + class SceneExternalRef(Base): __tablename__ = "scene_external_refs" diff --git a/app/scheduler/taxonomy_counts.py b/app/scheduler/taxonomy_counts.py index e751b6b..071e9b2 100644 --- a/app/scheduler/taxonomy_counts.py +++ b/app/scheduler/taxonomy_counts.py @@ -81,6 +81,37 @@ _PERFORMERS_SQL = text( """ ) +# scenes.max_quality_p: najwyzsza ZNANA jakosc sposrod zywych zrodel sceny. Odswiezamy +# tu, a nie przy ingescie, bo `quality` moze dojsc pozniej (re-scrape, nowe zrodlo po +# merge), a zapytanie jest tanie: dotyka tylko scen, ktore maja zrodlo z jakoscia. +# Sceny, ktore jakosc STRACILY (zrodlo umarlo), musza wrocic do NULL, stad druga czesc. +_MAX_QUALITY_SQL = text( + r""" + WITH q AS ( + SELECT ps.scene_id, + max(CAST(COALESCE(SUBSTRING(ps.quality FROM '\d+'), '0') AS INTEGER)) AS maxq + FROM playback_sources ps + WHERE ps.dead_at IS NULL AND ps.quality IS NOT NULL + GROUP BY ps.scene_id + ) + UPDATE scenes s SET max_quality_p = q.maxq + FROM q + WHERE q.scene_id = s.id + AND s.max_quality_p IS DISTINCT FROM q.maxq + """ +) + +_MAX_QUALITY_CLEAR_SQL = text( + """ + UPDATE scenes s SET max_quality_p = NULL + WHERE s.max_quality_p IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM playback_sources ps + WHERE ps.scene_id = s.id AND ps.dead_at IS NULL AND ps.quality IS NOT NULL + ) + """ +) + _STUDIOS_SQL = text( f""" UPDATE studios s SET scene_count = COALESCE(a.c, 0) @@ -112,4 +143,12 @@ def refresh_taxonomy_counts() -> dict[str, int]: out[name] = res.rowcount or 0 # Commit per-tabela, długie transakcje trzymałyby locki na hot tables. session.commit() + # max_quality_p, bez parametru wertykałów: jakość opisuje samo źródło, więc + # dotyczy tak samo głównego feedu jak wertykałów. + for name, stmt in ( + ("max_quality", _MAX_QUALITY_SQL), + ("max_quality_cleared", _MAX_QUALITY_CLEAR_SQL), + ): + out[name] = session.execute(stmt).rowcount or 0 + session.commit() return out diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index 93c968e..5165b62 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -16,6 +16,13 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + id: '2026-08-18c', + date: 'August 2026', + items: [ + 'Filter by minimum quality: 720p, 1080p or 4K, next to the length filter. It only shows scenes whose quality we actually know, and most sites never state it, so expect the list to get a lot shorter when you switch it on. The filter says so underneath.', + ], + }, { id: '2026-08-18b', date: 'August 2026', diff --git a/mobile/src/screens/ScenesFilterModal.tsx b/mobile/src/screens/ScenesFilterModal.tsx index 7cc7e21..15aac46 100644 --- a/mobile/src/screens/ScenesFilterModal.tsx +++ b/mobile/src/screens/ScenesFilterModal.tsx @@ -26,6 +26,9 @@ export interface FilterState { includeStubs: boolean; origin: string; minDurationSec: number; // 0 = bazowy próg (60s); >0 podnosi minimum (filtr na klipy-reklamy) + /** 0 = bez filtra. >0 = pokaż TYLKO sceny, o których WIEMY, że mają źródło w tej + * rozdzielczości lub wyższej. Patrz komentarz przy QUALITY_OPTIONS. */ + minQualityP: number; } export const DEFAULT_FILTER: FilterState = { @@ -37,6 +40,7 @@ export const DEFAULT_FILTER: FilterState = { includeStubs: false, // default: ukryj 5-10min hqporner trailery bez release_date origin: '', // pusty = wszystkie źródła; substring match np. 'hqporner' minDurationSec: 0, + minQualityP: 0, }; const SORT_OPTIONS: { value: ScenesSort; label: string }[] = [ @@ -56,6 +60,26 @@ const DURATION_OPTIONS: { value: number; label: string }[] = [ { value: 1800, label: '30+ min' }, ]; +// Filtr jakości (prośba użytkownika 17376139: "filter by video quality, like 720P, 1080p"). +// +// UWAGA co do zasięgu, i to jest istotne przy czytaniu wyników: `quality` zapisujemy +// oportunistycznie, tylko gdy listing źródła ją podaje. Pomiar 2026-08-18: zna ją +// **6,5% scen** (210 302 z 3 236 368), a najlepsze źródło ma 26% pokrycia. +// +// Dlatego filtr jest SUROWY: pokazuje wyłącznie sceny z potwierdzoną jakością >= progu +// (dla 1080p to 121 655 scen). Wariant łagodny, przepuszczający sceny o nieznanej +// jakości, został odrzucony po zmierzeniu: odsiewałby tylko 88 647 scen z 3,2 mln, +// czyli nie realizowałby intencji "nie chcę oglądać ziemniaka". +// +// Konsekwencja dla UI: po włączeniu filtra lista gwałtownie się kurczy i to jest +// POPRAWNE. Podpis pod przełącznikiem musi to mówić wprost, inaczej wygląda na zepsute. +const QUALITY_OPTIONS: { value: number; label: string }[] = [ + { value: 0, label: 'Any' }, + { value: 720, label: '720p+' }, + { value: 1080, label: '1080p+' }, + { value: 2160, label: '4K' }, +]; + export function ScenesFilterModal({ visible, initial, @@ -133,7 +157,8 @@ export function ScenesFilterModal({ draft.studioSlugs.length + draft.performerIds.length + (draft.hasPlayback ? 1 : 0) + - (draft.minDurationSec > 0 ? 1 : 0); + (draft.minDurationSec > 0 ? 1 : 0) + + (draft.minQualityP > 0 ? 1 : 0); return ( @@ -214,6 +239,36 @@ export function ScenesFilterModal({ +
+ + {QUALITY_OPTIONS.map((opt) => ( + setDraft({ ...draft, minQualityP: opt.value })} + > + + {opt.label} + + + ))} + + {draft.minQualityP > 0 ? ( + + Only scenes whose quality we actually know. Most sites do not state it, + so this hides a lot, including videos that may well be {draft.minQualityP}p. + + ) : null} +
+
0 ? filter.minQualityP : undefined, // 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.