diff --git a/app/api/scenes.py b/app/api/scenes.py index 6f83299..d4efb74 100644 --- a/app/api/scenes.py +++ b/app/api/scenes.py @@ -52,6 +52,9 @@ log = logging.getLogger(__name__) 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 = { "created_at", "release_date", @@ -606,6 +609,19 @@ def list_scenes( total_capped = False 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 # wiedziec, ile wierszy moze potrzebowac z jednego studia (patrz _studio_sorted_select). _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 # 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. - start = uuid.UUID(hashlib.md5((seed or uuid.uuid4().hex).encode("utf-8")).hexdigest()) - # 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. Szersze okno dla wszystkich - # nie wchodzi w gre: z filtrem tagu 6000 id to 2,7 s (probe scene_tags per scena). - 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()) + seed_value = seed or uuid.uuid4().hex + start = uuid.UUID(hashlib.md5(seed_value.encode("utf-8")).hexdigest()) + if _random_narrow: + # Z filtrem (szukanie, tag, obsada, studio, strona, jakosc) okno po PK nie ma + # sensu: 2000 kolejnych id to 0,05% katalogu, wiec wyszukiwanie w losowej + # kolejnosci pokazywalo garstke wynikow zamiast wszystkich (zgloszenie z apki + # 2026-09-16). Filtr sam zawezą zbior, wiec bierzemy pule jego wynikow i + # tasujemy JA: md5(id + seed) daje ten sam porzadek na kolejnych stronach, + # a nowy seed nowe rozdanie. Pula jest ograniczona, bo szeroki tag to setki + # tysiecy scen, a pelne sortowanie takiego zbioru trwaloby sekundy; przy + # typowym wyszukiwaniu (setki wynikow) pula obejmuje wszystko. + pool = base.limit(literal_column(str(_RANDOM_POOL))).subquery() + ordered = ( + select(Scene) + .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 ordered = base.order_by( Scene.created_at.desc(), Scene.release_date.desc().nullslast(), Scene.id.desc() diff --git a/mobile/App.tsx b/mobile/App.tsx index ad8e99a..958ade6 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -157,6 +157,10 @@ export default function App() { // 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). 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(null); useEffect(() => { @@ -276,11 +280,23 @@ export default function App() { if (apkInstallerAvailable) { const pendingApkUrl = out.apk_url!; const runInstall = async () => { + setInstalling(true); try { await installApk(pendingApkUrl); // PackageInstaller pokazuje natywny dialog, JS już nie ma co robić. } 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 () => { @@ -320,8 +336,12 @@ export default function App() { // wyświetlały się dwie identyczne liczby („Server: 0.3.0, App: 0.3.0") i // 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?`, + // 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: 'Download', onPress: () => Linking.openURL(out.apk_url!).catch(() => {}) }, { text: 'Install', onPress: tryInstall }, ], ); @@ -450,6 +470,30 @@ export default function App() { ) : null} + {installing ? ( + + + + Downloading the update + + + About 87 MB. Android will ask you to confirm the install when it finishes. + + + ) : null} {client ? ( diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index 4c1c6ba..7784e8d 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -35,6 +35,14 @@ export type 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', date: 'September 2026',