fix: losowanie w obrebie wynikow filtra, widoczne pobieranie aktualizacji

Zgloszenia z apki:
- „random filter on and searching titles shows few results": przy sort=random braliśmy
  okno 2000 kolejnych id (0,05% katalogu) i dopiero na nim nakladalismy filtry, wiec
  szukanie w losowej kolejnosci trafialo na garstke scen. Teraz przy aktywnym filtrze
  (szukanie, tag, obsada, studio, strona, jakosc, data, dlugosc) losujemy WYNIKI:
  md5(id + seed) nad pula do 5000 wynikow filtra. Okno po PK zostaje tylko dla listy
  bez filtra, gdzie chronilo przed skanem 2,3 mln wierszy.
- „clicking install does nothing": pobieranie 87 MB szlo bez zadnego sladu w UI. Teraz
  nakladka z kreceleem na czas pobierania, „Download" obok „Install" w monicie i link
  do pliku w komunikacie bledu (na repo git APK nie ma, user szukal go tam).

Sprawdzone: szukanie „massage" z sort=random daje pelne strony (50 + kolejna), ten sam
seed powtarza kolejnosc, strony sie nie nakladaja, inny seed inna kolejnosc; czasy 120-800
ms (tag anal 1,1 s), lista bez filtra bez zmian. Na emulatorze pelna sciezka aktualizacji
z 0.4.0: monit z trzema przyciskami, Install, pobieranie, systemowy dialog, versionCode 15.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-09-17 10:16:04 +02:00
parent 45754afc69
commit c8035c68fa
3 changed files with 100 additions and 16 deletions

View file

@ -52,6 +52,9 @@ log = logging.getLogger(__name__)
router = APIRouter(prefix="/scenes", tags=["scenes"], dependencies=[Depends(require_api_key)]) router = APIRouter(prefix="/scenes", tags=["scenes"], dependencies=[Depends(require_api_key)])
#: Ile wynikow filtra bierze udzial w losowaniu (patrz sort=random z filtrem).
_RANDOM_POOL = 5000
_VALID_SORTS = { _VALID_SORTS = {
"created_at", "created_at",
"release_date", "release_date",
@ -606,6 +609,19 @@ def list_scenes(
total_capped = False total_capped = False
total: int | None = _default_scene_count(session) if _is_pure_default else None total: int | None = _default_scene_count(session) if _is_pure_default else None
# Czy lista jest juz zawezona filtrem. Rozstrzyga o sposobie losowania (patrz nizej):
# wertykalu i has_playback tu nie liczymy, bo to sa polowy katalogu, nie zawezenie.
_random_narrow = bool(
q
or tag_slug_list
or perf_id_strings
or studio_slug_list
or origin
or min_quality_p is not None
or released_within_days is not None
or max_duration_sec is not None
)
# Offset i rozmiar pobrania sa potrzebne juz przy budowie sortu: `sort=studio` musi # Offset i rozmiar pobrania sa potrzebne juz przy budowie sortu: `sort=studio` musi
# wiedziec, ile wierszy moze potrzebowac z jednego studia (patrz _studio_sorted_select). # wiedziec, ile wierszy moze potrzebowac z jednego studia (patrz _studio_sorted_select).
_off = (page - 1) * per_page _off = (page - 1) * per_page
@ -676,21 +692,37 @@ def list_scenes(
# od losowego punktu startu JEST losowy i idzie po indeksie PK. Punkt startu # od losowego punktu startu JEST losowy i idzie po indeksie PK. Punkt startu
# wynika z seeda (md5 -> uuid), wiec paginacja jest stabilna, a nowy seed # wynika z seeda (md5 -> uuid), wiec paginacja jest stabilna, a nowy seed
# (pull-to-refresh w apce) daje nowe rozdanie. Za koncem zakresu lista sie konczy. # (pull-to-refresh w apce) daje nowe rozdanie. Za koncem zakresu lista sie konczy.
start = uuid.UUID(hashlib.md5((seed or uuid.uuid4().hex).encode("utf-8")).hexdigest()) seed_value = seed or uuid.uuid4().hex
# Okno kandydatow po PK z LIMIT, a filtry (EXISTS/NOT EXISTS) dopiero na nim. start = uuid.UUID(hashlib.md5(seed_value.encode("utf-8")).hexdigest())
# Bez okna planer robil Merge Anti Join z indeksem playback_sources po scene_id if _random_narrow:
# i przechodzil 2,3 mln wierszy (7,6 s), bo ORDER BY id pasowal do obu indeksow. # Z filtrem (szukanie, tag, obsada, studio, strona, jakosc) okno po PK nie ma
# Z oknem 2000 id to nested loop po kilkudziesieciu wierszach (pomiar 2026-09-14). # sensu: 2000 kolejnych id to 0,05% katalogu, wiec wyszukiwanie w losowej
# Okno rosnie ze strona, zeby glebsze strony mialy z czego wybierac. Sekcje # kolejnosci pokazywalo garstke wynikow zamiast wszystkich (zgloszenie z apki
# wertykalne (JAV, Anime) to ~2% katalogu, wiec dostaja 4x szersze okno; z 2000 id # 2026-09-16). Filtr sam zawezą zbior, wiec bierzemy pule jego wynikow i
# JAV wyciagal 27 scen i druga strona bylaby pusta. Szersze okno dla wszystkich # tasujemy JA: md5(id + seed) daje ten sam porzadek na kolejnych stronach,
# nie wchodzi w gre: z filtrem tagu 6000 id to 2,7 s (probe scene_tags per scena). # a nowy seed nowe rozdanie. Pula jest ograniczona, bo szeroki tag to setki
base_window = 8000 if (vertical or jav) else 2000 # tysiecy scen, a pelne sortowanie takiego zbioru trwaloby sekundy; przy
window = max(base_window, page * per_page * 4) # typowym wyszukiwaniu (setki wynikow) pula obejmuje wszystko.
cand = ( pool = base.limit(literal_column(str(_RANDOM_POOL))).subquery()
select(Scene.id).where(Scene.id > start).order_by(Scene.id.asc()).limit(window).subquery() ordered = (
) select(Scene)
ordered = base.where(Scene.id.in_(select(cand.c.id))).order_by(Scene.id.asc()) .join(pool, Scene.id == pool.c.id)
.order_by(func.md5(func.concat(Scene.id, seed_value)))
)
else:
# Okno kandydatow po PK z LIMIT, a filtry (EXISTS/NOT EXISTS) dopiero na nim.
# Bez okna planer robil Merge Anti Join z indeksem playback_sources po scene_id
# i przechodzil 2,3 mln wierszy (7,6 s), bo ORDER BY id pasowal do obu indeksow.
# Z oknem 2000 id to nested loop po kilkudziesieciu wierszach (pomiar 2026-09-14).
# Okno rosnie ze strona, zeby glebsze strony mialy z czego wybierac. Sekcje
# wertykalne (JAV, Anime) to ~2% katalogu, wiec dostaja 4x szersze okno; z 2000
# id JAV wyciagal 27 scen i druga strona bylaby pusta.
base_window = 8000 if (vertical or jav) else 2000
window = max(base_window, page * per_page * 4)
cand = (
select(Scene.id).where(Scene.id > start).order_by(Scene.id.asc()).limit(window).subquery()
)
ordered = base.where(Scene.id.in_(select(cand.c.id))).order_by(Scene.id.asc())
else: # created_at else: # created_at
ordered = base.order_by( ordered = base.order_by(
Scene.created_at.desc(), Scene.release_date.desc().nullslast(), Scene.id.desc() Scene.created_at.desc(), Scene.release_date.desc().nullslast(), Scene.id.desc()

View file

@ -157,6 +157,10 @@ export default function App() {
// OTA-check settled, gate'uje ekran PIN, żeby ewentualny reloadAsync (silent OTA) // OTA-check settled, gate'uje ekran PIN, żeby ewentualny reloadAsync (silent OTA)
// wydarzył się PRZED pytaniem o PIN, nie po (inaczej "PIN ×2", user-report mobilism). // wydarzył się PRZED pytaniem o PIN, nie po (inaczej "PIN ×2", user-report mobilism).
const [updateSettled, setUpdateSettled] = useState(false); const [updateSettled, setUpdateSettled] = useState(false);
// Pobieranie APK trwa i nic nie bylo tego widac: user klikal Install, a przez minute
// (87 MB) nie dzialo sie NIC, wiec zglaszal, ze przycisk nie dziala (zgloszenie z apki
// 2026-09-16). Ten stan rysuje nakladke z kreceleem na czas pobierania.
const [installing, setInstalling] = useState(false);
const backgroundedAt = useRef<number | null>(null); const backgroundedAt = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
@ -276,11 +280,23 @@ export default function App() {
if (apkInstallerAvailable) { if (apkInstallerAvailable) {
const pendingApkUrl = out.apk_url!; const pendingApkUrl = out.apk_url!;
const runInstall = async () => { const runInstall = async () => {
setInstalling(true);
try { try {
await installApk(pendingApkUrl); await installApk(pendingApkUrl);
// PackageInstaller pokazuje natywny dialog, JS już nie ma co robić. // PackageInstaller pokazuje natywny dialog, JS już nie ma co robić.
} catch (e) { } catch (e) {
Alert.alert('Install failed', e instanceof Error ? e.message : String(e)); // Gdy pobieranie albo instalacja padnie, zostaje przegladarka: plik jest
// pod tym samym adresem (na repo git APK nie ma, pyta o to user).
Alert.alert(
'Install failed',
`${e instanceof Error ? e.message : String(e)}\n\nYou can download the APK in your browser instead.`,
[
{ text: 'Later', style: 'cancel' },
{ text: 'Download', onPress: () => Linking.openURL(pendingApkUrl).catch(() => {}) },
],
);
} finally {
setInstalling(false);
} }
}; };
const tryInstall = async () => { const tryInstall = async () => {
@ -320,8 +336,12 @@ export default function App() {
// wyświetlały się dwie identyczne liczby („Server: 0.3.0, App: 0.3.0") i // wyświetlały się dwie identyczne liczby („Server: 0.3.0, App: 0.3.0") i
// monit wyglądał na pomyłkę. // monit wyglądał na pomyłkę.
`Goon ${out.version} is ready. It adds parts that silent updates cannot deliver, so it installs like a normal app.\n\nInstall now?`, `Goon ${out.version} is ready. It adds parts that silent updates cannot deliver, so it installs like a normal app.\n\nInstall now?`,
// Trzy przyciski to maksimum androidowego dialogu. „Download" zostaje,
// bo instalator w apce potrafi nie wystartowac (brak zgody, padniete
// pobieranie), a wtedy user nie ma jak wziac pliku recznie.
[ [
{ text: 'Later', style: 'cancel' }, { text: 'Later', style: 'cancel' },
{ text: 'Download', onPress: () => Linking.openURL(out.apk_url!).catch(() => {}) },
{ text: 'Install', onPress: tryInstall }, { text: 'Install', onPress: tryInstall },
], ],
); );
@ -450,6 +470,30 @@ export default function App() {
</View> </View>
</View> </View>
) : null} ) : null}
{installing ? (
<View
style={{
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
zIndex: 100,
backgroundColor: 'rgba(0,0,0,0.75)',
alignItems: 'center',
justifyContent: 'center',
padding: 32,
}}
>
<ActivityIndicator color={theme.fg} size="large" />
<RNText style={{ color: theme.fg, marginTop: 16, fontSize: 15, textAlign: 'center' }}>
Downloading the update
</RNText>
<RNText style={{ color: theme.muted, marginTop: 8, fontSize: 13, textAlign: 'center', lineHeight: 19 }}>
About 87 MB. Android will ask you to confirm the install when it finishes.
</RNText>
</View>
) : null}
{client ? ( {client ? (
<ClientProvider client={client}> <ClientProvider client={client}>
<PreferencesProvider> <PreferencesProvider>

View file

@ -35,6 +35,14 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-09-17a',
date: 'September 2026',
items: [
'Random order now shuffles what your filter or search actually found. Searching with random on used to return a handful of results, because the shuffle was picked before the search ran.',
'The update prompt shows that the download is running (it is 87 MB and nothing was visible for a minute), and it offers a plain browser download next to the in-app install.',
],
},
{ {
id: '2026-09-16d', id: '2026-09-16d',
date: 'September 2026', date: 'September 2026',