Compare commits

...

23 commits

Author SHA1 Message Date
goon-foss
45ee7a81e1 fix(api): szybkie szukanie po tytule dla popularnych i krotkich fraz
Some checks are pending
Backend tests / test (push) Waiting to run
Planer szacowal kazda fraze na 427 trafien (histogram 100 probek, dolny limit), wiec
zawsze szedl indeksem trigramow i sortowal wszystkie trafienia: "vo" 44 s (dwie
litery nie maja trigramu, indeks oddaje 4,3 mln wierszy), "mis" 6,5 s, "free" 1,9 s
(GOON-73/75).

- 0046: histogram title_normalized 1000 probek, szacunki bliskie prawdy.
- list_scenes: przy q i sorcie po dacie najpierw przejscie po indeksie sortu z
  limitem 300 ms (bez bitmap, w SAVEPOINT wycofywanym zawsze), gdy probka tytulow
  z pg_stats mowi, ze wystarczy przejsc do 30 tys. scen; inaczej zwykly plan.
  Frazy bez trigramu zawsze przejsciem (limit 3 s, potem pusto).

115 prawdziwych wyszukiwan z logow: wyniki identyczne, najgorszy 44 s -> 0,8 s,
srednie frazy dotykaja 3-7x mniej stron ("bad" 122 tys. -> 16 tys.).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:00:11 +02:00
goon-foss
7accd4692f fix(seo): bez prywatnych blacklist i z cache indeksu sitemapy
Blacklisty sa per urzadzenie od 2026-06-10, a SEO dalej wycinalo sume blacklist
wszystkich urzadzen: 465 osob, 4477 tagow, z publicznych stron znikalo ~86% scen.
To samo zapytanie przy liczeniu do /sitemap.xml pisalo 3,7 GB plikow tymczasowych
i przy kilku wejsciach robota naraz zapychalo dysk (GOON-22). Teraz strony SEO
pokazuja to, co katalog nowemu urzadzeniu, a indeks sitemapy liczy sie raz na 6 h
(blokada na rownolegle zadania). Liczenie 135 MB plikow tymczasowych, indeks 13 s,
potem 0,2 s. Wykonawcy 20 tys. -> 81 tys., sceny 3,5 mln.

Na VPS dodatkowo temp_file_limit=2GB dla API w override.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:00:11 +02:00
goon-foss
f33f37f117 fix(mobile): expo-video bez NoSuchElementException przy zamykaniu serwisu
toList() na values ConcurrentHashMap przy rozmiarze 1 robi iterator().next(); gdy
sesja zniknie miedzy size() i next(), leci NoSuchElementException (0.4.2, GOON-72).
ArrayList(values) idzie przez toArray(), ktore mapa robi bezpiecznie. Natywne,
czeka na nastepne APK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:00:11 +02:00
goon-foss
d3713d64b9 fix: source-stats z probki scen, nizsze progi autovacuum
source-stats liczyl flagi tag/obsada dla 2,5 mln par serwis-scena, czyli ok. 5 mln
losowych odczytow indeksow; trwal 22-32 min i przekroczyl limit (GOON-V). Teraz
flagi z probki 3000 scen na serwis (stala, po md5), liczba scen, swiezosc i
miniatury dalej dokladnie. 1 min 51 s, gwiazdki bez zmian, procenty +-2 pkt.

Autovacuum na domyslnych 20% nie ruszal duzych tabel od tygodni (scene_performers
11% stron widocznych, statystyki scenes z 25.08). Migracja 0045 daje 2% dla
szesciu najwiekszych tabel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:00:11 +02:00
goon-foss
55adf8b1cb fix(scheduler): godzina na dedup po tytule i dlugosci
Przebieg nadrabiajacy zaleglosci scalil 67 tys. scen (xnxx/xvideos, ten sam tytul, dlugosc
i obsada) i trwal 90 min, wiec przy limicie 30 min job raportowal sie jako zawieszony
(Sentry GOON-V), mimo ze pracowal. Zaleglosc wzieła sie z restartow workera, ktore ucinaly
wczesniejsze przebiegi.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:00:11 +02:00
goon-foss
5640ee094d fix(mobile): blad certyfikatu to nie usuniety film
"Trust anchor for certification path not found" lapal sie na "not found", wiec
odtworzenia eporner z bledem SSL szly jako "gone" (93 w tydzien): zly komunikat i zla
telemetria. Wejdzie z najblizsza paczka OTA.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:00:11 +02:00
goon-foss
41ad7f1818 feat: automatyczne sprzatanie martwych zrodel sxyprn i eporner
Oba serwisy kasuja filmy po cichu: strona odpowiada 200 z "Post Not Found" albo "Video has
been deleted". Probka 2026-09-19: 57% zywych w bazie zrodel sxyprn w wieku 10-40 dni i 22%
eporner bylo juz usunietych; w apce to puste kafelki i "the tube removed" po kliknieciu.

- eporner: ekstraktor rozpoznaje usunieta strone i rzuca HosterDead, wiec resolve oznacza
  zrodlo zamiast oddawac "blad chwilowy" (82 takie wejscia w 48 h, nic nie oznaczal).
- proxy miniatur: "Post Not Found" na sxyprn oznacza zrodlo od razu, 404 na miniaturze
  eporner uruchamia w tle sprawdzenie strony (28/30 takich miniatur to usuniete filmy).
  Indeks hash po thumbnail_url tylko dla eporner (0044), zeby znalezc zrodlo po obrazku.
- job dead-reap co 6 h: po 1500 zywych zrodel na serwis, polowa z ostatnich 30 dni.
  Oznacza tylko przy pewnym sygnale; siec i timeouty zostawia.

Sprawdzone na produkcji: 10/10 miniatur eporner i 23/23 postow sxyprn oznaczone, 17 zywych
postow nietknietych.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:00:11 +02:00
goon-foss
0edc85c05f feat(api): glowny feed bez archiwum i z limitem jednego zrodla na strone
"Newest ingest" bez filtrow pomija sceny z dociagania archiwum (backfill): deep-crawl
tubepornclassic dokladal 7,9 tys. scen z lat 2016-2018 dziennie, a xhamster/xnxx/xvideos/
sxyprn kolejne 3,3 tys., wszystkie na szczycie listy. Po odcieciu eporner (2 tys. anonimowych
uploadow dziennie) mial 121 ze 150 pozycji, wiec na pierwszych 8 stronach jedno zrodlo zajmuje
najwyzej 1/3 strony, dopoki sa inne; nadmiar zjezdza nizej, nic nie ginie. Pierwsze 3 strony:
eporner 51, xvideos 24, xnxx 20, youporn 20, pornxpph 15, fullporner 12.

Z filtrami i w sekcjach JAV/Anime bez zmian.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:00:11 +02:00
goon-foss
ca2a6a1547 fix: dzienny ingest filmow nie przepada o 05:00, apka nie staje na bledzie keystore
- scheduler: misfire_grace_time 1 h zamiast domyslnej 1 s. O 05:00 UTC startuje kilkanascie
  zadan naraz, movie-ingest dostawal watek 1,1 s po czasie i APScheduler go pomijal, wiec
  filmy nie przychodzily od 17.09 (watchdog, Sentry).
- getDeviceId: blad SecureStore ("Could not encrypt the value", Samsung A7, Android 10)
  wywalal kazde zapytanie do API. Teraz id zostaje w pamieci do konca sesji. Wejdzie z
  najblizsza paczka OTA.
- latka expo-modules-core 2.2.3, JNIDeallocator: watek sprzatajacy blokowal sie na sobie,
  nie na deallocatorze, stad ConcurrentModificationException przy przeladowaniu JS
  (Sentry GOON-48). Wejdzie z najblizszym APK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:00:11 +02:00
goon-foss
73853eb9ed perf(api): kilka tagow od najnowszych idzie po indeksie najrzadszego z nich
Z dwoma tagami (rough-sex + anal) lista szla stara droga po calym katalogu, 7,4 s na zimnym
cache. Teraz dolaczamy najrzadszy tag po ix_scene_tags_tag_sort, a reszta zostaje jako
EXISTS: 0,2 s. Dolaczona tabela ma alias, inaczej EXISTS po scene_tags korelowal sie z nia.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:00:11 +02:00
goon-foss
7f2fdfaed0 perf(api): lista scen jednego tagu od najnowszych po indeksie na scene_tags (GOON-64)
Ekran tagu (jeden tag, sort=release_date, 200 scen) sortowal po dacie z `scenes`, a filtrowal
po `scene_tags`. Postgres szedl od najnowszej sceny calego katalogu i kazda sprawdzal; przy
tagach rzedu 40-60 tys. scen (`rough-sex`) to 46 tys. scen na strone, 7-15 s na zimnym cache.
Dwie przebudowy samego zapytania nie pomogly (2x albo 30 s na duzych tagach), cache tez nie,
bo wejscia rozkladaja sie na setki tagow.

`scene_tags.sort_date` (0042) trzyma ta sama date co `_effective_date_desc()`, pilnowana przez
wyzwalacze w bazie, indeks (tag_id, sort_date DESC, scene_id DESC) w 0043. Wypelnianie 23 mln
wierszy: scripts/backfill_scene_tags_sort_date.py, partiami z pauza.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:00:11 +02:00
goon-foss
ed3a7b8f4e fix(merge): tytul z TPDB/StashDB nie przegrywa z dluzszym tytulem z tuba
Zasada "dluzszy tytul wygrywa" podmieniala czysty tytul kanoniczny na tubowy z doklejonym
studiem, obsada i data ("Milfy 26 09 16 Koda Monroe Voluptuous Therapist ..."). Pierwszy
przebieg studio_title_dedup zrobil tak z 491 scenami; tytuly przywrocone z external_records.
Teraz kanon wygrywa z tubem, a dluzszy tytul liczy sie tylko miedzy scenami tego samego rodzaju.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:00:10 +02:00
goon-foss
7fea132668 feat(dedup): kopie tej samej sceny z tubow o roznym formacie tytulu
Kazdy tube zapisuje tytul po swojemu ("[Milfy] Koda Monroe (...)", "Milfy 26 09 16 ...",
"... 4K"), a miniatury sa z innych klatek, wiec ani phash, ani title_duration_dedup
(identyczny tytul) ich nie lapaly. Jedna scena Milfy wisiala w katalogu 8 razy.

Nowy job porownuje rdzen tytulu w obrebie studia: bez nazwy studia, dat, znacznikow
jakosci i nazwisk obsady. Numery czesci zostaja ("Part 1" to nie "Part 2"). Straznicy:
dlugosc, data premiery, wspolna obsada, dluzszy rdzen gdy brak dlugosci. Kopie bez studia
dolaczaja tylko przy zgodnej co do 3 s dlugosci. Kategorie wpisane przez tube jako obsada
nie skracaja rdzenia. Cyklicznie co 12 h z oknem 30 dni.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:00:10 +02:00
goon-foss
c59ad6eb73 chore(site): link do APK 0.4.2
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:00:10 +02:00
goon-foss
6b06cc96bf fix(android): 0.4.2 domyka crash expo-video przy zamykaniu apki
Latka z 0.4.1 kopiowala mape sesji przed zwolnieniem playerow, ale sama kopia
tez iteruje mape, a wpisy znikaja z innego watku. Na LinkedHashMap wywalalo sie
wiec dalej, juz w toList() (Sentry GOON-6W, Redmi Note 8 Pro, Android 11).
Mapa jest teraz ConcurrentHashMap, iteracja przezyje rownolegle usuniecie.

versionCode 16 zeby posiadacze 0.4.0/0.4.1 dostali monit: runtime zostaje 1.4,
wiec sam numer kanalu ich nie rozrozni.

Sprawdzone na emulatorze: dwie sesje mediow zywe, zamkniecie z listy ostatnich
zwalnia obie, pusty bufor crashy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:00:10 +02:00
goon-foss
3c1d2c5907 perf(api): lista stron odswiezana w tle, nikt nie czeka na przeliczenie
Agregat po 4,1 mln zywych zrodel trwa 0,6-2 s, a cache wygasal co 5 minut, wiec co
wygasniecie ktos placil pelnym czasem (Sentry Slow DB Query list_sources, 186 zdarzen,
ostatnie dzisiaj). Teraz po wygasnieciu oddajemy stary wynik i przeliczamy w watku w tle,
a TTL idzie z 5 do 15 minut. Liczby zmieniaja sie o promile na godzine.

Pomiar na prodzie: pierwsze wejscie po restarcie 2,1 s (nieuniknione), potem 11-15 ms,
a wejscie tuz po wygasnieciu 38 ms zamiast pelnego przeliczenia.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:00:10 +02:00
goon-foss
be0f2d7d37 feat(android): apka instaluje sie i chodzi na Android TV
Prosba z apki 2026-09-16 („make a tv app of this"). Tania wersja: to samo APK,
zeby dalo sie je zainstalowac, znalezc w launcherze telewizora i obslugiwac pilotem
w istniejacym interfejsie. Pelnego interfejsu 10-stopowego (podswietlenie fokusu,
wieksze napisy, obejscie WebView przy hosterach) to NIE daje.

- uses-feature leanback i touchscreen jako NIEwymagane, plus jawne
  screen.portrait=false: blokada pionu sama z siebie dokladala WYMAGANIE ekranu
  pionowego i telewizor uznawal apke za niezgodna
- osobne wejscie TvMainActivity z LEANBACK_LAUNCHER, bez blokady pionu
  (na telefonie nic sie nie zmienia, launcher TV pyta tylko o leanback)
- banner 320x180 w res/drawable (res/drawable-*/ jest w .gitignore)

Dwie slepe uliczki, stad osobna klasa: `setRequestedOrientation` w onCreate to zmiana
konfiguracji, wiec system relaunchowal aktywnosc i na TV zostawal bialy ekran (widok
Reacta nie wracal); orientacja z values-television nie przechodzi, bo manifest czyta
zasoby bez konfiguracji (lint ManifestResource).

Sprawdzone na emulatorze android-34 android-tv (1920x1080): apka jest na liscie
leanback launchera, startuje na caly ekran poziomo, a pilotem da sie przejsc bramke
wieku (fokus chodzi po elementach, CENTER zaznacza), pominac samouczek i przewijac
siatke scen. Odtwarzania na TV nie testowalem, emulator ma software'owe kodeki.
Wersji APK nie ruszam, zmiana pojedzie z najblizszym wydaniem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:00:10 +02:00
goon-foss
c8035c68fa 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>
2026-09-22 12:00:10 +02:00
goon-foss
45754afc69 fix(mobile): Edge bez agresywnego kadrowania na foldzie i przy poziomych klipach
Zgloszenie Jana (fold): rozlozony ekran jest prawie kwadratowy, a klip byl skalowany do
wypelnienia i zostawal z niego srodek. Teraz liczymy, ile by sie ucielo: do 30 % zostaje
wypelnienie (pionowy klip na zwyklym telefonie traci ok. 20 % szerokosci i to jest OK),
powyzej cale wideo i czarne pasy. Podpis i kolumna przyciskow stoja przy krawedziach
ekranu, wiec przy pasach same wchodza na pasy zamiast na wideo. Poster pod wideo dostaje
to samo kadrowanie, zeby nie mrugalo przy starcie.

Zysk takze na zwyklym telefonie: ok. 19 % klipow RedGifs jest poziomych i do tej pory byly
rozdmuchane na caly ekran.

Zmierzone na emulatorze przez rozmiar powierzchni wideo (dumpsys activity top):
- telefon 1080x2400: pionowy klip 1242x2209 (wypelnienie, jak dotad), poziomy 1080x607
  i klip 3:4 1080x1440 (pasy zamiast kadrowania)
- rozlozony fold 1768x2208: pionowy klip 1140x1947 z pasami po 314 px, poziomy 1768x1298

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:00:10 +02:00
goon-foss
7bfd194485 fix(mobile): Edge - serduszko nie rusza dzwieku ani nie przewija klipu, jeden przycisk dzwieku, stroik feedu
Zgloszenia Jana 2026-09-16:
1+3. Tapniecie serduszka przelaczalo dzwiek i przewijalo klip na poczatek. Gesty lapala
   cala komorka razem z przyciskami, wiec tap w serduszko byl tez tapem w ekran, a zmiana
   dzwieku restartowala odtwarzanie (efekt aktywacji strony mial soundOn w zaleznosciach).
   Teraz gesty lapie tylko warstwa wideo, a wyciszenie ma osobny efekt.
2. Dwa przyciski dzwieku. Zostaje ten w kolumnie przy kciuku, w gornym pasku wchodzi stroik.
4. Za malo tagow na starcie: bylo 24 z 40, „anal" (27. co do liczby klipow) wypadal.
   Teraz 60 tagow + nisze + szukanie po calym slowniku (1,7 tys. tagow, nowy param q),
   wskazanie wazy 8 zamiast 5, a stroik mozna otworzyc w kazdej chwili i zmiana dziala
   od nastepnego rozdania.
5. Losowe miganie przy przewijaniu: dociagniecie kolejnej strony feedu podmienialo tablice
   klipow i restartowalo biezacy klip. Efekt zalezy teraz od numeru strony i adresow wideo.

Sprawdzone na emulatorze: tap w serduszko zostawia wyciszenie bez zmian i zapisuje like,
przycisk dzwieku odcisza, gorny pasek otwiera stroik, szukanie „anal" daje 8 wariantow,
a po Apply w clip_affinity siedzi anal=8.0. Edge dalej odklada zdarzenia w clip_feed_events.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:00:10 +02:00
goon-foss
40865d6ce8 test(normalize): zlepek nazwiska nie przechodzi przez separator feat/ft
Regula sklejki sasiednich czlonow (JoannaAngel, Bonnieb) nie byla pilnowana na
granicy obsady: przy martwym separatorze "Dickft" i "Paigefeat" wychodzily jako
trafienia. Trzy przypadki, ktore na starym regexie daly True, a teraz daja False.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:00:10 +02:00
goon-foss
bfa3444de2 feat(mobile): strona tworcy w Edge
Tapniecie nazwy tworcy otwiera jego strone: siatka wszystkich klipow, a tapniecie
kafelka gra je na pelnym ekranie z tym samym przewijaniem w pionie co Edge.
Zgloszenie z apki 2026-09-15: "cannot tap username as it's hidden behind icon tray".

- ClipPager: pionowy pager z dwoma playerami wyjety ze SwipeScreen, wspolny dla Edge
  i strony tworcy; feed, telemetria i autoSwipe zostaly w ekranach
- /clips?creator= oddaje `total` (liczba klipow tworcy) do naglowka
- ze strony tworcy leca tylko like/unlike i "nie interesuje": ogladanie stad nie moze
  zabetonowac feedu na jednym tworcy

Sprawdzone na emulatorze: link z Edge otwiera strone (@lilylovesyou2, 2 clips, verified),
kafelki trzymaja trzecia szerokosci takze w niepelnym rzedzie, pelny ekran gra i zamyka
sie przyciskiem pod paskiem stanu, a Edge dalej odklada zdarzenia w clip_feed_events.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:00:09 +02:00
goon-foss
7d5dd450f7 fix(mobile): monit o nowe APK takze na tym samym kanale OTA
0.4.0 i 0.4.1 maja ten sam runtime 1.4, a monit porownywal tylko runtime, wiec nikt
z 0.4.0 nie dostal propozycji aktualizacji i crash expo-video przy zamykaniu z ostatnich
wracal do Sentry dzien po poprawce (147164135).

- /version oddaje apk_build (versionCode 15)
- aplikacja czyta versionCode ZAINSTALOWANEGO APK przez natywny modul Sentry
  (expo-constants w SDK 52 nie oddaje go wcale, expo-application wymagalby nowego APK)
- numer builda trafia tez do kontekstu zgloszen z apki: app_version jedzie z paczka OTA,
  wiec nie mowil, ktore wydanie natywne ma zglaszajacy

Sprawdzone na emulatorze: 0.4.1 nie dostaje monitu i raportuje build=15, po instalacji
0.4.0 monit "Update available" pokazuje sie przy starcie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:00:09 +02:00
48 changed files with 2658 additions and 370 deletions

View file

@ -0,0 +1,89 @@
"""Data sortowania sceny powielona w scene_tags (GOON-64).
Lista "scen z tagiem X od najnowszych" (ekran tagu w apce, 200 scen) sortuje po dacie z
`scenes`, a filtruje po `scene_tags`. Zaden indeks nie laczy tych dwoch rzeczy, wiec
Postgres idzie po `ix_scenes_effective_date_desc` od najnowszej sceny i kazda sprawdza, czy
ma tag. Dla tagow sredniej wielkosci to dziesiatki tysiecy sprawdzen: `rough-sex` (41,5 tys.
scen) przegladal 46 tys. scen, zeby znalezc 201, czyli ~460 tys. blokow. 0,5 s na rozgrzanym
cache, 7-15 s na zimnym (Sentry GOON-64, pomiar 2026-09-18). Male tagi planer odwraca (od
tagu), duze znajduja 201 scen po kilku tysiacach, wiec bola te posrodku.
Dwie przebudowy zapytania nie pomogly: kandydaci po tagu bez ciezkich filtrow dali ~2x na
`rough-sex`, a wymuszenie "najpierw tag" robilo z duzych tagow 22-30 s (hash join po calym
`scenes`). Cache nie ma sensu: 237 wejsc w 48 h na 168 roznych tagow.
Dlatego `sort_date` = COALESCE(release_date, created_at w UTC jako data), to samo wyrazenie
co `_effective_date_desc()` w app/api/scenes.py, trzymane tez przy tagu. Indeks (tag_id,
sort_date DESC, scene_id DESC) z migracji 0043 daje od razu najnowsze sceny danego tagu.
Utrzymanie w bazie, nie w aplikacji, bo do scene_tags pisze wiele sciezek (ingest, merge,
porzadki tagow, skrypty):
- BEFORE INSERT / UPDATE OF scene_id na scene_tags: data z wiersza sceny,
- AFTER UPDATE OF release_date, created_at na scenes: poprawia tagi tej sceny (scena
dostaje date premiery z TPDB/StashDB, merge bierze wczesniejsze created_at).
Kolumna nullable i bez DEFAULT, wiec ALTER jest natychmiastowy. Istniejace 23 mln wierszy
wypelnia skrypt partiami w tle (scripts/backfill_scene_tags_sort_date.py), dopiero potem
indeks (0043) i przelaczenie zapytania. lock_timeout, bo ALTER czeka za kazdym dlugim
zapytaniem na scene_tags i w tym czasie blokuje wszystkie kolejne.
Revision ID: 0042_scene_tags_sort_date
Revises: 0041_scene_ext_refs_scene_source
Create Date: 2026-09-18
"""
from __future__ import annotations
from alembic import op
revision = "0042_scene_tags_sort_date"
down_revision: str | None = "0041_scene_ext_refs_scene_source"
branch_labels = None
depends_on = None
_SORT_DATE = "COALESCE({t}.release_date, ({t}.created_at AT TIME ZONE 'UTC')::date)"
def upgrade() -> None:
op.execute("SET LOCAL lock_timeout = '5s'")
op.execute("ALTER TABLE scene_tags ADD COLUMN IF NOT EXISTS sort_date date")
op.execute(f"""
CREATE OR REPLACE FUNCTION scene_tags_fill_sort_date() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
SELECT {_SORT_DATE.format(t='s')} INTO NEW.sort_date
FROM scenes s WHERE s.id = NEW.scene_id;
RETURN NEW;
END $$
""")
op.execute("""
CREATE TRIGGER trg_scene_tags_fill_sort_date
BEFORE INSERT OR UPDATE OF scene_id ON scene_tags
FOR EACH ROW EXECUTE FUNCTION scene_tags_fill_sort_date()
""")
op.execute(f"""
CREATE OR REPLACE FUNCTION scenes_propagate_sort_date() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE d date := {_SORT_DATE.format(t='NEW')};
BEGIN
UPDATE scene_tags SET sort_date = d
WHERE scene_id = NEW.id AND sort_date IS DISTINCT FROM d;
RETURN NULL;
END $$
""")
op.execute("""
CREATE TRIGGER trg_scenes_propagate_sort_date
AFTER UPDATE OF release_date, created_at ON scenes
FOR EACH ROW
WHEN (OLD.release_date IS DISTINCT FROM NEW.release_date
OR OLD.created_at IS DISTINCT FROM NEW.created_at)
EXECUTE FUNCTION scenes_propagate_sort_date()
""")
def downgrade() -> None:
op.execute("SET LOCAL lock_timeout = '5s'")
op.execute("DROP TRIGGER IF EXISTS trg_scenes_propagate_sort_date ON scenes")
op.execute("DROP TRIGGER IF EXISTS trg_scene_tags_fill_sort_date ON scene_tags")
op.execute("DROP FUNCTION IF EXISTS scenes_propagate_sort_date()")
op.execute("DROP FUNCTION IF EXISTS scene_tags_fill_sort_date()")
op.execute("ALTER TABLE scene_tags DROP COLUMN IF EXISTS sort_date")

View file

@ -0,0 +1,40 @@
"""Indeks "najnowsze sceny danego tagu" (GOON-64, kolumna z 0042).
(tag_id, sort_date DESC, scene_id DESC): lista scen z jednym tagiem, sort=release_date,
idzie po tym indeksie od najnowszej i konczy po LIMIT, niezaleznie od wielkosci tagu.
Uzasadnienie i pomiary w 0042.
Zakladac DOPIERO po wypelnieniu `sort_date` (scripts/backfill_scene_tags_sort_date.py).
Z indeksem w trakcie wypelniania kazda z 23 mln aktualizacji dopisywalaby tez wpis tutaj.
`ix_scene_tags_tag_id_scene_id` zostaje na razie, choc nowy indeks ma ten sam prefiks.
Osobna decyzja, jak przy 0040.
CONCURRENTLY w autocommit_block, jak w 0035, 0038, 0039 i 0041. Nieudany CONCURRENTLY
zostawia indeks INVALID; wtedy DROP INDEX i ponownie.
Revision ID: 0043_scene_tags_tag_sort_idx
Revises: 0042_scene_tags_sort_date
Create Date: 2026-09-18
"""
from __future__ import annotations
from alembic import op
revision = "0043_scene_tags_tag_sort_idx"
down_revision: str | None = "0042_scene_tags_sort_date"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.get_context().autocommit_block():
op.execute(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_scene_tags_tag_sort "
"ON scene_tags (tag_id, sort_date DESC, scene_id DESC)"
)
def downgrade() -> None:
with op.get_context().autocommit_block():
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_scene_tags_tag_sort")

View file

@ -0,0 +1,37 @@
"""Indeks do szukania zrodla eporner po adresie miniatury (app/dead_sources.py).
Proxy miniatur dostaje od CDN-u eporner 404 i zna tylko adres obrazka; zeby sprawdzic i
oznaczyc film, trzeba znalezc jego zrodlo. Miniatura ma inny identyfikator niz strona
filmu, wiec bez indeksu to przeszukanie 4 mln wierszy przy kazdym pustym kafelku.
Hash i czesciowy (tylko eporner): wyszukujemy wylacznie po rownosci, a eporner to ~350 tys.
wierszy, wiec indeks ma kilkanascie MB.
CONCURRENTLY w autocommit_block, jak w 0035, 0038, 0039, 0041 i 0043.
Revision ID: 0044_playback_eporner_thumb_idx
Revises: 0043_scene_tags_tag_sort_idx
Create Date: 2026-09-19
"""
from __future__ import annotations
from alembic import op
revision = "0044_playback_eporner_thumb_idx"
down_revision: str | None = "0043_scene_tags_tag_sort_idx"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.get_context().autocommit_block():
op.execute(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_playback_sources_eporner_thumb "
"ON playback_sources USING hash (thumbnail_url) "
"WHERE origin = 'tube:epornercom'"
)
def downgrade() -> None:
with op.get_context().autocommit_block():
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_playback_sources_eporner_thumb")

View file

@ -0,0 +1,52 @@
"""Nizsze progi autovacuum i autoanalyze dla duzych tabel.
Domyslne progi (20% zmienionych wierszy do vacuum, 10% do analyze) przy 4-23 mln wierszy
znacza, ze autovacuum prawie nie rusza tych tabel: 2026-09-21 scene_performers mialo
ostatni przebieg 27.07 i 11% stron oznaczonych jako widoczne, playback_sources 24%,
a statystyki scenes byly z 25.08. Skutek: index-only scan i tak czyta sterte (w
source-stats 94% odczytow scene_performers szlo na dysk, job przekroczyl 30 min,
Sentry GOON-V), a planista myli sie przy szukaniu po tytule (GOON-73).
2% daje przebieg co ~100 tys. zmian. Autovacuum jest dlawiony (cost_delay 2 ms), wiec
czesciej znaczy krocej, nie ciezej. SET na parametrach tabeli bierze tylko
SHARE UPDATE EXCLUSIVE, nie blokuje odczytow ani zapisow.
Revision ID: 0045_autovacuum_big_tables
Revises: 0044_playback_eporner_thumb_idx
Create Date: 2026-09-21
"""
from __future__ import annotations
from alembic import op
revision = "0045_autovacuum_big_tables"
down_revision: str | None = "0044_playback_eporner_thumb_idx"
branch_labels = None
depends_on = None
_TABLES = (
"scenes",
"playback_sources",
"scene_performers",
"scene_tags",
"scene_external_refs",
"external_records",
)
_PARAMS = (
"autovacuum_vacuum_scale_factor",
"autovacuum_vacuum_insert_scale_factor",
"autovacuum_analyze_scale_factor",
)
def upgrade() -> None:
op.execute("SET LOCAL lock_timeout = '5s'")
for table in _TABLES:
params = ", ".join(f"{p} = 0.02" for p in _PARAMS)
op.execute(f"ALTER TABLE {table} SET ({params})")
def downgrade() -> None:
op.execute("SET LOCAL lock_timeout = '5s'")
for table in _TABLES:
op.execute(f"ALTER TABLE {table} RESET ({', '.join(_PARAMS)})")

View file

@ -0,0 +1,36 @@
"""Wiekszy histogram statystyk dla scenes.title_normalized (szukanie po tytule).
Postgres szacuje `ILIKE '%fraza%'` z histogramu kolumny. Przy domyslnych 100 probkach
popularne frazy nie trafialy w zadna i szacunek spadal na dolny limit 0,01% (427 scen),
wiec planista zawsze szedl przez indeks trigramow i potem sortowal wszystkie trafienia:
"vo" 44 s (dwie litery nie maja trigramu, indeks oddaje 4,3 mln wierszy), "mis" 6,5 s,
"free" 1,9 s (Sentry GOON-73/75, 2026-09-22). Z 1000 probkami szacunki sa bliskie
prawdy i dla popularnych fraz wygrywa przejscie po ix_scenes_created_at_desc do 51.
trafienia: 9 ms, 15 ms, 60 ms. Rzadkie frazy dalej ida przez trigramy.
ANALYZE samej kolumny czyta probke 300 tys. wierszy, kilkanascie sekund.
Revision ID: 0046_scenes_title_stats
Revises: 0045_autovacuum_big_tables
Create Date: 2026-09-22
"""
from __future__ import annotations
from alembic import op
revision = "0046_scenes_title_stats"
down_revision: str | None = "0045_autovacuum_big_tables"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("SET LOCAL lock_timeout = '5s'")
op.execute("ALTER TABLE scenes ALTER COLUMN title_normalized SET STATISTICS 1000")
op.execute("ANALYZE scenes (title_normalized)")
def downgrade() -> None:
op.execute("SET LOCAL lock_timeout = '5s'")
op.execute("ALTER TABLE scenes ALTER COLUMN title_normalized SET STATISTICS -1")
op.execute("ANALYZE scenes (title_normalized)")

View file

@ -75,6 +75,9 @@ class ClipListOut(BaseModel):
limit: int limit: int
offset: int offset: int
has_more: bool has_more: bool
#: Liczba wszystkich klipow tworcy (tylko przy filtrze `creator`, indeks ix_clips_creator).
#: Strona tworcy w apce pokazuje ja w naglowku; przy innych listach nie liczymy.
total: int | None = None
class ClipStatsOut(BaseModel): class ClipStatsOut(BaseModel):
@ -484,16 +487,35 @@ _OPTIONS_CACHE: dict[str, tuple[float, OnboardingOptionsOut]] = {}
@router.get("/onboarding-options", response_model=OnboardingOptionsOut) @router.get("/onboarding-options", response_model=OnboardingOptionsOut)
def onboarding_options(session: Session = Depends(get_session)) -> OnboardingOptionsOut: def onboarding_options(
"""Najpopularniejsze tagi i nisze do wyboru „3 tapów" przy pierwszym wejsciu. session: Session = Depends(get_session),
Liczone z bazy, cache 10 min w procesie.""" q: str | None = Query(default=None, max_length=64, description="Szukaj w slowniku tagow"),
) -> OnboardingOptionsOut:
"""Najpopularniejsze tagi i nisze do wyboru przy pierwszym wejsciu w Edge.
Liczone z bazy, cache 10 min w procesie (szukanie z pominieciem cache).
`q` szuka po calym slowniku (1,7 tys. tagow), bo sama czolowka nie wystarcza: „anal"
to 27. tag co do liczby klipow i wypadal poza liste (uwaga Jana 2026-09-16)."""
if q and q.strip():
needle = f"%{q.strip().lower()}%"
found = [
OnboardingOptionOut(value=t, count=n)
for t, n in session.execute(
select(ClipTag.tag, func.count())
.where(ClipTag.tag.like(needle))
.group_by(ClipTag.tag)
.order_by(func.count().desc())
.limit(30)
)
]
return OnboardingOptionsOut(tags=found, niches=[])
hit = _OPTIONS_CACHE.get("v1") hit = _OPTIONS_CACHE.get("v1")
if hit and time.monotonic() - hit[0] < 600: if hit and time.monotonic() - hit[0] < 600:
return hit[1] return hit[1]
tags = [ tags = [
OnboardingOptionOut(value=t, count=n) OnboardingOptionOut(value=t, count=n)
for t, n in session.execute( for t, n in session.execute(
select(ClipTag.tag, func.count()).group_by(ClipTag.tag).order_by(func.count().desc()).limit(40) select(ClipTag.tag, func.count()).group_by(ClipTag.tag).order_by(func.count().desc()).limit(60)
) )
] ]
niches = [ niches = [
@ -513,15 +535,19 @@ def onboarding(
session: Session = Depends(get_session), session: Session = Depends(get_session),
device_id: str = Depends(get_device_id), device_id: str = Depends(get_device_id),
) -> AffinityOut: ) -> AffinityOut:
# +8 za wskazany tag, wiecej niz polubienie klipu (+5): to jawna deklaracja, a feed
# inaczej zbyt dlugo dochodzil do tego, co user i tak powiedzial na starcie (uwaga
# Jana 2026-09-16). Ten sam endpoint sluzy pozniejszemu dostrajaniu z zakladki Edge,
# wiec kolejne wskazania dokladaja sie do wag.
row, aff = _load_affinity(session, device_id) row, aff = _load_affinity(session, device_id)
for t in body.tags: for t in body.tags:
t = t.strip().lower()[:64] t = t.strip().lower()[:64]
if t: if t:
aff.tags[t] = round(aff.tags.get(t, 0.0) + 5.0, 3) aff.tags[t] = round(aff.tags.get(t, 0.0) + 8.0, 3)
for n in body.niches: for n in body.niches:
n = n.strip()[:64] n = n.strip()[:64]
if n: if n:
aff.niches[n] = round(aff.niches.get(n, 0.0) + 5.0, 3) aff.niches[n] = round(aff.niches.get(n, 0.0) + 8.0, 3)
_save_affinity(session, device_id, row, aff, 0) _save_affinity(session, device_id, row, aff, 0)
session.commit() session.commit()
row2 = session.get(ClipAffinity, device_id) row2 = session.get(ClipAffinity, device_id)
@ -639,8 +665,17 @@ def list_clips(
ids = [r.id for r in rows] ids = [r.id for r in rows]
tags = _tags_for(session, ids) tags = _tags_for(session, ids)
favs = _favorites_for(session, device_id, ids) favs = _favorites_for(session, device_id, ids)
total = None
if creator:
total = session.execute(
select(func.count()).select_from(Clip).where(Clip.dead_at.is_(None), Clip.creator == creator)
).scalar_one()
return ClipListOut( return ClipListOut(
items=[_clip_out(r, tags.get(r.id, []), r.id in favs) for r in rows], limit=limit, offset=offset, has_more=has_more items=[_clip_out(r, tags.get(r.id, []), r.id in favs) for r in rows],
limit=limit,
offset=offset,
has_more=has_more,
total=total,
) )

View file

@ -1,7 +1,9 @@
"""GET /scenes, lista i szczegóły scen z bazy kanonicznej.""" """GET /scenes, lista i szczegóły scen z bazy kanonicznej."""
from __future__ import annotations from __future__ import annotations
import collections
import logging import logging
import math
import re import re
import hashlib import hashlib
import uuid import uuid
@ -18,10 +20,12 @@ from sqlalchemy import (
func, func,
literal_column, literal_column,
select, select,
text,
true, true,
union, union,
) )
from sqlalchemy.exc import IntegrityError from psycopg.errors import QueryCanceled
from sqlalchemy.exc import IntegrityError, OperationalError
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session, aliased
from app.auth import require_api_key from app.auth import require_api_key
@ -52,6 +56,50 @@ 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
#: Glowny feed "newest ingest": najwiekszy udzial jednego zrodla na stronie i ile pierwszych
#: stron tak ukladamy (dalej zwykla kolejnosc po created_at). Patrz _diversify_pages.
_FEED_MAX_SHARE = 1 / 3
_FEED_DIVERSE_PAGES = 8
def _diversify_pages(
items: list[tuple[uuid.UUID, str]], page_size: int, max_share: float
) -> list[list[uuid.UUID]]:
"""Rozklada liste (id, zrodlo), juz w kolejnosci czasu, na strony tak, by jedno zrodlo
zajmowalo najwyzej `max_share` strony, dopoki sa sceny z innych zrodel. Strona bierze
najpierw najnowsze sceny w ramach limitu na zrodlo; gdy innych zrodel brakuje, dopelnia
sie najstarszymi z nadmiaru, wiec zadna strona nie zostaje dziurawa i nic nie ginie.
W obrebie strony i w obrebie zrodla kolejnosc czasu zostaje.
Po co: eporner dokladal ~2 tys. anonimowych uploadow dziennie przy kilkuset nowych
scenach z calej reszty i po odcieciu archiwum (backfill) zajmowal 121 ze 150 pozycji
pierwszych trzech stron (2026-09-19).
"""
cap = max(1, math.ceil(page_size * max_share))
remaining = list(enumerate(items))
pages: list[list[uuid.UUID]] = []
while remaining:
taken: list[tuple[int, tuple[uuid.UUID, str]]] = []
overflow: list[tuple[int, tuple[uuid.UUID, str]]] = []
counts: collections.Counter = collections.Counter()
for pos, item in remaining:
if len(taken) < page_size and counts[item[1]] < cap:
taken.append((pos, item))
counts[item[1]] += 1
else:
overflow.append((pos, item))
while len(taken) < page_size and overflow:
taken.append(overflow.pop(0))
taken.sort(key=lambda t: t[0])
pages.append([item[0] for _, item in taken])
used = {pos for pos, _ in taken}
remaining = [(pos, item) for pos, item in remaining if pos not in used]
return pages
_VALID_SORTS = { _VALID_SORTS = {
"created_at", "created_at",
"release_date", "release_date",
@ -162,6 +210,115 @@ def _blacklists_empty(session: Session, device_id: str) -> bool:
return not has_any return not has_any
# Szukanie po tytule (GOON-73/75, pomiary 2026-09-22). Sa dwa dobre plany i planer nie
# umie wybrac miedzy nimi, bo nie wie, ile tytulow pasuje:
# - przejscie po indeksie sortu od najnowszych do 51. trafienia: popularne i srednie frazy
# ("vo", "bad", "veronica") 10-110 ms, rzadkie ("jackerman") ponad 20 s, bo idzie przez
# caly katalog;
# - indeks trigramow, potem sort wszystkich trafien: rzadkie 30-500 ms, popularne sekundy
# ("mis" 6,5 s), a dwuliterowe ("vo") 44 s, bo bez trigramu indeks oddaje 4,3 mln wierszy.
# Dlatego najpierw przejscie z limitem czasu; nie zdazylo, to fraza jest rzadka i zwykly
# plan (trigramy) jest wlasciwy. Wiekszy histogram tytulow (migracja 0046) poprawil
# szacunki, ale srednie frazy dalej szly trigramami (0,2-1,3 s zamiast 10-110 ms).
# Zeby rzadkie frazy (nazwiska) i dalekie strony nie placily limitu na probe, szacujemy z
# probki tytulow ze statystyk Postgresa (1000 wartosci, ta sama, z ktorej planer liczy
# szacunek), ile scen trzeba przejsc: (offset + strona) / udzial trafien w probce. Na 115
# prawdziwych wyszukiwaniach przejscie wygrywalo zawsze do ~30 tys., wyzej i przy
# zawezeniu (tag, obsada, studio, dlugie minimum) zwykle nie miescilo sie w limicie.
_SEARCH_WALK_BUDGET_MS = 300
_SEARCH_WALK_MAX_SCAN = 30_000
# Fraza bez ciagu 3 liter/cyfr nie ma trigramu, wiec awaryjnego planu nie ma. Dluzszy
# limit, a po nim pusta lista: to zwykle stan w trakcie pisania ("q", "xz").
_SEARCH_WALK_BUDGET_NO_TRGM_MS = 3000
_TRGM_RUN_RE = re.compile(r"[^\W_]{3}")
_TITLE_SAMPLE_TTL = 3600.0
_TITLE_SAMPLE: dict = {"ts": 0.0, "vals": ()}
def _title_sample(session: Session) -> tuple[str, ...]:
"""Histogram + najczestsze wartosci `scenes.title_normalized` z pg_stats (cache 1 h)."""
import time as _time
now = _time.monotonic()
if _TITLE_SAMPLE["vals"] and now - _TITLE_SAMPLE["ts"] < _TITLE_SAMPLE_TTL:
return _TITLE_SAMPLE["vals"]
row = session.execute(
text(
"SELECT histogram_bounds::text::text[], most_common_vals::text::text[] "
"FROM pg_stats WHERE schemaname = 'public' AND tablename = 'scenes' "
"AND attname = 'title_normalized'"
)
).first()
vals = tuple((row[0] or []) + (row[1] or [])) if row else ()
_TITLE_SAMPLE.update(ts=now, vals=vals)
return vals
def _search_walk_ids(session: Session, ordered, off: int, want: int, budget_ms: int):
"""Id strony z `ordered` bez skanow bitmapowych i z limitem czasu; None po limicie.
Bez bitmap indeks trigramow (GIN) odpada, wiec planer idzie po indeksie sortu i konczy
na LIMIT. SAVEPOINT wycofujemy zawsze: SET LOCAL przezylby jego zwolnienie i reszta
requestu (obsada, tagi, zrodla) szlaby bez bitmap i z limitem czasu. Nic nie tracimy,
to sam odczyt, a id sa juz w Pythonie.
"""
sp = session.begin_nested()
try:
session.execute(
select(
func.set_config("enable_bitmapscan", "off", True),
func.set_config("statement_timeout", str(budget_ms), True),
)
)
return list(
session.execute(
ordered.with_only_columns(Scene.id)
.offset(literal_column(str(off)))
.limit(literal_column(str(want)))
).scalars()
)
except OperationalError as e:
if isinstance(e.orig, QueryCanceled):
return None
raise
finally:
sp.rollback()
def _search_walk_budget(q: str, off: int, want: int, narrowed: bool, sample) -> int | None:
"""Limit czasu (ms) na przejscie od najnowszych albo None, gdy od razu zwykly plan.
`sample` to funkcja zwracajaca probke tytulow, wolana tylko gdy potrzebna.
"""
if not _TRGM_RUN_RE.search(q):
return _SEARCH_WALK_BUDGET_NO_TRGM_MS
if narrowed:
return None
vals = sample()
needle = q.lower()
hits = sum(needle in v for v in vals)
if not hits or (off + want) * len(vals) / hits > _SEARCH_WALK_MAX_SCAN:
return None
return _SEARCH_WALK_BUDGET_MS
def _search_walk_rows(
session: Session, ordered, q: str, off: int, want: int, narrowed: bool
) -> list[Scene] | None:
"""Strona wynikow szukania przejsciem od najnowszych; None = uzyj zwyklego planu."""
budget = _search_walk_budget(q, off, want, narrowed, lambda: _title_sample(session))
if budget is None:
return None
ids = _search_walk_ids(session, ordered, off, want, budget)
if ids is None:
# Bez trigramu zwykly plan czyta caly indeks (44 s), wiec lepiej pusto.
return None if budget == _SEARCH_WALK_BUDGET_MS else []
if not ids:
return []
by_id = {s.id: s for s in session.execute(select(Scene).where(Scene.id.in_(ids))).scalars()}
return [by_id[i] for i in ids if i in by_id]
def _split_csv(raw: str | None) -> list[str]: def _split_csv(raw: str | None) -> list[str]:
if not raw: if not raw:
return [] return []
@ -463,17 +620,26 @@ def list_scenes(
# ix_scenes_created_at_desc zamiast materializować wszystkie scene_tags. Slug-JOIN # ix_scenes_created_at_desc zamiast materializować wszystkie scene_tags. Slug-JOIN
# ukrywał tag_id przed plannerem → używał średniej (8.4M/11541≈726) → zły plan # ukrywał tag_id przed plannerem → używał średniej (8.4M/11541≈726) → zły plan
# (4-12s). Z literałem: ~20ms. Zob. też _build... light mode. # (4-12s). Z literałem: ~20ms. Zob. też _build... light mode.
# Sort od najnowszych z filtrem tagu: jeden tag zamiast EXISTS dolaczamy jako wiersz
# scene_tags i sortujemy po jego `sort_date` (galaz sort == "release_date"). Przy kilku
# tagach dolaczamy NAJRZADSZY, bo spacer po indeksie konczy sie najpozniej na jego
# ostatniej scenie; reszta zostaje jako EXISTS. rough-sex + anal: 7,4 s na zimnym cache
# po starej sciezce (2026-09-18).
tag_sort_id: uuid.UUID | None = None
if tag_slug_list: if tag_slug_list:
id_by_slug = dict( tag_rows = session.execute(
session.execute( select(Tag.slug, Tag.id, Tag.scene_count).where(Tag.slug.in_(tag_slug_list))
select(Tag.slug, Tag.id).where(Tag.slug.in_(tag_slug_list))
).all() ).all()
) id_by_slug = {slug: tid for slug, tid, _ in tag_rows}
if sort == "release_date" and tag_rows:
tag_sort_id = min(tag_rows, key=lambda r: r[2] or 0)[1]
for slug in tag_slug_list: for slug in tag_slug_list:
tag_id = id_by_slug.get(slug) tag_id = id_by_slug.get(slug)
if tag_id is None: if tag_id is None:
base = base.where(false()) # nieznany slug → brak wyników base = base.where(false()) # nieznany slug → brak wyników
break break
if tag_id == tag_sort_id:
continue
base = base.where( base = base.where(
exists( exists(
select(1) select(1)
@ -587,6 +753,21 @@ def list_scenes(
# stubem. Filtruje anonymous tube-only sceny z newUrl/categories ingestu. # stubem. Filtruje anonymous tube-only sceny z newUrl/categories ingestu.
base = base.where(stub_exclusion_clause()) base = base.where(stub_exclusion_clause())
# Glowny feed "newest ingest" bez zawezen: bez scen z dociagania archiwum (backfill).
# deep-crawl tubepornclassic dokladal 7,9 tys. scen z lat 2016-2018 dziennie, a xhamster,
# xnxx, xvideos i sxyprn kolejne 3,3 tys., wszystkie z dzisiejszym created_at, wiec
# zajmowaly caly szczyt listy (zgloszenie 2026-09-19: "glownie theclassicporn i eporner").
# Z filtrem (szukanie, tag, obsada, studio, strona) i w sekcjach JAV/Anime zostaja, bo
# tam user chce wszystkiego z danego zakresu, a nie przegladu nowosci.
_plain_feed = (
sort == "created_at"
and not (q or tag_slug_list or perf_id_strings or studio_slug_list or origin)
and vertical is None
and not jav
)
if _plain_feed:
base = base.where(Scene.backfill.is_(False))
_is_pure_default = ( _is_pure_default = (
not include_stubs and not q and not studio_slug_list and not tag_slug_list not include_stubs and not q and not studio_slug_list and not tag_slug_list
and not perf_id_strings and origin is None and has_playback is None and not perf_id_strings and origin is None and has_playback is None
@ -606,6 +787,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
@ -628,6 +822,20 @@ def list_scenes(
# Wyrazenie musi byc IMMUTABLE, zeby weszlo do indeksu: `created_at::date` zalezy # Wyrazenie musi byc IMMUTABLE, zeby weszlo do indeksu: `created_at::date` zalezy
# od strefy sesji, stad jawne AT TIME ZONE 'UTC'. Indeks: # od strefy sesji, stad jawne AT TIME ZONE 'UTC'. Indeks:
# ix_scenes_effective_date_desc (migracja 0033). # ix_scenes_effective_date_desc (migracja 0033).
if tag_sort_id is not None:
# Z filtrem tagu ta sama data siedzi w scene_tags.sort_date (wyzwalacz,
# migracja 0042) i idzie po ix_scene_tags_tag_sort (0043): od najnowszej sceny
# tagu, koniec po LIMIT. Po samym `scenes` Postgres sprawdzal tag scena po scenie
# od najnowszej w calym katalogu i przy tagach rzedu 40-60 tys. scen (`rough-sex`)
# przegladal 46 tys. scen na jedna strone: 7-15 s na zimnym cache (GOON-64).
# W obrebie jednego dnia kolejnosc po id, nie po created_at: indeks jest
# wezszy, a stronicowanie i tak stabilne.
# Alias, bo przy kilku tagach EXISTS po scene_tags skorelowalby sie z tym JOIN-em.
st = aliased(SceneTag)
ordered = base.join(
st, and_(st.scene_id == Scene.id, st.tag_id == tag_sort_id)
).order_by(st.sort_date.desc(), st.scene_id.desc())
else:
ordered = base.order_by( ordered = base.order_by(
_effective_date_desc(), Scene.created_at.desc(), Scene.id.desc() _effective_date_desc(), Scene.created_at.desc(), Scene.id.desc()
) )
@ -676,14 +884,31 @@ 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
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. # 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 # 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. # 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). # 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 # 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 # wertykalne (JAV, Anime) to ~2% katalogu, wiec dostaja 4x szersze okno; z 2000
# JAV wyciagal 27 scen i druga strona bylaby pusta. Szersze okno dla wszystkich # 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). # 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 base_window = 8000 if (vertical or jav) else 2000
window = max(base_window, page * per_page * 4) window = max(base_window, page * per_page * 4)
@ -703,10 +928,53 @@ def list_scenes(
# psuje early-termination i przy filtrach EXISTS planer robi gather-all+sort (sekundy) # psuje early-termination i przy filtrach EXISTS planer robi gather-all+sort (sekundy)
# zamiast limit-aware index-walk po `ix_scenes_created_at_desc`. page/per_page to # zamiast limit-aware index-walk po `ix_scenes_created_at_desc`. page/per_page to
# walidowane inty (Query ge=1, le=200), więc literal_column jest bezpieczne. # walidowane inty (Query ge=1, le=200), więc literal_column jest bezpieczne.
if _plain_feed and page <= _FEED_DIVERSE_PAGES:
# Kandydatki (id + zrodlo) w kolejnosci czasu, ulozone przez _diversify_pages. Pula
# ma STALA wielkosc (2x tyle, ile stron ukladamy), bo inaczej kazda strona liczylaby
# uklad z innej puli i sceny przeskakiwalyby miedzy stronami. Pelne wiersze ladujemy
# tylko dla zwracanej strony.
cand_n = _FEED_DIVERSE_PAGES * per_page * 2
origin_col = (
select(func.min(PlaybackSource.origin))
.where(PlaybackSource.scene_id == Scene.id, PlaybackSource.dead_at.is_(None))
.correlate(Scene)
.scalar_subquery()
)
cand = session.execute(
ordered.with_only_columns(Scene.id, origin_col).limit(literal_column(str(cand_n)))
).all()
feed_pages = _diversify_pages([(r[0], r[1] or "") for r in cand], per_page, _FEED_MAX_SHARE)
page_ids = feed_pages[page - 1] if page <= len(feed_pages) else []
by_id = {
s.id: s for s in session.execute(select(Scene).where(Scene.id.in_(page_ids))).scalars()
} if page_ids else {}
rows = [by_id[i] for i in page_ids if i in by_id]
has_more = page < len(feed_pages) or len(cand) >= cand_n
else:
rows = studio_rows
if rows is None and q and (
sort == "created_at" or (sort == "release_date" and tag_sort_id is None)
):
rows = _search_walk_rows(
session,
ordered,
q,
_off,
_want,
narrowed=bool(
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
or (min_duration_sec or 0) > 600
),
)
if rows is None:
rows = ( rows = (
studio_rows session.execute(
if studio_rows is not None
else session.execute(
ordered.offset(literal_column(str(_off))).limit(literal_column(str(_want))) ordered.offset(literal_column(str(_off))).limit(literal_column(str(_want)))
) )
.scalars() .scalars()

View file

@ -13,8 +13,11 @@ Zasady (świadome, nie przypadkowe):
* **Age-gate = client-side overlay** (cookie `age_ok`). Treść jest w HTML, więc * **Age-gate = client-side overlay** (cookie `age_ok`). Treść jest w HTML, więc
crawler ją indeksuje; overlay zasłania ją tylko ludziom do potwierdzenia 18+. crawler ją indeksuje; overlay zasłania ją tylko ludziom do potwierdzenia 18+.
Dodatkowo `RTA` meta tag dla filtrów rodzicielskich. Dodatkowo `RTA` meta tag dla filtrów rodzicielskich.
* **Blacklist respektowany** te same wykluczenia performer/studio/tag co w /scenes. * **Bez blacklist.** Blacklisty sa prywatne, per urzadzenie (od 2026-06-10), a strony
Nie publikujemy SEO-stron dla zblacklistowanej treści. SEO sa anonimowe, wiec pokazuja to, co katalog pokazuje nowemu urzadzeniu. Wczesniej
wycinalismy sume blacklist WSZYSTKICH urzadzen: 465 osob ukrylo 4477 tagow i z SEO
znikalo ~86% scen, a liczenie do /sitemap.xml pisalo 3,7 GB plikow tymczasowych
i przy kilku naraz zapychalo dysk (Sentry GOON-22, 2026-09-22).
* **Anti-thin-page** strona encji powstaje tylko gdy ma realną zawartość * **Anti-thin-page** strona encji powstaje tylko gdy ma realną zawartość
(performer/studio z ≥1 żywą sceną; scena z ≥1 żywym source). Pusta encja → 404, (performer/studio z ≥1 żywą sceną; scena z ≥1 żywym source). Pusta encja → 404,
nie pusty doorway (Google karze masowe cienkie strony). nie pusty doorway (Google karze masowe cienkie strony).
@ -22,6 +25,8 @@ Zasady (świadome, nie przypadkowe):
from __future__ import annotations from __future__ import annotations
import os import os
import threading
import time
import uuid import uuid
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
@ -35,11 +40,6 @@ from sqlalchemy.orm import Session
from app.api.scenes import _needs_proxy, _wrap_image_proxy from app.api.scenes import _needs_proxy, _wrap_image_proxy
from app.db import get_session from app.db import get_session
from app.models.blacklist import (
BlacklistedPerformer,
BlacklistedStudio,
BlacklistedTag,
)
from app.models.performer import Performer, PerformerAlias from app.models.performer import Performer, PerformerAlias
from app.models.playback_source import PlaybackSource from app.models.playback_source import PlaybackSource
from app.models.scene import Scene, ScenePerformer, SceneTag from app.models.scene import Scene, ScenePerformer, SceneTag
@ -83,55 +83,24 @@ def _live_playback_exists():
) )
def _not_blacklisted():
"""Lista warunków WHERE wykluczających zblacklistowaną treść (performer/studio/tag).
Te same reguły co w GET /scenes, żeby SEO nie publikowało tego, co katalog ukrywa.
"""
return [
~exists(
select(1)
.select_from(ScenePerformer)
.join(
BlacklistedPerformer,
BlacklistedPerformer.performer_id == ScenePerformer.performer_id,
)
.where(ScenePerformer.scene_id == Scene.id)
),
~Scene.studio_id.in_(select(BlacklistedStudio.studio_id)),
~exists(
select(1)
.select_from(SceneTag)
.join(BlacklistedTag, BlacklistedTag.tag_id == SceneTag.tag_id)
.where(SceneTag.scene_id == Scene.id)
),
]
def _indexable_scenes(): def _indexable_scenes():
"""SELECT Scene żywych, nie-zblacklistowanych scen, baza pod listy i sitemap.""" """SELECT Scene z żywym źródłem, baza pod listy i sitemap."""
stmt = select(Scene).where(_live_playback_exists()) return select(Scene).where(_live_playback_exists())
for cond in _not_blacklisted():
stmt = stmt.where(cond)
return stmt
def _performer_indexable_exists(): def _performer_indexable_exists():
"""EXISTS: performer ma ≥1 indeksowalną (żywą, nie-blacklisted) scenę. """EXISTS: performer ma ≥1 indeksowalną (żywą) scenę.
Trzyma sitemap performerów w zgodzie z tym, co realnie renderuje Trzyma sitemap performerów w zgodzie z tym, co realnie renderuje
`performer_page`, inaczej sitemap zgłaszałby URL-e dające 404. `performer_page`, inaczej sitemap zgłaszałby URL-e dające 404.
""" """
sub = ( return exists(
select(1) select(1)
.select_from(ScenePerformer) .select_from(ScenePerformer)
.join(Scene, Scene.id == ScenePerformer.scene_id) .join(Scene, Scene.id == ScenePerformer.scene_id)
.where(ScenePerformer.performer_id == Performer.id) .where(ScenePerformer.performer_id == Performer.id)
.where(_live_playback_exists()) .where(_live_playback_exists())
) )
for cond in _not_blacklisted():
sub = sub.where(cond)
return exists(sub)
def _scene_card_rows(session: Session, scene_ids: list[uuid.UUID]) -> dict[uuid.UUID, dict]: def _scene_card_rows(session: Session, scene_ids: list[uuid.UUID]) -> dict[uuid.UUID, dict]:
@ -438,9 +407,27 @@ def _count(session: Session, stmt) -> int:
return session.execute(select(func.count()).select_from(stmt.subquery())).scalar_one() return session.execute(select(func.count()).select_from(stmt.subquery())).scalar_one()
# Indeks sitemapy liczy trzy agregaty po calym katalogu, a roboty potrafia zapytac kilka
# razy w tej samej sekundzie (2026-09-04: 5 naraz). Liczymy raz na _SITEMAP_INDEX_TTL
# w procesie; blokada sprawia, ze rownolegle zadania czekaja na jeden wynik.
_SITEMAP_INDEX_TTL = 6 * 3600.0
_sitemap_index_cache: tuple[float, str] | None = None
_sitemap_index_lock = threading.Lock()
@router.get("/sitemap.xml") @router.get("/sitemap.xml")
def sitemap_index(session: Annotated[Session, Depends(get_session)]) -> Response: def sitemap_index(session: Annotated[Session, Depends(get_session)]) -> Response:
"""Sitemap index, listuje paginowane pod-sitemapy per typ encji.""" """Sitemap index, listuje paginowane pod-sitemapy per typ encji."""
global _sitemap_index_cache
with _sitemap_index_lock:
cached = _sitemap_index_cache
if cached is None or time.monotonic() - cached[0] > _SITEMAP_INDEX_TTL:
cached = (time.monotonic(), _build_sitemap_index(session))
_sitemap_index_cache = cached
return Response(cached[1], media_type="application/xml")
def _build_sitemap_index(session: Session) -> str:
n_perf = _count( n_perf = _count(
session, session,
select(Performer.id).where(_performer_indexable_exists()), select(Performer.id).where(_performer_indexable_exists()),
@ -461,7 +448,7 @@ def sitemap_index(session: Annotated[Session, Depends(get_session)]) -> Response
for p in range(pages): for p in range(pages):
parts.append(f"<sitemap><loc>{bu}/sitemap/{kind}-{p}.xml</loc></sitemap>") parts.append(f"<sitemap><loc>{bu}/sitemap/{kind}-{p}.xml</loc></sitemap>")
parts.append("</sitemapindex>") parts.append("</sitemapindex>")
return Response("\n".join(parts), media_type="application/xml") return "\n".join(parts)
@router.get("/sitemap/{kind}-{page}.xml") @router.get("/sitemap/{kind}-{page}.xml")

View file

@ -16,6 +16,7 @@ from __future__ import annotations
import logging import logging
import os import os
import re import re
import threading
import time import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@ -27,7 +28,7 @@ from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.auth import require_api_key from app.auth import require_api_key
from app.db import get_session from app.db import get_session, session_scope
from app.models.playback_source import PlaybackSource from app.models.playback_source import PlaybackSource
from app.models.source_stats import SourceStats from app.models.source_stats import SourceStats
@ -86,8 +87,10 @@ class SourceOut(BaseModel):
_ICON_DIR = Path(__file__).resolve().parent.parent / "static" / "site-icons" _ICON_DIR = Path(__file__).resolve().parent.parent / "static" / "site-icons"
_SOURCES_TTL = 300.0 #: Po tylu sekundach wynik uznajemy za stary i przeliczamy W TLE (oddajac stary).
_SOURCES_TTL = 900.0
_SOURCES_CACHE: dict = {"at": 0.0, "rows": None} _SOURCES_CACHE: dict = {"at": 0.0, "rows": None}
_SOURCES_LOCK = threading.Lock()
def _icon_url(sitetag: str) -> str | None: def _icon_url(sitetag: str) -> str | None:
@ -134,20 +137,8 @@ def _sitetag_to_display(sitetag: str) -> str:
return sitetag return sitetag
@router.get("", response_model=SourceListOut) def _source_rows(session: Session):
def list_sources( return session.execute(
session: Annotated[Session, Depends(get_session)],
) -> SourceListOut:
"""Zwraca listę tube źródeł z ŻYWYMI playback_sources.
Filter: `origin LIKE 'tube:%'` (drop canonical:* TPDB trailery to inna semantyka).
"""
# Agregat idzie po 4,1 mln zywych zrodel (600 ms na kazde wejscie w Sites, Sentry:
# Slow DB Query list_sources). Liczby zmieniaja sie o promile na godzine, wiec
# wynik zyje w pamieci procesu przez _SOURCES_TTL sekund.
now = time.monotonic()
if _SOURCES_CACHE["rows"] is None or now - _SOURCES_CACHE["at"] > _SOURCES_TTL:
_SOURCES_CACHE["rows"] = session.execute(
select( select(
PlaybackSource.origin, PlaybackSource.origin,
func.count(PlaybackSource.id).label("scene_count"), func.count(PlaybackSource.id).label("scene_count"),
@ -157,8 +148,52 @@ def list_sources(
.where(PlaybackSource.origin.like("tube:%")) .where(PlaybackSource.origin.like("tube:%"))
.group_by(PlaybackSource.origin) .group_by(PlaybackSource.origin)
).all() ).all()
_SOURCES_CACHE["at"] = now
def _refresh_source_rows_bg() -> None:
if not _SOURCES_LOCK.acquire(blocking=False):
return # ktos juz liczy
def run() -> None:
try:
with session_scope() as s:
rows = _source_rows(s)
_SOURCES_CACHE["rows"] = rows
_SOURCES_CACHE["at"] = time.monotonic()
except Exception: # noqa: BLE001
log.exception("sources: odswiezenie listy stron w tle nie wyszlo")
finally:
_SOURCES_LOCK.release()
threading.Thread(target=run, name="sources-refresh", daemon=True).start()
def _cached_source_rows(session: Session):
rows = _SOURCES_CACHE["rows"] rows = _SOURCES_CACHE["rows"]
if rows is None:
with _SOURCES_LOCK:
if _SOURCES_CACHE["rows"] is None:
_SOURCES_CACHE["rows"] = _source_rows(session)
_SOURCES_CACHE["at"] = time.monotonic()
return _SOURCES_CACHE["rows"]
if time.monotonic() - _SOURCES_CACHE["at"] > _SOURCES_TTL:
_refresh_source_rows_bg()
return rows
@router.get("", response_model=SourceListOut)
def list_sources(
session: Annotated[Session, Depends(get_session)],
) -> SourceListOut:
"""Zwraca listę tube źródeł z ŻYWYMI playback_sources.
Filter: `origin LIKE 'tube:%'` (drop canonical:* TPDB trailery to inna semantyka).
"""
# Agregat idzie po 4,1 mln zywych zrodel (0,6-1,6 s na przeliczenie, Sentry: Slow DB
# Query list_sources, 186 zdarzen). Liczby zmieniaja sie o promile na godzine, wiec
# wynik zyje w pamieci procesu, a po wygasnieciu oddajemy STARY i przeliczamy w tle.
# Wczesniej co wygasniecie cache ktos placil pelnym czasem zapytania (2026-09-17).
rows = _cached_source_rows(session)
# Oceny z source_stats (policzone offline przez run_source_stats). Origin → row. # Oceny z source_stats (policzone offline przez run_source_stats). Origin → row.
stats = {s.origin: s for s in session.execute(select(SourceStats)).scalars().all()} stats = {s.origin: s for s in session.execute(select(SourceStats)).scalars().all()}

View file

@ -433,6 +433,12 @@ async def proxy_image(
# GOON-5 (Cloudflare 523) i GOON-D, bezsensowny noise w Sentry, lepiej # GOON-5 (Cloudflare 523) i GOON-D, bezsensowny noise w Sentry, lepiej
# info log + 502 pass-through bez exception. # info log + 502 pass-through bez exception.
log.info("img proxy upstream %d for %s", r.status_code, target) log.info("img proxy upstream %d for %s", r.status_code, target)
if r.status_code == 404 and ".eporner.com/" in target:
# 404 na miniaturze eporner prawie zawsze znaczy usuniety film; strona
# jest sprawdzana w tle przed oznaczeniem (app/dead_sources.py).
from app import dead_sources
dead_sources.eporner_thumb_404(target)
return Response( return Response(
content=b"", content=b"",
status_code=502 if r.status_code >= 500 else r.status_code, status_code=502 if r.status_code >= 500 else r.status_code,
@ -548,6 +554,11 @@ async def sxyprn_thumb(post_id: str) -> Response:
return Response(content=b"", status_code=502, media_type="image/jpeg") return Response(content=b"", status_code=502, media_type="image/jpeg")
html = r.text html = r.text
if "Post Not Found" in html: if "Post Not Found" in html:
# Post usuniety, czyli strumien tez (ta sama strona). Oznaczamy zrodlo w tle,
# zeby scena zniknela z list, zamiast czekac na klikniecie (2026-09-19).
from app import dead_sources
dead_sources.sxyprn_post_not_found(pid)
return Response(content=b"", status_code=404, media_type="image/jpeg") return Response(content=b"", status_code=404, media_type="image/jpeg")
m = _OG_IMG_RE.search(html) or _OG_IMG_RE2.search(html) or _VID_POSTER_RE.search(html) m = _OG_IMG_RE.search(html) or _OG_IMG_RE2.search(html) or _VID_POSTER_RE.search(html)
if not m: if not m:

View file

@ -116,6 +116,16 @@ class Settings(BaseSettings):
sched_title_dedup_hours: int = Field( sched_title_dedup_hours: int = Field(
default=12, validation_alias="GOON_SCHED_TITLE_DEDUP_HOURS" default=12, validation_alias="GOON_SCHED_TITLE_DEDUP_HOURS"
) )
# Dedup w obrebie studia po rdzeniu tytulu: ta sama scena z tubow, z ktorych kazdy pisze
# tytul po swojemu ("[Milfy] Koda Monroe (...)", "Milfy 26 09 16 ..."), 8 kopii jednej
# sceny w zgloszeniu bb1a357d. Okno 30 dni wstecz, bo kopie splywaja w ciagu dni. 0 = off.
sched_studio_title_dedup_hours: int = Field(
default=12, validation_alias="GOON_SCHED_STUDIO_TITLE_DEDUP_HOURS"
)
# Sprzatanie martwych zrodel sxyprn i eporner (app/scheduler/dead_reap.py): co N godzin
# porcja zywych zrodel na serwis, polowa z ostatnich 30 dni. 0 = off.
sched_dead_reap_hours: int = Field(default=6, validation_alias="GOON_SCHED_DEAD_REAP_HOURS")
dead_reap_per_origin: int = Field(default=1500, validation_alias="GOON_DEAD_REAP_PER_ORIGIN")
# Blacklista zdegenerowanych phashy, wartości dzielonych przez ≥10 scen (zaślepka, # Blacklista zdegenerowanych phashy, wartości dzielonych przez ≥10 scen (zaślepka,
# czarna klatka, intro studia). Audyt 2026-07-27: 73 takie wartości przy 4375 # czarna klatka, intro studia). Audyt 2026-07-27: 73 takie wartości przy 4375
# scenach, rekordzistka przy 892 scenach o 892 różnych tytułach. Tube może zacząć # scenach, rekordzistka przy 892 scenach o 892 różnych tytułach. Tube może zacząć

116
app/dead_sources.py Normal file
View file

@ -0,0 +1,116 @@
"""Oznaczanie martwych zrodel na podstawie sygnalow z proxy miniatur (2026-09-19).
Pusty kafelek to zwykle martwy film: user widzi scene bez miniatury, klika i dostaje
"the tube removed this video". Dotad zrodlo oznaczal dopiero resolve po kliknieciu, a sceny
bez klikniec wisialy w listach tygodniami. Proxy miniatur i tak pobiera to, co mowi o smierci
filmu, wiec tu zapisujemy wniosek:
- sxyprn: `/proxy/sxyprn-thumb/<post>` pobiera strone posta; "Post Not Found" to pewny
sygnal (ta sama strona daje miniature i strumien), oznaczamy od razu.
- eporner: miniatura z 404 w 28 na 30 przypadkow nalezala do usunietego filmu, w 2 strona
miala 404 (probka 2026-09-19). Mimo to sprawdzamy strone filmu przed oznaczeniem, bo 404
na miniaturze bywa tez chwilowe.
Wszystko w tle, na malej puli watkow: lista 50 scen potrafi dac kilkadziesiat 404 naraz,
a proxy miniatur nie moze na to czekac. Kazdy klucz obslugujemy raz na godzine.
"""
from __future__ import annotations
import logging
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from sqlalchemy import text
from app.db import session_scope
log = logging.getLogger(__name__)
_POOL = ThreadPoolExecutor(max_workers=2, thread_name_prefix="dead-mark")
_SEEN: dict[str, float] = {}
_SEEN_LOCK = threading.Lock()
_SEEN_TTL = 3600.0
_EPORNER_ORIGIN = "tube:epornercom"
_SXYPRN_ORIGIN = "tube:sxyprncom"
def _first_time(key: str) -> bool:
now = time.time()
with _SEEN_LOCK:
if _SEEN.get(key, 0.0) > now:
return False
_SEEN[key] = now + _SEEN_TTL
if len(_SEEN) > 50_000:
for k in [k for k, v in _SEEN.items() if v <= now]:
_SEEN.pop(k, None)
return True
def _mark(where_sql: str, params: dict, reason: str) -> int:
with session_scope() as s:
n = s.execute(
text(
"UPDATE playback_sources SET dead_at = now(), dead_reason = :reason "
f"WHERE dead_at IS NULL AND {where_sql}"
),
{**params, "reason": reason[:512]},
).rowcount
return n or 0
def sxyprn_post_not_found(post_id: str) -> None:
"""Strona posta sxyprn mowi "Post Not Found". Nie blokuje wolajacego."""
if not _first_time(f"sx:{post_id}"):
return
_POOL.submit(_mark_sxyprn, post_id)
def _mark_sxyprn(post_id: str) -> None:
try:
n = _mark(
"origin = :o AND page_url = :p",
{"o": _SXYPRN_ORIGIN, "p": f"https://sxyprn.com/post/{post_id}.html"},
"sxyprn: Post Not Found (proxy miniatury)",
)
if n:
log.info("dead-mark sxyprn %s: %d zrodel", post_id, n)
except Exception as e: # pragma: no cover - obronnie, to tylko sprzatanie
log.warning("dead-mark sxyprn %s padl: %s", post_id, e)
def eporner_thumb_404(thumb_url: str) -> None:
"""Miniatura eporner oddala 404. Sprawdza strone filmu w tle i oznacza, jesli usuniety."""
if not _first_time(f"ep:{thumb_url}"):
return
_POOL.submit(_verify_eporner, thumb_url)
def _verify_eporner(thumb_url: str) -> None:
from app.extractors._fetch import fetch_tube_html
from app.extractors._models import TubePageError
from app.extractors.tubes.eporner import is_deleted_page
try:
with session_scope() as s:
rows = s.execute(
text(
"SELECT page_url FROM playback_sources "
"WHERE origin = :o AND thumbnail_url = :t AND dead_at IS NULL"
),
{"o": _EPORNER_ORIGIN, "t": thumb_url},
).all()
for (page,) in rows:
reason = None
try:
if is_deleted_page(fetch_tube_html(page, timeout=30)):
reason = "eporner: video has been deleted (proxy miniatury)"
except TubePageError as e:
if e.status_code in (404, 410):
reason = f"eporner: strona {e.status_code} (proxy miniatury)"
if reason:
n = _mark("origin = :o AND page_url = :p", {"o": _EPORNER_ORIGIN, "p": page}, reason)
if n:
log.info("dead-mark eporner %s: %s", page, reason)
except Exception as e: # pragma: no cover - obronnie, to tylko sprzatanie
log.warning("dead-mark eporner %s padl: %s", thumb_url, e)

View file

@ -19,7 +19,7 @@ import logging
import re import re
from app.extractors._fetch import _DEFAULT_UA, browser_get, fetch_tube_html from app.extractors._fetch import _DEFAULT_UA, browser_get, fetch_tube_html
from app.extractors._models import StreamSource from app.extractors._models import HosterDead, StreamSource
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -46,8 +46,19 @@ def _hash_to_b36(hex_hash: str) -> str:
return "".join(parts) return "".join(parts)
def is_deleted_page(html: str) -> bool:
"""Strona usunietego filmu odpowiada 200 z tytulem, ale bez playera i z tekstem
"Video has been deleted" (np. "File has been removed due to copyright owner request").
Zywa strona tego tekstu nie ma (sprawdzone 2026-09-19)."""
return "Video has been deleted" in html and not _VID_RE.search(html)
def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None: def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None:
html = fetch_tube_html(page_url, timeout=timeout) html = fetch_tube_html(page_url, timeout=timeout)
if is_deleted_page(html):
# Bez tego resolve zwracal "blad chwilowy" (503) i zrodlo nigdy nie bylo oznaczane
# jako martwe: 82 takie wejscia w 48 h, te same martwe sceny wisialy w feedzie.
raise HosterDead(f"eporner {page_url}: video has been deleted")
m_vid = _VID_RE.search(html) m_vid = _VID_RE.search(html)
m_hash = _HASH_RE.search(html) m_hash = _HASH_RE.search(html)
if not m_vid or not m_hash: if not m_vid or not m_hash:

View file

@ -100,7 +100,7 @@ def healthz() -> dict[str, str]:
@app.get("/version") @app.get("/version")
def version() -> dict[str, str | None]: def version() -> dict[str, str | int | None]:
"""Mobile sprawdza po starcie żeby wykryć dostępność nowszej wersji APK. """Mobile sprawdza po starcie żeby wykryć dostępność nowszej wersji APK.
Zwraca: Zwraca:
@ -123,12 +123,21 @@ def version() -> dict[str, str | None]:
# mobile sklei z baseUrl. # mobile sklei z baseUrl.
public_url = os.environ.get("BACKEND_PUBLIC_URL", "").rstrip("/") public_url = os.environ.get("BACKEND_PUBLIC_URL", "").rstrip("/")
apk_url = f"{public_url}/static/app-release.apk" if public_url else "/static/app-release.apk" apk_url = f"{public_url}/static/app-release.apk" if public_url else "/static/app-release.apk"
# `runtime_version` to jedyny numer, po którym aplikacja pozna, JAKIE APK ma # `runtime_version` i `apk_build` to jedyne numery, po których aplikacja pozna, JAKIE
# zainstalowane. `version` z app.json przyjeżdża do niej razem z paczką OTA, więc po # APK ma zainstalowane. `version` z app.json przyjeżdża do niej razem z paczką OTA,
# aktualizacji stary APK raportuje nową wersję i sam siebie uznaje za aktualny. # więc po aktualizacji stary APK raportuje nową wersję i sam siebie uznaje za aktualny.
# Runtime siedzi w AndroidManifest, OTA go nie rusza. Podbijaj RAZEM z `version`, #
# ale tylko przy wydaniu APK (zmiana natywna). # `apk_build` = `versionCode` z android/app/build.gradle. Runtime sam nie wystarcza:
return {"version": "0.4.1", "runtime_version": "1.4", "apk_url": apk_url} # 0.4.0 i 0.4.1 mają ten sam runtime 1.4 (0.4.1 to poprawki natywne bez zmiany kanału
# OTA), więc przez dobę nikt z 0.4.0 nie dostał monitu, mimo że 0.4.1 naprawia crash
# expo-video przy zamykaniu z ostatnich (Sentry 147164135, zgłoszenie 2026-09-15).
# Podbijaj RAZEM z `version` przy każdym wydaniu APK.
return {
"version": "0.4.2",
"runtime_version": "1.4",
"apk_build": 16,
"apk_url": apk_url,
}
@app.get("/readyz") @app.get("/readyz")

View file

@ -134,3 +134,7 @@ class SceneTag(Base):
source_id: Mapped[uuid.UUID | None] = mapped_column( source_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("sources.id", ondelete="SET NULL") UUID(as_uuid=True), ForeignKey("sources.id", ondelete="SET NULL")
) )
# Data sortowania sceny (premiera albo dzien dodania), powielona tu pod indeks
# "najnowsze sceny tagu" (GOON-64, migracje 0042 i 0043). Pisze ja WYZWALACZ w bazie,
# nie aplikacja: wartosc podana przy INSERT i tak zostanie nadpisana.
sort_date: Mapped[date | None] = mapped_column(Date)

View file

@ -41,6 +41,18 @@ def _is_canonical(session: Session, source_id: uuid.UUID | None) -> bool:
return src is not None and src.kind in (SourceKind.tpdb, SourceKind.stashdb) return src is not None and src.kind in (SourceKind.tpdb, SourceKind.stashdb)
def _has_canonical_ref(session: Session, scene_id: uuid.UUID) -> bool:
return session.execute(
select(SceneExternalRef.scene_id)
.join(Source, Source.id == SceneExternalRef.source_id)
.where(
SceneExternalRef.scene_id == scene_id,
Source.kind.in_((SourceKind.tpdb, SourceKind.stashdb)),
)
.limit(1)
).first() is not None
class MergeError(Exception): class MergeError(Exception):
pass pass
@ -60,12 +72,18 @@ def merge_scenes(
if keep is None or drop is None: if keep is None or drop is None:
raise MergeError("scene not found") raise MergeError("scene not found")
# Sprawdzamy PRZED przepieciem refow, potem obie strony wygladalyby na kanoniczne.
keep_canonical = _has_canonical_ref(session, keep_id)
drop_canonical = _has_canonical_ref(session, drop_id)
_move_external_refs(session, keep_id=keep_id, drop_id=drop_id) _move_external_refs(session, keep_id=keep_id, drop_id=drop_id)
_move_performers(session, keep_id=keep_id, drop_id=drop_id) _move_performers(session, keep_id=keep_id, drop_id=drop_id)
_move_tags(session, keep_id=keep_id, drop_id=drop_id) _move_tags(session, keep_id=keep_id, drop_id=drop_id)
_move_fingerprints(session, keep_id=keep_id, drop_id=drop_id) _move_fingerprints(session, keep_id=keep_id, drop_id=drop_id)
_move_playback_sources(session, keep_id=keep_id, drop_id=drop_id) _move_playback_sources(session, keep_id=keep_id, drop_id=drop_id)
_coalesce_canonical_fields(keep, drop) _coalesce_canonical_fields(
keep, drop, keep_canonical=keep_canonical, drop_canonical=drop_canonical
)
session.delete(drop) session.delete(drop)
session.flush() session.flush()
@ -223,7 +241,9 @@ def _move_playback_sources(session: Session, *, keep_id: uuid.UUID, drop_id: uui
) )
def _coalesce_canonical_fields(keep: Scene, drop: Scene) -> None: def _coalesce_canonical_fields(
keep: Scene, drop: Scene, *, keep_canonical: bool = False, drop_canonical: bool = False
) -> None:
"""Wypełnij braki w `keep` polami z `drop`. Nie nadpisuje istniejących wartości.""" """Wypełnij braki w `keep` polami z `drop`. Nie nadpisuje istniejących wartości."""
if not keep.description and drop.description: if not keep.description and drop.description:
keep.description = drop.description keep.description = drop.description
@ -237,7 +257,16 @@ def _coalesce_canonical_fields(keep: Scene, drop: Scene) -> None:
keep.release_date = drop.release_date keep.release_date = drop.release_date
if not keep.studio_id and drop.studio_id: if not keep.studio_id and drop.studio_id:
keep.studio_id = drop.studio_id keep.studio_id = drop.studio_id
if drop.title and len(drop.title) > len(keep.title or ""): # Tytul z TPDB/StashDB to prawdziwy tytul sceny. Tube dokleja do niego studio, obsade
# i date ("Milfy 26 09 16 Koda Monroe Voluptuous Therapist ..."), wiec sama zasada
# "dluzszy wygrywa" podmieniala czysty tytul na zasmiecony: 491 scen kanonicznych przy
# pierwszym przebiegu studio_title_dedup (2026-09-18). Dluzszy wygrywa juz tylko
# miedzy scenami tego samego rodzaju.
take_drop_title = bool(drop.title) and (
(drop_canonical and not keep_canonical)
or (drop_canonical == keep_canonical and len(drop.title) > len(keep.title or ""))
)
if take_drop_title:
keep.title = drop.title keep.title = drop.title
keep.title_normalized = drop.title_normalized keep.title_normalized = drop.title_normalized
# created_at = najwcześniejsze "first seen" obu scen. NEW badge (apka) bazuje na # created_at = najwcześniejsze "first seen" obu scen. NEW badge (apka) bazuje na

126
app/scheduler/dead_reap.py Normal file
View file

@ -0,0 +1,126 @@
"""Cykliczne sprzatanie martwych zrodel sxyprn i eporner (2026-09-19).
Oba serwisy kasuja filmy szybko i po cichu: strona dalej odpowiada 200, tylko z
komunikatem ("Post Not Found" na sxyprn, "Video has been deleted" na eporner). Probka
2026-09-19: 57% zywych w bazie zrodel sxyprn w wieku 10-40 dni i 22% eporner bylo juz
usunietych. W apce to puste kafelki i "the tube removed this video" po kliknieciu.
Dwie drogi, ktore sie uzupelniaja:
- app/dead_sources.py oznacza to, co ktos wlasnie przewija (sygnal z proxy miniatur),
- ten job co kilka godzin sprawdza porcje reszty: polowe z ostatnich 30 dni, bo tam
kasowanie jest najczestsze, polowe losowo ze starszych. Bez kursora i bez stanu: kazdy
przebieg bierze tylko zywe zrodla, wiec oznaczone same wypadaja z puli.
Oznaczamy tylko przy pewnym sygnale (komunikat o usunieciu albo 404 strony). Siec, timeout,
rate-limit i strona bez playera to "nie wiadomo" i zrodlo zostaje. Wolno i grzecznie
(3 watki, odstep przed kazdym pobraniem), bo sxyprn to nasze najczesciej ogladane zrodlo i
ban IP bylby gorszy niz martwe kafelki. Pelny przebieg po zaleglosciach:
scripts/sxyprn_reap_dead.py.
"""
from __future__ import annotations
import collections
import logging
import time
from concurrent.futures import ThreadPoolExecutor
from sqlalchemy import text
from app.db import session_scope
log = logging.getLogger(__name__)
_CONCURRENCY = 3
_SPACING_SEC = 0.5
_TIMEOUT = 20.0
_ALIVE, _DEAD, _UNKNOWN = "alive", "dead", "unknown"
def _probe_sxyprn(page_url: str) -> str:
from app.extractors._models import HosterDead
from app.extractors.tubes import sxyprn
try:
res = sxyprn.extract(page_url, timeout=_TIMEOUT)
except HosterDead:
return _DEAD
except Exception:
return _UNKNOWN
return _ALIVE if res and getattr(res[0], "link", None) else _UNKNOWN
def _probe_eporner(page_url: str) -> str:
from app.extractors._fetch import fetch_tube_html
from app.extractors._models import TubePageError
from app.extractors.tubes.eporner import is_deleted_page
try:
html = fetch_tube_html(page_url, timeout=_TIMEOUT)
except TubePageError as e:
return _DEAD if e.status_code in (404, 410) else _UNKNOWN
except Exception:
return _UNKNOWN
if is_deleted_page(html):
return _DEAD
return _ALIVE if "EP.video.player.vid" in html else _UNKNOWN
_PROBES = {
"tube:sxyprncom": ("sxyprn-reap: Post Not Found", _probe_sxyprn),
"tube:epornercom": ("eporner-reap: video has been deleted", _probe_eporner),
}
def _pick(origin: str, per_run: int) -> list[tuple[str, str]]:
half = per_run // 2
with session_scope() as s:
recent = s.execute(
text(
"SELECT id::text, page_url FROM playback_sources "
"WHERE origin = :o AND dead_at IS NULL AND page_url IS NOT NULL "
"AND created_at > now() - interval '30 days' ORDER BY random() LIMIT :n"
),
{"o": origin, "n": half},
).all()
older = s.execute(
text(
"SELECT id::text, page_url FROM playback_sources "
"WHERE origin = :o AND dead_at IS NULL AND page_url IS NOT NULL "
"AND created_at <= now() - interval '30 days' ORDER BY random() LIMIT :n"
),
{"o": origin, "n": per_run - len(recent)},
).all()
return [(r[0], r[1]) for r in recent + older]
def run_dead_reap(*, per_origin: int = 2000) -> dict[str, dict[str, int]]:
"""Sprawdza po `per_origin` zywych zrodel sxyprn i eporner, oznacza usuniete."""
out: dict[str, dict[str, int]] = {}
for origin, (reason, probe) in _PROBES.items():
t0 = time.time()
rows = _pick(origin, per_origin)
cnt: collections.Counter = collections.Counter()
dead_ids: list[str] = []
def work(row: tuple[str, str], _probe=probe) -> tuple[str, str]:
time.sleep(_SPACING_SEC)
return row[0], _probe(row[1])
with ThreadPoolExecutor(max_workers=_CONCURRENCY) as pool:
for sid, verdict in pool.map(work, rows):
cnt[verdict] += 1
if verdict == _DEAD:
dead_ids.append(sid)
marked = 0
for i in range(0, len(dead_ids), 500):
with session_scope() as s:
marked += s.execute(
text(
"UPDATE playback_sources SET dead_at = now(), dead_reason = :r "
"WHERE id = ANY(CAST(:ids AS uuid[])) AND dead_at IS NULL"
),
{"r": reason, "ids": dead_ids[i : i + 500]},
).rowcount
out[origin] = {"checked": len(rows), **cnt, "marked": marked}
log.info("dead-reap %s: %s w %.0f s", origin, out[origin], time.time() - t0)
return out

View file

@ -390,15 +390,52 @@ def _job_title_duration_dedup() -> None:
try: try:
from app.scheduler.title_duration_dedup import run_title_duration_dedup from app.scheduler.title_duration_dedup import run_title_duration_dedup
# Godzina, nie domyslne 30 min: przebieg nadrabiajacy zaleglosci (po serii restartow
# workera, ktore ucinaly wczesniejsze) scalil 67 tys. scen i trwal 90 min, wiec job
# zglosil sie jako zawieszony, choc pracowal (2026-09-20, Sentry GOON-V).
_run_with_timeout( _run_with_timeout(
lambda: run_title_duration_dedup(playback_only=True, commit=True), lambda: run_title_duration_dedup(playback_only=True, commit=True),
label="title-duration-dedup", label="title-duration-dedup",
timeout_sec=3600,
) )
log.info("[scheduler] title-duration dedup done") log.info("[scheduler] title-duration dedup done")
except Exception: except Exception:
log.exception("[scheduler] title-duration dedup failed") log.exception("[scheduler] title-duration dedup failed")
def _job_studio_title_dedup() -> None:
"""Scal kopie tej samej sceny z tubow, ktore zapisuja tytul kazdy po swojemu (to samo
studio + ten sam rdzen tytulu, report bb1a357d). title_duration_dedup wymaga
identycznego tytulu, a phash nie lapie, bo miniatury sa z roznych klatek."""
log.info("[scheduler] studio-title dedup starting")
try:
from app.scheduler.studio_title_dedup import run_studio_title_dedup
_run_with_timeout(
lambda: run_studio_title_dedup(days=30, commit=True),
label="studio-title-dedup",
)
log.info("[scheduler] studio-title dedup done")
except Exception:
log.exception("[scheduler] studio-title dedup failed")
def _job_dead_reap(per_origin: int) -> None:
"""Oznacz martwe zrodla sxyprn i eporner (strona mowi, ze film usuniety). Porcja na
przebieg, polowa z ostatnich 30 dni; szczegoly w app/scheduler/dead_reap.py."""
log.info("[scheduler] dead-reap starting (per_origin=%d)", per_origin)
try:
from app.scheduler.dead_reap import run_dead_reap
# 2 x 1500 sprawdzen po ~1,2 s na 3 watkach to ~20 min; limit z zapasem.
_run_with_timeout(
lambda: run_dead_reap(per_origin=per_origin), label="dead-reap", timeout_sec=3600
)
log.info("[scheduler] dead-reap done")
except Exception:
log.exception("[scheduler] dead-reap failed")
def _job_ingest_watchdog( def _job_ingest_watchdog(
max_age_hours: int, search_max_age_hours: int, movie_max_age_hours: int max_age_hours: int, search_max_age_hours: int, movie_max_age_hours: int
) -> None: ) -> None:
@ -508,7 +545,12 @@ def build_scheduler(cfg: dict[str, Any]) -> BlockingScheduler:
performer_continuous_seconds: int | None performer_continuous_seconds: int | None
performer_continuous_refresh_days: int performer_continuous_refresh_days: int
""" """
sched = BlockingScheduler(timezone="UTC") # misfire_grace_time: domyslnie 1 s. O 05:00 UTC (wspolny anchor) startuje kilkanascie
# zadan naraz i dzienny movie-ingest dostawal watek 1,1 s po czasie, wiec APScheduler go
# POMIJAL ("was missed by 0:00:01"), dzien w dzien. Filmy staly 4-8 dni, zanim watchdog
# to pokazal (2026-09-19). Godzina zapasu; coalesce i max_instances=1 i tak zbijaja
# zaleglosci w jedno uruchomienie.
sched = BlockingScheduler(timezone="UTC", job_defaults={"misfire_grace_time": 3600})
sched.add_job( sched.add_job(
_job_site_icons, _job_site_icons,
@ -723,6 +765,30 @@ def build_scheduler(cfg: dict[str, Any]) -> BlockingScheduler:
) )
log.info("scheduler: title-duration dedup every %dh", cfg["title_dedup_hours"]) log.info("scheduler: title-duration dedup every %dh", cfg["title_dedup_hours"])
if cfg.get("studio_title_dedup_hours"):
sched.add_job(
_job_studio_title_dedup,
IntervalTrigger(hours=cfg["studio_title_dedup_hours"], start_date=INTERVAL_ANCHOR),
id="studio_title_dedup",
replace_existing=True,
max_instances=1,
coalesce=True,
)
log.info("scheduler: studio-title dedup every %dh", cfg["studio_title_dedup_hours"])
if cfg.get("dead_reap_hours"):
_dr_per = cfg.get("dead_reap_per_origin") or 1500
sched.add_job(
_job_dead_reap,
IntervalTrigger(hours=cfg["dead_reap_hours"], start_date=INTERVAL_ANCHOR),
args=[_dr_per],
id="dead_reap",
replace_existing=True,
max_instances=1,
coalesce=True,
)
log.info("scheduler: dead-reap every %dh (per_origin=%d)", cfg["dead_reap_hours"], _dr_per)
if cfg.get("ingest_watchdog_hours"): if cfg.get("ingest_watchdog_hours"):
wd_max_age = cfg.get("ingest_watchdog_max_age_hours") or 48 wd_max_age = cfg.get("ingest_watchdog_max_age_hours") or 48
wd_search_max_age = cfg.get("ingest_watchdog_search_max_age_hours") or 168 wd_search_max_age = cfg.get("ingest_watchdog_search_max_age_hours") or 168

View file

@ -33,6 +33,12 @@ _TELEMETRY_WINDOW_DAYS = 7
# mniejszej próbce lecimy na proxy/heurystykę (nie zerujemy gwiazdek znanemu-dobremu). # mniejszej próbce lecimy na proxy/heurystykę (nie zerujemy gwiazdek znanemu-dobremu).
_TELEMETRY_MIN_ATTEMPTS = 25 _TELEMETRY_MIN_ATTEMPTS = 25
_EVENT_RETENTION_DAYS = 30 _EVENT_RETENTION_DAYS = 30
# Flagi tag/perf/desc/studio/dur liczone z probki scen na origin, nie ze wszystkich.
# Pelny przebieg to ~5 mln losowych odczytow indeksow scene_tags/scene_performers
# (2,5 mln par origin-scena) i 22-32 min; 2026-09-21 przekroczyl 30 min limitu
# (Sentry GOON-V). Probka po md5(scene_id) jest stala miedzy przebiegami, wiec
# gwiazdki nie skacza; blad procentu przy 3000 scen to ok. 1 pkt.
_RICHNESS_SAMPLE = 3000
# Richness: wagi składowych (suma=1.0). thumb to minimum higieny, canonical-bogactwo # Richness: wagi składowych (suma=1.0). thumb to minimum higieny, canonical-bogactwo
# (desc/studio/tag/perf) waży więcej. dur średnio. # (desc/studio/tag/perf) waży więcej. dur średnio.
_RICHNESS_WEIGHTS = { _RICHNESS_WEIGHTS = {
@ -137,43 +143,65 @@ def run_source_stats() -> dict:
win_start = now - timedelta(days=_TELEMETRY_WINDOW_DAYS) win_start = now - timedelta(days=_TELEMETRY_WINDOW_DAYS)
with session_scope() as session: with session_scope() as session:
# 1) Freshness + richness: agregat po DISTINCT (origin, scene) z live sources, # 1a) Freshness + miniatury: dokladnie, po DISTINCT (origin, scene) z live sources.
# join do flag scen (has_tag/perf/desc/studio/dur policzone raz).
rows = session.execute( rows = session.execute(
text( text(
""" """
WITH scene_flags AS ( WITH live AS (
SELECT s.id,
(s.description IS NOT NULL AND length(btrim(s.description))>0) AS has_desc,
(s.studio_id IS NOT NULL) AS has_studio,
(s.duration_sec IS NOT NULL) AS has_dur,
EXISTS(SELECT 1 FROM scene_tags st WHERE st.scene_id=s.id) AS has_tag,
EXISTS(SELECT 1 FROM scene_performers sp WHERE sp.scene_id=s.id) AS has_perf
FROM scenes s
),
live AS (
SELECT DISTINCT ON (origin, scene_id) origin, scene_id, SELECT DISTINCT ON (origin, scene_id) origin, scene_id,
(thumbnail_url IS NOT NULL) AS has_thumb, created_at (thumbnail_url IS NOT NULL) AS has_thumb, created_at
FROM playback_sources FROM playback_sources
WHERE dead_at IS NULL AND origin LIKE 'tube:%' WHERE dead_at IS NULL AND origin LIKE 'tube:%'
) )
SELECT l.origin, SELECT origin,
count(*) AS scenes, count(*) AS scenes,
count(*) FILTER (WHERE l.created_at > :win) AS new_7d, count(*) FILTER (WHERE created_at > :win) AS new_7d,
max(l.created_at) AS newest_at, max(created_at) AS newest_at,
100.0*avg(l.has_thumb::int) AS thumb, 100.0*avg(has_thumb::int) AS thumb
100.0*avg(f.has_tag::int) AS tag, FROM live
100.0*avg(f.has_perf::int) AS perf, GROUP BY origin
100.0*avg(f.has_desc::int) AS descr,
100.0*avg(f.has_studio::int) AS studio,
100.0*avg(f.has_dur::int) AS dur
FROM live l JOIN scene_flags f ON f.id = l.scene_id
GROUP BY l.origin
""" """
), ),
{"win": win_start}, {"win": win_start},
).all() ).all()
# 1b) Reszta richness z probki scen (patrz _RICHNESS_SAMPLE).
flags = {
r.origin: r
for r in session.execute(
text(
"""
WITH live AS (
SELECT DISTINCT origin, scene_id
FROM playback_sources
WHERE dead_at IS NULL AND origin LIKE 'tube:%'
),
sample AS (
SELECT origin, scene_id FROM (
SELECT origin, scene_id, row_number() OVER (
PARTITION BY origin ORDER BY md5(scene_id::text)
) AS rn
FROM live
) ranked
WHERE rn <= :n
)
SELECT sm.origin,
100.0*avg((EXISTS(SELECT 1 FROM scene_tags st
WHERE st.scene_id=s.id))::int) AS tag,
100.0*avg((EXISTS(SELECT 1 FROM scene_performers sp
WHERE sp.scene_id=s.id))::int) AS perf,
100.0*avg((s.description IS NOT NULL
AND length(btrim(s.description))>0)::int) AS descr,
100.0*avg((s.studio_id IS NOT NULL)::int) AS studio,
100.0*avg((s.duration_sec IS NOT NULL)::int) AS dur
FROM sample sm JOIN scenes s ON s.id = sm.scene_id
GROUP BY sm.origin
"""
),
{"n": _RICHNESS_SAMPLE},
).all()
}
# 2) Telemetria health per origin (okno 7d). # 2) Telemetria health per origin (okno 7d).
tele = { tele = {
r.origin: r r.origin: r
@ -198,13 +226,14 @@ def run_source_stats() -> dict:
for r in rows: for r in rows:
origin = r.origin origin = r.origin
sitetag = origin.split(":", 1)[1] if ":" in origin else origin sitetag = origin.split(":", 1)[1] if ":" in origin else origin
f = flags.get(origin)
pcts = { pcts = {
"thumb": float(r.thumb or 0), "thumb": float(r.thumb or 0),
"tag": float(r.tag or 0), "tag": float(f.tag or 0) if f else 0.0,
"perf": float(r.perf or 0), "perf": float(f.perf or 0) if f else 0.0,
"desc": float(r.descr or 0), "desc": float(f.descr or 0) if f else 0.0,
"studio": float(r.studio or 0), "studio": float(f.studio or 0) if f else 0.0,
"dur": float(r.dur or 0), "dur": float(f.dur or 0) if f else 0.0,
} }
freshness = _freshness_score(r.newest_at, int(r.new_7d), int(r.scenes)) freshness = _freshness_score(r.newest_at, int(r.new_7d), int(r.scenes))
richness = _richness_score(pcts) richness = _richness_score(pcts)

View file

@ -0,0 +1,273 @@
"""Dedup w obrebie studia po rdzeniu tytulu.
Kazdy tube zapisuje tytul tej samej sceny po swojemu: "[Milfy] Koda Monroe (Voluptuous
Therapist Fucks Prisoner)", "Milfy 26 09 16 Koda Monroe Voluptuous Therapist Fucks Prisoner",
"Koda Monroe Voluptuous Therapist Fucks Prisoner 4K". Miniatury sa z innych klatek, wiec
phash sie nie pokrywa, a title_duration_dedup wymaga identycznego tytulu. Skutek: ta sama
scena Milfy wisiala w katalogu 8 razy (zgloszenie bb1a357d, 2026-09-18).
Rdzen tytulu = slowa bez nazwy studia, dat, znacznikow jakosci, lacznikow i nazwisk obsady
(suma obsady obu porownywanych scen, bo tube czesto nie ma obsady, a nazwisko ma w tytule).
Pojedyncze liczby ZOSTAJA: to numery czesci ("Whip 2", "Part 1"), ktore pierwsza wersja
reguly sklejala w jedno (suchy przebieg 2026-09-18).
Para jest ta sama scena, gdy to samo studio, rowne rdzenie i nic jej nie wyklucza:
- dlugosci (gdy obie znane) roznia sie o <= 5 s,
- daty premiery (gdy obie znane i zadna nie jest samym rokiem, czyli 1 stycznia) o <= 7 dni,
- bez obu dlugosci rdzen musi miec >= 4 slowa, bo zostaje sam tytul,
- obsady (gdy obie niepuste) maja wspolna osobe.
Grupy skladamy przez sume par, ale scalamy tylko sceny zgodne BEZPOSREDNIO z zostawiana,
zeby lancuch A~B~C nie wciagnal C, ktore z A sie nie zgadza (inna dlugosc przez scene bez
dlugosci w srodku).
Kopie bez studia (tube nie podal wytworni, 3 z 8 kopii Milfy) dopinamy do scen studyjnych
ostrzej: obie dlugosci znane i rowne co do 3 s, rdzen >= 4 slowa. Dwie sceny bez studia nie
maja wspolnej kotwicy, wiec tu sie nie lacza (identyczne tytuly zalatwia title_duration_dedup).
Okno czasowe, bo dupy splywaja z tubow w ciagu dni od premiery; cyklicznie w schedulerze.
"""
from __future__ import annotations
import collections
import logging
import re
import unicodedata
import uuid as _u
from dataclasses import dataclass
from datetime import date, datetime
from sqlalchemy import text
from app.db import session_scope
from app.resolve.scene_merge import merge_scenes
log = logging.getLogger(__name__)
_QUALITY = {"4k", "8k", "uhd", "fhd", "hd", "sd", "2160p", "1440p", "1080p", "720p", "480p", "360p", "full", "mp4", "vr"}
_CONNECT = {"and", "x", "ft", "feat", "featuring", "starring", "with"}
# Data wpisana w tytul przez tube: "26 09 16", "(17/9/26)", "21-08-2026", "2026.09.16".
_DATE_RE = re.compile(r"(?<![0-9])[0-9]{1,4}[ ./_-][0-9]{1,2}[ ./_-][0-9]{1,4}(?![0-9])")
_MAX_DURATION_DIFF = 5
_MAX_RELEASE_DIFF_DAYS = 7
# Kubelek (studio + ostatnie slowo rdzenia) wiekszy od tego to generyczny tytul w studiu-
# zbieraczu ("Only Fans"), gdzie porownanie parami jest drogie i ryzykowne.
_MAX_BUCKET = 400
_JUNK_TAG_MIN_SCENES = 1000
_ORPHAN_MAX_DURATION_DIFF = 3
_ORPHAN_MIN_CORE = 4
@dataclass(frozen=True)
class Cand:
id: str
title: str
studio_id: str
studio_name: str
studio_slug: str
duration_sec: int | None
release_date: date | None
created_at: datetime
perfs: tuple[str, ...]
canon: bool
live: int
def title_words(title: str) -> list[str]:
t = unicodedata.normalize("NFKD", title or "").encode("ascii", "ignore").decode().lower()
t = _DATE_RE.sub(" ", t)
return re.sub(r"[^a-z0-9]+", " ", t).split()
def studio_tokens(name: str, slug: str) -> set[str]:
words = title_words(name)
# "[BrazzersExxtra]" i "Brazzers Exxtra" to to samo studio.
return set(words) | set(title_words(slug)) | {"".join(words)}
def title_core(title: str, st_tokens: set[str], perf_tokens: set[str]) -> tuple[str, ...]:
return tuple(
w for w in title_words(title)
if w not in _QUALITY and w not in _CONNECT and w not in st_tokens and w not in perf_tokens
)
def _year_only(d: date | None) -> bool:
# Czesc tubow podaje sam rok, zapisany jako 1 stycznia. Taka data niczego nie wyklucza.
return d is not None and d.month == 1 and d.day == 1
def same_scene(a: Cand, b: Cand, st_tokens: set[str], junk_names: frozenset[str] = frozenset()) -> bool:
if a.studio_id and b.studio_id and a.studio_id != b.studio_id:
return False
if not a.studio_id and not b.studio_id:
return False
orphan = not (a.studio_id and b.studio_id)
perf_tokens = {
w for n in a.perfs + b.perfs if n.lower() not in junk_names for w in title_words(n)
}
ca = title_core(a.title, st_tokens, perf_tokens)
if len(ca) < (_ORPHAN_MIN_CORE if orphan else 3) or ca != title_core(b.title, st_tokens, perf_tokens):
return False
if a.duration_sec and b.duration_sec:
if abs(a.duration_sec - b.duration_sec) > (_ORPHAN_MAX_DURATION_DIFF if orphan else _MAX_DURATION_DIFF):
return False
elif orphan or len(ca) < 4:
return False
ra, rb = a.release_date, b.release_date
if ra and rb and not _year_only(ra) and not _year_only(rb):
if abs((ra - rb).days) > _MAX_RELEASE_DIFF_DAYS:
return False
if a.perfs and b.perfs and not (set(a.perfs) & set(b.perfs)):
return False
return True
def _keep_rank(c: Cand) -> tuple:
# Zostaje scena z kanonem (TPDB/StashDB), potem z najwieksza liczba zywych zrodel.
return (not c.canon, not c.studio_id, -c.live, not c.perfs, c.created_at)
def plan_merges(cands: list[Cand], junk_names: frozenset[str] = frozenset()) -> list[tuple[Cand, list[Cand]]]:
"""Zwraca [(zostaje, [do_scalenia...])]. Czysta funkcja, bez bazy."""
by_studio: dict[str, list[Cand]] = collections.defaultdict(list)
orphans: list[Cand] = []
for c in cands:
if c.studio_id:
by_studio[c.studio_id].append(c)
elif c.duration_sec:
orphans.append(c)
parent: dict[str, str] = {}
def find(x: str) -> str:
while parent.setdefault(x, x) != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
tokens_by_studio: dict[str, set[str]] = {}
by_id: dict[str, Cand] = {}
for sid, items in by_studio.items():
st = studio_tokens(items[0].studio_name, items[0].studio_slug)
tokens_by_studio[sid] = st
buckets: dict[str, list[Cand]] = collections.defaultdict(list)
for c in items:
core = title_core(c.title, st, set())
if core:
buckets[core[-1]].append(c)
for bucket in buckets.values():
if len(bucket) < 2 or len(bucket) > _MAX_BUCKET:
continue
for i, a in enumerate(bucket):
for b in bucket[i + 1:]:
if same_scene(a, b, st, junk_names):
parent[find(a.id)] = find(b.id)
by_id[a.id], by_id[b.id] = a, b
# Kopie bez studia: kandydaci z tym samym ostatnim slowem rdzenia i dlugoscia w tym samym
# albo sasiednim przedziale 10 s. Scena studyjna trafia pod oba warianty rdzenia, bo nazwa
# studia bywa na koncu tytulu ("Hotwife Hall Pass - TouchMyWife").
index: dict[tuple[str, int], list[Cand]] = collections.defaultdict(list)
for sid, items in by_studio.items():
st = tokens_by_studio[sid]
for c in items:
if not c.duration_sec:
continue
for core in {title_core(c.title, st, set()), title_core(c.title, set(), set())}:
if core:
index[(core[-1], c.duration_sec // 10)].append(c)
for x in orphans:
core = title_core(x.title, set(), set())
if not core:
continue
seen: set[str] = set()
slot = x.duration_sec // 10
for key in ((core[-1], slot - 1), (core[-1], slot), (core[-1], slot + 1)):
for y in index.get(key, ()):
if y.id in seen:
continue
seen.add(y.id)
if same_scene(x, y, tokens_by_studio[y.studio_id], junk_names):
parent[find(x.id)] = find(y.id)
by_id[x.id], by_id[y.id] = x, y
groups: dict[str, list[Cand]] = collections.defaultdict(list)
for cid, c in by_id.items():
groups[find(cid)].append(c)
plan: list[tuple[Cand, list[Cand]]] = []
for members in groups.values():
keep = min(members, key=_keep_rank)
st = tokens_by_studio[keep.studio_id]
drops = [
m for m in members
if m.id != keep.id
and (m.live or keep.live) # dwie niewidoczne w apce sceny zostawiamy bulk_dedup
and same_scene(keep, m, st, junk_names)
]
if drops:
plan.append((keep, drops))
return plan
def _load(days: int) -> tuple[list[Cand], frozenset[str]]:
sql = text("""
SELECT s.id::text, s.title, s.studio_id::text, st.name, st.slug, s.duration_sec,
s.release_date, s.created_at,
COALESCE((SELECT array_agg(p.canonical_name) FROM scene_performers sp
JOIN performers p ON p.id = sp.performer_id
WHERE sp.scene_id = s.id), '{}') AS perfs,
EXISTS (SELECT 1 FROM scene_external_refs r JOIN sources so ON so.id = r.source_id
WHERE r.scene_id = s.id AND so.kind IN ('tpdb', 'stashdb')) AS canon,
(SELECT count(*) FROM playback_sources p
WHERE p.scene_id = s.id AND p.dead_at IS NULL) AS live
FROM scenes s LEFT JOIN studios st ON st.id = s.studio_id
WHERE (s.created_at > now() - make_interval(days => :d) OR s.release_date > current_date - :d)
AND (s.studio_id IS NOT NULL OR s.duration_sec IS NOT NULL)
""")
with session_scope() as s:
rows = s.execute(sql, {"d": days}).all()
# Tube wpisuja czasem kategorie jako obsade ("Big Tits", "Deep Throat"). Takie
# "nazwiska" wycinalyby z rdzenia zwykle slowa tytulu, wiec ich nie uzywamy. Sama
# obecnosc wsrod tagow nie wystarcza: hashtagi z nazwiskami tez sa tagami ("Koda
# Monroe" 51 scen, "Angela White" 239), a kategorie maja ich tysiace ("Ball Licking"
# 11 tys., "Big Tits" 685 tys.). Pomiar 2026-09-18.
junk = frozenset(
n.lower()
for (n,) in s.execute(text("SELECT name FROM tags WHERE scene_count >= :m"), {"m": _JUNK_TAG_MIN_SCENES}).all()
if n
)
cands = [
Cand(
id=r[0], title=r[1] or "", studio_id=r[2] or "", studio_name=r[3] or "", studio_slug=r[4] or "",
duration_sec=r[5], release_date=r[6], created_at=r[7], perfs=tuple(r[8] or ()),
canon=bool(r[9]), live=int(r[10] or 0),
)
for r in rows
]
return cands, junk
def run_studio_title_dedup(*, days: int = 45, commit: bool = True) -> dict[str, int]:
"""Scal dupy w obrebie studia po rdzeniu tytulu. Zwraca {groups, merges, merged, errors}."""
cands, junk = _load(days)
plan = plan_merges(cands, junk)
merges = sum(len(d) for _, d in plan)
merged = errors = 0
if commit:
for keep, drops in plan:
for drop in drops:
try:
with session_scope() as s:
merge_scenes(
s, keep_id=_u.UUID(keep.id), drop_id=_u.UUID(drop.id),
resolved_by="studio_title_dedup",
)
merged += 1
except Exception as e: # pragma: no cover - obronnie
errors += 1
if errors <= 20:
log.warning("studio_title_dedup keep %s drop %s: %s", keep.id[:8], drop.id[:8], str(e)[:120])
result = {"candidates": len(cands), "groups": len(plan), "merges": merges, "merged": merged, "errors": errors}
log.info("studio_title_dedup: %s", result)
return result

View file

@ -216,6 +216,11 @@ def run_forever() -> int:
"thumb_dedup_hours": getattr(settings, "sched_thumb_dedup_hours", 12) or None, "thumb_dedup_hours": getattr(settings, "sched_thumb_dedup_hours", 12) or None,
# Title+duration dedup, missing-merge tube-dupy (reports 28fe8181/32df33b1). # Title+duration dedup, missing-merge tube-dupy (reports 28fe8181/32df33b1).
"title_dedup_hours": getattr(settings, "sched_title_dedup_hours", 12) or None, "title_dedup_hours": getattr(settings, "sched_title_dedup_hours", 12) or None,
# Studio + rdzen tytulu, kopie z tubow o roznym formacie tytulu (report bb1a357d).
"studio_title_dedup_hours": getattr(settings, "sched_studio_title_dedup_hours", 12) or None,
# Martwe zrodla sxyprn/eporner (strona mowi "usuniety"), porcja co 6 h.
"dead_reap_hours": getattr(settings, "sched_dead_reap_hours", 6) or None,
"dead_reap_per_origin": getattr(settings, "dead_reap_per_origin", 1500),
# Blacklista zdegenerowanych phashy, zaślepki dzielone przez setki scen (audyt # Blacklista zdegenerowanych phashy, zaślepki dzielone przez setki scen (audyt
# 2026-07-27). Bez tego zaślepka jest zawsze najbliższym trafieniem w Path 3. # 2026-07-27). Bez tego zaślepka jest zawsze najbliższym trafieniem w Path 3.
"phash_blacklist_hours": getattr(settings, "sched_phash_blacklist_hours", 24) or None, "phash_blacklist_hours": getattr(settings, "sched_phash_blacklist_hours", 24) or None,

View file

@ -32,7 +32,7 @@ import { OnboardingModal } from './src/components/OnboardingModal';
import { WhatsNewModal } from './src/components/WhatsNewModal'; import { WhatsNewModal } from './src/components/WhatsNewModal';
import { ErrorBoundary } from './src/ErrorBoundary'; import { ErrorBoundary } from './src/ErrorBoundary';
import { isAccepted as isAgeGateAccepted } from './src/lib/agegate'; import { isAccepted as isAgeGateAccepted } from './src/lib/agegate';
import { APP_VERSION, NATIVE_RUNTIME, isNewerVersion } from './src/lib/appVersion'; import { APP_VERSION, NATIVE_RUNTIME, isNewerVersion, nativeBuildNumber } from './src/lib/appVersion';
import { DEFAULT_API_KEY, DEFAULT_BACKEND_URL } from './src/lib/backend'; import { DEFAULT_API_KEY, DEFAULT_BACKEND_URL } from './src/lib/backend';
import { getSettings as getLockSettings } from './src/lib/applock'; import { getSettings as getLockSettings } from './src/lib/applock';
import { AppNavigator } from './src/navigation'; import { AppNavigator } from './src/navigation';
@ -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(() => {
@ -257,11 +261,18 @@ export default function App() {
try { try {
const out = await client.getServerVersion(); const out = await client.getServerVersion();
const bundled = APP_VERSION; const bundled = APP_VERSION;
// Porównujemy RUNTIME, nie numer wersji: `bundled` bierze się z app.json, które // Kolejność źródeł: numer builda APK → runtime → numer wersji. `bundled` bierze
// przyjeżdża razem z paczką OTA, więc stary APK po aktualizacji raportuje nową // się z app.json, które przyjeżdża razem z paczką OTA, więc stary APK po
// wersję i sam siebie uznaje za aktualny. Runtime jest z natywnego manifestu. // aktualizacji raportuje nową wersję i sam siebie uznaje za aktualny. Runtime
// Fallback na porównanie wersji, gdy serwer jeszcze nie podaje runtime. // jest z natywnego manifestu, ale zmienia się tylko przy zmianie kanału OTA:
const needsApk = out.runtime_version && NATIVE_RUNTIME // 0.4.0 i 0.4.1 mają ten sam runtime 1.4, więc wydanie naprawcze nie docierało
// do nikogo (crash expo-video z 0.4.0 wracał do Sentry dzień po poprawce).
// Numer builda rozróżnia nawet dwa APK na tym samym kanale.
const nativeBuild = await nativeBuildNumber();
const serverBuild = typeof out.apk_build === 'number' ? out.apk_build : null;
const needsApk = serverBuild && nativeBuild
? serverBuild > nativeBuild
: out.runtime_version && NATIVE_RUNTIME
? isNewerVersion(out.runtime_version, NATIVE_RUNTIME) ? isNewerVersion(out.runtime_version, NATIVE_RUNTIME)
: isNewerVersion(out.version, bundled); : isNewerVersion(out.version, bundled);
if (out.version && needsApk && out.apk_url) { if (out.version && needsApk && out.apk_url) {
@ -269,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 () => {
@ -313,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 },
], ],
); );
@ -443,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

@ -104,8 +104,8 @@ android {
applicationId 'com.goon.mobile' applicationId 'com.goon.mobile'
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 15 versionCode 16
versionName "0.4.1" versionName "0.4.2"
} }
signingConfigs { signingConfigs {
debug { debug {

View file

@ -7,6 +7,16 @@
<uses-permission android:name="android.permission.USE_FINGERPRINT"/> <uses-permission android:name="android.permission.USE_FINGERPRINT"/>
<uses-permission android:name="android.permission.VIBRATE"/> <uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<!-- Android TV (prosba z apki 2026-09-16). Obie cechy jako NIEwymagane: bez tego
telewizor uznaje apke za niezgodna (nie ma ekranu dotykowego), a telefon nie ma
leanbacka. Sam wpis nic nie wymusza, otwiera tylko instalacje na obu rodzajach
sprzetu. Pelnego interfejsu pod pilota to NIE daje, patrz MainActivity. -->
<uses-feature android:name="android.software.leanback" android:required="false"/>
<uses-feature android:name="android.hardware.touchscreen" android:required="false"/>
<!-- Blokada pionu w aktywnosci sama z siebie dokladala WYMAGANIE ekranu pionowego
(aapt: uses-feature android.hardware.screen.portrait), przez co telewizor uznawal
apke za niezgodna. Jawny wpis to odkreca. -->
<uses-feature android:name="android.hardware.screen.portrait" android:required="false"/>
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.VIEW"/> <action android:name="android.intent.action.VIEW"/>
@ -14,7 +24,7 @@
<data android:scheme="https"/> <data android:scheme="https"/>
</intent> </intent>
</queries> </queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:usesCleartextTraffic="false" android:networkSecurityConfig="@xml/network_security_config"> <application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:banner="@drawable/tv_banner" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:usesCleartextTraffic="false" android:networkSecurityConfig="@xml/network_security_config">
<!-- <!--
Expo Updates — ENABLED 2026-05-22 dla public release. Manifest serwowany Expo Updates — ENABLED 2026-05-22 dla public release. Manifest serwowany
przez backend `/expo-updates/manifest` (api.goon-foss.org). Nowe JS-only przez backend `/expo-updates/manifest` (api.goon-foss.org). Nowe JS-only
@ -39,7 +49,7 @@
gdy Chromium probowal podpiac skaner kodow (GOON-3B, 3 crashe od lipca). --> gdy Chromium probowal podpiac skaner kodow (GOON-3B, 3 crashe od lipca). -->
<meta-data android:name="com.google.android.gms.version" android:value="@integer/google_play_services_version"/> <meta-data android:name="com.google.android.gms.version" android:value="@integer/google_play_services_version"/>
<activity android:name="com.reactnative.googlecast.RNGCExpandedControllerActivity"/> <activity android:name="com.reactnative.googlecast.RNGCExpandedControllerActivity"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait"> <activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait" android:resizeableActivity="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
@ -51,5 +61,14 @@
<data android:scheme="com.goon.mobile"/> <data android:scheme="com.goon.mobile"/>
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Android TV. Bez wpisu LEANBACK_LAUNCHER apka nie pojawia sie na ekranie glownym
telewizora, mimo ze instaluje sie bez bledu. Banner (320x180) jest w <application>.
Osobna aktywnosc, bo tu NIE MA blokady pionu, patrz TvMainActivity. -->
<activity android:name=".TvMainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:resizeableActivity="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
</intent-filter>
</activity>
</application> </application>
</manifest> </manifest>

View file

@ -13,7 +13,8 @@ import com.reactnative.googlecast.api.RNGCCastContext
import expo.modules.ReactActivityDelegateWrapper import expo.modules.ReactActivityDelegateWrapper
class MainActivity : ReactActivity() { // open, bo dziedziczy z niej TvMainActivity (to samo, tylko bez blokady pionu).
open class MainActivity : ReactActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
// Set the theme to AppTheme BEFORE onCreate to support // Set the theme to AppTheme BEFORE onCreate to support
// coloring the background, status bar, and navigation bar. // coloring the background, status bar, and navigation bar.

View file

@ -0,0 +1,19 @@
package com.goon.mobile
/**
* Wejscie dla Android TV. Ta sama aktywnosc co na telefonie, rozni sie WYLACZNIE wpisem
* w manifescie: telefon ma blokade pionu, telewizor jej nie ma, bo ekran telewizora jest
* na stale poziomy i pionowa aplikacja wychodzi tam waskim paskiem na srodku.
*
* Dlaczego osobna klasa, a nie jedna aktywnosc:
* - `setRequestedOrientation` w `onCreate` to zmiana konfiguracji, wiec system
* relaunchuje aktywnosc tuz po starcie i na TV zostawal bialy ekran (widok Reacta nie
* wracal; emulator android-34 android-tv, 2026-09-17),
* - orientacja z zasobu (`values-television`) nie przechodzi, bo manifest czyta zasoby
* bez kontekstu konfiguracji (lint ManifestResource),
* - `activity-alias` nie pozwala nadpisac `screenOrientation`.
*
* Telewizorowy launcher pyta o `LEANBACK_LAUNCHER`, wiec na telefonie ta klasa jest
* niewidoczna, a na TV widoczna jest tylko ona.
*/
class TvMainActivity : MainActivity()

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

View file

@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "goon", "name": "goon",
"slug": "goon", "slug": "goon",
"version": "0.4.1", "version": "0.4.2",
"orientation": "portrait", "orientation": "portrait",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"newArchEnabled": false, "newArchEnabled": false,

View file

@ -0,0 +1,31 @@
diff --git a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/jni/JNIDeallocator.kt b/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/jni/JNIDeallocator.kt
--- a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/jni/JNIDeallocator.kt
+++ b/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/jni/JNIDeallocator.kt
@@ -34,7 +34,10 @@ class JNIDeallocator(shouldCreateDestructorThread: Boolean = true) : AutoCloseab
// Referent of PhantomReference were garbage collected so we can remove it from our registry.
// Note that we don't have to call `deallocate` method - it was called [com.facebook.jni.HybridData].
val current = referenceQueue.remove()
- synchronized(this) {
+ // Goon: `this` w `object : Thread` to watek, nie deallocator. Blokada na watku
+ // nic nie chronila i remove szedl rownolegle z iteracja w deallocate(), stad
+ // ConcurrentModificationException przy przeladowaniu JS (Sentry GOON-48).
+ synchronized(this@JNIDeallocator) {
destructorMap.remove(current)
}
} catch (e: InterruptedException) {
@@ -65,10 +68,13 @@ class JNIDeallocator(shouldCreateDestructorThread: Boolean = true) : AutoCloseab
* Deallocates valid references and clears the internal registry.
*/
internal fun deallocate() = synchronized(this) {
- destructorMap.values.forEach {
+ // Goon: kopia przed wolaniem deallocate(), ktore moze wrocic do tej mapy z tego
+ // samego watku (blokada jest reentrant, wiec by nie zatrzymala), GOON-48.
+ val refs = destructorMap.values.toList()
+ destructorMap.clear()
+ refs.forEach {
it.get()?.deallocate()
}
- destructorMap.clear()
destructorThread?.interrupt()
}

View file

@ -1,8 +1,17 @@
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/playbackService/ExpoVideoPlaybackService.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/playbackService/ExpoVideoPlaybackService.kt diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/playbackService/ExpoVideoPlaybackService.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/playbackService/ExpoVideoPlaybackService.kt
index cd798cf..34923b2 100644 index cd798cf..8cb6dfb 100644
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/playbackService/ExpoVideoPlaybackService.kt --- a/node_modules/expo-video/android/src/main/java/expo/modules/video/playbackService/ExpoVideoPlaybackService.kt
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/playbackService/ExpoVideoPlaybackService.kt +++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/playbackService/ExpoVideoPlaybackService.kt
@@ -136,10 +136,14 @@ class ExpoVideoPlaybackService : MediaSessionService() { @@ -26,7 +26,7 @@ class PlaybackServiceBinder(val service: ExpoVideoPlaybackService) : Binder()
@OptIn(UnstableApi::class)
class ExpoVideoPlaybackService : MediaSessionService() {
- private val mediaSessions = mutableMapOf<ExoPlayer, MediaSession>()
+ private val mediaSessions = java.util.concurrent.ConcurrentHashMap<ExoPlayer, MediaSession>()
private val binder = PlaybackServiceBinder(this)
private val commandSeekForward = SessionCommand(SEEK_FORWARD_COMMAND, Bundle.EMPTY)
@@ -136,10 +136,20 @@ class ExpoVideoPlaybackService : MediaSessionService() {
private fun cleanup() { private fun cleanup() {
hideAllNotifications() hideAllNotifications()
@ -10,7 +19,13 @@ index cd798cf..34923b2 100644
+ // Kopia przed iteracja: session.release() wola z powrotem unregisterPlayer, ktory + // Kopia przed iteracja: session.release() wola z powrotem unregisterPlayer, ktory
+ // usuwa wpis z mediaSessions w trakcie forEach -> ConcurrentModificationException + // usuwa wpis z mediaSessions w trakcie forEach -> ConcurrentModificationException
+ // przy zamykaniu apki z listy ostatnich (Goon, Sentry 147164135, 2026-09-15). + // przy zamykaniu apki z listy ostatnich (Goon, Sentry 147164135, 2026-09-15).
+ val sessions = mediaSessions.values.toList() + // Mapa jest ConcurrentHashMap (patrz deklaracja), bo sama kopia tez iteruje mape,
+ // a wpisy znikaja z innego watku przy zwalnianiu playera: na LinkedHashMap `toList()`
+ // wywalalo sie wtedy tak samo (Goon 0.4.1, Sentry GOON-6W, 2026-09-17).
+ // ArrayList(values), nie `toList()`: toList przy rozmiarze 1 robi iterator().next(),
+ // a wpis moze zniknac miedzy size() i next() -> NoSuchElementException (Goon 0.4.2,
+ // Sentry GOON-72). Konstruktor idzie przez toArray(), ktore mapa robi bezpiecznie.
+ val sessions = ArrayList(mediaSessions.values)
+ mediaSessions.clear() + mediaSessions.clear()
+ sessions.forEach { session -> + sessions.forEach { session ->
session.release() session.release()

View file

@ -589,6 +589,8 @@ export class GoonClient {
async getServerVersion(): Promise<{ async getServerVersion(): Promise<{
version: string; version: string;
runtime_version?: string | null; runtime_version?: string | null;
/** versionCode najnowszego APK; rozróżnia wydania na tym samym runtime. */
apk_build?: number | null;
apk_url: string | null; apk_url: string | null;
}> { }> {
// /version nie wymaga API key (publiczny endpoint do upgrade discovery). // /version nie wymaga API key (publiczny endpoint do upgrade discovery).
@ -667,8 +669,12 @@ export class GoonClient {
}); });
} }
async getClipOnboardingOptions(): Promise<ClipOnboardingOptions> { /** Bez `q`: najpopularniejsze tagi i nisze. Z `q`: szukanie po calym slowniku tagow. */
return this.request('/clips/onboarding-options'); async getClipOnboardingOptions(q?: string): Promise<ClipOnboardingOptions> {
const path = q && q.trim()
? `/clips/onboarding-options?q=${encodeURIComponent(q.trim())}`
: '/clips/onboarding-options';
return this.request(path);
} }
async postClipOnboarding(body: { tags: string[]; niches: string[] }): Promise<ClipAffinityOut> { async postClipOnboarding(body: { tags: string[]; niches: string[] }): Promise<ClipAffinityOut> {
@ -684,6 +690,23 @@ export class GoonClient {
await this.request<void>(`/clips/affinity?seen=${seen ? 'true' : 'false'}`, { method: 'DELETE' }); await this.request<void>(`/clips/affinity?seen=${seen ? 'true' : 'false'}`, { method: 'DELETE' });
} }
/** Klipy jednego tworcy (albo tagu/zrodla), najnowsze pierwsze. */
async listClips(params: {
creator?: string;
tag?: string;
source?: string;
limit?: number;
offset?: number;
}): Promise<ClipListOut> {
const q = new URLSearchParams();
if (params.creator) q.set('creator', params.creator);
if (params.tag) q.set('tag', params.tag);
if (params.source) q.set('source', params.source);
q.set('limit', String(params.limit ?? 50));
q.set('offset', String(params.offset ?? 0));
return this.request(`/clips?${q.toString()}`);
}
async listClipFavorites(limit = 50, offset = 0): Promise<ClipListOut> { async listClipFavorites(limit = 50, offset = 0): Promise<ClipListOut> {
return this.request(`/clips/favorites?limit=${limit}&offset=${offset}`); return this.request(`/clips/favorites?limit=${limit}&offset=${offset}`);
} }

View file

@ -35,6 +35,54 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-09-17b',
date: 'September 2026',
items: [
'Version 0.4.2 fixes the crash when closing the app while a video was open. The previous attempt only covered part of it: the list of players could also change from another thread while it was being copied.',
'Android TV: the app now shows up on the TV home screen, runs full screen and can be driven with the remote on the existing screens. It is not a purpose-built TV interface yet: there is no focus highlight, and sites that ask you to tap a human check are painful with a remote.',
],
},
{
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',
items: [
'Edge on a folding phone or a tablet: clips are no longer cropped down to the middle. When the screen and the clip are shaped too differently, you get the whole clip with black bars instead, and the caption and buttons sit on those bars. Wide clips gain from this on normal phones too.',
],
minRuntime: '1.4',
},
{
id: '2026-09-16c',
date: 'September 2026',
items: [
'Edge: tapping the heart no longer restarts the clip or flips the sound on, and the clip no longer jumps back to the start when the next batch loads in the background.',
'Edge: one sound button instead of two, and a new tuning button at the top. Open it any time to add what you want more of, including a search across every tag we have, and the feed uses it from the next deal on.',
],
minRuntime: '1.4',
},
{
id: '2026-09-16b',
date: 'September 2026',
items: [
'Edge: tap the creator name to open their page. You get every clip they have, and tapping one plays it full screen with the same swipe up for the next one.',
],
minRuntime: '1.4',
},
{
id: '2026-09-16a',
date: 'September 2026',
items: [
'The app now notices when a newer build is out even if it shares the silent-update channel with yours, so fixes that need a real install stop waiting for you to find them. If you are on 0.4.0, expect a prompt for 0.4.1: it fixes a crash when closing the app from recents.',
],
},
{ {
id: '2026-09-15d', id: '2026-09-15d',
date: 'September 2026', date: 'September 2026',

View file

@ -36,6 +36,7 @@ import { captureScreen } from 'react-native-view-shot';
import { GoonClient } from '../api'; import { GoonClient } from '../api';
import { theme } from '../theme'; import { theme } from '../theme';
import { nativeBuildNumber } from '../lib/appVersion';
import { BOTTOM_BAR_GAP, BOTTOM_BAR_HEIGHT, barHidden } from '../lib/bottomBar'; import { BOTTOM_BAR_GAP, BOTTOM_BAR_HEIGHT, barHidden } from '../lib/bottomBar';
import { formatLastPlayback } from '../lib/lastPlayback'; import { formatLastPlayback } from '../lib/lastPlayback';
import { getSxyprnDiag } from '../lib/sxyprnResolver'; import { getSxyprnDiag } from '../lib/sxyprnResolver';
@ -220,6 +221,11 @@ export function BugReportFAB({ client, appVersion, navRef }: Props) {
? `${pc?.Model || 'android'} / Android ${pc?.Release || Platform.Version}` ? `${pc?.Model || 'android'} / Android ${pc?.Release || Platform.Version}`
: `${Platform.OS} ${Platform.Version}`; : `${Platform.OS} ${Platform.Version}`;
extraContext.push(`device=${device.slice(0, 48)}`); extraContext.push(`device=${device.slice(0, 48)}`);
// Numer builda ZAINSTALOWANEGO APK. `app_version` idzie z paczki OTA, wiec zgloszenie
// z APK 0.4.0 po aktualizacji podawalo 0.4.1 i nie dalo sie odroznic, kto ma ktore
// wydanie natywne (crash expo-video siedzial tylko w 0.4.0).
const build = await nativeBuildNumber();
if (build) extraContext.push(`build=${build}`);
// Ostatnie odtwarzanie (serwer/host/pozycja/błąd), kluczowe dla "nie gra"/"audio // Ostatnie odtwarzanie (serwer/host/pozycja/błąd), kluczowe dla "nie gra"/"audio
// się rozjeżdża", bo mówi KTÓRE źródło i gdzie, niezależnie od ekranu zgłoszenia. // się rozjeżdża", bo mówi KTÓRE źródło i gdzie, niezależnie od ekranu zgłoszenia.
const play = formatLastPlayback(); const play = formatLastPlayback();

View file

@ -22,7 +22,7 @@ import { Image } from 'expo-image';
import type { VideoPlayer } from 'expo-video'; import type { VideoPlayer } from 'expo-video';
import { VideoView } from 'expo-video'; import { VideoView } from 'expo-video';
import React from 'react'; import React from 'react';
import { Animated, Pressable, StyleSheet, Text, View } from 'react-native'; import { Animated, Pressable, StyleSheet, Text, View, useWindowDimensions } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
@ -40,14 +40,35 @@ interface Props {
onLike: () => void; onLike: () => void;
onNotInterested: () => void; onNotInterested: () => void;
onTagPress?: (tag: string) => void; onTagPress?: (tag: string) => void;
onCreatorPress?: (creator: string) => void;
} }
function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike, onNotInterested, onTagPress }: Props) { function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike, onNotInterested, onTagPress, onCreatorPress }: Props) {
const clip = item.clip; const clip = item.clip;
const [progress, setProgress] = React.useState(0); const [progress, setProgress] = React.useState(0);
const [ready, setReady] = React.useState(false); const [ready, setReady] = React.useState(false);
const heart = React.useRef(new Animated.Value(0)).current; const heart = React.useRef(new Animated.Value(0)).current;
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
// Kadrowac czy dolozyc czarne pasy.
//
// `cover` wypelnia ekran i tnie to, co wystaje. Na zwyklym telefonie pionowy klip traci
// tak ze 20 % szerokosci i nikomu to nie przeszkadza, ale na rozlozonym foldzie ekran
// jest prawie kwadratowy i z pionowego klipu zostaje srodek (zgloszenie Jana
// 2026-09-16). To samo dotyczy klipow poziomych, ktorych na RedGifs jest ok. 19 %.
// Liczymy, ile by sie ucielo: do `MAX_CROP` zostaje `cover`, powyzej `contain`, czyli
// cale wideo i pasy po bokach albo u gory. Podpis i kolumna przyciskow stoja przy
// krawedziach ekranu, wiec przy pasach same wchodza na pasy zamiast na wideo.
// `useWindowDimensions` reaguje na zlozenie i obrot, wiec kadr zmienia sie od razu.
const win = useWindowDimensions();
const fit = React.useMemo<'cover' | 'contain'>(() => {
const MAX_CROP = 1.3;
const clipAspect = clip.width && clip.height ? clip.width / clip.height : 9 / 16;
const screenAspect = win.width / Math.max(win.height, 1);
if (!clipAspect || !screenAspect) return 'cover';
const crop = Math.max(screenAspect / clipAspect, clipAspect / screenAspect);
return crop > MAX_CROP ? 'contain' : 'cover';
}, [clip.width, clip.height, win.width, win.height]);
const lift = React.useMemo( const lift = React.useMemo(
() => () =>
barHidden.interpolate({ barHidden.interpolate({
@ -145,17 +166,22 @@ function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike
const tags = clip.tags.slice(0, 4); const tags = clip.tags.slice(0, 4);
return ( return (
<GestureDetector gesture={gestures}> // Gesty (tap = dzwiek, double-tap = serce, przytrzymanie = 2x, w lewo = „nie
// interesuje") lapie TYLKO warstwa wideo. Wczesniej GestureDetector obejmowal cala
// komorke razem z przyciskami, wiec tapniecie serduszka bylo jednoczesnie tapnieciem
// w ekran i przelaczalo dzwiek (zgloszenie Jana 2026-09-16).
<View style={styles.root}> <View style={styles.root}>
<GestureDetector gesture={gestures}>
<View style={StyleSheet.absoluteFill} collapsable={false}>
{poster ? ( {poster ? (
<Image source={{ uri: poster }} style={StyleSheet.absoluteFill} contentFit="cover" transition={0} /> <Image source={{ uri: poster }} style={StyleSheet.absoluteFill} contentFit={fit} transition={0} />
) : null} ) : null}
{active && player ? ( {active && player ? (
<VideoView <VideoView
player={player} player={player}
style={[StyleSheet.absoluteFill, !ready && { opacity: 0 }]} style={[StyleSheet.absoluteFill, !ready && { opacity: 0 }]}
nativeControls={false} nativeControls={false}
contentFit="cover" contentFit={fit}
allowsPictureInPicture={false} allowsPictureInPicture={false}
/> />
) : null} ) : null}
@ -173,6 +199,8 @@ function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike
> >
<MaterialCommunityIcons name="heart" size={120} color="#fff" /> <MaterialCommunityIcons name="heart" size={120} color="#fff" />
</Animated.View> </Animated.View>
</View>
</GestureDetector>
<Animated.View style={[StyleSheet.absoluteFill, { transform: [{ translateY: lift }] }]} pointerEvents="box-none"> <Animated.View style={[StyleSheet.absoluteFill, { transform: [{ translateY: lift }] }]} pointerEvents="box-none">
{/* Kolumna przyciskow */} {/* Kolumna przyciskow */}
@ -201,15 +229,27 @@ function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike
{/* Podpis: tworca, tytul, tagi */} {/* Podpis: tworca, tytul, tagi */}
<View style={styles.caption} pointerEvents="box-none"> <View style={styles.caption} pointerEvents="box-none">
<View style={styles.creatorRow}> {/* Nazwa tworcy prowadzi do jego strony (zgloszenie 2026-09-15: „cannot tap
username"). Bez tworcy (albo bez obslugi) zostaje sam napis. */}
<Pressable
style={styles.creatorRow}
disabled={!clip.creator || !onCreatorPress}
onPress={() => clip.creator && onCreatorPress?.(clip.creator)}
accessibilityRole={clip.creator && onCreatorPress ? 'link' : undefined}
accessibilityLabel={clip.creator ? `Clips by ${clip.creator}` : undefined}
hitSlop={8}
>
<Text style={styles.creator} numberOfLines={1}> <Text style={styles.creator} numberOfLines={1}>
{clip.creator ? `@${clip.creator}` : clip.source} {clip.creator ? `@${clip.creator}` : clip.source}
</Text> </Text>
{clip.creator_verified ? ( {clip.creator_verified ? (
<MaterialCommunityIcons name="check-decagram" size={15} color={theme.accentSecondary} style={{ marginLeft: 4 }} /> <MaterialCommunityIcons name="check-decagram" size={15} color={theme.accentSecondary} style={{ marginLeft: 4 }} />
) : null} ) : null}
{clip.creator && onCreatorPress ? (
<MaterialCommunityIcons name="chevron-right" size={18} color="#fff" style={{ marginLeft: 2, opacity: 0.8 }} />
) : null}
{item.is_fresh ? <Text style={styles.fresh}>NEW</Text> : null} {item.is_fresh ? <Text style={styles.fresh}>NEW</Text> : null}
</View> </Pressable>
{clip.title ? ( {clip.title ? (
<Text style={styles.title} numberOfLines={2}> <Text style={styles.title} numberOfLines={2}>
{clip.title} {clip.title}
@ -232,7 +272,6 @@ function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike
</View> </View>
</Animated.View> </Animated.View>
</View> </View>
</GestureDetector>
); );
} }

View file

@ -0,0 +1,226 @@
/**
* Pionowy pager klipow z DWOMA playerami (aktywny + nastepny w buforze).
*
* Wydzielone ze SwipeScreen (2026-09-16), bo strona tworcy pokazuje te same klipy w ten
* sam sposob. Komponent odpowiada tylko za odtwarzanie i przewracanie stron; feed,
* telemetria, autoSwipe i dolny pasek zostaly w ekranach, ktore go uzywaja.
*
* Pager (`react-native-pager-view`) jest modulem natywnym z APK od runtime 1.4, wiec
* ladujemy go przez `require` w try/catch: ten sam bundle JS leci OTA takze na starsze
* APK, tam zakladka Edge jest ukryta (`swipeAvailable`).
*/
import { createVideoPlayer, type VideoPlayer } from 'expo-video';
import * as Haptics from 'expo-haptics';
import React from 'react';
import { AppState, StyleSheet, View } from 'react-native';
import type { ClipFeedItem } from '../../types';
import { ClipCell } from './ClipCell';
let PagerViewImpl: React.ComponentType<any> | null = null;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
PagerViewImpl = require('react-native-pager-view').default ?? null;
} catch {
PagerViewImpl = null;
}
export const pagerAvailable: boolean = PagerViewImpl != null;
/** Co dzialo sie na stronie, z ktorej wlasnie zeszlismy (do telemetrii). */
export interface ClipLeaveInfo {
dwellMs: number;
loops: number;
positionSec: number;
}
interface Props {
items: ClipFeedItem[];
index: number;
initialPage?: number;
onPageSelected: (next: number) => void;
/** Zejscie ze strony `prevIndex`; wolane po zmianie `index`, nie przy odmontowaniu. */
onLeave?: (prevIndex: number, info: ClipLeaveInfo) => void;
soundOn: boolean;
onToggleSound: () => void;
isLiked: (clipId: string) => boolean;
onLike: (item: ClipFeedItem, position: number) => void;
onNotInterested: (item: ClipFeedItem, position: number) => void;
onCreatorPress?: (creator: string) => void;
/** Ref pagera dla wywolujacego (autoSwipe robi `setPage`). */
pagerRef?: React.MutableRefObject<any>;
}
/** Adres wideo klipu (HD, a gdy go nie ma, SD). Klip bez zadnego z nich sie nie odtworzy. */
export function clipSource(item: ClipFeedItem): string | null {
return item.clip.hd_url || item.clip.sd_url || null;
}
export function ClipPager({
items,
index,
initialPage = 0,
onPageSelected,
onLeave,
soundOn,
onToggleSound,
isLiked,
onLike,
onNotInterested,
onCreatorPress,
pagerRef,
}: Props) {
const localRef = React.useRef<any>(null);
const ref = pagerRef ?? localRef;
const players = React.useRef<VideoPlayer[]>([]);
const loopsRef = React.useRef(0);
const activatedAtRef = React.useRef(0);
const [activePlayer, setActivePlayer] = React.useState<VideoPlayer | null>(null);
React.useEffect(() => {
// Tworzymy tylko na pierwszym planie (ExoPlayer w tle rzuca; wzor z PlayerScreen).
if (AppState.currentState !== 'active') return;
players.current = [createVideoPlayer(null), createVideoPlayer(null)];
for (const p of players.current) {
p.loop = true;
p.timeUpdateEventInterval = 0.25;
}
return () => {
for (const p of players.current) {
try {
p.release();
} catch {
// juz zwolniony
}
}
players.current = [];
};
}, []);
const loadedSrc = React.useRef<Map<VideoPlayer, string | null>>(new Map());
const ensureSource = React.useCallback((p: VideoPlayer, src: string | null) => {
if (loadedSrc.current.get(p) === src) return;
loadedSrc.current.set(p, src);
try {
p.replace(src ? { uri: src } : null);
} catch {
// brak zrodla / zwolniony player
}
}, []);
// Aktywacja strony `index`: player i%2 gra, (i+1)%2 laduje nastepny.
//
// Zaleznosci to NUMER strony i adresy wideo, nie cala tablica `items` ani `soundOn`
// (2026-09-16). Kazda zmiana tamtych rzeczy przewijala klip na poczatek: dociagniecie
// kolejnej strony feedu podmienialo tablice w srodku ogladania („losowo miga i laduje
// sie od nowa"), a wlaczenie dzwieku restartowalo wideo. Wyciszenie ma wlasny efekt
// nizej, `items` czytamy przez ref.
const prevIndexRef = React.useRef<number | null>(null);
const leaveRef = React.useRef(onLeave);
leaveRef.current = onLeave;
const itemsRef = React.useRef(items);
itemsRef.current = items;
const soundRef = React.useRef(soundOn);
soundRef.current = soundOn;
const activeSrc = items[index] ? clipSource(items[index]) : null;
const nextSrc = items[index + 1] ? clipSource(items[index + 1]) : null;
React.useEffect(() => {
const ps = players.current;
const items = itemsRef.current;
if (!ps.length || !items.length) return;
const item = items[index];
if (!item) return;
const active = ps[index % 2];
const standby = ps[(index + 1) % 2];
const prev = prevIndexRef.current;
if (prev !== null && prev !== index && items[prev]) {
const prevPlayer = ps[prev % 2];
let pos = 0;
try {
pos = prevPlayer.currentTime || 0;
} catch {
pos = 0;
}
leaveRef.current?.(prev, {
dwellMs: Date.now() - activatedAtRef.current,
loops: loopsRef.current,
positionSec: pos,
});
try {
prevPlayer.pause();
} catch {
// ignore
}
}
prevIndexRef.current = index;
activatedAtRef.current = Date.now();
loopsRef.current = 0;
ensureSource(active, activeSrc);
active.muted = !soundRef.current;
active.playbackRate = 1.0;
try {
active.currentTime = 0;
active.play();
} catch {
// ignore
}
setActivePlayer(active);
const sub = active.addListener('playToEnd', () => {
loopsRef.current += 1;
});
if (nextSrc) {
ensureSource(standby, nextSrc);
try {
standby.pause();
} catch {
// ignore
}
}
Haptics.selectionAsync().catch(() => {});
return () => sub.remove();
}, [index, activeSrc, nextSrc, ensureSource]);
// Zmiana dzwieku bez zmiany strony.
React.useEffect(() => {
if (activePlayer) activePlayer.muted = !soundOn;
}, [soundOn, activePlayer]);
if (!PagerViewImpl) return null;
const PagerView = PagerViewImpl;
return (
<PagerView
ref={ref}
style={styles.pager}
orientation="vertical"
initialPage={initialPage}
offscreenPageLimit={1}
overdrag
onPageSelected={(e: { nativeEvent: { position: number } }) => onPageSelected(e.nativeEvent.position)}
>
{items.map((it, i) => (
<View key={it.clip.id} style={styles.page} collapsable={false}>
<ClipCell
item={it}
active={i === index}
player={i === index ? activePlayer : null}
muted={!soundOn}
liked={isLiked(it.clip.id) || Boolean(it.clip.is_favorite)}
onToggleMute={onToggleSound}
onLike={() => onLike(it, i)}
onNotInterested={() => onNotInterested(it, i)}
onCreatorPress={onCreatorPress}
/>
</View>
))}
</PagerView>
);
}
const styles = StyleSheet.create({
pager: { flex: 1 },
page: { flex: 1 },
});

View file

@ -18,7 +18,7 @@ import * as Updates from 'expo-updates';
* też nie idzie do góry, więc consistency jest zachowana. * też nie idzie do góry, więc consistency jest zachowana.
*/ */
export const APP_VERSION: string = export const APP_VERSION: string =
(Constants.expoConfig?.version as string | undefined) || '0.4.0'; (Constants.expoConfig?.version as string | undefined) || '0.4.2';
/** /**
* Runtime zapisany w APK (`EXPO_RUNTIME_VERSION` w AndroidManifest). JEDYNY numer, po * Runtime zapisany w APK (`EXPO_RUNTIME_VERSION` w AndroidManifest). JEDYNY numer, po
@ -39,6 +39,34 @@ export const NATIVE_RUNTIME: string | null = (() => {
})(); })();
/**
* `versionCode` ZAINSTALOWANEGO APK (0.4.0 = 14, 0.4.1 = 15), albo null.
*
* Po co, skoro jest runtime: runtime zmienia się tylko przy zmianie kanału OTA, a wydanie
* naprawcze zostaje na tym samym kanale. 0.4.0 i 0.4.1 mają oba runtime 1.4, więc nikt
* z 0.4.0 nie dostał monitu o 0.4.1, mimo że tamto APK naprawia crash przy zamykaniu
* aplikacji z ostatnich (Sentry 147164135). Numer builda rozróżnia je bez podbijania
* runtime i bez utrzymywania kolejnego kanału OTA.
*
* Skąd: natywny moduł Sentry, który i tak jest w aplikacji i czyta PackageInfo (to stąd
* bierze się `app_build` w zdarzeniach). expo-constants w SDK 52 oddaje puste
* `platform.android`, a expo-application wymagałby nowego APK, czyli dokładnie tego,
* czego tu brakuje. Wejście prywatne, więc całość w try/catch: brak numeru = stare
* zachowanie (porównanie runtime).
*/
export async function nativeBuildNumber(): Promise<number | null> {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { NATIVE } = require('@sentry/react-native/dist/js/wrapper');
const ctx: any = await NATIVE.fetchNativeDeviceContexts();
const raw = ctx?.contexts?.app?.app_build ?? ctx?.app?.app_build;
const n = parseInt(String(raw ?? ''), 10);
return Number.isFinite(n) && n > 0 ? n : null;
} catch {
return null;
}
}
/** „0.3.0" → [0,3,0]. Człony nieliczbowe (np. „0.3.0-rc1") lecą na 0. */ /** „0.3.0" → [0,3,0]. Człony nieliczbowe (np. „0.3.0-rc1") lecą na 0. */
function versionParts(v: string): number[] { function versionParts(v: string): number[] {
return String(v || '').split('.').map((x) => parseInt(x, 10) || 0); return String(v || '').split('.').map((x) => parseInt(x, 10) || 0);

View file

@ -15,6 +15,7 @@ import { View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { AppLockSettingsScreen } from './screens/AppLockSettingsScreen'; import { AppLockSettingsScreen } from './screens/AppLockSettingsScreen';
import { BlacklistScreen } from './screens/BlacklistScreen'; import { BlacklistScreen } from './screens/BlacklistScreen';
import { ClipCreatorScreen } from './screens/ClipCreatorScreen';
import { DiagnosticBrowserScreen } from './screens/DiagnosticBrowserScreen'; import { DiagnosticBrowserScreen } from './screens/DiagnosticBrowserScreen';
import { DonateScreen } from './screens/DonateScreen'; import { DonateScreen } from './screens/DonateScreen';
import { FavoritesScreen } from './screens/FavoritesScreen'; import { FavoritesScreen } from './screens/FavoritesScreen';
@ -71,6 +72,8 @@ export type RootStackParamList = {
// slownictwo GLOWNEGO feedu, wiec z Anime kazdy tag prowadzil donikad. // slownictwo GLOWNEGO feedu, wiec z Anime kazdy tag prowadzil donikad.
Tags: { vertical?: string } | undefined; Tags: { vertical?: string } | undefined;
TagScenes: { slug: string; name: string; vertical?: string | null }; TagScenes: { slug: string; name: string; vertical?: string | null };
// Strona tworcy klipow (Edge): siatka jego klipow + pelny ekran z pionowym pagerem.
ClipCreator: { creator: string };
AppLockSettings: undefined; AppLockSettings: undefined;
Blacklist: undefined; Blacklist: undefined;
Donate: undefined; Donate: undefined;
@ -280,6 +283,7 @@ export function AppNavigator({ onLogout, client, appVersion }: AppNavigatorProps
component={DonateScreen} component={DonateScreen}
options={{ title: 'Support project' }} options={{ title: 'Support project' }}
/> />
<Stack.Screen name="ClipCreator" component={ClipCreatorScreen} options={{ title: '' }} />
<Stack.Screen <Stack.Screen
name="DiagnosticBrowser" name="DiagnosticBrowser"
component={DiagnosticBrowserScreen} component={DiagnosticBrowserScreen}

View file

@ -0,0 +1,230 @@
/**
* Strona tworcy klipow: siatka jego klipow, tapniecie otwiera je na pelnym ekranie
* i dalej przewija sie w pionie, tak jak w Edge.
*
* Skad sie wziela: zgloszenie z apki 2026-09-15 („cannot tap username as it's hidden
* behind icon tray"). Pasek zaslanial podpis, ale pod nazwa tworcy i tak nic nie bylo.
*
* Dane: `GET /clips?creator=<nazwa>` (najnowsze pierwsze, strony po 60). Odtwarzanie
* i dwa playery to ten sam `ClipPager` co w Edge.
*
* Telemetria: wysylamy TYLKO like/unlike i „nie interesuje". Ogladania stad NIE licza sie
* do feedu: user wszedl na konkretnego tworce, wiec potraktowanie tego jak zwyklego
* ogladania zabetonowaloby feed na nim.
*/
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useInfiniteQuery } from '@tanstack/react-query';
import { Image } from 'expo-image';
import React from 'react';
import { ActivityIndicator, FlatList, Modal, Pressable, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useClient } from '../ClientContext';
import { ClipPager, clipSource } from '../components/swipe/ClipPager';
import { newSessionId } from '../lib/swipeTelemetry';
import type { RootStackParamList } from '../navigation';
import { fonts, theme } from '../theme';
import type { ClipFeedItem, ClipOut } from '../types';
const PAGE = 60;
const COLS = 3;
function asFeedItem(clip: ClipOut): ClipFeedItem {
return { clip, slot: 'creator', is_fresh: false };
}
function dur(sec: number | null): string {
if (!sec || sec <= 0) return '';
const s = Math.round(sec);
return s >= 60 ? `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}` : `0:${String(s).padStart(2, '0')}`;
}
export function ClipCreatorScreen() {
const client = useClient();
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'ClipCreator'>>();
const route = useRoute<RouteProp<RootStackParamList, 'ClipCreator'>>();
const { creator } = route.params;
const insets = useSafeAreaInsets();
const [playing, setPlaying] = React.useState<number | null>(null);
const [soundOn, setSoundOn] = React.useState(false);
const [liked, setLiked] = React.useState<Set<string>>(new Set());
const sessionId = React.useRef(newSessionId());
const q = useInfiniteQuery({
queryKey: ['clips-by-creator', creator],
queryFn: ({ pageParam }) => client.listClips({ creator, limit: PAGE, offset: pageParam as number }),
initialPageParam: 0,
getNextPageParam: (last, all) => (last.has_more ? all.length * PAGE : undefined),
});
const items = React.useMemo<ClipFeedItem[]>(() => {
const out: ClipFeedItem[] = [];
for (const page of q.data?.pages ?? []) {
for (const clip of page.items) {
if (clipSource(asFeedItem(clip))) out.push(asFeedItem(clip));
}
}
return out;
}, [q.data]);
const total = q.data?.pages[0]?.total ?? null;
const verified = items.some((it) => it.clip.creator_verified);
React.useLayoutEffect(() => {
navigation.setOptions({ title: `@${creator}` });
}, [navigation, creator]);
const sendEvent = React.useCallback(
(clipId: string, kind: 'like' | 'unlike' | 'not_interested', reason?: string) => {
client
.postClipEvents(sessionId.current, [{ clip_id: clipId, kind, reason, slot: 'creator' }])
.catch(() => {});
},
[client],
);
const like = React.useCallback(
(item: ClipFeedItem) => {
const was = liked.has(item.clip.id) || Boolean(item.clip.is_favorite);
setLiked((s) => {
const n = new Set(s);
if (was) n.delete(item.clip.id);
else n.add(item.clip.id);
return n;
});
sendEvent(item.clip.id, was ? 'unlike' : 'like');
},
[liked, sendEvent],
);
return (
<View style={styles.root}>
<FlatList
data={items}
keyExtractor={(it) => it.clip.id}
numColumns={COLS}
removeClippedSubviews={false}
columnWrapperStyle={styles.row}
contentContainerStyle={{ padding: 8, paddingBottom: 24 }}
ListHeaderComponent={
<View style={styles.header}>
<Text style={styles.count}>
{total != null ? `${total} ${total === 1 ? 'clip' : 'clips'}` : `${items.length} clips`}
</Text>
{verified ? (
<View style={styles.verified}>
<MaterialCommunityIcons name="check-decagram" size={14} color={theme.accentSecondary} />
<Text style={styles.verifiedText}>verified</Text>
</View>
) : null}
</View>
}
renderItem={({ item, index }) => (
<Pressable style={styles.tile} onPress={() => setPlaying(index)}>
{item.clip.poster_url || item.clip.thumb_url ? (
<Image
source={{ uri: (item.clip.poster_url || item.clip.thumb_url) as string }}
style={StyleSheet.absoluteFill}
contentFit="cover"
transition={100}
/>
) : null}
<View style={styles.tileFoot}>
<Text style={styles.tileDur}>{dur(item.clip.duration_sec)}</Text>
{liked.has(item.clip.id) || item.clip.is_favorite ? (
<MaterialCommunityIcons name="heart" size={13} color={theme.accentText} />
) : null}
</View>
</Pressable>
)}
onEndReachedThreshold={0.4}
onEndReached={() => {
if (q.hasNextPage && !q.isFetchingNextPage) void q.fetchNextPage();
}}
ListEmptyComponent={
q.isLoading ? (
<ActivityIndicator color={theme.accentText} style={{ marginTop: 32 }} />
) : (
<Text style={styles.empty}>No clips from this creator right now.</Text>
)
}
ListFooterComponent={
q.isFetchingNextPage ? <ActivityIndicator color={theme.accentText} style={{ margin: 16 }} /> : null
}
/>
<Modal visible={playing !== null} animationType="slide" onRequestClose={() => setPlaying(null)}>
<View style={styles.player}>
{playing !== null ? (
<ClipPager
items={items}
index={playing}
initialPage={playing}
onPageSelected={setPlaying}
soundOn={soundOn}
onToggleSound={() => setSoundOn((v) => !v)}
isLiked={(id) => liked.has(id)}
onLike={like}
onNotInterested={(it) => {
sendEvent(it.clip.id, 'not_interested', `creator:${creator}`);
setPlaying(null);
}}
/>
) : null}
{/* Modal rysuje sie od samej gory ekranu, wiec bez insetu przycisk laduje pod
paskiem stanu i tapniecie idzie do systemu (sprawdzone na emulatorze). */}
<Pressable
style={[styles.close, { top: insets.top + 8 }]}
onPress={() => setPlaying(null)}
accessibilityLabel="Close"
hitSlop={8}
>
<MaterialCommunityIcons name="chevron-down" size={26} color="#fff" />
</Pressable>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: theme.bg },
header: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 4, paddingBottom: 10 },
count: { color: theme.muted, fontFamily: fonts.body, fontSize: 13 },
verified: { flexDirection: 'row', alignItems: 'center', gap: 4 },
verifiedText: { color: theme.accentSecondary, fontFamily: fonts.medium, fontSize: 12 },
row: { gap: 6, marginBottom: 6 },
tile: {
// maxWidth, bo ostatni rzad bywa niepelny: bez tego jeden klip tworcy rozciagal
// sie na cala szerokosc ekranu (sprawdzone na emulatorze).
flex: 1,
maxWidth: `${100 / COLS}%`,
aspectRatio: 0.62,
borderRadius: 10,
overflow: 'hidden',
backgroundColor: theme.card,
justifyContent: 'flex-end',
},
tileFoot: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 6,
paddingVertical: 4,
backgroundColor: 'rgba(0,0,0,0.45)',
},
tileDur: { color: '#fff', fontFamily: fonts.mono, fontSize: 11 },
empty: { color: theme.muted, textAlign: 'center', marginTop: 40, paddingHorizontal: 24, lineHeight: 20 },
player: { flex: 1, backgroundColor: '#000' },
close: {
position: 'absolute',
left: 10,
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.35)',
},
});

View file

@ -106,6 +106,10 @@ function useMarkSourceBroken(params: RouteParams) {
// 403 NIE liczy się (auth/IP-bound → fallback proxy/WebView może uratować). // 403 NIE liczy się (auth/IP-bound → fallback proxy/WebView może uratować).
function isGoneError(msg: string | undefined | null): boolean { function isGoneError(msg: string | undefined | null): boolean {
if (!msg) return false; if (!msg) return false;
// Blad certyfikatu to nie usuniety film. "Trust anchor for certification path not found"
// lapal sie na "not found", wiec 93 odtworzenia eporner w tydzien (CDN z GlobalSign R6,
// ktorego starsze Androidy nie znaja) szly jako "gone": zly komunikat i zla telemetria.
if (/SSLHandshake|CertPathValidator|Trust anchor|certificate/i.test(msg)) return false;
return /\b(404|410)\b|response code:\s*4(0[14])|not found|gone/i.test(msg); return /\b(404|410)\b|response code:\s*4(0[14])|not found|gone/i.test(msg);
} }

View file

@ -17,14 +17,16 @@
* serwer uczy sie wag i uklada 80 % kolejnych stron. * serwer uczy sie wag i uklada 80 % kolejnych stron.
*/ */
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import { createVideoPlayer, type VideoPlayer } from 'expo-video';
import React from 'react'; import React from 'react';
import { ActivityIndicator, AppState, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { ActivityIndicator, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import { useClient } from '../ClientContext'; import { useClient } from '../ClientContext';
import { ClipCell } from '../components/swipe/ClipCell'; import { ClipPager, clipSource, pagerAvailable, type ClipLeaveInfo } from '../components/swipe/ClipPager';
import type { RootStackParamList } from '../navigation';
import { NATIVE_RUNTIME, isNewerVersion } from '../lib/appVersion'; import { NATIVE_RUNTIME, isNewerVersion } from '../lib/appVersion';
import { setBarHidden } from '../lib/bottomBar'; import { setBarHidden } from '../lib/bottomBar';
import { usePersistedState } from '../lib/persistedState'; import { usePersistedState } from '../lib/persistedState';
@ -32,19 +34,9 @@ import { SwipeTelemetry, leaveKind, newSessionId } from '../lib/swipeTelemetry';
import { fonts, theme } from '../theme'; import { fonts, theme } from '../theme';
import type { ClipFeedItem, ClipOnboardingOptions } from '../types'; import type { ClipFeedItem, ClipOnboardingOptions } from '../types';
// Pager jest modulem natywnym: jest w APK od runtime 1.4. Wczesniejsze APK dostaja ten
// bundle przez OTA, wiec import musi byc miekki.
let PagerViewImpl: React.ComponentType<any> | null = null;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
PagerViewImpl = require('react-native-pager-view').default ?? null;
} catch {
PagerViewImpl = null;
}
export const SWIPE_MIN_RUNTIME = '1.4'; export const SWIPE_MIN_RUNTIME = '1.4';
export const swipeAvailable: boolean = export const swipeAvailable: boolean =
PagerViewImpl != null && !isNewerVersion(SWIPE_MIN_RUNTIME, NATIVE_RUNTIME || '0'); pagerAvailable && !isNewerVersion(SWIPE_MIN_RUNTIME, NATIVE_RUNTIME || '0');
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
const PREFETCH_AT = 4; const PREFETCH_AT = 4;
@ -57,10 +49,6 @@ interface SwipePrefs {
} }
const DEFAULT_PREFS: SwipePrefs = { soundOn: false, autoSwipeSec: 0, onboarded: false }; const DEFAULT_PREFS: SwipePrefs = { soundOn: false, autoSwipeSec: 0, onboarded: false };
function sourceOf(item: ClipFeedItem): string | null {
return item.clip.hd_url || item.clip.sd_url || null;
}
export function SwipeScreen() { export function SwipeScreen() {
const client = useClient(); const client = useClient();
const [prefs, setPrefs, prefsReady] = usePersistedState<SwipePrefs>('swipe_prefs', DEFAULT_PREFS); const [prefs, setPrefs, prefsReady] = usePersistedState<SwipePrefs>('swipe_prefs', DEFAULT_PREFS);
@ -68,7 +56,9 @@ export function SwipeScreen() {
const [index, setIndex] = React.useState(0); const [index, setIndex] = React.useState(0);
const [liked, setLiked] = React.useState<Set<string>>(new Set()); const [liked, setLiked] = React.useState<Set<string>>(new Set());
const [sheetItem, setSheetItem] = React.useState<ClipFeedItem | null>(null); const [sheetItem, setSheetItem] = React.useState<ClipFeedItem | null>(null);
const [tuneOpen, setTuneOpen] = React.useState(false);
const pagerRef = React.useRef<any>(null); const pagerRef = React.useRef<any>(null);
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
// ---- dane ------------------------------------------------------------------------ // ---- dane ------------------------------------------------------------------------
const feed = useInfiniteQuery({ const feed = useInfiniteQuery({
@ -84,7 +74,7 @@ export function SwipeScreen() {
const out: ClipFeedItem[] = []; const out: ClipFeedItem[] = [];
for (const page of feed.data?.pages ?? []) { for (const page of feed.data?.pages ?? []) {
for (const it of page.items) { for (const it of page.items) {
if (!seen.has(it.clip.id) && sourceOf(it)) { if (!seen.has(it.clip.id) && clipSource(it)) {
seen.add(it.clip.id); seen.add(it.clip.id);
out.push(it); out.push(it);
} }
@ -106,8 +96,7 @@ export function SwipeScreen() {
// o jeden klip. Po wyjsciu z Edge pasek musi byc widoczny. // o jeden klip. Po wyjsciu z Edge pasek musi byc widoczny.
React.useEffect(() => () => setBarHidden(false), []); React.useEffect(() => () => setBarHidden(false), []);
const onPageSelected = React.useCallback( const onPageSelected = React.useCallback(
(e: { nativeEvent: { position: number } }) => { (next: number) => {
const next = e.nativeEvent.position;
if (next === 0 || next < index) setBarHidden(false); if (next === 0 || next < index) setBarHidden(false);
else if (next > index) setBarHidden(true); else if (next > index) setBarHidden(true);
setIndex(next); setIndex(next);
@ -124,111 +113,21 @@ export function SwipeScreen() {
return () => t.stop(); return () => t.stop();
}, []); }, []);
// ---- dwa playery ----------------------------------------------------------------- // ---- zejscie ze strony: jedno zdarzenie z dwell ------------------------------------
const players = React.useRef<VideoPlayer[]>([]); const onLeave = React.useCallback(
const loopsRef = React.useRef(0); (prev: number, info: ClipLeaveInfo) => {
const activatedAtRef = React.useRef(0); const left = items[prev];
const [activePlayer, setActivePlayer] = React.useState<VideoPlayer | null>(null); if (!left) return;
React.useEffect(() => {
// Tworzymy tylko na pierwszym planie (ExoPlayer w tle rzuca; wzor z PlayerScreen).
if (AppState.currentState !== 'active') return;
players.current = [createVideoPlayer(null), createVideoPlayer(null)];
for (const p of players.current) {
p.loop = true;
p.timeUpdateEventInterval = 0.25;
}
return () => {
for (const p of players.current) {
try {
p.release();
} catch {
// juz zwolniony
}
}
players.current = [];
};
}, []);
const loadedSrc = React.useRef<Map<VideoPlayer, string | null>>(new Map());
const ensureSource = React.useCallback((p: VideoPlayer, src: string | null) => {
if (loadedSrc.current.get(p) === src) return;
loadedSrc.current.set(p, src);
try {
p.replace(src ? { uri: src } : null);
} catch {
// brak zrodla / zwolniony player
}
}, []);
// Aktywacja strony `index`: player i%2 gra, (i+1)%2 laduje nastepny.
const prevIndexRef = React.useRef<number | null>(null);
React.useEffect(() => {
const ps = players.current;
if (!ps.length || !items.length) return;
const item = items[index];
if (!item) return;
const active = ps[index % 2];
const standby = ps[(index + 1) % 2];
// Zamkniecie poprzedniej strony: jedno zdarzenie z dwell.
const prev = prevIndexRef.current;
if (prev !== null && prev !== index && items[prev]) {
const prevPlayer = ps[prev % 2];
const dwell = Date.now() - activatedAtRef.current;
let pos = 0;
try {
pos = prevPlayer.currentTime || 0;
} catch {
pos = 0;
}
telemetry.current?.push({ telemetry.current?.push({
clip_id: items[prev].clip.id, clip_id: left.clip.id,
kind: leaveKind(dwell, loopsRef.current, pos, items[prev].clip.duration_sec), kind: leaveKind(info.dwellMs, info.loops, info.positionSec, left.clip.duration_sec),
dwell_ms: dwell, dwell_ms: info.dwellMs,
slot: items[prev].slot, slot: left.slot,
position: prev, position: prev,
}); });
try { },
prevPlayer.pause(); [items],
} catch { );
// ignore
}
}
prevIndexRef.current = index;
activatedAtRef.current = Date.now();
loopsRef.current = 0;
ensureSource(active, sourceOf(item));
active.muted = !prefs.soundOn;
active.playbackRate = 1.0;
try {
active.currentTime = 0;
active.play();
} catch {
// ignore
}
setActivePlayer(active);
const sub = active.addListener('playToEnd', () => {
loopsRef.current += 1;
});
const next = items[index + 1];
if (next) {
ensureSource(standby, sourceOf(next));
try {
standby.pause();
} catch {
// ignore
}
}
Haptics.selectionAsync().catch(() => {});
return () => sub.remove();
}, [index, items, ensureSource, prefs.soundOn]);
// Zmiana dzwieku bez zmiany strony.
React.useEffect(() => {
if (activePlayer) activePlayer.muted = !prefs.soundOn;
}, [prefs.soundOn, activePlayer]);
// AutoSwipe: po N sekundach na stronie przechodzimy dalej. // AutoSwipe: po N sekundach na stronie przechodzimy dalej.
React.useEffect(() => { React.useEffect(() => {
@ -276,7 +175,6 @@ export function SwipeScreen() {
telemetry.current?.stop(); telemetry.current?.stop();
telemetry.current = new SwipeTelemetry(client, newSessionId()); telemetry.current = new SwipeTelemetry(client, newSessionId());
telemetry.current.start(); telemetry.current.start();
prevIndexRef.current = null;
setBarHidden(false); setBarHidden(false);
setIndex(0); setIndex(0);
setSeedKey((k) => k + 1); setSeedKey((k) => k + 1);
@ -293,9 +191,23 @@ export function SwipeScreen() {
const options = useQuery({ const options = useQuery({
queryKey: ['clip-onboarding-options'], queryKey: ['clip-onboarding-options'],
queryFn: () => client.getClipOnboardingOptions(), queryFn: () => client.getClipOnboardingOptions(),
enabled: prefsReady && !prefs.onboarded, enabled: prefsReady && (!prefs.onboarded || tuneOpen),
staleTime: 10 * 60 * 1000, staleTime: 10 * 60 * 1000,
}); });
// Dostrojenie juz w trakcie: te same wagi co przy pierwszym wejsciu, potem nowe
// rozdanie, zeby zmiana byla widoczna od razu, a nie po wyczerpaniu biezacych stron.
const tuneFeed = React.useCallback(
async (tags: string[], niches: string[]) => {
try {
await client.postClipOnboarding({ tags, niches });
} catch {
// brak sieci: wagi dojda przy kolejnej probie, feed i tak uczy sie z ogladania
}
newDeal();
},
[client, newDeal],
);
const finishOnboarding = React.useCallback( const finishOnboarding = React.useCallback(
async (tags: string[], niches: string[]) => { async (tags: string[], niches: string[]) => {
if (tags.length || niches.length) { if (tags.length || niches.length) {
@ -312,7 +224,7 @@ export function SwipeScreen() {
); );
// ---- render -------------------------------------------------------------------------- // ---- render --------------------------------------------------------------------------
if (!swipeAvailable || !PagerViewImpl) { if (!swipeAvailable) {
return ( return (
<View style={styles.center}> <View style={styles.center}>
<MaterialCommunityIcons name="gesture-swipe-vertical" size={40} color={theme.accentText} /> <MaterialCommunityIcons name="gesture-swipe-vertical" size={40} color={theme.accentText} />
@ -321,36 +233,22 @@ export function SwipeScreen() {
</View> </View>
); );
} }
const PagerView = PagerViewImpl;
const current = items[index];
return ( return (
<View style={styles.root}> <View style={styles.root}>
{items.length ? ( {items.length ? (
<PagerView <ClipPager
ref={pagerRef} items={items}
style={styles.pager} index={index}
orientation="vertical" pagerRef={pagerRef}
initialPage={0}
offscreenPageLimit={1}
overdrag
onPageSelected={onPageSelected} onPageSelected={onPageSelected}
> onLeave={onLeave}
{items.map((it, i) => ( soundOn={prefs.soundOn}
<View key={it.clip.id} style={styles.page} collapsable={false}> onToggleSound={toggleMute}
<ClipCell isLiked={(id) => liked.has(id)}
item={it} onLike={likeItem}
active={i === index} onNotInterested={(it) => setSheetItem(it)}
player={i === index ? activePlayer : null} onCreatorPress={(creator) => navigation.navigate('ClipCreator', { creator })}
muted={!prefs.soundOn}
liked={liked.has(it.clip.id) || Boolean(it.clip.is_favorite)}
onToggleMute={toggleMute}
onLike={() => likeItem(it, i)}
onNotInterested={() => setSheetItem(it)}
/> />
</View>
))}
</PagerView>
) : feed.isLoading ? ( ) : feed.isLoading ? (
<View style={styles.center}> <View style={styles.center}>
<ActivityIndicator color={theme.accentText} /> <ActivityIndicator color={theme.accentText} />
@ -375,8 +273,11 @@ export function SwipeScreen() {
<MaterialCommunityIcons name={prefs.autoSwipeSec ? 'play-speed' : 'pause-circle-outline'} size={22} color="#fff" /> <MaterialCommunityIcons name={prefs.autoSwipeSec ? 'play-speed' : 'pause-circle-outline'} size={22} color="#fff" />
{prefs.autoSwipeSec ? <Text style={styles.topBtnLabel}>{prefs.autoSwipeSec}s</Text> : null} {prefs.autoSwipeSec ? <Text style={styles.topBtnLabel}>{prefs.autoSwipeSec}s</Text> : null}
</Pressable> </Pressable>
<Pressable onPress={toggleMute} style={styles.topBtn} accessibilityLabel="Sound"> {/* Dzwiek ma JEDEN przycisk: ten w kolumnie po prawej, przy kciuku (uwaga Jana
<MaterialCommunityIcons name={prefs.soundOn ? 'volume-high' : 'volume-off'} size={22} color="#fff" /> 2026-09-16, byly dwa). Tapniecie w wideo dalej przelacza. Tu za to wejscie do
stroika feedu, bo po starcie nie bylo jak dolozyc tagow. */}
<Pressable onPress={() => setTuneOpen(true)} style={styles.topBtn} accessibilityLabel="Tune my feed">
<MaterialCommunityIcons name="tune-variant" size={22} color="#fff" />
</Pressable> </Pressable>
<Pressable onPress={newDeal} style={styles.topBtn} accessibilityLabel="New deal"> <Pressable onPress={newDeal} style={styles.topBtn} accessibilityLabel="New deal">
<MaterialCommunityIcons name="refresh" size={22} color="#fff" /> <MaterialCommunityIcons name="refresh" size={22} color="#fff" />
@ -418,12 +319,21 @@ export function SwipeScreen() {
) : null} ) : null}
</Modal> </Modal>
{/* Onboarding 3 tapow */} {/* Wybor zainteresowan: przy pierwszym wejsciu i pozniej spod ikony stroika */}
<OnboardingModal <OnboardingModal
visible={prefsReady && !prefs.onboarded && !!options.data} visible={prefsReady && !prefs.onboarded && !!options.data}
options={options.data} options={options.data}
onDone={finishOnboarding} onDone={finishOnboarding}
/> />
<OnboardingModal
visible={tuneOpen && !!options.data}
options={options.data}
tune
onDone={(tags, niches) => {
setTuneOpen(false);
if (tags.length || niches.length) void tuneFeed(tags, niches);
}}
/>
</View> </View>
); );
} }
@ -442,13 +352,34 @@ function OnboardingModal({
visible, visible,
options, options,
onDone, onDone,
tune,
}: { }: {
visible: boolean; visible: boolean;
options: ClipOnboardingOptions | undefined; options: ClipOnboardingOptions | undefined;
onDone: (tags: string[], niches: string[]) => void; onDone: (tags: string[], niches: string[]) => void;
/** true = otwarte z zakladki (dostrajanie), false = pierwsze wejscie. */
tune?: boolean;
}) { }) {
const client = useClient();
const [picked, setPicked] = React.useState<Set<string>>(new Set()); const [picked, setPicked] = React.useState<Set<string>>(new Set());
const tags = (options?.tags ?? []).slice(0, 24); const [pickedNiches, setPickedNiches] = React.useState<Set<string>>(new Set());
const [q, setQ] = React.useState('');
const [debouncedQ, setDebouncedQ] = React.useState('');
React.useEffect(() => {
const t = setTimeout(() => setDebouncedQ(q.trim()), 300);
return () => clearTimeout(t);
}, [q]);
// Szukanie idzie po CALYM slowniku tagow (1,7 tys.), bo sama czolowka go nie miesci:
// "anal" jest dopiero 27. co do liczby klipow i wypadal z listy (uwaga Jana 2026-09-16).
const search = useQuery({
queryKey: ['clip-onboarding-search', debouncedQ],
queryFn: () => client.getClipOnboardingOptions(debouncedQ),
enabled: visible && debouncedQ.length >= 2,
staleTime: 5 * 60 * 1000,
});
const searching = debouncedQ.length >= 2;
const tags = searching ? (search.data?.tags ?? []) : (options?.tags ?? []);
const niches = searching ? [] : (options?.niches ?? []).slice(0, 12);
const toggle = (t: string) => const toggle = (t: string) =>
setPicked((s) => { setPicked((s) => {
const n = new Set(s); const n = new Set(s);
@ -456,27 +387,66 @@ function OnboardingModal({
else n.add(t); else n.add(t);
return n; return n;
}); });
const toggleNiche = (t: string) =>
setPickedNiches((s) => {
const n = new Set(s);
if (n.has(t)) n.delete(t);
else n.add(t);
return n;
});
const total = picked.size + pickedNiches.size;
return ( return (
<Modal visible={visible} transparent animationType="fade"> <Modal visible={visible} transparent animationType="fade">
<View style={styles.onboardBackdrop}> <View style={styles.onboardBackdrop}>
<View style={styles.onboardCard}> <View style={styles.onboardCard}>
<Text style={styles.sheetTitle}>What do you want to see?</Text> <Text style={styles.sheetTitle}>{tune ? 'More of what you like' : 'What do you want to see?'}</Text>
<Text style={styles.centerText}>Pick three or more. The feed learns from what you watch anyway, this only gives it a head start.</Text> <Text style={styles.centerText}>
<ScrollView style={{ maxHeight: 300 }} contentContainerStyle={styles.chipsWrap}> {tune
? 'Pick anything you want more of. It counts right away and the next deal uses it.'
: 'Pick three or more, or search for anything that is not on the list. The feed learns from what you watch anyway, this only gives it a head start.'}
</Text>
<TextInput
style={styles.search}
value={q}
onChangeText={setQ}
placeholder="search tags..."
placeholderTextColor={theme.muted}
autoCapitalize="none"
autoCorrect={false}
/>
<ScrollView
style={{ maxHeight: 300 }}
contentContainerStyle={styles.chipsWrap}
keyboardShouldPersistTaps="handled"
>
{tags.map((t) => ( {tags.map((t) => (
<Chip key={t.value} label={`#${t.value}`} selected={picked.has(t.value)} onPress={() => toggle(t.value)} /> <Chip key={t.value} label={`#${t.value}`} selected={picked.has(t.value)} onPress={() => toggle(t.value)} />
))} ))}
{searching && !search.isLoading && tags.length === 0 ? (
<Text style={styles.centerText}>Nothing matches that.</Text>
) : null}
{niches.map((n) => (
<Chip
key={n.value}
label={n.value}
selected={pickedNiches.has(n.value)}
onPress={() => toggleNiche(n.value)}
/>
))}
</ScrollView> </ScrollView>
<View style={styles.onboardRow}> <View style={styles.onboardRow}>
<Pressable onPress={() => onDone([], [])} style={styles.secondaryBtn}> <Pressable onPress={() => onDone([], [])} style={styles.secondaryBtn}>
<Text style={styles.secondaryBtnText}>Skip</Text> <Text style={styles.secondaryBtnText}>{tune ? 'Cancel' : 'Skip'}</Text>
</Pressable> </Pressable>
<Pressable <Pressable
onPress={() => onDone([...picked], [])} onPress={() => onDone([...picked], [...pickedNiches])}
disabled={picked.size < 3} disabled={tune ? total === 0 : total < 3}
style={[styles.primaryBtn, picked.size < 3 && { opacity: 0.4 }]} style={[styles.primaryBtn, (tune ? total === 0 : total < 3) && { opacity: 0.4 }]}
> >
<Text style={styles.primaryBtnText}>Start{picked.size ? ` (${picked.size})` : ''}</Text> <Text style={styles.primaryBtnText}>
{tune ? 'Apply' : 'Start'}
{total ? ` (${total})` : ''}
</Text>
</Pressable> </Pressable>
</View> </View>
</View> </View>
@ -487,8 +457,6 @@ function OnboardingModal({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: '#000' }, root: { flex: 1, backgroundColor: '#000' },
pager: { flex: 1 },
page: { flex: 1 },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 28, backgroundColor: theme.bg }, center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 28, backgroundColor: theme.bg },
centerTitle: { color: theme.fg, fontFamily: fonts.display, fontSize: 18, marginTop: 12, textAlign: 'center' }, centerTitle: { color: theme.fg, fontFamily: fonts.display, fontSize: 18, marginTop: 12, textAlign: 'center' },
centerText: { color: theme.muted, fontFamily: fonts.body, fontSize: 14, marginTop: 8, textAlign: 'center', lineHeight: 20 }, centerText: { color: theme.muted, fontFamily: fonts.body, fontSize: 14, marginTop: 8, textAlign: 'center', lineHeight: 20 },
@ -510,6 +478,19 @@ const styles = StyleSheet.create({
onboardBackdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.7)', justifyContent: 'center', padding: 18 }, onboardBackdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.7)', justifyContent: 'center', padding: 18 },
onboardCard: { backgroundColor: theme.card, borderRadius: 18, padding: 18 }, onboardCard: { backgroundColor: theme.card, borderRadius: 18, padding: 18 },
onboardRow: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 12 }, onboardRow: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 12 },
search: {
marginTop: 12,
marginBottom: 10,
height: 42,
borderRadius: 12,
paddingHorizontal: 12,
backgroundColor: theme.bgElevated,
borderWidth: 1,
borderColor: theme.border,
color: theme.fg,
fontFamily: fonts.body,
fontSize: 15,
},
primaryBtn: { backgroundColor: theme.accent, borderRadius: 14, paddingHorizontal: 18, paddingVertical: 10, marginLeft: 10, marginTop: 8 }, primaryBtn: { backgroundColor: theme.accent, borderRadius: 14, paddingHorizontal: 18, paddingVertical: 10, marginLeft: 10, marginTop: 8 },
primaryBtnText: { color: '#fff', fontFamily: fonts.display, fontSize: 15 }, primaryBtnText: { color: '#fff', fontFamily: fonts.display, fontSize: 15 },
secondaryBtn: { borderRadius: 14, paddingHorizontal: 14, paddingVertical: 10, marginTop: 8 }, secondaryBtn: { borderRadius: 14, paddingHorizontal: 14, paddingVertical: 10, marginTop: 8 },

View file

@ -18,10 +18,23 @@ function _genDeviceId(): string {
export async function getDeviceId(): Promise<string> { export async function getDeviceId(): Promise<string> {
if (_deviceIdCache) return _deviceIdCache; if (_deviceIdCache) return _deviceIdCache;
let id = await SecureStore.getItemAsync(DEVICE_KEY); // Keystore potrafi odmowic ("Could not encrypt the value for key 'goon.device_id'",
// Samsung A7 z Androidem 10, Sentry 2026-09-19). Kazde zapytanie do API czeka na to id,
// wiec wyjatek stad wylaczal cala apke. Teraz id zostaje w pamieci do konca sesji; bez
// zapisu po restarcie powstanie nowe, ale apka dziala.
let id: string | null = null;
try {
id = await SecureStore.getItemAsync(DEVICE_KEY);
} catch {
id = null;
}
if (!id) { if (!id) {
id = _genDeviceId(); id = _genDeviceId();
try {
await SecureStore.setItemAsync(DEVICE_KEY, id); await SecureStore.setItemAsync(DEVICE_KEY, id);
} catch {
// zostaje tylko w _deviceIdCache
}
} }
_deviceIdCache = id; _deviceIdCache = id;
return id; return id;

View file

@ -520,4 +520,6 @@ export interface ClipListOut {
limit: number; limit: number;
offset: number; offset: number;
has_more: boolean; has_more: boolean;
/** Liczba wszystkich klipow przy filtrze po tworcy; null przy innych listach. */
total?: number | null;
} }

View file

@ -0,0 +1,79 @@
"""Wypelnia scene_tags.sort_date w istniejacych wierszach (migracja 0042, GOON-64).
Partiami po zakresach stron heapu (TID range scan, Postgres 14+), commit po kazdej partii,
VACUUM co kilka partii: martwe wersje zwalniaja miejsce dla nastepnych partii, zamiast
podwajac 1,7 GB tabeli i jej dwa indeksy. Nowe wiersze w trakcie wypelnia juz wyzwalacz
z 0042. Wznawialny: rusza tylko wiersze z sort_date IS NULL, na koncu dobiera resztki.
Odpalac w tle, nie z restartowanego workera:
docker compose run -d --rm --name backfill_sort_date worker \
python3 -u /srv/scripts/backfill_scene_tags_sort_date.py [stron_na_partie] [pauza_s]
Pauza miedzy partiami oddaje dysk apce: bez niej (2000 stron, 25-45 s na partie) zapytania
list scen czekaly na odczyt po 30 s (2026-09-18). Z 1000 stron i pauza 10 s bylo spokojnie.
Po nim: alembic upgrade (0043, indeks) i dopiero wtedy kod, ktory z indeksu korzysta.
"""
from __future__ import annotations
import sys
import time
from sqlalchemy import text
from app.db import engine
PAGES = int(sys.argv[1]) if len(sys.argv) > 1 else 1000
PAUSE = float(sys.argv[2]) if len(sys.argv) > 2 else 10.0
VACUUM_EVERY = 8
SORT_DATE = "COALESCE(s.release_date, (s.created_at AT TIME ZONE 'UTC')::date)"
def rel_pages() -> int:
with engine.connect() as c:
return int(c.execute(text(
"SELECT pg_relation_size('scene_tags') / current_setting('block_size')::int"
)).scalar())
def vacuum(analyze: bool = False) -> None:
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as c:
c.execute(text("VACUUM (ANALYZE) scene_tags" if analyze else "VACUUM scene_tags"))
t0 = time.time()
total = 0
page = 0
batch = 0
pages = rel_pages()
print(f"scene_tags: {pages} stron, partia {PAGES} stron, pauza {PAUSE} s", flush=True)
while page < pages:
tb = time.time()
with engine.begin() as c:
c.execute(text("SET LOCAL statement_timeout = '600s'"))
n = c.execute(text(f"""
UPDATE scene_tags st SET sort_date = {SORT_DATE}
FROM scenes s
WHERE s.id = st.scene_id AND st.sort_date IS NULL
AND st.ctid >= format('(%s,0)', CAST(:p0 AS int))::tid
AND st.ctid < format('(%s,0)', CAST(:p1 AS int))::tid
"""), {"p0": page, "p1": page + PAGES}).rowcount
total += n
page += PAGES
batch += 1
if batch % VACUUM_EVERY == 0:
vacuum()
pages = rel_pages() # tabela moze urosnac, zanim VACUUM zwolni miejsce
print(f"partia {batch}: strony do {page}/{pages}, {n} wierszy w {time.time() - tb:.1f} s, "
f"razem {total}, {time.time() - t0:.0f} s", flush=True)
if n:
time.sleep(PAUSE)
with engine.begin() as c:
rest = c.execute(text(f"""
UPDATE scene_tags st SET sort_date = {SORT_DATE}
FROM scenes s WHERE s.id = st.scene_id AND st.sort_date IS NULL
""")).rowcount
print(f"resztki: {rest}", flush=True)
vacuum(analyze=True)
with engine.connect() as c:
left = c.execute(text("SELECT count(*) FROM scene_tags WHERE sort_date IS NULL")).scalar()
print(f"KONIEC: {total + rest} wierszy, puste po wszystkim: {left}, {time.time() - t0:.0f} s", flush=True)

View file

@ -95,7 +95,7 @@
</p> </p>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
<a href="/goon-v0.4.1.apk" <a href="/goon-v0.4.2.apk"
class="px-6 py-4 rounded-xl bg-accent text-white font-bold hover:bg-accentDeep transition glow"> class="px-6 py-4 rounded-xl bg-accent text-white font-bold hover:bg-accentDeep transition glow">
Download APK Download APK
</a> </a>
@ -327,7 +327,7 @@
<li class="bg-card border border-border rounded-2xl p-5 flex gap-4"> <li class="bg-card border border-border rounded-2xl p-5 flex gap-4">
<span class="text-accent font-extrabold text-xl">1</span> <span class="text-accent font-extrabold text-xl">1</span>
<span class="text-sm text-gray-300 leading-relaxed"> <span class="text-sm text-gray-300 leading-relaxed">
<a href="/goon-v0.4.1.apk" class="text-accent font-bold hover:underline">Download the APK</a> <a href="/goon-v0.4.2.apk" class="text-accent font-bold hover:underline">Download the APK</a>
and open it. Allow "install from unknown sources" for your browser if Android asks. and open it. Allow "install from unknown sources" for your browser if Android asks.
</span> </span>
</li> </li>
@ -404,7 +404,7 @@
</p> </p>
</div> </div>
<div class="flex flex-col gap-1 text-right"> <div class="flex flex-col gap-1 text-right">
<a href="/goon-v0.4.1.apk" class="hover:text-accent transition">Download APK</a> <a href="/goon-v0.4.2.apk" class="hover:text-accent transition">Download APK</a>
<a href="https://git.goon-foss.org/goon-foss/goon" class="hover:text-accent transition">Source code</a> <a href="https://git.goon-foss.org/goon-foss/goon" class="hover:text-accent transition">Source code</a>
<a href="#donate" class="hover:text-accent transition">Donate</a> <a href="#donate" class="hover:text-accent transition">Donate</a>
</div> </div>

View file

@ -0,0 +1,51 @@
import uuid
from app.api.scenes import _diversify_pages
def _ids(n, origin):
return [(uuid.uuid4(), origin) for _ in range(n)]
def _mixed(flood_share=0.7, n=300, others=("b", "c", "d")):
"""Jak feed 2026-09-19: jedno zrodlo zalewa, reszta kapie, wszystko w kolejnosci czasu."""
items, k = [], 0
for i in range(n):
if (i % 10) < flood_share * 10:
items.append((uuid.uuid4(), "flood"))
else:
items.append((uuid.uuid4(), others[k % len(others)]))
k += 1
return items
def test_flood_is_capped_while_other_sources_exist():
items = _mixed()
pages = _diversify_pages(items, 10, 1 / 3)
origin = dict(items)
# 90 scen z innych zrodel wystarcza na 15 stron po 6; tyle stron trzyma limit
for pg in pages[:15]:
assert len(pg) == 10
assert sum(origin[i] == "flood" for i in pg) <= 4
def test_nothing_is_lost_and_pages_are_full():
items = _mixed()
pages = _diversify_pages(items, 10, 1 / 3)
assert sorted(i for pg in pages for i in pg) == sorted(i for i, _ in items)
assert all(len(pg) == 10 for pg in pages[:-1])
def test_two_sources_still_fill_pages():
items = _ids(40, "a") + _ids(40, "b")
pages = _diversify_pages(items, 10, 1 / 3)
assert all(len(pg) == 10 for pg in pages)
def test_order_within_source_is_kept():
items = _mixed()
flat = [i for pg in _diversify_pages(items, 10, 1 / 3) for i in pg]
for src in ("flood", "b", "c", "d"):
mine = [i for i, o in items if o == src]
mine_set = set(mine)
assert [i for i in flat if i in mine_set] == mine

View file

@ -87,6 +87,11 @@ def test_strip_accents_idempotent() -> None:
("Sasha Paige ft.Martin Spell", "Martin Spell", True), ("Sasha Paige ft.Martin Spell", "Martin Spell", True),
# ft wewnatrz slowa to nie separator. # ft wewnatrz slowa to nie separator.
("Craft Beer Night", "Craft Beer", True), ("Craft Beer Night", "Craft Beer", True),
# Zlepek tez nie przechodzi przez separator: „dick" i „ft" to sasiednie czlony
# tylko dopoki tytul nie jest ciety, a byly sklejane w jedno nazwisko.
("Twins Sasha Paige Share the Dick ft. Martin Spell", "Dickft", False),
("Sasha Paige feat Martin Spell", "Paigefeat", False),
("Sasha Paige ft. Martin Spell", "Paige Ft Martin", False),
], ],
) )
def test_person_named_in(title: str, person: str, expected: bool) -> None: def test_person_named_in(title: str, person: str, expected: bool) -> None:

52
tests/test_search_walk.py Normal file
View file

@ -0,0 +1,52 @@
from app.api.scenes import (
_SEARCH_WALK_BUDGET_MS,
_SEARCH_WALK_BUDGET_NO_TRGM_MS,
_search_walk_budget,
)
# 1000 tytulow jak histogram z pg_stats: 20 z "anal", 1 z "hailey rose", reszta obojetna.
SAMPLE = ["anal scene %d" % i for i in range(20)] + ["hailey rose pov"] + [
"other title %d" % i for i in range(979)
]
def _budget(q, off=0, want=51, narrowed=False):
return _search_walk_budget(q, off, want, narrowed, lambda: SAMPLE)
def test_common_phrase_walks():
# 20/1000 = 2%: na strone 1 trzeba przejsc ~2,5 tys. scen
assert _budget("anal") == _SEARCH_WALK_BUDGET_MS
def test_phrase_absent_from_sample_goes_to_trigrams():
assert _budget("jackerman") is None
def test_single_hit_phrase_goes_to_trigrams():
# 1/1000 = 0,1%: ~51 tys. scen do przejscia, powyzej progu
assert _budget("hailey rose") is None
def test_deep_page_of_common_phrase_goes_to_trigrams():
# strona 13 "anal": (600 + 51) / 2% = ~32 tys.
assert _budget("anal", off=600) is None
def test_narrowed_search_goes_to_trigrams():
assert _budget("anal", narrowed=True) is None
def test_short_phrase_always_walks():
# bez trigramu zwykly plan czyta caly indeks; tez przy zawezeniu i poza probka
for q in ("vo", "q", "xz", " a "):
assert _budget(q, narrowed=True) == _SEARCH_WALK_BUDGET_NO_TRGM_MS
assert _budget("zq", off=5000) == _SEARCH_WALK_BUDGET_NO_TRGM_MS
def test_sample_not_read_when_not_needed():
def boom():
raise AssertionError("probka niepotrzebna")
assert _search_walk_budget("vo", 0, 51, False, boom) == _SEARCH_WALK_BUDGET_NO_TRGM_MS
assert _search_walk_budget("anal", 0, 51, True, boom) is None

View file

@ -0,0 +1,93 @@
from datetime import date, datetime
from app.scheduler.studio_title_dedup import Cand, plan_merges, same_scene, studio_tokens
_T0 = datetime(2026, 9, 16, 23, 0)
_MILFY = studio_tokens("Milfy", "milfy")
def _c(cid, title, dur=None, rel=None, perfs=(), canon=False, live=1, studio="milfy"):
return Cand(
id=cid, title=title, studio_id=studio, studio_name="Milfy", studio_slug="milfy",
duration_sec=dur, release_date=rel, created_at=_T0, perfs=tuple(perfs), canon=canon, live=live,
)
# Zgloszenie bb1a357d: ta sama scena z osmiu tubow, kazdy z innym formatem tytulu.
_MILFY_COPIES = [
_c("canon", "Voluptuous Therapist Fucks Prisoner", 2218, date(2026, 9, 16),
("Troy Francisco", "Koda Monroe"), canon=True),
_c("a", "MILFY – Koda Monroe – Voluptuous Therapist Fucks Prisoner", 2218, date(2026, 9, 16),
("Troy Francisco", "Koda Monroe"), live=4),
_c("b", "[Milfy] Koda Monroe (Voluptuous Therapist Fucks Prisoner)", None, date(2026, 9, 16)),
_c("c", "[MILFY] Koda Monroe – Voluptuous Therapist Fucks Prisoner", perfs=("Koda Monroe",)),
_c("d", "Koda Monroe - Voluptuous Therapist Fucks Prisoner (17/9/26)", 2218),
_c("e", "Koda Monroe Voluptuous Therapist Fucks Prisoner 4K", 2218),
_c("f", "Milfy 26 09 16 Koda Monroe Voluptuous Therapist Fucks Prisoner", None, date(2026, 9, 16)),
]
def test_milfy_copies_collapse_into_canonical_scene():
plan = plan_merges(_MILFY_COPIES)
assert len(plan) == 1
keep, drops = plan[0]
assert keep.id == "canon"
assert {d.id for d in drops} == {"a", "b", "c", "d", "e", "f"}
def test_part_numbers_are_not_dates():
one = _c("1", "Ariel Swings Her Whip", 600, date(2026, 9, 16))
two = _c("2", "Ariel Swings Her Whip 2", 600, date(2026, 9, 16))
assert not same_scene(one, two, _MILFY)
p1 = _c("p1", "Jewell - Punishment Stool - Part 1", 232, date(2003, 11, 13))
p2 = _c("p2", "Jewell - Punishment Stool - Part 2", 231, date(2003, 11, 13))
assert not same_scene(p1, p2, _MILFY)
def test_guards():
base = _c("x", "Voluptuous Therapist Fucks Prisoner", 2218, date(2026, 9, 16), ("Koda Monroe",))
longer = _c("y", "Voluptuous Therapist Fucks Prisoner", 2600, date(2026, 9, 16), ("Koda Monroe",))
assert not same_scene(base, longer, _MILFY)
later = _c("z", "Voluptuous Therapist Fucks Prisoner", 2218, date(2026, 10, 1), ("Koda Monroe",))
assert not same_scene(base, later, _MILFY)
year_only = _c("j", "Voluptuous Therapist Fucks Prisoner", 2218, date(2026, 1, 1), ("Koda Monroe",))
assert same_scene(base, year_only, _MILFY)
other_cast = _c("o", "Voluptuous Therapist Fucks Prisoner", 2218, date(2026, 9, 16), ("Someone Else",))
assert not same_scene(base, other_cast, _MILFY)
other_studio = _c("s", "Voluptuous Therapist Fucks Prisoner", 2218, date(2026, 9, 16), studio="blacked")
assert not same_scene(base, other_studio, _MILFY)
# Bez dlugosci po obu stronach trzy slowa to za malo.
short_a = _c("sa", "[Milfy] Hot Stepmom Fun")
short_b = _c("sb", "Milfy 26 09 16 Hot Stepmom Fun")
assert not same_scene(short_a, short_b, _MILFY)
def test_chain_does_not_pull_in_incompatible_scene():
keep = _c("k", "Hotel Vixen Season 3 Episode 4 Soft Launch Hard Times", 2608, canon=True)
bridge = _c("m", "[Milfy] Hotel Vixen Season 3 Episode 4 Soft Launch Hard Times")
other = _c("l", "Milfy Hotel Vixen Season 3 Episode 4 Soft Launch Hard Times", 2896)
plan = plan_merges([keep, bridge, other])
assert [(k.id, [d.id for d in ds]) for k, ds in plan] == [("k", ["m"])]
def test_category_named_as_performer_does_not_shorten_core():
# Tube wpisal kategorie jako obsade. Bez listy smieci "big", "ass" i "tits" wypadlyby
# z obu tytulow i dwie rozne sceny mialyby ten sam rdzen "blonde fucks jason luv".
a = _c("a", "Big Ass Blonde Fucks Jason Luv", perfs=("Big Ass", "Big Tits"))
b = _c("b", "Big Tits Blonde Fucks Jason Luv")
assert not same_scene(a, b, _MILFY, junk_names=frozenset({"big ass", "big tits"}))
def test_copies_without_studio_join_only_with_matching_duration():
orphan = _c("o1", "Koda Monroe - Voluptuous Therapist Fucks Prisoner (17/9/26)", 2218, studio="")
orphan_4k = _c("o2", "Koda Monroe Voluptuous Therapist Fucks Prisoner 4K", 2217, studio="")
other_cut = _c("o3", "Koda Monroe Voluptuous Therapist Fucks Prisoner", 2600, studio="")
no_duration = _c("o4", "Koda Monroe Voluptuous Therapist Fucks Prisoner", None, studio="")
plan = plan_merges(_MILFY_COPIES + [orphan, orphan_4k, other_cut, no_duration])
keep, drops = plan[0]
assert keep.id == "canon"
ids = {d.id for d in drops}
assert {"o1", "o2"} <= ids
assert not ids & {"o3", "o4"}
# Dwie sceny bez studia nie maja kotwicy.
assert not same_scene(orphan, orphan_4k, _MILFY)