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:
parent
085c0c1dd6
commit
0fed952c5c
8 changed files with 222 additions and 23 deletions
50
alembic/versions/20260818_0029_scenes_max_quality.py
Normal file
50
alembic/versions/20260818_0029_scenes_max_quality.py
Normal 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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||
|
|
@ -214,6 +239,36 @@ export function ScenesFilterModal({
|
|||
</View>
|
||||
</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">
|
||||
<TextInput
|
||||
style={styles.search}
|
||||
|
|
@ -422,6 +477,9 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
sortPillActive: { borderColor: theme.accentText, backgroundColor: theme.bg },
|
||||
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' },
|
||||
switchRow: {
|
||||
flexDirection: 'row',
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ export function ScenesScreen({
|
|||
origin: filter.origin.trim() || undefined,
|
||||
jav: jav || undefined,
|
||||
vertical,
|
||||
min_quality_p: filter.minQualityP > 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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue