feat(filtr): minimalna jakosc w filtrze scen (720p/1080p/4K)

Prosba uzytkownika (zgloszenie 17376139). Backend mial juz parametr `min_quality_p`,
ale w praktyce byl NIEUZYWALNY i to zajelo wiekszosc pracy.

Wydajnosc, mierzona na produkcji przez EXPLAIN ANALYZE, prog 1080p:

  EXISTS na playback_sources (stan zastany)   32 s   3 556 612 odwiedzonych scen
  + indeks funkcyjny per-scena                24 s
  JOIN z DISTINCT po jakosci                   1,1 s W IZOLACJI
  ten sam JOIN + reszta filtrow feedu         52 s   planer wraca do skanu po dacie
  kolumna scenes.max_quality_p (0029)          2,0-2,6 s
  + indeksy czesciowe pod progi (0030)         0,09-0,11 s

Wniosek, ktory kosztowal najwiecej: ksztaltem zapytania sie tego NIE dalo zalatwic.
Koszt to LICZBA odwiedzonych scen, nie pojedynczy test, a przy `ORDER BY created_at
DESC LIMIT 24` planer i tak wracal do przechodzenia scen po dacie, gdy tylko doszly
pozostale filtry (bramka wertykalow, heurystyka szkieletow, prog dlugosci). Stad
denormalizacja + indeksy czesciowe z predykatem progu, ktore daja gotowe sortowanie.

Semantyka SUROWA, swiadomie: `quality` zapisujemy oportunistycznie, zna ja 6,5% scen
(210 302 z 3 236 368), najlepsze zrodlo ma 26% pokrycia. Wariant lagodny, przepuszczajacy
sceny o nieznanej jakosci, odrzucony po zmierzeniu: odsiewalby 88 647 z 3,2 mln, czyli
nie realizowalby intencji "nie chce ogladac ziemniaka". Filtr pokazuje wiec tylko jakosc
POTWIERDZONA, a UI mowi to wprost podpisem pod przelacznikiem, bo inaczej gwaltowny
spadek liczby wynikow wyglada na zepsuta aplikacje.

Warianty tekstowe 4K/UHD usuniete z warunku: 0 wystapien na 238 452 zywe zrodla,
a `OR ilike` blokowal uzycie indeksu.

Odswiezanie w taxonomy_counts (co 3h), z czyszczeniem do NULL gdy scena traci zrodlo
z jakoscia. Backfill: 210 302 sceny w 2m56s.

Zweryfikowane na emulatorze: sekcja MIN QUALITY renderuje sie z podpisem ostrzegawczym.
Lista wyszla pusta, ale to build debugowy dostajacy 403 (podpis debug spoza allowlisty),
nie blad filtra: ten sam URL z poprawnym podpisem oddaje 50 wynikow w 0,12 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-08-18 11:06:34 +02:00
parent 085c0c1dd6
commit 0fed952c5c
8 changed files with 222 additions and 23 deletions

View file

@ -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")

View file

@ -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")

View file

@ -390,28 +390,14 @@ def list_scenes(
base = base.where(Scene.release_date >= cutoff) base = base.where(Scene.release_date >= cutoff)
if min_quality_p is not None: if min_quality_p is not None:
# PlaybackSource.quality to wolny string, szukamy liczb w prefixie ('1080p', # Skalarny filtr po zdenormalizowanej kolumnie. Wcześniejsze podejścia (EXISTS
# '1080p Full HD', '2160p'). Heurystyka: wystarczy że scena ma JEDEN żywy # na playback_sources, potem JOIN z DISTINCT) dawały 32-52 s na produkcji, bo
# playback z quality liczbą >= min. '4K'/'UHD' aliasujemy na 2160. # planer i tak przechodził `scenes` po `created_at` scena po scenie. Pomiary
from sqlalchemy import Integer, cast, or_ # i uzasadnienie: migracja 0029_scenes_max_quality.
numeric_q = cast( #
func.coalesce(func.substring(PlaybackSource.quality, r"\d+"), "0"), # NULL (93,5% katalogu) NIE przechodzi, i tak ma być: filtr obiecuje jakość
Integer, # potwierdzoną, a nie zgadywaną. UI mówi o tym wprost pod przełącznikiem.
) base = base.where(Scene.max_quality_p >= min_quality_p)
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),
)
)
)
if not include_stubs: if not include_stubs:
# Stub scene heuristic (współdzielona z licznikiem +N ulubionych): tube-only scena # Stub scene heuristic (współdzielona z licznikiem +N ulubionych): tube-only scena

View file

@ -10,6 +10,7 @@ from sqlalchemy import (
Float, Float,
ForeignKey, ForeignKey,
Integer, Integer,
SmallInteger,
String, String,
Text, Text,
UniqueConstraint, UniqueConstraint,
@ -50,6 +51,13 @@ class Scene(UUIDPKMixin, TimestampMixin, Base):
Boolean, nullable=False, server_default="false", index=True 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): class SceneExternalRef(Base):
__tablename__ = "scene_external_refs" __tablename__ = "scene_external_refs"

View file

@ -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( _STUDIOS_SQL = text(
f""" f"""
UPDATE studios s SET scene_count = COALESCE(a.c, 0) 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 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()
# 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 return out

View file

@ -16,6 +16,13 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: 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', id: '2026-08-18b',
date: 'August 2026', date: 'August 2026',

View file

@ -26,6 +26,9 @@ export interface FilterState {
includeStubs: boolean; includeStubs: boolean;
origin: string; origin: string;
minDurationSec: number; // 0 = bazowy próg (60s); >0 podnosi minimum (filtr na klipy-reklamy) 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 = { 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 includeStubs: false, // default: ukryj 5-10min hqporner trailery bez release_date
origin: '', // pusty = wszystkie źródła; substring match np. 'hqporner' origin: '', // pusty = wszystkie źródła; substring match np. 'hqporner'
minDurationSec: 0, minDurationSec: 0,
minQualityP: 0,
}; };
const SORT_OPTIONS: { value: ScenesSort; label: string }[] = [ const SORT_OPTIONS: { value: ScenesSort; label: string }[] = [
@ -56,6 +60,26 @@ const DURATION_OPTIONS: { value: number; label: string }[] = [
{ value: 1800, label: '30+ min' }, { 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({ export function ScenesFilterModal({
visible, visible,
initial, initial,
@ -133,7 +157,8 @@ export function ScenesFilterModal({
draft.studioSlugs.length + draft.studioSlugs.length +
draft.performerIds.length + draft.performerIds.length +
(draft.hasPlayback ? 1 : 0) + (draft.hasPlayback ? 1 : 0) +
(draft.minDurationSec > 0 ? 1 : 0); (draft.minDurationSec > 0 ? 1 : 0) +
(draft.minQualityP > 0 ? 1 : 0);
return ( return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose}> <Modal visible={visible} animationType="slide" onRequestClose={onClose}>
@ -214,6 +239,36 @@ export function ScenesFilterModal({
</View> </View>
</Section> </Section>
<Section title="Min quality">
<View style={styles.sortRow}>
{QUALITY_OPTIONS.map((opt) => (
<Pressable
key={opt.value}
style={[
styles.sortPill,
draft.minQualityP === opt.value && styles.sortPillActive,
]}
onPress={() => setDraft({ ...draft, minQualityP: opt.value })}
>
<Text
style={[
styles.sortPillText,
draft.minQualityP === opt.value && styles.sortPillTextActive,
]}
>
{opt.label}
</Text>
</Pressable>
))}
</View>
{draft.minQualityP > 0 ? (
<Text style={styles.qualityNote}>
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.
</Text>
) : null}
</Section>
<Section title="Source / hoster"> <Section title="Source / hoster">
<TextInput <TextInput
style={styles.search} style={styles.search}
@ -422,6 +477,9 @@ const styles = StyleSheet.create({
}, },
sortPillActive: { borderColor: theme.accentText, backgroundColor: theme.bg }, sortPillActive: { borderColor: theme.accentText, backgroundColor: theme.bg },
sortPillText: { color: theme.fg, fontSize: 13 }, sortPillText: { color: theme.fg, fontSize: 13 },
// Podpis pojawia się TYLKO gdy filtr jest włączony: bez niego gwałtowny spadek
// liczby wyników wygląda na błąd aplikacji, a jest poprawnym skutkiem 6,5% pokrycia.
qualityNote: { color: theme.muted, fontSize: 12, lineHeight: 17, marginTop: 8 },
sortPillTextActive: { color: theme.accentText, fontWeight: '600' }, sortPillTextActive: { color: theme.accentText, fontWeight: '600' },
switchRow: { switchRow: {
flexDirection: 'row', flexDirection: 'row',

View file

@ -84,6 +84,7 @@ export function ScenesScreen({
origin: filter.origin.trim() || undefined, origin: filter.origin.trim() || undefined,
jav: jav || undefined, jav: jav || undefined,
vertical, vertical,
min_quality_p: filter.minQualityP > 0 ? filter.minQualityP : undefined,
// 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.