Compare commits

..

139 commits

Author SHA1 Message Date
goon-foss
cf9741b319 fix(normalize): separator feat/ft w person_named_in naprawde dziala
Some checks are pending
Backend tests / test (push) Waiting to run
W _PERSON_SEPARATOR_RE zamiast \b staly doslowne znaki 0x08 (heredoc przy
commicie b7a8911), wiec tytul nigdy nie byl ciety na feat/ft. Regex uzywa
teraz prawdziwych granic slowa, lapie tez "featuring", a myslniki podaje
jako escape zamiast doslownych znakow.

Skutek w danych sprawdzony na prodzie: 255 701 przypisan na scenach z
sxyprn, 6530 tytulow z feat/ft, stara i nowa regula roznia sie w 0
przypadkach, bo sam czlon feat/ft i tak przerywal sasiedztwo nazwiska.
Poprawka zamyka przypadki brzegowe (smieciowy performer "Feat").

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 15:59:39 +02:00
goon-foss
d2b3378d93 fix(ingest): czysc opisy z encji i znacznikow HTML, title_normalized zawsze przez normalize
Opisy scen szly do bazy z `&#34;`, `&lt;br&gt;` i surowymi `<br />`, a apka i strona
SEO pokazuja opis jako tekst, wiec bylo to widac doslownie. clean_description
dekoduje encje, `<br>` i koniec akapitu zamienia na nowa linie, a znane znaczniki
wycina (`<3` zostaje tekstem). Wpiete w normalize_scene i normalize_movie.

bulk_rescrape_hqporner.py i repair_truncated_titles.py liczyly title_normalized
jako lower(title), przez co 6005 scen mialo interpunkcje w kluczu dopasowania.
Oba licza teraz normalize() i dekoduja encje w tytule.

Skrypty: fix_html_entity_titles.py przemianowany na fix_html_entities.py z
przebiegiem po opisach; nowy fix_title_normalized.py przelicza klucz tam, gdzie
rozjechal sie z normalize(title).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 15:59:39 +02:00
goon-foss
d471bb3293 fix(ingest): dekoduj encje HTML w tytulach scen i filmow
xvideos (JSON-LD) i kilka innych tubow oddaje tytuly z encjami HTML5
(`we&apos;re`, `&lpar;AI&rpar;`), czasem podwojnie zakodowane. normalize
robil z nich slowa `apos`, `lpar`, wiec tytul nie trafial w wyszukiwarke
ani w kanon.

unescape_html w app/normalize/text.py dekoduje encje zakonczone srednikiem
po slowniku HTML5, do skutku (max 3 przebiegi). Wpiete w normalize_scene i
normalize_movie oraz w parsery xvideos_browse i pornxp. Skrypt
fix_html_entity_titles.py poprawia istniejace wiersze (title,
title_normalized, slug gdy byl wyliczony z tytulu).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 15:59:39 +02:00
goon-foss
7ed878ce16 feat(mobile): gorne belki w jednym wierszu, pasek w Edge chowa sie przy przewijaniu
- SegmentTabs w ui.tsx: zakladki w wierszu narzedzi obok ikon
- Ulubione, performerka, studio, Performers i Tags: jeden wiersz pod naglowkiem
  zamiast trzech-czterech (etykieta z licznikiem, zakladki, podpowiedz, sortowanie)
- gwiazdka na ekranie performerki i studia bierze /favorites?counts=false
- Edge: kolejny klip chowa pasek, cofniecie pokazuje; podpis, przyciski i postep
  jada razem z paskiem; FAB zgloszen ukryty, lezal na "Not interested"
- changelog 2026-09-15c/d

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 15:37:53 +02:00
goon-foss
37e6b7f03c perf(api): liczniki +N ulubionych z cache i indeksem, podobne sceny z najrzadszych tagow
- /favorites i /favorites/studios: liczniki w cache procesu (swiezy 5 min, potem
  odswiezanie w tle), mark_seen zeruje od razu; parametr counts=false dla ekranow,
  ktore chca tylko stan gwiazdki
- licznik studiow idzie indeksem ix_scenes_studio_effective_date_desc (migracja 0038)
  i konczy po 200 scenach na studio: 10-25 s -> kilkanascie ms
- okno licznika sortuje tak jak lista (data premiery albo dzien dodania), wczesniej
  rozjechane od 0033
- similar_scenes: pula z 4 najrzadszych tagow zamiast grupowania po wszystkich
  (do 5 s -> ponizej 0,4 s)
- enrich-tags: tagi jednym zapytaniem po slugach i jeden INSERT
- /scene-favorites: serializer wsadowy

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 15:05:43 +02:00
goon-foss
f0d49889d8 chore(git): latki patch-package zawsze z LF
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:33:25 +02:00
goon-foss
080fc4b70e fix(apk 0.4.1): puste ikony na paczce wbudowanej, crash expo-video przy zamykaniu z ostatnich
- expo-file-system jako jawna zaleznosc: zagniezdzony pod expo nie byl autolinkowany,
  ExpoAsset.downloadAsync padal na AppDirectories not found (Sentry 143555436)
- app/build.gradle: create*UpdatesResources zawsze generowane; zadanie mialo same napisy
  jako wejscia i od 2026-05-31 osadzalo stary app.manifest (23 assety, bez fontu ikon)
- patch-package + patches/expo-video+2.0.6.patch: cleanup() iteruje po kopii mediaSessions
  (ConcurrentModificationException w onTaskRemoved, Sentry 147164135)
- versionCode 15 / 0.4.1, runtime bez zmian (1.4), /version 0.4.1, linki na stronie

Test: czysta instalacja offline na emulatorze, 0.4.0 = 0 glifow ikon + blad w Sentry,
0.4.1 = 11 glifow i brak bledu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:33:13 +02:00
goon-foss
747d0cec3b perf(api): historia ogladania wsadowo, wyszukiwanie w obrebie strony po rownosci originu
- /watch/recent: _build_scenes_out_batch(light) zamiast 5-7 zapytan na scene (N+1 w Sentry)
- /scenes?origin=tube❌ rownosc zamiast ILIKE '%...%', 4,6 s -> 1,1 s na hdporngg z q

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:06:01 +02:00
goon-foss
8462ab50df feat(mobile): zakladka nazywa sie Edge; /version 0.4.0 runtime 1.4
Nazwa od edgingu: feed krotkich klipow bez konca (wybor Jana). W kodzie zostaje
Swipe (SwipeScreen, swipeTelemetry), etykieta i teksty user-facing to Edge.
Backend /version podaje 0.4.0 / 1.4, wiec starsze APK dostaja monit o nowej
wersji; plik app-release.apk podmieniany razem z publikacja.
2026-09-15 09:29:40 +02:00
goon-foss
9872d5e4ca feat(mobile): zakladka Swipe, pionowy feed klipow (etap 4, APK 0.4.0 / runtime 1.4)
Nowa zakladka w dolnym pasku: react-native-pager-view (pion, natywny snap), dwie
instancje createVideoPlayer (aktywna + nastepna w buforze), komorka z posterem,
podpisem tworcy i tagami, kolumna przyciskow, pasek postepu. Gesty bez reanimated:
tap = dzwiek, double-tap = ulubione z wybuchem serca i haptyka, przytrzymanie = 2x,
przesuniecie w lewo = arkusz "show less of this" (tworca / tagi / nisze / seen it,
plus blokada tworcy). Onboarding 3 tapow z top tagow, AutoSwipe (off/5/10 s),
licznik obejrzanych, nowe rozdanie.

Telemetria: SwipeTelemetry zbiera zdarzenia i wysyla paczki co 5 s / 10 zdarzen /
w tle; przy opuszczeniu strony impression z dwell, skip (<2 s) albo complete (petla
lub >=80%). Serwer z tego uczy wagi i uklada 80% nastepnych stron.

Pager to modul natywny, wiec import jest miekki (require w try/catch) i zakladka
pokazuje sie tylko od runtime 1.4; ten sam bundle leci OTA na 1.2/1.3 z wpisem
"pobierz 0.4.0" (onlyBelowRuntime), a wpis o Swipe ma nowe pole minRuntime.
Bump: app.json 0.4.0 / runtime 1.4, versionCode 14, AndroidManifest 1.4,
publish_update RUNTIME_DEFAULT 1.4, strona goon-foss.org pod 0.4.0.

Sprawdzone na emulatorze (APK 0.4.0): zakladka widoczna, onboarding zapisany na
serwerze, feed spersonalizowany 8/2, ExoPlayer dekoduje wideo i audio, swipe daje
zdarzenia complete/impression, double-tap daje like + ulubione + wagi.
2026-09-15 09:24:13 +02:00
goon-foss
538a72107e perf(clips): explore po podzapytaniu okna, kandydaci bez ORM i bez tagow, max(uuid) przez tekst
Strona feedu spadla z 310-470 ms do 50-130 ms (srednio 85 ms na 30 stronach,
0 powtorek). Postgres nie ma max(uuid): koniec okna liczony po rzutowaniu na
tekst (ten sam porzadek dla malych hex w stalym formacie).
2026-09-14 20:34:19 +02:00
goon-foss
0df2236b37 feat(clips): feed Swipe z afinicznoscia, pamiecia widzianych i telemetria (etap 3)
Tabele per urzadzenie (migracja 0037): clip_seen (wykluczenie, wiersz przy
podaniu klipu), clip_feed_events (append-only: impression z dwell, skip,
complete, like, unlike, not_interested, autoswipe), clip_affinity (wagi
tag/nisza/tworca w JSONB), clip_favorites, clip_blacklist. GIN na clips.niches.

GET /clips/feed: kursor base64 {seed, pk, page} zamiast offsetu; 80% z puli
afinicznosci (skany po tagu/niszy/tworcy od losowego uuid, punktacja
sum wag + 0,3 ln(1+lajki) + swiezosc, max 2 klipy tworcy na strone) + 20%
explore (okno 2000 id po PK, losowanie wazone lajkami, zawijanie na koncu).
Bez wag 100% explore. POST /clips/events: zapis, podniesienie outcome,
ulubione, delty wag (like +5, complete +3, dwell>=10 s +1, skip<2 s -1,
not_interested -4 i -8 na wskazany cel). Onboarding 3 tapow, opcje,
blacklista tag/nisza/tworca, reset feedu, ulubione. Import miedzy
urzadzeniami kopiuje clip_seen, clip_favorites i clip_blacklist.

Job clips-maintenance co 24 h: decay wag x0,97, retencja zdarzen 90 dni,
reaper (HEAD na 2000 najdawniej sprawdzanych). Logika bez bazy w
app/clips_feed.py z testami (kursor, delty, cap/decay, punktacja, sklad).
2026-09-14 20:29:49 +02:00
goon-foss
cd4803ed51 fix(clips): RedGifs 1 zapytanie/s, odstep z error.delay przy 429, przerwanie backfillu zamiast mielenia nisz
Pierwszy backfill po niszach przy ~3 zapytaniach/s po ~65 zapytaniach wpadl
w trwale 429 (cialo: delay 1167 s, subject per IP i endpoint) i przemielil
40 kolejnych nisz w bledach. Teraz: 1/s, odstep z Retry-After albo
error.delay, powyzej 5 min RateLimited i koniec przebiegu (wznowienie --skip).
2026-09-14 16:41:34 +02:00
goon-foss
4f5de92bfc feat(clips): tabele klipow do feedu Swipe + ingest RedGifs (etap 2)
Nowy wertykal krotkich pionowych klipow tworcow, osobno od scen: inne kolumny
(tworca, audio, lajki, nisze), inny cykl zycia, inna taksonomia, brak filtra
60 s. Tabele `clips` (unikalne source+source_id) i `clip_tags` z indeksem
(tag, clip_id) pod skan po tagu od losowego uuid w przyszlym feedzie.

Zrodlo nr 1: RedGifs, oficjalne API v2 z tokenem tymczasowym (zwiazany z IP
i UA, na 401 odnowienie). Globalne `order=latest` bez search_text daje ~100
na strone, kazde zapytanie widzi maks. 10 000 wynikow, wiec katalog chodzi
sie po niszach (skrypt backfillu). Pliki media.redgifs.com sa publiczne i bez
podpisu (206 z VPS-a i z telefonu), do bazy wchodzi gotowy adres.

Scheduler: job clips-latest co godzine (5 stron), zatrzymuje sie na stronie
bez nowych. Reaper (HEAD, 403/404/410 -> dead_at) jako skrypt, upsert
odzywia klip, ktory zrodlo znow podaje. API: GET /clips i /clips/stats do
pilota; wlasciwy feed, pamiec widzianych i telemetria to etap 3.

Sprawdzone tego dnia i odlozone: nsfwswipe (lustro v.redd.it, bez audio,
stoi od 19.08), Pornhub Shorties (zrodlo nr 2, HLS z tokenem 2 h, Referer
na segmentach, 0/110 performerow z kanonu).
2026-09-14 16:32:57 +02:00
goon-foss
f28acf8f0d fix(player): rodzina DoodStream, warstwy reklamowe nad playerem nie lapia juz stukniec
- DOOD_OVERLAY_JS (injectedJavaScript po zaladowaniu): fixed + z-index > 100000 bez
  Cloudflare -> pointer-events none, iframe sandbox/srcdoc usuwany; co 700 ms
- diagnoza devtools: pelnoekranowy div opacity 0.01 z z 2147463647, iframe reklamowy i
  pusty kwadrat 80x80 dokladnie na przycisku play; popunder u nas zablokowany, wiec
  warstwa nigdy nie znikala (zgloszenia e9fc12e8, 1278eb49)
- changelog 2026-09-14c, OTA 69b27e52 na 1.3

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:40:42 +02:00
goon-foss
656604e868 feat(mobile): sortowanie losowe, najdluzsze i najkrotsze w filtrze scen; seed losowy per lista, nowy przy pull-to-refresh
- changelog 2026-09-14b, OTA 6770483f na 1.3

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:19:58 +02:00
goon-foss
bd8af5f44c chore(compose): rotacja logow kontenerow (50m x 3), pelny dysk 2026-09-14
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:19:46 +02:00
goon-foss
be6c18b3b4 feat(scenes): sortowanie losowe (seed, okno po PK) i po dlugosci (indeks partial), zgloszenie ae6a045b
- random: md5(seed) -> uuid startu, okno 2000 id (8000 dla wertykalow) po pk_scenes,
  filtry na oknie; bez okna planer robil Merge Anti Join po 2,3 mln wierszy (7,6 s)
- duration_desc/asc: bez NULL, do 10 h, indeks ix_scenes_duration (migracja 0035)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:13:13 +02:00
goon-foss
d5fb3fb521 fix(mobile): reset filtrow dziala od razu, licznik filtrow wzgledem domyslnych, rok w filtrze filmow 1900..2100
- ScenesFilterModal: Reset all filters stosuje domyslne i zamyka arkusz (f5193fba)
- ScenesScreen: has_playback domyslnie wlaczone nie liczy sie jako filtr, za to sort,
  min. dlugosc, min. jakosc i stuby tak
- MovieFiltersSheet: rok poza 1900..2100 ignorowany zamiast 422 (2b6dbc75)
- changelog 2026-09-14a, OTA 6cca22ce na 1.3

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 14:38:59 +02:00
goon-foss
c55596dd6f fix(thumbs): xhamster miniatury z JSON strony wynikow, mypornerleak z src i og:image posta, backfill brakujacych
- _search_base: hak _thumbnail_map (mapa url -> miniatura z calej strony) przed
  szukaniem <img> w oknie tekstu; xhamster mial 54% zrodel bez miniatury
- mypornerleak search: karty maja teraz src zamiast data-src; browse (REST) bez
  featured media -> og:image ze strony posta
- scripts/backfill_thumbs.py: uzupelnia miniatury ze strony sceny, od najnowszych

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 14:33:51 +02:00
goon-foss
320d928927 feat(mobile): naglowki ekranow podrzednych jak sekcje glowne, ikony ulubione/ukryj, pasek dolny wyzszy z pierscieniem centrowanym przez layout
- screenOptions: tlo ekranu, bez cienia, tytul 17 display, tint accentText
- HeaderIconButton (star/star-outline, eye-off-outline) na 5 ekranach zamiast ★/🚫
- BottomTabsBar: 76 dp, pierscien statyczny w iconBox w spoczynku, kapsula tylko w
  trakcie przejscia, srodki zaokraglone do piksela
- changelog 2026-09-08b/c, OTA 1cbb9181 i 18ba425a na 1.3
2026-09-08 10:09:34 +02:00
goon-foss
385aeb3ddb perf(api): lista filmow serializowana wsadowo (7 zapytan/film -> 8 na strone), /sources z agregatem w pamieci na 5 min
- GET /movies 371 -> 82 ms, z has_playback 477 -> 209 ms; GET /sources 604 -> 8 ms
- VACUUM ANALYZE tabel filmow po tygodniu przemialu (11k martwych wierszy w movies)
2026-09-08 10:04:01 +02:00
goon-foss
44842478ae fix(studio movies): osobny klucz cache dla listy nieskonczonej, kolizja z useQuery w StudioScenes wywalala pages.length (Sentry 140915208)
- changelog 2026-09-08a, OTA 15f8e545 na 1.3
2026-09-08 09:53:20 +02:00
goon-foss
b0350baff0 fix(player): przekierowanie z pierwszego adresu przechodzi (myvidplay -> playmogo), rodzina dooda jako jeden host; jeden wiersz ikon na ekranie strony i performera
- WebView: przed pierwszym onLoadEnd kazda nawigacja top-frame to lancuch redirectow
  serwera, nie popunder; po zaladowaniu przeskoki w obrebie rodziny DoodStream tez OK
- SiteScenes: wyszukiwarka w obrebie strony + ikona tagow z licznikiem + X
- PerformerScenes: licznik scen + ikony szukaj-wiecej/filtr w jednym wierszu, chipsy
  top-tagow wyciete (tagi sa w filtrze), zbedne zapytanie performer-tags usuniete
- changelog 2026-09-07h, OTA 49cda698 na 1.3
2026-09-07 15:13:43 +02:00
goon-foss
e13463cba4 feat(sites): loga stron (favicon -> PNG w static, job co 24h) + sortowanie w arkuszu pod ikona
- scripts/site_icons.py: apple-touch-icon / icon / favicon.ico, PIL do 64x64 PNG,
  proxy Bright Data w odwodzie, mapa domen dla sitetagow bez TLD
- /sources: icon_url gdy plik istnieje (BACKEND_PUBLIC_URL albo wzgledne)
- SitesScreen: SiteLogo z literowym fallbackiem, SortSheet (most scenes, recently
  updated, best rated, name) zapamietany w SecureStore, koniec wiersza Top/Recent
- changelog 2026-09-07g, OTA 1e90f427 na 1.3
2026-09-07 12:13:58 +02:00
goon-foss
8c75b12066 feat(mobile): pasek chowa sie przy przewijaniu w dol (hystereza po drodze), FAB jedzie z nim, podpowiedz Sites do pierwszego przypiecia
- useAutoHideOnScroll: licznik drogi w jednym kierunku (48 px w dol chowa, 24 w gore
  pokazuje), strefy zawsze-widoczny przy gorze i dole listy, overscroll ignorowany
- HomeScreen useFocusEffect pokazuje pasek po powrocie z ekranu szczegolow
- changelog 2026-09-07e/f, OTA 40931479 i 8604dadb na 1.3
2026-09-07 11:53:31 +02:00
goon-foss
d9fbc08218 feat(mobile): pasek znika pod klawiatura, stukniecie w aktywna sekcje przewija na gore, Scenes w jednym wierszu ikon
- lib/bottomBar.ts: stale paska, szyna scroll-top, wartosc Animated do chowania
  (hysteresis po przebytej drodze, strefy gora/dol), inset dla list
- BottomTabsBar: absolute nad trescia, translateY z native drivera, null przy klawiaturze
- Scenes: Performers/Tags/Favorites jako IconButton obok lejka i zebatki, chipsy
  zapisanych wyszukiwan tylko przy fokusie albo wpisanym tekscie, Save jako chip
- changelog 2026-09-07d, OTA 3f7e21c5 na 1.3
2026-09-07 11:45:53 +02:00
goon-foss
90724af8ef feat(mobile): bez gornej belki, ikona filtra, darowizna i backend w ustawieniach
- pasek dolny nizszy (62) i przyciety na krawedzi ekranu, zaokraglone tylko gorne rogi
- IconButton (filter-variant z licznikiem) zamiast pigulki Filter, SettingsButton w
  wierszu narzedzi Scenes/Movies/Sites
- ustawienia: sekcje Support the project i Backend (host + wylogowanie), tytul Settings
- changelog 2026-09-07c, OTA 5c8895d6 na 1.3
2026-09-07 11:37:12 +02:00
goon-foss
dce738b8ab chore(mobile): usun gorny pasek zakladek, zastapiony dolnym 2026-09-07 11:23:56 +02:00
goon-foss
b847b300bf feat(mobile): zakladki na dole ekranu, pierscien swiatla rozciaga sie do nowej ikony
- BottomTabsBar: pigulka nad krawedzia, aktywna ikona w pierscieniu; przy zmianie
  przednia krawedz wystrzela, tylna dogania (kapsula od starej do nowej), jeden
  kolor accentText z miekka poswiata zamiast neonu z reela
- naglowek: nazwa sekcji obok logo zamiast gornego paska
- BugReportFAB wisi nad paskiem na ekranie-gospodarzu
- changelog 2026-09-07b, OTA a2328005 na 1.3
2026-09-07 11:23:49 +02:00
goon-foss
7fe9a11136 chore(mobile): wpis changelogu o tubepornclassic, porndupe i kolejnosci zrodel w filmach
Nowe strony wchodza po stronie backendu, wiec bez publish OTA uzytkownik nie
dostaje popupu What's new (changelog jedzie w bundlu). Sam wpis, bez zmian w
kodzie apki.
2026-09-07 10:15:21 +02:00
goon-foss
994e4e98ad feat(tubepornclassic): konektor retro/vintage na silniku TXXX, wspolny tor HLS z vjav
tubepornclassic.com to SPA sieci TXXX (ten sam silnik co vjav): HTML ma pusty
<title> i zero linkow do scen, wszystko idzie z JSON API. Listing
/api/json/videos2/<cache>/str/latest-updates/60/..<page>.all...json oddaje
60 rekordow z pelnymi metadanymi (tytul, slug, dlugosc, post_date, obsada,
kategorie+tagi, miniatura), wiec nie ma fetchu per scena. ~168 tys. wideo,
wgrania codziennie. Koniec katalogu = 200 z code=videos_not_found.

Stream: videofile.php -> dekod video_url -> get_file -> f=video.m3u8 -> HLS
na ahcdn (klucz time-bound, nie IP-bound: adres wybity z VPS gra z lacza
domowego). Ten tor zyl dotad w tubes/vjav.py; wyniesiony do
_txxx.extract_hls, vjav i tubepornclassic wolaja to samo. HLS zamiast mp4,
bo mp4 z tego samego get_file ma w URL CDN speed=... (~1,2 MB/s), a segmenty
HLS leca ~3,6 MB/s. Wyzwanie 429 (ciasteczko w ciele) obsluzone w
ekstraktorze, scraperze i juz wczesniej w /proxy/hls.

Bramka: 38% tytulow to generator SEO ("Fabulous Sex Movie Vintage Try To
Watch For Ever Seen"). Z doczepionym nazwiskiem scena jest odnajdywalna
i zostaje; bez obsady (22%) nie wchodzi, bo nie ma po czym jej znalezc
ani z czym zdeduplikowac. Obsada u 46% scen, studiow brak.

Pilot: 2 strony, 120 scen, 111 nowych, 9 juz w bazie, wszystkie z dlugoscia.
Odtworzenie na emulatorze: playback_events success (ttff 3,3 s), AudioTrack
started. vjav po refaktorze: ten sam wynik ekstraktora co przed.
2026-09-07 10:04:24 +02:00
goon-foss
9906c665ec feat(porndupe): konektor, skorka KVS z plikami na OK.ru i zaszyfrowanym kluczem sesji
Z szesciu stron z listy piec juz jest lub bylo w bazie: fullmovies.xxx,
hdporn.gg, neporn.com sa zywe (ingest z dzisiaj, odtwarzanie 84-99%),
a hqfap.com i 4k69.com nadal oddaja Cloudflare JS-challenge na podstronach
(403 "Just a moment" takze z lacza domowego, wiec to nie blokada po IP).
Jedyna nowa to porndupe.

Porndupe wyglada jak KVS, ale to atrapa: kazdy get_stream/<id>-<q>.mp4?md5=
odpowiada doslownie "error" (sprawdzone z sesja, Refererem strony i embedu,
z permutacja md5 kodem licencyjnym). Prawdziwy tor widac w DevTools:

  generate_mp4('<b64 JSON>', '<haslo>', '<okid>', '<id>')
  -> {ciphertext, iv, salt, iterations: 999}
  -> PBKDF2-SHA512 + AES-256-CBC -> klucz sesji OK.ru (staly; haslo i
     ladunek zmieniaja sie co wczytanie, to zaciemnienie)
  -> api.ok.ru/fb.do method=video.get -> url_low/medium/high/fullhd,
     duration (ms), created_ms, thumbnail_url

Wariant PBKDF2 ustalony empirycznie: SHA-1/SHA-256 daja bad decrypt,
SHA-512 oddaje dokladnie klucz z trace'u.

okcdn: srcIp wbite w URL, NIE egzekwowane (URL z lacza domowego pobrany
z VPS -> 206, 514 MB, ftypisom) -> mobile_direct_ok. Cap 720p, bo OK.ru
dlawi polaczenie do ~2,1 Mbps (decyzja z 4k69/8kporner).

Browse po id (vjav pattern): kazdy listing to catch-all z tymi samymi 24
linkami, sitemapy pokrywaja 3500 z 12 887 id. /video/<id>/ -> 301 slug
(istnieje) albo 302 / (brak). Dlugosc z api.ok.ru przy ingescie (HTML nie
ma duration ani uploadDate). Daty CELOWO nie: created_ms to dwie fale
hurtowego wgrania (07.2024, 04.2025), nie premiery.

Katalog ZAMROZONY od kwietnia 2025, wiec to jednorazowy import, nie zrodlo
swiezosci. Wchodzi, bo 87% scen (52/60) nie mamy, a to tresc studyjna
(Nubile Films, NF Busty, Family Strokes) trafiajaca w kanon, w odroznieniu
od hentaicloud (17%). Pilot crawl_page(1): 22/22 z dlugoscia, 21/22 z
performerem, studia i tagi per-video.

Blad diagnostyczny po drodze: strona blankuje sie do about:blank po wykryciu
CDP (disable-devtool z jsdelivr); trace przezyl, DOM nie.
2026-09-05 22:34:39 +02:00
goon-foss
25416a2858 fix(movies): rodzina DoodStream (playmogo/doply/myvidplay) na koniec listy zrodel
Zgloszenia "Playmogo nie dziala tutaj" (1278eb49, 2026-09-04) i seria
"Myvidplay nie dziala" (3b75cb54, d3af0813, 11d7b17b). myvidplay to 301
na playmogo, a playmogo to klon DoodStream z KLIKANYM Turnstile: widzet
pojawia sie po tapie w play i przeladowaniu, wiec pass_md5 nie ma w HTML
i telefon nie rozwiaze tego sam. Telemetria: ZERO zdarzen odtwarzania
w 30 dniach (droga WebView nic nie raportuje), gdy mixdrop/voe/luluvid/
vidara graja w ~99%.

Przyczyna kolejnosci: _movie_origin_priority dawal tym originom neutralne
500, wiec sortowaly sie z dzialajacymi po created_at DESC, a doply/playmogo
bywaja dopisane jako OSTATNIE -> ladowaly na GORZE "Watch on". Zgloszony
film 12e9d0c1 mial obok zywy mixdrop.

Pomiar 2026-09-05: rodzina siedzi w 54 446 filmach, 54 320 (99,8%) ma
dzialajaca alternatywe, tylko 126 zalezy od niej wylacznie. Nowy tier 800
(za neutralnymi 500, przed martwa rodzina seekplayer 900): dla tych 126
zostaje jako ostatnia deska ratunku, wiec nikomu nie zabiera dostepu.

Weryfikacja po deployu: film 12e9d0c1 -> [mangoporn:mixdrop, mangoporn:playmogo].
2026-09-05 22:21:13 +02:00
goon-foss
ded80d3bc7 feat(sxyprn purge): tryb --strip, sceny zostaja bez tagow i performerow spoza tytulu 2026-09-04 11:27:21 +02:00
goon-foss
01298c9699 fix(sxyprn): blokada uploaderow ze spamem obsady/tagow + dopasowanie nazwisk bez przedrostka w obie strony
- person_named_in: jednowyrazowe nazwisko tylko co do znaku (albo zlepek sasiednich
  czlonow tytulu), przedrostek co najwyzej o 2 znaki; "Thebdck" lapal kazde "the"
- sxyprn: autor z sciezki miniatury, _BLOCKED_AUTHORS (Shizuka, Ajx, Denzel),
  karty z >20 hashtagami wchodza bez tagow
- scripts/sxyprn_purge_uploaders.py: dry-run/apply, sceny tych autorow + smieciowi performerzy
2026-09-04 11:09:37 +02:00
goon-foss
9c988bc12a feat(mobile): przypinanie stron w zakladce Sites + filtry zapamietywane per sekcja
Dwie prosby z apki 2026-09-02.

Sites: dlugie nacisniecie przypina albo odpina strone (gwiazdka i akcent na ramce),
przypiete ida na gore listy w tej samej kolejnosci co reszta, takze przy szukaniu i przy
sortowaniu "Recent". Stan po stronie serwera (/favorites/sites), wiec przechodzi kodem
synchronizacji jak inne ulubione.

Filtry: `usePersistedState` w SecureStore, osobny klucz dla Scenes, JAV, Anime
(`filters_<sekcja>`) i Movies. Zapis dopiero po wczytaniu, zeby domyslne z pierwszego
renderu nie nadpisaly zapisanych; odczyt scalany z domyslnymi, zeby nowe pole w kolejnej
wersji dostalo wartosc. Zapytanie o liste czeka na wczytanie (`enabled`), bez podwojnego
strzalu. SecureStore ma ~2 KB na klucz; przy bardzo dlugiej liscie tagow zapis moze sie
nie udac i wtedy filtr zyje tylko do zamkniecia apki, ale ekran sie nie wywraca.

tsc: 11 bledow przed i po (baza). Wpis w changelogu 2026-09-02b.
2026-09-02 22:44:54 +02:00
goon-foss
f1b8b8d466 feat(favorites): przypinane strony (favorite_sites) z endpointami list/add/remove
Prosba z apki 2026-09-02: ulubione strony maja siedziec na gorze zakladki Sites. Po
stronie serwera jak reszta ulubionych, bo kod synchronizacji to device_id i tabele
kluczowane po nim przechodza na nowe urzadzenie razem. Origin bez klucza obcego
(zrodla nie maja tabeli po origin), za to przy dodawaniu sprawdzany w playback_sources,
a sam napis ograniczony wzorcem, zeby nie dalo sie wpisac smieci.

Migracja 0034 (create table, bez blokad). Sprawdzone TestClientem w kontenerze api:
dodanie, powtorne dodanie (created=false), lista w kolejnosci dodania, nieznany origin
404, zly wzorzec 422, usuniecie 204.
2026-09-02 22:44:35 +02:00
goon-foss
59b7b5a651 fix(movies): poprawki po code review scorera, merge_movies i rozdzielania
Scorer tytulu (`movie_score._title_signal`), na zywym ingescie:
* sklejanie ogonkow apostrofu obejmowalo KAZDA pojedyncza litere, wiec "watch a whore s
  tale" dawalo "watcha", a przedimek "a" nigdy nie wpadal do wypelniaczy; lustra z A/I
  na poczatku tytulu przestawaly sie laczyc. Teraz tylko s/t/d.
* rowne zbiory zlozone z samych liczb ("adult movie 2019" vs "porn film 2019" -> {2019})
  dawaly 1.0; teraz sufit.
* zaostrzenie "rozne slowa = sufit" zabijalo warianty zapisu: Vol./Volume, Pt./Part,
  Gang-Bang/Gangbang, literowki, liczbe mnoga. Skroty rozwijane, zlepki porownywane po
  zdjeciu spacji, slowa wlasne obu stron parowane jeden do jednego: liczba mnoga = 0.95,
  literowka tylko przy >= 0.92 i slowach od 4 liter. Prog 0.85 na slowie przepuszczal
  "sexy"/"sex", "intense"/"interns" i "infedele"/"fedele" (niewierna vs wierna), a
  porownanie calych posortowanych ciagow "girls only 4" vs "girls on girls 4".
Sprawdzone na 28 parach, w tym wszystkich przykladach z review.

`merge_movies`: ulubione i postep odtwarzania przenoszone na `keep` przed skasowaniem
`drop`. Oba maja FK ondelete=CASCADE, wiec kazde scalenie po cichu wymazywalo userowi
dane. Dotyczy tez dedup po TPDB.

Skrypt rozdzielania:
* `_istniejacy_film` dostaje pelna liste performer_id (jak resolver), nie tylko przekroj,
  ktory zawyzal Jaccarda (3 z 5 wspolnych dawalo 1.0); kandydaci z trigramow PLUS
  dokladne trafienie w title_normalized, bo LIMIT 50 gubil "control 2" wsrod sasiadow;
  wstepny test tytulu oszczedza zapytan o obsade; flush przed blokowaniem.
* klastry rozdzielone w tym samym filmie nie moga sie do siebie dolaczyc (klastrowanie
  juz orzeklo, ze to rozne filmy).
* przed przepisaniem kotwicy sprawdzenie, czy zbior refow nie zmienil sie w trakcie
  pobierania (wyscig miedzy shardami).
* dry-run pokazuje tez dolaczanie do istniejacych; liczniki liczone przy tworzeniu.
* sprzatanie dubli po `updated_at` (created_at jest cofane i nie nadaje sie na znacznik
  przebiegu), keep = wiecej refow, potem starsze pierwsze zobaczenie; try/except per
  film; --after-id; naiwny timestamp = UTC.
2026-09-02 22:37:32 +02:00
goon-foss
c4845e9f61 fix(unmerge): szukaj istniejacego filmu przed utworzeniem, sprzataj duble, dziedzicz created_at
Trzy skutki pierwszego przebiegu rozdzielania (2026-09-02, 5846 nowych filmow), oba
zgloszone z apki tego samego dnia:

1. Duble (a5cdda7f). Klaster poza kotwica dostawal ZAWSZE nowy film, choc "Control 2"
   z innego zrodla juz istnial: 798 nowych o tytule sprzed przebiegu, 408 dubli miedzy
   soba. Teraz najpierw `find_movie_candidates` + `score_movie_candidate` (ten sam
   resolver co ingest) i przy >= 0.85 refy ida do istniejacego. Tryb
   `--dedupe-created-after <ts>` scala juz utworzone przez `merge_movies` (691 scalonych).

2. Scorer przepuszczal jeszcze pary z roznymi slowami po obu stronach: "hardcore
   fucking sluts 2" vs "foot fucking sluts 2", "santa list gangbang" vs "gangbang d"
   (token_set 85-94, ze zgodnym studiem i rokiem auto-merge). Lustra nie zmieniaja
   tytulow, wiec: rowne zbiory slow po zdjeciu wypelniaczy SEO = 1.0, podzbior albo
   rozne slowa po obu stronach = sufit 0.6. Ogonki po apostrofie sklejane ("she s" ->
   "shes"), bo normalize() tnie apostrof na spacje. Lista dubli spadla z 941 do 691 i
   sa to same identyczne tytuly.

3. Falszywe "+N nowych" (1db95176). Nowy film dostawal created_at = teraz, a badge w
   ulubionych od lipca sumuje sceny i FILMY: 2196 filmow z obsada z dzisiejsza data
   zapalilo "+N" u kazdego z performerem w ulubionych, przy zerze nowych scen na liscie.
   Teraz created_at = pierwsze zobaczenie refow klastra; 5231 juz utworzonych
   poprawione w bazie tym samym kryterium.
2026-09-02 15:29:21 +02:00
goon-foss
6874eec076 fix(unmerge): flush po kazdym profilu, dopelnianie pol ze wszystkich luster, shardy
Pierwszy prawdziwy przebieg na "The Doctor" padl na kluczu glownym movie_performers:
helpery resolvera sprawdzaja "czy juz jest" SELECT-em, ktory bez flush nie widzi
wierszy dodanych przez poprzedni profil klastra, wiec ten sam performer z dwoch luster
wpadal dwa razy. Teraz flush po kazdym profilu; transakcja per film, wiec nieudany
film wycofuje sie w calosci i nic nie zostaje w polowie.

Dopelnianie pol (tylko braki) z kazdego pobranego lustra klastra, nie tylko ze wzorca:
wzorcem bywa paradisehill bez roku, a mangoporn ma rok i studio ("Doctor Adventures 14"
tracil rok 2013).

`--shard k/n` po hashtext(id) do rownoleglych kontenerow: 8204 filmy w jednym watku to
~16 h (40 filmow w 4,6 min w probie), w trzech ~6 h.

Wynik na "The Doctor": 16 refow -> 11 filmow, kotwica z jednym refem i rozdzialami,
"Granny's Doctor Games" z dokladnie swoimi dwiema stronami i embedami, 76 zwietrzalych
lub cudzych hosterow skasowanych z kotwicy, 115 przeniesionych do wlasciwych filmow.
2026-09-02 12:57:59 +02:00
goon-foss
ec38403e40 feat(movies): rozdzielanie zlepionych filmow + sufit dla tytulu-podzbioru w scorerze
Skrypt `scripts/movie_unmerge.py` rozdziela filmy, ktore stary scorer zlepil z kilku
tytulow (8204 filmy z >=2 refami z jednego zrodla, 31 299 refow). Musi pobierac strony
na nowo, bo rekordy zewnetrzne filmow trzymaja tylko {slug, html_len} / {hex_id,
html_len}, a ingest jest przyrostowy, wiec skasowane rekordy same sie nie odbuduja.

Dla kazdego filmu: refy pobierane przez konektory zrodel (paradisehill, dooplay x4,
freeomovie; reszta profil ze sluga), klastry union-find po wspolnym adresie embedu
(bez prefiksu zrodla, ten sam plik siedzi pod mangoporn/pandamovies/streamporn) albo
po nowej ocenie zlozonej >= 0.85. Klaster z najstarszym refem zostaje filmem
(zachowuje id, ulubione, postep), kazdy inny dostaje nowy film z refami, hosterami,
obsada, tagami i rozdzialami. Kotwica zostaje z hosterami potwierdzonymi przez swieze
strony; reszta z rodzin luster leci, bo pandamovies zdazyl zrotowac hostery (swieza
strona ma 0 z 4 kluczy z bazy), wiec to i tak zwietrzale linki.

Proba na "The Doctor" ujawnila druga luke scorera: po normalizacji zostaje samo
"doctor", a jedno slowo jest podzbiorem kazdego tytulu z tym slowem, wiec "granny s
doctor games" i "we need a doctor" dostawaly 100 i szly do kotwicy. Teraz podzbior z
prawdziwymi dodatkowymi slowami ma sufit 0.6, a przedrostki SEO luster ("watch cece
adult movie online free") sa zdejmowane przed testem, zeby prawdziwe lustra nadal sie
laczyly. Po poprawce "The Doctor" rozpada sie na 11 klastrow, kotwica to sam
paradisehill, a "Doctor Adventures 14" laczy swoje cztery lustra.
2026-09-02 12:54:42 +02:00
goon-foss
e5eb27228e fix(player): dolny pasek sterowania nad systemowym paskiem nawigacji
Zgloszenie 386ef7ab (Galaxy S25, Android 16, nawigacja trzema przyciskami): pasek
przewijania niewidoczny w pionie. `controlsBottom` mial `bottom: 0` i stale
`paddingBottom: 18`, bez marginesow systemowych. Od Androida 15 apka rysuje pod paskiem
nawigacji, wiec przy trzech przyciskach (~48 dp) scrubber ladowal pod nimi.

Dolozony `useSafeAreaInsets()`: dol plus lewy i prawy, bo w poziomie pasek przyciskow
siedzi z boku. Z gestami margines to kilka dp, wiec wyglad prawie bez zmian.
`SafeAreaProvider` juz owija apke, ten sam hook jest w nawigacji.

tsc: 11 bledow przed i po, zadnego w okolicy zmiany. Wpis w changelogu 2026-09-02a.
2026-09-02 12:26:20 +02:00
goon-foss
ca80ae44b6 fix(movies): nie lacz filmow rozniacych sie numerem serii (The Doctor = 9 filmow)
Zgloszenie 32100b94: "w pierwszym linku jest granny porn". Pod filmem "The Doctor"
siedzialo 55 zrodel z co najmniej 9 roznych filmow pandamovies (Doctor Adventures 14 i
21, Granny's Doctor Games, Pervy Doctor 9, The Doctor Is In 2, We Need a Doctor...).

Przyczyna: tytul porownywany przez `token_set_ratio`, ktore mierzy czesc wspolna, wiec
tytul bedacy podzbiorem drugiego dostaje pelne 100 ("the doctor" vs "the doctor is in
2", "fantasies" vs "dark fantasies 3"). Numerowane serie wychodza na 90-96, tez ponad
progiem auto-merge 0.85. Gdy zrodlo nie ma roku ani studia, o wszystkim decyduje sam
tytul i cala seria zapada sie w jeden wpis: "Her way to use Tinder 61" ma 102 strony,
"Women Seeking Women 56" 87. W bazie 1878 filmow ma >=2 rozne strony z pandamovies.

Dwie zasady oparte na numerze serii (ostatnia liczba w tytule, z pominieciem lat):
* oba tytuly maja numer i sie rozni -> twarde odrzucenie,
* numer ma tylko jedna strona -> tytul najwyzej 0.6, wiec sam nie wystarczy do
  auto-merge, ale ze zgodnym studiem i rokiem trafia do recznego przegladu.
Podzbior bez numerow zostaje bez kary celowo: "cece" vs "watch cece adult" to prawdziwe
lustro z przedrostkiem SEO, dla niego jest token_set.

Sprawdzone na 11 parach z bazy, w tym kontrolnych (lustro SEO bez kary, rok nie liczy
sie jako numer). Dziala tylko w przod; rozdzielenie juz polaczonych to osobna robota.

Osobno `scripts/browse_catchup.py`: jednorazowe nadrobienie N stron jednego browse-
scrapera z pliku. Powstal, bo `docker compose run -d ... python3 -` ze skryptem na
stdin nigdy nie startuje: przy -d stdin nie jest domkniety i python czeka w
nieskonczonosc. Tak "chodzilo" nadrabianie porndoe przez 2 dni bez jednego zapisu.
2026-09-02 12:26:20 +02:00
goon-foss
ad59baea09 docs(site): zsynchronizuj strone z rzeczywistoscia i dodaj link do zrodel
Na forum padlo pytanie, gdzie sa zrodla, bo na stronie nie ma do nich ani jednego
odnosnika. Sluszny zarzut przy projekcie, ktory sprzedaje sie jako otwarty. Dodane w
dwoch miejscach: stopka i lista linkow.

Liczby byly zawyzone. Prawdziwe na 2026-08-31, z bazy:
* sceny 3,8 mln -> 3,3 mln. Liczymy sceny z ZYWYM zrodlem (3 342 052), bo tyle user
  realnie widzi w apce. Wierszy w tabeli jest 4 046 223, ale czesc nie ma czego odtworzyc.
* performerzy 108k -> 110k, filmy 45k -> 46k, zrodla 46 -> 43 (w trzech miejscach).

Dodany wpis do "What's new" o sprzataniu martwych zrodel i naprawie odtwarzania, bo
ostatni byl z Chromecasta.

ZOSTAWIONE swiadomie: ostrzezenie o wersji 0.2.2. Wyglada na przeterminowane, ale nie
jest: instalacje starsze niz 0.2.2 byly podpisane kluczem, ktorego juz nie mamy, wiec
Android odmawia aktualizacji w miejscu i trzeba odinstalowac. Ta instrukcja z kodem
synchronizacji jest dla tych ludzi jedynym sposobem, zeby nie stracic ulubionych.

Plik w repo rozjechal sie z produkcja (repo mialo jeszcze 0.2.2, produkcja 0.3.0), bo
strone edytowano bezposrednio na serwerze. Teraz site/index.html to dokladnie to, co
stoi na goon-foss.org.
2026-08-31 09:59:34 +02:00
goon-foss
5e41285d44 fix(porndoe): scrape przez proxy, bo tub blokuje IP serwerowni (10 dni bez nowych scen)
Watchdog zglaszal "tube:porndoecom (browse) bez nowych tresci (173h, prog 48h)" i
potraktowalem to jako szum razem z alertami dla tubow, ktore faktycznie przestaly
publikowac. Bledna ocena: porndoe to nasze czwarte co do wielkosci zrodlo (356 tys.
zrodel), a ostatnia nowa scena wpadla 2026-08-21.

Przyczyna: porndoe zaczal oddawac HTTP 403 na IP serwerowni. Scraper dostawal strone
bledu, znajdowal zero linkow i konczyl sie bez wyjatku, wiec z zewnatrz wygladalo to na
"tub sie wyczerpal", a nie na awarie. Ten sam ksztalt pomylki co przy porntrex, gdzie
cicha zmiana markupu ukryla 6479 scen.

Sprawdzone 2026-08-31: bez proxy 403 i 0 scen, przez Bright Data ISP proxy 200 i 31 scen
z pierwszej strony, z datami z 30 sierpnia, dlugosciami i obsada. Wzorzec i uzasadnienie
kosztowe jak przy superporn: ISP proxy jest ryczaltowe, wiec deep-crawl nie kosztuje
wiecej, a przez proxy leci sam HTML, nigdy strumien.

Bez skonfigurowanego proxy `crawl_page` zwraca pusta liste zamiast dobijac sie o 403.
2026-08-31 09:46:31 +02:00
goon-foss
dd87f646cc docs(changelog): wpis 2026-08-27a (vjav gra, odswiezanie zrodel 8kporner i perverzija, 81 tys. martwych zrodel usunietych) 2026-08-27 20:40:15 +02:00
goon-foss
ef43b61a12 fix(playback): odrzucaj nie-manifest z 200 i odswiezaj zrodlo dla 8kporner i perverzija
Sprawdzenie dwoch tubow, ktore w statystyce bledow odtwarzania wygladaly zle. Oba maja
inna przyczyne niz vjav i ZADEN nie jest awaria calego tuba.

**perverzija.** Naglowkowe "44% bledow" jest mylace: 132 ze 176 bledow pochodzi z awarii
13-18 sierpnia, ktora sama minela (te dni maja zero udanych odtworzen). Ostatnie 8 dni to
~16%. Wszystkie trzy sceny, ktore realnie padly u userow, resolwuja sie dzis poprawnie i
przechodza przez /proxy/hls jako prawidlowy manifest, wiec biezacej awarii nie ma.

Zostaje jednak realna dziura: hoster potrafi oddac 200 z trescia, ktora manifestem nie
jest (strona bledu albo reklama). Przepuszczalismy to dalej jako
`application/vnd.apple.mpegurl` ze statusem 200, a odtwarzacz konczyl komunikatem "Input
does not start with the #EXTM3U header" (203 zdarzenia w 14 dni). Gorsze od samego bledu
bylo to, ze przy statusie 200 apka uznawala odpowiedz za dobra i nie probowala odswiezyc
zrodla. Teraz w takiej sytuacji leci 502.

**8kporner.** Bledy to 502 i 400 z okcdn, rozlozone rownomiernie, przy stale dzialajacym
tubie. To zwietrzaly zapisany adres, ten sam wzorzec co watchporn. Potwierdzone na
czterech scenach, ktore padly u userow: swiezy resolve kazdej z nich oddal 206 z trescia.
Jeden przypadek w tej probce byl bledem DNS po stronie klienta, nie naszym.

Stad oba origins dopisane do `BACKEND_RERESOLVE_ORIGINS`, czyli mechanizmu juz dzialajacego
dla watchporn i galaxyporn.

Sprawdzone na prodzie po wdrozeniu: prawdziwy manifest dalej przechodzi (200, 58 kB),
a odpowiedz nie bedaca manifestem dostaje 502 zamiast 200 ze smieciami.
2026-08-27 20:36:24 +02:00
goon-foss
b84ff5bdbc fix(vjav): powtorz pobranie manifestu z ciasteczkiem wyzwania (100% bledow -> gra)
vjav mial 587 bledow odtwarzania i ZERO udanych, przez caly czas, odkad zbieramy dla
niego zdarzenia. Kazdy blad to ten sam komunikat odtwarzacza: "Source error Response
code: 429". Dotyczylo to 58 779 zrodel, czyli tub nigdy ani razu nie zagral.

Siec TXXX dlawi ruch z serwerowni: zamiast tresci oddaje 429 i mikroskopijna strone,
ktora w JS ustawia jedno ciasteczko i przeladowuje sie. Wartosc stoi wprost w tej
odpowiedzi, wiec wystarczy ja odczytac i powtorzyc zapytanie, bez wykonywania JS.

Ekstraktor vjav robil dokladnie to od 2026-08-21, ale TYLKO przy `videofile.php`. Adres
manifestu oddawany odtwarzaczowi tej sztuczki nie dostawal, a `/proxy/hls` pobiera go z
VPS przy KAZDYM odtworzeniu. Stad komplet bledow.

To dlatego bug byl niewidoczny z zewnatrz: `try_extract` konczyl sie sukcesem, wiec w
kazdym pomiarze zrodla vjav wychodzily jako zywe (149 na 150), podczas gdy u userow nie
dzialalo nic. "Zywe" znaczy "ekstraktor zwrocil adres", a nie "to gra".

Sprawdzone na prodzie: przed poprawka /proxy/hls oddawal 429, po poprawce 200 i poprawny
#EXTM3U z segmentami na vjav0.ahcdn.com. Ponowienie odpala sie wylacznie przy 429 z
odpowiedzia ponizej 4 KB, wiec nie dotyka pozostalych hosterow.

Osobno potwierdzone, ze blokada jest po adresie IP: ta sama strona z VPS oddaje 429, a
przez proxy z innym IP 200 i 72 KB tresci.
2026-08-27 19:23:55 +02:00
goon-foss
4df82b0bbe fix(reap): dodaj brakujacy modul sondy do repo
`.gitignore` ma regule na `_*.py` (pliki robocze), przez co wspolny modul sondy wypadl z
poprzedniego commita i zostaly w nim dwa skrypty importujace nieistniejacy modul.
2026-08-27 15:31:03 +02:00
goon-foss
f10d999410 feat(reap): generyczny przemial martwych zrodel z bramka, ktora tub musi zdac
Uogolnienie `sxyprn_reap_dead.py` na pozostale tuby. Werdykt bierze sie z `try_extract`,
czyli z kodu, ktorego uzywa aplikacja przy odtwarzaniu, wiec sonda nie widzi czegos
innego niz user. Martwe pada TYLKO na 404/410 albo `HosterDead`; pusty wynik ekstraktora
to "nieznane" i zrodla nikt nie rusza.

Bramka odpala sie ZA KAZDYM URUCHOMIENIEM, zamiast polegac na tym, ze ktos pamieta wynik
wczesniejszego pomiaru. Sprawdza jednoczesnie, czy sonda widzi smierc (adresy scen, ktore
nie moga istniec, musza wrocic martwe) i czy nie zabija zywych (sceny odegrane w ostatnich
7 dniach musza wrocic zywe). Nie zdana bramka konczy skrypt bez tkniecia bazy.

Wynik pierwszego pomiaru: przemial ma sens dla hqporner (~29%) i galaxyporn (~14%).
porntrex, xvideos, neporn, supjav i reszta soft-404 odpadaja, bo sonda ich smierci nie
widzi. youperv odpada, bo oddaje martwe na 25% scen, ktore realnie sie odegraly.

Trzy rzeczy, ktore wyszly dopiero przez kontrole, nie przez czytanie kodu:

* Pierwsza wersja sondy pytala tylko o status HTTP i uznala SXYPRN za tuba, na ktorym nic
  nie widac (0/6), chociaz wlasnie wygasilismy tam 34% zrodel. Sxyprn oddaje skasowany
  post jako HTTP 200, a `HosterDead` leci dopiero z ekstraktora. Po przejsciu na
  `try_extract` negatywna daje 7/7.
* Kontrola pozytywna przy 12 probach byla za ciasna: jeden trup to 8% i przewracal prog
  5%, chociaz realny szum galaxyporn to ~3%. Stad 40 prob i okno 7 dni zamiast 14, bo im
  swiezsze odtworzenie, tym mocniej "martwe" wskazuje na nasz blad, a nie na kasowanie.
* Budowanie adresow nieistniejacych przez podmiane cyfr nie dzialalo dla tubow BEZ numeru
  sceny w adresie (hentaigasm, mypornerleak, freshporno). Wychodzily jako "0/0", czyli
  niezmierzone, a to okolo 950 tys. zrodel. Teraz kolejno: numer w query, segment-
  identyfikator, a dopiero na koncu slug. Kolejnosc jest istotna, bo psucie segmentu
  trasy (`videos`) sprawdzaloby, czy tub obsluguje adres, a nie czy istnieje scena, wiec
  tub oddajacy 404 na zla trase i 200 na nieistniejaca scene falszywie zdalby bramke.

Sonda siedzi w `_reap_probe.py` wspolnym dla pomiaru i przemialu, zeby bramka testowala
dokladnie ten kod, ktory potem mieli baze.
2026-08-27 15:30:54 +02:00
goon-foss
bbdff6bc90 feat(reap): pomiar martwych zrodel per tub, z kontrola pozytywna i negatywna
Po sxyprn (34% martwych na 188 tys. zrodel) nasuwa sie przemielenie tak samo calej
reszty. Metoda sxyprn nie przenosi sie wprost:

* Twardy sygnal smierci (`HosterDead`) ma tylko 6 z 42 ekstraktorow. Dla reszty "nic nie
  zwrocil" znaczy naraz "skasowane", "zmienil sie markup" i "rate limit". Wygaszanie na
  tej podstawie schowaloby ZYWE sceny, jak przy porntrex (6479 scen po cichej zmianie
  markupu).
* Zywych zrodel jest ~3,9 mln, czyli ponad tydzien ciaglego pukania do wlasnej podazy.

Dlatego najpierw pomiar. Skrypt jest wylacznie do odczytu, niczego nie oznacza.

Martwe pada tylko na twardych sygnalach: HTTP 404/410 albo `HosterDead`. Pusty wynik
ekstraktora to "nieznane" i zrodla nikt nie rusza.

Kazdy tub dostaje DWIE kontrole, bo sam wynik proby niczego nie dowodzi: "0% martwych"
znaczy albo "tub zdrowy", albo "nie widzimy trupow".

* Pozytywna: zrodla scen, ktore realnie odegraly sie w ostatnich 14 dniach. Musza wrocic
  zywe. Lapie tuba, ktory zaczal oddawac 404 wszystkiemu bez sesji, czyli przypadek, w
  ktorym przemial skasowalby caly katalog. Prog to 5%, nie zero, bo czesc scen znika
  naprawde. Odsetek z kontroli odejmuje sie od wyniku proby.
* Negatywna: prawdziwy adres z numerem sceny podmienionym na absurdalny. Taka strona nie
  moze istniec, wiec musi wrocic martwa.

Negatywna od razu sie oplacila: pierwsza wersja pytala tylko o status HTTP i uznala
sxyprn za tuba, na ktorym sonda jest slepa (0/6), chociaz wiemy, ze ma tam 34% trupow.
Powod: sxyprn oddaje skasowany post jako HTTP 200, a `HosterDead` leci dopiero z
ekstraktora. Po przejsciu na `try_extract` negatywna daje 7/7.

Tuby resolvowane tylko w WebView na telefonie sa pomijane, bo z VPS sa nieweryfikowalne.
2026-08-27 14:36:48 +02:00
goon-foss
ecca52b8bd perf(sxyprn): wznawiaj reaper od granicy zamiast mielic potwierdzone zywe
Po wznowieniu przerwanego przebiegu pierwsza porcja dala 498 zywych i ZERO martwych,
przy sredniej 35%. Powod: przebieg idzie `ORDER BY id`, a poprzedni zdazyl sprawdzic
151 tys. pozycji. Martwe wypadly ze zbioru, ale 100 763 POTWIERDZONYCH JAKO ZYWE
zostalo i skrypt sprawdzal je po raz drugi. To 73% pracy i tyle samo zbednych zadan do
sxyprn, czyli dokladnie to, czego chcialem uniknac przy tym zrodle.

Dolozony `--after-id`: pomija wszystko o id <= podanego. Granice bierze sie z bazy jako
najwyzsze id wsrod juz oznaczonych (`max(id::text)::uuid`), sposob zapisany w komentarzu
przy zapytaniu.

Sprawdzone, ze granica jest wlasciwa: przed nia 0 martwych na 500, zaraz za nia
6 martwych na 10. Do zrobienia zostalo 37 391 zamiast 138 154.
2026-08-27 12:08:24 +02:00
goon-foss
9219c94c5a fix(sxyprn): reaper porcjami, z widocznym postepem i odporny na restart workera
Przebieg po 151 tys. sprawdzonych zrodel przepadl, bo sam ubilem go wlasnym
`docker compose restart worker` przy wdrazaniu poprawek seekplayera. Log urwal sie
o 06:54, kontener wystartowal o 06:58. Proces zostal jako pusta skorupa (jeden watek,
zero postepu), a wygladalo to na zawieszenie: sxyprn odpowiadal normalnie w 0,2 s,
baza byla bez blokad.

Trzy zmiany, kazda usuwa jedna slabosc, ktora to ukrywala albo pogarszala:
* PORCJAMI po 500, ze swieza pula na kazda porcje. `pool.map` na calej liscie
  kolejkowal 188 tys. zadan naraz, wiec przerwanie w polowie nie zostawialo zadnego
  punktu zaczepienia, a pamiec rosla bez potrzeby.
* JEDEN `UPDATE` na porcje zamiast jednego na wiersz.
* `flush()` po kazdej porcji. Log do pliku byl buforowany i milczal godzinami, przez co
  martwy przebieg wygladal identycznie jak pracujacy.

W docstringu jawne ostrzezenie: to zadanie NIE MOZE chodzic w kontenerze workera, bo
kazdy deploy backendu je ubija. Odpalac przez `docker compose run -d --rm worker`,
czyli w osobnym kontenerze. Wznawianie jest darmowe, bo bierzemy tylko zywe zrodla.

Stan: 52 998 zrodel juz oznaczonych martwymi, wznowione na pozostalych 138 154.
2026-08-27 12:04:25 +02:00
goon-foss
1fd1a750e3 chore(seekplayer): wygaszona cala rodzina dla filmow + reaper zgodny z resolverem
Na prosbe Jana wygaszone hurtem 338 904 zrodla rodziny seekplayer (easyvidplayer,
embedseek, seekplayer, upns, player4me, rpmplay) na 26 248 filmach.

Dowody, ktore to uzasadnily, trzy niezalezne:
1. Swieze identyfikatory wziete PROSTO ze strony serwisu tego samego dnia zachowuja sie
   tak samo jak nasze z bazy: brak adresu albo 404. Czyli to NIE jest nasza staroc,
   tylko hoster nie serwuje (przy galaxyporn ten sam test wychodzil odwrotnie).
2. Pomiar per marka, po 6 probek na kazda: 0 grajacych na 36.
3. Rozkodowany ich wlasny player: komunikat "Media source is not playable" pada dopiero
   po wyczerpaniu WSZYSTKICH czterech sciezek CDN (Ce.shift() az do pustej listy). Czyli
   ich klient tez sie poddaje.

Zabezpieczenie: NIE wygaszono zrodel filmu, ktory nie ma nic innego (4 zrodla, 1 film),
zeby nic nie zniknelo z katalogu. Po operacji 0 filmow stracilo wszystkie zrodla, a te,
ktore mialy ta rodzine, maja srednio 10,6 zywych zrodel.

Odwracalne: ustawiamy dead_at + czytelny dead_reason, nie kasujemy wierszy.

Skrypt: `_probe` deleguje teraz do `seekplayer_engine.extract`, zeby werdykt byl TEN SAM
co u resolvera aplikacji. Wczesniej mial wlasna, luzniejsza regule (sa pola = zywe) i
uznawal za zywe zrodla z adresem do usunietego pliku, na ktorych CDN oddaje 502.
Usuniete nieuzywane importy i akapit docstringu, ktory po tej zmianie mowil cos
przeciwnego niz kod.
2026-08-27 11:53:45 +02:00
goon-foss
38eee03902 fix(player): nie wstrzykuj naszego skryptu na stronach rodziny DoodStream
Zgloszenie Jana: myvidplay i easyvidplayer na filmach dalej nie dzialaly, mimo
wczorajszych poprawek backendu.

Backend byl OK, sprawdzone na zywo: myvidplay resolwuje sie teraz na playmogo.com, a
easyvidplayer oddaje 410 i znika z listy. Problem byl PO STRONIE APLIKACJI.

Playmogo (tam laduja myvidplay i doply) zmienil zabezpieczenie. Nasza notatka mowila, ze
to niewidzialny Turnstile przechodzacy automatycznie. To juz NIEPRAWDA: ich strona wola
turnstile.render z size flexible, czyli WIDOCZNY widget, i to dopiero po kliknieciu w
przycisk play, a po weryfikacji przeladowuje strone i dopiero wtedy pojawia sie pass_md5.

Nasz wstrzykiwany skrypt psul dokladnie ten przeplyw. Widget siedzi w kontenerze o klasie
captcha-player z z-index 100000, czyli w tym, co nasz usuwacz nakladek traktuje jak
reklame, a nasze auto-klikniecie w plakat odpalalo brame, zanim widget zdazyl sie
wyrenderowac. Do tego patchujemy prototypy HTMLMediaElement, co Turnstile potrafi uznac
za podejrzane srodowisko (dokladnie ten sam objaw mielismy na vikingfile).

Straz, ktora dotad obejmowala tylko vikingfile, obejmuje teraz cala rodzine bramkowana
Cloudflare. Na stronie pliku i tak nie potrzebujemy ani wyciszania autoplay, ani ad-blocka.

Pulapka po drodze, warta zapamietania: komentarz z backtickami wewnatrz literalu
szablonowego INJECTED_JS przerywa ten literal i wywala parsowanie calego pliku.

Zweryfikowane: regex po rozwinieciu szablonu pomija playmogo/myvidplay/doply/vikingfile/
d0000d/ds2play, a zostawia dzialajacy skrypt na galaxyporn, sxyprn i eporner; podobnie
brzmiacy goodplay.com sie nie lapie. Typecheck wrocil do bazowych 11 bledow.

NIE mam na to dowodu z urzadzenia: przejscie Turnstile trzeba potwierdzic na telefonie.
2026-08-27 09:10:16 +02:00
goon-foss
365c97a1de fix(seekplayer): sprawdzaj czy adres CDN naprawde odpowiada, plus obsluga sciezki Tiktok
Zgloszenie a405b9d6 "znowu galaxyporn nie dziala". Przyczyna byla inna niz sie wydawalo:
nasz resolver dzialal, tylko oddawal adres do pliku, ktorego juz nie ma.

Co ustalone przez reverse engineering ich bundla i pomiary:

1. Adres strumienia siedzi w KILKU polach, a serwis sam podaje w `streamingConfig`,
   ktorej sciezki uzyc i w jakiej kolejnosci:
   {Cloudflare: cf, Tiktok: hlsVideoTiktok, Google: hlsVideoGoogle, "In-House": source}.
   Czytalismy WYLACZNIE `source`, wiec sceny majace tylko `hlsVideoTiktok` traktowalismy
   jak martwe. A one graja: zweryfikowany manifest 1080p, 200 i z serwerowni, i z lacza
   domowego. Doszlo skladanie adresu wg ich regul (`/hls/` -> `/hlsmod/<domain>/` + params).

2. WAZNIEJSZE: adres do usunietego pliku wyglada normalnie, a CDN oddaje na niego 502.
   Sprawdzone: wezly 94.131.217.172/175/177 rotuja i WSZYSTKIE daja 502 dla jednego pliku,
   podczas gdy TEN SAM wezel .172 serwuje inny plik poprawnie. Czyli CDN zyje, brakuje
   konkretnych plikow. Oddawalismy taki adres jako dobry, aplikacja pokazywala czarny
   ekran, a ratunek z vikingfile nigdy sie nie uruchamial, bo lista zrodel nie byla pusta.
   Teraz kazdy kandydat jest sprawdzany (`_responds`) i zwracamy tylko ten, ktory
   naprawde odpowiada manifestem.

3. galaxyporn: gdy player mowi wprost, ze pliku nie ma, a na stronie NIE MA tez linku do
   vikingfile, przepuszczamy `HosterDead` dalej zamiast zwracac None. Wczesniej takie
   sceny dawaly 503 "sprobuj ponownie" i wracaly w kolko.

Weryfikacja, ze `_responds` nie odrzuca dzialajacych: scena `#puwcko` -> http=200,
responds=True, prawdziwy #EXTM3U (przyjeta); `#bgitoa` -> 502 na wszystkich wezlach
(odrzucona). Na probce 20 scen galaxyporn 85% trafia teraz do vikingfile zamiast dostawac
martwy adres. Scena ze zgloszenia zostala oznaczona martwa przez prawdziwy endpoint
resolve (410 zamiast czarnego ekranu).

Czego NIE zrobilem: vikingfile wymaga tokenu z WIDOCZNEGO checkboxa Cloudflare
(`POST <adres strony>` z `cf-turnstile-response=<token>`). Obchodzenia tego nie buduje.
2026-08-27 08:59:28 +02:00
goon-foss
62c1aaf250 feat(sxyprn): reaper martwych scen po sygnale "Post Not Found"
Obserwacja Jana: scena sxyprn z widoczna miniatura gra, bez miniatury w ~90% nie, i
jest sporo takich smieci. Sprawdzone i potwierdzone mechanicznie.

Miniatura sxyprn NIE jest brana z zapisanego thumbnail_url (te sa podpisane czasowo,
TTL < 1h, dawno wygasly), tylko z /proxy/sxyprn-thumb/<id>, ktory NA ZYWO pobiera
strone posta i wyluskuje poster. Odtwarzanie bierze data-vnfo z DOKLADNIE tej samej
strony. Skasowany post = pusty kafelek ORAZ brak streamu. Stad korelacja widoczna w apce.

Sygnal smierci jest twardy: usuniety post zwraca HTTP 200 ze strona "Post Not Found"
(soft-404), a ekstraktor rzuca wtedy HosterDead. Tylko to oznaczamy martwym. Strona
bez data-vnfo (niepewny przypadek) zostaje nietknieta.

Oznaczamy dead_at (odwracalne), NIE kasujemy wiersza: gdyby to byl chwilowy blad albo
sxyprn wskrzesil post, wystarczy wyzerowac znacznik. Efekt w UI ten sam co kasowanie.

Wolno i grzecznie (3 watki, 0,5 s odstepu), bo sxyprn to nasz TOP origin (~12 tys.
odtworzen miesiecznie) i nie chcemy bana. Przebieg testowy 5000: 65% zywych, 35%
martwych (1755 oznaczonych), 2 niepewne, 18 min, zero oznak rate-limitu.

Pelny przemiał 188 602 zywych zrodel uruchomiony odpiety w kontenerze.
2026-08-26 23:46:12 +02:00
goon-foss
c0a5d02ece feat(performers): pelny filtr scen na ekranie performera
Prosba 39314780: "Add similar filters to when we search for videos by pornstars".

Ekran performera dostaje TEN SAM modal co glowny feed (ScenesFilterModal), nie osobny:
jakosc, minimalna dlugosc, sortowanie, zrodlo/hoster, tagi, studia oraz "Also featuring"
(sceny takze z inna osoba). Chipsy top-tagow zostaja OBOK, jako jednodotykowy skrot na
najczestszy przypadek; ich wybor scala sie z tagami z modala.

Trzy roznice wzgledem glownego feedu, wszystkie zamierzone:
* domyslne sortowanie po dacie premiery (tak ten ekran dzialal od zawsze),
* prog dlugosci domyslnie 0 (wejscie w konkretnego performera nie ma ukrywac scen o
  nieznanej dlugosci, bo NULL nie przechodzi `>= 60`; wybor "5+ min" podnosi go jawnie),
* "Also featuring" zamiast "Performers": backend laczy performer_ids koniunkcja, wiec
  wybrani sa dokladani do biezacego performera przez AND, nie zastepuja go. Modal
  dostal na to prop `performersLabel`.

Licznik na przycisku liczy sie wzgledem domyslnych TEGO ekranu, inaczej has_playback=true
podbijaloby "Filter 1" na czysto.

Backend bez zmian: /scenes juz przyjmuje wszystkie te parametry.
2026-08-26 14:19:36 +02:00
goon-foss
ccba96376c fix(performers): "Search more scenes" odpowiada od razu, wyszukiwanie mieli w tle
Zgloszenie 23c64a28: "Search more scenes button not working". Przycisk DZIALAL, tylko
wygladal na zepsuty: endpoint byl synchroniczny i przy performerce z 871 scenami search
po ~25 tubach trwal dluzej, niz telefon utrzymuje polaczenie. W logu zostaly 4 starty
performer_driven bez ANI JEDNEJ domknietej odpowiedzi HTTP: kazdy tap konczyl sie
"Refresh failed", uzytkownik klikal znowu i szukania mielily sie rownolegle, a wyniki
i tak dopisywaly sie w tle, tylko bez zadnego sygnalu dla czlowieka.

Endpoint startuje teraz watek i odpowiada od razu (`status="started"`). Rate-guard 60s
bez zmian, `last_searched_at` idzie w gore PRZED startem watku, wiec chroni tez przed
zdublowaniem samego startu. Watek ma wlasna obsluge bledow, bo nie ma juz requestu,
ktoremu moglby oddac wyjatek.

Mobile: przy `status="started"` komunikat "Searching... new finds appear within a
minute or two" i odswiezenie listy po 60 s. Stare pola odpowiedzi zostaly, wiec klient
sprzed OTA pokaze "0 new scenes", co jest znosne przez kilkuminutowe okno do publikacji.

Zweryfikowane na produkcji: odpowiedz w 0,03 s, drugi strzal 429, watek tla skonczyl
po 30 s i zalogowal wynik.
2026-08-26 14:12:25 +02:00
goon-foss
360af9fabc fix(movies): rozwiazuj przekierowanie hostera na serwerze, zanim embed trafi do WebView
Zgloszenia 3b75cb54/11d7b17b/d3af0813: "Myvidplay nie dziala", czarny ekran.

Mechanizm to powtorka z galaxyporn (vikingfile -> vik1ngfile), tylko w drugiej galezi,
ktorej wtedy nie sprawdzilem: myvidplay.com i doply.net odpowiadaja 30x na playmogo.com,
a WebView w apce blokuje top-frame skok na inna domene (ochrona przed popunderami).
Telefon dostawal surowy adres myvidplay, przekierowanie bylo blokowane i zostawala czern.
Lekcja "sprawdz te sama pulapke w pozostalych galeziach" byla zapisana w pamieci po
galaxyporn; ta galaz wlasnie ja potwierdzila.

`resolve_movie_playback` rozwiazuje teraz przekierowanie po stronie serwera i UTRWALA
koncowy adres w `embed_url`, wiec kazdy adres kosztuje jeden fetch raz w zyciu, a po
utrwaleniu regex przestaje pasowac i krok jest darmowy. Fetch odpala sie WYLACZNIE dla
rodziny DoodStream (znanej ze zmiany szyldu co pare miesiecy); zwykle embedy ida bez
dotykania sieci. Blad hostera (5xx) nie jest utrwalany, zeby nie zabetonowac chwilowej
awarii jako nowego adresu.

Easyvidplayer z tych samych zgloszen NIE wymagal zmian: zrodla juz sa oznaczone
martwymi przez niedzielny mechanizm HosterDead, czyli samoleczenie dziala.

Zweryfikowane na wszystkich trzech zgloszonych filmach: resolve oddaje
https://playmogo.com/e/..., baza utrwalona, telefon rozpoznaje playmogo natywnie
(DOOD_HOSTS), a strona playmogo z tymi sciezkami byla sprawdzona 2026-08-24 (200,
markery Turnstile). Deploy backendu, bez OTA.
2026-08-26 14:00:56 +02:00
goon-foss
2c92fab2a9 feat(ota): wysylaj source mapy paczek OTA do Sentry
Bez tego mapy mial WYLACZNIE bundle wbudowany w APK, a praktycznie kazdy uzytkownik
dociaga paczke OTA przy pierwszym uruchomieniu i od tego momentu w Sentry widac
`app:///index.android.bundle` zamiast nazw plikow i numerow linii. Czyli przebudowa APK
z tokenem sama w sobie dawala czytelne slady na kilkadziesiat sekund zycia instalacji.

Trzy zmiany w `publish_update.py`:
* `expo export` z `--source-maps`
* `upload_sourcemaps()` woła oficjalny `expo-upload-sourcemaps.js` z @sentry/react-native.
  Dopasowanie idzie po `debug id` zaszytym w paczce, wiec nazwa release'u ani dist nie sa
  potrzebne. Brak `SENTRY_AUTH_TOKEN` NIE jest bledem, bo publikacja ma dzialac takze u
  kogos, kto stawia wlasna instancje; wtedy tylko o tym mowimy. Nieudana wysylka rowniez
  nie blokuje publikacji: gorsza jest apka bez poprawki niz nieczytelny slad.
* `strip_sourcemaps()` kasuje mapy z `dist` PRZED scp. Mapa wazy 11 MB przy 3 MB paczki i
  zawiera caly kod zrodlowy, a `scp -r .` wyslalby ja razem z reszta, wiec lezalaby
  publicznie obok paczki. `metadata.json` jej nie wymienia, wiec manifest sie nie psuje
  (sprawdzone).

Pulapka skryptu Sentry: siega po konfiguracje Expo, gdy brakuje KTOREGOKOLWIEK z
SENTRY_ORG, SENTRY_PROJECT i SENTRY_URL, i wtedy konczy bledem. SENTRY_URL nie jest
sekretem, wiec podstawiamy sentry.io. Musi tez dzialac z katalogu `mobile`, bo zaklada,
ze stoi w korzeniu projektu.

Publikuj przez `iclaude goon -- ...`, wtedy token wjezdza ze srodowiska.

Zweryfikowane end-to-end na katalogu testowym na VPS, bez ruszania zywych kanalow:
mapy poszly do Sentry (debug id e2feb975), zostaly usuniete z dist (11,1 MB), manifest
zbudowany z 23 assetami, a na serwerze wyladowalo 0 plikow .map.

Przy okazji ustalone: paczki juz stojace na wszystkich trzech kanalach maja identyczna
tresc z ta, ktorej mapy wyslalem, wiec slady dla obecnych uzytkownikow odczytaja sie bez
zmuszania kogokolwiek do ponownego pobrania 3 MB.
2026-08-26 10:23:09 +02:00
goon-foss
031c309091 fix(mobile): stary APK po OTA podawal sie za nowa wersje i nie dostawal monitu
Zgloszone przez uzytkownika: po wydaniu 0.3.0 nie dostal propozycji aktualizacji.

Przyczyna jest moja. `APP_VERSION` czyta `Constants.expoConfig.version`, czyli app.json,
a app.json przyjezdza do aplikacji RAZEM Z PACZKA OTA. Podbilem wersje na 0.3.0 i
opublikowalem te paczke rowniez na stary kanal 1.2, wiec kazdy uzytkownik APK 0.2.2
zaczal raportowac 0.3.0 i sam siebie uznal za aktualnego. Skutek podwojny: nie dostal ani
monitu o nowym APK, ani wpisu w changelogu, ktory byl kierowany dokladnie do niego.

Poprzednia poprawka (dad4bbf, porownanie "nowsza" zamiast "rozna") tego NIE naprawiala:
przy dwoch identycznych numerach oba warunki daja falsz.

Jedyny numer odporny na OTA to runtime z AndroidManifest, bo expo-updates uzywa go do
sprawdzania zgodnosci paczek i nie moze brac go z paczki. Stad `NATIVE_RUNTIME`
w `src/lib/appVersion.ts`, a serwer oddaje teraz `runtime_version` obok `version`.
Porownanie leci po runtime, z zapasowym porownaniem wersji, gdy serwer go nie poda.
Bramka wpisow w changelogu przeszla z `onlyBelowVersion` na `onlyBelowRuntime`.

`expo-application` bylby czystszym zrodlem natywnej wersji, ale to modul natywny, wiec
nie wszedlby przez OTA do ludzi na starym APK, czyli dokladnie do tych, ktorych to
dotyczy. Runtime dziala na tym, co juz maja zainstalowane.

Przy okazji tresc dialogu: pokazywala "Server: 0.3.0, App: 0.3.0", czyli dwie identyczne
liczby, bo drugi numer byl z paczki, a nie z APK. Teraz nie pokazujemy numeru
zainstalowanej wersji, bo po OTA nie mamy wiarygodnego.

Zweryfikowane na emulatorze w obie strony, na podpisanych APK:
* APK 0.2.2 (runtime 1.2) po pobraniu paczki: monit jest, tresc czytelna.
* APK 0.3.0 (runtime 1.3): monitu nie ma.
Instalacja 0.3.0 na wierzch 0.2.2 przeszla ponownie.
2026-08-24 14:47:04 +02:00
goon-foss
bdac60f022 feat(mobile): Chromecast (0.3.0, runtime 1.3)
Zgloszenie de93ba42 ("Can't stream to Chrome cast"). Cast to natywny SDK, wiec NIE da
sie go wyslac przez OTA: to nowe APK, ktore ludzie musza pobrac sami. Klucz podpisujacy
ten sam, wiec ALLOWED_APP_SIG_HASH bez zmian i aktualizacja wchodzi na wierzch starej.

Argument, ktory przewazyl: od czerwca release ma wlaczone FLAG_SECURE, wiec zwykle
mirrorowanie ekranu na telewizor jest zablokowane. Cast jest jedyna droga na duzy ekran.

Zasieg: 77% udanych odtworzen konczy sie bezposrednim adresem, plus czesc hosterow
filmowych oddajacych gotowy plik. Okolo 13% (porndoe, mypornerleak, superporn) idzie
przez WebView i castowac sie NIE DA, tam przycisku nie ma.

Wpiete recznie, bez `expo prebuild`, bo katalog android/ jest w repo i ma reczne zmiany,
ktore prebuild by przepisal. Cztery edycje, dokladnie to co robi config plugin:
manifest (OPTIONS_PROVIDER + expanded controller), MainActivity, castFrameworkVersion
w rootProject.ext oraz JAWNA zaleznosc play-services-cast-framework w app/build.gradle.
Ta ostatnia jest konieczna, bo biblioteka deklaruje ja jako `implementation`, co nie
jest przechodnie na sciezke kompilacji, i bez niej build pada na compileReleaseKotlin.

Identyfikator odbiornika CELOWO zaden: bez wpisu biblioteka bierze domyslny odbiornik
Google. Rejestracja wlasnego wiazalaby konto Google z Goonem, a przy tresci dla
doroslych latwo o cofniecie.

Urzadzenia bez Google Play Services zabezpieczone w DWOCH miejscach, bo mamy uzytkownika
na LineageOS (GOON-3B) i sideload na zegarku (GOON-51): `getSharedInstance` w try/catch
po stronie natywnej, a po stronie JS cala obsluga w osobnym `CastControl`, montowanym
dopiero gdy `useCastAvailable` potwierdzi Play Services. To nie jest ostroznosc na
wyrost: hooki biblioteki synchronicznie subskrybuja natywne zdarzenia i bez GMS wywracaja
ekran, a hooka nie da sie wywolac warunkowo.

Chromecast pobiera plik SAM i nie wysyla naszych naglowkow, wiec zrodla wymagajace
Referera ida przez /proxy (dokłada go po stronie serwera), a reszta bezposrednio, zeby
nie obciazac VPS. Do tego proxy przestaje przekazywac CORS hostera: mixdrop oddawal
`Access-Control-Allow-Origin: https://mixdrop.my`, co odbiornik odrzucal przy HLS.

Przy okazji GOON-3B: doszedl brakujacy wpis `com.google.android.gms.version`, ktory
wywracal apke na LineageOS, gdy Chromium probowal podpiac skaner kodow.

Zweryfikowane na emulatorze na podpisanym APK: certyfikat identyczny ze starym
(3294aba5...), instalacja NA WIERZCH 0.2.2 udana, wersja 13/0.3.0, runtime 1.3 w
manifescie, aplikacja wstaje bez crasha, ExoPlayer gra (MediaCodec dla video), a SDK
Casta startuje poprawnie: dynamite sie laduje i `MediaRouterProxy` czyta NASZ CastOptions.
Logika wyboru adresu przetestowana osobno na osmiu przypadkach.

CZEGO NIE ZWERYFIKOWALEM: realnego rzutu na telewizor. W sieci emulatora nie ma zadnego
Chromecasta, wiec `loadMedia` i odtwarzanie po stronie odbiornika sa niesprawdzone.

Build wymaga SENTRY_DISABLE_AUTO_UPLOAD=true, bo sentry.properties czeka na
SENTRY_ORG/SENTRY_PROJECT/SENTRY_AUTH_TOKEN ze srodowiska (Infisical, workspace
goon-app). Skutek: slady JS z 0.3.0 beda w Sentry nieczytelne do czasu przebudowy z tymi
zmiennymi.
2026-08-24 14:27:41 +02:00
goon-foss
dad4bbf0ab fix(mobile): nie namawiaj na instalacje STARSZEJ wersji
Apka porownywala wersje z serwerem przez `!==`, a nie "nowsza niz moja". Serwer trzyma
numer najnowszego APK na sztywno w `app/main.py`, wiec w momencie, gdy aplikacja
wyprzedza serwer, KAZDY uzytkownik nowej wersji dostawal monit "Update available" z
propozycja zejscia w dol.

Zlapane na emulatorze przy weryfikacji 0.3.0: swiezo zainstalowana 0.3.0 kazala
zainstalowac 0.2.2. Bez tej weryfikacji poszloby to do ludzi.

Wspolny `isNewerVersion` w `src/lib/appVersion.ts`, uzywany teraz i przez monit o APK,
i przez bramkowanie wpisow w changelogu (byla tam druga kopia tej samej logiki).
Sprawdzone na przypadkach granicznych, w tym 0.3.10 wobec 0.3.9 i pusta odpowiedz
serwera.
2026-08-24 14:27:06 +02:00
goon-foss
b7a8911441 fix(sxyprn): koniec z przypisywaniem scen po samym imieniu
Zgloszenie e28f2517: scena "Bridgette B, Gigi Rivera, Julia Ann, Phoenix Marie, ..."
miala przypisana Julie B., ktorej w tytule nie ma.

Mechanizm: scraper wola strone performera i sprawdza, czy scena naprawde go dotyczy,
porownujac czlony nazwiska z tytulem. Robil to jednak KAZDY Z OSOBNA i tylko od trzech
znakow. Dla "Julia B." czlon "b." odpadal na dlugosci, zostawalo samo "julia", i
przypisanie lapalo kazda scene z tym imieniem w tytule. Stad tez "Chloe S" przy
"Chloe Kreams", "Sharon D" przy "Sharon White", "May E" przy "Danielle Maye".

Nowa regula (`person_named_in` w `app/normalize/text.py`): czlony musza stac OBOK
SIEBIE i w tej samej kolejnosci. Od trzech znakow wystarczy zgodnosc przedrostka, zeby
przezyly warianty zapisu ("Savanna Bond" przy tytule "Savannah Bond", "Jyn Maze" przy
"Jynx Maze"). Inicjaly musza zgadzac sie dokladnie. Tytul tniemy wczesniej na segmenty
po przecinkach i myslnikach, bo bez tego smieciowy performer "Rope Bondage" dopasowywal
sie do doklejonego ogona tagow "- rope - bondage".

Swiadomy koszt: odwrocona kolejnosc ("Goldy Kim" wobec "Kim Gold") nie przejdzie, a
performer z inicjalem zamiast nazwiska nie dostanie ze sxyprn nic. Przy wyborze miedzy
pominieciem sceny a przypisaniem jej niewlasciwej osoby wolimy pominac, bo to bledne
przypisanie widac na ekranie performera i to ono jest zglaszane.

Smoke test po zmianie: Lana Rhoades, Julia Ann, Angela White i Abella Danger dalej po
30 scen ze strony performera, wiec scraper sie nie zamrozil (to sie juz raz zdarzilo
przy zbyt ostrym filtrze). "Julia B." oddaje teraz zero zamiast cudzych scen.

Dolozony `scripts/sxyprn_fix_overattribution.py`, ktory stosuje te sama regule wstecz.
Uruchomiony na produkcji: skasowane 85 379 z 239 686 przypisan (35,6%). Ruszamy tylko
sceny majace WYLACZNIE zrodlo sxyprn i tylko przypisania od scrapera; kanon
(tpdb/stashdb) nietkniety. Regula sprawdzona recznie na dwoch probkach po ~20 pozycji,
wszystkie kasowane byly faktycznie bledne.

Skutek uboczny: scen bez zadnego performera przybylo z 3 622 do 23 891. To sceny,
ktorych jedyne przypisanie bylo bledne, wiec brak jest uczciwszy niz cudze nazwisko.
2026-08-24 13:49:31 +02:00
goon-foss
5a48175a85 fix(movies): przestan podsuwac hostery z rodziny seekplayer, ktore nie maja plikow
Zgloszenia z 2026-08-24: "Easyvidplayer nie chce odpalic" (ccbc75da), "Embedseek tez
nie rusza" (d59fc396) i sasiednie. To NIE byl nasz blad parsowania.

Co ustalone: `/api/v1/video` odpowiada 200 i poprawnie sie odszyfrowuje, tylko nie ma
w odpowiedzi pola `source`. W ich wlasnym bundlu warunek jest doslownie taki sam i
konczy sie komunikatem "Video is not ready yet". Potwierdzone w przegladarce z lacza
domowego (plakat obok oddal 502), z VPS i przez brytyjskie proxy, wiec to nie blokada
po IP ani po kraju. Protokol sie nie zmienil, hoster po prostu nie ma juz plikow.

Skala na 250 losowych zrodlach: 3 zywe, 88 razy HTTP 404, 79 razy brak `source`.
Czyli okolo 2% jeszcze gra.

Dwie zmiany:
* `seekplayer_engine.extract` rzuca `HosterDead` zamiast zwracac None. Dopiero wtedy
  `resolve_movie_playback` oznacza zrodlo martwe. Zwracanie None dawalo WebView z tym
  samym komunikatem, w kolko, i wlasnie to zglaszali uzytkownicy. Katalog czysci sie
  teraz sam, w miare jak ludzie po nim chodza.
* De-ranking calej rodziny w `_movie_origin_priority`, zeby na wierzch wychodzily
  hostery, ktore graja. Nie usuwamy ich z listy, bo te 2% bywa jedynym zrodlem.

Bezpieczne: filmow zaleznych WYLACZNIE od tej rodziny jest w bazie jeden, pozostale
26 223 maja alternatywe.

Dolozony `scripts/seekplayer_reap.py` do sprzatania calosci na zadanie. NIE odpalam go
w tle: przy limitach hostera (32% odpowiedzi to 429) pelny przemial to kilkadziesiat
godzin ciaglego pukania, co grozi zablokowaniem naszego IP dla prawdziwych resolve.

Zweryfikowane na filmie ze zgloszenia: piec zrodel seekplayer oznaczonych martwymi,
zostaly mixdrop (206 video/mp4) i voe (200 m3u8), oba sprawdzone spoza serwerowni.
2026-08-24 13:39:40 +02:00
goon-foss
3c8bcada84 feat(mobile): otaguj w Sentry, ktora paczke JS odpalil uzytkownik
Bez tego kazdy crash jest nie do zinterpretowania: `release` bierze sie z
`app.json` i przy publikacji OTA sie NIE zmienia, wiec raport sprzed poprawki
wyglada identycznie jak raport po niej.

Konkret: GOON-1S (crash na `maintainVisibleContentPosition`) wrocil jako
regressed, a propa nie ma w zrodle od 1e6be19 i nie ustawia go zadna biblioteka.
Nie dalo sie rozstrzygnac, czy poprawka nie dziala, czy to ogon starych
instalacji. Grep po bundlu tez nie pomaga, bo Hermes trzyma jeden egzemplarz
kazdego napisu, a ta nazwa siedzi w samym React Native.

Cztery tagi: id paczki (albo `embedded` gdy leci bundle z APK), data jej
publikacji, runtime i flaga awaryjnego startu. Ta ostatnia rozroznia "user siedzi
na starym kodzie" od "paczka OTA nie dala sie zaladowac i apka sie cofnela".
2026-08-23 21:25:44 +02:00
goon-foss
c9fa6edc6d feat(mobile): scalaj wiele duplikatow naraz zamiast parami
Zgloszenie c7f5e59e: "przydalaby sie mozliwosc zaznaczania wiecej niz 2 video
przy duplikatach". Przy pieciu kopiach tej samej sceny stary uklad wymagal
czterech przebiegow, kazdy z osobnym dialogiem, i za kazdym razem trzeba bylo
na nowo wskazac oryginal.

Model jest teraz odwrocony: dlugie przytrzymanie wybiera scene, ktora ZOSTAJE
(plakietka KEEP), a kolejne tapniecia zaznaczaja i odznaczaja jej duplikaty
(plakietka DUPLICATE, kafelek gasnie). Banner liczy zaznaczone i scala wszystkie
jednym przyciskiem. Dzieki temu uzytkownik swiadomie wybiera te kopie, ktorej
tytul i miniaturka przetrwaja, zamiast trafiac w nia przypadkiem.

Backend bez zmian, endpoint scala parami, wiec lecimy petla. Kazde scalenie to
osobna transakcja, wiec czesciowa porazka nie cofa udanych i raportujemy ja
wprost zamiast udawac sukces.

Sprawdzone na emulatorze na opublikowanej paczce: pozycja w menu, plakietki
KEEP i DUPLICATE, licznik 2 -> 1 -> 2 przy odznaczaniu, tresc potwierdzenia
z lista scen. Samego scalania nie wykonalem, to sa prawdziwe sceny z produkcji.
2026-08-23 21:25:32 +02:00
goon-foss
646f03d7be test(player): nie wstrzykuj naszego skryptu na stronach z Turnstile
Kolejny podejrzany o negatywna weryfikacje Cloudflare: nasz wlasny INJECTED_JS.
Wstrzykujemy go PRZED zaladowaniem strony, a on podmienia gettery i settery na
prototypie HTMLMediaElement (wyciszanie autoplay) oraz usuwa elementy uznane za
reklamy. Turnstile potrafi uznac tak zmodyfikowane srodowisko za podejrzane.

Na hostach vikingfile konczymy teraz wykonanie zaraz po naszym bloku: zostaje tylko
podswietlenie ramki z zabezpieczeniem, meldunek needs_interaction i podsluch POST-a
z bezposrednim linkiem. Reszta patchy sie nie wykonuje. Na stronie pliku i tak nie
potrzebujemy ani wyciszania autoplay, ani ad-blocka.

To jest TEST HIPOTEZY, nie potwierdzona naprawa. Nie udalo mi sie odtworzyc bledu u
siebie: w Chrome na emulatorze, na tej samej sieci, weryfikacja przechodzi zarowno w
trybie mobilnym, jak i desktopowym. Do apki na emulatorze nie dojde ta scena, bo ma
duration NULL i w glownym feedzie jest ukryta progiem 60 s, a przez strone aktorki
trzeba by przewinac 701 pozycji.

Przy okazji: naprawiony pojedynczy backslash w regexie hosta (szablon go zjadal) i
zaktualizowany komentarz naglowkowy, ktory dalej opisywal chowanie strony, usuniete
dwie zmiany wczesniej.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:35:42 +02:00
goon-foss
2071b36311 fix(player): nie podstawiaj desktopowego UA hosterom z Cloudflare
Dla hosterow z zabezpieczeniem Cloudflare (vikingfile, playmogo, klony DoodStreama)
przestajemy nadpisywac User-Agent i zostawiamy wlasny UA WebView. Dotad podstawialismy
Chrome na Windows, podczas gdy silnikiem jest Android WebView, a Cloudflare porownuje
deklaracje ze srodowiskiem.

UCZCIWE ZASTRZEZENIE: to NIE jest potwierdzona przyczyna. Test A/B w Chrome na
emulatorze (ta sama siec co telefon): tryb mobilny przechodzi weryfikacje, tryb
desktopowy TEZ przechodzi. Czyli sam UA nie wystarcza do odtworzenia bledu. Zmiane
zostawiam, bo zgodnosc UA ze srodowiskiem jest po prostu poprawniejsza, ale przyczyny
szukam dalej. Kolejny podejrzany: nasz INJECTED_JS, ktory patchuje prototypy
HTMLMediaElement przed zaladowaniem strony.

Potwierdzone przy okazji: strona i wyzwanie dzialaja na tej sieci, po klikniecu
checkboxa pojawia sie odtwarzacz i przycisk Download.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:27:24 +02:00
goon-foss
9fe6d5c1ed fix(galaxyporn): lap tez nowa pisownie domeny vikingfile
Regex w backendzie dopasowywal wylacznie vikingfile.com, mimo ze serwis rotuje
domena. Poszerzony na vik[i1]ngfile z dowolna koncowka. W aplikacji ten sam wzorzec
byl juz poszerzony, backend zostal w tyle.

Na 20 losowych scenach: 16 oddaje vik1ngfile.site, 4 nie maja zadnego zrodla (stary
player padl, a linku do vikingfile na stronie nie ma wcale). Te 4 to uczciwe 503, nie
awaria po naszej stronie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:10:40 +02:00
goon-foss
612558c880 fix(galaxyporn): rozwiazuj przekierowanie TAKZE w galezi z iframem
Wczorajsza poprawka objela tylko galaz zapasowa (link vikingfile w tresci strony), a
nie te, ktora obsluguje nowsze sceny, gdzie vikingfile JEST iframem. Polowa katalogu
dalej dostawala wiec stary adres vikingfile.com, ktory oddaje 302 na inna domene,
WebView to blokowal i zostawal czarny ekran.

Znalezione dopiero, gdy sprawdzilem KONKRETNA scene zglaszana przez uzytkownika
("Stella Cox passes on tennis") zamiast losowej probki: resolve oddawal
vikingfile.com, mimo ze losowe sceny testowane wczesniej oddawaly juz vik1ngfile.site.

Po poprawce na 10 losowych scenach: 9 oddaje vik1ngfile.site, 1 bez zrodla.
Kontrola pliku: 2,93 GB, nazwa zgodna ze scena.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:45:31 +02:00
goon-foss
46e7839fb2 fix(galaxyporn): czarny ekran to zablokowane przekierowanie na druga domene
Trzecia proba i tym razem z dowodem z emulatora, a nie ze zgadywania. vikingfile.com
oddaje 302 na INNA domene (dzis vik1ngfile.site). WebView w aplikacji celowo blokuje
przejscie top-frame na inny eTLD+1, zeby popundery nie przejmowaly ekranu, wiec to
przekierowanie bylo odrzucane i zostawal czarny ekran. Z serwera tego nie widac, bo
httpx sledzi przekierowanie po cichu; widac dopiero w przegladarce na urzadzeniu,
gdzie w pasku adresu stoi vik1ngfile.site.

Poprawki:
- ekstraktor oddaje adres DOCELOWY (rozwiazuje 302 przy kazdym resolve, bo domena
  zapasowa rotuje), wiec aplikacja od razu otwiera wlasciwa domene i zadna zmiana
  domeny w ogole nie zachodzi
- wzorzec hosta we wstrzykiwanym skrypcie obejmuje obie pisownie (vik[i1]ngfile.),
  wczesniej dopasowywal tylko vikingfile.com, wiec po przekierowaniu w ogole sie nie
  uruchamial

Przy okazji z emulatora widac, ze zabezpieczenie to CHECKBOX "Verify you are human",
czyli wymaga kliniecia. Dlatego strona musi byc widoczna i uzywalna, a nie schowana.

Opublikowane OTA na 1.2 i 1.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:03:54 +02:00
goon-foss
a1717e4b5e fix(player): czarny ekran na galaxyporn to byla 15-sekundowa zaslona
Zgloszenie: "galaxyporn dalej czarny ekran po Loading video". Zdiagnozowalem to
wczoraj zle. WebView ma nieprzezroczysta zaslone (coverOverlay z tlem motywu), ktora
znika dopiero po SZTYWNYCH 15 sekundach albo wczesniej, gdy uda sie wyluskac adres
wideo. Dla hostera, ktory wymaga kliknięcia, znaczy to 15 sekund gapienia sie w
spinner, a strony i tak nie widac.

Na to naloilem wczoraj wlasny blad: oswajajac strone vikingfile chowalem CALA jej
oprawe i zostawialem tylko widget na czarnym tle. Gdy widget sie nie wyrenderowal,
po zdjeciu zaslony zostawala czysta czern, czyli gorzej niz przed moja zmiana.

Dwie poprawki:
- strona vikingfile NIE jest juz chowana, tylko podswietlamy ramke z zabezpieczeniem
  i przewijamy do niej; najgorszy przypadek to normalna strona hostera
- gdy widget sie wyrenderuje, strona melduje to aplikacji (needs_interaction), a ta
  zdejmuje zaslone OD RAZU, zamiast czekac 15 sekund

Podsluch POST-a z bezposrednim linkiem zostaje: gdy zabezpieczenie przejdzie samo,
user w ogole nie zobaczy strony, tylko natywny odtwarzacz.

Opublikowane OTA na 1.2 i 1.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:52:33 +02:00
goon-foss
1f73dad7d3 feat(vikingfile): przechwytuj bezposredni link zamiast pokazywac strone
Jan slusznie zapytal, czy WebView jest w ogole potrzebny. Rozlozylem protokol do
konca: po przejsciu zabezpieczenia strona robi POST na WLASNY adres z cialem
'cf-turnstile-response=<token>' i dostaje JSON z polem link, czyli gotowy plik.
Endpoint to po prostu adres strony pliku, nie zadne osobne API.

Bez tokenu sie nie da: POST z pustym albo brakujacym cf-turnstile-response oddaje
zwykly HTML (701 kB), a nie JSON. Sprawdzone trzy warianty ciala. Tokenu nie
wyprodukuje klient HTTP, bo wymaga silnika JS. Ale NIE wymaga czlowieka: widget jest
w trybie flexible z callbackiem, wiec na czystym domowym laczu przechodzi sam.

Wykorzystujemy to: WebView zostaje jako silnik JS, tylko podsluchujemy ten jeden POST
i gdy w odpowiedzi pojawi sie link, oddajemy go aplikacji przez istniejacy kanal
video_url. Aplikacja przechodzi wtedy na NATYWNY odtwarzacz, bez reklam i bez
ogladania strony hostera. Oswojony wyglad strony zostaje jako zabezpieczenie na
wypadek, gdyby zabezpieczenie jednak zazadalo kliniecia.

Blok zwalidowany jako realny JavaScript (new Function po zamianie podwojnych
backslashy na pojedyncze, tak jak robi to szablon). Typecheck bez nowych bledow.

Opublikowane OTA na 1.2 i 1.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:46:13 +02:00
goon-foss
1154834170 fix(player): oswoj strone vikingfile, zeby nie wygladala na pusta
Zgloszenie 21d6f5af: galaxyporn laduje sie, a potem pusta strona, bez bledu.
Kierowanie bylo juz poprawne, problem byl wizualny. Vikingfile to STRONA PLIKU, nie
goly odtwarzacz: naglowek serwisu, nazwa pliku, rozmiar, przyciski pobierania,
progres i stopka, a wlasciwy widget Cloudflare siedzi w #captcha, czesto pod
zgieciem. W malym oknie odtwarzacza user widzial wiec pustke i nie mial pojecia,
ze ma gdzies kliknac.

Wstrzykujemy teraz dla tego jednego hosta krotki blok, ktory chowa cala oprawe
strony, wysrodkowuje #captcha-download na pelnym ekranie i dokłada jedna linijke
podpowiedzi. Gdy strona zdejmie z <video> klase hidden, chowamy widget i oddajemy
obraz na caly ekran. Dalej dziala zwykly scrape <source>, wiec gdy tylko pojawi sie
adres pliku, i tak przeskakujemy na natywny odtwarzacz.

Blok zwalidowany jako realny JavaScript (new Function na wyciagnietym fragmencie po
zamianie podwojnych backslashy na pojedyncze, tak jak robi to szablon).

Opublikowane OTA na 1.2 i 1.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:24:31 +02:00
goon-foss
a62d438500 fix(player): po nieudanym resolverze wchodz od razu w WebView
Zgloszenia 57d6265a ("wszedzie doodstream resolver fail") i 3d3e1b33. Przyczyna nie
jest po naszej stronie: playmogo i klony DoodStreama przeszly z NIEWIDZIALNEGO
Turnstile na RENDEROWANY. Sprawdzone 2026-08-22 przez residential IP: strona ma 59 kB
i zawiera pass_md5, czyli sam protokol zyje, ale w HTML jest turnstile.render i ZERO
znacznikow trybu invisible. Z serwerowni ta sama strona ma 5 kB i pass_md5 w ogole nie
ma, wiec bez residential IP nie da sie tego nawet zdiagnozowac.

Skoro zadne ciche pobranie tego nie przejdzie, ekran "DoodStream resolver fail" z
przyciskiem "Open in WebView" byl juz tylko dodatkowym klikiem przed dzialajacym
odtwarzaniem. Teraz po nieudanym cichym resolverze wchodzimy do WebView od razu, czyli
tam, gdzie user moze kliknac widget. To ta sama sciezka, ktora dziala dla vikingfile.

Opublikowane OTA na 1.2 i 1.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:17:33 +02:00
goon-foss
95c51f78d2 fix(galaxyporn): stary katalog NIE jest martwy, tylko iframe zwietrzal
Korekta mojej wczorajszej diagnozy. Twierdzilem, ze stary katalog galaxyporn padl po
stronie hostera: player wypisuje "Video is not ready yet", a /api/v1/video oddaje
payload BEZ jakiegokolwiek adresu wideo (75% probki 16 scen). Objawy sie zgadzaly,
wniosek byl zly.

Na TYCH SAMYCH stronach jest link do vikingfile, tylko nie w iframie, lecz w zwyklym
<a href>, wiec go nie widzielismy. Pliki tam zyja: sprawdzone trzy, 890 MB, 742 MB i
2,5 GB, nazwy zgodne ze scenami. Zwietrzaly byl iframe (news.upns.pro,
sport.seekplays.com, galaxy.4meplayer.pro), zostawiony po migracji.

Szukamy wiec vikingfile po calym HTML. Kolejnosc celowa: seekplayer PIERWSZY, bo
resolwuje sie po naszej stronie i gra bez CAPTCHA, vikingfile jako ratunek, ktory
kosztuje usera przejscie Turnstile w WebView. Martwy player potrafi RZUCIC
(HosterDead przy 404), nie tylko oddac pustke, wiec lapiemy tez wyjatek, inaczej
ratunek nigdy sie nie wykonuje.

Na 12 losowych scenach: bylo 0 rozwiazanych, jest 11. ZADNYCH scen nie kasujemy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 12:00:30 +02:00
goon-foss
83a6092d89 fix(pornbusy): probuj generycznego hostera zamiast od razu poddawac
Zgloszenie 3b03a744 ("Pornbusy pisze ze temporary problem with the host"). Wbrew
mojej pierwszej diagnozie to NIE byla awaria rodziny seekplayera: pornbusy w ogole
juz jej nie uzywa. Probka 10 najswiezszych scen (2026-08-21): morencius.com 4,
cdn.loadvid.com 4, playmate.to 1, upload18 1.

Morencius to zwykly JWPlayer z P.A.C.K.E.R.-em i HLS-em, bez Turnstile, wiec radzi
sobie z nim nasz generyczny rozpakowywacz BEZ zadnych zmian (oddaje gotowy
master.m3u8). Zamiast wiec zwracac None dla kazdego nieznanego hostera, najpierw
podajemy embed generycznemu: najgorszy przypadek to None, czyli stan sprzed zmiany.

Bylo 0/5, jest 3/5. Zostaja playmate.to (rozpakowywacz go nie zna) oraz upload18,
ktory pomijamy swiadomie, bo ma token zwiazany z IP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:51:04 +02:00
goon-foss
ae5f2aa7ac fix(extractors): vjav przechodzi wyzwanie 429, galaxyporn oddaje vikingfile telefonowi
Znalezione w nocnym smoke tescie (9 ostrzezen, w tym trzy ekstraktory po 0/3).

vjav: /api/videofile.php oddaje z serwerowni 429 i 343-bajtowa strone, ktora w JS
ustawia JEDNO ciasteczko i sie przeladowuje. Wartosc jest wprost w tej odpowiedzi,
wiec czytamy ja i powtarzamy zapytanie, bez wykonywania JS. Sprawdzone: druga proba
oddaje 200 i poprawny JSON. Bylo 0/3, jest 5/5.

galaxyporn: serwis przenosi NOWE wgrania na vikingfile.com. Probka: 12/12 najnowszych
scen na vikingfile, 12/12 najstarszych dalej na rodzinie seekplayera. Vikingfile chowa
wideo za RENDEROWANYM Cloudflare Turnstile (turnstile.render, bez trybu invisible), a
<video> jest puste i ukryte, dopoki wyzwanie nie padnie. Serwerowo nie do obejscia,
wiec oddajemy embed jako type=hoster i telefon otwiera go w WebView z domowego IP.
Rozroznienie po hoscie, bo seekplayer jako SPA w WebView pokazuje pusty player.
Najnowsze sceny: bylo 0/3, jest 3/3.

ZOSTAJE OTWARTE: rodzina seekplayera zmienila format odpowiedzi /api/v1/video. Stary
klucz AES dalej odszyfrowuje poprawnie, ale w miejsce sources sa teraz pola pk,
streamingConfig i hlsVideoTiktok ze SCIEZKA wzgledna. Zlozenie jej z hostem playera
daje 404, a z domenami z streamingConfig 403, wiec URL jest podpisywany po stronie
klienta z pola pk. Dotyczy starego katalogu galaxyporn ORAZ pornbusy (0/5).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:35:05 +02:00
goon-foss
1e6be1926e fix(mobile): zdejmij maintainVisibleContentPosition, wywala apke na amen
GOON-1S: 53 wystapienia u 35 osob od 2026-07-03, poziom fatal. Lancuch przyczyn jest
jednoznaczny: JSApplicationIllegalArgumentException przy ustawianiu wlasciwosci
maintainVisibleContentPosition na RCTScrollView, dalej
MaintainVisibleScrollPositionHelper.getUIManagerModule() robi assertNotNull i gdy
ScrollView powstaje w chwili, w ktorej UIManager jest niedostepny, leci AssertionError.
To blad React Native 0.76.9, nie da sie go obejsc konfiguracja.

Wlasciwosc byla anti-jankiem: przy sort=created_at scrapery dosypuja sceny na gore,
wiec refetch potrafil szarpnac trescia pod palcem. Skakanie listy jest gorsze niz nic,
ale crash jest gorszy od skakania. Do rozwazenia po podniesieniu React Native.

Przy okazji GOON-4N (Cannot read property 'length' of undefined, 2 osoby): dokladnego
miejsca NIE da sie wskazac, bo build 0.2.2 poszedl bez source map, a zaden z naszych
czterech getNextPageParam nie siega po .length. Dokladamy tam tania defensywe na
niepelna strone; jesli bedzie wracac, najpierw trzeba naprawic wysylke source map.

Opublikowane OTA na 1.2 i 1.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 17:01:54 +02:00
goon-foss
5065df6220 fix(ingest): odrzucaj daty premiery odlegle w przyszlosc
Zgloszenie de732da2 ("porndish jakby zaprzestal ingestu"). Ingest dziala, 106 nowych
zrodel w 7 dni i 12-18 scen dziennie z poprawnymi datami. Problem byl na samej gorze
listy: dwie sceny porndish z datami 2026-12-02 i 2026-09-04, dodane jeszcze w maju,
stoja przy sortowaniu po premierze nad wszystkim, wiec pierwsze, co user widzi, nigdy
sie nie zmienia.

Data odlegla o miesiace to literowka w roku albo blad parsera, nie zapowiedz.
Przyjmujemy do 14 dni w przod, dalej odrzucamy, czyli scena sortuje sie po dacie
dodania. Wyczyszczone tez wstecz: 10 wierszy w calej bazie, po tym porndish ma na
gorze sceny z 2026-08-19.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:45:03 +02:00
goon-foss
e26c78df24 fix(ingest,proxy): pusta strona to nie koniec katalogu + miniaturki blokowane po IP
naughtymachinima (zgloszenie 83eb300d, "tylko 148 scen"): deep-crawl uznal katalog
za wyczerpany na stronie 8, bo trafil na pusta strone. Listing ma 1770 stron po 30
filmow, a ponowne wejscie na strone 9 oddalo 13 scen i ZERO bledow pobrania, czyli
tamto zero bylo chwilowym dlawieniem. Pusta strona bywa tez calkiem legalna, bo
bramka odrzuca klipy < 180 s, a to 2/3 tego katalogu. Konczymy wiec dopiero po trzech
pustych stronach pod rzad. Kursor odblokowany, kontrolny run przeszedl strony 9-14:
49 scen, 7 nowych, bez oznaczenia exhausted.

fullvideosporn (zgloszenie f4995691, "brak miniaturek"): adresy w bazie sa poprawne,
ale tn.sextu.com odrzuca nasz adres serwerowni: 429 przy kazdej probie, takze po
przerwie, a przez residential proxy 200 i 26 kB. Proxy obrazkow po odmowie
(401/403/429) ponawia teraz raz przez to proxy. Obrazek ma kilkadziesiat kB i klient
cachuje go dobe, wiec to nie jest proxowanie wideo. Sprawdzone: 6/6 miniaturek
laduje sie (15-116 kB), wczesniej 0/6.

Uwaga na przyszlosc: helper wladowal sie przy pierwszym podejsciu MIEDZY dekorator
trasy a proxy_image, wiec @router.get udekorowal jego, a /proxy/img/ oddawalo URL
proxy z loginem i haslem jako JSON. Naprawione, w docstringu ostrzezenie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:43:29 +02:00
goon-foss
fa7fb668b6 fix(sites): sortuj po dacie premiery z fallbackiem na date dodania + napraw miniaturki
Zgloszenie 1df50de3 ("hqporner: brak nowych scen ze strony, brak miniaturek").
Oba objawy mialy inne przyczyny i oba sie potwierdzily.

1. Kolejnosc. Sceny byly i ingest dzialal (91 nowych w 7 dni, ostatnia tego dnia),
ale tuby daty premiery zwykle NIE podaja: xhamster 99,2% scen bez daty, xnxx 97,7%,
porntrex 95,8%, hqporner 93,5%, sxyprn 85,7%. Przy release_date DESC NULLS LAST
wszystkie bezdatowe ladowaly ZA datowanymi, wiec 82 z tych 91 scen siedzialy za
3,4 tys. starszych pozycji. Teraz sortujemy po dacie premiery, a gdy jej brak, po
dniu trafienia do katalogu; sceny z prawdziwa data (kanon) bez zmian. Wyrazenie jest
immutable (jawne AT TIME ZONE 'UTC'), wiec wchodzi do indeksu, migracja 0033.

2. Miniaturki. Kafelki bez obrazka byly podpisane FULLPORNER, nie HQPORNER. Picker
bral pierwsze zrodlo alfabetycznie, a tube:fullporner < tube:hqpornercom. Dwie wady
naraz: czesc miniaturek fullporner to 153-bajtowa atrapa oddawana z kodem 200, a
czesc wskazuje CDN hqporner, ktory odrzuca Referer fullporner.com (ten sam plik:
Referer fullporner.com 403, wlasny host obrazka 200, 30 kB). Wiec: przegladany
serwis wygrywa wybor miniaturki, a proxy obrazkow po 403 ponawia raz z wlasnym
originem obrazka.

Pomiar na 40 kafelkach hqporner: przed 38 ladowalo i 1 nie, po 39 ladowalo i 0 nie,
wszystkie podpisane wlasciwym serwisem. Glowny feed bez zmian.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:25:53 +02:00
goon-foss
34276afb71 fix(scenes): min_duration_sec=0 znaczy bez progu, nie duration_sec >= 0
Zgloszenie 2ec95be5 ("Hentaimama dalej 0 scen") bylo trafne, mimo ze drill-downy
juz wysylaly min_duration_sec=0. Backend tlumaczyl to na duration_sec >= 0, a w SQL
NULL nie przechodzi ZADNEGO porownania, wiec scena o nieznanej dlugosci wypadala
dokladnie tak samo jak dziesieciosekundowy smiec. Serwisy, ktore dlugosci nie podaja
w ogole (hentaimama, javflix, supjav, hentaigasm, hentaiplay, xanimeporn to 100%
NULL), oddawaly PUSTA liste.

Teraz 0 znaczy brak progu i filtr w ogole sie nie naklada, a prog > 0 dalej odcina
NULL-e swiadomie, bo tam chodzi o smieci w glownym feedzie. To samo rozroznienie w
scieszce "pure default", zeby jawne 0 nie gubilo taniego liczenia.

Zweryfikowane na produkcji: hentaimama, javflix, supjav, hentaigasm i xanimeporn
oddaja sceny, glowny feed z progiem 60 bez zmian.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:03:51 +02:00
goon-foss
8f6fa8c732 perf(scenes): indeks pod sortowanie serwisu po dacie premiery + fix wyscigu zapisu wyszukiwania
Zgloszenie 4cccc70f: sxyprn na liscie sites ladowal sie ok 18 sekund. Odtworzone:
/scenes?origin=tube:sxyprncom&sort=release_date schodzilo 19,23 s, a to samo z
sort=created_at 0,08 s. Ekran serwisu sortuje po dacie premiery, czyli
release_date DESC NULLS LAST, created_at DESC, id DESC, a istniejacy
ix_scenes_release_date jest ROSNACY: czytany od tylu daje NULL-e na poczatku i nie
obsluguje kluczy rozstrzygajacych. Planner leial wiec pelnym skanem scenes (3,9 mln)
i playback_sources (3,7 mln). Po zalozeniu pasujacego indeksu strumieniuje w
docelowej kolejnosci i konczy po 25 trafieniach: sxyprn 19,23 s -> 0,11 s,
hqporner 7,99 s -> 0,87 s. Drugi indeks po originie, bo playback_sources nie mial
zadnego.

GOON-4Y: add_saved_search sprawdzal istnienie i wstawial w dwoch krokach, wiec dwa
rownolegle zapisy tego samego zapytania (podwojne tapniecie, ponowienie po
timeoucie) oba widzialy pusto i drugi dostawal 500 na uq_saved_searches_device_query.
Teraz IntegrityError konczy sie oddaniem istniejacego wiersza. Test: 6 rownoleglych
zapisow -> 6x200 i jeden wiersz.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:00:58 +02:00
goon-foss
65f768cf8a chore(movies): wylacz pandamovies, serwis padl
Zostal im tylko zapamietany fronton. Strona glowna oddaje 291 kB i wypisuje 56
filmow, ale KAZDA inna sciezka to 404: listing /movies/ (ten, ktory czytal
konektor), /movies/page/2/ linkowana przez ich wlasny szablon, /genre/* i
/release-year/* z ich menu, sitemapa, a przede wszystkim same strony filmow,
0 na 6 dzialajacych. To nie blokada na nasze IP, bo przez residential proxy
wychodzi identyczne 404 (te same 796 bajtow).

Konektor konczyl runy jako success z zerem rekordow, wiec falszowal obraz w
monitoringu. Watchdog filmowy czyta te sama liste, wiec alert tez cichnie, i
slusznie, bo przestajemy to ingestowac.

Playback tego, co juz mamy, dziala dalej: zrodla wskazuja na hostery (doply,
luluvid, seekplayer), nie na ten serwis. Klasa zostaje w dooplay.py dla
backrefu origins pandamovies:*.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 12:51:41 +02:00
goon-foss
fa2a65b408 feat(superporn): nadrobienie katalogu z sitemapy
Deep-crawl przemielil listing HTML do konca (kursor 548, exhausted), a mimo to
sitemapa ma sceny, ktorych nie mamy. Pomiar na osmiu submapach po 60 losowych
adresow: 46% angielskich URL-i nieznanych, w nowszych submapach nawet 75%. Przy
ok. 29 tys. adresow to rzedu 13 tys. scen. Listing nie wystawia calego katalogu.

Skrypt, nie job w schedulerze, bo superporn nic juz nie publikuje: submapy sa
ulozone chronologicznie, a najnowszy wpis ma date 2025-01-28. To nadrobienie raz.

Wznawianie za darmo, bo bierzemy tylko adresy nieobecne w playback_sources.
Sceny leca z backfill=True, zeby katalog 2022-2025 nie udawal swiezosci.
Kontrola jakosci na pierwszej partii: obsada 20% (korpus superporna ma 17,2%),
srednio 4,7 tagu (3,4), dlugosc w 100%, zero bledow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 12:44:10 +02:00
goon-foss
17601fd0b8 fix(javflix): czytaj /new/, bo strona glowna to staly zestaw
javflix stal 89h bez nowosci, mimo ze scraper chodzil i widzial sceny. Powod:
listing szedl ze strony glownej, a ta pokazuje staly zestaw 16 postow, ktory mamy
w calosci. Audyt 2026-08-20: /new/ ma 7 nieznanych scen z 9, strona glowna 0 z 16.

Przy okazji paginacja: WordPress ja tu tylko udaje. /page/2/ i /page/3/ oddaja
identyczna tresc co strona glowna, a /new/page/2/ to samo co /new/, wiec
browse-latest mielil ten sam zestaw piec razy na run. Teraz strona 1 to /new/,
strona 2 to strona glowna, dalej pusto.

Zweryfikowane na zywo: run oddal seen=25, new=7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 12:30:58 +02:00
goon-foss
b4c1216133 fix(player): nie twórz playera, gdy apka jest w tle
expo-video wola w init konstruktora VideoPlayer bezwarunkowy startService dla
androidx.media3 MediaSessionService (VideoPlayer.kt:233), a Android od 8.0
zabrania startu zwyklego serwisu z tla. Gdy ekran playera montowal sie juz w tle
(user wyszedl w trakcie resolve albo system wznowil stos ekranow), konstruktor
rzucal IllegalStateException i zamiast wideo pokazywal sie ekran bledu.
GOON-12: 63 wystapienia, 28 osob, wszystkie z in_foreground=false.

Konfiguracja tego nie wylacza, wiec bramkujemy montowanie: hooka nie da sie
warunkowac, stad wlasciwy ekran wyjechal do PlayerScreenActive, a eksportowany
PlayerScreen czeka na AppState 'active'. Bramka dziala tylko przy montowaniu,
wiec zejscie w tlo z juz dzialajacym playerem nie przerywa odtwarzania.

Opublikowane OTA na 1.2 i 1.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 12:16:33 +02:00
goon-foss
544de9c5ba feat(ingest): backfill brakujacej dlugosci scen
1 755 421 scen nie ma dlugosci ani na scenie, ani na zrodle, wiec wypadaja z
feedu: w SQL "duration_sec >= 60" nie jest prawda dla NULL, przez co scena o
nieznanej dlugosci znika tak samo jak dziesieciosekundowy smiec. Filtr zostaje,
bo odcina smieci, uzupelniamy dane.

Swiadomie waskie originy. Uniwersalny regex trafia w smieci: porntrex ma w HTML
"duration: 5" reklamy, mypornerleak dlugosc ciastka 720, a xhamster i porntrex
wypisuja dlugosci filmow z paska obok (naiwne dopasowanie dalo 198s zamiast 240s).
Zla dlugosc jest gorsza niz brak, bo dedup opiera sie na niej najmocniej.

- xvideos, xnxx: VideoObject LD-JSON, 5/5 na probce
- xhamster: videoEntity z kontrola zgodnosci id z URL-a
- porntrex pominiety: strona szczegolow nie podaje wlasnej dlugosci

Kolejka jak przy phashu (plan raz, mielenie paczkami, przezywa restart):
845 899 scen, 500 stron co 5 min, czyli ok. 6 dni. Plan powtarza sie co 24h,
zeby swieze sceny bez dlugosci tez trafialy do kolejki.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 12:10:22 +02:00
goon-foss
cddf60acc2 fix(perverzija): powrot na natywny HLS + passthrough manifestu
Zmiana z 18.08 przestawila perverzije na embed-iframe na podstawie jednej
strony, ktora akurat miala mixdrop. Probka 30 stron: 90% dalej stoi na
xtremestream, a xs1.php oddaje poprawny #EXTM3U (8/8 na szesciu rotujacych
subdomenach). Bledy "Input does not start with #EXTM3U" braly sie ze zlego
Referera, ktory zwraca 403 z HTML-em, nie ze zmiany protokolu.

- registry: perverzijacom wraca na natywny ekstraktor
- brak xtremestream -> fallback na embed-iframe (mixdrop, playhydrax)
- mobile_direct_ok: przez VPS idzie sam manifest, segmenty telefon ciagnie
  prosto z CDN (zweryfikowane cross-IP: 200, sync 0x47 z domowego lacza)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 11:55:08 +02:00
goon-foss
ae3c4ac9bb fix(listy): prog dlugosci nie ukrywa juz scen o NIEZNANEJ dlugosci w drill-downach
Zgloszenia 2026-08-20 ("hentaimama dalej 0 scen", "porndish zaprzestal ingestu",
"czemu sexu ma tylko 38 scen"). Trzy razy ten sam pierwotny powod, i to NIE ingest.

`api.ts` doklejal `min_duration_sec=60` do KAZDEGO listScenes bez wlasnej wartosci.
W SQL `duration_sec >= 60` jest nieprawda takze dla NULL, wiec prog wycinal nie tylko
krotkie sceny, ale wszystkie o NIEZNANEJ dlugosci. Zmierzone na produkcji:

  supjav, hentaigasm, hentaimama, hentaiplay, javflix, xanimeporn   100% NULL
  xhamster 90,3% (360 709)   porndish 88,9% (37 736)   porntrex 76,8% (616 345)
  latestpornvideo 68,4%      xnxx 65,2% (226 217)      xvideos 63,8% (258 711)

Czyli wejscie w konkretny serwis ukrywalo ponad 1,5 mln scen i wygladalo jak martwy
albo zatrzymany tube. Ekrany drill-down (SiteScenes, PerformerScenes, StudioScenes,
TagScenes) przekazuja teraz 0: user wszedl JAWNIE w te pozycje, wiec ma zobaczyc jej
zawartosc. Glowny feed zostaje z progiem 60s, bo tam chroni przed teaserami.

Zweryfikowane na produkcji: hentaimama / supjav / xanimeporn 0 -> 50 pozycji.

Pulapka NULL opisana w api.ts, zeby ktos nie "uproscil" tego z powrotem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:07:58 +02:00
goon-foss
20c04698b1 fix(player): scrubber seekuje takze gdy dlugosc zna tylko scena, nie player
SPROSTOWANIE: paska postepu NIE trzeba bylo budowac, on juz istnial (track + fill +
thumb + gest + czasy, 48px hit-box). Moja wczesniejsza diagnoza "player nie ma czym
przewijac" byla bledna: szukalem po nazwach Slider/progressBar/seekBar, a implementacja
nazywa sie scrub*. Zamiast dublowac funkcje, zostal realny blad, ktory przy okazji
wyszedl.

Blad: UI paska liczy sie z `dur = knownDuration || durationSec || 0`, czyli z fallbackiem
na dlugosc z metadanych sceny, ale seek w `onEnd` byl strzezony warunkiem na samym
`knownDuration` (to, co zglosil player). Dla zrodla, ktore nie poda dlugosci playerowi,
a ma ja w metadanych, uchwyt i czas jechaly za palcem, a po puszczeniu nie dzialo sie
nic. Teraz seek uzywa tej samej wartosci co rysowanie, przez `durRef` (gest powstaje raz
w useMemo, wiec domykalby stara wartosc).

Zweryfikowane na emulatorze na realnym odtwarzaniu: przeciagniecie paska przenioslo
pozycje z 1:36 na 18:03, wypelnienie i uchwyt zgodne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:55:32 +02:00
goon-foss
068a65dd8c feat(ui): przebudowa ekranu szczegolow sceny wg makiety (kierunek A)
Hero dostaje badge w rogach: jakosc po lewej, dlugosc po prawej, ten sam jezyk co na
kafelku, wiec przejscie z siatki do szczegolow nie zmienia zasad czytania.

Sekcja STUDIO zwinieta do linii metadanych (studio - serwis) pod tytulem. Zajmowala caly
blok na jeden chip. WAZNE: studio ZOSTAJE klikalne i dalej prowadzi do swoich scen,
zwiniecie wygladu nie moglo zabrac funkcji. Zweryfikowane na emulatorze: tap w "Gang AV"
otwiera ekran studia z 200 scenami.

Sekcja SOURCES usunieta. Pokazywala wewnetrzna nazwe konektora ("TUBE-SCRAPER"), ktora
uzytkownikowi nic nie mowila. Zastapiona nazwa serwisu wyliczana z originu (PORNDOE,
FULLPORNER) w tej samej linii co studio.

Naglowki sekcji dostaly kreske ciagnaca sie do prawej krawedzi: cztery sekcje mialy
cztery rozne odstepy i ekran nie mial rytmu.

Opis (Description) zostaje, wbrew makiecie, ktora go zgubila.

Zweryfikowane na emulatorze na prawdziwych danych (build debugowy podpisany kluczem
release): badge dlugosci, linia "Gang AV / PORNDOE", kreski przy naglowkach, zero
wystapien "TUBE-SCRAPER".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:36:56 +02:00
goon-foss
bbc2059728 feat(ui): badge jakosci i nazwa serwisu na kafelku sceny
Kierunek A z makiet (miniaturki niosa ekran, chrome cichy) + jeden element z C:
jakosc i zrodlo widoczne wprost na kafelku.

Backend musial to najpierw WYSTAWIC, bo lista jedzie w trybie `light`, ktory okrawa
playback_sources do samej miniatury:
  - `SceneOut.max_quality_p` (kolumna z migracji 0029, dotad uzywana tylko jako filtr),
  - slim wpis niesie PRAWDZIWY `origin` zrodla wybranej miniatury zamiast "".

Kafelek: badge jakosci w LEWYM dolnym rogu (czas zostaje w prawym, ten sam ciezar
wizualny), a w linii meta studio + przygaszona nazwa serwisu. Badge pokazujemy TYLKO
gdy jakosc jest znana (6,5% scen), bo ma byc faktem, nie zgadywaniem.

Zweryfikowane na emulatorze na PRAWDZIWYCH danych: zeby to bylo mozliwe, zbudowalem
build debugowy podpisany kluczem release (przechodzi kontrole X-App-Signature, a jako
debug nie ma FLAG_SECURE, wiec da sie zrobic zrzut). Widac "4K" i "1280p" w rogu oraz
"ONLY FANS  SXYPRN" w linii meta. Tymczasowa zmiana podpisu w build.gradle cofnieta.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:41:16 +02:00
goon-foss
576058497a fix(ui): grupuj changelog po miesiacu, zdejmij naglowek z Sites
Changelog: 17 sierpniowych wpisow drukowalo "AUGUST 2026" 17 razy, bo naglowek szedl
przy KAZDYM wpisie. Teraz wpisy sa laczone w grupy miesieczne (sa juz newest-first,
wiec wystarczy sklejac kolejne z tym samym `date`), naglowek leci raz na grupe.

Sites: usuniety tytul "SITES" + licznik i podpowiedz "tap a tube -> newest scenes".
Byl jedynym ekranem-zakladka z wlasnym naglowkiem, przez co jego tresc zaczynala sie
nizej niz na pozostalych. Martwe style (headerRow/headerLabel/headerCount/hint) wyciete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:23:04 +02:00
goon-foss
17e4ecaa8a fix(ui): jeden wspolny zestaw kontrolek naglowka zamiast kopii per ekran
Zgloszenie 2026-08-18 (cztery zrzuty obok siebie): "rozne odstepy miedzy zakladkami,
kazda zakladka wyglada inaczej, rozne wielkosci text fields, rozne obramowki, chyba
nawet inne czcionki".

Przyczyna strukturalna: kazdy ekran-lista mial WLASNA kopie stylow search/toolbar/
przycisku filtra. Zmierzony dryf: rog pola 12 vs 10, ramka 1,5 vs 1, font 16 vs 14,
odstep toolbara 12 vs 8, dwa promienie pigulek (8 i 12), padding kontenera 16/16 vs
16/12 vs 0. "Inne czcionki" = etykiety mialy fontWeight:'700' BEZ fontFamily, a RN nie
syntezuje pogrubienia dla fontu custom -> chudy Regular zamiast bold.

Nowy `src/ui.tsx`: SearchInput, PillButton, Toolbar + wspolne stale SCREEN_PAD_*.
Jedna wysokosc kontrolek w wierszu, jeden promien, jedna ramka, waga przez fontFamily
(nie fontWeight), stan aktywny robi kolor. Scenes/Movies/Sites przepiete na to, ich
lokalne duplikaty stylow usuniete (w tym osierocony SegButton w Sites). JAV i Anime
reuzywaja ScenesScreen, wiec dostaja to samo za darmo.

Zweryfikowane na emulatorze: cztery zakladki jeden przy drugim maja identyczne pole,
przycisk, pigulki i marginesy. 403 na liscie to build debug spoza allowlisty, nie UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 11:42:32 +02:00
goon-foss
6d338219ea fix(ui): przesuwane podkreslenie zamiast ciekłej kropli w zakladkach
Zgloszenie 2026-08-18: kropla "zrobila wiecej zlego niz dobrego". Trzy zarzuty,
wszystkie trafne:
  - nie wygladala ladniej,
  - rysunek SVG mial 44 px i wystawal ponad pasek, wiec naglowek spuchl i rozjechalo
    sie wyrownanie wiersza do srodka,
  - morfowanie ksztaltu nie przypominalo wzoru.

Research wzorcow z liderow (X/Twitter, YouTube, Reddit, App Store, Duolingo, apki
bankowe): gorne zakladki to wszedzie plaski, powsciagliwy pasek STALEJ WYSOKOSCI z
PRZESUWANYM wskaznikiem, nie morfowanym. Uzytkownik wybral podkreslenie (wzorzec
dominujacy na Androidzie).

TopTabsBar: aktywna zakladka = accentText + cienka kreska hugujaca tekst, przejezdza
w 220 ms ease-out (translateX + width z pomiarow onLayout). Wysokosc = wiersz tekstu,
wiec logo/zakladki/ikony znow sa w jednej linii. Naglowek wraca na theme.card (trik z
theme.bg byl tylko po to, zeby kropla byla widoczna).

Usuniete: LiquidTabs.tsx + lib/liquidTabPath.ts (caly aparat SVG-morph, martwy).

Zweryfikowane na emulatorze nagraniem klatka po klatce: kreska przejezdza Anime->Scenes
mijajac zakladki, kolory sie przelaczaja, laduje pod aktywna; wiersz wyrownany.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 11:22:02 +02:00
goon-foss
0fed952c5c feat(filtr): minimalna jakosc w filtrze scen (720p/1080p/4K)
Prosba uzytkownika (zgloszenie 17376139). Backend mial juz parametr `min_quality_p`,
ale w praktyce byl NIEUZYWALNY i to zajelo wiekszosc pracy.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 11:06:34 +02:00
goon-foss
085c0c1dd6 fix(playback): perverzija na nowy hoster, naughtymachinima re-resolve na reset
Dwa zgloszenia z apki, dwa rozne pierwotne powody.

perverzija (zgloszenie af9be63a, 520 bledow "Input does not start with #EXTM3U",
ZERO sukcesow): strona porzucila player xtremestream/xs1.php i osadza teraz mixdrop.
Stare URL-e xs1.php oddaja HTML zamiast manifestu HLS, stad kazdy playback padal.
Rejestr przelaczony z natywnego perverzija.extract na _embed_iframe.extract, ktory
wyluskuje mixdrop i oddaje type='hoster' -> telefon resolwuje w WebView. Natywny
ekstraktor zostaje w drzewie, gdyby xtremestream wrocil.

UWAGA co do weryfikacji: mixdrop gejtuje odtwarzanie za adsco.re (antybot). Sprawdzone
w prawdziwym Chrome na IP residential - MDCore.wurl zostaje null, zero requestu wideo,
bo brama celuje w AUTOMATYZACJE. Dla realnego usera w WebView player mixdrop gra
normalnie (to caly biznes mixdropa), ale nie dalo sie tego zautomatyzowac end-to-end.
To i tak scisle lepsze niz obecny stan 100% martwy.

naughtymachinima (zgloszenie ef728604 "od razu fallbackuje do strony, 502/429"):
ekstraktor SPRAWNY, direct mp4 bez tokenu, IP-bound do residential (403 z datacenter,
telefon gra). Telemetria 64 sukcesy / 19 bledow, blad to przejsciowy Connection reset
z CDN na dzialajacym URL-u. Bez re-resolve pojedynczy reset od razu spychal na WebView,
ktory ladowal zdlawiona strone tube'a - stad 502/429 w zgloszeniu. Dopisany do
BACKEND_RERESOLVE_ORIGINS: przy bledzie odswieza polaczenie zamiast uciekac do WebView.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 10:23:05 +02:00
goon-foss
0949091f78 fix(ui): pasek zakladek naprawde plynie - zakladki jako stan, nie trasy
Zgloszenie: "zamiast slicznych animowanych przesuwajacych sie elementow mam
statyczna kropke". Dwie niezalezne przyczyny, obie konieczne do naprawy:

1. Animacja NIE MIALA FIZYCZNEJ MOZLIWOSCI sie wykonac. Kazda zakladka byla
   osobnym ekranem native stacka, a przelaczenie robilo navigation.replace(t),
   czyli wymienialo caly ekran razem z naglowkiem. LiquidTabs montowal sie od
   nowa z kropla juz na docelowej pozycji (pierwsze ustawienie celowo bez
   animacji). Fix: jeden HomeScreen, zakladka to stan lokalny, pasek przezywa
   przelaczenie. key={tab} na tresci zachowuje dotychczasowy reset stanu.

2. Pasek byl NIEWIDZIALNY: wypelnienie theme.card na naglowku o tle theme.card.
   Z calego "roztapiania" zostawala sama akcentowa kropka. Fix: naglowek na
   theme.bg.

Do tego drobne: easing Easing.out(cubic) 260ms (wczesniej bez znaczenia, bo kod
nigdy nie dzialal); BugReportFAB czyta zakladke z params.tab (nazwa trasy to
teraz zawsze 'Scenes'); ikony naglowka ujednolicone na wszystkich zakladkach;
App.tsx: blad ladowania fontow przestaje wieszac boot na wiecznym spinnerze
(fail-open na system font) - obnazone przez rozjazd JS/natywki w dev buildzie.

Trasy Movies/Sites/JAV/Anime wyrejestrowane (nic poza paskiem do nich nie
nawigowalo); typy w RootStackParamList zostaja dla useNavigation.

Zweryfikowane nagraniem ekranu emulatora klatka po klatce: kropla plynie
Scenes->Sites i JAV->Anime z wedrujacym wglebieniem, pasek widoczny, tresc
i przycisk Performers/Tags zgodne z zakladka.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:59:24 +02:00
goon-foss
f48fbbd4ad fix(performers): wlasna obsada dla JAV, brak przycisku dla animacji
Domkniecie tej samej rodziny bledu co przy tagach i studiach. Pomiar rozstrzygnal,
ze oba wertykaly wymagaja INNEGO rozwiazania, wiec nie ma tu jednego filtra:

  animacja   12 698 scen ->     0 performerow
  JAV        54 888 scen -> 1 392 performerow

JAV: /performers dostaje parametr `vertical`, liczony na zywo, bo zdenormalizowany
`performers.scene_count` z zalozenia opisuje wylacznie glowny feed (job taxonomy_counts
wyklucza originy wertykalowe). Bez tego zakladka pokazywala zachodnia obsade, a wlasne
nazwiska mialy licznik 0 i ladowaly na koncu listy. Po zmianie: Satomi Suzuki (137),
Kei Mizushima (128), yui hatano (68).

Animacja: przycisk Performers jest chowany. Zero obsady to nie brak danych, tylko
wlasciwosc tresci: dla postaci rysowanych nie istnieje kanon (TPDB/StashDB ich nie maja),
wiec zaden scraper nie ma czego przypisac i zaden nigdy nie bedzie mial. Filtrowanie
dawaloby gwarantowanie pusta liste, czyli dokladnie ten sam objaw, ktory naprawiamy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:51:39 +02:00
goon-foss
3818cd6141 fix(tags): wlasna lista tagow dla wertykalow + bez tagow z zerem scen
Trzy rzeczy, jedna rodzina bledu "klikniecie prowadzi donikad".

1. Ekran Tags nie znal wertykalu, wiec z zakladki Anime i JAV pokazywal slownictwo
   GLOWNEGO katalogu. Backend liczyl to poprawnie od dawna (parametr ?vertical=),
   tylko apka go nie przekazywala. Po zmianie Anime dostaje swoje 3380 tagow
   (Schoolgirl, Rape, Uncensored, Harem) zamiast zachodnich, ktore w tej sekcji nie
   maja pokrycia. Tag otwarty z tej listy niesie wertykal dalej.

2. Lista i filtr przestaja pokazywac tagi z zerem scen: 33 427 z 208 822. To glownie
   slownictwo animacji i JAV, bo `tags.scene_count` z zalozenia nie liczy scen
   wertykalowych. Widac je bylo przy WYSZUKIWANIU (sortowanie po popularnosci trzymalo
   je na koncu listy), a kazdy taki tag konczyl sie pustym ekranem.

3. Znaczniki jakosci zniknely wczesniej po stronie danych, wpis w changelogu.

Zakres swiadomie zatrzymany: `/performers` nie ma wsparcia dla wertykalu, wiec przycisk
Performers na zakladce Anime nadal pokazuje zachodnia obsade. To wymaga zmiany backendu
i jest osobnym zadaniem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:41:07 +02:00
goon-foss
8c8f22d388 feat(tags): zdejmij znaczniki jakosci + przytnij ogon tagow rule34video
Znaczniki jakosci (hd, 4k, 720p, HD Videos, wzorzec ^\d{3,4}p$) trafiaja na stoplist
i znikaja z katalogu: 323 379 przypisan, 16 tagow. Powod: apka ma osobny filtr jakosci
liczony ze ZRODEL ODTWARZANIA, ktory jest wiarygodny, a tag jest deklaracja tuba i bywa
falszywy. Dwa rownolegle sygnaly o tym samym, z ktorych jeden klamie, sa gorsze niz jeden.

Ogon rule34video: 356 scen wnosilo 1112 roznych tagow do WSPOLNEJ listy uzywanej przez
caly katalog, z czego 602 wystepowaly dokladnie raz (nazwy postaci i serii, tagowanie
w stylu booru). Nowy job co 24h zostawia tagi z >=3 scen i najwyzej 15 na scene,
uporzadkowane po czestosci w obrebie serwisu.

Wazny szczegol reguly, wychwycony dopiero w symulacji: ochrona tagow wspolnych dotyczy
TYLKO progu, nie limitu. Pierwsza wersja wylaczala tagi wspolne rowniez z limitu i przez
to prawie nie dzialala, rekord spadal 161 -> 147 zamiast do 15, bo najbardziej obsypana
scena skladala sie glownie z tagow wspolnych.

Wynik na produkcji: srednia na scenie rule34video 13,5 -> 9,1, rekord 161 -> 15, zadna
scena nie zostala bez tagow. Usuniete 1554 przypisania i 2475 osieroconych tagow.
VACUUM ANALYZE po obu operacjach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:28:47 +02:00
goon-foss
38a2724963 feat(tags): odrzucaj tagi marketingowe zamiast opisujacych tresc
Zgloszenie 778cd5be: "cos za duzo tych tagow, one sa faktycznie na stronie czy to
over attribution?". Sprawdzone u zrodla: tytul posta na sxyprn ma dokladnie te 28
hashtagow, wiec to NIE nasza over-atrybucja, tylko uploader wklejajacy ten sam blok
do kazdego wideo (stad #France i #french przy amerykanskiej scenie).

Statystycznie tego nie da sie odsiac: blok jest inny u kazdego uploadera, a najczestszy
tag na sxyprn (Hardcore) ma 47% udzialu, wiec zaden prog nie oddziela spamu od prawdy.
Da sie za to wyciac slowa, ktore nigdy nie opisuja tresci. Tylko to robi ta zmiana.

Filtr wpiety w resolve_tag, czyli jedno miejsce, przez ktore ida wszystkie tagi
z kazdego zrodla, wiec nie wroca przy re-scrape. Retroaktywnie skasowane 137 861
przypisan scen i 247 filmow, 29 tagow. Sprawdzone przed kasowaniem: tylko 270 scen
zostaje bez tagow i ZERO userow ma ktorykolwiek z nich na liscie ukrytych. Srednia
liczba tagow na scene 7,7 -> 7,3. VACUUM ANALYZE odpalony od razu.

Swiadomie zostawione: onlyfans (realny typ tresci, 72 tys. przypisan) oraz 4k/hd/720p
(niosa informacje, choc apka ma juz wiarygodny filtr jakosci ze zrodel odtwarzania,
wiec to decyzja produktowa, nie sprzatanie smieci).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:00:45 +02:00
goon-foss
a1f604a7a4 fix(verticals): przenos wertykal przy przejsciu do tagu/studia/performera
Zgloszenia 195ae42e i e9de414c: klikniecie studia w anime nic nie robilo, a tag
kliniety w anime pokazywal sceny spoza anime.

Jedna przyczyna: TagScenes/StudioScenes/PerformerScenes wolaly listScenes BEZ
parametru vertical, a backend bez niego wyklucza wszystkie wertykaly. Studio
animacyjne mialo wiec zero scen (pusty ekran = wyglada na martwy klik), a tag
zwracal wylacznie sceny glownego katalogu.

SceneOut dostaje pole "vertical" liczone z originow zrodel, czyli jedno zrodlo
prawdy w app/verticals.py zamiast dublowania listy originow w apce. W trybie
light dociagamy origin z tego samego wiersza co miniaturka, wiec zero dodatkowych
zapytan. SceneDetail przekazuje wartosc dalej w paramach nawigacji.

Dotyczy tak samo sekcji JAV, ktora miala ten sam blad.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 11:42:47 +02:00
goon-foss
d9177febf7 fix(performers): zdejmij przypisania kategorii udajacych ludzi
"Natural Tits", "Big Black", "Anal Milf", "Cum Swallow", "Fat Ass" i 94
inne siedzialy w tabeli performers z tagow tubow wciaganych jako obsada.
Ugryzly 2026-08-14 przy rozplataniu zlepkow: regula uznala "Big Black" za
osobe wymieniona w tytule i dala mu 124 sceny.

TRZY OCZYWISTE HEURYSTYKI SPRAWDZONE I ODRZUCONE, kazda z kontrprzykladem:

  1. "nazwa jest tez tagiem"  -> prawdziwe dla Lauren Phillips, Julii Ann,
     Nikki Benz (tuby robia tagi z nazwisk), 7625 z 74150 nazw
  2. "nie ma kanonu"          -> TPDB SAM MA "Big Black" i "Lesbian Love"
     jako performerow, a realna "Mena Mason" refow nie ma
  3. "tag >> performer"       -> dziala dla "Natural Tits" (2147x) i "Rough
     Sex" (1552x), ale "Big Black" ma 0,2x, "Lesbian Love" 0,0x

Kryterium jest wiec STRUKTURALNE i nie zalezy od zadnej z tych baz: cala
nazwa zlozona wylacznie ze slow opisujacych tresc. Kandydatow 101, czyli
na tyle malo, ze lista zostala PRZEJRZANA RECZNIE.

Przeglad wylapal falszywki: "Dick Nasty" ma refy z szesciu zrodel, w tym
StashDB, i jest realnym meskim performerem (obsada "Sugar Daddy 17" obok
Dave'a Cummingsa). "King Cock" wykluczony z ostroznosci. Oba w
_JEDNAK_LUDZIE, z uzasadnieniem w kodzie.

Wynik: 11 793 przypisania zdjete od 99 kategorii, do archiwum. Same rekordy
performerow ZOSTAJA: scene_count spadl do 0, wiec i tak wypadaja z list
z only_with_content, a skasowanie rekordu zerwaloby refy kanoniczne przy
czyms, co TPDB u siebie trzyma jako performera.

Kontrola: Big Black/Natural Tits/Anal Milf/Fat Ass -> 0 scen, Lauren
Phillips 2146 i Dick Nasty 69 nietkniete.
2026-08-14 11:27:00 +02:00
goon-foss
96d16b5488 fix(performers): rozplac zlepki dwoch osob w jedna nieistniejaca
Scraper tworzyl osoby, ktore nie istnieja, biorac imie jednej i nazwisko
drugiej osoby z tej samej sceny:

    "Lilly Blossom" = Lilly Bell   + Blake Blossom
    "Chloe Ryder"   = Chloe Temple + Sera Ryder
    "James Driller" = Kenna James  + Ryan Driller
    "Parker Banks"  = Zoe Parker   + Cassidy Banks

prune_token_overlap_attributions ich nie widzi, bo wymaga INNEGO
udokumentowanego performera na scenie, a zlepek jest zwykle JEDYNYM
przypisanym. Znowu ODZYSKIWANIE, nie kasowanie: obie prawdziwe osoby stoja
w tytule i nie byly przypisane.

Wynik: 3993 zlepki zdjete, 7988 prawdziwych przypisan dolozonych.
Zyskali m.in. Lauren Phillips +74 sceny, Gianna Dior +45, Jenna Sativa +42,
Aaliyah Love +41, Keiran Lee +41.

BLAD W PIERWSZYM PRZEBIEGU I JEGO NAPRAWA: regula uznala nazwy-KATEGORIE
za "wlasciciela" wymienionego w tytule i "Big Black" dostal 124 sceny,
a dziewiec innych po kilka. Zdjete osobno (197 przypisan, do archiwum),
plus jawna lista wykluczen w skrypcie. Lista jest JAWNA, bo obie oczywiste
heurystyki zawodza: "nazwa jest tez tagiem" jest prawdziwe dla Lauren
Phillips i Julii Ann, a "ma kanon" jest prawdziwe dla "Big Black".

CZEGO NIE RUSZAM: 3150 zlepkow MAJACYCH kanon. "Nikki Monroe" czy
"Valentina Lopez" moga istniec naprawde, a "Lauren Smith" wzielo sie
z trojczlonowego nazwiska aktorki ("lauren lee smith"). Istnienie osoby
nie przesadza, czy jest w TEJ scenie.

Szersza reguła "w tytule stoi ktos inny" ODRZUCONA po probce: 31% trafien
to byly kategorie-smieci, a similarity trigramowe nie rozdziela klas
(siostry Dellai 0,318 vs Nikki Nevada/Brooks 0,316).
2026-08-14 11:00:48 +02:00
goon-foss
0d4e11bc31 fix(performers): odzyskaj sceny przypisane do samego czlonu nazwiska
Siedem performerow o nazwie JEDNOCZLONOWEJ ("James", "Blue", "Moon",
"Crystal", "Cruz", "Starr", "Knight") zbieralo sceny wielu roznych osob
dzielacych ten czlon: "Blue" mial sceny Anity, Delilah i Bonnie Blue,
"Starr" mial Violet, Natashe i Bobbi Starr.

prune_token_overlap_attributions z zalozenia ich NIE RUSZAL i to nie byl
niedopatrzenie: dla nazwy jednoczlonowej za dowod uznaje samo wystapienie
tego slowa w tytule, wiec "James" jest "udokumentowany" przez kazda scene
wspominajaca jakiegokolwiek Jamesa. Stad 0 skasowanych z tej siodemki.

To NIE bylo kasowanie, tylko ODZYSKIWANIE: w probce 14/14 wlasciwa osoba
NIE byla przypisana do sceny, czyli Bonnie Blue nie miala tych scen na
swojej stronie.

Kryterium: przypisanie ze scrapera (kanon nietykalny — z 605 przypisan
tych siedmiu 518 jest ze scrapera) + istnieje performer, ktorego nazwa
konczy sie tym czlonem po spacji + ta PELNA nazwa stoi w tytule sceny
jako ciag calych slow. Ostatni warunek odroznia odzyskanie od zgadywania.

Wynik: dolozono 271 wlasciwych przypisan, zdjeto 268 mononimow (roznica
to sceny, gdzie w tytule stoja DWIE takie osoby, np. Bonnie i Mick Blue —
obie sa dokladane, mononim schodzi raz).

Zyskali m.in.: Violet Starr +22 sceny, Mick Blue +16, Stacy Cruz +15,
Rachel Starr +13, Alexis Crystal +12, Natasha Starr +8.

Mononimy spadly: Starr 93->28, Cruz 45->8, Moon 64->23, Blue 113->73.
Reszta zostaje nietknieta, bo nie ma w tytule wskazanego wlasciciela —
"Blue" i "Cruz" maja refy StashDB, wiec czesc z nich moze byc prawdziwa.

Zdjete wiersze ida do scene_performers_pruned, cofniecie to INSERT..SELECT.
2026-08-14 10:34:14 +02:00
goon-foss
f70082e7de chore(seekplayer): przestan przekazywac martwy cf_fallback
Zadanie bylo "podpiac albo usunac". Sprawdzone i NIE DA SIE podpiac.

Fallback za Cloudflare (svb.professionalvisibility.cyou/...cf-master.<ts>.txt)
oddaje 403 w szesciu wariantach:

    httpx + Referer hostera        httpx + Referer galaxyporn.net
    curl_cffi chrome120 z Ref      curl_cffi bez Ref (oddaje strone CF)
    znacznik czasu biezacy         znacznik z linku glownego

I to samo 403 z lacza RESIDENTIAL, wiec to nie blokada datacenter. Adres
jest najpewniej zwiazany z sesja tego samego wywolania /api/v1/video,
a my siegamy po niego osobno i po czasie (znacznik w nazwie pliku bywa
5 dni stary). Podpiecie dokladaloby request, ktory ZAWSZE pada, na
sciezce bledu — czyli opoznienie zamiast ratunku.

Cel, ktoremu mial sluzyc (przezyc padniety nod CDN), jest juz osiagniety
inaczej: ponowny resolve wraca z innym adresem CDN (47547fe).

`proxy_no_verify` ZOSTAJE mimo ze nic go nie czyta: zapisuje realne
ryzyko (cert niepasujacy do golego IP), a jego usuniecie skasowaloby te
informacje. Falszywy komentarz o istniejacej galezi `verify=False` juz
poprawiony.

Wszystkie szesc prob zapisane w docstringu, zeby nikt ich nie powtarzal.
Kontrola po zmianie: extractor oddaje m3u8, proxy 200.
2026-08-14 10:29:02 +02:00
goon-foss
47547fe694 fix(playback): galaxyporn re-resolwuje sie po bledzie zamiast poddawac
Zgloszenie 7f2234eb: "samo wideo dziala, a aplikacja zglasza problem
z hostem". Ekstraktor NIE jest zepsuty:

  - try_extract oddaje dzialajacy manifest w 0,4 s
  - z Refererem ekstraktora (galaxy.4meplayer.pro) link daje 200; bez
    niego 403, wiec naglowek jest ustawiany poprawnie
  - pobranie przez nasze /proxy oddaje 200 i playliste z wariantem 1080p
  - telemetria 14 dni: 1140 udanych, 25 bledow (2,1%)

Padal POJEDYNCZY nod CDN. Kolejne wywolania resolve tej samej sceny
wracaja z ROZNYMI adresami (185.237.107.133, potem 203.188.166.163), a
token ma ~2,8 h TTL i nie jest IP-bound. Czyli ponowny resolve po prostu
laduje na zywym wezle — dokladnie wzorzec, ktory dziala juz dla watchporn.
Stad `tube:galaxyporncom` w BACKEND_RERESOLVE_ORIGINS.

Po drodze skorygowany FALSZYWY komentarz w seekplayer_engine.py: twierdzil,
ze stream_proxy.py ma galaz `verify=False` dla hostow pod golym IP. Nie ma
jej — slowo `verify` nie wystepuje w tym pliku ani razu. `proxy_no_verify`
i `cf_fallback` sa ustawiane przez trzy ekstraktory i przez NIC nieczytane.
cf_fallback jest o tyle bolesny, ze to gotowy zapasowy URL, ktory uratowalby
wlasnie ten przypadek. Osobne zadanie.

Blad diagnostyczny po drodze, dla potomnosci: pierwszy test dal 404, bo
przetestowalem URL uciety wlasnym printem [:70], bez /master.m3u8.
2026-08-14 10:22:08 +02:00
goon-foss
e074229bbb feat(ui): pasek zakladek roztapia sie wokol aktywnej pozycji
Wzorowane na reelu _code_and_chill_ ("the bar melts around it"), ale
technika wzieta z jego drugiego zdania: "every socket is solved for
tangency, so the liquid never creases". Czyli sciezka LICZONA, nie
rozmywana.

Filtr "goo" (rozmycie + prog alfy) byl pierwszym pomyslem i zostal
ODRZUCONY po zajrzeniu w zrodlo react-native-svg. Filtry na Androidzie
dzialaja (GroupView extends RenderableView, ktory obsluguje mFilter), ale
RenderableView.java:376 przy KAZDYM rysowaniu robi Bitmap.createBitmap(
canvas.getWidth(), canvas.getHeight(), ARGB_8888), rysuje do niej element,
naklada programowy filtr i wrzuca wynik z powrotem. Przy animowanej kropli
to pelnoekranowa alokacja na klatke, w nagłowku KAZDEGO ekranu.

liquidTabPath: gniazdo to dwa luki styczne jednoczesnie do krawedzi paska
(srodek na y=top+k) i do kropli (odleglosc srodkow = r+k), stad
dx = sqrt((r+k)^2 - (top+k-cy)^2). Jedna <Path>, zero alokacji.

Weryfikacja liczbowa (nie wizualna): trzy niezmienniki stycznosci trzymaja
sie do 1e-9, piec przypadkow brzegowych OK. Test wylapal brakujacy warunek
`cy - r < top`: bez niego wzor ma rozwiazanie takze gdy kropla siedzi CALA
w pasku (top=26, cy=45, r=17, k=12 -> dx≈28,1) i powstawaloby gniazdo, w
ktore nie ma sie co wtopic.

Animacja: Animated.Value + listener przepisujacy cx do stanu, bo sciezka
to string i nie da sie jej podac Animated wprost (brak Reanimated w
projekcie). ~12 re-renderow SVG na 200 ms przejscia, poza tym komponent stoi.
useNativeDriver=false swiadomie: animujemy geometrie, nie transform.

Etykieta aktywnej zakladki siedzi na wypelnieniu akcentu — jasny tekst daje
tam 5,03:1, jedyny wariant tego koloru przechodzacy AA (patrz theme.ts).
2026-08-14 10:13:30 +02:00
goon-foss
2a163cbf2c feat(tile): dwie linie tytulu + zdejmowanie powtorzonej nazwy studia
Pomiar na 200 tys. grywalnych scen >=60 s (2026-08-13): przy JEDNEJ linii
pelny tytul widzialo **14,3%** scen. Mediana tytulu to 46 znakow, a w
kolumnie dwukolumnowej siatki miesci sie ~24 na linie, wiec 85,7% tytulow
bylo przycietych. Przy dwoch liniach pelny tytul widzi 54,4%.

To SWIADOMIE odwraca czesc wczesniejszej decyzji ("Jan feedback: wieksze
miniaturki, mniej tekstu" w docstringu tego pliku) — uzgodnione, bo tamta
uwaga dotyczyla nadmiaru metadanych, nie przycinania samego tytulu.
Miniaturka zostaje w tym samym rozmiarze, rosnie tylko blok tekstu (~18 px).

Do tego `titleWithoutStudio`: gdy druga linia pokazuje studio, zdejmujemy
jego nazwe z PRZODU tytulu ("MYLF - Aria's MILF Temptation" nad meta
"MYLF"). Dotyczy 8,2% scen. Zdejmujemy tylko przedrostek, wiec studio na
koncu tytulu ("… - ATKGirlfriends") zostaje nietkniete. Gdy po zdjeciu
zostaje <12 znakow, oddajemy tytul nietkniety, bo "Scene 1" jest gorsze
niz powtorzenie. 9 przypadkow testowych na realnych tytulach.

Korekta wlasnej diagnozy z przegladu: napisalem tam, ze tytul i studio
maja niemal rowna wage. Nieprawda — 14 px display/fg vs 10 px mono/muted
wersalikami. Hierarchia byla w porzadku, problemem bylo obciecie.
2026-08-14 09:58:20 +02:00
goon-foss
7deda639bf fix(theme): rozdziel role akcentu i dodaj ramke, ktora cokolwiek oddziela
Pomiar WCAG na realnych parach z apki (2026-08-13):

    accent #B23A48 jako tekst:  3,21 na bg | 2,96 na bgElevated | 2,75 na card
    border #3A3128 jako granica: 1,48 na bg | 1,27 na card

Czyli kolor MARKI byl ponizej nawet progu 3:1 dla elementow interfejsu,
a nie tylko ponizej 4,5 dla tekstu. Dotyczylo to aktywnych zakladek,
gwiazdek ulubionych, linkow i chipow. Ramki przy 1,27 nie byly granica,
tylko cieniem.

NIE zmieniam koloru marki. Podniesienie samego `accent` do progu tekstu
wymagaloby zejscia w roz (#CE6975), co likwiduje tozsamosc, ktorej broni
naglowek theme.ts. Zamiast tego rozdzielam ROLE tego samego koloru:

  accent      zostaje do WYPELNIEN — jasny tekst na nim ma 5,03, przechodzi
  accentText  #C75260 dla tekstu i obwodek — 4,30 / 3,96 / 3,68

62 podmiany w 24 plikach (34 color, 26 borderColor, 2 thumbColor).
38 wypelnien nietknietych. Sprawdzone skryptem, ze zaden zmieniony tekst
nie stoi na tle akcentu.

borderStrong #7B7065 dla granic NIOSACYCH ZNACZENIE (pola tekstowe, 12
miejsc); dekoracyjne wloski zostaja na `border`. Wartosc dobrana pod obie
powierzchnie, bo pola leza i na bg, i na card: #6B6157 dawal na karcie
2,66, a #73695E dokladnie 2,999, czyli wciaz ponizej progu.
2026-08-14 09:50:08 +02:00
goon-foss
f3f16b5c67 feat(favorites): ulubione studia licza takze filmy
Studio bywa WYLACZNIE filmowe: Jonathan Jordan XXX 0 scen / 654 filmy,
Kink Clips 0/616, EAGLE 0/523. Lista ulubionych pokazywala wtedy samo
"0 scenes", czyli wygladala na pusta, mimo ze badge "+N" liczyl filmy
juz od 2026-07-28 (_merged_new_counts).

Backend: `FavoriteStudioOut.movie_count`, liczony NA ZYWO. Ulubionych
studiow jest kilkaset na urzadzenie, a filmow o rzedy wielkosci mniej niz
scen, wiec agregat jest tani i nie trzeba kolejnej zdenormalizowanej
kolumny do utrzymania.

Apka pokazuje oba liczniki, a gdy jednego nie ma, pomija go, zeby nie
robic z tego "0 scenes · 654 movies".

Domyka to temat zaczety zakladka Movies na ekranie studia: dodanie do
ulubionych od strony filmu juz dzialalo (gwiazdka w StudioMoviesScreen).
2026-08-13 20:30:17 +02:00
goon-foss
1a75433281 fix(proxy): rozdziel wygasly podpis od awarii CDN + ponow przejsciowe 5xx
Zgloszenie 7f2234eb: "Cos nie tak z ekstraktorem? Bo samo wideo dziala a
aplikacja zglasza problem z hostem" (scena galaxyporn).

Ekstraktor jest SPRAWNY. Sprawdzone end-to-end: resolve 200, master m3u8,
wariant, 525 segmentow, 60/60 pobranych. Telemetria 30 dni dla tego
zrodla: 1310 sukcesow, 25 bledow (1,9%).

Ale przy okazji wyszlo cos wiekszego. Wszystkie te bledy to "502", a
mapowanie w `_upstream_error_response` sklejalo w ten kod DWIE rozne
awarie: upstream 5xx (CDN padl) ORAZ kazde inne 4xx, w tym 403. Test
syntetyczny: upstream oddaje 403, nasze proxy oddawalo 502.

To bolalo podwojnie. `playback_events.error_detail` bierze sie z komunikatu
ExoPlayera, wiec w telemetrii nie dalo sie odroznic "CDN padl" od "wygasl
podpisany token" — a to zupelnie inne naprawy (pierwsze: czekac, drugie:
odswiezyc resolve). Do tego 403 szedl przez raise jako "moze nasz bug" i
zasmiecal Sentry, choc wygasanie tokenow na tych CDN-ach jest NORMALNE.

Zmiany:
1. 401/403 przepuszczane z prawdziwym kodem (+ log INFO, bez wyjatku).
   Cache i tak byl juz invalidowany na 401/403/404/410, wiec kolejny
   resolve dostaje swiezy URL.
2. Przejsciowe 502/503/504 ponawiane RAZ (tylko GET/HEAD, idempotentne).
   Te CDN-y stoja na wielu wezlach (galaxyporn: 3 rozne IP w jednej
   sesji), wiec druga proba zwykle trafia w zdrowy. 4xx nie ponawiamy.

Zweryfikowane: 403 wychodzi teraz jako 403 (bylo 502), normalne
odtwarzanie bez zmian (60/60 segmentow 200).

Uwaga: sciezka ponowienia nie zostala odpalona end-to-end, bo 502 nie da
sie wywolac na zadanie. Widac ja w logu ("proxy: upstream N, ponawiam").
2026-08-13 18:16:54 +02:00
goon-foss
dde21f2e6e fix(thumbs): degraduj chwiejne hosty miniaturek zamiast zerowac
Zgloszenie "Jakis problem z miniaturkami z fullporner" (36c33200).
fullporner NIE jest winowajca: pomiar 2026-08-13 dal 40/40 poprawnych
JPEG-ow i 0 z 68 414 zrodel bez URL-a. Winna byla KOLEJNOSC WYBORU.

Miniaturke wygrywal pierwszy origin alfabetycznie, a `tube:fullmoviesxxx`
sortuje sie PRZED `tube:fullporner`, wiec chwiejny host przykrywal zdrowy.
Pomiar (20-25 losowych URL-i na host):

    img.fullmovies.xxx    12/20 ok  (5x ReadTimeout, 3x 502)  54 019 zrodel
    img.hdporn.gg         16/20 ok  (3x 502, 1x ReadTimeout)  54 267 zrodel
    imgs.xiaoshenke.net   20/20 ok  (fullporner)              68 406 zrodel
    freshporno.org        20/20 ok

Nowy predykat `_is_flaky_thumb`: degraduje do tieru fallback w LISCIE, ale
w odroznieniu od `_is_rotting_thumb` NIE zeruje w SceneDetail. Zerowanie
powtorzyloby wpadke z sierpnia, gdzie sceny majace tylko fullporner
tracily obrazek calkowicie.

Dwie iteracje, obie z pomiaru, nie z zalozenia:

1. Degradacja samego fullmovies przesunela wybor na hdporn (44 z 60),
   czyli PRZENIOSLA problem. Stad hdporn dopisany.
2. Po zdegradowaniu obu fullmovies WROCIL (38 z 60): sceny majace tylko
   te dwa hosty laduja w fallbacku, a `setdefault` bral pierwszy
   alfabetycznie, czyli gorszy z dwoch. Stad `_FLAKY_RANK` — w obrebie
   fallbacku wybieramy mniejsze zlo.

Po zmianie w dwoch niezaleznych losowaniach fullmovies nie wygrywa nigdzie,
a sceny majace WYLACZNIE chwiejne hosty dostaja hdporn (40/40, zero bez
miniatury), wiec nic nie traci obrazka.
2026-08-13 15:34:40 +02:00
goon-foss
276fb4c24d fix(dedup): samozlaczenie po phashu odsiewa zdegenerowane wartosci
`_pairs_exact_phash` laczylo scene_fingerprints po wartosci BEZ zadnego
ograniczenia. Do tej pory uchodzilo, bo zdegenerowanych wartosci bylo
malo i blacklista je kasowala. Zmienia to backfill: dokladamy ~240 tys.
odciskow z xvideos/xnxx/epornera, a te serwuja zaslepki przy usunietych
filmach.

Dwa problemy naraz:

1. Blacklista odswieza sie RAZ NA DOBE, wiec swiezo powstala zaslepka ma
   pelna dobe okna na wpuszczenie do potoku tysiecy niepowiazanych scen.
2. Samozlaczenie jest KWADRATOWE po liczbie scen dzielacych wartosc:
   zaslepka przy 5 tys. scen to 12,5 mln par i job pada na pamieci,
   zanim cokolwiek zdazy ja zablacklistowac.

Teraz zapytanie samo odsiewa wartosci dzielone przez >=10 scen (ten sam
prog co phash_blacklist) ORAZ te juz zablacklistowane, wiec dziala
natychmiast, nie po dobie.

NIE jest to atrapa: 4% w backfill jedna taka wartosc juz istnieje
(12 scen) i blacklista jej jeszcze nie widziala. Po zmianie samozlaczenie
daje 23 555 par w 8,3 s.
2026-08-13 14:50:46 +02:00
goon-foss
91c2ce6e0a feat(cleanup): archiwum przed kasowaniem bledych przypisan performerow
Kasowane wiersze ida najpierw do `scene_performers_pruned`, w TEJ SAMEJ
transakcji co DELETE: albo wiersz jest zapisany i skasowany, albo nie
dzieje sie nic. Cofniecie to `INSERT ... SELECT`, nie backup calej bazy.
Archiwum trzyma tez nazwe performera i tytul sceny, wiec da sie je
przejrzec bez odtwarzania joinow. Tabela swiadomie poza alembikiem: to
slad po jednorazowym sprzataniu, nie czesc schematu.

Kryterium doprecyzowane po dwoch losowych probkach:

1. „brak nazwy w tytule" samo w sobie kasowalo MESKICH performerow (tuby
   nie wypisuja ich w tytulach, przypisanie szlo z listy obsady) -> stad
   warunek „inny performer sceny ma dowod I dzieli czlon".
2. Dowodem dla nazwy o >=2 czlonach jest kazdy czlon jako OSOBNE SLOWO,
   nie spojna fraza: „Silvia and Eveline Dellai" to scena Silvii Dellai,
   ale ciagu „silviadellai" tam nie ma (1 blad na 22 w probce). Nazwy o
   jednym uzytecznym czlonie („Eliza E") zostaja przy ostrym kryterium,
   bo inaczej lapia kazda inna Elize.
3. Granica slowa przez `strpos` na znormalizowanym tekscie, NIE regex:
   nazwy z nawiasami wywalaly caly przebieg („parentheses not balanced"),
   a metaznaki po cichu psulyby dopasowania.

Dry-run: 321 833 przypisania (vs 370 587 przed poprawka 2). Szkoda
rozproszona, nie skoncentrowana: rekordzistka traci 20 z 909.
2026-08-13 12:16:13 +02:00
goon-foss
5217d07222 fix(search): nazwa performera musi byc w slugu SPOJNA FRAZA
Zgloszenie 2026-08-13: strona Tiffani Time pelna scen o Tiffany Tatum,
Tiffani Madison i Victorii Tiffani.

Przyczyna: `_search_base.search` dokleja szukanego performera do KAZDEJ
sceny bezwarunkowo (`all_performers = [RawPerformer(name=query), ...]`),
wiec cala poprawnosc obsady wisi na filtrze tokenow. A filtr testowal
PODCIAG na luznych tokenach, wiec:

  "tiffany" ⊂ "lp-s-tiffany-love-first-time-gonzo"   (inna osoba)
  "time"    ⊂ "adult-time", "first-time", "multiple-times"  (pospolite slowo)

Historia tej linijki to trzy poziomy: any() -> all() -> (teraz) fraza.
`all()` naprawil "Ava Koxxx na scenach Avy Devine", ale zostawil oba
powyzsze. Teraz slug musi zawierac CALA nazwe jako ciag, z granica slowa
po bokach, z dowolnym separatorem miedzy czlonami.

Dozwolone koncowe "s": slug gubi apostrof, wiec "TiffaniTime's" zapisuje
jako "tiffanitimes". Nie luzuje bramki, bo przed tym "s" stoi cala fraza.

hqporner mial WLASNA kopie tego filtru (i wlasny zestaw dziur) - podpiety
pod ta sama funkcje, zeby sie nie rozjezdzalo.

12 przypadkow testowych z realnych danych, w tym te ktore MUSZA przejsc
(tiffani-time-*, tiffanitimes-*, mia-li-* z czlonem <3 znaki).

Do tego skrypt czyszczacy juz istniejace pomylki. Kryterium zawezone po
obejrzeniu probki: samo "brak nazwy w tytule" kasowalo MESKICH performerow
(tuby nie wypisuja ich w tytule, a przypisanie szlo z listy obsady, nie
z searcha). Dolozony warunek "inny performer sceny ma dowod I dzieli
czlon" - probka 14/14 to realne pomylki.
2026-08-13 11:17:43 +02:00
goon-foss
a16883d2f3 feat(dedup): backfill phasha z zapisanych miniaturek
Root cause zgloszenia 9d272101 ("5 duplikatow, czemu nie zmergowane?"):
phash mialo tylko 37,8% grywalnych scen. Licza go WYLACZNIE niektore
konektory w momencie ingestu, wiec najwieksze zrodla maja miniaturke dla
~100% scen, a odcisk dla 1-2% (xvideos 1,8%, xnxx 1,4%, eporner 1,0%).
Material jest zapisany, brakowalo tylko obliczenia.

W zglaszanym przypadku 7 wierszy o identycznej dlugosci 2324 s, a dwa
kafelki z IDENTYCZNA okladka nie mialy phasha wcale, wiec jedyny sygnal,
ktory by je sklail, nigdy nie powstal.

Zakres CELOWANY, nie caly katalog: sceny w klastrze (performer, dokladna
dlugosc) o licznosci >1, bez phasha, ale z zapisana miniaturka = 238 704
pozycje (wobec ~1,4 mln scen bez phasha). Reszta nie ma sie z czym skleic.

Kolejka jako TABELA, nie zapytanie w locie: wyznaczenie kandydatow to
ciezki agregat po calym katalogu, ktorego nie chcemy powtarzac przy kazdej
paczce, a kolejka przezywa restart workera i widac z niej postep.

Weryfikacja end-to-end na zglaszanej scenie: ce81a110 dostal phash
85c1bac2f8c60ecf, czyli DOKLADNIE ten sam, ktory mial juz 8373bbb5 (dwa
z kafelkow uzytkownika) -> istniejacy phash_exact je teraz sklei.

Nie zapisujemy wartosci z phash_blacklist (zaslepki dzielone przez setki
scen). Throttling swiadomy: 800 co 15 min, ~3 dni na nadrobienie.

Dwie pulapki SQL zapisane w kodzie: `referer` ma 255 znakow wiec origin
wycinamy w SQL (pelny page_url wywalal planowanie), a rzutowanie musi isc
przez CAST() bo text() SQLAlchemy nie podstawia parametru za ktorym stoi
`::`.
2026-08-13 10:49:12 +02:00
goon-foss
ff898b0783 feat(verticals): animacja odgrodzona od glownego feedu + filmy na ekranie studia
1. WERTYKALY. Hentai i 3D szly do glownego feedu wymieszane z zachodnim
katalogiem, mimo ze argument za odgrodzeniem byl ten sam co przy JAV.
Zamiast drugiej kopii mechanizmu JAV wprowadzam wspolne pojecie wertykalu
(app/verticals.py): mapa nazwa -> originy, jeden predykat, `?vertical=`.

Brama po originie jest DOKLADNA, zmierzone: 9507 scen animowanych, ZERO
z originem spoza listy (nie maja sie z czym zdeduplikowac). Dlatego osobna
baza nie jest potrzebna, rozdzielam logicznie.

Tagi to samodzielny powod: z 3116 tagow przy scenach animowanych tylko 452
wystepuja tez poza animacja, czyli 85% to slownictwo wylacznie animacyjne.
`/tags?vertical=` liczy je NA ZYWO (male zbiory, zero migracji), a
zdenormalizowany Tag.scene_count opisuje juz WYLACZNIE glowny feed.

Przy okazji naprawione zasmiecenie JAV-em istniejace od lipca:
taxonomy_counts wliczal wertykaly do licznikow, ktore opisuja feed ich
nie pokazujacy. Refresh: 1476 tagow, 1402 performerow, 154 studia.

Stary `?jav=true` DZIALA dalej (instalacje sprzed sierpnia go wysylaja).

2. FILMY NA EKRANIE STUDIA. Zgloszenie: polubione studio od filmow nie
pokazuje nowych filmow i nie ma "+1". Backend liczyl je poprawnie
(_merged_new_counts scala sceny i filmy od 2026-07-28). Blad byl w apce:
tap na ulubione studio ZEROWAL licznik (markStudioSeen) i wchodzil na
ekran wylacznie ze scenami, wiec "+N" z nowych filmow znikalo, a filmy
nie mialy sie gdzie pokazac. StudioMoviesScreen istnial, ale wchodzilo
sie do niego tylko z detalu filmu. StudioScenes dostaje zakladki
Scenes/Movies, jak PerformerScenes.

Pasek zakladek w headerze przewija sie w poziomie, bo piata pozycja
(Anime) nie miescila sie na waskich telefonach.
2026-08-13 10:31:54 +02:00
goon-foss
bb1b502e9a docs(site): strona goon-foss.org do repo + aktualizacja o nowosci
Strona istniala WYLACZNIE na serwerze (wolumen infisical-caddy), a cala
historia to byly kopie index.html.bak-* obok pliku. Wjezdza do repo.

Tresc zaktualizowana o to, czego strona nie mowila:
- filmy (45k) w ogole nie byly wspomniane, mimo osobnej zakladki
- "30+ public tubes" -> 46 realnych zrodel
- pasek liczb w hero (3.8M scen, 108k performerow, 45k filmow, 46 zrodel)
- zakladki: scenes/movies/sites/JAV zamiast samego grida scen
- sekcja "What's new": animacja i 3D, filmy, JAV we wlasnej zakladce

Makiety w sekcji "In the app" zastapione prawdziwymi zrzutami (byl tam
komentarz zeby to zrobic po wgraniu realnych screenow). Miniaturki scen
sa na nich rozmyte. Przy okazji -100 linii martwego CSS-u makiet.
2026-08-13 09:54:25 +02:00
goon-foss
1c1e46130a feat(3d): konektor naughtymachinima - szoste zrodlo wertykalu
Machinima i animacje SFM/Blender. Kanon nie istnieje tak samo jak przy
hentai i rule34video, wiec kazda pozycja jest orphanem i to poprawne.

Najzdrowsza jednostka tresci calego wertykalu 3D: mediana 289 s, ZERO
pozycji ponizej minuty, 18/25 powyzej progu potoku. Dla porownania
rule34video ma mediane 68 s i traci ~70% katalogu.

Playback najprostszy z serii: jawne <source> w trzech jakosciach, bez
tokenu, bez AJAX-a, bez CF. Zweryfikowane z VPS: 206, ftypisom, 140 MB,
dziala bez Referera, wiec mobile_direct_ok i zero pracy po stronie apki
(inaczej niz rule34video, gdzie token byl IP-bound).

Szosty niezalezny host wertykalu (videos.naughtymachinima.com), zadna
z szesciu stron nie re-hostuje pozostalych.

Odsiewamy pusty <source src=""> (placeholder playera) i preroll
/images/preroll/NMintro.mp4, ktory pasuje do naiwnego \.mp4.

Bramka dlugosci zestrojona z min_ingest_duration_sec, jak przy
rule34video. Pilot 3 strony: seen 48, new 48, skipped 0, errors 0.
2026-08-13 09:06:24 +02:00
goon-foss
2939e03d59 feat(3d): konektor rule34video - piate zrodlo wertykalu, pierwsze 3D
Animacje SFM/Blender. Kanon nie istnieje tak samo jak przy hentai, wiec
kazda pozycja jest orphanem i to poprawne.

Playback: KVS z jawnymi flashvars (4 jakosci, bez license_code), ale
`v-acctoken` jest ZWIAZANY Z IP tego, kto pobral strone. Test krzyzowy:
URL wygenerowany przez proxy i pobrany z VPS = 403, ten sam URL z IP
generujacego = 206 ftypisom 70 MB. Po zdekodowaniu tokenu adresu nie
widac wprost (inaczej niz fpoxxx), wiec samo ogladanie nie wystarcza.

Dlatego bez mobile_direct_ok: telefon resolwuje sam, UPFRONT, istniejaca
sciezka isGetFileUrl()/resolveGetFilePage z SceneDetailScreen. Rozszerzam
getfileResolver o flashvars, bo dotad umial tylko <source> (hdporn.gg,
fullmovies.xxx). Zweryfikowane end-to-end z residential IP: strona ->
flashvars 720p -> 302 na boomio-cdn -> 206 ftypisom 39,8 MB.

Bramka dlugosci zestrojona z `min_ingest_duration_sec` (prod 180 s)
zamiast wlasnej liczby: pierwsza wersja miala 60 s i liczniki klamaly
(seen 57, new 31, skipped 26). Po zestrojeniu seen 20, new 20, skipped 0.
Krotka tresc wycina ~70% katalogu (mediana 68 s).

Tagi TYLKO z class=tag_item. `class=button` wyglada na wlasciwe, a jest
chmura "Top Tags": przy filmie o Lux siedzialy tam "cell (dbz)" i
"queen of pain (dota)". Autor animacji zapisany jako studio.
2026-08-13 08:47:04 +02:00
goon-foss
fcfac697cf feat(hentai): konektor hentaiplay.net - czwarte zrodlo wertykalu
CZWARTY niezalezny host: pliki na hentaiplanet.info, podczas gdy hentaimama ma
gdvid.info, xanimeporn download.xanimeporn.com, a hentaigasm hgasm<N>.com. Cztery
strony, cztery osobne pule plikow - zadna nie re-hostuje pozostalych, mimo ze katalogi
odcinkow mocno sie pokrywaja.

Metadane z RSS-a, tak jak przy hentaigasm i z tego samego powodu: /genre/ w HTML strony
to 2084 linki, najwieksza chmura z sidebara w calym wertykale (hentaigasm mial 38,
hentaimama 118). Feed daje date, tytul i realne kategorie. Odsiewamy przy tym pozycje
czysto techniczne (Hentai Anime, New Hentai, English Subbed) oraz rocznik, bo to nie sa
tagi tresci. /page/2/ nie zwraca odcinkow, /feed/?paged=N stronicuje chronologicznie.

Playback: mp4 wprost w HTML (video.js), bez AJAX-a i bez Cloudflare. Pulapka: serwis
nazywa miniaturki od pliku wideo (…-RAW.mp4_snapshot_07.59.jpg), wiec naiwne szukanie
.mp4 lapie zarowno wideo, jak i obrazek. Wymagamy URL-a KONCZACEGO sie na .mp4 i
odrzucamy wp-content/uploads.

Druga pulapka, zlapana dopiero testem: og:image na tej stronie NIE ISTNIEJE, miniaturka
jest tylko w JSON-LD i z escapowanymi ukosnikami. Pierwsza wersja dawala 12/12 odcinkow
bez miniaturki.

Zweryfikowane z VPS: 206 na Range, ftypmp42, 78 MB, dziala BEZ Referera i bez tokenu.
Serwer oddaje content-type application/octet-stream, ale sciezka konczy sie .mp4, wiec
ExoPlayer wybierze Mp4Extractor po rozszerzeniu.

Pilot: seen 48, new 48, errors 0. Komplet dat i miniaturek.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 15:31:21 +02:00
goon-foss
a5295be10e feat(hentai): konektor hentaigasm.com - trzecie zrodlo wertykalu
TRZECI niezalezny host w wertykale. Te same odcinki co hentaimama i xanimeporn, ale
znowu inne pliki: tutaj hgasm<N>.com/<Tytul Ze Spacjami>.mp4, tam gdvid.info/N/... i
download.xanimeporn.com/videos/1080p/[1080p]<id>.mp4. Zadna z trzech stron nie
re-hostuje pozostalych.

Metadane w CALOSCI z RSS-a i to nie skrot tylko koniecznosc:
- strona odcinka NIE MA daty publikacji w zadnym standardowym miejscu (ani JSON-LD,
  ani article:published_time, ani <time>), a feed ma pubDate
- /genre/ w HTML to 38 linkow, czyli kolejna chmura z sidebara; feed daje 5 realnych
  kategorii, gdzie pierwsza to nazwa serii
- /page/2/ nie zwraca odcinkow w ogole, a /feed/?paged=N stronicuje chronologicznie

Playback najprostszy z calej trojki: konfiguracja JWPlayera z gotowym file: wprost w
HTML, bez AJAX-a i bez Cloudflare. Sciezka zawiera SPACJE, wiec kodujemy sama sciezke,
zostawiajac schemat i host. Host ROTUJE miedzy odcinkami (hgasm1/hgasm3), wiec czytamy
go ze strony zamiast wpisywac na sztywno.

Zweryfikowane z VPS: 206 na Range, ftypisom, 198 MB, dziala BEZ Referera i bez tokenu,
wiec URL nie wygasa. mobile_direct_ok.

Pilot: seen 107, new 107, errors 0. Metadane: 100 procent z data, miniaturka i tagami.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 14:57:28 +02:00
goon-foss
7a8e10c175 feat(hentai): konektor xanimeporn.com - drugie zrodlo wertykalu
NIE jest re-hostem hentaimamy i to bylo pierwsze, co sprawdzilem. Wspolny odcinek
(Shoujo Ramune 7) lezy na obu stronach, ale to zupelnie inne pliki: hentaimama serwuje
gdvid.info/S/shoujo-ramune-7.mp4 w jednej jakosci bez etykiety, a tutaj jest
download.xanimeporn.com z jawnym wyborem 1080p (222 MB) i 720p (84 MB).

Listing idzie z RSS-a i to jedyna droga, ktora dziala. Sprawdzone trzy inne:
- /page/2/ i /page/3/ oddaja ten sam zestaw co strona 1 (28 z 32 wspolnych),
  czyli paginacja zepsuta jak przy 8kporner
- /uncensored/ i /censored/ stronicuja poprawnie, ale NIE sa chronologiczne: pierwsze
  pozycje z 2024, zakres 2015-2026. Browse-latest nigdy nie zobaczylby nowego odcinka,
  czyli pulapka z freshporno. Pierwsza wersja konektora wlasnie tak dzialala i dopiero
  test na danych to pokazal
- /hentai-list/ ma caly katalog (1048) na jednej stronie, ale nie jest chronologiczny

Drugi filtr tez wymuszony przez dane: slugi gatunkow (/futanari/, /big-tits/) sa takimi
samymi slugami najwyzszego poziomu jak odcinki i wchodzily jako pozycje bez daty i
miniaturki. _parse_detail wymaga teraz postid w klasie body, ktory maja tylko
pojedyncze wpisy. Ten sam identyfikator jest potrzebny extractorowi.

Playback: Fluid Player z PUSTYM <source src=''>, wideo wstrzykiwane w runtime, wiec w
HTML nie ma zadnego linku. Wyjsciem jest zakladka Downloads:
videos.xanimeporn.com/download.php?id=<postId>&quality=. Oddajemy download.php, NIE
docelowy plik, zeby token generowal sie przy odtwarzaniu i nie wietrzal (lekcja z
watchporn). Bez Cloudflare i bez proxy, w odroznieniu od hentaimamy.

Pilot: seen 50, new 50, errors 0. Metadane: 100 procent z data, miniaturka, kategoria
i seria jako tagi.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 14:48:13 +02:00
goon-foss
85ee42d5a3 feat(hentai): konektor hentaimama.io - pierwsze zrodlo wertykalu hentai
Jednostka tresci to ODCINEK (/episodes/<seria>-episode-N/), mapowany na scene tak samo
jak w wertykale JAV, bez zmian w schemacie. Seria ladue jako tag, wiec odcinki daja sie
grupowac.

KRYTERIUM JEST TU INNE I TO CELOWE. Dla animacji nie istnieje kanon - TPDB i StashDB
nie maja rysowanych postaci - wiec kazda scena stad jest technicznie orphanem i to
jest POPRAWNE. Nie stosujemy ani bramki na obsade (nie ma obsady), ani miary canonical
match, ktora sluzyla nam do oceny tubow z zywymi aktorami. Jakosc mierzymy kompletem
seria + odcinek + gatunki.

Motyw jest z rodziny Dooplay, ale DooplayConnector NIE pasuje: brak data-fl-source i
data-post, na ktorych stoi tamten parser. Player idzie w trzech krokach: id posta ze
strony odcinka, POST /wp-admin/admin-ajax.php z action=get_player_contents, potem
strona wlasnego playera, ktora oddaje bezposredni mp4. Sam POST jest za Cloudflare
(GET-y przechodza), wiec wymaga Bright Data, dokladnie jak POST /api/video-info w sexu.

Playback najlepszy z mozliwych: preferujemy gdvid.info, bo oddaje plik BEZ tokenu,
wiec URL nie wygasa. Zweryfikowane z VPS: 206 na Range, ftypisom (faststart), dziala
BEZ Referera, stad mobile_direct_ok.

Pulapka obeszla: /genre/ w calym HTML to 118 linkow (chmura w sidebarze, ten sam wzorzec
co przy sexu i yourdailypornmovies). Gatunki bierzemy tylko z div.sgeneros, co daje 5-10
realnych pozycji na odcinek.

Pilot: seen 90, new 90, errors 0. W bazie 90 odcinkow, 100 procent z data i tagami,
zakres 2025-12-29 do 2026-07-31.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 13:35:10 +02:00
goon-foss
a4486980a4 style: myslniki em/en z komentarzy i docstringow (calosc repo)
1745 wystapien w 291 plikach (backend, scripts, mobile). Dominowal jeden ksztalt,
' — ' laczace zdania, wiec poszedl przecinek; myslnik otwierajacy mysl zdjety;
zakresy 0–5 i A–Z na dywiz.

Podzial komentarz/kod robil skaner stanow, nie regex, zeby nie ruszyc myslnika
stojacego w stringu albo w wyrazeniu regularnym. Potwierdzone maszynowo: po wycieciu
komentarzy z wersji HEAD i z dysku obie sa identyczne we wszystkich 291 plikach,
czyli poza komentarzami nie zmienilo sie nic. 233 pliki .py parsuja sie bez bledu,
tsc ma te same 11 bledow w tych samych 3 plikach co przed zmiana.

Dwie rzeczy wymagaly poprawki po automacie: wyrownane listy definicji, gdzie myslnik
byl separatorem kolumny (37 miejsc, kolumna przywrocona), i trzy przeniesienia mysli
do nowej linii, gdzie zostawal wiszacy przecinek na poczatku (poprawione recznie).

Bundla nie republikuje: Hermes i tak nie niesie komentarzy, wiec dla uzytkownika
to zero zmiany.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:51:57 +02:00
goon-foss
017927793a style(mobile): myslniki em/en poza tekstow widocznych dla uzytkownika
32 miejsca w 14 plikach: changelog, onboarding, ekrany Sites/Blacklist/Login/Player,
komunikaty bledow, placeholdery. Zamiast myslnika przecinek, dwukropek, kropka albo
nawias, zaleznie od tego co dane zdanie robi.

Zostawione swiadomie: pojedynczy glif U+2014 jako znacznik BRAKU WARTOSCI
(release_year ?? '—', nieocenione osie w SitesScreen, separator zakresu w filtrze
filmow). To element interfejsu, nie proza, a zwykly dywiz wyglada tam gorzej.
Zostawione tez komentarze w kodzie, w tym te wewnatrz INJECTED_JS.

Przy okazji: MigrationBanner dostal twardy warunek na runtime (needsManualMigration).
Wczesniej przed pokazaniem go nowej wersji chronilo TYLKO to, ze kanal OTA 1.2 byl
pusty. Pierwsza publikacja na 1.2 wyslalaby ten sam bundle i baner kazalby migrowac
z wersji, na ktora user wlasnie przeszedl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:29:21 +02:00
goon-foss
007f84a236 docs: kanal OTA domyslnie 1.2
RUNTIME_DEFAULT 1.1 -> 1.2 (APK 0.2.2 z expo-keep-awake). Kanal 1.1 zostaje zywy
dla instalacji sprzed 0.2.2, ktore nie zaktualizuja sie in-place (stary klucz
podpisujacy przepadl) i dostaly baner migracji; poprawki dla nich publikuj jawnie
przez --runtime 1.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:11:14 +02:00
goon-foss
635017c993 release: /version na 0.2.2
Wgrane razem z APK. Bez tego stary dialog Update available wyskakuje z mylacym
Server 0.2.1 / App 0.2.2 (bundle OTA niesie app.json, wiec APP_VERSION raportuje
juz 0.2.2 nawet na starym APK).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 09:57:48 +02:00
goon-foss
e747b38d70 fix(mobile): EXPO_RUNTIME_VERSION w AndroidManifest na 1.2
Katalog android/ jest w repo (bare workflow), wiec zmiana runtimeVersion w app.json
sama nie przepisuje manifestu, robi to dopiero prebuild. Bez tego APK 0.2.2 siedzialoby
na kanale OTA 1.1 i dostaloby baner migracji, czyli kazaloby migrowac jeszcze raz,
w kolko. Zlapane przed wydaniem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 09:48:48 +02:00
goon-foss
1a6ddfef40 fix(mobile): skroc tresc banera migracji + changelog + em-dash w dialogu instalacji
Weryfikacja na emulatorze (build debugowy, Metro): pasek i modal renderuja sie
poprawnie, kod synchronizacji sie pokazuje, przycisk otwiera goon-foss.org.
Tresc byla za dluga, urywala sie na naglowku kroku 4 i wygladala jak zepsuta;
po skroceniu miesci sie bez scrollowania.

Przy okazji potwierdzone na dexach: ExpoKeepAwake/KeepAwakeModule wchodzi teraz
do APK, wiec keep-awake zadziala po wydaniu 0.2.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 09:32:04 +02:00
goon-foss
0c818ac1df feat(mobile): baner migracji na nowo podpisane APK + expo-keep-awake do buildu
Klucz podpisujacy stare buildy przepadl, wiec update in-place jest niemozliwy:
Android nadpisze aplikacje tylko buildem z tym samym podpisem. Odinstalowanie
czysci SecureStore, a w nim goon.device_id, czyli kod synchronizacji, bez ktorego
ulubione/postep zostaja na serwerze bez sposobu wskazania ich. Baner pokazuje kod
na wierzchu i prowadzi przez kroki (kopiuj kod, pobierz, odinstaluj, wklej kod).

expo-keep-awake: sprawdzenie stringow w dexach APK 0.2.1 potwierdzilo, ze modulu
ExpoKeepAwake tam nie ma (jest tylko interfejs z expo-modules-core), wiec ekranu
gasnacego w trakcie ogladania NIE dalo sie naprawic przez OTA. Teraz jawna
zaleznosc, autolinking ja widzi, wchodzi do APK 0.2.2 (runtime 1.2).

UWAGA przy publikacji banera na runtime 1.1: manifest bierze runtimeVersion z
argumentu --runtime, wiec 1.1 jest serwowane poprawnie mimo app.json=1.2, ale
extras nadal niosa app.json (APP_VERSION zaczyna raportowac 0.2.2). Dlatego
serwerowe /version trzeba bumpnac do 0.2.2 razem z wgraniem APK, inaczej stary
dialog Update available wyskoczy z mylacym Server 0.2.1 / App 0.2.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 09:17:30 +02:00
goon-foss
72b7b561e5 fix(thumbs): odblokuj miniaturki fullporner (atrapa byla przejsciowa)
Pomiar na 300 losowych URL-ach z bazy: 298 prawdziwych JPEG-ow (5-25 kB),
tak samo z VPS jak i z lacza domowego. Wpis w _is_rotting_thumb zerowal
miniature w SceneDetail, wiec sceny majace tylko fullporner traciły ja
calkowicie (53 tys. zrodel).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 09:10:13 +02:00
388 changed files with 19013 additions and 3436 deletions

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
# Latki patch-package musza miec LF, inaczej nie naloza sie na pliki z npm (autocrlf na Windowsie).
mobile/patches/*.patch text eol=lf

View file

@ -0,0 +1,47 @@
"""phash_backfill_queue: kolejka scen do policzenia phasha z zapisanej miniaturki
Uzasadnienie i liczby: `app/models/phash_backfill_queue.py`.
Revision ID: 0028_phash_backfill_queue
Revises: 0027_scenes_title_trgm
Create Date: 2026-08-13
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0028_phash_backfill_queue"
down_revision: str | None = "0027_scenes_title_trgm"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"phash_backfill_queue",
sa.Column("scene_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("thumbnail_url", sa.String(length=1024), nullable=False),
sa.Column("referer", sa.String(length=255), nullable=True),
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
sa.Column("done_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.ForeignKeyConstraint(["scene_id"], ["scenes.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("scene_id"),
)
# Jedyne zapytanie ścieżki gorącej: „weź N nieprzetworzonych". Indeks częściowy,
# bo przetworzone wiersze zostają w tabeli jako ślad, ale nigdy ich nie czytamy.
op.create_index(
"ix_phash_backfill_pending",
"phash_backfill_queue",
["attempts"],
postgresql_where=sa.text("done_at IS NULL"),
)
def downgrade() -> None:
op.drop_index("ix_phash_backfill_pending", table_name="phash_backfill_queue")
op.drop_table("phash_backfill_queue")

View file

@ -0,0 +1,50 @@
"""scenes.max_quality_p: zdenormalizowana najwyzsza znana jakosc zrodel sceny
Po co, skoro filtr dalo sie napisac zapytaniem: NIE dalo sie go napisac szybko.
Zmierzone na produkcji 2026-08-18 (EXPLAIN ANALYZE, prog 1080p):
EXISTS na playback_sources 32 s (3 556 612 odwiedzonych scen)
+ indeks funkcyjny per-scena 24 s
JOIN z DISTINCT po jakosci 1,1 s W IZOLACJI, ale
ten sam JOIN + reszta filtrow 52 s (planer wraca do przechodzenia scen)
Ostatni wiersz jest sednem: w polaczeniu z bramka wertykalow, heurystyka szkieletow
i progiem dlugosci planer i tak wybiera przejscie `scenes` po `created_at` i testowanie
warunku scena po scenie. Kaztaltem zapytania sie tego nie ominie, bo koszt to LICZBA
odwiedzonych scen, a nie pojedynczy test. Skalarna kolumna zdejmuje problem: staje sie
zwyklym filtrem, takim jak `duration_sec`, ktory jest szybki.
NULL = nie znamy jakosci zadnego zywego zrodla (93,5% katalogu: `quality` zapisujemy
oportunistycznie, tylko gdy listing zrodla ja podaje). Filtr celowo takie sceny ODSIEWA,
patrz komentarz przy QUALITY_OPTIONS w mobile/src/screens/ScenesFilterModal.tsx.
Revision ID: 0029_scenes_max_quality
Revises: 0028_phash_backfill_queue
Create Date: 2026-08-18
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision: str = "0029_scenes_max_quality"
down_revision: str | None = "0028_phash_backfill_queue"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("scenes", sa.Column("max_quality_p", sa.SmallInteger(), nullable=True))
# (max_quality_p, created_at DESC): filtr to zakres po pierwszej kolumnie, a sort
# feedu idzie po drugiej. Partial, bo 93,5% scen ma NULL i nie ma po co ich indeksowac.
op.create_index(
"ix_scenes_max_quality_created",
"scenes",
["max_quality_p", sa.text("created_at DESC")],
postgresql_where=sa.text("max_quality_p IS NOT NULL"),
)
def downgrade() -> None:
op.drop_index("ix_scenes_max_quality_created", table_name="scenes")
op.drop_column("scenes", "max_quality_p")

View file

@ -0,0 +1,50 @@
"""Indeksy czesciowe pod progi filtra jakosci (720/1080/2160)
Dlaczego osobne indeksy na prog, a nie jeden na (max_quality_p, created_at):
tamten nie da sie uzyc do `ORDER BY created_at DESC LIMIT n`, bo wynik nie jest
posortowany po created_at w obrebie ZAKRESU wartosci max_quality_p. Planer go
ignorowal i wracal do przejscia `scenes` po dacie z filtrem.
Indeks czesciowy z predykatem `max_quality_p >= N`, posortowany po created_at DESC,
jest dla progu N dokladnie tym, czego zapytanie potrzebuje: Postgres uzywa go, gdy
warunek zapytania implikuje predykat indeksu. Zmierzone na produkcji 2026-08-18:
0029 (sama kolumna) 2,0-2,6 s planer szedl ix_scenes_created_at_desc
0030 (te indeksy) 0,09-0,11 s Index Scan using ix_scenes_created_q1080
Progi sa ZWIAZANE Z UI: `QUALITY_OPTIONS` w mobile/src/screens/ScenesFilterModal.tsx
oferuje dokladnie 720/1080/2160. Inna wartosc w `?min_quality_p=` nadal dziala, tylko
wolniej (fallback do skanu po dacie). Dodajac opcje w UI, dodaj tu indeks.
Rozmiary sa pomijalne (4,3 MB / 2,7 MB / 0,3 MB), bo jakosc zna tylko 6,5% katalogu.
Revision ID: 0030_scenes_quality_idx
Revises: 0029_scenes_max_quality
Create Date: 2026-08-18
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision: str = "0030_scenes_quality_idx"
down_revision: str | None = "0029_scenes_max_quality"
branch_labels = None
depends_on = None
_THRESHOLDS = (720, 1080, 2160)
def upgrade() -> None:
for q in _THRESHOLDS:
op.create_index(
f"ix_scenes_created_q{q}",
"scenes",
[sa.text("created_at DESC")],
postgresql_where=sa.text(f"max_quality_p >= {q}"),
)
def downgrade() -> None:
for q in _THRESHOLDS:
op.drop_index(f"ix_scenes_created_q{q}", table_name="scenes")

View file

@ -0,0 +1,55 @@
"""duration_backfill_queue: kolejka scen do doliczenia dlugosci ze strony tuba
Uzasadnienie i liczby: `app/models/duration_backfill_queue.py`.
Revision ID: 0031_duration_backfill
Revises: 0030_scenes_quality_idx
Create Date: 2026-08-20
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# Uwaga: `alembic_version.version_num` to varchar(32), dluzsze id nie wejdzie.
revision: str = "0031_duration_backfill"
down_revision: str | None = "0030_scenes_quality_idx"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"duration_backfill_queue",
sa.Column("scene_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("page_url", sa.String(length=1024), nullable=False),
sa.Column("origin", sa.String(length=64), nullable=False),
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
sa.Column("done_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.ForeignKeyConstraint(
["scene_id"], ["scenes.id"], ondelete="CASCADE",
name="fk_duration_backfill_queue_scene_id_scenes",
),
sa.PrimaryKeyConstraint("scene_id", name="pk_duration_backfill_queue"),
)
# Paczka bierze wiersze niezrobione, po originie (zeby nie zawalic jednego hosta).
op.create_index(
"ix_duration_backfill_queue_pending",
"duration_backfill_queue",
["origin", "attempts"],
postgresql_where=sa.text("done_at IS NULL"),
)
def downgrade() -> None:
op.drop_index(
"ix_duration_backfill_queue_pending", table_name="duration_backfill_queue"
)
op.drop_table("duration_backfill_queue")

View file

@ -0,0 +1,51 @@
"""indeksy pod listowanie scen jednego serwisu sortowane po dacie premiery
Zgłoszenie 4cccc70f: „po kliknięciu sxyprn na liście sites sceny ładują się po ok
18 sekundach". Odtworzone co do sekundy: `/scenes?origin=tube:sxyprncom&
sort=release_date` schodziło 19,23 s, a to samo z `sort=created_at` 0,08 s.
Powód: ekran serwisu sortuje po dacie premiery, czyli
`release_date DESC NULLS LAST, created_at DESC, id DESC`. Istniejący
`ix_scenes_release_date` jest ROSNĄCY, więc czytany od tyłu daje NULL-e na
początku, a nie na końcu, i nie obsługuje tego układu ani dwóch kluczy
rozstrzygających. Bez pasującego indeksu planner leciał pełnym skanem `scenes`
(3,9 mln) i `playback_sources` (3,7 mln), sortował całość i dopiero brał 25 wierszy.
Z pasującym indeksem strumieniuje sceny już w docelowej kolejności i zatrzymuje się
po 25 trafieniach. Po założeniu na produkcji: sxyprn 19,23 s → 0,11 s,
hqporner 7,99 s → 0,87 s.
Drugi indeks (`origin`, `scene_id`) obsługuje wyszukanie źródeł jednego serwisu.
Na `playback_sources` nie było ŻADNEGO indeksu po originie.
`IF NOT EXISTS`, bo na produkcji oba założono od razu (CONCURRENTLY, żeby nie
blokować ruchu). Tu chodzi o to, by świeża instalacja miała to samo.
Revision ID: 0032_origin_release_idx
Revises: 0031_duration_backfill
Create Date: 2026-08-20
"""
from __future__ import annotations
from alembic import op
revision: str = "0032_origin_release_idx"
down_revision: str | None = "0031_duration_backfill"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"CREATE INDEX IF NOT EXISTS ix_scenes_release_created_id_desc "
"ON scenes (release_date DESC NULLS LAST, created_at DESC, id DESC)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_playback_sources_origin_live "
"ON playback_sources (origin, scene_id) WHERE dead_at IS NULL"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_playback_sources_origin_live")
op.execute("DROP INDEX IF EXISTS ix_scenes_release_created_id_desc")

View file

@ -0,0 +1,48 @@
"""indeks pod sortowanie po dacie premiery z fallbackiem na date dodania
Zgłoszenie 1df50de3 („hqporner: brak nowych scen"). Sceny były i ingest działał, tylko
tuby daty premiery zwykle NIE podają: xhamster 99,2% scen bez daty, xnxx 97,7%,
porntrex 95,8%, hqporner 93,5%, sxyprn 85,7%. Przy `release_date DESC NULLS LAST`
wszystkie bezdatowe lądowały ZA datowanymi, więc 82 z 91 scen dodanych w hqporner
przez tydzień siedziały za 3,4 tys. starszych pozycji i użytkownik widział pustkę.
Sortujemy więc po dacie premiery, a gdy jej nie ma, po dniu trafienia sceny do
katalogu. Sceny z prawdziwą datą (kanon z TPDB/StashDB) zachowują dotychczasową
kolejność.
Wyrażenie MUSI być immutable, żeby weszło do indeksu. `created_at::date` zależy od
strefy czasowej sesji (stable, nie immutable), stąd jawne `AT TIME ZONE 'UTC'`.
Musi też być IDENTYCZNE z `_effective_date_desc()` w `app/api/scenes.py`, inaczej
planner indeksu nie użyje i wróci do pełnego skanu.
Pomiar po założeniu: hqporner Index Scan bez sortowania, 0,49 s; sxyprn, xhamster
i porntrex po 0,02 s, wszystkie z dzisiejszymi scenami na górze.
`IF NOT EXISTS`, bo na produkcji założony od razu (CONCURRENTLY, bez blokowania ruchu).
Revision ID: 0033_effective_date_idx
Revises: 0032_origin_release_idx
Create Date: 2026-08-20
"""
from __future__ import annotations
from alembic import op
revision: str = "0033_effective_date_idx"
down_revision: str | None = "0032_origin_release_idx"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"CREATE INDEX IF NOT EXISTS ix_scenes_effective_date_desc ON scenes ("
" (COALESCE(release_date, (created_at AT TIME ZONE 'UTC')::date)) DESC,"
" created_at DESC,"
" id DESC"
")"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_scenes_effective_date_desc")

View file

@ -0,0 +1,34 @@
"""ulubione strony: przypinanie zrodel na gorze zakladki Sites
Prosba z apki 2026-09-02. Tabela jak favorite_studios, kluczowana po device_id, wiec
kod synchronizacji (= device_id) przenosi ja na inne urzadzenie razem z reszta.
Origin bez klucza obcego: zrodla nie maja tabeli kluczowanej po origin.
Revision ID: 0034_favorite_sites
Revises: 0033_effective_date_idx
Create Date: 2026-09-02
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "0034_favorite_sites"
down_revision = "0033_effective_date_idx"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"favorite_sites",
sa.Column("device_id", sa.String(64), primary_key=True),
sa.Column("origin", sa.String(64), primary_key=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
def downgrade() -> None:
op.drop_table("favorite_sites")

View file

@ -0,0 +1,35 @@
"""Indeks pod sortowanie scen po dlugosci (najdluzsze / najkrotsze).
Jeden indeks (duration_sec, created_at, id) WHERE duration_sec IS NOT NULL obsluguje
oba kierunki: skan w przod daje najkrotsze, w tyl najdluzsze. Sceny bez dlugosci sa
odfiltrowane w zapytaniu, wiec kolejnosc NULL-i nie gra roli i wystarczy jeden indeks.
CONCURRENTLY w autocommit_block, jak w 0027: zwykly CREATE INDEX blokowalby zapisy
ingestu na czas budowy (4,1 mln wierszy). Nieudany CONCURRENTLY zostawia indeks
INVALID; wtedy DROP INDEX i ponownie.
Revision ID: 0035_scenes_duration_idx
Revises: 0034_favorite_sites
Create Date: 2026-09-14
"""
from __future__ import annotations
from alembic import op
revision = "0035_scenes_duration_idx"
down_revision: str | None = "0034_favorite_sites"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.get_context().autocommit_block():
op.execute(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_scenes_duration "
"ON scenes (duration_sec, created_at, id) WHERE duration_sec IS NOT NULL"
)
def downgrade() -> None:
with op.get_context().autocommit_block():
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_scenes_duration")

View file

@ -0,0 +1,70 @@
"""Klipy do feedu Swipe: tabele `clips` i `clip_tags`.
Osobny wertykał (krótkie pionowe wideo twórców z RedGifs / Pornhub Shorties), nie sceny.
Uzasadnienie w docstringu `app/models/clip.py`.
Revision ID: 0036_clips
Revises: 0035_scenes_duration_idx
Create Date: 2026-09-14
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0036_clips"
down_revision: str | None = "0035_scenes_duration_idx"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"clips",
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("source", sa.String(16), nullable=False),
sa.Column("source_id", sa.String(64), nullable=False),
sa.Column("title", sa.Text(), nullable=True),
sa.Column("creator", sa.String(128), nullable=True),
sa.Column("creator_verified", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("duration_sec", sa.Float(), nullable=True),
sa.Column("width", sa.Integer(), nullable=True),
sa.Column("height", sa.Integer(), nullable=True),
sa.Column("has_audio", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("content_type", sa.String(32), nullable=True),
sa.Column("niches", postgresql.ARRAY(sa.String(64)), nullable=False, server_default="{}"),
sa.Column("hd_url", sa.Text(), nullable=True),
sa.Column("sd_url", sa.Text(), nullable=True),
sa.Column("poster_url", sa.Text(), nullable=True),
sa.Column("thumb_url", sa.Text(), nullable=True),
sa.Column("hls", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("likes", sa.Integer(), nullable=False, server_default="0"),
sa.Column("views", sa.Integer(), nullable=False, server_default="0"),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("dead_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("dead_reason", sa.String(128), nullable=True),
sa.PrimaryKeyConstraint("id", name="pk_clips"),
sa.UniqueConstraint("source", "source_id", name="uq_clips_source_source_id"),
)
op.create_index("ix_clips_published_at", "clips", ["published_at"])
op.create_index("ix_clips_creator", "clips", ["creator"])
op.create_table(
"clip_tags",
sa.Column("clip_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("tag", sa.String(64), nullable=False),
sa.ForeignKeyConstraint(["clip_id"], ["clips.id"], name="fk_clip_tags_clip_id_clips", ondelete="CASCADE"),
sa.PrimaryKeyConstraint("clip_id", "tag", name="pk_clip_tags"),
)
op.create_index("ix_clip_tags_tag_clip", "clip_tags", ["tag", "clip_id"])
def downgrade() -> None:
op.drop_index("ix_clip_tags_tag_clip", table_name="clip_tags")
op.drop_table("clip_tags")
op.drop_index("ix_clips_creator", table_name="clips")
op.drop_index("ix_clips_published_at", table_name="clips")
op.drop_table("clips")

View file

@ -0,0 +1,86 @@
"""Feed Swipe, stan per urządzenie: clip_seen, clip_feed_events, clip_affinity,
clip_favorites, clip_blacklist. Plus GIN na clips.niches pod skan po niszy.
Uzasadnienie w docstringu `app/models/clip_feed.py`.
Revision ID: 0037_clip_feed
Revises: 0036_clips
Create Date: 2026-09-14
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0037_clip_feed"
down_revision: str | None = "0036_clips"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"clip_seen",
sa.Column("device_id", sa.String(64), nullable=False),
sa.Column("clip_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("outcome", sa.String(16), nullable=False, server_default="served"),
sa.Column("seen_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["clip_id"], ["clips.id"], name="fk_clip_seen_clip_id_clips", ondelete="CASCADE"),
sa.PrimaryKeyConstraint("device_id", "clip_id", name="pk_clip_seen"),
)
op.create_table(
"clip_feed_events",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("device_id", sa.String(64), nullable=False),
sa.Column("session_id", sa.String(32), nullable=True),
sa.Column("clip_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("kind", sa.String(16), nullable=False),
sa.Column("dwell_ms", sa.Integer(), nullable=True),
sa.Column("reason", sa.String(160), nullable=True),
sa.Column("slot", sa.String(12), nullable=True),
sa.Column("position", sa.SmallInteger(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id", name="pk_clip_feed_events"),
)
op.create_index("ix_clip_feed_events_device_created", "clip_feed_events", ["device_id", "created_at"])
op.create_index("ix_clip_feed_events_created", "clip_feed_events", ["created_at"])
op.create_table(
"clip_affinity",
sa.Column("device_id", sa.String(64), nullable=False),
sa.Column("tag_weights", postgresql.JSONB(), nullable=False, server_default="{}"),
sa.Column("niche_weights", postgresql.JSONB(), nullable=False, server_default="{}"),
sa.Column("creator_weights", postgresql.JSONB(), nullable=False, server_default="{}"),
sa.Column("events_seen", sa.Integer(), nullable=False, server_default="0"),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("device_id", name="pk_clip_affinity"),
)
op.create_table(
"clip_favorites",
sa.Column("device_id", sa.String(64), nullable=False),
sa.Column("clip_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["clip_id"], ["clips.id"], name="fk_clip_favorites_clip_id_clips", ondelete="CASCADE"),
sa.PrimaryKeyConstraint("device_id", "clip_id", name="pk_clip_favorites"),
)
op.create_table(
"clip_blacklist",
sa.Column("device_id", sa.String(64), nullable=False),
sa.Column("kind", sa.String(8), nullable=False),
sa.Column("value", sa.String(128), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("device_id", "kind", "value", name="pk_clip_blacklist"),
)
# Skan kandydatów po niszy (`niches && ARRAY[...]`) bez pełnego przebiegu tabeli.
op.execute("CREATE INDEX IF NOT EXISTS ix_clips_niches_gin ON clips USING gin (niches)")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_clips_niches_gin")
op.drop_table("clip_blacklist")
op.drop_table("clip_favorites")
op.drop_table("clip_affinity")
op.drop_index("ix_clip_feed_events_created", table_name="clip_feed_events")
op.drop_index("ix_clip_feed_events_device_created", table_name="clip_feed_events")
op.drop_table("clip_feed_events")
op.drop_table("clip_seen")

View file

@ -0,0 +1,37 @@
"""Indeks pod liste scen studia i licznik "+N" ulubionych studiow.
Oba sortuja po dacie premiery albo, gdy jej nie ma, dniu trafienia do katalogu (to samo
wyrazenie co ix_scenes_effective_date_desc z 0033), w obrebie jednego studia. Bez tego
Postgres czytal WSZYSTKIE sceny studia (Brazzers ma ich dziesiatki tysiecy), sprawdzal
kazdej playback i sortowal, a licznik dla 209 ulubionych studiow trwal 10-25 s. Z tym
indeksem idzie po kolei i konczy po 200 widocznych scenach na studio.
CONCURRENTLY w autocommit_block, jak w 0035. Nieudany CONCURRENTLY zostawia indeks
INVALID; wtedy DROP INDEX i ponownie.
Revision ID: 0038_scenes_studio_effdate_idx
Revises: 0037_clip_feed
Create Date: 2026-09-15
"""
from __future__ import annotations
from alembic import op
revision = "0038_scenes_studio_effdate_idx"
down_revision: str | None = "0037_clip_feed"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.get_context().autocommit_block():
op.execute(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_scenes_studio_effective_date_desc "
"ON scenes (studio_id, COALESCE(release_date, (created_at AT TIME ZONE 'UTC')::date) DESC, "
"created_at DESC, id DESC) WHERE studio_id IS NOT NULL"
)
def downgrade() -> None:
with op.get_context().autocommit_block():
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_scenes_studio_effective_date_desc")

View file

@ -30,7 +30,7 @@ router = APIRouter(
def _raw_to_thumb(raw: dict, kind: SourceKind) -> str | None:
"""Wyciąga thumbnail URL z external_records.raw dla danego źródła.
TPDB ma `image`/`poster`/`background.large`. StashDB raw nie zawiera image
(osobny query do StashDB potrzebny — tu zwracamy None)."""
(osobny query do StashDB potrzebny, tu zwracamy None)."""
if kind == SourceKind.tpdb:
for k in ("image", "poster"):
v = raw.get(k)
@ -42,7 +42,7 @@ def _raw_to_thumb(raw: dict, kind: SourceKind) -> str | None:
if isinstance(v, str) and v.startswith("http"):
return v
elif kind == SourceKind.stashdb:
# StashDB scene response includes images via separate query — nie trzymamy
# StashDB scene response includes images via separate query, nie trzymamy
# tego w raw obecnie. TODO: dorzucić mirror do `paths.screenshot` przy ingest.
paths = raw.get("paths")
if isinstance(paths, dict):
@ -142,7 +142,7 @@ def list_candidates(
# Pre-fetch po jednym statycznym i animowanym thumbnailu per scenę (mobile queue
# używa statycznego do listy + animowanego po hold-to-preview). Wybieramy najpierw
# napotkany niepusty URL — kolejność rzędów playback_sources nie jest gwarantowana,
# napotkany niepusty URL, kolejność rzędów playback_sources nie jest gwarantowana,
# ale dla triage to wystarcza.
thumbs: dict[uuid.UUID, str] = {}
animated_thumbs: dict[uuid.UUID, str] = {}
@ -292,7 +292,7 @@ def _fmt_bytes(b: int) -> str:
def bandwidth_stats() -> BandwidthStats:
"""Per-CDN VPS proxy bytes-out + Hetzner traffic stats.
Critical dla public release — pokazuje gdzie VPS bandwidth wycieka. Pozwala
Critical dla public release, pokazuje gdzie VPS bandwidth wycieka. Pozwala
spotted Mixdrop / bandwidth-heavy CDN-y przed Hetzner overage charge.
"""
from app.api.stream_proxy import get_bandwidth_stats
@ -308,7 +308,7 @@ def bandwidth_stats() -> BandwidthStats:
s_24h = get_bandwidth_stats(24)
s_7d = get_bandwidth_stats(168)
# Hetzner stats — load from cache file (written by check_hetzner_traffic.py cron).
# Hetzner stats, load from cache file (written by check_hetzner_traffic.py cron).
hetzner_data = None
settings = get_settings()
if settings.hetzner_api_token and settings.hetzner_server_id:

View file

@ -1,9 +1,9 @@
"""htmx + Jinja2 admin UI dla MergeCandidate triage.
Endpointy:
GET /ui/ — lista pending (filter status)
GET /ui/candidate/{id} — side-by-side scen
POST /ui/candidate/{id}/resolve — htmx form submit (action=merge_keep_left|merge_keep_right|reject)
GET /ui/ lista pending (filter status)
GET /ui/candidate/{id} side-by-side scen
POST /ui/candidate/{id}/resolve, htmx form submit (action=merge_keep_left|merge_keep_right|reject)
zwraca fragment HTML z potwierdzeniem
"""
from __future__ import annotations
@ -197,9 +197,9 @@ def resolve_form(
def mount_static(app) -> None: # pragma: no cover - dev convenience
# APK MIME type — bez tego Android Browser nie traktuje pliku jako instalable APK
# APK MIME type, bez tego Android Browser nie traktuje pliku jako instalable APK
# (text/plain → "Plik został pobrany" zamiast prompta install). Rejestracja jest
# idempotentna na poziomie procesu — bezpiecznie wywoływać przy każdym startup.
# idempotentna na poziomie procesu, bezpiecznie wywoływać przy każdym startup.
import mimetypes
mimetypes.add_type("application/vnd.android.package-archive", ".apk")
if _STATIC_DIR.exists():

View file

@ -1,12 +1,12 @@
"""Blacklists — globalnie ukryte performerki/studia/tagi.
"""Blacklists, globalnie ukryte performerki/studia/tagi.
Sceny które MAJĄ blacklisted entity wypadają z każdego /scenes (pełna lista, search,
performer scenes, tag scenes). Auto-apply w `app/api/scenes.py`.
Endpointy:
GET /blacklist — wszystkie 3 listy w jednym response
POST /blacklist/{kind}/{entity_id} — dodaj (idempotent)
DELETE /blacklist/{kind}/{entity_id} — usuń
GET /blacklist wszystkie 3 listy w jednym response
POST /blacklist/{kind}/{entity_id} dodaj (idempotent)
DELETE /blacklist/{kind}/{entity_id} usuń
`kind` ∈ {performer, studio, tag}.
"""

View file

@ -1,10 +1,10 @@
"""Bug reports — mobile FAB → POST /bug-reports → admin lista przez admin_html.
"""Bug reports, mobile FAB → POST /bug-reports → admin lista przez admin_html.
POST nie wymaga obecnego scene_id (user może raportować z FavoritesScreen,
SearchScreen itp.). Screenshot opcjonalny — niektóre ekrany nie warto kapturować.
SearchScreen itp.). Screenshot opcjonalny, niektóre ekrany nie warto kapturować.
Limit body 1.5MB (FastAPI default jest hojny, ale dla rozsądku ograniczamy).
Screenshot to PNG/JPEG z react-native-view-shot, base64 — typowe rozmiary:
Screenshot to PNG/JPEG z react-native-view-shot, base64, typowe rozmiary:
- mały ekran scene-list: ~200-400KB
- duży scene-detail z thumbnail: ~600KB-1MB
"""
@ -92,7 +92,7 @@ def create_bug_report(
scene_id = payload.scene_id
elif session.get(Movie, payload.scene_id) is not None:
movie_id = payload.scene_id
# else: ID nie istnieje już nigdzie (deleted) — drop oba na null
# else: ID nie istnieje już nigdzie (deleted), drop oba na null
br = BugReport(
id=uuid.uuid4(),
@ -211,7 +211,7 @@ def get_bug_report_screenshot(
bug_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)],
) -> dict[str, str | None]:
"""Zwraca base64-encoded screenshot (jeśli jest) — admin UI go renderuje."""
"""Zwraca base64-encoded screenshot (jeśli jest), admin UI go renderuje."""
br = session.get(BugReport, bug_id)
if br is None:
raise HTTPException(status_code=404, detail="not found")

664
app/api/clips.py Normal file
View file

@ -0,0 +1,664 @@
"""Klipy (feed Swipe): feed z afinicznością, pamięć widzianych, telemetria, ulubione,
blacklista, onboarding. Logika bez bazy w `app/clips_feed.py`.
**Feed** `GET /clips/feed`: strona = 80 % z puli afiniczności (tagi, nisze, twórcy z wag
urządzenia) + 20 % explore (okno po kluczu głównym od kursora, losowanie ważone lajkami).
Bez wag (nowe urządzenie, brak onboardingu) 100 % explore. Każdy podany klip ląduje
w `clip_seen` od razu, więc nigdy nie wraca. Widoczność: żywy, do `clips_feed_max_duration_sec`,
z plikiem, nie widziany, nie na blackliście (tag / nisza / twórca).
**Zdarzenia** `POST /clips/events`: paczka z apki (co ~5 s). Zapis do `clip_feed_events`,
podniesienie `clip_seen.outcome`, ulubione przy like/unlike, delty na wagi afiniczności
(`clips_feed.apply_event`).
Adresy plików i posterów są publiczne (`media.redgifs.com`), więc jadą do apki wprost,
bez image proxy: RedGifs nie sprawdza Referera, a proxy tylko dodałoby VPS do toru.
"""
from __future__ import annotations
import logging
import time
import uuid
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from pydantic import BaseModel, Field
from sqlalchemy import String, cast, func, select, text
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
from app import clips_feed as cf
from app.api.device import get_device_id
from app.auth import require_api_key
from app.config import get_settings
from app.db import get_session
from app.models.clip import Clip, ClipTag
from app.models.clip_feed import ClipAffinity, ClipBlacklist, ClipFavorite, ClipFeedEvent, ClipSeen
log = logging.getLogger(__name__)
router = APIRouter(prefix="/clips", tags=["clips"], dependencies=[Depends(require_api_key)])
_EXPLORE_WINDOW = 2000
_AFFINITY_TAGS, _AFFINITY_NICHES, _AFFINITY_CREATORS = 6, 3, 3
_PER_TAG, _PER_NICHE, _PER_CREATOR = 60, 60, 40
_UUID_ZERO = uuid.UUID(int=0)
_BLACKLIST_KINDS = frozenset({"tag", "niche", "creator"})
# ---- schematy ---------------------------------------------------------------------------
class ClipOut(BaseModel):
id: uuid.UUID
source: str
source_id: str
title: str | None
creator: str | None
creator_verified: bool
duration_sec: float | None
width: int | None
height: int | None
has_audio: bool
content_type: str | None
niches: list[str]
tags: list[str]
hd_url: str | None
sd_url: str | None
poster_url: str | None
thumb_url: str | None
likes: int
views: int
published_at: datetime | None
is_favorite: bool = False
class ClipListOut(BaseModel):
items: list[ClipOut]
limit: int
offset: int
has_more: bool
class ClipStatsOut(BaseModel):
total: int
alive: int
dead: int
with_audio: int
verified: int
by_source: dict[str, int]
newest_published_at: datetime | None
tags: int
class FeedItemOut(BaseModel):
clip: ClipOut
#: `affinity` | `explore`, do telemetrii i do pomiaru, czy personalizacja coś daje.
slot: str
is_fresh: bool
class FeedPageOut(BaseModel):
items: list[FeedItemOut]
next_cursor: str
seed: str
#: True gdy okno explore doszło do końca katalogu i zawinęło się na początek.
wrapped: bool
#: Ile klipów to urządzenie już widziało (pigułka „N nowych").
served_total: int
personalized: bool
class FeedEventIn(BaseModel):
clip_id: uuid.UUID
kind: str = Field(min_length=1, max_length=16)
dwell_ms: int | None = Field(default=None, ge=0, le=3_600_000)
reason: str | None = Field(default=None, max_length=160)
slot: str | None = Field(default=None, max_length=12)
position: int | None = Field(default=None, ge=0, le=32767)
class FeedEventsIn(BaseModel):
session_id: str | None = Field(default=None, max_length=32)
events: list[FeedEventIn] = Field(max_length=100)
class OnboardingIn(BaseModel):
tags: list[str] = Field(default_factory=list, max_length=20)
niches: list[str] = Field(default_factory=list, max_length=20)
class OnboardingOptionOut(BaseModel):
value: str
count: int
class OnboardingOptionsOut(BaseModel):
tags: list[OnboardingOptionOut]
niches: list[OnboardingOptionOut]
class AffinityOut(BaseModel):
tags: dict[str, float]
niches: dict[str, float]
creators: dict[str, float]
events_seen: int
class BlacklistItemOut(BaseModel):
kind: str
value: str
# ---- pomocnicze -------------------------------------------------------------------------
def _tags_for(session: Session, clip_ids: list[uuid.UUID]) -> dict[uuid.UUID, list[str]]:
if not clip_ids:
return {}
out: dict[uuid.UUID, list[str]] = {}
for cid, tag in session.execute(
select(ClipTag.clip_id, ClipTag.tag).where(ClipTag.clip_id.in_(clip_ids)).order_by(ClipTag.tag)
):
out.setdefault(cid, []).append(tag)
return out
def _favorites_for(session: Session, device_id: str, clip_ids: list[uuid.UUID]) -> set[uuid.UUID]:
if not clip_ids:
return set()
return {
r[0]
for r in session.execute(
select(ClipFavorite.clip_id).where(ClipFavorite.device_id == device_id, ClipFavorite.clip_id.in_(clip_ids))
)
}
def _clip_out(r: Clip, tags: list[str], fav: bool = False) -> ClipOut:
return ClipOut(
id=r.id,
source=r.source,
source_id=r.source_id,
title=r.title,
creator=r.creator,
creator_verified=r.creator_verified,
duration_sec=r.duration_sec,
width=r.width,
height=r.height,
has_audio=r.has_audio,
content_type=r.content_type,
niches=list(r.niches or []),
tags=tags,
hd_url=r.hd_url,
sd_url=r.sd_url,
poster_url=r.poster_url,
thumb_url=r.thumb_url,
likes=r.likes,
views=r.views,
published_at=r.published_at,
is_favorite=fav,
)
def _load_affinity(session: Session, device_id: str) -> tuple[ClipAffinity | None, cf.Affinity]:
row = session.get(ClipAffinity, device_id)
if row is None:
return None, cf.Affinity()
return row, cf.Affinity(
tags=dict(row.tag_weights or {}), niches=dict(row.niche_weights or {}), creators=dict(row.creator_weights or {})
)
def _save_affinity(session: Session, device_id: str, row: ClipAffinity | None, aff: cf.Affinity, events: int) -> None:
if row is None:
row = ClipAffinity(device_id=device_id)
session.add(row)
row.tag_weights = aff.tags
row.niche_weights = aff.niches
row.creator_weights = aff.creators
row.events_seen = (row.events_seen or 0) + events
def _blacklist(session: Session, device_id: str) -> dict[str, set[str]]:
out: dict[str, set[str]] = {"tag": set(), "niche": set(), "creator": set()}
for kind, value in session.execute(
select(ClipBlacklist.kind, ClipBlacklist.value).where(ClipBlacklist.device_id == device_id)
):
out.setdefault(kind, set()).add(value)
return out
def _visible_clauses(device_id: str, bl: dict[str, set[str]]) -> list:
clauses = [
Clip.dead_at.is_(None),
Clip.duration_sec.is_not(None),
Clip.duration_sec <= get_settings().clips_feed_max_duration_sec,
(Clip.hd_url.is_not(None)) | (Clip.sd_url.is_not(None)),
~select(ClipSeen.clip_id)
.where(ClipSeen.device_id == device_id, ClipSeen.clip_id == Clip.id)
.exists(),
]
if bl["creator"]:
clauses.append((Clip.creator.is_(None)) | (~Clip.creator.in_(list(bl["creator"]))))
if bl["niche"]:
clauses.append(~Clip.niches.overlap(list(bl["niche"])))
if bl["tag"]:
clauses.append(
~select(ClipTag.clip_id)
.where(ClipTag.clip_id == Clip.id, ClipTag.tag.in_(list(bl["tag"])))
.exists()
)
return clauses
_CAND_COLS = (Clip.id, Clip.creator, Clip.likes, Clip.published_at, Clip.niches)
def _rows_to_candidates(session: Session, rows, *, with_tags: bool) -> list[cf.Candidate]:
"""Wiersze `_CAND_COLS` → Candidate. Tagi tylko na życzenie: pula explore punktuje
wyłącznie lajkami, więc ciągnięcie tagów dla 300 kandydatów było czystym kosztem."""
tags = _tags_for(session, [r[0] for r in rows]) if with_tags else {}
return [
cf.Candidate(id=r[0], creator=r[1], likes=r[2], published_at=r[3], tags=tags.get(r[0], []), niches=list(r[4] or []))
for r in rows
]
def _candidates(session: Session, ids: list[uuid.UUID], clauses: list) -> list[cf.Candidate]:
if not ids:
return []
rows = session.execute(select(*_CAND_COLS).where(Clip.id.in_(ids), *clauses)).all()
return _rows_to_candidates(session, rows, with_tags=True)
def _explore_candidates(
session: Session, pk: uuid.UUID, clauses: list, limit: int
) -> tuple[list[cf.Candidate], uuid.UUID, bool]:
"""Widoczni kandydaci z okna `_EXPLORE_WINDOW` id po `pk` (podzapytanie, bez
przenoszenia 2000 uuid przez SQL). Zwraca (kandydaci, koniec okna, czy okno było
ostatnie). Ostatnie okno = katalog wyczerpany od `pk`, następna strona zawija do zera."""
window = (
select(Clip.id).where(Clip.id > pk, Clip.dead_at.is_(None)).order_by(Clip.id).limit(_EXPLORE_WINDOW)
).subquery()
# Postgres nie ma max(uuid); tekstowo porządek jest ten sam (małe hex, stały format).
end_txt, n = session.execute(
select(func.max(cast(window.c.id, String)), func.count()).select_from(window)
).one()
if not n:
return [], _UUID_ZERO, True
end_id = uuid.UUID(end_txt)
rows = session.execute(
select(*_CAND_COLS).where(Clip.id.in_(select(window.c.id)), *clauses).order_by(Clip.id).limit(limit)
).all()
last = n < _EXPLORE_WINDOW
return _rows_to_candidates(session, rows, with_tags=False), (end_id if not last else _UUID_ZERO), last
def _affinity_candidate_ids(session: Session, aff: cf.Affinity, rng) -> set[uuid.UUID]:
ids: set[uuid.UUID] = set()
for tag in aff.top("tags", _AFFINITY_TAGS):
start = uuid.UUID(int=rng.getrandbits(128))
rows = session.execute(
select(ClipTag.clip_id).where(ClipTag.tag == tag, ClipTag.clip_id > start).order_by(ClipTag.clip_id).limit(_PER_TAG)
).all()
if len(rows) < _PER_TAG:
rows += session.execute(
select(ClipTag.clip_id).where(ClipTag.tag == tag).order_by(ClipTag.clip_id).limit(_PER_TAG - len(rows))
).all()
ids.update(r[0] for r in rows)
for niche in aff.top("niches", _AFFINITY_NICHES):
start = uuid.UUID(int=rng.getrandbits(128))
rows = session.execute(
select(Clip.id).where(Clip.niches.overlap([niche]), Clip.id > start).order_by(Clip.id).limit(_PER_NICHE)
).all()
ids.update(r[0] for r in rows)
for creator in aff.top("creators", _AFFINITY_CREATORS):
start = uuid.UUID(int=rng.getrandbits(128))
rows = session.execute(
select(Clip.id).where(Clip.creator == creator, Clip.id > start).order_by(Clip.id).limit(_PER_CREATOR)
).all()
if len(rows) < _PER_CREATOR:
rows += session.execute(
select(Clip.id).where(Clip.creator == creator).order_by(Clip.id).limit(_PER_CREATOR - len(rows))
).all()
ids.update(r[0] for r in rows)
return ids
# ---- feed -------------------------------------------------------------------------------
@router.get("/feed", response_model=FeedPageOut)
def clip_feed(
session: Session = Depends(get_session),
device_id: str = Depends(get_device_id),
seed: str | None = Query(default=None, max_length=16),
cursor: str | None = Query(default=None, max_length=200),
limit: int = Query(default=10, ge=1, le=30),
) -> FeedPageOut:
t0 = time.monotonic()
decoded = cf.decode_cursor(cursor) if cursor else None
if decoded:
seed, pk, page = decoded
else:
seed = (seed or cf.new_seed())[:16]
pk, page = cf.seed_start_uuid(seed), 0
rng = cf.page_rng(seed, page)
_row, aff = _load_affinity(session, device_id)
personalized = not aff.is_empty()
bl = _blacklist(session, device_id)
clauses = _visible_clauses(device_id, bl)
n_aff, n_explore = cf.split_counts(limit, personalized)
picked_aff: list[cf.Candidate] = []
if n_aff:
cand_ids = _affinity_candidate_ids(session, aff, rng)
picked_aff = cf.pick_affinity(_candidates(session, list(cand_ids), clauses), aff, n_aff)
taken = {c.id for c in picked_aff}
# Explore: okno po PK; gdy w oknie brak widocznych, przesuwamy sie dalej (max 3 okna).
# Braki z afinicznosci (za malo kandydatow po filtrach) dopelnia explore.
picked_explore: list[cf.Candidate] = []
wrapped = False
next_pk = pk
want = n_explore + max(0, n_aff - len(picked_aff))
for _ in range(3):
pool, next_pk, last = _explore_candidates(session, next_pk, clauses, limit=max(60, want * 15))
pool = [c for c in pool if c.id not in taken]
picked_explore += cf.pick_explore(pool, want - len(picked_explore), rng)
taken.update(c.id for c in picked_explore)
if last:
wrapped = True
if len(picked_explore) >= want or last:
break
page_items = cf.compose_page(picked_aff, picked_explore, rng)
clip_ids = [c.id for c, _ in page_items]
if clip_ids:
session.execute(
pg_insert(ClipSeen)
.values([{"device_id": device_id, "clip_id": cid, "outcome": "served"} for cid in clip_ids])
.on_conflict_do_nothing()
)
rows = {r.id: r for r in session.execute(select(Clip).where(Clip.id.in_(clip_ids))).scalars()} if clip_ids else {}
tags = _tags_for(session, clip_ids)
favs = _favorites_for(session, device_id, clip_ids)
served_total = session.execute(
select(func.count()).select_from(ClipSeen).where(ClipSeen.device_id == device_id)
).scalar_one()
session.commit()
items = [
FeedItemOut(
clip=_clip_out(rows[c.id], tags.get(c.id, []), c.id in favs),
slot=slot,
is_fresh=cf.is_fresh(rows[c.id].published_at),
)
for c, slot in page_items
if c.id in rows
]
log.info(
"clips/feed: device=%s page=%d served=%d aff=%d explore=%d wrapped=%s ms=%d",
device_id[:8], page, len(items), len(picked_aff), len(picked_explore), wrapped,
int((time.monotonic() - t0) * 1000),
)
return FeedPageOut(
items=items,
next_cursor=cf.encode_cursor(seed, next_pk, page + 1),
seed=seed,
wrapped=wrapped,
served_total=int(served_total),
personalized=personalized,
)
# ---- zdarzenia --------------------------------------------------------------------------
@router.post("/events", status_code=204)
def clip_events(
body: FeedEventsIn,
session: Session = Depends(get_session),
device_id: str = Depends(get_device_id),
) -> Response:
events = [e for e in body.events if e.kind in cf.ALLOWED_KINDS]
if not events:
return Response(status_code=204)
clip_ids = list({e.clip_id for e in events})
clips = {r.id: r for r in session.execute(select(Clip).where(Clip.id.in_(clip_ids))).scalars()}
tags = _tags_for(session, clip_ids)
seen = {
r.clip_id: r
for r in session.execute(
select(ClipSeen).where(ClipSeen.device_id == device_id, ClipSeen.clip_id.in_(clip_ids))
).scalars()
}
row, aff = _load_affinity(session, device_id)
changed = 0
for e in events:
session.add(
ClipFeedEvent(
device_id=device_id,
session_id=body.session_id,
clip_id=e.clip_id,
kind=e.kind,
dwell_ms=e.dwell_ms,
reason=e.reason,
slot=e.slot,
position=e.position,
)
)
clip = clips.get(e.clip_id)
if clip is None:
continue
# clip_seen: podnies wynik, nigdy nie cofaj.
outcome = e.kind if e.kind in cf.OUTCOME_RANK else None
if outcome:
s = seen.get(e.clip_id)
if s is None:
s = ClipSeen(device_id=device_id, clip_id=e.clip_id, outcome=outcome)
session.add(s)
seen[e.clip_id] = s
elif cf.OUTCOME_RANK[outcome] > cf.OUTCOME_RANK.get(s.outcome, 0):
s.outcome = outcome
if e.kind == "like":
session.execute(
pg_insert(ClipFavorite).values(device_id=device_id, clip_id=e.clip_id).on_conflict_do_nothing()
)
elif e.kind == "unlike":
session.execute(
text("DELETE FROM clip_favorites WHERE device_id = :d AND clip_id = :c"),
{"d": device_id, "c": e.clip_id},
)
if cf.apply_event(
aff,
kind=e.kind,
dwell_ms=e.dwell_ms,
reason=e.reason,
tags=tags.get(e.clip_id, []),
niches=list(clip.niches or []),
creator=clip.creator,
):
changed += 1
if changed:
_save_affinity(session, device_id, row, aff, changed)
session.commit()
return Response(status_code=204)
# ---- onboarding, afinicznosc ------------------------------------------------------------
_OPTIONS_CACHE: dict[str, tuple[float, OnboardingOptionsOut]] = {}
@router.get("/onboarding-options", response_model=OnboardingOptionsOut)
def onboarding_options(session: Session = Depends(get_session)) -> OnboardingOptionsOut:
"""Najpopularniejsze tagi i nisze do wyboru „3 tapów" przy pierwszym wejsciu.
Liczone z bazy, cache 10 min w procesie."""
hit = _OPTIONS_CACHE.get("v1")
if hit and time.monotonic() - hit[0] < 600:
return hit[1]
tags = [
OnboardingOptionOut(value=t, count=n)
for t, n in session.execute(
select(ClipTag.tag, func.count()).group_by(ClipTag.tag).order_by(func.count().desc()).limit(40)
)
]
niches = [
OnboardingOptionOut(value=v, count=n)
for v, n in session.execute(
text("SELECT n, count(*) FROM clips, unnest(niches) n WHERE dead_at IS NULL GROUP BY n ORDER BY 2 DESC LIMIT 30")
)
]
out = OnboardingOptionsOut(tags=tags, niches=niches)
_OPTIONS_CACHE["v1"] = (time.monotonic(), out)
return out
@router.post("/onboarding", response_model=AffinityOut)
def onboarding(
body: OnboardingIn,
session: Session = Depends(get_session),
device_id: str = Depends(get_device_id),
) -> AffinityOut:
row, aff = _load_affinity(session, device_id)
for t in body.tags:
t = t.strip().lower()[:64]
if t:
aff.tags[t] = round(aff.tags.get(t, 0.0) + 5.0, 3)
for n in body.niches:
n = n.strip()[:64]
if n:
aff.niches[n] = round(aff.niches.get(n, 0.0) + 5.0, 3)
_save_affinity(session, device_id, row, aff, 0)
session.commit()
row2 = session.get(ClipAffinity, device_id)
return AffinityOut(tags=aff.tags, niches=aff.niches, creators=aff.creators, events_seen=row2.events_seen if row2 else 0)
@router.get("/affinity", response_model=AffinityOut)
def get_affinity(session: Session = Depends(get_session), device_id: str = Depends(get_device_id)) -> AffinityOut:
row, aff = _load_affinity(session, device_id)
return AffinityOut(tags=aff.tags, niches=aff.niches, creators=aff.creators, events_seen=row.events_seen if row else 0)
@router.delete("/affinity", status_code=204)
def reset_affinity(
session: Session = Depends(get_session),
device_id: str = Depends(get_device_id),
seen: bool = Query(default=False, description="True = zapomnij tez widziane klipy (feed od zera)"),
) -> Response:
"""„Resetuj moj feed": kasuje wagi; z `seen=true` takze pamiec widzianych."""
session.execute(text("DELETE FROM clip_affinity WHERE device_id = :d"), {"d": device_id})
if seen:
session.execute(text("DELETE FROM clip_seen WHERE device_id = :d"), {"d": device_id})
session.commit()
return Response(status_code=204)
# ---- ulubione, blacklista ----------------------------------------------------------------
@router.get("/favorites", response_model=ClipListOut)
def clip_favorites(
session: Session = Depends(get_session),
device_id: str = Depends(get_device_id),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> ClipListOut:
rows = session.execute(
select(Clip)
.join(ClipFavorite, ClipFavorite.clip_id == Clip.id)
.where(ClipFavorite.device_id == device_id)
.order_by(ClipFavorite.created_at.desc())
.offset(offset)
.limit(limit + 1)
).scalars().all()
has_more = len(rows) > limit
rows = rows[:limit]
tags = _tags_for(session, [r.id for r in rows])
return ClipListOut(
items=[_clip_out(r, tags.get(r.id, []), True) for r in rows], limit=limit, offset=offset, has_more=has_more
)
@router.get("/blacklist", response_model=list[BlacklistItemOut])
def clip_blacklist(session: Session = Depends(get_session), device_id: str = Depends(get_device_id)) -> list[BlacklistItemOut]:
return [
BlacklistItemOut(kind=k, value=v)
for k, v in session.execute(
select(ClipBlacklist.kind, ClipBlacklist.value).where(ClipBlacklist.device_id == device_id).order_by(ClipBlacklist.kind, ClipBlacklist.value)
)
]
@router.post("/blacklist/{kind}/{value}", status_code=204)
def clip_blacklist_add(
kind: str, value: str, session: Session = Depends(get_session), device_id: str = Depends(get_device_id)
) -> Response:
if kind not in _BLACKLIST_KINDS or not value.strip():
raise HTTPException(status_code=400, detail="kind must be tag|niche|creator")
session.execute(
pg_insert(ClipBlacklist).values(device_id=device_id, kind=kind, value=value.strip()[:128]).on_conflict_do_nothing()
)
session.commit()
return Response(status_code=204)
@router.delete("/blacklist/{kind}/{value}", status_code=204)
def clip_blacklist_remove(
kind: str, value: str, session: Session = Depends(get_session), device_id: str = Depends(get_device_id)
) -> Response:
session.execute(
text("DELETE FROM clip_blacklist WHERE device_id = :d AND kind = :k AND value = :v"),
{"d": device_id, "k": kind, "v": value.strip()[:128]},
)
session.commit()
return Response(status_code=204)
# ---- listing i statystyki (pilot) --------------------------------------------------------
@router.get("", response_model=ClipListOut)
def list_clips(
session: Session = Depends(get_session),
device_id: str = Depends(get_device_id),
tag: str | None = Query(default=None, max_length=64),
source: str | None = Query(default=None, max_length=16),
creator: str | None = Query(default=None, max_length=128),
has_audio: bool | None = None,
max_duration_sec: float | None = Query(default=None, gt=0),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> ClipListOut:
q = select(Clip).where(Clip.dead_at.is_(None))
if tag:
q = q.where(Clip.id.in_(select(ClipTag.clip_id).where(ClipTag.tag == tag)))
if source:
q = q.where(Clip.source == source)
if creator:
q = q.where(Clip.creator == creator)
if has_audio is not None:
q = q.where(Clip.has_audio.is_(has_audio))
if max_duration_sec is not None:
q = q.where(Clip.duration_sec <= max_duration_sec)
rows = session.execute(
q.order_by(Clip.published_at.desc().nullslast(), Clip.id).offset(offset).limit(limit + 1)
).scalars().all()
has_more = len(rows) > limit
rows = rows[:limit]
ids = [r.id for r in rows]
tags = _tags_for(session, ids)
favs = _favorites_for(session, device_id, ids)
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
)
@router.get("/stats", response_model=ClipStatsOut)
def clip_stats(session: Session = Depends(get_session)) -> ClipStatsOut:
row = session.execute(
select(
func.count(),
func.count().filter(Clip.dead_at.is_(None)),
func.count().filter(Clip.dead_at.is_not(None)),
func.count().filter(Clip.has_audio.is_(True), Clip.dead_at.is_(None)),
func.count().filter(Clip.creator_verified.is_(True), Clip.dead_at.is_(None)),
func.max(Clip.published_at),
)
).one()
by_source = {s: n for s, n in session.execute(select(Clip.source, func.count()).group_by(Clip.source))}
tags = session.execute(select(func.count(func.distinct(ClipTag.tag)))).scalar_one()
return ClipStatsOut(
total=row[0], alive=row[1], dead=row[2], with_audio=row[3], verified=row[4],
by_source=by_source, newest_published_at=row[5], tags=tags,
)

View file

@ -6,7 +6,7 @@ nowi użytkownicy widzieli/nadpisywali ulubione, watched-badge i blacklisty Jana
nagłówku `X-Device-Id`; backend scope'uje wszystkie tabele stanu po `device_id`.
Stare wiersze (sprzed migracji) mają `device_id = LEGACY_DEVICE`. Klient bez nagłówka
(stara wersja apki przed OTA) trafia również na LEGACY_DEVICE — czyli dostaje dawną
(stara wersja apki przed OTA) trafia również na LEGACY_DEVICE, czyli dostaje dawną
współdzieloną pulę, dopóki nie zaktualizuje bundla. Endpoint `/me/adopt-legacy`
przepina LEGACY rows na konkretne device (Jan robi to raz po update).
"""

View file

@ -8,9 +8,9 @@ Każdy update wgrany przez `scripts/publish_update.py` ląduje w
`app/static/expo-updates/<runtime>/<update_id>/`. Plik
`app/static/expo-updates/<runtime>/current.json` wskazuje aktywny update_id.
Endpointy SĄ PUBLICZNE (no auth) — Expo Updates SDK nie wstrzykuje X-API-Key.
Endpointy SĄ PUBLICZNE (no auth), Expo Updates SDK nie wstrzykuje X-API-Key.
Bezpieczeństwo opiera się na TLS pinningu (mobile ufa tylko naszej self-signed
cert SPKI z network_security_config) — ktoś bez tego pinu nie podstawi MITM
cert SPKI z network_security_config), ktoś bez tego pinu nie podstawi MITM
manifestu. Jeśli kiedyś trzeba twardo: dorobić expo-updates code signing key.
"""
from __future__ import annotations
@ -35,7 +35,7 @@ def get_manifest(
expo_platform: str | None = Header(default=None, alias="expo-platform"),
) -> Response:
"""Zwraca aktualny manifest dla podanego `expo-runtime-version` (default 1.0)
+ platform (default android — i tak tylko Android wspieramy).
+ platform (default android, i tak tylko Android wspieramy).
204 No Content gdy nie ma update'u dla tego runtime'u → klient nadal odpala
embedded bundle z APK. Mobile zna `expo-protocol-version` (single-manifest
@ -87,7 +87,7 @@ def get_asset(
) -> Response:
"""Serwuje pojedynczy asset (JS bundle, image, font) z update directory.
`asset` to relative path względem `static/expo-updates/<runtime>/` —
`asset` to relative path względem `static/expo-updates/<runtime>/`,
zwykle `<update_id>/_expo/static/js/android/<hash>.js` lub
`<update_id>/assets/<hash>`. Path traversal blocked przez resolve+is_relative.
"""
@ -103,7 +103,7 @@ def get_asset(
raise HTTPException(status_code=400, detail="invalid asset path")
if not target.exists() or not target.is_file():
raise HTTPException(status_code=404, detail="asset not found")
# Content type — bundle to text/javascript, reszta autodetect przez FileResponse.
# Content type, bundle to text/javascript, reszta autodetect przez FileResponse.
media_type = None
if target.suffix in (".js", ".bundle"):
media_type = "application/javascript"

View file

@ -1,19 +1,19 @@
"""Favorites — ulubione performerki + studia + liczenie nowych scen.
"""Favorites, ulubione performerki + studia + liczenie nowych scen.
Single-user (brak users), więc API zwraca/operuje na global zbiorze. Multi-user
można dodać dorzuceniem `user_id` query/header bez breaking change.
Endpointy (performers — `/favorites/...` zostawione żeby nie łamać starego mobile):
GET /favorites — lista ulubionych performerek
POST /favorites/{performer_id} — dodaj (idempotent)
DELETE /favorites/{performer_id} — usuń
POST /favorites/{performer_id}/seen — mark-as-seen (zeruje badge)
Endpointy (performers, `/favorites/...` zostawione żeby nie łamać starego mobile):
GET /favorites lista ulubionych performerek
POST /favorites/{performer_id} dodaj (idempotent)
DELETE /favorites/{performer_id} usuń
POST /favorites/{performer_id}/seen mark-as-seen (zeruje badge)
Endpointy (studios):
GET /favorites/studios — lista ulubionych studiów
POST /favorites/studios/{studio_id} — dodaj
DELETE /favorites/studios/{studio_id} — usuń
POST /favorites/studios/{studio_id}/seen — mark-as-seen
GET /favorites/studios lista ulubionych studiów
POST /favorites/studios/{studio_id} dodaj
DELETE /favorites/studios/{studio_id} usuń
POST /favorites/studios/{studio_id}/seen, mark-as-seen
"Nowa scena" = scena której Scene.created_at > favorite.last_seen_at:
- dla performerki: ScenePerformer.performer_id = X
@ -21,26 +21,32 @@ Endpointy (studios):
"""
from __future__ import annotations
import logging
import threading
import time
import uuid
from datetime import UTC, datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
from pydantic import BaseModel
from sqlalchemy import exists, select
from sqlalchemy import exists, func, select
from sqlalchemy.orm import Session
from app.api.device import get_device_id
from app.auth import require_api_key
from app.db import get_session
from app.db import get_session, session_scope
from app.models.favorite_movie import FavoriteMovie
from app.models.favorite_performer import FavoritePerformer
from app.models.favorite_site import FavoriteSite
from app.models.favorite_studio import FavoriteStudio
from app.models.movie import Movie
from app.models.performer import Performer
from app.models.scene import Scene, ScenePerformer
from app.models.studio import Studio
log = logging.getLogger(__name__)
router = APIRouter(
prefix="/favorites", tags=["favorites"], dependencies=[Depends(require_api_key)]
)
@ -60,55 +66,71 @@ def _new_counts(session: Session, device_id: str, *, kind: str) -> dict:
"""Policz per-favorite "+N nowych" = sceny created_at > last_seen_at, ale TYLKO wśród
tych które user zobaczy na liście: te same filtry widoczności co list_scenes (żywy
playback + blacklist + stub) i to samo okno (top-_FAVORITES_PAGE_CAP pod sortem
release_date desc), z pominięciem backfillu. Liczy w SQL (count(*) FILTER), zwraca
{group_id: n} — bez streamowania N*200 wierszy do Pythona.
`release_date` z list_scenes, czyli data premiery albo dzień dodania), z pominięciem
backfillu. Liczy w SQL (count(*) FILTER), zwraca {group_id: n}.
kind="performer": grupuje po ScenePerformer.performer_id, join favorite_performers.
kind="studio": grupuje po Scene.studio_id, join favorite_studios. Studia potrzebują
odsiewu stub (mogą nie mieć performera); performerki nie (mają).
"""
from sqlalchemy import and_, func
from sqlalchemy import and_, func, true
from app.api.scenes import blacklist_clauses, live_playback_exists, stub_exclusion_clause
from app.api.scenes import (
_effective_date_desc,
blacklist_clauses,
live_playback_exists,
stub_exclusion_clause,
)
clauses = [live_playback_exists(), *blacklist_clauses(session, device_id)]
list_order = (_effective_date_desc(), Scene.created_at.desc(), Scene.id.desc())
if kind == "performer":
gid = ScenePerformer.performer_id
last_seen = FavoritePerformer.last_seen_at
base = (
select(
gid.label("gid"),
Scene.created_at.label("created_at"),
Scene.backfill.label("backfill"),
last_seen.label("last_seen"),
)
.select_from(FavoritePerformer)
.join(ScenePerformer, ScenePerformer.performer_id == FavoritePerformer.performer_id)
.join(Scene, Scene.id == ScenePerformer.scene_id)
.where(FavoritePerformer.device_id == device_id)
)
partition = ScenePerformer.performer_id
else:
if kind == "studio":
# Po kolei indeksem ix_scenes_studio_effective_date_desc (migracja 0038) i stop po
# 200 widocznych scenach na studio. Okno row_number() czytało wszystkie sceny
# wszystkich ulubionych studiów (324 tys. u najcięższego urządzenia, 10-25 s).
clauses.append(stub_exclusion_clause())
gid = Scene.studio_id
last_seen = FavoriteStudio.last_seen_at
base = (
top = (
select(Scene.created_at.label("created_at"), Scene.backfill.label("backfill"))
.where(Scene.studio_id == FavoriteStudio.studio_id, *clauses)
.order_by(*list_order)
.limit(_FAVORITES_PAGE_CAP)
.lateral("top")
)
rows = session.execute(
select(
gid.label("gid"),
Scene.created_at.label("created_at"),
Scene.backfill.label("backfill"),
last_seen.label("last_seen"),
FavoriteStudio.studio_id,
func.count().filter(
and_(
top.c.backfill.is_(False),
top.c.created_at > FavoriteStudio.last_seen_at,
)
),
)
.select_from(FavoriteStudio)
.join(Scene, Scene.studio_id == FavoriteStudio.studio_id)
.join(top, true())
.where(FavoriteStudio.device_id == device_id)
)
partition = Scene.studio_id
.group_by(FavoriteStudio.studio_id)
).all()
return {gid_val: int(n) for gid_val, n in rows}
# Performerki: sceny idą przez scene_performers, więc indeks po dacie nie pomoże i
# trzeba przeczytać całą filmografię. To jest drogie (6-15 s przy ~1000 ulubionych),
# dlatego wynik siedzi w cache, patrz _cached_new_counts.
base = (
select(
ScenePerformer.performer_id.label("gid"),
Scene.created_at.label("created_at"),
Scene.backfill.label("backfill"),
FavoritePerformer.last_seen_at.label("last_seen"),
)
.select_from(FavoritePerformer)
.join(ScenePerformer, ScenePerformer.performer_id == FavoritePerformer.performer_id)
.join(Scene, Scene.id == ScenePerformer.scene_id)
.where(FavoritePerformer.device_id == device_id)
)
rn = func.row_number().over(
partition_by=partition,
order_by=(Scene.release_date.desc().nullslast(), Scene.created_at.desc()),
partition_by=ScenePerformer.performer_id, order_by=list_order
).label("rn")
inner = base.add_columns(rn).where(*clauses).subquery()
rows = session.execute(
@ -134,7 +156,7 @@ def _new_movie_counts(session: Session, device_id: str, *, kind: str) -> dict:
Filtr widoczności jak w `list_movies`: film musi mieć żywy playback. Filmów jest
o rzędy wielkości mniej niż scen (brak tube-spamu), więc nie potrzeba okna
top-N ani odsiewu stub/backfill — te pojęcia dotyczą scen.
top-N ani odsiewu stub/backfill, te pojęcia dotyczą scen.
"""
from sqlalchemy import func
@ -185,6 +207,94 @@ def _merged_new_counts(session: Session, device_id: str, *, kind: str) -> dict:
return counts
# Cache liczników "+N" w pamięci procesu API (uvicorn chodzi jednym procesem). Liczenie
# dla urządzeń z setkami ulubionych trwa sekundy, a apka woła /favorites przy wejściu na
# listę scen, ulubione i każdą performerkę. Liczniki zmieniają się wolno (ingest), więc:
# świeży wynik oddajemy od razu, starszy niż _COUNTS_FRESH_SEC też od razu i liczymy
# nowy w tle, a dopiero starszy niż _COUNTS_MAX_AGE_SEC liczymy na miejscu.
# mark_seen zeruje licznik w cache, żeby badge zgasł natychmiast.
_COUNTS_FRESH_SEC = 300.0
_COUNTS_MAX_AGE_SEC = 6 * 3600.0
_counts_cache: dict[tuple[str, str], tuple[float, dict]] = {}
_counts_seen: dict[tuple[str, str], dict] = {} # gid -> kiedy oznaczono jako obejrzane
_counts_locks: dict[tuple[str, str], threading.Lock] = {}
_counts_guard = threading.Lock()
def _counts_lock(key: tuple[str, str]) -> threading.Lock:
with _counts_guard:
lock = _counts_locks.get(key)
if lock is None:
lock = _counts_locks[key] = threading.Lock()
return lock
def _store_counts(key: tuple[str, str], started: float, counts: dict) -> None:
with _counts_guard:
# mark_seen w trakcie liczenia: wynik policzono ze starym last_seen_at
for gid, at in _counts_seen.get(key, {}).items():
if at >= started:
counts[gid] = 0
_counts_seen.pop(key, None)
_counts_cache[key] = (time.monotonic(), counts)
stale = [k for k, (at, _) in _counts_cache.items() if time.monotonic() - at > _COUNTS_MAX_AGE_SEC]
for k in stale:
_counts_cache.pop(k, None)
_counts_locks.pop(k, None)
def _compute_counts(key: tuple[str, str]) -> None:
device_id, kind = key
started = time.monotonic()
with session_scope() as session:
counts = _merged_new_counts(session, device_id, kind=kind)
_store_counts(key, started, counts)
def _refresh_in_background(key: tuple[str, str]) -> None:
lock = _counts_lock(key)
if not lock.acquire(blocking=False):
return # już ktoś liczy
def run() -> None:
try:
_compute_counts(key)
except Exception: # noqa: BLE001
log.exception("favorites: odświeżenie liczników w tle nie wyszło %s", key)
finally:
lock.release()
threading.Thread(target=run, name="fav-counts", daemon=True).start()
def _cached_new_counts(device_id: str, *, kind: str) -> dict:
key = (device_id, kind)
hit = _counts_cache.get(key)
if hit is not None:
age = time.monotonic() - hit[0]
if age < _COUNTS_MAX_AGE_SEC:
if age > _COUNTS_FRESH_SEC:
_refresh_in_background(key)
return dict(hit[1])
# Brak w cache: liczymy teraz. Lock, żeby równoległe zapytania z kilku ekranów
# nie liczyły tego samego naraz, drugie poczeka i weźmie gotowy wynik.
with _counts_lock(key):
hit = _counts_cache.get(key)
if hit is not None and time.monotonic() - hit[0] < _COUNTS_FRESH_SEC:
return dict(hit[1])
_compute_counts(key)
return dict(_counts_cache[key][1])
def _mark_counts_seen(device_id: str, kind: str, gid: uuid.UUID) -> None:
key = (device_id, kind)
with _counts_guard:
_counts_seen.setdefault(key, {})[gid] = time.monotonic()
hit = _counts_cache.get(key)
if hit is not None and hit[1].get(gid):
hit[1][gid] = 0
class FavoriteOut(BaseModel):
performer_id: uuid.UUID
canonical_name: str
@ -198,13 +308,17 @@ class FavoriteOut(BaseModel):
class FavoriteListOut(BaseModel):
items: list[FavoriteOut]
total: int
new_total: int # suma new_count po wszystkich — dla badge w toolbar
new_total: int # suma new_count po wszystkich, dla badge w toolbar
@router.get("", response_model=FavoriteListOut)
def list_favorites(
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
counts: bool = Query(
default=True,
description="false: bez liczników +N (ekrany, które chcą tylko wiedzieć, czy zapalić gwiazdkę)",
),
) -> FavoriteListOut:
rows = session.execute(
select(FavoritePerformer, Performer)
@ -216,10 +330,10 @@ def list_favorites(
return FavoriteListOut(items=[], total=0, new_total=0)
# scene_count: czytamy zdenormalizowany Performer.scene_count (refresh w tle przez
# _job_refresh_taxonomy_counts) — ta sama definicja co przed (sceny z żywym
# _job_refresh_taxonomy_counts), ta sama definicja co przed (sceny z żywym
# playback). Wcześniej grouped count z EXISTS playback per-request. Migracja 0019.
scene_counts: dict = {perf.id: perf.scene_count for _, perf in rows}
new_counts = _merged_new_counts(session, device_id, kind="performer")
new_counts = _cached_new_counts(device_id, kind="performer") if counts else {}
items: list[FavoriteOut] = []
new_total = 0
@ -274,7 +388,7 @@ def remove_favorite(
) -> None:
fav = session.get(FavoritePerformer, (device_id, performer_id))
if fav is None:
# idempotent — brak ulubionego = nie ma nic do usunięcia, success
# idempotent, brak ulubionego = nie ma nic do usunięcia, success
return
session.delete(fav)
session.commit()
@ -296,6 +410,7 @@ def mark_seen(
raise HTTPException(status_code=404, detail="not in favorites")
fav.last_seen_at = datetime.now(UTC)
session.commit()
_mark_counts_seen(device_id, "performer", performer_id)
return SeenOut(performer_id=performer_id, last_seen_at=fav.last_seen_at)
@ -307,6 +422,10 @@ class FavoriteStudioOut(BaseModel):
slug: str
network: str | None = None
scene_count: int
#: Filmów z żywym playbackiem. Studio może mieć ZERO scen i setki filmów
#: (Jonathan Jordan XXX 0/654, Kink Clips 0/616, EAGLE 0/523), a lista ulubionych
#: pokazywała wtedy samo „0" i wyglądała na pustą, mimo że „+N" liczyło już filmy.
movie_count: int = 0
new_count: int
last_seen_at: datetime
created_at: datetime
@ -322,6 +441,10 @@ class FavoriteStudioListOut(BaseModel):
def list_favorite_studios(
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
counts: bool = Query(
default=True,
description="false: bez liczników +N i filmów (sam stan gwiazdki)",
),
) -> FavoriteStudioListOut:
rows = session.execute(
select(FavoriteStudio, Studio)
@ -331,10 +454,51 @@ def list_favorite_studios(
).all()
if not rows:
return FavoriteStudioListOut(items=[], total=0, new_total=0)
if not counts:
return FavoriteStudioListOut(
items=[
FavoriteStudioOut(
studio_id=st.id,
name=st.name,
slug=st.slug,
network=st.network,
scene_count=st.scene_count,
new_count=0,
last_seen_at=fav.last_seen_at,
created_at=fav.created_at,
)
for fav, st in rows
],
total=len(rows),
new_total=0,
)
# scene_count: zdenormalizowany Studio.scene_count (refresh w tle, migracja 0019).
scene_counts: dict = {st.id: st.scene_count for _, st in rows}
new_counts = _merged_new_counts(session, device_id, kind="studio")
# movie_count liczymy NA ŻYWO: ulubionych studiów jest kilkaset na urządzenie,
# a filmów o rzędy wielkości mniej niż scen, więc agregat jest tani i nie trzeba
# kolejnej zdenormalizowanej kolumny do utrzymania.
from app.models.movie import Movie
from app.models.movie_playback_source import MoviePlaybackSource
studio_ids = [st.id for _, st in rows]
movie_counts: dict = {
sid: n
for sid, n in session.execute(
select(Movie.studio_id, func.count())
.where(
Movie.studio_id.in_(studio_ids),
exists(
select(1).where(
MoviePlaybackSource.movie_id == Movie.id,
MoviePlaybackSource.dead_at.is_(None),
)
),
)
.group_by(Movie.studio_id)
).all()
}
new_counts = _cached_new_counts(device_id, kind="studio")
items: list[FavoriteStudioOut] = []
new_total = 0
@ -348,6 +512,7 @@ def list_favorite_studios(
slug=st.slug,
network=st.network,
scene_count=scene_counts.get(st.id, 0),
movie_count=movie_counts.get(st.id, 0),
new_count=nc,
last_seen_at=fav.last_seen_at,
created_at=fav.created_at,
@ -411,12 +576,77 @@ def mark_studio_seen(
raise HTTPException(status_code=404, detail="not in favorites")
fav.last_seen_at = datetime.now(UTC)
session.commit()
_mark_counts_seen(device_id, "studio", studio_id)
return SeenStudioOut(studio_id=studio_id, last_seen_at=fav.last_seen_at)
# ---------- Sites (origins) ----------
# Przypiete strony w zakladce Sites (prosba z apki 2026-09-02). Bez licznikow "+N": lista
# zrodel i tak pokazuje swiezosc per strona, a badge liczy sie tylko dla performerow,
# studiow i filmow.
_ORIGIN_RE = r"^[a-z0-9][a-z0-9:._-]{1,63}$"
class FavoriteSiteListOut(BaseModel):
items: list[str] # origins, w kolejnosci dodania
total: int
class FavoriteSiteAddOut(BaseModel):
origin: str
created: bool
@router.get("/sites", response_model=FavoriteSiteListOut)
def list_favorite_sites(
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> FavoriteSiteListOut:
rows = session.execute(
select(FavoriteSite.origin)
.where(FavoriteSite.device_id == device_id)
.order_by(FavoriteSite.created_at)
).scalars().all()
return FavoriteSiteListOut(items=list(rows), total=len(rows))
@router.post("/sites/{origin}", response_model=FavoriteSiteAddOut)
def add_favorite_site(
origin: Annotated[str, Path(pattern=_ORIGIN_RE)],
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> FavoriteSiteAddOut:
from app.models.playback_source import PlaybackSource
znany = session.execute(
select(1).where(PlaybackSource.origin == origin).limit(1)
).scalar_one_or_none()
if znany is None:
raise HTTPException(status_code=404, detail="unknown origin")
if session.get(FavoriteSite, (device_id, origin)) is not None:
return FavoriteSiteAddOut(origin=origin, created=False)
session.add(FavoriteSite(device_id=device_id, origin=origin))
session.commit()
return FavoriteSiteAddOut(origin=origin, created=True)
@router.delete("/sites/{origin}", status_code=status.HTTP_204_NO_CONTENT)
def remove_favorite_site(
origin: Annotated[str, Path(pattern=_ORIGIN_RE)],
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> None:
fav = session.get(FavoriteSite, (device_id, origin))
if fav is None:
return
session.delete(fav)
session.commit()
# ── Favorite movies ────────────────────────────────────────────────────────
# Movies nie mają child scenes per-favorite (jak performerki/studia), więc
# `last_seen_at` nie jest tu używany do NEW count — tylko jako tracking ostatniego
# `last_seen_at` nie jest tu używany do NEW count, tylko jako tracking ostatniego
# wglądu przez usera. Mobile używa NEW badge w liście /movies przez OSOBNY
# globalny last_seen z AsyncStorage (client-side, brak backendowego state).

View file

@ -2,7 +2,7 @@
Po migracji device-scopingu (0022) stare wiersze mają `device_id='legacy-shared'`.
`POST /me/adopt-legacy` przepina WSZYSTKIE legacy wiersze (favorites/progress/blacklisty)
na device wołającego. Robi to JEDEN raz właściciel instancji po update apki — kolejne
na device wołającego. Robi to JEDEN raz właściciel instancji po update apki, kolejne
wywołania nic nie znajdą (legacy już puste). Jeśli na keep-device istnieje już wiersz
dla tej samej encji, legacy duplikat jest pomijany (ON CONFLICT DO NOTHING → potem
kasujemy resztki legacy).
@ -22,7 +22,7 @@ from app.db import get_session
router = APIRouter(prefix="/me", tags=["me"], dependencies=[Depends(require_api_key)])
# (tabela, kolumna-encji) — wszystkie tabele device-scoped (migracja 0022).
# (tabela, kolumna-encji), wszystkie tabele device-scoped (migracja 0022).
_TABLES: list[tuple[str, str]] = [
("favorite_performers", "performer_id"),
("favorite_studios", "studio_id"),
@ -33,6 +33,11 @@ _TABLES: list[tuple[str, str]] = [
("blacklisted_performers", "performer_id"),
("blacklisted_studios", "studio_id"),
("blacklisted_tags", "tag_id"),
# Feed Swipe: widziane klipy i ulubione klipy jadą z użytkownikiem na nowy telefon.
# Afiniczność (clip_affinity) celowo NIE: ma jeden wiersz na urządzenie bez kolumny
# encji, a i tak odbuduje się z pierwszych zdarzeń.
("clip_seen", "clip_id"),
("clip_favorites", "clip_id"),
]
@ -48,7 +53,10 @@ class ImportOut(BaseModel):
# Tabele do przeniesienia przy transferze między urządzeniami + kolumna-encji do dedupu.
# saved_searches ma własne `id` PK (nie (device_id,encja)), więc dedup po `query`.
_COPY_TABLES: list[tuple[str, str]] = _TABLES + [("saved_searches", "query")]
_COPY_TABLES: list[tuple[str, str]] = _TABLES + [
("saved_searches", "query"),
("clip_blacklist", "value"),
]
@router.post("/import/{source_device}", response_model=ImportOut)
@ -59,7 +67,7 @@ def import_from_device(
) -> ImportOut:
"""Backup/transfer stanu (ulubione/blacklisty/progres/zapisane wyszukiwania) z innego
urządzenia na wołające. `source_device` to device_id starego telefonu (pełni rolę kodu
transferu — jest losowym UUID, więc jego znajomość == autoryzacja; instancja bez kont).
transferu, jest losowym UUID, więc jego znajomość == autoryzacja; instancja bez kont).
KOPIUJE (nie przenosi): stary telefon zachowuje swoje. Dedup: pomija encje które
urządzenie już ma. Idempotentne. `id`-PK (saved_searches) dostaje nowy gen_random_uuid()."""
@ -114,7 +122,7 @@ def adopt_legacy(
).bindparams(dev=device_id, legacy=LEGACY_DEVICE)
)
moved[table] = res.rowcount or 0
# Resztki legacy (encje które device już miał) — skasuj, żeby nie wisiały.
# Resztki legacy (encje które device już miał), skasuj, żeby nie wisiały.
session.execute(
text(f"DELETE FROM {table} WHERE device_id = :legacy").bindparams(legacy=LEGACY_DEVICE)
)

View file

@ -1,7 +1,8 @@
"""GET /movies — lista i szczegóły filmów."""
"""GET /movies, lista i szczegóły filmów."""
from __future__ import annotations
import uuid
from collections import defaultdict
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
@ -132,14 +133,14 @@ def list_movies(
base = base.limit(per_page).offset((page - 1) * per_page)
movies = session.execute(base).scalars().all()
items = [_movie_to_out(session, m, device_id=device_id) for m in movies]
movies = list(session.execute(base).scalars().all())
items = _movies_to_out(session, movies, device_id=device_id)
return MovieListOut(items=items, total=total, page=page, per_page=per_page)
# Movie playback origin policy — module-level (kiedyś było inline per-request
# definition, code-review #19 — perf hit + dorosły kod).
# Movie playback origin policy, module-level (kiedyś było inline per-request
# definition, code-review #19, perf hit + dorosły kod).
# Ranking ustalony ad-hoc 2026-05-09 (extract_stream_from_hoster na 5 sample
# random per origin).
_MOVIE_PREFERRED_ORIGINS = (
@ -150,9 +151,9 @@ _MOVIE_PREFERRED_ORIGINS = (
"streamporn",
"pandamovies",
)
# File hosters które NIGDY nie dadzą się stream-extract bez premium account —
# File hosters które NIGDY nie dadzą się stream-extract bez premium account,
# odfiltrowywane całkowicie (zaśmiecały listę watch options, bug-report
# 2026-05-15). Streamtape przywrócony 2026-05-15 — ma dedicated extractor,
# 2026-05-15). Streamtape przywrócony 2026-05-15, ma dedicated extractor,
# ~5% URLów żyje.
_MOVIE_DROP_ORIGINS = frozenset({
"mangoporn:rapidgator",
@ -163,7 +164,49 @@ _MOVIE_DROP_ORIGINS = frozenset({
_MOVIE_LANDING_HIDE = frozenset({"mangoporn", "pandamovies", "streamporn"})
# Rodzina seekplayer (embed `#<hash>`, wspólny silnik) na sam koniec listy.
# Pomiar 2026-08-24 na 250 losowych źródłach: 3 miały jeszcze strumień, 88 oddało
# HTTP 404, a 79 odpowiedź bez pola `source`, po której ICH WŁASNY player pisze
# „Video is not ready yet" (sprawdzone w przeglądarce z łącza domowego, z VPS i przez
# brytyjskie proxy, więc to nie blokada po IP). Zgłoszenia `d59fc396`, `ccbc75da`,
# `7bfc2e8d` i sąsiednie z tego dnia to dokładnie te trafienia.
#
# Nie wyrzucamy ich z listy (te ~2% działa i bywa jedynym źródłem), ale nie mogą stać
# przed hosterami, które grają. Filmów zależnych WYŁĄCZNIE od tej rodziny jest w bazie
# jeden, więc de-ranking nikomu nie zabiera dostępu.
_MOVIE_DEPRIORITIZED_HOSTERS = frozenset({
"easyvidplayer", "embedseek", "seekplayer", "upns", "player4me", "rpmplay",
"4meplayer", "seekplays", "ezplayer", "seeks",
})
# Rodzina DoodStream (playmogo, doply, myvidplay i klony) wymaga KLIKANEGO Cloudflare
# Turnstile: widżet pojawia sie dopiero po tapie w play i przeladowaniu, wiec
# `pass_md5` nie ma w HTML i telefon nie rozwiaze tego sam (memory 2026-08-26, INJECTED_JS
# omija te hosty). Skutek w telemetrii: ZERO zdarzen odtwarzania w 30 dniach, bo droga
# WebView niczego nie raportuje, podczas gdy mixdrop/voe/luluvid/vidara graja w ~99%.
#
# Dotad te originy mialy priorytet neutralny (500) i sortowaly sie z dzialajacymi po dacie
# dodania, a doply/playmogo bywaja dopisane jako OSTATNIE, wiec ladowaly na GORZE listy
# "Watch on". Zgloszenia "Playmogo nie dziala tutaj" (1278eb49, 2026-09-04) i seria
# "Myvidplay nie dziala" (3b75cb54, d3af0813, 11d7b17b) to dokladnie ten mechanizm:
# zgloszony film mial obok zywy mixdrop, ktory gra.
#
# Pomiar 2026-09-05: rodzina siedzi w 54 446 filmach, z czego 54 320 (99,8%) ma dzialajaca
# alternatywe. Tylko 126 filmow zalezy od niej wylacznie, dla nich zostaje jako ostatnia
# deska ratunku, wiec de-ranking nikomu nie zabiera dostepu. Lista hostow = doodstream.ts.
_MOVIE_INTERACTIVE_HOSTERS = frozenset({
"playmogo", "myvidplay", "doply", "doodstream", "dood", "d0000d", "dooood",
"do0od", "do7go", "ds2play", "vikingfile", "vide0",
})
def _movie_origin_priority(origin: str) -> int:
host = origin.rpartition(":")[2]
if host in _MOVIE_DEPRIORITIZED_HOSTERS:
return 900 # za wszystkim, co nieklasyfikowane
if host in _MOVIE_INTERACTIVE_HOSTERS:
return 800 # gra, ale tylko po recznym kliku w Turnstile; za wszystkim, co gra samo
try:
return _MOVIE_PREFERRED_ORIGINS.index(origin)
except ValueError:
@ -183,115 +226,161 @@ def get_movie(
def _movie_to_out(session: Session, movie: Movie, *, device_id: str = LEGACY_DEVICE) -> MovieOut:
studio_out: StudioOut | None = None
if movie.studio_id:
studio = session.get(Studio, movie.studio_id)
if studio is not None:
studio_out = StudioOut.model_validate(studio)
return _movies_to_out(session, [movie], device_id=device_id)[0]
performer_rows = session.execute(
select(Performer, MoviePerformer.as_alias)
.join(MoviePerformer, MoviePerformer.performer_id == Performer.id)
.where(MoviePerformer.movie_id == movie.id)
def _movies_to_out(
session: Session, movies: list[Movie], *, device_id: str = LEGACY_DEVICE
) -> list[MovieOut]:
"""Serializacja wsadowa: po jednym zapytaniu na relacje dla calej strony listy.
Wczesniej kazdy film robil 7 zapytan (studio, obsada, tagi, rozdzialy, refy,
zrodla, ulubione + postep), czyli ~210 na strone 30 filmow i 350 ms na
GET /movies (Sentry: N+1 Query list_movies, pomiar 2026-09-08). Logika per film
jest ta sama co w starym `_movie_to_out`, tylko dane przychodza z map.
"""
if not movies:
return []
ids = [m.id for m in movies]
studio_ids = {m.studio_id for m in movies if m.studio_id}
studios: dict = {}
if studio_ids:
studios = {
st.id: st for st in session.execute(select(Studio).where(Studio.id.in_(studio_ids))).scalars()
}
perf_by: dict = defaultdict(list)
for mid, perf, alias in session.execute(
select(MoviePerformer.movie_id, Performer, MoviePerformer.as_alias)
.join(Performer, MoviePerformer.performer_id == Performer.id)
.where(MoviePerformer.movie_id.in_(ids))
.order_by(MoviePerformer.position.asc().nulls_last())
).all()
performers = [
PerformerOut(
id=p.id,
canonical_name=p.canonical_name,
slug=p.slug,
gender=p.gender.value if p.gender else None,
as_alias=alias,
)
for p, alias in performer_rows
]
).all():
perf_by[mid].append((perf, alias))
tag_rows = session.execute(
select(Tag).join(MovieTag, MovieTag.tag_id == Tag.id)
.where(MovieTag.movie_id == movie.id)
tags_by: dict = defaultdict(list)
for mid, tag in session.execute(
select(MovieTag.movie_id, Tag)
.join(Tag, MovieTag.tag_id == Tag.id)
.where(MovieTag.movie_id.in_(ids))
.order_by(Tag.name.asc())
).scalars().all()
tags = [TagOut.model_validate(t) for t in tag_rows]
).all():
tags_by[mid].append(tag)
chapter_rows = session.execute(
select(MovieChapter).where(MovieChapter.movie_id == movie.id)
.order_by(MovieChapter.chapter_index.asc())
).scalars().all()
chapters = [MovieChapterOut.model_validate(c) for c in chapter_rows]
chapters_by: dict = defaultdict(list)
for ch in session.execute(
select(MovieChapter).where(MovieChapter.movie_id.in_(ids)).order_by(MovieChapter.chapter_index.asc())
).scalars():
chapters_by[ch.movie_id].append(ch)
ref_rows = session.execute(
refs_by: dict = defaultdict(list)
for ref, name in session.execute(
select(MovieExternalRef, Source.name)
.join(Source, Source.id == MovieExternalRef.source_id)
.where(MovieExternalRef.movie_id == movie.id)
).all()
external_refs = [
ExternalRefOut(
source=name,
external_id=ref.external_id,
url=ref.url,
last_seen=ref.last_seen,
)
for ref, name in ref_rows
]
.where(MovieExternalRef.movie_id.in_(ids))
).all():
refs_by[ref.movie_id].append((ref, name))
pb_rows = session.execute(
pb_by: dict = defaultdict(list)
for pb in session.execute(
select(MoviePlaybackSource)
.where(MoviePlaybackSource.movie_id == movie.id)
.where(MoviePlaybackSource.movie_id.in_(ids))
.where(MoviePlaybackSource.dead_at.is_(None))
.order_by(MoviePlaybackSource.created_at.desc())
).scalars().all()
pb_rows = [p for p in pb_rows if p.origin not in _MOVIE_DROP_ORIGINS]
# Bug-report 2026-05-16: raw landing origins (`mangoporn`/`pandamovies`/
# `streamporn` BEZ `:host`) otwierały WebView z reklamami pełnoekranowymi
# i myliły usera. Ukrywamy raw landing GDY ten sam movie ma co najmniej
# jeden sub-host entry (origin zawiera `:`). Jeśli movie nie ma sub-hosters
# (bo theme HTML się zmienił lub regex nie złapał), zostawiamy landing jako
# last-resort.
has_subhost = any(":" in p.origin for p in pb_rows)
if has_subhost:
pb_rows = [p for p in pb_rows if p.origin not in _MOVIE_LANDING_HIDE]
pb_rows = sorted(pb_rows, key=lambda p: _movie_origin_priority(p.origin))
# Dedup po realnym celu: 3 mirrory dooplay (mangoporn/pandamovies/streamporn)
# zapisują TEN SAM embed hostera jako osobne wiersze (np. luluvid/e/X z każdego
# mirrora) → film miał ~100 linków, z czego ~połowa to ten sam film (bug-report
# 41ca1fa4). Zostawiamy pierwszy = najwyższy priorytet (sort wyżej).
seen_targets: set[str] = set()
deduped: list[MoviePlaybackSource] = []
for p in pb_rows:
target = (p.embed_url or p.stream_url or p.page_url or "").strip()
if target and target in seen_targets:
continue
if target:
seen_targets.add(target)
deduped.append(p)
pb_rows = deduped
playback_sources = [PlaybackSourceOut.model_validate(p) for p in pb_rows]
).scalars():
pb_by[pb.movie_id].append(pb)
is_fav = session.get(FavoriteMovie, (device_id, movie.id)) is not None
progress = session.get(MoviePlayProgress, (device_id, movie.id))
return MovieOut(
id=movie.id,
title=movie.title,
slug=movie.slug,
release_year=movie.release_year,
release_date=movie.release_date,
duration_sec=movie.duration_sec,
description=movie.description,
director=movie.director,
country=movie.country,
rating=movie.rating,
poster_url=movie.poster_url,
backdrop_url=movie.backdrop_url,
studio=studio_out,
performers=performers,
tags=tags,
chapters=chapters,
external_refs=external_refs,
playback_sources=playback_sources,
created_at=movie.created_at,
is_favorite=is_fav,
last_played_at=progress.last_played_at if progress else None,
finished=progress.finished if progress else False,
position_sec=progress.position_sec if progress else 0,
favs = set(
session.execute(
select(FavoriteMovie.movie_id)
.where(FavoriteMovie.device_id == device_id)
.where(FavoriteMovie.movie_id.in_(ids))
).scalars()
)
progress_by = {
pr.movie_id: pr
for pr in session.execute(
select(MoviePlayProgress)
.where(MoviePlayProgress.device_id == device_id)
.where(MoviePlayProgress.movie_id.in_(ids))
).scalars()
}
out: list[MovieOut] = []
for movie in movies:
studio = studios.get(movie.studio_id) if movie.studio_id else None
studio_out = StudioOut.model_validate(studio) if studio is not None else None
performers = [
PerformerOut(
id=perf.id,
canonical_name=perf.canonical_name,
slug=perf.slug,
gender=perf.gender.value if perf.gender else None,
as_alias=alias,
)
for perf, alias in perf_by.get(movie.id, [])
]
tags = [TagOut.model_validate(t) for t in tags_by.get(movie.id, [])]
chapters = [MovieChapterOut.model_validate(c) for c in chapters_by.get(movie.id, [])]
external_refs = [
ExternalRefOut(source=name, external_id=ref.external_id, url=ref.url, last_seen=ref.last_seen)
for ref, name in refs_by.get(movie.id, [])
]
pb_rows = list(pb_by.get(movie.id, []))
pb_rows = [p for p in pb_rows if p.origin not in _MOVIE_DROP_ORIGINS]
# Bug-report 2026-05-16: raw landing origins (`mangoporn`/`pandamovies`/
# `streamporn` BEZ `:host`) otwierały WebView z reklamami pełnoekranowymi
# i myliły usera. Ukrywamy raw landing GDY ten sam movie ma co najmniej
# jeden sub-host entry (origin zawiera `:`). Jeśli movie nie ma sub-hosters
# (bo theme HTML się zmienił lub regex nie złapał), zostawiamy landing jako
# last-resort.
has_subhost = any(":" in p.origin for p in pb_rows)
if has_subhost:
pb_rows = [p for p in pb_rows if p.origin not in _MOVIE_LANDING_HIDE]
pb_rows = sorted(pb_rows, key=lambda p: _movie_origin_priority(p.origin))
# Dedup po realnym celu: 3 mirrory dooplay (mangoporn/pandamovies/streamporn)
# zapisują TEN SAM embed hostera jako osobne wiersze (np. luluvid/e/X z każdego
# mirrora) → film miał ~100 linków, z czego ~połowa to ten sam film (bug-report
# 41ca1fa4). Zostawiamy pierwszy = najwyższy priorytet (sort wyżej).
seen_targets: set[str] = set()
deduped: list[MoviePlaybackSource] = []
for p in pb_rows:
target = (p.embed_url or p.stream_url or p.page_url or "").strip()
if target and target in seen_targets:
continue
if target:
seen_targets.add(target)
deduped.append(p)
pb_rows = deduped
playback_sources = [PlaybackSourceOut.model_validate(p) for p in pb_rows]
progress = progress_by.get(movie.id)
out.append(
MovieOut(
id=movie.id,
title=movie.title,
slug=movie.slug,
release_year=movie.release_year,
release_date=movie.release_date,
duration_sec=movie.duration_sec,
description=movie.description,
director=movie.director,
country=movie.country,
rating=movie.rating,
poster_url=movie.poster_url,
backdrop_url=movie.backdrop_url,
studio=studio_out,
performers=performers,
tags=tags,
chapters=chapters,
external_refs=external_refs,
playback_sources=playback_sources,
created_at=movie.created_at,
is_favorite=movie.id in favs,
last_played_at=progress.last_played_at if progress else None,
finished=progress.finished if progress else False,
position_sec=progress.position_sec if progress else 0,
)
)
return out

View file

@ -1,14 +1,14 @@
"""POST /scenes/{scene_id}/playback/{playback_id}/resolve — rozwiązuje stream URL.
"""POST /scenes/{scene_id}/playback/{playback_id}/resolve, rozwiązuje stream URL.
Mobile apka woła ten endpoint na klik "Watch" — backend ekstraktuje świeży
Mobile apka woła ten endpoint na klik "Watch", backend ekstraktuje świeży
stream URL (m3u8/mp4) z page tube'a i zwraca go. Mobile otwiera URL przez
Linking.openURL → Android player chooser (MX Player / VLC / browser).
Stream URLs są podpisane/expire (zwykle ~kilka godzin) — nie cache'ujemy ich
Stream URLs są podpisane/expire (zwykle ~kilka godzin), nie cache'ujemy ich
w DB, tylko resolve on-demand. Logika ekstrakcji per-tube w `app.extractors`.
**Dead-link detection**: gdy hoster embed page mówi "Video deleted/not found",
oznaczamy `PlaybackSource.dead_at = now()` — API dalej go nie listuje, mobile
oznaczamy `PlaybackSource.dead_at = now()`, API dalej go nie listuje, mobile
nie pokaże martwego buttonu.
"""
from __future__ import annotations
@ -39,23 +39,23 @@ log = logging.getLogger(__name__)
router = APIRouter(prefix="/scenes", tags=["playback"], dependencies=[Depends(require_api_key)])
# CDN-domain allowlist dla mobile direct fetch — token IS time-bound (nie IP-bound),
# CDN-domain allowlist dla mobile direct fetch, token IS time-bound (nie IP-bound),
# zweryfikowane cross-IP curl test 2026-05-18. Mobile ExoPlayer pobiera manifest+segments
# bezpośrednio z CDN, **zero VPS bandwidth**. Critical dla public release (TB+/miesiąc).
#
# Verified time-bound:
# - xvideos-cdn.com, xnxx-cdn.com (WGCZ Holding) — signed token w path + exp_time
# - phncdn.com (pornhub), ypncdn.com (youporn), rdtcdn.com (redtube) — validfrom+validto+hash
# - privatehost.com (pornhat CDN) — sign + exp_time, brak Referer requirement
# - sxyprn.com — signed path
# - eporner.com CDN — IP literal w path ale CDN go ignoruje
# - xvideos-cdn.com, xnxx-cdn.com (WGCZ Holding), signed token w path + exp_time
# - phncdn.com (pornhub), ypncdn.com (youporn), rdtcdn.com (redtube), validfrom+validto+hash
# - privatehost.com (pornhat CDN), sign + exp_time, brak Referer requirement
# - sxyprn.com, signed path
# - eporner.com CDN, IP literal w path ale CDN go ignoruje
#
# NIE w allowlist (IP-bound, wymagają proxy):
# - premilkyway.com (latestpornvideo) — 403 cross-IP
# - tnmr.org (mypornerleak) — 403 cross-IP
# - porntrex.com/get_file — single-use token (410 po reuse)
# - freshporno.org/get_file — cv= signed token IP-bound
# - sn.porn-xp.com, porn00.org — force_proxy explicit
# - premilkyway.com (latestpornvideo), 403 cross-IP
# - tnmr.org (mypornerleak), 403 cross-IP
# - porntrex.com/get_file, single-use token (410 po reuse)
# - freshporno.org/get_file, cv= signed token IP-bound
# - sn.porn-xp.com, porn00.org, force_proxy explicit
_TIME_BOUND_CDN_RE = re.compile(
r"\b(?:"
r"xvideos-cdn|xnxx-cdn|phncdn|ypncdn|rdtcdn" # mainstream
@ -65,10 +65,42 @@ _TIME_BOUND_CDN_RE = re.compile(
re.IGNORECASE,
)
# IP-BOUND CDN signature — token bind do requester IP, cross-IP fetch = 403.
# Hostery znane z przekierowywania całej domeny na następcę (rodzina DoodStream
# zmienia szyld co kilka miesięcy). TYLKO dla nich rozwiązujemy przekierowanie przed
# oddaniem embedu, bo to kosztuje jeden fetch; zwykłe embedy idą bez dotykania sieci.
_REDIRECTING_EMBED_RE = re.compile(
r"//(?:[a-z0-9-]+\.)?(?:myvidplay|doply|doodstream|dood|d0+d|dooood|do0od|do7go"
r"|ds2play)\.[a-z]{2,6}/",
re.IGNORECASE,
)
def _resolve_embed_redirect(url: str) -> str:
"""Zwraca końcowy adres po przekierowaniach albo oryginał, gdy nic się nie zmienia.
Wynik trafia do `embed_url` w bazie, więc każdy adres jest rozwiązywany najwyżej
raz; po utrwaleniu regex przestaje pasować i ta funkcja robi się darmowa.
"""
if not _REDIRECTING_EMBED_RE.search(url):
return url
from app.extractors import browser_get
try:
res = browser_get(url, timeout=15.0, follow_redirects=True)
final = str(getattr(res, "url", "") or "")
# Utrwalamy tylko realny skok na inny adres http(s); błąd hostera (5xx itp.)
# zostawia oryginał, żeby nie zabetonować chwilowej awarii jako "nowego" adresu.
if final.startswith("http") and final != url and res.status_code < 400:
return final
except Exception as e:
log.info("embed redirect resolve padl dla %s: %s", url, e)
return url
# IP-BOUND CDN signature, token bind do requester IP, cross-IP fetch = 403.
# Wymaga mobile WebView fallback (mobile extract z phone session, nie VPS).
# Shared KVS infrastructure across multiple hosters (luluvid movies, mypornerleak,
# latestpornvideo) — wszystkie używają tego samego CDN pool.
# latestpornvideo), wszystkie używają tego samego CDN pool.
_IP_BOUND_CDN_RE = re.compile(
r"\b(?:"
r"premilkyway\.com" # latestpornvideo
@ -82,17 +114,17 @@ _IP_BOUND_CDN_RE = re.compile(
class StreamLink(BaseModel):
"""Pojedynczy variant stream URL (różne quality / kontener).
`stream_url` = URL do video file (mp4/m3u8/webm) — proxy-wrapped URL przez backend
`stream_url` = URL do video file (mp4/m3u8/webm), proxy-wrapped URL przez backend
VPS (`/proxy/{token}/play.ext`). Bezpieczny fallback gdy CDN binduje URL do IP
extractora (np. fpo.xxx z kt_remote_ips cookie). Bandwidth idzie przez VPS.
`direct_url` + `headers` = surowy CDN URL z headers do bezpośredniego fetchu z
urządzenia. Większość tube CDN (xhamster, redtube, watchporn, eporner) zwraca
poprawnie content gdy mobile player wysyła `Referer` + `User-Agent` z `headers`.
Mobile próbuje direct PIERWSZY — gdy CDN zwróci 403/410 (IP-bound), spada na
Mobile próbuje direct PIERWSZY, gdy CDN zwróci 403/410 (IP-bound), spada na
`stream_url` (proxy). Daje 0 bandwidth na VPS-ie dla większości scen.
`embed_url` = URL do embed/hoster page (HTML, np. StreamWish, doodporn) — mobile
`embed_url` = URL do embed/hoster page (HTML, np. StreamWish, doodporn), mobile
otwiera w WebView. Type: 'hoster'.
"""
@ -122,7 +154,7 @@ def resolve_movie_playback(
playback_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)],
) -> ResolveOut:
"""Movies playback resolve — analog do `/scenes/{id}/playback/{pb}/resolve`.
"""Movies playback resolve, analog do `/scenes/{id}/playback/{pb}/resolve`.
Origin patterns:
- 'paradisehill' → tylko page_url (Yii2 player wymaga login session, więc
@ -150,7 +182,7 @@ def resolve_movie_playback(
if pb.origin == "paradisehill":
# Paradisehill: pobierz page, parsuj `var videoList = [...]` żeby dostać N parts.
# Każdy part to direct mp4 z paradisehill CDN (v1.paradisehill.cc), serwowane
# bez auth — 200 OK z plain User-Agent + Referer.
# bez auth, 200 OK z plain User-Agent + Referer.
# Bug-reports `c5693926`/`418270e4`/`3c999b27` 2026-05-21 ("ładuje tylko 1 z N").
# Poprzednio: tylko WebView fallback → mobile gra 1. part w playerze paradisehilla,
# nie ma sposobu przejść do następnego.
@ -162,7 +194,7 @@ def resolve_movie_playback(
parts = []
if parts:
for url, label in parts:
# NIE proxifikujemy tutaj — outer `_proxify_link` poniżej (linia 247) opakuje
# NIE proxifikujemy tutaj, outer `_proxify_link` poniżej (linia 247) opakuje
# wszystkie linki. Double-wrap → token wewnątrz tokena (broken proxy URL).
links.append(
StreamLink(
@ -174,7 +206,7 @@ def resolve_movie_playback(
)
)
else:
# Fallback: brak videoList (np. login-only movie) — WebView na całość.
# Fallback: brak videoList (np. login-only movie), WebView na całość.
links = [
StreamLink(
stream_url=None,
@ -191,7 +223,7 @@ def resolve_movie_playback(
try:
stream = extract_stream_from_hoster(target, referer=referer)
except HosterDead as e:
# Hoster wprost mówi "video deleted" — oznacz dead, NIE proponuj
# Hoster wprost mówi "video deleted", oznacz dead, NIE proponuj
# embed fallback (mobile ExoPlayer dostałby 404 HTML page i
# próbowałby zapisać jako .bin file; bug-report 2026-05-16
# "streamtape ściąga hurtowo pliki .bin").
@ -203,7 +235,7 @@ def resolve_movie_playback(
except Exception as e:
log.warning("movie hoster extract failed for %s: %s", target, e)
if stream and _IP_BOUND_CDN_RE.search(stream):
# IP-bound CDN (luluvid → cdn-tnmr.org, etc.) — token bind do VPS IP,
# IP-bound CDN (luluvid → cdn-tnmr.org, etc.), token bind do VPS IP,
# mobile direct = 403. Skip stream, fallback na embed_url (mobile WebView).
log.info(
"movie playback %s: stream URL IP-bound CDN — skip, WebView fallback",
@ -225,11 +257,11 @@ def resolve_movie_playback(
type_hint = "m3u8" if ".m3u8" in stream.lower() else "mp4"
raw_meta: dict = {"origin": pb.origin, "host": target}
# seekplayer-engine (#hash family: easyvidplayer/player4me/seekplayer/
# embedseek/upns — ~322k sources) zwraca master.m3u8 na raw-IP CDN
# embedseek/upns, ~322k sources) zwraca master.m3u8 na raw-IP CDN
# (185.237.x/203.188.x/45.156.x `/v4/<tok>/<exp>/pp/<hash>/master.m3u8`).
# Zweryfikowane cross-IP (curl_cffi chrome + Bright Data, 2026-06-06):
# manifest + variant + fMP4 segment WSZYSTKIE 200 z innego IP, a cert jest
# VALID (verify=True OK — IP-SAN, nie self-signed jak głosił stary docstring).
# VALID (verify=True OK, IP-SAN, nie self-signed jak głosił stary docstring).
# Token jest TIME-bound (`<exp>` unix ts), NIE IP-bound. → mobile ExoPlayer
# gra direct z CDN, zero VPS bandwidth (był to największy movie proxy-sink).
# Proxy (`stream_url`) zostaje jako fallback (stream_proxy IP-host gałąź
@ -246,8 +278,23 @@ def resolve_movie_playback(
raw=raw_meta,
)
)
# Zawsze dorzucamy embed jako fallback — mobile WebView może wyłapać URL z JS-a
# Zawsze dorzucamy embed jako fallback, mobile WebView może wyłapać URL z JS-a
if pb.embed_url:
# Hoster potrafi zostać atrapą przekierowującą na następcę (myvidplay i
# doply odpowiadają 30x na playmogo). WebView w apce blokuje top-frame
# skok na inną domenę (ochrona przed popunderami), więc surowy adres
# kończy się czarnym ekranem: zgłoszenia 3b75cb54/11d7b17b/d3af0813,
# wcześniej identyczny mechanizm w galaxyporn (vikingfile → vik1ngfile).
# Rozwiązujemy przekierowanie TUTAJ i utrwalamy wynik, żeby telefon
# dostał adres, na którym wolno mu zostać.
final_embed = _resolve_embed_redirect(pb.embed_url)
if final_embed != pb.embed_url:
log.info(
"movie playback %s: embed %s przekierowuje na %s, utrwalam",
pb.id, pb.embed_url, final_embed,
)
pb.embed_url = final_embed[:2048]
session.commit()
links.append(
StreamLink(
stream_url=None,
@ -297,7 +344,7 @@ def mark_playback_dead(
request: Request,
session: Annotated[Session, Depends(get_session)],
) -> None:
"""User-triggered mark dead — long-press na playback chip w mobile.
"""User-triggered mark dead, long-press na playback chip w mobile.
Bug-report 2026-05-12 (dd17c709): "Eporner to nie temporary. Więc długie
przytrzymanie na linku celem usunięcia też byłoby ok". Backend mark-dead
@ -362,7 +409,7 @@ def resolve_playback(
sitetag: str | None = None
if pb.origin.startswith("pornapp:"):
# Legacy origin format — pre-pornapp-removal migration. Po Fazie 2 zostanie tylko `tube:`.
# Legacy origin format, pre-pornapp-removal migration. Po Fazie 2 zostanie tylko `tube:`.
sitetag = pb.origin.split(":", 1)[1]
elif pb.origin.startswith("tube:"):
sitetag = pb.origin.split(":", 1)[1]
@ -382,7 +429,7 @@ def resolve_playback(
log.info("marked playback %s dead (origin=%s reason=%s)", pb.id, pb.origin, e)
raise HTTPException(status_code=410, detail=f"playback dead: {e}") from e
except TubePageError as e:
# Tube page is gone (404/410) — mark dead, propagate as 410. Inne 5xx → 502.
# Tube page is gone (404/410), mark dead, propagate as 410. Inne 5xx → 502.
if e.status_code in (404, 410):
reason = f"tube page {e.status_code} {pb.page_url}"
pb.dead_at = datetime.now(UTC)
@ -397,7 +444,7 @@ def resolve_playback(
) from e
if not sources:
# Extractor None — TRANSIENT failure (network glitch, tube chwilowy 503,
# Extractor None, TRANSIENT failure (network glitch, tube chwilowy 503,
# ad-network response zmieniony, race condition). NIE oznaczamy `dead_at`
# bo wcześniej powodowało false-positive permanent dead dla freshporno scen
# które działały przy następnym attempt (bug-report 2026-05-12).
@ -410,9 +457,9 @@ def resolve_playback(
"extractor None for playback %s (origin=%s) — transient, not marking dead",
pb.id, pb.origin,
)
# 503 (not 410!) żeby mobile NIE pokazało "Tube usunął ten film" — ten kod
# 503 (not 410!) żeby mobile NIE pokazało "Tube usunął ten film", ten kod
# jest dla permanent removal. 503 = transient, user może retry.
# Sentry filtruje HTTPException 502/503/504 w `_sentry_before_send` (main.py) —
# Sentry filtruje HTTPException 502/503/504 w `_sentry_before_send` (main.py),
# bez tego GOON-3 spam-floodował issue list (16 events/5h dla expected case).
raise HTTPException(
status_code=503,
@ -446,7 +493,7 @@ DEFAULT_PLAYER_UA = (
def _proxify_link(link: StreamLink, referer: str) -> StreamLink:
"""Wzbogaca StreamLink o:
- `stream_url`: proxy URL (fallback gdy direct fails)
- `direct_url`: surowy CDN URL (preferowany — 0 VPS bandwidth)
- `direct_url`: surowy CDN URL (preferowany, 0 VPS bandwidth)
- `headers`: Referer + User-Agent dla direct fetch
Mobile player próbuje direct PIERWSZY, fallback na stream_url po błędzie 403/410.
"""
@ -462,21 +509,21 @@ def _proxify_link(link: StreamLink, referer: str) -> StreamLink:
use_impersonate = bool(link.raw and link.raw.get("proxy_impersonate"))
# force_proxy=True (extractor flag) → direct_url=proxied od razu. Dla CDN-ów
# gdzie token IS bound do VPS IP (porn00 v-acctoken, pornxp sv.porn-xp.com
# signed path) — mobile direct ZAWSZE 403, więc nie ma sensu próbować.
# signed path), mobile direct ZAWSZE 403, więc nie ma sensu próbować.
# Bez tego: każdy playback "mrugnie" (direct fail → fallback na proxy).
force_proxy = bool(link.raw and link.raw.get("force_proxy"))
# mobile_direct_ok=True (extractor flag) → m3u8 może iść direct do mobile bo
# CDN URL ma time-bound (nie IP-bound) signed token. Mobile ExoPlayer pobiera
# manifest+segments bezpośrednio z CDN, zero VPS bandwidth.
mobile_direct_ok = bool(link.raw and link.raw.get("mobile_direct_ok"))
# Auto-detect time-bound CDN po domain — bez per-extractor flag setting.
# Auto-detect time-bound CDN po domain, bez per-extractor flag setting.
# Critical dla public release: wszystkie mainstream tubes (xvideos/xnxx/pornhub/
# youporn/redtube + pornhat) zwracają time-bound URLs które działają cross-IP.
if not mobile_direct_ok and raw_url and _TIME_BOUND_CDN_RE.search(raw_url):
mobile_direct_ok = True
refetch_url = (link.raw or {}).get("refetch_url")
refetch_hoster = (link.raw or {}).get("refetch_hoster")
# manifest_producer: hoster oddaje TREŚĆ manifestu (POST), nie URL — `/proxy/hls`
# manifest_producer: hoster oddaje TREŚĆ manifestu (POST), nie URL, `/proxy/hls`
# zawoła producenta zamiast GET-a. Patrz extractors/hosters/loadvid.py.
manifest_producer = (link.raw or {}).get("manifest_producer")
token = make_token(
@ -498,15 +545,15 @@ def _proxify_link(link: StreamLink, referer: str) -> StreamLink:
else:
ext = "mp4"
proxied = f"/proxy/{token}/play.{ext}"
# `direct_url`: surowy CDN URL — mobile próbuje go PIERWSZY (0 VPS bandwidth).
# `direct_url`: surowy CDN URL, mobile próbuje go PIERWSZY (0 VPS bandwidth).
# ALE: dla type=m3u8/hls/mpd manifest URL musi być rewritowany żeby segmenty/keys
# też leciały przez proxy (inne IP może też mieć rate limit/token issues), plus
# ExoPlayer wybiera extractor po URL extension — `.mp4` w direct URL pornhat
# ExoPlayer wybiera extractor po URL extension, `.mp4` w direct URL pornhat
# → Mp4Extractor → fail bo content to HLS. Dla m3u8/mpd zwracamy proxied JAKO
# direct (mobile używa go bezpośrednio, 1 hop przez VPS ale to jedyny sposób
# żeby manifest+segments były spójne i ExoPlayer wybrał HlsMediaSource).
# Dla CDNs które wymagają chrome JA3 (mxcontent) direct_url też zawsze przez
# proxy — bez tego mobile direct fetch z OkHttp JA3 dostaje 403 → fallback proxy
# proxy, bez tego mobile direct fetch z OkHttp JA3 dostaje 403 → fallback proxy
# → extra round-trip + ExoPlayer "no extractors" przed retry.
# mobile_direct_ok overrides m3u8 default-to-proxy: gdy CDN ma time-bound token
# (nie IP-bound), mobile ExoPlayer może pobrać manifest direct bez VPS proxy.

View file

@ -1,8 +1,8 @@
"""Playback telemetry — POST /playback-events (fire-and-forget z apki).
"""Playback telemetry, POST /playback-events (fire-and-forget z apki).
Apka po każdej próbie odtwarzania woła to z origin sceny + wynikiem (success/error).
Sygnał zasila health/szybkość w rankingu źródeł (run_source_stats agreguje okno 7d).
Best-effort: apka NIE czeka i ignoruje błąd — telemetria nie może psuć playbacku.
Best-effort: apka NIE czeka i ignoruje błąd, telemetria nie może psuć playbacku.
"""
from __future__ import annotations
@ -22,12 +22,12 @@ router = APIRouter(prefix="/playback-events", tags=["telemetry"], dependencies=[
class PlaybackEventIn(BaseModel):
# 'tube:<sitetag>' — zgodne z playback_sources.origin. Bez tego ping bezużyteczny.
# 'tube:<sitetag>', zgodne z playback_sources.origin. Bez tego ping bezużyteczny.
origin: str = Field(min_length=1, max_length=64)
status: Literal["success", "error"]
scene_id: uuid.UUID | None = None
error_kind: str | None = Field(default=None, max_length=64)
# Surowa treść błędu z native playera (ExoPlayer message) — diagnostyka realnych
# Surowa treść błędu z native playera (ExoPlayer message), diagnostyka realnych
# padów których nie da się odtworzyć na emulatorze (np. watchporn player_error).
error_detail: str | None = Field(default=None, max_length=512)
ttff_ms: int | None = Field(default=None, ge=0, le=600_000)

View file

@ -1,12 +1,12 @@
"""Saved searches — zapisane słowa kluczowe per urządzenie.
"""Saved searches, zapisane słowa kluczowe per urządzenie.
User-report mobilism: sceny słabo opisane → user chce zapisywać często używane
zapytania i szybko je odpalać. Scope po device_id (X-Device-Id), jak reszta stanu.
Endpointy:
GET /saved-searches — lista zapisów tego urządzenia (najnowsze pierwsze)
POST /saved-searches — dodaj {query} (idempotent na device+query)
DELETE /saved-searches/{id} — usuń
GET /saved-searches lista zapisów tego urządzenia (najnowsze pierwsze)
POST /saved-searches dodaj {query} (idempotent na device+query)
DELETE /saved-searches/{id} usuń
"""
from __future__ import annotations
@ -17,6 +17,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, field_validator
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.api.device import get_device_id
@ -30,7 +31,7 @@ router = APIRouter(
dependencies=[Depends(require_api_key)],
)
# Limit zapisów per urządzenie — to wygodny skrót, nie nieograniczona kolekcja.
# Limit zapisów per urządzenie, to wygodny skrót, nie nieograniczona kolekcja.
MAX_PER_DEVICE = 50
@ -71,7 +72,7 @@ def add_saved_search(
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> SavedSearch:
# Idempotent — ten sam (device, query) zwraca istniejący wiersz.
# Idempotent, ten sam (device, query) zwraca istniejący wiersz.
existing = session.execute(
select(SavedSearch).where(
SavedSearch.device_id == device_id, SavedSearch.query == payload.query
@ -89,7 +90,23 @@ def add_saved_search(
)
row = SavedSearch(device_id=device_id, query=payload.query)
session.add(row)
session.commit()
try:
session.commit()
except IntegrityError:
# Wyscig na `uq_saved_searches_device_query`: sprawdzenie wyzej i INSERT to dwie
# osobne operacje, wiec dwa rownolegle zapisy tego samego zapytania (podwojne
# tapniecie, ponowienie po timeoucie) oba widza pusto i oba wstawiaja. Drugi
# dostawal 500 zamiast idempotentnej odpowiedzi (GOON-4Y). Skoro wiersz juz
# istnieje, to dokladnie ten, ktory user chcial zapisac, wiec go oddajemy.
session.rollback()
existing = session.execute(
select(SavedSearch).where(
SavedSearch.device_id == device_id, SavedSearch.query == payload.query
)
).scalar_one_or_none()
if existing is None:
raise
return existing
session.refresh(row)
return row

View file

@ -1,9 +1,9 @@
"""Scene favorites — ulubione sceny (single-user, równolegle do /favorites/performers).
"""Scene favorites, ulubione sceny (single-user, równolegle do /favorites/performers).
Endpointy:
GET /scene-favorites — lista ulubionych scen (pełen SceneOut)
POST /scene-favorites/{scene_id} — dodaj (idempotent)
DELETE /scene-favorites/{scene_id} — usuń
GET /scene-favorites lista ulubionych scen (pełen SceneOut)
POST /scene-favorites/{scene_id} dodaj (idempotent)
DELETE /scene-favorites/{scene_id} usuń
"""
from __future__ import annotations
@ -16,7 +16,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.device import get_device_id
from app.api.scenes import _build_scene_out
from app.api.scenes import _build_scenes_out_batch
from app.api.schemas import SceneOut
from app.auth import require_api_key
from app.db import get_session
@ -54,7 +54,11 @@ def list_scene_favorites(
)
.all()
)
items = [_build_scene_out(session, scene, device_id=device_id) for scene, _ in rows]
# Wsadowo jak listy scen: ekran ulubionych rysuje ten sam kafelek, a pojedyncze
# _build_scene_out robilo 7 zapytan na scene (setki ulubionych = sekundy).
items = _build_scenes_out_batch(
session, [scene for scene, _ in rows], light=True, device_id=device_id
)
return SceneFavoriteListOut(items=items, total=len(items))

View file

@ -1,14 +1,15 @@
"""GET /scenes — lista i szczegóły scen z bazy kanonicznej."""
"""GET /scenes, lista i szczegóły scen z bazy kanonicznej."""
from __future__ import annotations
import logging
import re
import hashlib
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy import distinct, exists, false, func, literal_column, select
from sqlalchemy import Date, and_, distinct, exists, false, func, literal_column, select, union
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@ -29,6 +30,7 @@ from app.models.favorite_scene import FavoriteScene
from app.models.performer import Performer, PerformerExternalRef
from app.models.play_progress import ScenePlayProgress
from app.models.playback_source import PlaybackSource
from app import verticals
from app.models.scene import Scene, SceneExternalRef, ScenePerformer, SceneTag
from app.models.source import Source, SourceKind
from app.models.studio import Studio
@ -39,7 +41,29 @@ log = logging.getLogger(__name__)
router = APIRouter(prefix="/scenes", tags=["scenes"], dependencies=[Depends(require_api_key)])
_VALID_SORTS = {"created_at", "release_date", "title", "studio"}
_VALID_SORTS = {
"created_at",
"release_date",
"title",
"studio",
"random",
"duration_desc",
"duration_asc",
}
def _effective_date_desc():
"""`release_date`, a gdy go brak, dzien trafienia sceny do katalogu, malejaco.
MUSI byc identyczne z wyrazeniem w `ix_scenes_effective_date_desc`, inaczej planner
indeksu nie uzyje i wroci do pelnego skanu `scenes`. `created_at::date` NIE nadaje
sie do indeksu, bo zalezy od strefy czasowej sesji (stable, nie immutable), stad
jawne AT TIME ZONE 'UTC'.
"""
return func.coalesce(
Scene.release_date,
func.cast(func.timezone("UTC", Scene.created_at), Date),
).desc()
# TTL-cache dla count'u scen-z-żywym-playback (default lista bez filtra). Full-scan
# 1.69M scen + EXISTS ~950ms; liczba zmienia się wolno i jest przybliżona (header
@ -60,8 +84,8 @@ def _default_scene_count(session: Session) -> int:
PlaybackSource.dead_at.is_(None),
)
),
# Domyślny feed wyklucza JAV (osobna sekcja) → licznik też.
~_jav_source_exists(),
# Domyślny feed wyklucza wertykały (JAV, animacja) → licznik też.
~_vertical_source_exists(),
).subquery()
)
total = session.execute(count_query).scalar_one()
@ -70,30 +94,36 @@ def _default_scene_count(session: Session) -> int:
return total
# JAV vertical — osobna sekcja. Sceny z tych originów NIE wchodzą do głównego feedu
# (domyślnie wykluczone), tylko do zakładki JAV (?jav=true). JAV to osobny namespace
# (kody typu BKD-368, azjatyckie tytuły), nie deduplikuje się z zachodnim katalogiem,
# więc scena JAV ma WYŁĄCZNIE origin JAV → wykluczenie/inkluzja po tym originie jest pewna.
JAV_ORIGINS = ("tube:javflix", "tube:javguru", "tube:vjav", "tube:supjav")
# Wertykały (JAV, animacja) mają własne sekcje w apce i NIE wchodzą do głównego feedu.
# Definicje i uzasadnienie: `app/verticals.py`. Tu zostaje tylko budowa predykatu.
# `JAV_ORIGINS` re-eksportowane, bo importują je inne moduły i skrypty.
JAV_ORIGINS = verticals.JAV_ORIGINS
def _jav_source_exists():
def _vertical_source_exists(vertical: str | None = None):
"""EXISTS żywego źródła z originem wertykału. `None` → DOWOLNY wertykał, czyli
predykat, którym główny feed odcina je wszystkie naraz."""
return exists(
select(1).where(
PlaybackSource.scene_id == Scene.id,
PlaybackSource.dead_at.is_(None),
PlaybackSource.origin.in_(JAV_ORIGINS),
PlaybackSource.origin.in_(verticals.origins_for(vertical)),
)
)
def _jav_source_exists():
"""Zachowane pod starą nazwą, używane przez `/scenes/{id}/similar` i testy."""
return _vertical_source_exists("jav")
# Blacklisty (performer/studio/tag) są zwykle PUSTE (self-hosted, single-user). Mimo to
# 3 NOT EXISTS klauzule doklejały się do KAŻDEJ filtrowanej listy scen i były ewaluowane
# per-row — przy filtrze typu duży-tag/has_playback planer chodzi po ~176k scen, więc te
# per-row, przy filtrze typu duży-tag/has_playback planer chodzi po ~176k scen, więc te
# puste-zawsze klauzule kosztowały ~3.4s (mega-tag „anal": 6.7s→3.3s po pominięciu).
# Cache'ujemy emptiness (TTL 5 min); gdy ktoś doda blacklist-wpis, w ciągu 5 min klauzule
# wracają. Patrz reference_scenes_list_perf / task #22.
# Cache per device_id (blacklisty są teraz device-scoped — bug 2026-06-08).
# Cache per device_id (blacklisty są teraz device-scoped, bug 2026-06-08).
_BLACKLIST_EMPTY_CACHE: dict[str, tuple[float, bool]] = {}
_BLACKLIST_EMPTY_TTL = 300.0
@ -241,6 +271,14 @@ def list_scenes(
"z jedynym playback z hqporner (~7-min Brazzers trailer clipy zalewają katalog)."
),
),
vertical: str | None = Query(
default=None,
description=(
"Wertykał do pokazania: 'jav' albo 'animation'. Brak → główny feed, "
"czyli WSZYSTKIE wertykały wykluczone. Zastępuje starsze `jav=true`, "
"które zostaje dla instalacji sprzed sierpnia 2026."
),
),
jav: bool = Query(
default=False,
description=(
@ -248,7 +286,15 @@ def list_scenes(
"JAV to osobna sekcja w apce, nie zalewa głównego feedu (origin javflix/javguru/vjav/supjav)."
),
),
sort: str = Query(default="created_at", description="created_at|release_date|title|studio"),
sort: str = Query(
default="created_at",
description="created_at|release_date|title|studio|random|duration_desc|duration_asc",
),
seed: str | None = Query(
default=None,
max_length=64,
description="Dla sort=random: ten sam seed daje ten sam porzadek na kolejnych stronach.",
),
page: int = Query(default=1, ge=1),
per_page: int = Query(default=50, ge=1, le=200),
) -> SceneListOut:
@ -283,7 +329,7 @@ def list_scenes(
if len(tag_slug_list) > _MAX_TAGS:
raise HTTPException(status_code=422, detail=f"too many tag filters (max {_MAX_TAGS})")
# AND między tagami: scena musi mieć WSZYSTKIE zaznaczone tagi. Każdy slug → osobny
# exists() — zaznaczanie kolejnych filtrów zawęża wyniki, jak intuicja użytkownika.
# exists(), zaznaczanie kolejnych filtrów zawęża wyniki, jak intuicja użytkownika.
#
# PERF (2026-06-07): resolvujemy slug→tag_id w aplikacji i filtrujemy po LITERALNYM
# tag_id (NIE JOIN po Tag.slug). Z literałem planner zna kardynalność tagu ze
@ -337,32 +383,49 @@ def list_scenes(
base = base.where(~live_playback_exists())
if origin:
# Substring match na origin — 'hqporner' złapie 'tube:hqpornercom'.
# Pelny origin z ekranu strony ('tube:hdporngg') porownujemy wprost: rownosc idzie
# po ix_playback_sources_origin_live, a ILIKE '%...%' przeszukiwal kazde zrodlo
# kazdej kandydujacej sceny (wyszukiwanie w obrebie strony 4,6 s, Sentry Slow DB
# Query 2026-09-15). Krotka forma z filtra ('hqporner') zostaje substringiem.
origin_clause = (
PlaybackSource.origin == origin
if ":" in origin
else PlaybackSource.origin.ilike(f"%{origin}%")
)
base = base.where(
exists(
select(1).where(
PlaybackSource.scene_id == Scene.id,
PlaybackSource.dead_at.is_(None),
PlaybackSource.origin.ilike(f"%{origin}%"),
origin_clause,
)
)
)
# JAV vertical gate: domyślnie JAV wykluczone z głównego feedu; ?jav=true → TYLKO JAV.
# ALE gdy user browse'uje KONKRETNY origin (Sites → javflix/vjav/supjav → SiteScenes),
# bramy nie stosujemy — jawnie chce ten origin, więc pokazujemy (regresja "No videos"
# na stronach JAV, bug-report 2026-07-19). Brama dotyczy tylko głównego feedu (origin=None).
if jav:
base = base.where(_jav_source_exists())
# Brama wertykałów: domyślnie WSZYSTKIE wykluczone z głównego feedu, `?vertical=X`
# → TYLKO ten wertykał. ALE gdy user browse'uje KONKRETNY origin (Sites → javflix/
# vjav/hentaigasm → SiteScenes), bramy nie stosujemy, bo jawnie chce ten origin
# (regresja "No videos" na stronach JAV, bug-report 2026-07-19). Brama dotyczy tylko
# głównego feedu (origin=None).
wanted = vertical if vertical in verticals.VERTICALS else ("jav" if jav else None)
if wanted:
base = base.where(_vertical_source_exists(wanted))
elif origin is None:
base = base.where(~_jav_source_exists())
base = base.where(~_vertical_source_exists())
# Blacklisty device (performer/studio/tag) — globalne wykluczenia, współdzielone z
# Blacklisty device (performer/studio/tag), globalne wykluczenia, współdzielone z
# licznikiem +N ulubionych. Puste blacklisty → [] (typowy single-user, zero kosztu).
for _bl_clause in blacklist_clauses(session, device_id):
base = base.where(_bl_clause)
if min_duration_sec is not None:
# `min_duration_sec=0` znaczy "bez progu", a NIE `duration_sec >= 0`. To rozroznienie
# jest tu istotne, bo w SQL NULL nie przechodzi ZADNEGO porownania: przy `>= 0` scena
# o NIEZNANEJ dlugosci wypadala tak samo jak dziesieciosekundowy smiec. Dla serwisow,
# ktore dlugosci nie podaja w ogole (hentaimama, javflix, supjav, hentaigasm,
# hentaiplay, xanimeporn maja 100% NULL), lista wychodzila PUSTA, mimo ze wejscie w
# konkretny serwis jest jawna prosba usera o jego zawartosc. Prog > 0 dalej odcina
# NULL-e swiadomie, bo tam filtrujemy smieci w glownym feedzie.
if min_duration_sec:
base = base.where(Scene.duration_sec >= min_duration_sec)
if max_duration_sec is not None:
base = base.where(Scene.duration_sec <= max_duration_sec)
@ -373,28 +436,14 @@ def list_scenes(
base = base.where(Scene.release_date >= cutoff)
if min_quality_p is not None:
# PlaybackSource.quality to wolny string — szukamy liczb w prefixie ('1080p',
# '1080p Full HD', '2160p'). Heurystyka: wystarczy że scena ma JEDEN żywy
# playback z quality liczbą >= min. '4K'/'UHD' aliasujemy na 2160.
from sqlalchemy import Integer, cast, or_
numeric_q = cast(
func.coalesce(func.substring(PlaybackSource.quality, r"\d+"), "0"),
Integer,
)
conds = [numeric_q >= min_quality_p]
if min_quality_p <= 2160:
conds.append(PlaybackSource.quality.ilike("%4k%"))
conds.append(PlaybackSource.quality.ilike("%uhd%"))
base = base.where(
exists(
select(1).where(
PlaybackSource.scene_id == Scene.id,
PlaybackSource.dead_at.is_(None),
PlaybackSource.quality.isnot(None),
or_(*conds),
)
)
)
# Skalarny filtr po zdenormalizowanej kolumnie. Wcześniejsze podejścia (EXISTS
# na playback_sources, potem JOIN z DISTINCT) dawały 32-52 s na produkcji, bo
# planer i tak przechodził `scenes` po `created_at` scena po scenie. Pomiary
# i uzasadnienie: migracja 0029_scenes_max_quality.
#
# NULL (93,5% katalogu) NIE przechodzi, i tak ma być: filtr obiecuje jakość
# potwierdzoną, a nie zgadywaną. UI mówi o tym wprost pod przełącznikiem.
base = base.where(Scene.max_quality_p >= min_quality_p)
if not include_stubs:
# Stub scene heuristic (współdzielona z licznikiem +N ulubionych): tube-only scena
@ -406,9 +455,9 @@ def list_scenes(
_is_pure_default = (
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 min_duration_sec is None
and not min_duration_sec
and max_duration_sec is None and released_within_days is None
and min_quality_p is None and not jav
and min_quality_p is None and not jav and vertical is None
)
# Count strategy:
# - PURE default: cached pełny licznik katalogu (TTL 10 min).
@ -416,45 +465,88 @@ def list_scenes(
# dominującym kosztem (~4s na has_playback / min_duration / duży tag) i plan
# był NIESTABILNY (literal LIMIT + count-nad-PK pomogły w części przypadków,
# ale planer i tak czasem skanuje cały zbiór zamiast urwać). Mobile paginuje
# po `has_more` (per_page+1 fetch), NIE po `total` — `total` to tylko licznik
# po `has_more` (per_page+1 fetch), NIE po `total`, `total` to tylko licznik
# "N+" w UI. Wyprowadzamy go z has_more PO fetchu (patrz niżej): dolna granica
# + flaga "jest więcej". Eliminuje cały koszt count z każdej filtrowanej listy.
total_capped = False
total: int | None = _default_scene_count(session) if _is_pure_default else None
# Sort: KAŻDY tryb kończy się `Scene.id.desc()` — unikalny PK jako ostateczny
# Sort: KAŻDY tryb kończy się `Scene.id.desc()`, unikalny PK jako ostateczny
# tie-break. Bez tego przy OFFSET-paginacji sceny z IDENTYCZNYM created_at (masowy
# ingest = ten sam timestamp co do sekundy) i release_date mają NIEustalony porządek,
# więc kolejne strony nakładają się na siebie → feed "powtarza się" przy scrollu
# (report 304c9258). created_at desc dalej pierwszy, więc index-walk zostaje.
if sort == "release_date":
# Data premiery LUB, gdy jej nie ma, data trafienia do katalogu. Czyste
# `release_date DESC NULLS LAST` chowalo swiezosc, bo tuby daty premiery zwykle
# NIE podaja: xhamster 99,2% scen bez daty, xnxx 97,7%, porntrex 95,8%, hqporner
# 93,5%, sxyprn 85,7%. Wszystkie NULL-e ladowaly za datowanymi, wiec 82 z 91 scen
# dodanych w hqporner przez tydzien siedzialo za 3,4 tys. starszych pozycji i
# user widzial "brak nowych scen" (zgloszenie 1df50de3), mimo ze ingest dzialal.
# Sceny z prawdziwa data (kanon z TPDB/StashDB) sortuja sie jak dotad.
# Wyrazenie musi byc IMMUTABLE, zeby weszlo do indeksu: `created_at::date` zalezy
# od strefy sesji, stad jawne AT TIME ZONE 'UTC'. Indeks:
# ix_scenes_effective_date_desc (migracja 0033).
ordered = base.order_by(
Scene.release_date.desc().nullslast(), Scene.created_at.desc(), Scene.id.desc()
_effective_date_desc(), Scene.created_at.desc(), Scene.id.desc()
)
elif sort == "title":
ordered = base.order_by(
Scene.title_normalized.asc(), Scene.created_at.desc(), Scene.id.desc()
)
elif sort == "studio":
# Sceny bez studio na końcu; w obrębie studio — najświeższe pierwsze.
# Sceny bez studio na końcu; w obrębie studio, najświeższe pierwsze.
ordered = (
base.outerjoin(Studio, Studio.id == Scene.studio_id)
.order_by(
Studio.name_normalized.asc().nullslast(),
Scene.release_date.desc().nullslast(),
_effective_date_desc(),
Scene.created_at.desc(),
Scene.id.desc(),
)
)
elif sort in ("duration_desc", "duration_asc"):
# Najdluzsze / najkrotsze (zgloszenie ae6a045b). Sceny bez dlugosci odpadaja,
# dzieki czemu jeden indeks (duration_sec, created_at, id) WHERE duration_sec
# IS NOT NULL obsluguje oba kierunki skanem w przod i w tyl (migracja 0035).
dur = Scene.duration_sec.desc() if sort == "duration_desc" else Scene.duration_sec.asc()
created = Scene.created_at.desc() if sort == "duration_desc" else Scene.created_at.asc()
ident = Scene.id.desc() if sort == "duration_desc" else Scene.id.asc()
# Gorny limit 10 h: tuby raportuja czasem absurdy (rekord 130 h), ktore
# zasmiecalyby szczyt listy „najdluzsze".
ordered = (
base.where(Scene.duration_sec.isnot(None), Scene.duration_sec <= 36000)
.order_by(dur, created, ident)
)
elif sort == "random":
# Losowo bez sortowania milionow wierszy: id to losowy UUID, wiec porzadek po id
# od losowego punktu startu JEST losowy i idzie po indeksie PK. Punkt startu
# wynika z seeda (md5 -> uuid), wiec paginacja jest stabilna, a nowy seed
# (pull-to-refresh w apce) daje nowe rozdanie. Za koncem zakresu lista sie konczy.
start = uuid.UUID(hashlib.md5((seed or uuid.uuid4().hex).encode("utf-8")).hexdigest())
# Okno kandydatow po PK z LIMIT, a filtry (EXISTS/NOT EXISTS) dopiero na nim.
# Bez okna planer robil Merge Anti Join z indeksem playback_sources po scene_id
# i przechodzil 2,3 mln wierszy (7,6 s), bo ORDER BY id pasowal do obu indeksow.
# Z oknem 2000 id to nested loop po kilkudziesieciu wierszach (pomiar 2026-09-14).
# Okno rosnie ze strona, zeby glebsze strony mialy z czego wybierac. Sekcje
# wertykalne (JAV, Anime) to ~2% katalogu, wiec dostaja 4x szersze okno; z 2000 id
# JAV wyciagal 27 scen i druga strona bylaby pusta. Szersze okno dla wszystkich
# nie wchodzi w gre: z filtrem tagu 6000 id to 2,7 s (probe scene_tags per scena).
base_window = 8000 if (vertical or jav) else 2000
window = max(base_window, page * per_page * 4)
cand = (
select(Scene.id).where(Scene.id > start).order_by(Scene.id.asc()).limit(window).subquery()
)
ordered = base.where(Scene.id.in_(select(cand.c.id))).order_by(Scene.id.asc())
else: # created_at
ordered = base.order_by(
Scene.created_at.desc(), Scene.release_date.desc().nullslast(), Scene.id.desc()
)
# Fetch per_page+1 — obecność (per_page+1)-szego wiersza = jest kolejna strona.
# Fetch per_page+1, obecność (per_page+1)-szego wiersza = jest kolejna strona.
# To źródło prawdy dla paginacji (mobile getNextPageParam), niezależne od bounded
# `total`. Nadmiarowy wiersz odcinamy przed serializacją.
# LIMIT/OFFSET literalne (NIE bound-param) — patrz wyżej: sparametryzowany LIMIT
# LIMIT/OFFSET literalne (NIE bound-param), patrz wyżej: sparametryzowany LIMIT
# 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
# walidowane inty (Query ge=1, le=200), więc literal_column jest bezpieczne.
@ -475,7 +567,9 @@ def list_scenes(
total = (page - 1) * per_page + len(rows)
total_capped = has_more
items = _build_scenes_out_batch(session, list(rows), light=True, device_id=device_id)
items = _build_scenes_out_batch(
session, list(rows), light=True, device_id=device_id, prefer_origin=origin
)
return SceneListOut(
items=items,
@ -506,14 +600,14 @@ def similar_scenes(
device_id: Annotated[str, Depends(get_device_id)],
limit: int = Query(default=12, ge=1, le=30),
) -> SceneListOut:
"""Sceny podobne do danej — ranking po WSPÓLNYCH performerach (mocny sygnał) i
"""Sceny podobne do danej, ranking po WSPÓLNYCH performerach (mocny sygnał) i
tagach (słabszy). Zwraca tylko grywalne sceny (żywy playback), zgodne z bramą JAV
(JAV↔JAV, nie-JAV↔nie-JAV), z pominięciem blacklist/stubów/samej siebie.
Wydajność: performerzy są selektywni, więc pula kandydatów z nich jest mała (cap
_CAND_CAP). Tagi liczymy TYLKO w obrębie tej puli (tanio). Fill z tagów (bounded
_TAG_FILL_CAP, HAVING >=2 wspólne) odpala się gdy scena ma mało/zero performerów
(anonimowe tube sceny) — inaczej szeroki tag typu 'blowjob' skanowałby setki tysięcy
(anonimowe tube sceny), inaczej szeroki tag typu 'blowjob' skanowałby setki tysięcy
wierszy. Cały ranking i sort robimy w Pythonie nad OGRANICZONYMI zbiorami id."""
scene = session.get(Scene, scene_id)
if scene is None:
@ -527,7 +621,7 @@ def similar_scenes(
select(ScenePerformer.performer_id).where(ScenePerformer.scene_id == scene_id)
).scalars().all()
# Odsiej ŚMIECIOWYCH performerów: kategorie/studia podszywające się pod performera
# (over-attribution z luźnego tube-searcha — np. "Big Tits", "Brazzers", "Ball Licking").
# (over-attribution z luźnego tube-searcha, np. "Big Tits", "Brazzers", "Ball Licking").
# Sygnał: slug pokrywa się z tagiem I performer NIE ma kanonicznego refa (tpdb/stashdb).
# Prawdziwi performerzy z tym samym tagiem (tuby tagują sceny nazwiskiem) mają kanoniczny
# ref → zostają. Bez tego similar klastrował się po "Ball Licking" zamiast po treści.
@ -584,15 +678,38 @@ def similar_scenes(
.group_by(SceneTag.scene_id)
).all()
tag_shared = {r[0]: r[1] for r in rows}
# Fill z tagów gdy performerów mało/brak (anonimowe tube sceny) — inaczej pomijamy,
# Fill z tagów gdy performerów mało/brak (anonimowe tube sceny), inaczej pomijamy,
# bo szeroki tag skanuje setki tysięcy scen.
if tag_ids and len(cand_ids) < limit * 4:
# Pula z NAJRZADSZYCH tagów (najwięcej mówią o scenie), po _TAG_SEED_ROWS scen na
# tag, a wspólne tagi liczymy tylko dla tej puli. Grupowanie po wszystkich tagach
# sceny naraz czytało całe 'amateur' + 'teen-18' + 'handjob' (600 tys. wierszy,
# do 5 s). Sceny, które łączą z celem wyłącznie szerokie tagi, i tak są słabo
# podobne, więc ich pominięcie nic nie psuje.
_TAG_SEED_TAGS, _TAG_SEED_ROWS = 4, 1500
tag_sizes = dict(
session.execute(select(Tag.id, Tag.scene_count).where(Tag.id.in_(tag_ids))).all()
)
live_tags = [t for t in tag_ids if (tag_sizes.get(t) or 0) > 0] or list(tag_ids)
seed_tags = sorted(live_tags, key=lambda t: tag_sizes.get(t) or 0)[:_TAG_SEED_TAGS]
pool = union(
*[
select(SceneTag.scene_id)
.where(SceneTag.tag_id == t, SceneTag.scene_id != scene_id)
.limit(_TAG_SEED_ROWS)
for t in seed_tags
]
).subquery("pool")
shared = func.count(distinct(SceneTag.tag_id))
rows = session.execute(
select(SceneTag.scene_id, func.count(distinct(SceneTag.tag_id)).label("c"))
.where(SceneTag.tag_id.in_(tag_ids), SceneTag.scene_id != scene_id)
.group_by(SceneTag.scene_id)
.having(func.count(distinct(SceneTag.tag_id)) >= 2)
.order_by(func.count(distinct(SceneTag.tag_id)).desc())
select(pool.c.scene_id, shared.label("c"))
.join(
SceneTag,
and_(SceneTag.scene_id == pool.c.scene_id, SceneTag.tag_id.in_(tag_ids)),
)
.group_by(pool.c.scene_id)
.having(shared >= 2)
.order_by(shared.desc())
.limit(_TAG_FILL_CAP)
).all()
for sid, c in rows:
@ -602,19 +719,24 @@ def similar_scenes(
if not cand_ids:
return _empty
# Filtr widoczności (ta sama definicja co lista): grywalne + zgodne z bramą JAV +
# Filtr widoczności (ta sama definicja co lista): grywalne + ten sam wertykał +
# blacklist + nie-stub + nie-self.
target_is_jav = bool(
session.execute(
select(_jav_source_exists()).select_from(Scene).where(Scene.id == scene_id)
).scalar()
)
# Podobne muszą zostać w tym samym wertykale co scena-cel: dla sceny animowanej
# nie proponujemy zachodnich i odwrotnie (inne namespace'y, zero dedupu).
target_vertical: str | None = None
for _name in verticals.VERTICALS:
if session.execute(
select(_vertical_source_exists(_name)).select_from(Scene).where(Scene.id == scene_id)
).scalar():
target_vertical = _name
break
filt = select(Scene.id).where(
Scene.id.in_(cand_ids),
Scene.id != scene_id,
live_playback_exists(),
stub_exclusion_clause(),
_jav_source_exists() if target_is_jav else ~_jav_source_exists(),
_vertical_source_exists(target_vertical) if target_vertical
else ~_vertical_source_exists(),
)
for _bl in blacklist_clauses(session, device_id):
filt = filt.where(_bl)
@ -642,7 +764,7 @@ _SXYPRN_POST_RE = re.compile(r"sxyprn\.com/post/([0-9a-f]{6,40})", re.IGNORECASE
def _sxyprn_thumb_url(page_url: str | None) -> str | None:
"""Dla źródła sxyprn zwraca STABILNY endpoint on-demand resolvera
(`/proxy/sxyprn-thumb/<id>`) zamiast martwego trafficdeposit URL — token żyje ~1h,
(`/proxy/sxyprn-thumb/<id>`) zamiast martwego trafficdeposit URL, token żyje ~1h,
więc poster resolvujemy przy serwowaniu (bug 2026-06-10)."""
if not page_url:
return None
@ -655,7 +777,7 @@ _YOUPORN_WATCH_RE = re.compile(r"youporn\.com/watch/(\d+)", re.IGNORECASE)
def _youporn_thumb_url(page_url: str | None, thumb: str | None) -> str | None:
"""Dla youporn z PODPISANYM posterem (imgproxy `?hash=...&validto=`) zwraca stabilny
on-demand endpoint `/proxy/youporn-thumb/<video_id>` — zapisany podpis wygasa i CDN
on-demand endpoint `/proxy/youporn-thumb/<video_id>`, zapisany podpis wygasa i CDN
oddaje 410 (bug-report bebf8c92; 8666 z 62930 źródeł). Statycznych `.../original/N.jpg`
NIE ruszamy (działają bezterminowo, zero dodatkowych fetchy)."""
if not page_url or not thumb or "hash=" not in thumb:
@ -667,40 +789,94 @@ def _youporn_thumb_url(page_url: str | None, thumb: str | None) -> str | None:
def _is_rotting_thumb(url: str) -> bool:
"""sxyprn/trafficdeposit miniaturki są czasowo podpisane i rotują (asset 404 po
~tygodniach, nie odświeżalne server-side; bug 2026-06-10). De-prioritize je w wyborze
slim-thumbnaila — używamy tylko gdy scena nie ma żadnej innej miniaturki."""
slim-thumbnaila, używamy tylko gdy scena nie ma żadnej innej miniaturki."""
if "trafficdeposit.com" in url:
return True
# imgs.xiaoshenke.net (fullporner) oddaje 200 z ~153-bajtową ATRAPĄ zamiast obrazu.
# Formalnie „ma miniaturę", więc przechodziło warunek `find(s => s.thumbnail_url)`
# w apce i WYGRYWAŁO jako pierwsze źródło — a działająca miniatura (np. hqporner,
# 22 kB) leżała niżej i nigdy nie była użyta. Objawiało się dopiero po scaleniu scen
# („3 źródła live, a miniaturki nie ma", zgłoszenia a01dc891 / 2fbba822 / b437d5f7):
# przy jednym źródle brało się to dobre, po merge na czoło wchodziła atrapa.
if "imgs.xiaoshenke.net" in url:
return True
# tn.sextu.com (fullvideosporn) — CDN odrzuca ruch z IP datacenter (429), więc nasze
# imgs.xiaoshenke.net (fullporner) było tu wpisane 2026-08-02, bo host oddawał 200
# z ~153-bajtową atrapą zamiast obrazu (zgłoszenia a01dc891 / 2fbba822 / b437d5f7).
# To był stan PRZEJŚCIOWY: pomiar 2026-08-11 na 300 losowych URL-ach z bazy dał
# 298 prawdziwych JPEG-ów (5-25 kB), tak samo z VPS jak i z łącza domowego.
# Blokada zdjęta, bo szkodziła bardziej niż pomagała: w SceneDetail (linia ~879)
# rotting-thumb jest ZEROWANY, więc sceny mające tylko fullporner traciły miniaturę
# całkowicie („Jakiś problem z miniaturkami z fullporner"). Dotyczyło 53 tys. źródeł.
# Gdyby atrapy wróciły, filtruj po ROZMIARZE odpowiedzi w image-proxy, nie po hoście.
# tn.sextu.com (fullvideosporn), CDN odrzuca ruch z IP datacenter (429), więc nasze
# image-proxy NIGDY tej miniatury nie pobierze (znany problem tego źródła). Traktujemy
# jak martwą, żeby scena po merge sięgnęła po działającą z innego źródła.
if "tn.sextu.com" in url:
return True
# fastpic.org (galaxyporn i inne re-uploadery) — hosting zrywa połączenie zamiast
# fastpic.org (galaxyporn i inne re-uploadery), hosting zrywa połączenie zamiast
# oddać obraz (RemoteProtocolError), czyli martwy. Zerowanie ma tu dodatkowy zysk:
# scena bez miniatury odpala w apce auto-enrich, który pobiera świeżą ze strony
# tube'a. Zgłoszenie c2f1dd41; dotyczy 240 z 20213 źródeł galaxyporn.
return "fastpic.org" in url
def _is_flaky_thumb(url: str) -> bool:
"""Host, który działa BYWA, ale zbyt często pada, żeby wygrywać wybór miniaturki.
W odróżnieniu od `_is_rotting_thumb` NIE zerujemy takiej miniatury w SceneDetail:
to nie jest martwy URL, tylko niepewny. Zerowanie powtórzyłoby wpadkę z fullpornerem
z sierpnia 2026, gdzie sceny mające tylko to źródło traciły obrazek całkowicie.
Problem był w kolejności wyboru: miniaturkę wygrywał pierwszy origin alfabetycznie,
a `tube:fullmoviesxxx` sortuje się PRZED `tube:fullporner`, więc chwiejny host
przykrywał zdrowy. 51 799 z 52 758 takich scen (98%) ma alternatywę, więc degradacja
niemal zawsze ma czym zastąpić.
Pomiar 2026-08-13, po 20-25 losowych URL-ach na host:
img.fullmovies.xxx 12/20 ok (5× ReadTimeout, 3× 502) 54 019 źródeł
img.hdporn.gg 16/20 ok (3× 502, 1× ReadTimeout) 54 267 źródeł
imgs.xiaoshenke.net 20/20 ok (fullporner) 68 406 źródeł
freshporno.org 20/20 ok
`img.hdporn.gg` dopisany po sprawdzeniu efektu pierwszej wersji: degradacja samego
fullmovies przesunęła wybór na hdporn (44 z 60 scen), czyli przeniosłaby problem
zamiast go rozwiązać. Po dopisaniu obu wygrywają hosty ze 100% skutecznością.
"""
return "img.fullmovies.xxx" in url or "img.hdporn.gg" in url
#: Im niżej, tym gorzej. Używane TYLKO wewnątrz tieru fallback, czyli gdy scena nie ma
#: żadnej zdrowej miniatury i trzeba wybrać mniejsze zło. Bez tego decydowała kolejność
#: alfabetyczna originu, przez co scena mająca fullmovies + hdporn dostawała fullmovies,
#: czyli host o 40% awaryjności zamiast tego o 20%.
_FLAKY_RANK = (
("img.hdporn.gg", 1), # 16/20 ok
("img.fullmovies.xxx", 2), # 12/20 ok
)
def _flaky_rank(url: str) -> int:
for host, rank in _FLAKY_RANK:
if host in url:
return rank
return 0 # nie-chwiejny (martwy rotting-thumb) — bez preferencji
def _is_preferred_origin(origin: str | None, prefer_origin: str | None) -> bool:
"""Czy to origin, ktory user wlasnie przeglada (`/scenes?origin=`).
Porownanie substringiem, bo parametr API jest substringowy: `hqporner` ma zlapac
`tube:hqpornercom`.
"""
if not prefer_origin or not origin:
return False
return prefer_origin.lower() in origin.lower()
def _needs_proxy(url: str) -> bool:
"""Wszystkie thumbnaile z playback_sources są proxowane przez backend.
Większość CDN-ów porn-tube'ów wymaga Refera (hqporner, mypornerleak/58img,
inne sxyprn/eporner CDN-y) — expo-image nie wysyła Referera.
inne sxyprn/eporner CDN-y), expo-image nie wysyła Referera.
Self-hosted lub backend-internal URL-e (zaczynające się od `/`) skipujemy."""
return url.startswith("http") and not url.startswith("/proxy/")
def _wrap_image_proxy(url: str, referer: str) -> str:
"""Wraps a thumbnail URL through /proxy/img/{token}/img.jpg. Klient nie musi
znać sekretu Referer — backend wstawi sam. Long TTL (30d) bo thumby
znać sekretu Referer, backend wstawi sam. Long TTL (30d) bo thumby
są stabilne, krótkie ttl by tylko niepotrzebnie zaśmiecało cache."""
from app.api.stream_proxy import make_token
# stable_bucket_sec=7d → proxied URL identyczny przez tydzień → expo-image
@ -713,11 +889,16 @@ def _wrap_image_proxy(url: str, referer: str) -> str:
def _build_scenes_out_batch(
session: Session, scenes: list[Scene], *, light: bool = False, device_id: str = LEGACY_DEVICE
session: Session,
scenes: list[Scene],
*,
light: bool = False,
device_id: str = LEGACY_DEVICE,
prefer_origin: str | None = None,
) -> list[SceneOut]:
"""Batch-fetch wszystkich relacji dla N scen w 7 zapytaniach (zamiast 7×N).
Eliminuje N+1 z `_build_scene_out` w listach scen — `/scenes?per_page=24` szło
Eliminuje N+1 z `_build_scene_out` w listach scen, `/scenes?per_page=24` szło
z ~9.6s do <500ms. Pojedyncza scena (`/scenes/{id}`) nadal używa `_build_scene_out`
bo overhead na batch nie ma sensu dla N=1.
@ -760,7 +941,7 @@ def _build_scenes_out_batch(
)
)
# 3) Tags + 4) External refs — kafelek listy ich nie używa; w light mode pomijamy
# 3) Tags + 4) External refs, kafelek listy ich nie używa; w light mode pomijamy
# (SceneDetail re-fetchuje pełną scenę przez /scenes/{id}).
tags_by_scene: dict = defaultdict(list)
refs_by_scene: dict = defaultdict(list)
@ -788,9 +969,11 @@ def _build_scenes_out_batch(
)
)
# 5) Playback sources. Light mode: tylko miniaturka (jedna na scenę) — kafelek
# 5) Playback sources. Light mode: tylko miniaturka (jedna na scenę), kafelek
# czyta wyłącznie playback_sources[].thumbnail_url / animated_thumbnail_url.
pb_by_scene: dict = defaultdict(list)
# origin per scena, wyłącznie do policzenia `vertical` (obie ścieżki go wypełniają).
origins_by_scene: dict = {}
if light:
pb_light = session.execute(
select(
@ -798,6 +981,10 @@ def _build_scenes_out_batch(
PlaybackSource.thumbnail_url,
PlaybackSource.animated_thumbnail_url,
PlaybackSource.page_url,
# origin dociągamy wyłącznie po to, żeby policzyć `vertical` (tania kolumna
# z tego samego wiersza, zero dodatkowych zapytań). Do samego kafelka
# nieużywany, slim wpis niżej dostaje origin="".
PlaybackSource.origin,
)
.where(
PlaybackSource.scene_id.in_(scene_ids),
@ -806,31 +993,46 @@ def _build_scenes_out_batch(
.order_by(PlaybackSource.origin.asc())
).all()
# Pierwsza miniaturka + pierwszy animated per scena (1 slim wpis). De-prioritize
# sxyprn/trafficdeposit thumbnaile — są podpisane czasowo i ROTUJĄ (asset 404 po
# sxyprn/trafficdeposit thumbnaile, są podpisane czasowo i ROTUJĄ (asset 404 po
# ~tygodniach, nie da się re-signować; bug 2026-06-10). Wolimy miniaturkę z innego
# źródła gdy istnieje; sxyprn bierzemy tylko gdy nic innego nie ma (świeże jeszcze
# działają, martwe → mobile pokazuje placeholder zamiast broken-image).
thumb_by_scene: dict = {}
thumb_fallback: dict = {}
anim_by_scene: dict = {}
for sid, thumb, anim, page_url in pb_light:
for sid, thumb, anim, page_url, origin in pb_light:
origins_by_scene.setdefault(sid, []).append(origin)
sxy = _sxyprn_thumb_url(page_url)
yp = _youporn_thumb_url(page_url, thumb)
if sxy:
# sxyprn → żywy on-demand resolver (martwy stored URL ignorujemy),
# tier fallback: użyty tylko gdy scena nie ma stabilniejszej miniatury.
thumb_fallback.setdefault(sid, (sxy, page_url))
thumb_fallback.setdefault(sid, (sxy, page_url, origin))
elif yp:
# youporn z podpisanym (wygasającym) posterem → on-demand resolver.
# Też tier fallback: statyczna miniatura z innego źródła jest tańsza.
thumb_fallback.setdefault(sid, (yp, page_url))
thumb_fallback.setdefault(sid, (yp, page_url, origin))
elif thumb:
if _is_rotting_thumb(thumb):
thumb_fallback.setdefault(sid, (thumb, page_url))
elif sid not in thumb_by_scene:
thumb_by_scene[sid] = (thumb, page_url)
if sid not in anim_by_scene and anim:
anim_by_scene[sid] = (anim, page_url)
if _is_rotting_thumb(thumb) or _is_flaky_thumb(thumb):
# Martwe ORAZ chwiejne lądują w tierze fallback: użyte tylko wtedy,
# gdy scena nie ma stabilniejszej miniatury z innego źródła. W obrębie
# samego fallbacku wybieramy mniejsze zło wg `_FLAKY_RANK`, bo inaczej
# decydowałaby kolejność alfabetyczna originu i scena z fullmovies +
# hdporn dostawała gorszy z dwóch (pomiar: 38 z 60 takich scen).
prev = thumb_fallback.get(sid)
if prev is None or _flaky_rank(thumb) < _flaky_rank(prev[0]):
thumb_fallback[sid] = (thumb, page_url, origin)
elif sid not in thumb_by_scene or _is_preferred_origin(origin, prefer_origin):
# Gdy user przeglada KONKRETNY serwis, jego miniaturka wygrywa.
# Wczesniej decydowala kolejnosc alfabetyczna originu, wiec na liscie
# hqporner kafelki scen wspoldzielonych z fullpornerem bralo
# `tube:fullporner` (< `tube:hqpornercom`), a czesc tych miniaturek to
# 153-bajtowa atrapa z imgs.xiaoshenke.net oddawana z kodem 200. Efekt:
# puste kafelki podpisane FULLPORNER na liscie hqporner, mimo ze ta
# sama scena miala obok zdrowa miniaturke hqporner (zgloszenie 1df50de3).
thumb_by_scene[sid] = (thumb, page_url, origin)
if anim and (sid not in anim_by_scene or _is_preferred_origin(origin, prefer_origin)):
anim_by_scene[sid] = (anim, page_url, origin)
# Uzupełnij scenami które mają TYLKO rotting thumbnail (sxyprn-only).
for sid, val in thumb_fallback.items():
thumb_by_scene.setdefault(sid, val)
@ -846,11 +1048,12 @@ def _build_scenes_out_batch(
t_url = _wrap_image_proxy(t_url, ref)
if a_url and _needs_proxy(a_url):
a_url = _wrap_image_proxy(a_url, ref)
# id/origin/page_url wymagane przez schemat ale nieużywane przez kafelek
# (SceneDetail re-fetchuje pełne źródła) — dummy sentinel.
# `origin` niesiemy PRAWDZIWY (źródła wybranej miniatury): kafelek pokazuje
# nazwę serwisu (element z makiety C, 2026-08-18). `id`/`page_url` zostają
# sentinelem, bo SceneDetail i tak re-fetchuje pełne źródła.
pb_by_scene[sid].append(
PlaybackSourceOut(
id=uuid.UUID(int=0), origin="", page_url="",
id=uuid.UUID(int=0), origin=(t or a)[2] or "", page_url="",
thumbnail_url=t_url, animated_thumbnail_url=a_url,
)
)
@ -864,6 +1067,7 @@ def _build_scenes_out_batch(
.order_by(PlaybackSource.origin.asc())
).scalars().all()
for p in pb_rows:
origins_by_scene.setdefault(p.scene_id, []).append(p.origin)
out = PlaybackSourceOut.model_validate(p)
# sxyprn/trafficdeposit stored thumb rotuje (404 po ~tygodniach). SceneDetail
# bierze PIERWSZE źródło z thumbem (origin ASC → sxyprncom często pierwsze), więc
@ -925,6 +1129,8 @@ def _build_scenes_out_batch(
tags=tags_by_scene.get(scene.id, []),
external_refs=refs_by_scene.get(scene.id, []),
playback_sources=pb_by_scene.get(scene.id, []),
vertical=verticals.vertical_for_origins(origins_by_scene.get(scene.id, ())),
max_quality_p=scene.max_quality_p,
created_at=scene.created_at,
backfill=scene.backfill,
last_played_at=progress.last_played_at if progress else None,
@ -999,7 +1205,7 @@ def _build_scene_out(session: Session, scene: Scene, *, device_id: str = LEGACY_
)
# Collapse źródła dzielące ten sam origin (hoster). Zmergowana scena często agreguje
# kilka uploadów z JEDNEGO tube'a (re-enkody / wersje 4K: bug-report aa79a995 "2 linki,
# oba do porntrex" = ta sama scena std+4K) — w UI to nierozróżnialne linki do tego
# oba do porntrex" = ta sama scena std+4K), w UI to nierozróżnialne linki do tego
# samego hostera (resolvują tym samym extractorem). Zostawiamy jeden najlepszy per
# origin: preferuj długość zgodną ze sceną (realny match) → jakąkolwiek długość →
# pierwszy (stabilnie, query jest origin-asc). Martwe już odfiltrowane (dead_at).
@ -1022,7 +1228,7 @@ def _build_scene_out(session: Session, scene: Scene, *, device_id: str = LEGACY_
for p in playback_rows:
out = PlaybackSourceOut.model_validate(p)
# sxyprn: podmień martwy trafficdeposit URL na żywy on-demand resolver
# (/proxy/sxyprn-thumb/<id>) — TAK JAK lista (_build_scenes_out_batch). Bez tego
# (/proxy/sxyprn-thumb/<id>), TAK JAK lista (_build_scenes_out_batch). Bez tego
# detal sceny pokazywał martwą miniaturę sxyprn mimo że lista działała, i "refresh
# thumbnail" nie pomagał (sxyprn IP-bound, enrich z VPS pada). Report 2026-07-22.
sxy = _sxyprn_thumb_url(p.page_url)
@ -1034,7 +1240,7 @@ def _build_scene_out(session: Session, scene: Scene, *, device_id: str = LEGACY_
elif out.thumbnail_url and _is_rotting_thumb(out.thumbnail_url):
out.thumbnail_url = None
# Wrap thumbnail URL-e przez backend image proxy gdy CDN wymaga Refera
# (hqporner — fastporndelivery zwraca 403 bez Referer headera, expo-image
# (hqporner, fastporndelivery zwraca 403 bez Referer headera, expo-image
# nie wysyła go domyślnie). Token ma 30-dniowy TTL bo thumby są stabilne.
if out.thumbnail_url and _needs_proxy(out.thumbnail_url):
out.thumbnail_url = _wrap_image_proxy(out.thumbnail_url, p.page_url)
@ -1073,6 +1279,8 @@ def _build_scene_out(session: Session, scene: Scene, *, device_id: str = LEGACY_
tags=tags_out,
external_refs=refs_out,
playback_sources=playback_out,
vertical=verticals.vertical_for_origins(p.origin for p in playback_rows),
max_quality_p=scene.max_quality_p,
created_at=scene.created_at,
backfill=scene.backfill,
last_played_at=progress.last_played_at if progress else None,
@ -1090,7 +1298,7 @@ def remove_tag_from_scene(
) -> None:
"""Usuwa relację scene↔tag (np. user uznał że tag jest błędny dla tej sceny).
Idempotent: brak relacji = success. Nie kasuje samego Tag-a — inne sceny mogą
Idempotent: brak relacji = success. Nie kasuje samego Tag-a, inne sceny mogą
z niego korzystać. Sam tag zostaje w słowniku tagów.
"""
rel = session.execute(
@ -1142,7 +1350,7 @@ def hide_scene(
) -> SceneHideOut:
"""Ukryj scenę (user long-press → „usuń"). Oznacza wszystkie playback_sources
jako dead → scena wypada z list (has_playback=false). Odwracalne w DB (dead_at).
Nie kasujemy wiersza sceny — zachowujemy refs/dedup, tylko znika z UI."""
Nie kasujemy wiersza sceny, zachowujemy refs/dedup, tylko znika z UI."""
from datetime import UTC, datetime
from app.models.playback_source import PlaybackSource
@ -1273,27 +1481,45 @@ def enrich_tags_from_tube(
# Upsert: dla każdego taga utwórz/znajdź Tag, dorzuć SceneTag idempotentnie.
# Używamy PostgreSQL INSERT ... ON CONFLICT DO NOTHING zamiast ORM session.add()
# bo `resolve_tag` robi session.flush() w pętli, emitując pending SceneTag INSERT
# z poprzednich iteracji — gdy 2 concurrent enrich-tags collide na tym samym
# z poprzednich iteracji, gdy 2 concurrent enrich-tags collide na tym samym
# (scene_id, tag_id), drugi flush dostaje UniqueViolation (GOON-H, 4 events
# w 10h mimo wcześniejszego seen_tag_ids fix). ON CONFLICT skip'uje silently.
from sqlalchemy.dialects.postgresql import insert as pg_insert
added = 0
seen_tag_ids: set = set()
from app.normalize.tag_stoplist import is_junk_tag
# Istniejące tagi jednym zapytaniem po slugach. resolve_tag robi 1-4 zapytania na
# tag (Sentry N+1 przy 20-40 tagach ze strony), więc woła się go już tylko dla
# nowych nazw i dla slugów z końcówką litera+"2", które kanonizuje po swojemu.
slugs = {slugify(n) for n in tag_names}
direct = [
s for s in slugs
if s and len(s) <= 120 and not is_junk_tag(s)
and not (len(s) > 1 and s[-1] == "2" and s[-2].isalpha())
]
by_slug = (
{t.slug: t for t in session.execute(select(Tag).where(Tag.slug.in_(direct))).scalars()}
if direct else {}
)
tag_ids: list = []
for name in tag_names:
norm = NormalizedTag(name=name, slug=slugify(name), external_id=None)
tag = resolve_tag(session, norm=norm)
if tag is None or tag.id in seen_tag_ids:
continue
seen_tag_ids.add(tag.id)
stmt = (
slug = slugify(name)
tag = by_slug.get(slug)
if tag is None:
tag = resolve_tag(session, norm=NormalizedTag(name=name, slug=slug, external_id=None))
if tag is not None and tag.id not in tag_ids:
tag_ids.append(tag.id)
added = 0
if tag_ids:
# Jeden INSERT; sortowane id, żeby dwa równoległe wywołania dla tej samej sceny
# zakładały blokady w tej samej kolejności.
result = session.execute(
pg_insert(SceneTag.__table__)
.values(scene_id=scene_id, tag_id=tag.id, source_id=None)
.values([{"scene_id": scene_id, "tag_id": t, "source_id": None} for t in sorted(tag_ids)])
.on_conflict_do_nothing(index_elements=["scene_id", "tag_id"])
.returning(SceneTag.__table__.c.tag_id)
)
result = session.execute(stmt)
# rowcount == 1 gdy faktycznie wstawiony, 0 gdy ON CONFLICT skip
if result.rowcount and result.rowcount > 0:
added += 1
added = len(result.fetchall())
session.commit()
return EnrichTagsOut(scene_id=scene_id, added=added, tube_used=sitetag, tags=tag_names)
@ -1309,11 +1535,11 @@ def enrich_duration_from_tube(
scene_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)],
) -> EnrichDurationOut:
"""Wyciąga duration z dowolnego tube playback_source — wszystkie znane tube'y
"""Wyciąga duration z dowolnego tube playback_source, wszystkie znane tube'y
udostępniają duration na detail page (og:video:duration lub LD-JSON ISO 8601).
Mobile wywołuje to przy otwarciu SceneDetail gdy scene.duration_sec jest null
AND ma tube source. Dla dedupu duration to najsilniejszy single signal — bez
AND ma tube source. Dla dedupu duration to najsilniejszy single signal, bez
niego sceny z weak title-only score są capowane na 0.85 (review queue).
Idempotent: zwraca aktualne duration_sec jeśli już ustawione.
@ -1379,7 +1605,7 @@ def enrich_studio_from_tube(
"""Wyciąga studio (DVD/series) z pornhat scene page'a.
Pornhat ma `class="info-video js-ajax-dvd" data-setup='{"title": "Adult Time", ...}'`
dla studio. Inne tube'y obsługiwane będą gdy znajdziemy ich pattern — na razie
dla studio. Inne tube'y obsługiwane będą gdy znajdziemy ich pattern, na razie
tylko pornhat (najczystsze studio metadata wśród free tubes).
"""
import json as _json
@ -1476,7 +1702,7 @@ def enrich_thumbnail_from_tube(
Mobile auto-wywoła to przy otwarciu SceneDetail bez thumb (jak duration).
`force=true` (przycisk "Refresh thumbnail" na SceneDetail, zgłoszenie d3376a71):
NADPISUJE istniejącą miniaturę świeżą ze strony tube'a — dla zepsutych/stałych
NADPISUJE istniejącą miniaturę świeżą ze strony tube'a, dla zepsutych/stałych
(rotting sxyprn/trafficdeposit, błędna grafika)."""
from app.extractors._fetch import browser_get
from app.extractors._models import TubePageError
@ -1497,7 +1723,7 @@ def enrich_thumbnail_from_tube(
sources_with_thumb = [s for s in sources if s.thumbnail_url]
if sources_with_thumb and not force:
# już mamy — idempotent return (force=true pomija, żeby odświeżyć).
# już mamy, idempotent return (force=true pomija, żeby odświeżyć).
return EnrichThumbOut(
scene_id=scene_id,
thumbnail_url=sources_with_thumb[0].thumbnail_url,

View file

@ -66,8 +66,17 @@ class SceneOut(BaseModel):
tags: list[TagOut] = []
external_refs: list[ExternalRefOut] = []
playback_sources: list[PlaybackSourceOut] = []
# Wertykał sceny ('jav' / 'animation') albo None dla głównego katalogu. Liczony
# z originów źródeł, patrz `app/verticals.py`. Mobile MUSI go przekazywać dalej przy
# nawigacji do tagu/studia/performera: bez `?vertical=` backend wyklucza wszystkie
# wertykały, więc tag z anime pokazywał sceny spoza anime, a studio animacyjne
# wyglądało na martwe (zgłoszenia 195ae42e / e9de414c, 2026-08-14).
vertical: str | None = None
# Najwyższa ZNANA jakość źródeł (720/1080/2160), NULL gdy żadne jej nie podało.
# Kafelek pokazuje ją jako badge; patrz migracja 0029_scenes_max_quality.
max_quality_p: int | None = None
# Kiedy scena trafiła do bazy (ingest). Używane przez mobile do oznaczenia
# "NEW" na karcie scen w PerformerScenesScreen / StudioScenesScreen — gdy
# "NEW" na karcie scen w PerformerScenesScreen / StudioScenesScreen, gdy
# `created_at > last_seen_at` (favorite) → badge.
created_at: datetime | None = None
# True = scena z masowego backfillu katalogu (deep-crawl). Mobile NIE pokazuje na niej
@ -87,7 +96,7 @@ class SceneListOut(BaseModel):
page: int
per_page: int
# has_more: czy istnieje kolejna strona. Liczone z fetcha per_page+1 (≈darmowe),
# NIE z `total` — bo dla filtrowanych list `total` jest bounded ("1000+") żeby
# NIE z `total`, bo dla filtrowanych list `total` jest bounded ("1000+") żeby
# uniknąć ~5s exhaustive count. Mobile paginuje po has_more, nie po total.
has_more: bool = False
# total_capped: True gdy `total` to bounded cap (są >total wyników). UI: "{total}+".

View file

@ -1,6 +1,6 @@
"""Publiczna, crawlowalna powierzchnia SEO — programmatic entity pages.
"""Publiczna, crawlowalna powierzchnia SEO, programmatic entity pages.
Jedyny publiczny router HTML poza /static i healthchecks — NIE wymaga api key,
Jedyny publiczny router HTML poza /static i healthchecks, NIE wymaga api key,
bo Googlebot/użytkownik musi dotrzeć bez tokenu. Cel: łapać nawigacyjny long-tail
(nazwy performerów / studiów / tytuły scen), którego mainstream-SEO nie indeksuje,
a który realnie generuje organiczny ruch → pobranie apki.
@ -8,14 +8,14 @@ a który realnie generuje organiczny ruch → pobranie apki.
Zasady (świadome, nie przypadkowe):
* **LINK-OUT only.** Strony renderują metadane i odsyłają do źródeł (`page_url`).
NIE eksponujemy `stream_url`/`embed_url` — to zostaje value-add apki i trzyma
NIE eksponujemy `stream_url`/`embed_url`, to zostaje value-add apki i trzyma
legalny profil "agregatora/wyszukiwarki" (dowozimy ruch tubom, nie re-streamujemy).
* **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+.
Dodatkowo `RTA` meta tag dla filtrów rodzicielskich.
* **Blacklist respektowany** — te same wykluczenia performer/studio/tag co w /scenes.
* **Blacklist respektowany** te same wykluczenia performer/studio/tag co w /scenes.
Nie publikujemy SEO-stron dla zblacklistowanej treści.
* **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,
nie pusty doorway (Google karze masowe cienkie strony).
"""
@ -49,7 +49,7 @@ from app.models.tag import Tag
_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
# Analityka — wstrzykiwana do każdej strony SEO tylko gdy odpowiedni env jest ustawiony.
# Analityka, wstrzykiwana do każdej strony SEO tylko gdy odpowiedni env jest ustawiony.
# Puste = tag się nie renderuje (zero third-party requestów, zachowanie bez zmian).
# Włączenie = ustaw zmienną w .env na VPS + restart, bez zmian w kodzie.
templates.env.globals["gtm_id"] = os.environ.get("GOON_GTM_ID", "")
@ -58,7 +58,7 @@ templates.env.globals["gsc_verify"] = os.environ.get("GOON_GSC_VERIFY", "")
# Limit URL-i na pojedynczy plik sitemap (spec: max 50k). Trzymamy z zapasem.
_SITEMAP_PAGE = 25_000
# Ile scen renderujemy na stronie encji (performer/studio) — pełna lista 1000+ scen
# Ile scen renderujemy na stronie encji (performer/studio), pełna lista 1000+ scen
# to thin/slow; bierzemy najświeższe N, reszta i tak wpada przez sitemap scen.
_SCENES_PER_ENTITY = 120
@ -86,7 +86,7 @@ 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.
Te same reguły co w GET /scenes, żeby SEO nie publikowało tego, co katalog ukrywa.
"""
return [
~exists(
@ -109,7 +109,7 @@ def _not_blacklisted():
def _indexable_scenes():
"""SELECT Scene żywych, nie-zblacklistowanych scen — baza pod listy i sitemap."""
"""SELECT Scene żywych, nie-zblacklistowanych scen, baza pod listy i sitemap."""
stmt = select(Scene).where(_live_playback_exists())
for cond in _not_blacklisted():
stmt = stmt.where(cond)
@ -120,7 +120,7 @@ def _performer_indexable_exists():
"""EXISTS: performer ma ≥1 indeksowalną (żywą, nie-blacklisted) scenę.
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 = (
select(1)
@ -180,7 +180,7 @@ def performer_page(
if performer is None:
raise HTTPException(status_code=404, detail="performer not found")
# Sceny tej osoby — żywe, nie-blacklisted, najświeższe pierwsze.
# Sceny tej osoby, żywe, nie-blacklisted, najświeższe pierwsze.
scenes = (
session.execute(
_indexable_scenes()
@ -283,7 +283,7 @@ def scene_page(
if scene is None:
raise HTTPException(status_code=404, detail="scene not found")
# Źródła — żywe, deduplikowane po origin (pokazujemy 1 link per tube).
# Źródła, żywe, deduplikowane po origin (pokazujemy 1 link per tube).
sources_raw = (
session.execute(
select(PlaybackSource)
@ -354,7 +354,7 @@ def landing(
request: Request,
session: Annotated[Session, Depends(get_session)],
) -> HTMLResponse:
"""Strona główna — crawl-entry. Najświeższe indeksowalne sceny + CTA."""
"""Strona główna, crawl-entry. Najświeższe indeksowalne sceny + CTA."""
scenes = (
session.execute(
_indexable_scenes()
@ -386,8 +386,8 @@ def landing(
@router.get("/get", response_class=HTMLResponse)
def get_app(request: Request) -> HTMLResponse:
"""Paid-traffic landing page — odchudzona pod konwersję instalacji APK.
noindex (patrz get.html) — nie ma konkurować w SERP z entity-stronami."""
"""Paid-traffic landing page, odchudzona pod konwersję instalacji APK.
noindex (patrz get.html), nie ma konkurować w SERP z entity-stronami."""
return templates.TemplateResponse(
request,
"seo/get.html",
@ -411,7 +411,7 @@ def page_2257(request: Request) -> HTMLResponse:
def gsc_site_verification(fname: str) -> PlainTextResponse:
"""Plik weryfikacyjny Google Search Console (metoda 'Plik HTML').
Token z env GOON_GSC_FILE (np. 'google67b49088b5416adc' — bez '.html'). Google
Token z env GOON_GSC_FILE (np. 'google67b49088b5416adc', bez '.html'). Google
pobiera /<token>.html i oczekuje body 'google-site-verification: <token>.html'.
Każda inna nazwa .html → 404. Nie koliduje z innymi trasami (żadna inna nie jest
jednosegmentowym *.html w rootcie).
@ -440,7 +440,7 @@ def _count(session: Session, stmt) -> int:
@router.get("/sitemap.xml")
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."""
n_perf = _count(
session,
select(Performer.id).where(_performer_indexable_exists()),

View file

@ -1,12 +1,12 @@
"""GET /sources — lista tube źródeł dla feature "Sites" (mobile top-level tab).
"""GET /sources, lista tube źródeł dla feature "Sites" (mobile top-level tab).
Bug-report 2026-05-24 (ea6f05f9, Scenes screen): user chce wybrać "pages"
obok Scenes i Movies — widzieć liście tube'ów i wchodzić w nie żeby zobaczyć
obok Scenes i Movies, widzieć liście tube'ów i wchodzić w nie żeby zobaczyć
najnowsze sceny z konkretnego źródła.
Endpoint enumeruje distinct `playback_sources.origin` z ŻYWYCH playback_sources
(`dead_at IS NULL`), tylko origins zaczynające się od 'tube:' (kanoniczne źródła
typu `canonical:tpdb_trailer` są pomijane — to nie są "scrapowane strony" w sensie
typu `canonical:tpdb_trailer` są pomijane, to nie są "scrapowane strony" w sensie
intencji feature'a).
Sortowanie: scene_count DESC (najbardziej "wypełnione" tubey na górze).
@ -14,8 +14,11 @@ Sortowanie: scene_count DESC (najbardziej "wypełnione" tubey na górze).
from __future__ import annotations
import logging
import os
import re
import time
from datetime import datetime
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends
@ -42,7 +45,7 @@ class SourceRating(BaseModel):
richness: int
"""0-5: bogactwo metadanych (miniaturka/tagi/desc/aktorzy/studio/długość)."""
health: int | None
"""0-5: czy realnie gra (telemetria odtwarzania) — 0=offline. None gdy brak danych."""
"""0-5: czy realnie gra (telemetria odtwarzania), 0=offline. None gdy brak danych."""
health_basis: str | None = None
"""'telemetry' (realne pingi z apki) albo 'proxy' (oszacowanie z typu resolve)."""
components: dict | None = None
@ -51,30 +54,48 @@ class SourceRating(BaseModel):
class SourceOut(BaseModel):
origin: str
"""Raw origin string z DB — np. 'tube:hqpornercom'. Używany jako parametr
"""Raw origin string z DB, np. 'tube:hqpornercom'. Używany jako parametr
`origin=` filtra w GET /scenes (substring match)."""
sitetag: str
"""Origin bez prefiksu 'tube:' — np. 'hqpornercom'. Stabilne ID tube'a (zgodne
"""Origin bez prefiksu 'tube:', np. 'hqpornercom'. Stabilne ID tube'a (zgodne
z `BaseDirectTubeScraper.sitetag`)."""
display_name: str
"""Czytelna nazwa do UI — np. 'hqporner.com'. Wyprowadzona z sitetag przez
"""Czytelna nazwa do UI, np. 'hqporner.com'. Wyprowadzona z sitetag przez
`_sitetag_to_display`. Tylko presentation; logikę trzymamy na sitetag/origin."""
scene_count: int
"""Liczba ŻYWYCH playback_sources (dead_at IS NULL) per origin. Approx scenes
coverage — scena może mieć wiele sources tego samego origin (różne page_url),
coverage, scena może mieć wiele sources tego samego origin (różne page_url),
więc trochę zawyża rzeczywistą scene-distinct count, ale dla orientacji OK."""
last_scraped_at: datetime | None
"""MAX(last_seen_at) — najświeższy scrape dla tego origin. Pozwala mobile pokazać
"""MAX(last_seen_at), najświeższy scrape dla tego origin. Pozwala mobile pokazać
'scrapowane Xh temu' i sortować świeżość."""
rating: SourceRating | None = None
"""Ocena 0-5★ (freshness/richness/health) z source_stats — None gdy jeszcze
"""Ocena 0-5★ (freshness/richness/health) z source_stats, None gdy jeszcze
nie policzona (job source-stats leci co kilka h)."""
icon_url: str | None = None
"""Logo strony (favicon 64x64 PNG) z app/static/site-icons, pobierane przez
scripts/site_icons.py (job raz na dobe). None, gdy jeszcze nie ma pliku. Adres
absolutny przez BACKEND_PUBLIC_URL, inaczej wzgledny '/static/...' (apka dokleja
baseUrl)."""
_ICON_DIR = Path(__file__).resolve().parent.parent / "static" / "site-icons"
_SOURCES_TTL = 300.0
_SOURCES_CACHE: dict = {"at": 0.0, "rows": None}
def _icon_url(sitetag: str) -> str | None:
if not (_ICON_DIR / f"{sitetag}.png").is_file():
return None
base = os.environ.get("BACKEND_PUBLIC_URL", "").rstrip("/")
return f"{base}/static/site-icons/{sitetag}.png"
class SourceListOut(BaseModel):
items: list[SourceOut]
@ -119,18 +140,25 @@ def list_sources(
) -> SourceListOut:
"""Zwraca listę tube źródeł z ŻYWYMI playback_sources.
Filter: `origin LIKE 'tube:%'` (drop canonical:* — TPDB trailery to inna semantyka).
Filter: `origin LIKE 'tube:%'` (drop canonical:* TPDB trailery to inna semantyka).
"""
rows = session.execute(
select(
PlaybackSource.origin,
func.count(PlaybackSource.id).label("scene_count"),
func.max(PlaybackSource.last_seen_at).label("last_scraped_at"),
)
.where(PlaybackSource.dead_at.is_(None))
.where(PlaybackSource.origin.like("tube:%"))
.group_by(PlaybackSource.origin)
).all()
# 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(
PlaybackSource.origin,
func.count(PlaybackSource.id).label("scene_count"),
func.max(PlaybackSource.last_seen_at).label("last_scraped_at"),
)
.where(PlaybackSource.dead_at.is_(None))
.where(PlaybackSource.origin.like("tube:%"))
.group_by(PlaybackSource.origin)
).all()
_SOURCES_CACHE["at"] = now
rows = _SOURCES_CACHE["rows"]
# 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()}
@ -159,6 +187,7 @@ def list_sources(
scene_count=scene_count,
last_scraped_at=last_scraped_at,
rating=rating,
icon_url=_icon_url(sitetag),
)
)

View file

@ -1,8 +1,8 @@
"""Stream proxy — pomost VPS↔phone dla podpisanych URL-i CDN-ów.
"""Stream proxy, pomost VPS↔phone dla podpisanych URL-i CDN-ów.
Wiele hosterów (luluvids/medixiru/cdnvids/bigcdn) bindą podpisany URL do IP klienta
który fetchował embed page. Gdy backend ekstraktuje URL z VPS-a, signature
weryfikuje VPS IP — telefon dostaje 403. Player na phonie kieruje requesty
weryfikuje VPS IP, telefon dostaje 403. Player na phonie kieruje requesty
*przez backend* (tym samym IP co podczas extracji) → CDN sprawdza signature
poprawnie i serwuje content.
@ -31,6 +31,7 @@ import time
from typing import Annotated
from urllib.parse import urljoin, urlparse
import asyncio
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import Response, StreamingResponse
@ -41,8 +42,8 @@ router = APIRouter(prefix="/proxy", tags=["proxy"])
log = logging.getLogger(__name__)
# In-memory bandwidth counter — bytes-out per CDN domain per hour bucket.
# Restart api resetuje counter (akceptowalne — to operational metric, nie billing).
# In-memory bandwidth counter, bytes-out per CDN domain per hour bucket.
# Restart api resetuje counter (akceptowalne, to operational metric, nie billing).
# Critical dla widzenia gdzie VPS bandwidth wycieka przed Hetzner overage.
from collections import defaultdict
from threading import Lock
@ -90,7 +91,7 @@ TOKEN_TTL_SEC = 4 * 60 * 60 # 4h
# URL-level redirect cache: target_url -> (final_resolved_url, expires_ts).
# Mobile ExoPlayer robi range-requesty per seek/preload — każdy hituje proxy z tym
# Mobile ExoPlayer robi range-requesty per seek/preload, każdy hituje proxy z tym
# samym tokenem, proxy GET-uje target_url. Dla `porntrex.com/get_file/...` (a także
# fpoxxx, freshporno) URL jest **single-use**: pierwszy GET → 302 → CDN URL (time-bound),
# drugi GET → 410. Bez cache: drugi range = 410 → ExoPlayer fail → mobile fallback do
@ -147,6 +148,36 @@ HOP_BY_HOP = {
"content-length",
}
# Nagłówki CORS hostera są bezużyteczne dalej i wręcz szkodliwe: mixdrop oddaje
# `Access-Control-Allow-Origin: https://mixdrop.my`, więc odbiornik Chromecasta (który
# ma zupełnie inny origin) odrzuciłby manifest. Wycinamy je i podstawiamy własne.
_UPSTREAM_CORS_HEADERS = {
"access-control-allow-origin",
"access-control-allow-methods",
"access-control-allow-headers",
"access-control-allow-credentials",
"access-control-expose-headers",
}
def _with_cast_cors(headers: dict[str, str]) -> dict[str, str]:
"""Pozwól odtwarzać ten strumień z dowolnego origin.
Po co: Chromecast pobiera plik SAM, ze swojego adresu, a przy HLS robi to przez
Media Source Extensions, które wymagają zgody CORS. Bez tego telewizor dostaje
manifest i go wyrzuca. Progresywne MP4 leci przez zwykły element `video` i o CORS
nie pyta, ale ustawiamy jednakowo, żeby nie zależeć od tego, co akurat gra.
Bezpieczne, bo dostęp do treści i tak reguluje podpisany token w ścieżce, a nie
origin pytającego.
"""
out = {k: v for k, v in headers.items() if k.lower() not in _UPSTREAM_CORS_HEADERS}
out["Access-Control-Allow-Origin"] = "*"
out["Access-Control-Allow-Methods"] = "GET, HEAD, OPTIONS"
out["Access-Control-Allow-Headers"] = "Range"
out["Access-Control-Expose-Headers"] = "Content-Length, Content-Range, Accept-Ranges"
return out
def _secret() -> bytes:
s = os.environ.get("STREAM_PROXY_SECRET") or os.environ.get("API_KEYS", "")
@ -170,10 +201,10 @@ def make_token(
`refresh`: URL embed page do refetch gdy `url` zwraca 4xx. Proxy odbierze
fresh stream URL z embed (np. mixdrop MDCore.wurl) gdy oryginalny token expired.
`refresh_hoster`: hoster name dla refresh logic (mixdrop / etc.) — proxy
`refresh_hoster`: hoster name dla refresh logic (mixdrop / etc.), proxy
dispatch do dedicated re-extract logic.
`impersonate`: użyć curl_cffi chrome120 zamiast httpx (dla hosterów z JA3 bot
detection — mxcontent, cloudflare-protected).
detection, mxcontent, cloudflare-protected).
`stable_bucket_sec`: kwantyzuje czas bazowy expiry do okna N sekund, więc token
(→ proxied URL) jest IDENTYCZNY dla wszystkich requestów w oknie. Bez tego `e`
zawiera bieżący timestamp → URL inny przy każdym fetchu listy → expo-image cache
@ -190,7 +221,7 @@ def make_token(
if impersonate:
payload["i"] = 1
if producer:
# Hoster oddający TREŚĆ manifestu (nie URL) — `/proxy/hls` zawoła producenta
# Hoster oddający TREŚĆ manifestu (nie URL), `/proxy/hls` zawoła producenta
# zamiast GET-a. Patrz app/extractors/hosters/loadvid.py.
payload["mp"] = producer
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
@ -220,7 +251,7 @@ def parse_token(token: str) -> dict:
def _ascii_safe_url(url: str) -> str:
"""Encode non-ASCII chars w URL path/query, zachowując reserved chars dla URI.
httpx wymaga ASCII headers — Referer z polskim/cyrillic/unicode (np. hqporner
httpx wymaga ASCII headers, Referer z polskim/cyrillic/unicode (np. hqporner
`Honies_№2.html`) wcześniej throw'ował UnicodeEncodeError (GOON-A). `quote`
z `safe=":/?#[]@!$&'()*+,;=%"` zostawia URI structure nietkniętą, tylko
enkoduje znaki spoza ASCII."""
@ -255,8 +286,8 @@ def _rewrite_m3u8(content: str, base_url: str, referer: str | None) -> str:
"""Rewrite m3u8 manifest tak, że wszystkie sub-resourcey idą przez proxy.
HLS manifest ma:
- linie URI (segmenty .ts / sub-playlisty .m3u8) — relatywne lub absolute
- tagi typu `#EXT-X-KEY:METHOD=AES-128,URI="key.bin"` — też potrzebują rewrite
- linie URI (segmenty .ts / sub-playlisty .m3u8), relatywne lub absolute
- tagi typu `#EXT-X-KEY:METHOD=AES-128,URI="key.bin"`, też potrzebują rewrite
Każdy URL → token + /proxy/{token}/<basename>.<ext>.
"""
out: list[str] = []
@ -284,7 +315,7 @@ def _rewrite_m3u8(content: str, base_url: str, referer: str | None) -> str:
def _absolutize_m3u8(content: str, base_url: str) -> str:
"""Rewrite m3u8 tak, że child-URL-e (variant/segmenty/key) są ABSOLUTNE na CDN —
"""Rewrite m3u8 tak, że child-URL-e (variant/segmenty/key) są ABSOLUTNE na CDN,
NIE przez proxy. Dla direct-HLS passthrough (`/proxy/hls/...`): telefon ciągnie je
bezpośrednio z CDN-a (phone IP, time-bound token). Relatywne → absolute (urljoin),
absolute → bez zmian. Odwrotność `_rewrite_m3u8` (który wszystko proxifikuje)."""
@ -310,21 +341,34 @@ def sign_url(
referer: str | None = Query(default=None),
) -> dict:
"""Pomocniczy endpoint dla mobile do uzyskania świeżego tokena (np. po expiry).
Normalnie /resolve zwraca już proxy URL — to fallback."""
Normalnie /resolve zwraca już proxy URL, to fallback."""
return {"token": make_token(url, referer), "expires_in": TOKEN_TTL_SEC}
@functools.lru_cache(maxsize=1)
def _fallback_proxy_url() -> str | None:
"""Residential proxy, uzywany TYLKO gdy CDN odrzucil nasz adres IP.
NIE wstawiaj niczego miedzy dekorator trasy a `proxy_image`: przy pierwszym podejsciu
ta funkcja wladowala sie wlasnie tam i `@router.get` udekorowal JA, wiec /proxy/img/
oddawalo URL proxy razem z loginem i haslem jako JSON.
"""
from app.config import get_settings
return getattr(get_settings(), "brightdata_proxy_url", None) or None
@router.get("/img/{token}/{_basename:path}")
async def proxy_image(
token: str,
_basename: str,
request: Request,
) -> Response:
"""Image proxy — używany dla thumbnaili z CDN-ów wymagających Referera
"""Image proxy, używany dla thumbnaili z CDN-ów wymagających Referera
(hqporner i inne porn-app sourcy). Mobile expo-image nie wysyła Referera
domyślnie, CDN zwraca 403. Backend dodaje Referer i streamuje obrazek.
Cache-Control: public,max-age=86400 — thumby są stabilne, klient może cachować."""
Cache-Control: public,max-age=86400, thumby są stabilne, klient może cachować."""
payload = parse_token(token)
target = payload["u"]
referer = payload["r"] or None
@ -334,18 +378,59 @@ async def proxy_image(
try:
r = await client.get(target, headers=headers)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as e:
# CDN connect/timeout — transient (np. Cloudflare 523 origin unreachable
# CDN connect/timeout, transient (np. Cloudflare 523 origin unreachable
# gdy upstream host jest off). Log INFO + 503, mobile renderuje placeholder.
# Bez tego Sentry dostawał setki ERROR-ów (GOON-D/6) z każdym broken
# tube'em — spam-szumiło real-issues.
# tube'em, spam-szumiło real-issues.
log.info("img proxy connect/timeout for %s: %s", target, e)
return Response(content=b"", status_code=503, media_type="image/jpeg")
except Exception as e:
log.warning("img proxy fetch failed for %s: %s", target, e)
raise HTTPException(status_code=502, detail=f"img fetch failed: {e}") from e
if r.status_code in (401, 403):
# Referer moze byc z INNEGO serwisu niz host obrazka. Zdarza sie, gdy tube
# zaciaga miniaturki cudzego CDN-a: fullporner zapisuje pliki z
# fastporndelivery.hqporner.com, a my dokladalismy Referer fullporner.com,
# ktorego CDN nie akceptuje. Pomiar 2026-08-20 na tym samym pliku:
# Referer fullporner.com 403 (153 B), brak Referera 403, Referer hqporner.com
# 200 (30 kB), Referer rowny wlasnemu hostowi obrazka 200. Skutkiem byly
# puste kafelki podpisane FULLPORNER na liscie hqporner (zgloszenie 1df50de3).
# Ponawiamy raz z wlasnym originem obrazka, bo to dziala dla calej tej klasy
# przypadkow i nie zmienia zachowania tam, gdzie pierwsza proba sie udaje.
from urllib.parse import urlsplit
parts = urlsplit(str(r.url) or target)
own = f"{parts.scheme}://{parts.netloc}/"
if own != (referer or ""):
try:
r2 = await client.get(target, headers=_build_headers(own))
if r2.status_code < 400:
r = r2
except Exception as e:
log.info("img proxy retry z wlasnym originem padl %s: %s", target, e)
if r.status_code in (401, 403, 429):
# Ostatnia deska ratunku: CDN blokuje NASZ adres IP, nie zapytanie. Miniaturki
# fullvideosporn (tn.sextu.com) oddaja z serwerowni 429 przy kazdej probie
# (3/3, tez po przerwie), a przez residential proxy 200 i 26 kB. Stad puste
# kafelki mimo poprawnych adresow w bazie (zgloszenie f4995691). Obrazek ma
# kilkadziesiat kB i klient cachuje go na dobe, wiec to nie jest ten sam
# przypadek co proxowanie wideo. Sciagamy tylko wtedy, gdy host nas odrzucil.
proxy_url = _fallback_proxy_url()
if proxy_url:
try:
async with httpx.AsyncClient(
timeout=timeout, follow_redirects=True, proxy=proxy_url
) as pclient:
r3 = await pclient.get(target, headers=headers)
if r3.status_code < 400:
r = r3
else:
log.info("img proxy: proxy tez oddal %d dla %s", r3.status_code, target)
except Exception as e:
log.info("img proxy: proba przez proxy padla %s: %s", target, e)
if r.status_code >= 400:
# Upstream 4xx/5xx dla thumba — degraded zamiast raise (placeholder w mobile).
# GOON-5 (Cloudflare 523) i GOON-D — bezsensowny noise w Sentry, lepiej
# Upstream 4xx/5xx dla thumba, degraded zamiast raise (placeholder w mobile).
# GOON-5 (Cloudflare 523) i GOON-D, bezsensowny noise w Sentry, lepiej
# info log + 502 pass-through bez exception.
log.info("img proxy upstream %d for %s", r.status_code, target)
return Response(
@ -362,7 +447,7 @@ async def proxy_image(
# sxyprn on-demand thumbnail resolve (bug 2026-06-10). trafficdeposit poster token
# żyje ~1h (bucket godzinowy), więc URL-i NIE da się przechować — resolvujemy bieżący
# żyje ~1h (bucket godzinowy), więc URL-i NIE da się przechować, resolvujemy bieżący
# og:image ze strony /post/<id> przy serwowaniu. Cache resolved poster URL ~40min
# (< 1h TTL). Klient dostaje STABILNY /proxy/sxyprn-thumb/<id> → cache'uje bajty na
# stałe (treść postera niezmienna), więc fetchujemy stronę post ~raz per post.
@ -376,10 +461,10 @@ _SXYPRN_PID_RE = re.compile(r"^[0-9a-f]{6,40}$")
# youporn on-demand thumbnail (bug-report bebf8c92 2026-07-25 "miniaturki z youporn
# nie działają"). youporn przeszedł dla części katalogu na imgproxy z PODPISANYM URL-em
# (`.../original_<id>.mov/plain/rs:fit:1280:720/vts:N?hash=...&validto=<ts>`) — taki URL
# (`.../original_<id>.mov/plain/rs:fit:1280:720/vts:N?hash=...&validto=<ts>`), taki URL
# wygasa, więc zapisany w bazie po czasie zwraca 410 (audit 2026-07-26: 8666 z 62930
# źródeł youporn). Reszta katalogu ma stare statyczne `.../original/N.jpg`, które działają
# bezterminowo — tych NIE ruszamy (żaden dodatkowy fetch). Tu, jak w sxyprn, resolvujemy
# bezterminowo, tych NIE ruszamy (żaden dodatkowy fetch). Tu, jak w sxyprn, resolvujemy
# świeży URL przy serwowaniu; klient dostaje stabilny `/proxy/youporn-thumb/<video_id>`.
_YOUPORN_POSTER_CACHE: dict[str, tuple[str, float]] = {}
_YOUPORN_POSTER_TTL = 1800
@ -417,7 +502,7 @@ async def youporn_thumb(video_id: str) -> Response:
)
if not m:
return Response(content=b"", status_code=404, media_type="image/jpeg")
# JSON-LD escapuje `\/`, a og:image ma encje HTML (`&amp;`) — bez unescape
# JSON-LD escapuje `\/`, a og:image ma encje HTML (`&amp;`), bez unescape
# podpis w query jest zepsuty i CDN oddaje 403.
poster = _htmllib.unescape(m.group(1).strip().replace("\\/", "/"))
if poster.startswith("//"):
@ -492,7 +577,7 @@ async def sxyprn_thumb(post_id: str) -> Response:
async def _refetch_mixdrop_url(session: "AsyncSession", embed_url: str) -> str | None:
"""Re-fetch mixdrop embed, decode P.A.C.K.E.R., extract fresh MDCore.wurl.
Cookies persist w session, użytkowane potem do mp4 GET (same-session bind).
UA + Accept wymagane — bez tego mixdrop zwraca minimalny body (bez packera).
UA + Accept wymagane, bez tego mixdrop zwraca minimalny body (bez packera).
"""
import re
from yt_dlp.utils import decode_packed_codes
@ -563,9 +648,9 @@ async def _curl_cffi_stream(
await session.close()
return _upstream_error_response(upstream.status_code, dict(upstream.headers), target)
out_headers = {
k: v for k, v in upstream.headers.items() if k.lower() not in HOP_BY_HOP
}
out_headers = _with_cast_cors(
{k: v for k, v in upstream.headers.items() if k.lower() not in HOP_BY_HOP}
)
async def streamer():
bytes_out = 0
@ -610,6 +695,28 @@ async def _produce_manifest(producer: str, embed_url: str) -> str | None:
return None
_CHALLENGE_COOKIE_RE = re.compile(r"document\.cookie\s*=\s*'([^=]+)=([^;']+)")
def _challenge_cookie(r: httpx.Response) -> str | None:
"""Ciasteczko z odpowiedzi-wyzwania sieci TXXX (vjav i rodzina), albo None.
Serwis dlawi ruch z serwerowni: zamiast tresci oddaje 429 i mikroskopijna strone,
ktora w JS ustawia jedno ciasteczko i przeladowuje sie. Wartosc stoi WPROST w tej
odpowiedzi, wiec wystarczy ja odczytac i powtorzyc zapytanie, bez wykonywania JS.
Ekstraktor vjav robil to od 2026-08-21 przy `videofile.php`, ale adres manifestu
oddawany odtwarzaczowi tej sztuczki nie dostawal. Skutek: `try_extract` konczyl sie
sukcesem, wiec zrodlo wygladalo na zywe, a `/proxy/hls` przy kazdym odtworzeniu
dostawal 429 i oddawal blad. Stad 587 bledow i ZERO udanych odtworzen na vjav w
ciagu 14 dni, przy 58 779 zrodel.
"""
if r.status_code != 429 or len(r.content) > 4096:
return None
m = _CHALLENGE_COOKIE_RE.search(r.text)
return f"{m.group(1)}={m.group(2)}" if m else None
@router.get("/hls/{token}/{_basename:path}")
async def proxy_hls_manifest(token: str, _basename: str) -> Response:
"""Direct-HLS manifest passthrough dla time-bound (mobile_direct_ok) m3u8 hosterów.
@ -622,7 +729,7 @@ async def proxy_hls_manifest(token: str, _basename: str) -> Response:
Tu serwujemy SAM manifest pod `play.m3u8` (ExoPlayer → HlsMediaSource), a child-URL-e
(variant/segmenty) zostają ABSOLUTNE na CDN → telefon ciągnie je DIRECT (phone IP,
token time-bound działa cross-IP; Referer propaguje Media3 na child-requesty —
token time-bound działa cross-IP; Referer propaguje Media3 na child-requesty,
zweryfikowane cross-IP 2026-06-11). Przez VPS leci tylko ~1KB manifestu, nie segmenty.
Tylko dla mobile_direct_ok (gdzie segmenty z definicji działają cross-IP); IP-bound
hostery dalej idą pełnym `/proxy/`."""
@ -647,16 +754,32 @@ async def proxy_hls_manifest(token: str, _basename: str) -> Response:
async with httpx.AsyncClient(follow_redirects=True, timeout=20.0) as client:
try:
r = await client.get(target, headers=headers)
if (ck := _challenge_cookie(r)) is not None:
r = await client.get(target, headers=dict(headers, Cookie=ck))
except httpx.HTTPError as e:
log.info("proxy-hls fetch failed %s: %s", target, e)
raise HTTPException(status_code=502, detail="manifest fetch failed") from e
if r.status_code >= 400:
return _upstream_error_response(r.status_code, dict(r.headers), target)
# Hoster potrafi oddac 200 z trescia, ktora manifestem NIE jest (strona bledu albo
# reklama zamiast playlisty). Bez tej kontroli przepuszczalismy takie cos dalej jako
# `application/vnd.apple.mpegurl` ze statusem 200, a odtwarzacz konczyl komunikatem
# "Input does not start with the #EXTM3U header" (perverzija, 203 zdarzenia/14 dni).
# Gorsze od samego bledu bylo to, ze przy statusie 200 apka uznawala odpowiedz za
# dobra i nie probowala odswiezyc zrodla. Oddajemy 502, zeby wlaczyla sie normalna
# sciezka ponowienia.
if not r.text.lstrip(" \t\r\n").startswith("#EXTM3U"):
log.info(
"proxy-hls: odpowiedz 200 nie jest manifestem (%dB) dla %s", len(r.content), target
)
raise HTTPException(status_code=502, detail="upstream returned non-manifest body")
rewritten = _absolutize_m3u8(r.text, base_url=str(r.url))
return Response(
content=rewritten,
media_type="application/vnd.apple.mpegurl",
headers={"Cache-Control": "no-store"},
headers=_with_cast_cors({"Cache-Control": "no-store"}),
)
@ -674,7 +797,7 @@ async def proxy_stream(
refetch_hoster = payload.get("rh")
# Jeśli ten target był już wcześniej follow-redirect-ed, użyj cached final URL.
# Powód: porntrex `get_file/` 410 po reuse — patrz `_REDIRECT_CACHE` docstring.
# Powód: porntrex `get_file/` 410 po reuse, patrz `_REDIRECT_CACHE` docstring.
cached_target = _redirect_cache_get(original_target)
target = cached_target or original_target
@ -684,10 +807,10 @@ async def proxy_stream(
if range_h:
headers["Range"] = range_h
method = "GET" # ExoPlayer głównie GET; HEAD nie potrzebny — proxy zwraca pełne odpowiedzi
method = "GET" # ExoPlayer głównie GET; HEAD nie potrzebny, proxy zwraca pełne odpowiedzi
# Hostery które wymagają Chrome JA3 fingerprint (mxcontent / cloudflare-protected
# CDNs) — od razu używamy curl_cffi zamiast httpx żeby uniknąć 403→retry round-trip.
# CDNs), od razu używamy curl_cffi zamiast httpx żeby uniknąć 403→retry round-trip.
# Token `i=1` flag ustawiana przez extractor dla tych hostów (mixdrop.py).
if use_impersonate:
return await _curl_cffi_stream(
@ -707,19 +830,41 @@ async def proxy_stream(
client = httpx.AsyncClient(timeout=timeout, follow_redirects=True)
try:
# Sprobój streaming send PIERWSZY — sprawdź content-type po headers,
# Sprobój streaming send PIERWSZY, sprawdź content-type po headers,
# potem decyzja: rewrite manifest vs stream binary.
upstream = await client.send(
client.build_request(method, target, headers=headers),
stream=True,
follow_redirects=True,
)
# Przejściowy błąd SERWERA upstreamu → jedno ponowienie, zanim oddamy błąd
# do playera. Te CDN-y stoją na wielu węzłach (galaxyporn: 203.188.166.163,
# 185.237.106.182, 185.237.107.133), więc druga próba zwykle trafia w zdrowy.
#
# Telemetria 30 dni: 502/503 to JEDYNA klasa błędów playbacku przy kilku
# niezależnych źródłach naraz (porndoe 65, 8kporner 37, galaxyporn 22), przy
# 1310 udanych odtworzeniach samego galaxyporn, czyli 1,9%. Zgłoszenie
# 7f2234eb („samo wideo działa a aplikacja zgłasza problem z hostem") to
# dokładnie ten przypadek: dla usera wygląda na zepsute źródło, a to mignięcie.
#
# Ponawiamy TYLKO 502/503/504 i tylko dla GET/HEAD (idempotentne, segment HLS
# to zwykły GET). 4xx zostawiamy: 403/410 to wygasły token, a nie chwilowa
# awaria, i mają własną ścieżkę z invalidacją cache niżej.
if upstream.status_code in (502, 503, 504) and method.upper() in ("GET", "HEAD"):
await upstream.aclose()
await asyncio.sleep(0.25)
log.info("proxy: upstream %d, ponawiam %s", upstream.status_code, target[:70])
upstream = await client.send(
client.build_request(method, target, headers=headers),
stream=True,
follow_redirects=True,
)
if upstream.status_code >= 400:
status = upstream.status_code
ups_headers = dict(upstream.headers)
await upstream.aclose()
await client.aclose()
# Cached final URL zwrócił error (np. CDN signed-URL expired, 403/410) —
# Cached final URL zwrócił error (np. CDN signed-URL expired, 403/410),
# invaliduj cache i daj mobile retry przez fresh /resolve. Bez tego stale
# cache trzymałby martwy CDN URL przez 30 min (TTL).
if cached_target is not None and status in (401, 403, 404, 410):
@ -728,7 +873,7 @@ async def proxy_stream(
# Pierwszy successful pass dla single-use targets (np. porntrex get_file):
# cache resolved final URL (po follow_redirects). Następne range-requesty
# pójdą direct w CDN URL — get_file nie dostaje drugiego hita.
# pójdą direct w CDN URL, get_file nie dostaje drugiego hita.
if cached_target is None:
final_url = str(upstream.url)
if final_url != original_target:
@ -741,7 +886,7 @@ async def proxy_stream(
or "application/x-mpegurl" in ct
)
if is_m3u8:
# Manifest content — buffer fully, rewrite, return as m3u8.
# Manifest content, buffer fully, rewrite, return as m3u8.
body = await upstream.aread()
await upstream.aclose()
await client.aclose()
@ -757,9 +902,9 @@ async def proxy_stream(
headers={"Cache-Control": "no-store"},
)
out_headers = {
k: v for k, v in upstream.headers.items() if k.lower() not in HOP_BY_HOP
}
out_headers = _with_cast_cors(
{k: v for k, v in upstream.headers.items() if k.lower() not in HOP_BY_HOP}
)
async def streamer():
bytes_out = 0
@ -769,7 +914,7 @@ async def proxy_stream(
yield chunk
except httpx.TransportError as e:
# Upstream CDN zerwał połączenie w trakcie streamu (peer closed before
# full body — RemoteProtocolError, ReadError, timeout). Klient dostaje
# full body, RemoteProtocolError, ReadError, timeout). Klient dostaje
# uciętą odpowiedź i ponawia Range-requestem; to NIE jest bug aplikacji.
# Bez tego wyjątek leci z generatora PO zwróceniu StreamingResponse, omija
# outer try/except i ląduje w Sentry jako unhandled (GOON-Y 2026-06-11).
@ -792,7 +937,7 @@ async def proxy_stream(
await client.aclose()
raise
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as e:
# CDN connect failure / timeout — transient, log na INFO (nie ERROR do Sentry).
# CDN connect failure / timeout, transient, log na INFO (nie ERROR do Sentry).
# Zwracamy 503 zamiast 502 + Retry-After, mobile może retry-ować bez panic.
await client.aclose()
log.info("proxy connect/timeout for %s: %s", target, e)
@ -818,10 +963,10 @@ def _upstream_error_response(
Rationale per status:
- **429 Too Many Requests**: CDN rate-limit (np. fpo.xxx gdy proxy hammeruje
get_file/). Pass-through 429 + Retry-After żeby mobile zrobiło backoff.
Log INFO (nie ERROR) — to expected behavior CDN-a, nie nasz bug.
Log INFO (nie ERROR), to expected behavior CDN-a, nie nasz bug.
- **404/410**: video deleted/expired token. Pass-through żeby player wiedział.
- **5xx upstream**: pochodzi z CDN-a, nie z naszego kodu. Log INFO.
- **inne 4xx**: 502 (i Sentry warn) — może być nasza wina (bad referer itp.).
- **inne 4xx**: 502 (i Sentry warn), może być nasza wina (bad referer itp.).
"""
retry_after = upstream_headers.get("retry-after") or upstream_headers.get("Retry-After")
if status == 429:
@ -845,7 +990,7 @@ def _upstream_error_response(
media_type="text/plain",
)
if 500 <= status < 600:
# CDN-side error (np. Cloudflare 523 — origin unreachable). Pass-through
# CDN-side error (np. Cloudflare 523, origin unreachable). Pass-through
# 502 ale log INFO bo to nie nasza wina.
log.info("proxy upstream %d for %s", status, target)
return Response(
@ -854,5 +999,28 @@ def _upstream_error_response(
headers={"Retry-After": "5"},
media_type="text/plain",
)
# 4xx other (403 itp.) — raise żeby Sentry zarejestrował (może bug naszego kodu)
if status in (401, 403):
# WYGASŁY/NIEWAŻNY PODPIS, nie awaria hosta. Przepuszczamy prawdziwy kod zamiast
# mapować na 502, z dwóch powodów:
#
# 1. Diagnostyka. `playback_events.error_detail` bierze się z komunikatu
# ExoPlayera („Response code: 502"), więc dopóki mapowaliśmy wszystko na 502,
# w telemetrii nie dało się odróżnić „CDN padł" od „token wygasł". Zgłoszenie
# 7f2234eb („samo wideo działa a aplikacja zgłasza problem z hostem") to
# dokładnie ta dwuznaczność: dla usera źródło wygląda na zepsute, a wystarczy
# świeży resolve.
# 2. Sentry. Wygaśnięcie podpisanego URL-a jest NORMALNE (te CDN-y dają tokeny
# na minuty), a raise robił z tego wyjątek „może nasz bug" i zaśmiecał alerty.
#
# Cache i tak jest tu invalidowany wyżej (401/403/404/410), więc kolejny resolve
# dostanie świeży URL.
log.info("proxy upstream %d (wygasly podpis?) for %s", status, target)
return Response(
content=f"upstream {status}",
status_code=status,
media_type="text/plain",
headers={"Cache-Control": "no-store"},
)
# Pozostałe 4xx (400/405/451…) nadal jako 502 + wyjątek: tego NIE spodziewamy się
# od poprawnie zbudowanego requestu, więc chcemy o tym wiedzieć w Sentry.
raise HTTPException(status_code=502, detail=f"upstream {status}")

View file

@ -1,4 +1,4 @@
"""GET /tags, /performers, /studios — listy taxonomies do filtrów na mobile.
"""GET /tags, /performers, /studios, listy taxonomies do filtrów na mobile.
Każdy endpoint wspiera:
- q: substring search po name_normalized (trgm fallback ilike)
@ -9,6 +9,7 @@ Zwraca też scene_count żeby UI pokazywał "(123)" przy każdym tagu/performerz
"""
from __future__ import annotations
import logging
import uuid
from typing import Annotated
@ -26,6 +27,9 @@ from app.models.playback_source import PlaybackSource
from app.models.scene import ScenePerformer, SceneTag
from app.models.studio import Studio
from app.models.tag import Tag
from app.verticals import VERTICALS
log = logging.getLogger(__name__)
router = APIRouter(tags=["taxonomies"], dependencies=[Depends(require_api_key)])
@ -102,12 +106,19 @@ def list_tags(
" Filtruje krótkie listy filtrów żeby nie pokazywać tagów-sierot."
),
),
vertical: str | None = Query(
default=None,
description=(
"Wertykał ('jav'/'animation'): licz i pokazuj TYLKO tagi jego scen. "
"Brak → główny feed, bez tagów wertykałowych."
),
),
) -> TagListOut:
if order not in ("popular", "scene_count", "name"):
raise HTTPException(status_code=400, detail="order must be 'popular' or 'name'")
if for_movies:
# Movie tag count — zliczamy tylko Movies z ≥1 live MoviePlaybackSource.
# Movie tag count, zliczamy tylko Movies z ≥1 live MoviePlaybackSource.
# Tag-bez-żadnego-movie zwraca 0 (LEFT OUTER JOIN przez coalesce). Movies są
# małe (~41k) więc live-aggregat OK; scenes path niżej używa denormalizacji.
_movie_live = exists().where(
@ -146,7 +157,55 @@ def list_tags(
]
return TagListOut(items=items, total=total, page=page, per_page=per_page)
# Scenes path — czyta zdenormalizowany Tag.scene_count (refresh w tle przez
if vertical in VERTICALS:
# Wertykał liczymy NA ŻYWO, bez denormalizacji: to małe zbiory (animacja ~9,5 tys.
# scen wobec 3,8 mln całości), więc agregat jest tani, a dokładanie kolumny
# `scene_count_<wertykał>` do `tags` kosztowałoby migrację przy każdym nowym
# wertykale. Zdenormalizowany `Tag.scene_count` opisuje WYŁĄCZNIE główny feed.
v_scenes = (
select(PlaybackSource.scene_id)
.where(
PlaybackSource.dead_at.is_(None),
PlaybackSource.origin.in_(VERTICALS[vertical]),
)
.distinct()
.subquery()
)
v_count = (
select(SceneTag.tag_id, func.count().label("c"))
.join(v_scenes, v_scenes.c.scene_id == SceneTag.scene_id)
.group_by(SceneTag.tag_id)
.subquery()
)
base = (
select(Tag, func.coalesce(v_count.c.c, 0).label("scene_count"))
.outerjoin(v_count, v_count.c.tag_id == Tag.id)
)
if q:
base = base.where(Tag.name.ilike(f"%{q}%"))
# Tag spoza wertykału ma tu 0, więc `only_with_content` sam odsiewa zachodnie
# słownictwo. Dla wertykału trzymamy to WŁĄCZONE zawsze: lista bez tego byłaby
# w 99% tagami, których w tej sekcji nie ma czym wypełnić.
base = base.where(v_count.c.tag_id.is_not(None))
total = session.execute(
select(func.count()).select_from(base.subquery())
).scalar_one()
if order in ("popular", "scene_count"):
ordered = base.order_by(func.coalesce(v_count.c.c, 0).desc(), Tag.name.asc())
else:
ordered = base.order_by(Tag.name.asc())
rows = session.execute(
ordered.offset((page - 1) * per_page).limit(per_page)
).all()
return TagListOut(
items=[
TagCount(id=t.id, name=t.name, slug=t.slug, scene_count=int(c))
for t, c in rows
],
total=total, page=page, per_page=per_page,
)
# Scenes path, czyta zdenormalizowany Tag.scene_count (refresh w tle przez
# _job_refresh_taxonomy_counts). Wcześniej liczył agregat 6.3M scene_tags +
# EXISTS playback per-request (~4.3s ×2). Patrz migracja 0019.
base = select(Tag)
@ -182,10 +241,72 @@ def list_performers(
order: str = Query(default="scene_count", description="scene_count|name"),
page: int = Query(default=1, ge=1),
per_page: int = Query(default=50, ge=1, le=500),
vertical: str | None = Query(
default=None,
description=(
"Wertykał ('jav'): licz i pokazuj TYLKO obsadę jego scen. Brak → główny feed. "
"Dla 'animation' zwraca pusto, bo animacja z definicji nie ma obsady."
),
),
) -> PerformerListOut:
if order not in ("scene_count", "popular", "name"):
raise HTTPException(status_code=400, detail="order must be 'scene_count' or 'name'")
if vertical in VERTICALS:
# Jak przy tagach: wertykał liczymy NA ŻYWO, bo `Performer.scene_count` z założenia
# opisuje wyłącznie główny feed (job taxonomy_counts wyklucza originy wertykałowe).
# Bez tego zakładka JAV pokazywała zachodnią obsadę, a jej własne 1392 nazwiska
# miały licznik 0 i lądowały na końcu listy.
#
# Animacja NIE MA obsady i to nie jest brak danych, tylko wlasciwosc: dla postaci
# rysowanych nie istnieje kanon (TPDB/StashDB ich nie maja), patrz app/verticals.py.
# Zmierzone 2026-08-14: 12 698 scen animowanych, 0 performerów. Zapytanie odda
# wiec pusto i tak ma byc; apka chowa tam ten przycisk.
v_scenes = (
select(PlaybackSource.scene_id)
.where(
PlaybackSource.dead_at.is_(None),
PlaybackSource.origin.in_(VERTICALS[vertical]),
)
.distinct()
.subquery()
)
v_count = (
select(ScenePerformer.performer_id, func.count().label("c"))
.join(v_scenes, v_scenes.c.scene_id == ScenePerformer.scene_id)
.group_by(ScenePerformer.performer_id)
.subquery()
)
base = (
select(Performer, func.coalesce(v_count.c.c, 0).label("scene_count"))
.join(v_count, v_count.c.performer_id == Performer.id)
)
if q:
base = base.where(Performer.name_normalized.ilike(f"%{q.lower()}%"))
total = session.execute(
select(func.count()).select_from(base.subquery())
).scalar_one()
if order in ("scene_count", "popular"):
ordered = base.order_by(v_count.c.c.desc(), Performer.canonical_name.asc())
else:
ordered = base.order_by(Performer.canonical_name.asc())
rows = session.execute(
ordered.offset((page - 1) * per_page).limit(per_page)
).all()
return PerformerListOut(
items=[
PerformerCount(
id=p.id,
canonical_name=p.canonical_name,
slug=p.slug,
gender=p.gender.value if p.gender else None,
scene_count=int(c),
)
for p, c in rows
],
total=total, page=page, per_page=per_page,
)
# Czyta zdenormalizowany Performer.scene_count (refresh w tle). Wcześniej agregat
# 3M scene_performers + EXISTS playback per-request. Patrz migracja 0019.
base = select(Performer)
@ -236,13 +357,13 @@ def list_studios(
description="True: ukrywa studia z 0 wystąpieniami w wybranym typie.",
),
) -> StudioListOut:
from app.models.scene import Scene # lokalny import — Scene FK do Studio
from app.models.scene import Scene # lokalny import, Scene FK do Studio
if order not in ("name", "scene_count", "popular"):
raise HTTPException(status_code=400, detail="order must be 'name' or 'scene_count'")
if for_movies:
# Movies małe (~41k) — live aggregat OK. Scenes path niżej = denormalizacja.
# Movies małe (~41k), live aggregat OK. Scenes path niżej = denormalizacja.
_movie_live = exists().where(
and_(
MoviePlaybackSource.movie_id == Movie.id,
@ -280,7 +401,7 @@ def list_studios(
]
return StudioListOut(items=items, total=total, page=page, per_page=per_page)
# Scenes path — czyta zdenormalizowany Studio.scene_count (refresh w tle).
# Scenes path, czyta zdenormalizowany Studio.scene_count (refresh w tle).
# Wcześniej agregat 1.69M scenes + EXISTS playback per-request. Patrz migracja 0019.
base = select(Studio)
if q:
@ -322,6 +443,10 @@ class PerformerRefreshOut(BaseModel):
counters: dict[str, dict[str, int]]
new_scenes: int
last_searched_at: str | None
# "started" = wyszukiwanie ruszyło w tle i sceny dopiszą się za chwilę. Pole doszło
# przy przejściu na async (zgłoszenie 23c64a28); klient sprzed tej zmiany go nie
# czyta i pokaże "0 new scenes", co jest znośne przez okno do publikacji OTA.
status: str = "done"
class PerformerRescrapeOut(BaseModel):
@ -340,13 +465,13 @@ class PerformerRescrapeOut(BaseModel):
# przy partial commits. 45s wall-clock + 50 scen max = ~12 fetches × 3s budgetowo.
# Większe rescrape'y user może odpalać wielokrotnie (idempotent dzięki has_thumb/
# tag_count check).
_RESCRAPE_WALL_SEC = 55.0 # nginx read timeout 60s — 5s margin na response build
_RESCRAPE_WALL_SEC = 55.0 # nginx read timeout 60s, 5s margin na response build
_RESCRAPE_MAX_SCENES = 50
# Re-fetch tagów dla scen z < N tagami. Niektórzy performerzy mają legit 1-2 tagi
# (niche), no harm w sprawdzeniu pierwszy raz; powtarzane wywołania są idempotent
# bo INSERT ... ON CONFLICT DO NOTHING.
_TAG_RESCRAPE_THRESHOLD = 3
# Mainstream tubes priority dla tagów — bogate metadane.
# Mainstream tubes priority dla tagów, bogate metadane.
_TAG_PRIORITY = [
"xhamstercom", "porntrexcom", "epornercom", "youporncom",
"xvideoscom", "xnxxcom",
@ -360,7 +485,7 @@ def performer_top_tags(
limit: int = Query(default=20, ge=1, le=50),
) -> TagListOut:
"""Top tagi/kategorie agregowane ze scen performera (PerformerScenes UI chips,
bug-report 1a4bf258 — zastępują dev-only przycisk Re-scrape). Liczymy tylko po
bug-report 1a4bf258, zastępują dev-only przycisk Re-scrape). Liczymy tylko po
scenach z żywym playbackiem (to co user realnie widzi na liście). scene_id index
pokrywa oba joiny; performer ma rzędu setek scen → tani agregat per-request."""
live = exists().where(
@ -418,7 +543,7 @@ def rescrape_performer_scenes(
if perf is None:
raise HTTPException(status_code=404, detail="performer not found")
# 1) ID-only query — sceny ze ≥1 alive tube playback.
# 1) ID-only query, sceny ze ≥1 alive tube playback.
scene_ids = session.execute(
select(Scene.id)
.join(ScenePerformer, ScenePerformer.scene_id == Scene.id)
@ -468,7 +593,7 @@ def rescrape_performer_scenes(
capped = False
cap_reason: str | None = None
started = _time.monotonic()
# Narrow exception set — łapiemy TYLKO oczekiwane network/parse failures.
# Narrow exception set, łapiemy TYLKO oczekiwane network/parse failures.
# `Exception` catch-all blokował KeyboardInterrupt + maskował pool exhaustion.
NET_EXC = (TubePageError, _httpx.HTTPError, OSError, ValueError)
@ -486,7 +611,7 @@ def rescrape_performer_scenes(
has_thumb = any(s.thumbnail_url for s in sources)
existing_tag_count = tag_counts.get(scene_id, 0)
# SAVEPOINT — fail isolation. Pojedyncza scena z FK violation w SceneTag
# SAVEPOINT, fail isolation. Pojedyncza scena z FK violation w SceneTag
# insert nie odpaliłby outer transaction; bez nested rollback całe N scen
# po niej miałoby PendingRollbackError.
sp = session.begin_nested()
@ -504,7 +629,7 @@ def rescrape_performer_scenes(
thumb = extract_thumbnail_url(r.text)
if thumb:
# Update tylko źródła z którego pochodzi thumb (single playback).
# Wcześniej apply'owalismy do wszystkich siblings — wrong-CDN
# Wcześniej apply'owalismy do wszystkich siblings, wrong-CDN
# cross-attribution (np. xhamster thumb na porntrex entry).
# `scene.thumbnail_url` w UI bierze pierwszy z thumb (mobile
# find()), więc 1 wystarczy.
@ -586,11 +711,18 @@ def refresh_performer(
performer_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)],
) -> PerformerRefreshOut:
"""On-demand search across all tubes dla pojedynczego performera. Synchronous —
blokujemy aż search skończy. Mobile pokazuje spinner.
"""On-demand search across all tubes dla pojedynczego performera. Startuje w TLE
i odpowiada od razu (`status="started"`), a sceny dopisują się w miarę mielenia.
Rate-guard: jeśli refresh był < 60s temu, zwraca cached result (HTTP 429-style
detail). Continuous worker w tle też robi swoje, więc cache jest częsty.
Dlaczego nie synchronicznie, jak do 2026-08-26: search po ~25 tubach trwa dłużej,
niż klient jest gotów czekać, i przy rozbudowanych performerach telefon zrywał
połączenie. W logu zostawały starty `performer_driven` bez ANI JEDNEJ domkniętej
odpowiedzi (Cassie Del Isla, 871 scen, 4 zerwane requesty), użytkownik widział
"Refresh failed", klikał znowu i mieliliśmy to samo kilka razy równolegle
(zgłoszenie 23c64a28 "Search more scenes button not working").
Rate-guard bez zmian: refresh < 60s temu → 429. `last_searched_at` stawiamy PRZED
startem wątku, więc guard chroni też przed dublowaniem samego startu.
"""
from datetime import UTC as _UTC, datetime as _dt, timedelta as _td
@ -606,27 +738,37 @@ def refresh_performer(
detail=f"recently searched {int(elapsed.total_seconds())}s ago, try in a bit",
)
# Lazy import — performer_driven ma ciężki connector tree
from app.scheduler.performer_driven import run_performer_driven
# NOTE: ten request blokuje request thread API na 30-90s (search across ~25 tubes).
# Akceptowalne dla self-hosted single-user. W razie potrzeby dorobić task queue.
counters_obj = run_performer_driven(
performer_ids=[performer_id],
top_n=0,
per_performer_limit=200,
)
# Update last_searched_at + counter (tak samo jak continuous worker)
perf.last_searched_at = _dt.now(_UTC)
perf.search_run_count = (perf.search_run_count or 0) + 1
session.commit()
name = perf.canonical_name
searched_at = perf.last_searched_at.isoformat()
def _run_in_background() -> None:
# Własny import i własna obsługa błędów: wątek nie ma requestu, któremu mógłby
# oddać wyjątek, a `run_performer_driven` otwiera sesje DB samodzielnie.
try:
from app.scheduler.performer_driven import run_performer_driven
counters_obj = run_performer_driven(
performer_ids=[performer_id],
top_n=0,
per_performer_limit=200,
)
new_total = sum(s.get("new", 0) for s in counters_obj.per_source.values())
log.info("refresh-performer %s (%s): tlo skonczone, new=%d", performer_id, name, new_total)
except Exception:
log.exception("refresh-performer %s (%s): tlo padlo", performer_id, name)
import threading
threading.Thread(target=_run_in_background, name=f"perf-refresh-{performer_id}", daemon=True).start()
new_total = sum(s.get("new", 0) for s in counters_obj.per_source.values())
return PerformerRefreshOut(
performer_id=performer_id,
canonical_name=perf.canonical_name,
counters=counters_obj.per_source,
new_scenes=new_total,
last_searched_at=perf.last_searched_at.isoformat() if perf.last_searched_at else None,
canonical_name=name,
counters={},
new_scenes=0,
last_searched_at=searched_at,
status="started",
)

View file

@ -1,8 +1,8 @@
"""Watch history + continue watching.
Single-user. Mobile pingu POST /scenes/{id}/progress przy:
- Klik Watch (position_sec=0) — wciąga scenę do recent watch
- Powrót z MX z ACTION_RESULT (gdy włączone EXTRA_RETURN_RESULT) — z faktyczną pozycją
- Klik Watch (position_sec=0), wciąga scenę do recent watch
- Powrót z MX z ACTION_RESULT (gdy włączone EXTRA_RETURN_RESULT), z faktyczną pozycją
Continue watching rail na home: GET /watch/recent?limit=10 → top scen po last_played_at,
filtruje dead-finished (>=95% lub flag finished). Mobile pokazuje progress bar
@ -20,7 +20,7 @@ from sqlalchemy import desc, select
from sqlalchemy.orm import Session
from app.api.device import get_device_id
from app.api.scenes import _build_scene_out
from app.api.scenes import _build_scenes_out_batch
from app.api.schemas import SceneOut
from app.auth import require_api_key
from app.db import get_session
@ -55,7 +55,7 @@ def upsert_progress(
if session.get(Scene, scene_id) is None:
raise HTTPException(status_code=404, detail="scene not found")
# PG upsert — eliminuje race condition gdy mobile wysyła progress równolegle
# PG upsert, eliminuje race condition gdy mobile wysyła progress równolegle
# (np. 2 instancje playera lub auto-save + manual save). Wcześniej `get → add →
# commit` rzucało IntegrityError(pk_scene_play_progress) przy concurrent writes.
from sqlalchemy.dialects.postgresql import insert as pg_insert
@ -139,7 +139,7 @@ def upsert_movie_progress(
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> MovieProgressOut:
"""Mirror upsert_progress dla filmów (bug-report b207ff17 2026-05-26 —
"""Mirror upsert_progress dla filmów (bug-report b207ff17 2026-05-26,
"przydałoby się oznaczenie filmów już obejrzanych")."""
if session.get(Movie, movie_id) is None:
raise HTTPException(status_code=404, detail="movie not found")
@ -237,11 +237,20 @@ def list_recent(
if not include_finished:
stmt = stmt.where(ScenePlayProgress.finished.is_(False))
# Serializacja wsadowa jak w liscie scen: rail „Continue watching" leci przy kazdym
# starcie apki, a pelny `_build_scene_out` robil 5-7 zapytan na scene (N+1 w Sentry,
# 2026-09-14). Rail uzywa tytulu, miniatury i dlugosci, wiec wersja lekka wystarcza.
rows = session.execute(stmt).all()
outs = _build_scenes_out_batch(session, [scene for _, scene in rows], light=True, device_id=device_id)
out_by_id = {o.id: o for o in outs}
items: list[WatchEntry] = []
for prog, scene in session.execute(stmt).all():
for prog, scene in rows:
scene_out = out_by_id.get(scene.id)
if scene_out is None:
continue
items.append(
WatchEntry(
scene=_build_scene_out(session, scene, device_id=device_id),
scene=scene_out,
position_sec=prog.position_sec,
duration_sec=prog.duration_sec,
finished=prog.finished,

View file

@ -1,7 +1,7 @@
"""API key authentication.
Klucz przyjmowany z header `X-API-Key` lub `Authorization: Bearer <key>`.
Gdy `settings.api_keys` jest puste — auth jest wyłączony (dev mode).
Gdy `settings.api_keys` jest puste, auth jest wyłączony (dev mode).
Dodatkowo (anti-tamper): gdy `ALLOWED_APP_SIG_HASH` jest ustawione, każdy request
musi zawierać `X-App-Signature` z SHA256 (hex) signing certu APK. Mismatch → 403.
@ -30,7 +30,7 @@ def require_api_key(
)
if not settings.auth_enabled:
return # local/dev — wszystko otwarte
return # local/dev, wszystko otwarte
candidate: str | None = None
if x_api_key:

243
app/clips_feed.py Normal file
View file

@ -0,0 +1,243 @@
"""Logika feedu Swipe bez bazy: kursor, wagi afiniczności, punktacja, składanie strony.
Wyniesione z `app/api/clips.py`, żeby dało się testować bez Postgresa (`tests/test_clips_feed.py`).
**Kursor** = base64 JSON `{"s": seed, "pk": <uuid>, "p": <nr strony>}`. Nie offset: pula
„explore" idzie oknem po kluczu głównym od `pk` (ten sam trik co `sort=random` w
`list_scenes`), pula afiniczności losuje punkty startu z `Random(seed, p)`. Dzięki temu
dwa telefony z tym samym seedem dostają inne strony tylko przez różne `clip_seen`,
a pull-to-refresh to po prostu nowy seed.
**Wagi**: trzy słowniki (tag, nisza, twórca) → liczba. Zdarzenia dodają delty
(`EVENT_DELTAS`), powód „nie interesuje" dokłada karę na wskazany cel, nocny decay ×0,97
zapomina stare gusta, cap trzyma słowniki małe (60/40/40). Wszystko celowo proste i
przejrzyste: da się wytłumaczyć użytkownikowi, dlaczego coś widzi, i skasować jednym
wywołaniem.
**Punktacja kandydata** = Σ wag tagów + 2·waga niszy + 3·waga twórcy + 0,3·ln(1+lajki)
+ 0,5 gdy klip z ostatnich 7 dni. Strona: 80 % z afiniczności (max 2 klipy jednego twórcy),
20 % explore, przemieszane deterministycznie z seeda.
"""
from __future__ import annotations
import base64
import hashlib
import json
import math
import random
import uuid
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
FRESH_DAYS = 7
EXPLORE_SHARE = 0.2
MAX_PER_CREATOR = 2
CAP_TAGS, CAP_NICHES, CAP_CREATORS = 60, 40, 40
DECAY_FACTOR = 0.97
DECAY_FLOOR = 0.2
#: Poniżej tego dwell „impression" nic nie znaczy, powyżej `DWELL_POSITIVE_MS` to plus.
SKIP_MS = 2000
DWELL_POSITIVE_MS = 10000
#: kind → (delta tag, delta nisza, delta twórca)
EVENT_DELTAS: dict[str, tuple[float, float, float]] = {
"complete": (3.0, 3.0, 2.0),
"like": (5.0, 5.0, 4.0),
"unlike": (-5.0, -5.0, -4.0),
"dwell": (1.0, 1.0, 0.5),
"skip": (-1.0, -1.0, -0.5),
"not_interested": (-4.0, -4.0, -3.0),
}
REASON_PENALTY = -8.0
ALLOWED_KINDS = frozenset(
{"impression", "skip", "complete", "like", "unlike", "not_interested", "autoswipe", "open"}
)
#: Kolejność „siły" wyniku w `clip_seen`: nie cofamy do słabszego.
OUTCOME_RANK = {"served": 0, "skip": 1, "complete": 2, "like": 3, "not_interested": 4}
# ---- kursor -----------------------------------------------------------------------------
def new_seed() -> str:
return uuid.uuid4().hex[:12]
def seed_start_uuid(seed: str, salt: str = "") -> uuid.UUID:
return uuid.UUID(hashlib.md5(f"{seed}:{salt}".encode()).hexdigest())
def encode_cursor(seed: str, pk: uuid.UUID, page: int) -> str:
raw = json.dumps({"s": seed, "pk": str(pk), "p": page}, separators=(",", ":"))
return base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=")
def decode_cursor(cursor: str) -> tuple[str, uuid.UUID, int] | None:
"""None gdy kursor jest zepsuty (klient zaczyna wtedy od nowa, bez 400)."""
try:
pad = "=" * (-len(cursor) % 4)
d = json.loads(base64.urlsafe_b64decode(cursor + pad).decode())
seed = str(d["s"])[:16]
pk = uuid.UUID(str(d["pk"]))
page = int(d["p"])
if not seed or page < 0 or page > 100000:
return None
return seed, pk, page
except Exception:
return None
def page_rng(seed: str, page: int) -> random.Random:
return random.Random(f"{seed}:{page}")
# ---- wagi -------------------------------------------------------------------------------
@dataclass
class Affinity:
tags: dict[str, float] = field(default_factory=dict)
niches: dict[str, float] = field(default_factory=dict)
creators: dict[str, float] = field(default_factory=dict)
def is_empty(self) -> bool:
return not (
any(w > 0 for w in self.tags.values())
or any(w > 0 for w in self.niches.values())
or any(w > 0 for w in self.creators.values())
)
def top(self, which: str, n: int) -> list[str]:
d = getattr(self, which)
return [k for k, w in sorted(d.items(), key=lambda kv: -kv[1]) if w > 0][:n]
def _bump(d: dict[str, float], key: str | None, delta: float) -> None:
if not key or not delta:
return
d[key] = round(d.get(key, 0.0) + delta, 3)
def cap_weights(d: dict[str, float], n: int) -> dict[str, float]:
"""Zostaw `n` pozycji o największej wartości bezwzględnej (kary też są informacją)."""
if len(d) <= n:
return d
keep = sorted(d.items(), key=lambda kv: -abs(kv[1]))[:n]
return dict(keep)
def decay_weights(d: dict[str, float], factor: float = DECAY_FACTOR, floor: float = DECAY_FLOOR) -> dict[str, float]:
out = {k: round(v * factor, 3) for k, v in d.items()}
return {k: v for k, v in out.items() if abs(v) >= floor}
def event_kind_for_weights(kind: str, dwell_ms: int | None) -> str | None:
"""Zdarzenie z apki → klucz w `EVENT_DELTAS`. `impression` liczy się tylko przy długim
dwell, `skip` tylko przy krótkim; `autoswipe`/`open` nie ruszają wag."""
if kind == "impression":
return "dwell" if (dwell_ms or 0) >= DWELL_POSITIVE_MS else None
if kind == "skip":
return "skip" if (dwell_ms or 0) < SKIP_MS else None
return kind if kind in EVENT_DELTAS else None
def apply_event(
aff: Affinity,
*,
kind: str,
dwell_ms: int | None,
reason: str | None,
tags: list[str],
niches: list[str],
creator: str | None,
) -> bool:
"""Nakłada delty zdarzenia na wagi. True gdy coś się zmieniło."""
wkind = event_kind_for_weights(kind, dwell_ms)
changed = False
if wkind:
dt, dn, dc = EVENT_DELTAS[wkind]
for t in tags:
_bump(aff.tags, t, dt)
for n in niches:
_bump(aff.niches, n, dn)
_bump(aff.creators, creator, dc)
changed = True
if kind == "not_interested" and reason and ":" in reason:
target_kind, _, target = reason.partition(":")
target = target.strip()
if target_kind == "tag":
_bump(aff.tags, target, REASON_PENALTY)
elif target_kind == "niche":
_bump(aff.niches, target, REASON_PENALTY)
elif target_kind == "creator":
_bump(aff.creators, target, REASON_PENALTY)
changed = True
if changed:
aff.tags = cap_weights(aff.tags, CAP_TAGS)
aff.niches = cap_weights(aff.niches, CAP_NICHES)
aff.creators = cap_weights(aff.creators, CAP_CREATORS)
return changed
# ---- punktacja i składanie strony -----------------------------------------------------
@dataclass
class Candidate:
id: uuid.UUID
creator: str | None
likes: int
published_at: datetime | None
tags: list[str]
niches: list[str]
def score_candidate(c: Candidate, aff: Affinity, now: datetime | None = None) -> float:
now = now or datetime.now(UTC)
s = sum(aff.tags.get(t, 0.0) for t in c.tags)
s += 2.0 * sum(aff.niches.get(n, 0.0) for n in c.niches)
s += 3.0 * aff.creators.get(c.creator or "", 0.0)
s += 0.3 * math.log1p(max(c.likes, 0))
if c.published_at and c.published_at >= now - timedelta(days=FRESH_DAYS):
s += 0.5
return s
def pick_affinity(cands: list[Candidate], aff: Affinity, n: int, *, max_per_creator: int = MAX_PER_CREATOR) -> list[Candidate]:
"""Najlepsze `n` po punktach, max `max_per_creator` klipów jednego twórcy na stronę."""
ranked = sorted(cands, key=lambda c: -score_candidate(c, aff))
out: list[Candidate] = []
per: dict[str, int] = {}
for c in ranked:
key = c.creator or ""
if per.get(key, 0) >= max_per_creator:
continue
per[key] = per.get(key, 0) + 1
out.append(c)
if len(out) >= n:
break
return out
def pick_explore(cands: list[Candidate], n: int, rng: random.Random) -> list[Candidate]:
"""Losowanie ważone popularnością: ln(1+lajki)+1, żeby zero lajków też miało szansę."""
pool = list(cands)
out: list[Candidate] = []
while pool and len(out) < n:
weights = [math.log1p(max(c.likes, 0)) + 1.0 for c in pool]
pick = rng.choices(range(len(pool)), weights=weights, k=1)[0]
out.append(pool.pop(pick))
return out
def split_counts(limit: int, has_affinity: bool) -> tuple[int, int]:
"""(z afiniczności, explore). Bez wag wszystko idzie z explore."""
if not has_affinity:
return 0, limit
n_explore = max(1, round(limit * EXPLORE_SHARE))
return limit - n_explore, n_explore
def compose_page(aff_items: list[Candidate], explore_items: list[Candidate], rng: random.Random) -> list[tuple[Candidate, str]]:
page = [(c, "affinity") for c in aff_items] + [(c, "explore") for c in explore_items]
rng.shuffle(page)
return page
def is_fresh(published_at: datetime | None, now: datetime | None = None) -> bool:
now = now or datetime.now(UTC)
return bool(published_at and published_at >= now - timedelta(days=FRESH_DAYS))

View file

@ -25,7 +25,7 @@ class Settings(BaseSettings):
log_level: str = Field(default="INFO", validation_alias="LOG_LEVEL")
# Sentry observability — pusty DSN = init no-op (devel/local). Cloud free tier
# Sentry observability, pusty DSN = init no-op (devel/local). Cloud free tier
# 5k errors/mies wystarczy dla 1-user app.
sentry_dsn: str | None = Field(default=None, validation_alias="SENTRY_DSN")
sentry_environment: str = Field(default="dev", validation_alias="SENTRY_ENVIRONMENT")
@ -48,12 +48,12 @@ class Settings(BaseSettings):
title_token_set_min: int = 88
date_window_days: int = 7
# Skip ingestu clip-store (ManyVids/IWantClips/Clips4Sale/...) z canonical source —
# Skip ingestu clip-store (ManyVids/IWantClips/Clips4Sale/...) z canonical source,
# to permanentne orphany (free tubes nie hostują), ~56% ingestu TPDB/StashDB.
# False = wciągaj jak dawniej. Tube'y z clip-store studiem NIE są skipowane (mają playback).
skip_clip_store: bool = Field(default=True, validation_alias="GOON_SKIP_CLIP_STORE")
# Minimalny duration sceny z tube/scraper przy ingescie — <N s = trailer/teaser/preview.
# Minimalny duration sceny z tube/scraper przy ingescie, <N s = trailer/teaser/preview.
# 0 = wyłączony. Nieznany duration nie jest wycinany. NIE dotyczy canonical (TPDB/StashDB).
min_ingest_duration_sec: int = Field(default=180, validation_alias="GOON_MIN_INGEST_DURATION_SEC")
@ -74,7 +74,7 @@ class Settings(BaseSettings):
sched_performer_continuous_refresh_days: int = Field(
default=30, validation_alias="GOON_SCHED_PERFORMER_CONTINUOUS_REFRESH_DAYS"
)
# Movie ingest — paradisehill (primary) + dooplay mirrory (mangoporn/streamporn/
# Movie ingest, paradisehill (primary) + dooplay mirrory (mangoporn/streamporn/
# pandamovies). Każdy connector zapisuje swój `Source` i robi delta od ostatniego
# successful run. Set to 0 to disable. Domyślnie 24h: movie sites rosną wolniej
# niż tube'y (~5-30 nowych dziennie), nie ma sensu wymiatać częściej.
@ -92,50 +92,80 @@ class Settings(BaseSettings):
sched_browse_latest_max_pages: int = Field(
default=5, validation_alias="GOON_SCHED_BROWSE_LATEST_MAX_PAGES"
)
# Deep-crawl (Faza 2a) — pełne katalogi browse-tube'ów (porndoe ~62k itd.), nie tylko
# Deep-crawl (Faza 2a), pełne katalogi browse-tube'ów (porndoe ~62k itd.), nie tylko
# top-N. Round-robin po tube'ach, wznawialny kursor (app/_state/deepcrawl_state.json).
# 0 = wyłączony. 60 stron/run × ~31 scen ≈ 1860 scen/run (~22 min, hard-timeout 1h).
sched_deep_crawl_hours: int = Field(default=1, validation_alias="GOON_SCHED_DEEP_CRAWL_HOURS")
deep_crawl_pages_per_run: int = Field(default=60, validation_alias="GOON_DEEP_CRAWL_PAGES_PER_RUN")
deepcrawl_state_path: str = Field(default="", validation_alias="GOON_DEEPCRAWL_STATE_PATH")
# Bulk-dedup performers safety net — auto-merge duplikatów które resolver-time
# Bulk-dedup performers safety net, auto-merge duplikatów które resolver-time
# scoring pominął. 12h cadence: leci 2x dziennie (po porannym browse-latest run).
sched_bulk_dedup_hours: int = Field(
default=12, validation_alias="GOON_SCHED_BULK_DEDUP_HOURS"
)
# Thumb-asset dedup — scala dupy hdporn.gg/fullmovies.xxx (ten sam film, różne tytuły,
# Thumb-asset dedup, scala dupy hdporn.gg/fullmovies.xxx (ten sam film, różne tytuły,
# ten sam asset-id miniatury + długość). bulk_dedup tego nie łapie (brak phash/tytuł).
# Re-ingesty pod nowymi tytułami → dupy odrastają, stąd cykliczny job. 12h. 0 = off.
sched_thumb_dedup_hours: int = Field(
default=12, validation_alias="GOON_SCHED_THUMB_DEDUP_HOURS"
)
# Title+duration dedup — scala missing-merge dupy (ten sam performer + identyczny
# Title+duration dedup, scala missing-merge dupy (ten sam performer + identyczny
# znormalizowany tytuł + długość co do sekundy), których bulk_dedup nie łapie (tube
# re-scrape / cross-tube np. porn00 vs xnxx, reports 28fe8181/32df33b1). Odrastają
# przy re-ingeście, stąd cyklicznie. 12h, playback-only (to co user widzi). 0 = off.
sched_title_dedup_hours: int = Field(
default=12, validation_alias="GOON_SCHED_TITLE_DEDUP_HOURS"
)
# 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
# scenach, rekordzistka przy 892 scenach o 892 różnych tytułach. Tube może zacząć
# serwować zaślepkę w dowolnym momencie, stąd cyklicznie. 24h. 0 = off.
sched_phash_blacklist_hours: int = Field(
default=24, validation_alias="GOON_SCHED_PHASH_BLACKLIST_HOURS"
)
# Doliczanie phasha scenom, które mają zapisaną miniaturkę, ale nie mają odcisku.
# Audyt 2026-08-13: phash miało tylko 37,8% grywalnych scen, bo liczą go WYŁĄCZNIE
# niektóre konektory przy ingeście (xvideos 1,8%, xnxx 1,4%, eporner 1,0%, przy
# ~100% pokryciu miniaturką). Bez odcisku dedup nie ma czym skleić duplikatów, patrz
# zgłoszenie 9d272101. Job leci często i małymi paczkami, bo to pobrania z cudzych
# CDN-ów, patrz throttling w `app/scheduler/phash_backfill.py`. 0 = off.
# 800 co 15 min = ~3200/h, czyli ~3 dni na nadrobienie 239 tys. zaległości. Paczka
# 400 zajmuje ~22 s (4 wątki), więc realnie worker śpi prawie cały ten czas, a
# pobrania rozkładają się na kilkanaście różnych CDN-ów, nie na jeden host.
sched_phash_backfill_minutes: int = Field(
default=15, validation_alias="GOON_SCHED_PHASH_BACKFILL_MINUTES"
)
sched_phash_backfill_batch: int = Field(
default=800, validation_alias="GOON_SCHED_PHASH_BACKFILL_BATCH"
)
# Doliczanie brakujacej dlugosci scen (patrz app/models/duration_backfill_queue.py).
# 500 stron co 5 min to ~144 tys. dziennie, wiec kolejka 846 tys. schodzi w ok. 6 dni.
# To ~1,7 zapytania na sekunde rozlozone na trzy duze tuby, czyli ruch, ktorego one
# nie zauwaza. Job ma 30-minutowy limit, a paczka schodzi w ok. 3 min, wiec z zapasem.
sched_duration_backfill_minutes: int = Field(
default=5, validation_alias="GOON_SCHED_DURATION_BACKFILL_MINUTES"
)
sched_duration_backfill_batch: int = Field(
default=500, validation_alias="GOON_SCHED_DURATION_BACKFILL_BATCH"
)
# Próg ciszy dla konektorów FILMOWYCH. Wcześniej filmy nie były pokryte watchdogiem
# w ogóle — streamporn.vip stał 23 dni i nic nie krzyknęło. Filmy ingestujemy
# w ogóle, streamporn.vip stał 23 dni i nic nie krzyknęło. Filmy ingestujemy
# codziennie, więc 72h to bezpieczny margines na jeden nieudany cykl.
ingest_watchdog_movie_max_age_hours: int = Field(
default=72, validation_alias="GOON_INGEST_WATCHDOG_MOVIE_MAX_AGE_HOURS"
)
# Śmieciowi performerzy — kategorie i studia podszywające się pod osoby („Creampie",
# Śmieciowi performerzy, kategorie i studia podszywające się pod osoby („Creampie",
# „Natural tits", „Brazzers"). Audyt 2026-07-27: 277 rekordów, 33 784 przypisania.
# Odrastają, bo tube-search dokleja performera po jednym tokenie. 24h. 0 = off.
sched_junk_performers_hours: int = Field(
default=24, validation_alias="GOON_SCHED_JUNK_PERFORMERS_HOURS"
)
# Ingest freshness watchdog — alert do Sentry gdy aktywny tube (origin
# Przycinanie ogona tagow rule34video (tagowanie booru: nazwy postaci/serii).
# 356 scen wnosilo 1112 tagow do WSPOLNEJ listy, 602 z nich uzyte raz. 24h. 0 = off.
sched_tag_tail_trim_hours: int = Field(
default=24, validation_alias="GOON_SCHED_TAG_TAIL_TRIM_HOURS"
)
# Ingest freshness watchdog, alert do Sentry gdy aktywny tube (origin
# tube:<sitetag>) przestał dawać nowe sceny > próg. Łapie zamrożenie
# pojedynczego origin, którego globalny monitor (jeden Source "tube-scraper") nie
# widzi (np. freshporno browse z rotującego roota, report 14f3a655). 6h cadence
@ -143,19 +173,19 @@ class Settings(BaseSettings):
sched_ingest_watchdog_hours: int = Field(
default=6, validation_alias="GOON_SCHED_INGEST_WATCHDOG_HOURS"
)
# Próg dla browse-scraperów (ALL_BROWSE_SCRAPERS) — crawlowane raz dziennie z
# Próg dla browse-scraperów (ALL_BROWSE_SCRAPERS), crawlowane raz dziennie z
# listingu, więc 48h ciszy = anomalia.
ingest_watchdog_max_age_hours: int = Field(
default=48, validation_alias="GOON_INGEST_WATCHDOG_MAX_AGE_HOURS"
)
# Próg dla performer-driven search-scraperów (ALL_DIRECT_SCRAPERS) — kadencja jest
# Próg dla performer-driven search-scraperów (ALL_DIRECT_SCRAPERS), kadencja jest
# nierówna (continuous queue ~30d refresh per performer, ingest orphan-heavy), więc
# 48h dawałoby false-positivy. 7d (168h): healthy search-tuby obserwowane <6h świeżości
# (continuous tick hituje wszystkie tuby per performer), zamrożone ≥73h → ~28× margines.
ingest_watchdog_search_max_age_hours: int = Field(
default=168, validation_alias="GOON_INGEST_WATCHDOG_SEARCH_MAX_AGE_HOURS"
)
# TPDB movie enrichment — wzbogaca filmy obsadą + kategoriami (tagi) + studiem +
# TPDB movie enrichment, wzbogaca filmy obsadą + kategoriami (tagi) + studiem +
# reżyserem, plus kanoniczny TPDB UUID do dedupu mirrorów. paradisehill (primary)
# prawie nie ma obsady, więc to domyka największą lukę zakładki movies. Batch
# najświeższych GRYWALNYCH filmów bez obsady/studia per run; 6h cadence. 0 = off.
@ -165,20 +195,20 @@ class Settings(BaseSettings):
tpdb_movie_enrich_batch: int = Field(
default=200, validation_alias="GOON_TPDB_MOVIE_ENRICH_BATCH"
)
# Taxonomy scene_count refresh — przelicza denormalizowane liczniki scen na
# Taxonomy scene_count refresh, przelicza denormalizowane liczniki scen na
# tags/performers/studios (hot-path /tags|/performers|/studios|/favorites czyta
# gotową kolumnę zamiast agregować 6.3M scene_tags per-request). 3h cadence —
# gotową kolumnę zamiast agregować 6.3M scene_tags per-request). 3h cadence,
# counts do tego stale, dla sortu "popular" + badge "(N)" bez znaczenia. 0 = off.
sched_taxonomy_counts_hours: int = Field(
default=3, validation_alias="GOON_SCHED_TAXONOMY_COUNTS_HOURS"
)
# Hetzner Cloud bandwidth monitor — read-only API token (Security → API Tokens
# Hetzner Cloud bandwidth monitor, read-only API token (Security → API Tokens
# w panelu Hetzner Cloud). Bez tokenu monitor wyłączony (warning w log).
# Free traffic per server: CX22=20TB, CPX21=20TB itd. Overage = €1/TB.
hetzner_api_token: str | None = Field(default=None, validation_alias="HETZNER_API_TOKEN")
hetzner_server_id: int | None = Field(default=None, validation_alias="HETZNER_SERVER_ID")
# Alert thresholds (% of included_traffic) — Sentry severity levels.
# Alert thresholds (% of included_traffic), Sentry severity levels.
hetzner_alert_info_pct: int = Field(default=50, validation_alias="HETZNER_ALERT_INFO_PCT")
hetzner_alert_warning_pct: int = Field(default=80, validation_alias="HETZNER_ALERT_WARNING_PCT")
hetzner_alert_error_pct: int = Field(default=95, validation_alias="HETZNER_ALERT_ERROR_PCT")
@ -188,14 +218,27 @@ class Settings(BaseSettings):
default=6, validation_alias="GOON_SCHED_HETZNER_MONITOR_HOURS"
)
# Source ranking (Sites screen) — przelicz source_stats (freshness/richness/health
# Source ranking (Sites screen), przelicz source_stats (freshness/richness/health
# per origin). 0/None = wyłączone. Domyślnie 6h (richness to ciężki agregat po
# ~2M live playback_sources; częściej bez sensu, dane zmieniają się powoli).
sched_source_stats_hours: int = Field(
default=6, validation_alias="GOON_SCHED_SOURCE_STATS_HOURS"
)
# Bright Data ISP proxy (stałe IP od ISP, rozliczane ryczałtem NIE per-GB) —
# Klipy do feedu Swipe (RedGifs): godzinny run globalnego „najnowsze", N stron po
# 100 (RedGifs publikuje kilkaset klipów na godzinę). 0 = wyłączony. Backfill po
# niszach ręcznie: `python -m scripts.clips_backfill_redgifs`.
sched_clips_hours: int = Field(default=1, validation_alias="GOON_SCHED_CLIPS_HOURS")
clips_latest_pages: int = Field(default=5, validation_alias="GOON_CLIPS_LATEST_PAGES")
# Konserwacja feedu Swipe raz na dobę: decay wag afiniczności (×0,97), retencja
# zdarzeń (90 dni), reaper martwych plików (HEAD na 2000 najdawniej sprawdzanych).
sched_clips_maintenance_hours: int = Field(
default=24, validation_alias="GOON_SCHED_CLIPS_MAINTENANCE_HOURS"
)
# Feed podaje tylko klipy do tej długości (RedGifs p90 = 60 s; dłuższe to nie „swipe").
clips_feed_max_duration_sec: int = Field(default=120, validation_alias="GOON_CLIPS_FEED_MAX_DURATION_SEC")
# Bright Data ISP proxy (stałe IP od ISP, rozliczane ryczałtem NIE per-GB),
# używany do ingestu HTML (scrape) tubów które blokują VPS IP twardym Cloudflare
# 403 nawet z browser-TLS (superporn). Streamu i tak nie ruszamy proxy (tokeny CDN
# IP-bound). Format env: `host:port:user:pass` (panel Bright Data). Pusty = brak.

View file

@ -1,14 +1,14 @@
"""Connector registry helpers.
Lazy factories — importy connectorów wykonują się dopiero w `get_movie_connectors()`
Lazy factories, importy connectorów wykonują się dopiero w `get_movie_connectors()`
żeby uniknąć circular imports (modeles/db). Każdy entry: `(name, class)` w porządku
ingestu (primary FIRST, mirrory potem — `resolve_movie` wtedy ma do czego dokleić
ingestu (primary FIRST, mirrory potem, `resolve_movie` wtedy ma do czego dokleić
mirror playback sources).
## Jak dodać nowe movie site
1. Napisz subclass `DooplayConnector` w `app/connectors/dooplay.py` (jeśli site używa
dooplay/PsyPlay WP theme) — wystarczy `name` + `base_url`. Jeśli inny theme,
dooplay/PsyPlay WP theme), wystarczy `name` + `base_url`. Jeśli inny theme,
napisz osobny connector implementujący `BaseMovieConnector.fetch_movies()`.
2. Dodaj entry do `_MOVIE_CONNECTORS` poniżej.
3. Backend job `_job_movie_ingest` w `app/scheduler/jobs.py` automatycznie weźmie
@ -31,11 +31,10 @@ from __future__ import annotations
def get_movie_connectors() -> list[tuple[str, type]]:
"""Zwraca listę (name, ConnectorCls) tuples w kolejności ingestu.
Lazy import — uniknięcie circular import bo connectory zaczepiają db/models.
Lazy import, uniknięcie circular import bo connectory zaczepiają db/models.
"""
from app.connectors.dooplay import (
MangopornConnector,
PandamoviesConnector,
StreampornVipConnector,
)
from app.connectors.freeomovie import FreeoMovieConnector
@ -43,10 +42,10 @@ def get_movie_connectors() -> list[tuple[str, type]]:
from app.connectors.yourdailypornmovies import YourDailyPornMoviesConnector
# Kolejność ingestu: paradisehill FIRST (canonical primary, mirrory się do
# niego przyklejają), potem mangoporn (jedyny mirror z realnym new-content —
# niego przyklejają), potem mangoporn (jedyny mirror z realnym new-content,
# 72 nowych 2026-05-28; streamporn/pandamovies zwracają stale 0 new), na końcu
# streamporn + pandamovies. Powód reorderu (2026-05-30): gdy streamporn wiesza
# się intermittentnie, mangoporn musi zdążyć przed nim — patrz per-connector
# się intermittentnie, mangoporn musi zdążyć przed nim, patrz per-connector
# timeout w _job_movie_ingest.
# streamporn.nl padł (0 new od 2026-05-23) → zastąpiony żywym streamporn.vip
# (StreampornVipConnector, ocena 2026-07-02). Stara klasa .nl zostaje w dooplay.py
@ -55,16 +54,26 @@ def get_movie_connectors() -> list[tuple[str, type]]:
("paradisehill", ParadisehillConnector),
("mangoporn", MangopornConnector),
("streampornvip", StreampornVipConnector),
("pandamovies", PandamoviesConnector),
# freeomovie.to (bestia theme, nie dooplay) — mirror: świeże filmy DVD,
# pandamovies WYLACZONY 2026-08-20: serwis padl, zostal im tylko zapamietany
# fronton. Strona glowna oddaje 291 kB i wypisuje 56 filmow, ale KAZDA inna
# sciezka to 404: listing /movies/ (ten, ktory czytal konektor), /movies/page/2/
# linkowana przez ich wlasny szablon, /genre/* i /release-year/* z ich menu,
# sitemapa, a przede wszystkim same strony filmow (0 na 6 dzialajacych).
# To nie blokada na nasze IP: przez residential proxy wychodzi identyczne 404
# (te same 796 bajtow). Konektor konczyl runy jako success z zerem rekordow,
# co falszowalo obraz w monitoringu. Playback tego, co juz mamy, dziala dalej,
# bo zrodla wskazuja na hostery (doply, luluvid, seekplayer), nie na ten serwis.
# Klasa zostaje w dooplay.py dla backrefu origins `pandamovies:*`.
# ("pandamovies", PandamoviesConnector),
# freeomovie.to (bestia theme, nie dooplay), mirror: świeże filmy DVD,
# title-trigram attach do canonical + playback z TABS. Ocena 2026-07-02.
("freeomovie", FreeoMovieConnector),
# yourdailypornmovies.ws (własny motyw WP, nie dooplay) — ocena 2026-07-28.
# yourdailypornmovies.ws (własny motyw WP, nie dooplay), ocena 2026-07-28.
# SPRAWDZONY NA DUPLIKAT: 18/40 filmów ze strony głównej było nowych, bo to inna
# pula treści (okładki DVD z adultempire, nie re-hosty z pandanetwork.club, na
# których siedzą mangoporn/pandamovies/speedporn). Katalog min. 8000, do 2003.
# Metadane: obsada, studio (kryje się pod `/director/`), tagi, rok, synopsis,
# id produktu Adult Empire. Bez długości. Playback: voe + mixdrop (oba mają
# gotowe moduły hosterów), vidara.to nieznana — leci jako zwykły embed.
# gotowe moduły hosterów), vidara.to nieznana, leci jako zwykły embed.
("yourdailypornmovies", YourDailyPornMoviesConnector),
]

View file

@ -93,7 +93,7 @@ class RawScene(BaseModel):
(np. 'tpdb', 'stashdb')."""
raw: dict[str, Any] = Field(default_factory=dict)
"""Oryginalny payload z API — leci do external_records.raw."""
"""Oryginalny payload z API, leci do external_records.raw."""
class BaseConnector(abc.ABC):
@ -114,15 +114,15 @@ class BaseConnector(abc.ABC):
# ---------------------------------------------------------------------------
# Movies — odrębny encja od scen, ale ten sam wzorzec connectorów
# Movies, odrębny encja od scen, ale ten sam wzorzec connectorów
# ---------------------------------------------------------------------------
class RawMovieChapter(BaseModel):
"""Pojedynczy rozdział filmu (movies czasem dzielą się na "Part 1/2/3" itp.).
Identyfikatory chaptera nie są kanonizowane między źródłami — są lokalne dla movie,
Identyfikatory chaptera nie są kanonizowane między źródłami, są lokalne dla movie,
indeksowane przez `chapter_index`. Może linkować do separate scene (jeśli ta scena
znana z TPDB/StashDB) — tym zajmuje się normalizator wyżej."""
znana z TPDB/StashDB), tym zajmuje się normalizator wyżej."""
model_config = ConfigDict(extra="allow")
@ -133,7 +133,7 @@ class RawMovieChapter(BaseModel):
class RawMovie(BaseModel):
"""Surowy film z connectora — odpowiednik RawScene dla movies.
"""Surowy film z connectora, odpowiednik RawScene dla movies.
Performers / studio / tags reusable z RawPerformer / RawStudio / RawTag (te same
typy w obu pipelinach). Playback sources to lista mirrorów odtwarzania (paradisehill
@ -170,7 +170,7 @@ class BaseMovieConnector(abc.ABC):
"""Connector dla source'a movies (paradisehill, psyplay, wp_movies).
Symetrycznie do BaseConnector ale yielduje RawMovie. Każde źródło zna własną
paginację i format ID — konwerter wyżej (resolver) dba o dedup między źródłami.
paginację i format ID, konwerter wyżej (resolver) dba o dedup między źródłami.
"""
kind: SourceKind

View file

@ -0,0 +1,2 @@
"""Konektory klipów (feed Swipe). Osobne od `direct_scrapers`: inny model (`Clip`),
inny potok (upsert bez dedupu i bez matchera performerów), własne joby."""

View file

@ -0,0 +1,223 @@
"""RedGifs, oficjalne publiczne API v2. Główne źródło klipów do feedu Swipe. Dodane 2026-09-14.
**Dlaczego RedGifs, a nie nsfwswipe.** Sprawdzone tego samego dnia: nsfwswipe to lustro
wideo natywnych Reddita (v.redd.it) bez audio, z ostatnim klipem z 19.08 (stoi od
czterech tygodni) i z losowym, nieoficjalnym API oddającym HTML. RedGifs daje JSON,
klipy z tej minuty, 1080x1920, audio w ok. 1/3 klipów, twórcę z odznaką weryfikacji,
lajki i widoki. Pliki `media.redgifs.com/<Id>.mp4` są publiczne, bez podpisu, grają bez
Referera z VPS-a i z telefonu (206), więc do bazy wchodzi gotowy adres, bez ekstraktora.
**Token.** `GET /v2/auth/temporary` oddaje token na kilka godzin, związany z IP i User-Agentem
wołającego: kolejne zapytania MUSZĄ iść z tymi samymi nagłówkami, inaczej 401 `WrongSender`
(zjadło pół godziny przy pierwszym podejściu, bo curl i node miały inne UA). Na 401
odnawiamy token raz i powtarzamy.
**Listing.** `GET /v2/gifs/search?order=latest&count=100&page=N` bez `search_text` to
globalne „najnowsze" (98-100 na stronę). `order` ∈ latest|top|top7|top28|trending|score,
NIE `new` (400 BadOrder). Każde zapytanie widzi maks. 10 000 wyników (`pages` ≤ 100,
strona 101 → 500), więc pełny katalog chodzi się po niszach (`/v2/niches`, 1812
społeczności, `/v2/niches/<id>/gifs`) i po twórcach (`/v2/users/<name>/search`).
Limit: per IP i per endpoint (`subject: "<ip>|niche-gifs|<n>"` w ciele 429), bez nagłówka
Retry-After, za to w ciele `error.delay` w sekundach (widziane 1167 s po ~65 zapytaniach
w 20 s). Trzymamy 1 zapytanie/s; przy 429 czekamy `delay`, a gdy jest dłuższy niż 5 min,
przerywamy przebieg (`RateLimited`), bo dalsze zapytania tylko przedłużają blokadę.
**Rekord gifa** (pola, które bierzemy): `id`, `type` (1 = wideo, 2 = obraz, pomijamy),
`urls{hd,sd,poster,thumbnail}`, `duration`, `width/height`, `hasAudio`, `hls`, `tags[]`,
`niches[]`, `contentType`, `sexuality[]`, `likes`, `views`, `createDate` (unix s),
`userName`, `verified`, `cta`/`promoted` (reklama, informacyjnie).
"""
from __future__ import annotations
import logging
import re
import time
from dataclasses import dataclass, field
from datetime import UTC, datetime
import httpx
log = logging.getLogger(__name__)
_BASE = "https://api.redgifs.com/v2"
#: Stałe UA i Origin: token jest do nich przywiązany (patrz docstring).
_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36"
),
"Accept": "application/json",
"Origin": "https://www.redgifs.com",
"Referer": "https://www.redgifs.com/",
}
_PAGE_MAX = 100
#: Pomiar 2026-09-14: przy ~3 zapytaniach/s po ~65 zapytaniach z rzedu RedGifs oddaje 429
#: z `delay` ~20 min. 1 zapytanie/s ma sie trzymac w limicie (do weryfikacji na backfillu).
_MIN_INTERVAL_S = 1.0
_RATE_LIMIT_BACKOFF_S = (30, 90, 180)
#: Dluzsze `delay` z ciala 429 nie czekamy w procesie, tylko przerywamy przebieg.
_RATE_LIMIT_MAX_WAIT_S = 300
_TAG_SLUG_RE = re.compile(r"[^a-z0-9]+")
@dataclass
class RawClip:
source: str
source_id: str
title: str | None
creator: str | None
creator_verified: bool
duration_sec: float | None
width: int | None
height: int | None
has_audio: bool
content_type: str | None
niches: list[str] = field(default_factory=list)
tags: list[str] = field(default_factory=list)
hd_url: str | None = None
sd_url: str | None = None
poster_url: str | None = None
thumb_url: str | None = None
hls: bool = False
likes: int = 0
views: int = 0
published_at: datetime | None = None
def tag_slug(name: str) -> str:
""""Big Tits" → "big-tits". Ta sama reguła dla RedGifs i (później) Pornhuba, żeby
afiniczność liczyła się na wspólnych slugach."""
return _TAG_SLUG_RE.sub("-", (name or "").strip().lower()).strip("-")[:64]
def map_gif(g: dict) -> RawClip | None:
"""Rekord API → RawClip. None dla obrazów, rekordów bez pliku i bez id."""
if not isinstance(g, dict) or g.get("type") not in (1, None):
return None
gid = str(g.get("id") or "").strip()
urls = g.get("urls") or {}
hd, sd = urls.get("hd"), urls.get("sd")
if not gid or not (hd or sd):
return None
created = g.get("createDate")
published = (
datetime.fromtimestamp(int(created), tz=UTC) if isinstance(created, (int, float)) and created > 0 else None
)
tags: list[str] = []
seen: set[str] = set()
for t in g.get("tags") or []:
s = tag_slug(str(t))
if s and s not in seen:
seen.add(s)
tags.append(s)
niches = [str(n)[:64] for n in (g.get("niches") or []) if n]
dur = g.get("duration")
return RawClip(
source="redgifs",
source_id=gid,
title=(g.get("description") or None),
creator=(g.get("userName") or None),
creator_verified=bool(g.get("verified")),
duration_sec=float(dur) if isinstance(dur, (int, float)) and dur > 0 else None,
width=g.get("width") if isinstance(g.get("width"), int) else None,
height=g.get("height") if isinstance(g.get("height"), int) else None,
has_audio=bool(g.get("hasAudio")),
content_type=(str(g["contentType"])[:32] if g.get("contentType") else None),
niches=niches,
tags=tags,
hd_url=hd,
sd_url=sd,
poster_url=urls.get("poster"),
thumb_url=urls.get("thumbnail"),
hls=bool(g.get("hls")),
likes=int(g.get("likes") or 0),
views=int(g.get("views") or 0),
published_at=published,
)
def _rate_limit_wait(r: httpx.Response, attempt: int) -> float:
"""Odstęp po 429: `Retry-After`, potem `error.delay` z ciała, potem stała drabinka."""
retry_after = r.headers.get("Retry-After")
if retry_after and retry_after.isdigit():
return float(retry_after)
try:
delay = (r.json() or {}).get("error", {}).get("delay")
if isinstance(delay, (int, float)) and delay > 0:
return float(delay)
except ValueError:
pass
return float(_RATE_LIMIT_BACKOFF_S[min(attempt, len(_RATE_LIMIT_BACKOFF_S) - 1)])
class RateLimited(RuntimeError):
"""429 mimo odczekania: wołający ma przerwać przebieg, nie iść do następnej niszy
(każde kolejne zapytanie tylko przedłuża blokadę)."""
class RedgifsClient:
"""Cienki klient z tokenem tymczasowym, odnowieniem na 401 i odczekiwaniem 429."""
def __init__(self, *, timeout: float = 30.0) -> None:
self._http = httpx.Client(headers=_HEADERS, timeout=timeout)
self._token: str | None = None
self._last_call = 0.0
def close(self) -> None:
self._http.close()
def _auth(self) -> str:
r = self._http.get(f"{_BASE}/auth/temporary")
r.raise_for_status()
tok = (r.json() or {}).get("token")
if not tok:
raise RuntimeError("redgifs: brak tokenu w /auth/temporary")
self._token = tok
return tok
def _get(self, path: str, params: dict | None = None) -> dict:
wait = _MIN_INTERVAL_S - (time.monotonic() - self._last_call)
if wait > 0:
time.sleep(wait)
tok = self._token or self._auth()
for attempt in range(1 + len(_RATE_LIMIT_BACKOFF_S)):
r = self._http.get(f"{_BASE}{path}", params=params, headers={"Authorization": f"Bearer {tok}"})
self._last_call = time.monotonic()
if r.status_code == 401 and attempt == 0:
tok = self._auth()
continue
if r.status_code == 429:
if attempt >= len(_RATE_LIMIT_BACKOFF_S):
raise RateLimited(f"redgifs: 429 na {path} po {attempt} odczekaniach")
wait = _rate_limit_wait(r, attempt)
if wait > _RATE_LIMIT_MAX_WAIT_S:
raise RateLimited(f"redgifs: 429 na {path}, zadany odstep {wait:.0f} s")
log.info("redgifs: 429 na %s, czekam %.0f s", path, wait)
time.sleep(wait)
continue
r.raise_for_status()
return r.json()
raise RuntimeError(f"redgifs: {path} nie odpowiedzial poprawnie")
# ---- listingi ---------------------------------------------------------------------
def latest(self, page: int = 1, count: int = _PAGE_MAX) -> list[dict]:
"""Globalne „najnowsze" (bez search_text)."""
return self._get("/gifs/search", {"order": "latest", "count": count, "page": page}).get("gifs") or []
def search(self, text: str, *, order: str = "latest", page: int = 1, count: int = _PAGE_MAX) -> list[dict]:
return self._get(
"/gifs/search", {"search_text": text, "order": order, "count": count, "page": page}
).get("gifs") or []
def niches(self, page: int = 1, count: int = _PAGE_MAX) -> dict:
"""`{"page","pages","total","niches":[{"id","name","gifs","subscribers","tags":[...]}]}`"""
return self._get("/niches", {"count": count, "page": page})
def niche_gifs(self, niche_id: str, *, order: str = "latest", page: int = 1, count: int = _PAGE_MAX) -> list[dict]:
return self._get(f"/niches/{niche_id}/gifs", {"order": order, "count": count, "page": page}).get("gifs") or []
def user_gifs(self, user: str, *, order: str = "latest", page: int = 1, count: int = _PAGE_MAX) -> list[dict]:
return self._get(f"/users/{user}/search", {"order": order, "count": count, "page": page}).get("gifs") or []
def gif(self, gif_id: str) -> dict | None:
return (self._get(f"/gifs/{gif_id}") or {}).get("gif")

View file

@ -1,25 +1,25 @@
"""Direct tube scrapers.
Każdy scraper hit'uje tube bezpośrednio HTTPm — różne tube'y to różne rate limit
Każdy scraper hit'uje tube bezpośrednio HTTPm, różne tube'y to różne rate limit
budgets, więc mogą iść równolegle. Wszystkie feedują sceny do tej samej
`Source(name=SCRAPER_SOURCE_NAME)` z external_id `f"{sitetag}:{url}"`. Resolver
mergeuje idempotentnie po tym kluczu.
Nazwa źródła: do 2026-06-07 brzmiała `"pornapp"` — myląca pozostałość po usuniętym
zewnętrznym porn-app API (sugerowała zależność od obcego serwisu, której NIE MA —
Nazwa źródła: do 2026-06-07 brzmiała `"pornapp"`, myląca pozostałość po usuniętym
zewnętrznym porn-app API (sugerowała zależność od obcego serwisu, której NIE MA,
to nasze własne direct-scrapery tubów). Przemianowana na `"tube-scraper"`; wiersz
`sources` zaktualizowany w DB (UPDATE name) więc cała historia ingest_runs została.
Search-based ścieżka (per performer name); category browse'ng przez `categoriesUrl`.
UWAGA — speculative scrapers: większość aggregator + special tubes (xmoviesforyou,
UWAGA, speculative scrapers: większość aggregator + special tubes (xmoviesforyou,
watchporn, siska, porn4days, porndish, xxxfreewatch, latestleaks, mypornerleak,
porndittcom, perverzija, fpoxxx, ...) ma URL templates + regex'y oparte na typowych
WordPress conventions. Wymagają post-deploy verification — gdy któryś nie zwraca
WordPress conventions. Wymagają post-deploy verification, gdy któryś nie zwraca
wyników, sprawdź real search HTML + popraw template/regex w odpowiednim pliku.
"""
# Umbrella Source.name dla wszystkich direct-scraperów (deep-crawl, browse-latest,
# performer-driven). Rename z legacy "pornapp" 2026-06-07 (mylące — nie ma zależności
# performer-driven). Rename z legacy "pornapp" 2026-06-07 (mylące, nie ma zależności
# od zewnętrznego porn-app API).
SCRAPER_SOURCE_NAME = "tube-scraper"
@ -27,7 +27,7 @@ from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.connectors.direct_scrapers.base import BaseDirectTubeScraper
from app.connectors.direct_scrapers.eporner import EpornerScraper
from app.connectors.direct_scrapers.fpoxxx import FpoxxxScraper
from app.connectors.direct_scrapers.hdporn92 import HDPorn92Scraper # noqa: F401 — kept for backref; disabled
from app.connectors.direct_scrapers.hdporn92 import HDPorn92Scraper # noqa: F401, kept for backref; disabled
from app.connectors.direct_scrapers.hqporner import HQPornerScraper
from app.connectors.direct_scrapers.latestleaks import LatestLeaksScraper
from app.connectors.direct_scrapers.latestpornvideo import LatestPornVideoScraper
@ -48,6 +48,12 @@ from app.connectors.direct_scrapers.galaxyporn import GalaxyPornScraper
from app.connectors.direct_scrapers.pornbusy import PornBusyScraper
from app.connectors.direct_scrapers.pornmike import PornMikeScraper
from app.connectors.direct_scrapers.kporner8 import KPorner8Scraper
from app.connectors.direct_scrapers.hentaimama import HentaiMamaScraper
from app.connectors.direct_scrapers.hentaigasm import HentaiGasmScraper
from app.connectors.direct_scrapers.hentaiplay import HentaiPlayScraper
from app.connectors.direct_scrapers.naughtymachinima import NaughtyMachinimaScraper
from app.connectors.direct_scrapers.rule34video import Rule34VideoScraper
from app.connectors.direct_scrapers.xanimeporn import XAnimePornScraper
from app.connectors.direct_scrapers.fullporner import FullPornerScraper
from app.connectors.direct_scrapers.watchporn import WatchPornScraper
from app.connectors.direct_scrapers.youperv import YoupervScraper
@ -55,21 +61,21 @@ from app.connectors.direct_scrapers.xhamster import XHamsterScraper
from app.connectors.direct_scrapers.xmoviesforyou import XMoviesForYouScraper
from app.connectors.direct_scrapers.xnxx import XnxxScraper
from app.connectors.direct_scrapers.xvideos import XVideosScraper
from app.connectors.direct_scrapers.xxxfreewatch import XxxFreeWatchScraper # noqa: F401 — kept for backref; delisted
from app.connectors.direct_scrapers.xxxfreewatch import XxxFreeWatchScraper # noqa: F401, kept for backref; delisted
from app.connectors.direct_scrapers.youporn import YouPornScraper
ALL_DIRECT_SCRAPERS: list[type[BaseDirectTubeScraper]] = [
# Existing 4 (verified, in production)
HQPornerScraper,
# HDPorn92Scraper — wyłączony 2026-05-18. Scene pages to SEO shell: ZERO player iframe
# HDPorn92Scraper, wyłączony 2026-05-18. Scene pages to SEO shell: ZERO player iframe
# (tylko happyleafmotion ads), JS hijackuje wszystkie kliki → `go.rmishe.com/smartpop/...`
# popunder redirect. Mobile WebView page-as-hoster pokazuje ad redirect zamiast video.
# 33,598 playback_sources mass-marked dead, 27,374 solo-orphan scenes deleted.
SxyLandScraper,
# ZeroDayXXScraper (0dayxx) — USUNIĘTY CAŁKOWICIE 2026-06-22 (user request). Orphan
# ZeroDayXXScraper (0dayxx), USUNIĘTY CAŁKOWICIE 2026-06-22 (user request). Orphan
# factory (0.1% canonical), zastępujemy lepszymi źródłami. Dane/pliki/extractor skasowane.
# Mainstream (URL templates well-known)
# PornHub + RedTube — USUNIĘTE CAŁKOWICIE 2026-06-22 (user request). Disabled od
# PornHub + RedTube, USUNIĘTE CAŁKOWICIE 2026-06-22 (user request). Disabled od
# 2026-05-12 (0.4% canonical match), zamrożone dane skasowane z DB, pliki scraperów
# i ekstraktory usunięte. Powód: skrócone amatorskie clipy, nigdy nie matchują studio.
XVideosScraper,
@ -78,60 +84,60 @@ ALL_DIRECT_SCRAPERS: list[type[BaseDirectTubeScraper]] = [
YouPornScraper,
PornTrexScraper,
EpornerScraper,
# Aggregators (WordPress-like ?s= search; speculative — verify post-deploy)
# XMoviesForYouScraper — wyłączony 2026-05-12 (post audit fix). 100% scen serwuje
# streamtape (DEAD_HOSTER_RE — malware drive-by .reg) + opcjonalnie playmogo/mixdrop.
# Aggregators (WordPress-like ?s= search; speculative, verify post-deploy)
# XMoviesForYouScraper, wyłączony 2026-05-12 (post audit fix). 100% scen serwuje
# streamtape (DEAD_HOSTER_RE, malware drive-by .reg) + opcjonalnie playmogo/mixdrop.
# Mixdrop zrebrandował na m1xdrop.bz, yt-dlp out-of-date, packer/JS extract = fail.
# Playmogo = DoodStream CAPTCHA. Porn-app sam olewa xmoviesforyou (brak handlera w
# jadx). 1,321 solo-orphan scen.
# WatchPornScraper — RE-ENABLE 2026-07-02, przeniesiony do ALL_BROWSE_SCRAPERS.
# WatchPornScraper, RE-ENABLE 2026-07-02, przeniesiony do ALL_BROWSE_SCRAPERS.
# Site przebudowany na KVS (DoodStream-CAPTCHA z 2026-05-12 zniknął); browse
# /latest-updates/ + flashvars get_file direct mp4 (extractor watchporn). Patrz watchporn.py.
# SiskaScraper — przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-20,
# bo search siski zepsuty site-side — `?s=` ignoruje query). Patrz siska.py.
# Porn4DaysScraper — wyłączony 2026-05-12 (post audit fix). 100% scen na streamtape
# SiskaScraper, przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-20,
# bo search siski zepsuty site-side, `?s=` ignoruje query). Patrz siska.py.
# Porn4DaysScraper, wyłączony 2026-05-12 (post audit fix). 100% scen na streamtape
# only (DEAD_HOSTER_RE blacklist - malware drive-by .reg downloads). SERVER1_URL =
# streamtape, brak SERVER2/SERVER3 backup. Porn-app sam olewa porn4days. 10,346
# solo-orphan scen.
# PornDishScraper — przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-24,
# PornDishScraper, przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-24,
# watchdog GOON-16: search `?s=` zamarzł 2026-05-07). WordPress → browse przez WP REST
# API (/wp-json/wp/v2/posts) jak perverzija: tytuł/data/thumb/studio(category)/tagi.
# XxxFreeWatchScraper — wyłączony 2026-05-18. 790 scen, 0% canonical match, 100% solo-orphan.
# XxxFreeWatchScraper, wyłączony 2026-05-18. 790 scen, 0% canonical match, 100% solo-orphan.
# Cloudflare 403 z VPS IP, mobile WebView teoretycznie działa ale 0/790 scen miało jakikolwiek
# match do TPDB/StashDB. Pure orphan factory. Solo scenes deleted, scraper disabled.
# LatestPornVideoScraper — przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22,
# LatestPornVideoScraper, przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22,
# user 1da0375e: search-driven nie brał feedu "latest" → stary zestaw w apce).
# LatestLeaksScraper — wyłączony 2026-05-12 (source quality report): 16,438 scen, 0.0%
# LatestLeaksScraper, wyłączony 2026-05-12 (source quality report): 16,438 scen, 0.0%
# canonical match. Slug-concat tytuły, brak studio/duration/date signali. Solo orphany
# usunięte (~15k scen).
MyPornerLeakScraper,
# Added 2026-05-12 (theporndude survey): jeden z 14 free tubes na liście który
# zwraca consistent search results. KVS engine, slug-aware scene URLs. Mostly
# orphan ingest (auto-screenshots, no canonical phash match — sprawdzone), ale
# orphan ingest (auto-screenshots, no canonical phash match, sprawdzone), ale
# może łapać sceny popularnych performerów których jeszcze nie mamy w TPDB.
# PornHat (pornhatcom) + PornDitt (porndittcom) — USUNIĘTE CAŁKOWICIE 2026-06-22
# PornHat (pornhatcom) + PornDitt (porndittcom), USUNIĘTE CAŁKOWICIE 2026-06-22
# (user request). Orphan factories (0.2% / weak-signal canonical match), zastępujemy
# lepszymi źródłami. Dane/pliki scraperów/extractory skasowane.
# Special
SxyPrnScraper,
# PerverzijaScraper — przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22,
# PerverzijaScraper, przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22,
# user request). Search `?s=` → 429, homepage JS-renderowane; browse przez WP REST API
# (/wp-json/wp/v2/posts) daje tytuł/datę/thumb/studio(category)/tagi. Playback embed-iframe.
# FpoxxxScraper — przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22,
# FpoxxxScraper, przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22,
# user request). fpo.xxx to KVS, nie WordPress → search `?s=` zwracał 0; browse z
# `/new-<n>/` daje listing tile (tytuł/thumb/duration). Playback i tak phone-side (KVS).
]
# Browse-mode scrapers — iterują `latest-vids` listing zamiast search-by-performer.
# Browse-mode scrapers, iterują `latest-vids` listing zamiast search-by-performer.
# Phash thumbnail fingerprint (waga 0.40 w composite scoring) auto-mergeuje do
# canonical (TPDB/StashDB) gdy tube hot-linkuje studio thumbnail. Schedulowane
# raz dziennie, pages 1-5. Patrz `_browse_base.BaseBrowseScraper` +
# `app/scheduler/browse_latest.py`.
#
# **Pilot results (2026-05-12):**
# - ShyfapScraper: 0/23 match (0%) — robi własne thumbnails ≠ canonical
# - ShyfapScraper: 0/23 match (0%), robi własne thumbnails ≠ canonical
# (phash Hamming 12-16). Plus rebranduje tytuły. **Wyłączony.**
# - FreshpornoScraper: 39/59 match (66%) — hot-linkuje studio thumbnaile
# - FreshpornoScraper: 39/59 match (66%), hot-linkuje studio thumbnaile
# (phash Hamming 0). Oryginalne tytuły + channels=studio 1:1. **Aktywny.**
from app.connectors.direct_scrapers.freshporno import FreshpornoScraper # noqa: E402
from app.connectors.direct_scrapers.porn00 import Porn00Scraper # noqa: E402
@ -147,6 +153,8 @@ from app.connectors.direct_scrapers.javflix import JavflixScraper # noqa: E402
from app.connectors.direct_scrapers.vjav import VjavScraper # noqa: E402
from app.connectors.direct_scrapers.supjav import SupjavScraper # noqa: E402
from app.connectors.direct_scrapers.neporn import NepornScraper # noqa: E402
from app.connectors.direct_scrapers.porndupe import PorndupeScraper # noqa: E402
from app.connectors.direct_scrapers.tubepornclassic import TubePornClassicScraper # noqa: E402
from app.connectors.direct_scrapers.superporn import SuperpornScraper # noqa: E402
from app.connectors.direct_scrapers.eporner_api import EpornerApiScraper # noqa: E402
from app.connectors.direct_scrapers.xvideos_browse import XVideosBrowseScraper # noqa: E402
@ -154,62 +162,93 @@ from app.connectors.direct_scrapers.xvideos_browse import XVideosBrowseScraper
ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [
PerverzijaScraper,
PornDishScraper,
# WatchPornScraper — re-enabled 2026-07-02 jako browse (site przebudowany na KVS,
# WatchPornScraper, re-enabled 2026-07-02 jako browse (site przebudowany na KVS,
# DoodStream-CAPTCHA zniknął). Browse /latest-updates/: og:title, JSON-LD duration/
# uploadDate, /models/ performerzy, /tags/ tagi, /categories/ studio. Playback KVS
# get_file direct mp4 (extractor watchporn, VPS-side, token nie IP-bound).
WatchPornScraper,
# YoupervScraper — dodany 2026-07-26 (ocena: orphan-risk LOW, najlepszy kandydat od
# YoupervScraper, dodany 2026-07-26 (ocena: orphan-risk LOW, najlepszy kandydat od
# hqporner). Ripy paysite, tytuły `Studio - Performer - Title` (71% próbki), performerzy
# z linków, duration + release_date ISO, ~60-70 scen/dzień. Playback: direct mp4 bez
# tokena (extractor youpervcom, VPS-side, CDN pilnuje tylko Referera).
YoupervScraper,
# GalaxyPornScraper — dodany 2026-07-26 (ocena 5/5). Ripy paysite, obsada w
# GalaxyPornScraper, dodany 2026-07-26 (ocena 5/5). Ripy paysite, obsada w
# wydzielonym `<div id="video-actors">` (zero pollution), studio+data z prefiksu
# tytułu, paginacja zdrowa. Duration liczone z thumbnail.vtt playera (nie ma go
# w HTML, a NULL ukryłby sceny pod filtrem min_duration_sec). Playback: iframe
# seekplayer → istniejący silnik, HLS przez /proxy/hls.
GalaxyPornScraper,
# PornBusyScraper — dodany 2026-07-27 (ocena 4.5/5). Metadane w `itemprop` (duration/
# PornBusyScraper, dodany 2026-07-27 (ocena 4.5/5). Metadane w `itemprop` (duration/
# uploadDate/thumb/desc) + obsada w `<div id="video-actors">` bez pollution. Homepage
# pagination ZEPSUTA (str. 1/2/3 identyczne) → listing z sitemapy, newest-first.
# Brak pola studio. Playback: loadvid/seekplayer/zpi ≈ 69% katalogu.
PornBusyScraper,
# PornMikeScraper — dodany 2026-07-27 (ocena 4/5). Metadane w CAŁOŚCI z JSON-LD
# PornMikeScraper, dodany 2026-07-27 (ocena 4/5). Metadane w CAŁOŚCI z JSON-LD
# VideoObject, obsada bez pollution (1 link = realna obsada), 82% performerów z
# refem tpdb/stashdb, 19-20/20 kanałów znamy jako studia. Bramka na obsadę (23%
# katalogu bez actor[]). Tagi TYLKO z JSON-LD (HTML ma 38+22 linków nav/sidebar).
# UWAGA: to klipy 5-12 min, nie pełne sceny — ~80% katalogu to jednak studia bez
# UWAGA: to klipy 5-12 min, nie pełne sceny, ~80% katalogu to jednak studia bez
# żadnego pokrycia u nas, czyli nowa podaż. Playback: gołe mp4, zero proxy.
PornMikeScraper,
# KPorner8Scraper — dodany 2026-07-27. Mały, ale wysokiej jakości: ORYGINALNE
# KPorner8Scraper, dodany 2026-07-27. Mały, ale wysokiej jakości: ORYGINALNE
# tytuły studyjne (nie SEO-rewrite) + realne 4K + przenośny stream, więc sceny
# dobrze siadają na kanon. Katalog jest ZAKORKOWANY: sitemapa i `/watch/*.html`
# są za Cloudflare, paginacja zwraca stronę 1, load-more chce logowania → widać
# tylko ~115 scen z homepage. Detale pobieramy przez `?link1=watch&id=`. Bramka
# na obsadę przepuszcza 62/115, a z nich 97% performerów ma ref tpdb/stashdb.
# Pilot: seen 62, new 25, updated 37 — 60% dopina się do scen, które już mamy.
# Pilot: seen 62, new 25, updated 37, 60% dopina się do scen, które już mamy.
KPorner8Scraper,
# FullPornerScraper — dodany 2026-07-27. PEŁNE sceny (mediana ~33 min, zero poniżej
# HentaiMamaScraper — dodany 2026-08-04, PIERWSZE źródło wertykału hentai.
# UWAGA na kryterium: dla animacji NIE ISTNIEJE kanon (TPDB/StashDB nie mają
# rysowanych postaci), więc każda scena stąd jest orphanem i to jest POPRAWNE.
# Nie stosujemy tu ani bramki na obsadę (nie ma obsady), ani miary canonical match.
# Jakość mierzymy kompletem seria + odcinek + gatunki. Jednostka = ODCINEK.
# Motyw z rodziny Dooplay, ale DooplayConnector NIE pasuje (brak data-fl-source).
# Playback: extractor `hentaimama`, POST admin-ajax za Cloudflare → Bright Data.
HentaiMamaScraper,
# XAnimePornScraper — dodany 2026-08-04, drugie źródło wertykału hentai.
# NIE re-host hentaimamy: wspólny odcinek (Shoujo Ramune 7) leży na obu, ale to inne
# pliki — tu jest jawny wybór 1080p (222 MB) / 720p (84 MB), tam jedna jakość bez
# etykiety. Paginacja strony głównej ZEPSUTA (str. 2-3 = str. 1, jak przy 8kporner),
# więc listing z dwóch archiwów: /uncensored/ i /censored/, które stronicują poprawnie.
# Playback bez Cloudflare i bez proxy, w odróżnieniu od hentaimamy.
XAnimePornScraper,
# HentaiGasmScraper — dodany 2026-08-04, trzecie zrodlo wertykalu i TRZECI
# niezalezny host (hgasm1.com). Metadane w CALOSCI z RSS-a, bo strona odcinka nie ma
# daty w zadnym standardowym miejscu, a /genre/ to 38 linkow chmury z sidebara.
# Playback najprostszy z calej trojki: JWPlayer z gotowym file: wprost w HTML.
HentaiGasmScraper,
# HentaiPlayScraper — dodany 2026-08-04, CZWARTY niezalezny host wertykalu
# (hentaiplanet.info). Metadane z RSS-a, bo /genre/ w HTML to 2084 linki chmury,
# najwiecej w calym wertykale. Playback: mp4 wprost w HTML, bez CF i bez proxy.
HentaiPlayScraper,
# NaughtyMachinimaScraper — dodany 2026-08-13, drugie zrodlo 3D i najzdrowsza
# jednostka tresci wertykalu (mediana 289s, zero ponizej minuty).
NaughtyMachinimaScraper,
# Rule34VideoScraper — dodany 2026-08-13, pierwsze zrodlo 3D (SFM/Blender).
# Bramka >=60s odrzuca ~47% katalogu (mediana 68s), bo apka i tak filtruje
# ponizej minuty. Tagi TYLKO z class=tag_item, `class=button` to chmura.
Rule34VideoScraper,
# FullPornerScraper, dodany 2026-07-27. PEŁNE sceny (mediana ~33 min, zero poniżej
# 10 min), ~140-240 dziennie, listing chronologiczny mimo nagłówka „Featured".
# Playback najtrwalszy w portfolio: mp4 do 1080p bez tokenu i bez wygasania.
# Metadane cienkie: BRAK studia, mediana 3 tagi, tytuły przepisane pod SEO — za to
# Metadane cienkie: BRAK studia, mediana 3 tagi, tytuły przepisane pod SEO, za to
# data jako unix timestamp i długość co do sekundy. Dry-run resolvera na 120
# scenach: 31% dopina się do tego, co mamy (composite waży performera + długość),
# przeciek duplikatów ~15% po odjęciu tła kontrolnego. Bramka na obsadę, bo sceny
# bez performera scaliły się 0/23. phash liczony celowo — to jedyny sygnał dla
# bez performera scaliły się 0/23. phash liczony celowo, to jedyny sygnał dla
# bulk_dedup na te duplikaty, po tytule nie pójdzie.
FullPornerScraper,
# SexuScraper (`sexu.py`) — ZBUDOWANY 2026-07-27, ale NIE ingestujemy. Metadane ma
# SexuScraper (`sexu.py`), ZBUDOWANY 2026-07-27, ale NIE ingestujemy. Metadane ma
# najlepsze z całej serii (studio 97%, 96% performerów z refem tpdb/stashdb, dokładna
# długość, świeżość w godzinach), tylko jednostka treści jest zła: to **klipy 5-10
# minut**, nie sceny. Mediana 571 s na stronie 30 i 459 s na 200, ZERO filmów ≥20 min
# w całym katalogu. Pilot na 240 scenach: 1 (jeden) canonical match, bo długość i
# tytuł, czyli sygnały resolvera, są przepisane pod tube. Przy ~340 uploadach dziennie
# to ~10k nierozwiązywalnych wierszy miesięcznie i klip obok pełnej sceny na stronie
# performera. Scraper i extractor zostają w repo — gdyby serwis kiedyś zaczął wrzucać
# performera. Scraper i extractor zostają w repo, gdyby serwis kiedyś zaczął wrzucać
# pełne sceny, wystarczy dopisać klasę z powrotem tutaj.
# FullVideosPornScraper — dodany 2026-07-27 (ocena 3/5, ADD Z ZASTRZEŻENIAMI).
# FullVideosPornScraper, dodany 2026-07-27 (ocena 3/5, ADD Z ZASTRZEŻENIAMI).
# = sextu.com pod nową marką (NIE klon fullmovies.xxx). BRAMKA NA OBSADĘ: 65-75%
# katalogu nie ma performera i weszłoby jako puste orphany, więc ingestujemy tylko
# sceny z ≥1 performerem (~25-35% katalogu, ale 88-89% nazwisk canonical u nas).
@ -224,105 +263,114 @@ ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [
XnxxBrowseScraper,
FreshpornoScraper,
FpoxxxScraper,
# LatestPornVideoScraper — browse od 2026-06-22 (user 1da0375e: search-driven
# LatestPornVideoScraper, browse od 2026-06-22 (user 1da0375e: search-driven
# nie brał feedu "latest"). Listing card: tytuł (z embedded "<Studio> YY MM DD"),
# thumb (studio+date w nazwie), category-* jako tag. Performerów listing nie ma
# czysto (brak `actors-*`) → puste, dorabia canonical-merge. Playback: luluvid
# iframe → extractor latestpornvideocom (_embed_iframe) → telefon resolwuje.
LatestPornVideoScraper,
# SiskaScraper — re-enabled 2026-06-20 jako browse (user fa4083a2). Search siski
# SiskaScraper, re-enabled 2026-06-20 jako browse (user fa4083a2). Search siski
# zepsuty site-side (`?s=` ignoruje query), więc latest-browse z `/page/<n>/`.
# Komplet metadanych z kafelka listingu (tytuł/duration/thumb/performer/studio/
# kategoria). Playback: playmogo + luluvid → telefon resolwuje phone-side.
SiskaScraper,
# PornXPScraper — pilot 2026-05-17 (20 scen): studio 100%, performer 95%,
# PornXPScraper, pilot 2026-05-17 (20 scen): studio 100%, performer 95%,
# release_date 100%, duration 100%, stream_url 100%, phash 100%. Najlepsze
# sygnały spośród browse-mode scraperów. Stream direct mp4 (sv.porn-xp.com)
# 360/720 quality. Release year z `Released: <year>` na detail.
PornXPScraper,
# Porn00Scraper — pilot 2026-05-17 (16 scen): brak studio (0%) + brak release
# Porn00Scraper, pilot 2026-05-17 (16 scen): brak studio (0%) + brak release
# date (0%) ALE performer 100%, duration 100%, stream_url 100% (KVS video_alt_url
# 720p). Tytuł zachowuje studio prefix ("Studio Title - Scene Name") → title
# fuzzy match (rapidfuzz token_set_ratio) może załapać canonical. Monitorować.
Porn00Scraper,
# PornDoeScraper — dołączony 2026-05-21 (theporndude audit). Każda scena ma
# PornDoeScraper, dołączony 2026-05-21 (theporndude audit). Każda scena ma
# kompletny JSON-LD VideoObject: title + uploadDate + duration + named studio
# (producer/publisher) + named performers (actor[]) + thumbnail. Najbogatsze
# strukturalne metadane spośród browse scraperów — composite fuzzy match ma
# strukturalne metadane spośród browse scraperów, composite fuzzy match ma
# komplet sygnałów. Phash hit-rate niski (własne crop-thumbnaile), studio +
# performer + date + duration nadrabiają.
PornDoeScraper,
# YesPornVipScraper — dołączony 2026-05-27 (user audit). JSON-LD VideoObject
# YesPornVipScraper, dołączony 2026-05-27 (user audit). JSON-LD VideoObject
# + `<meta property="video:duration|release_date|tag">` per scena (Goon ma
# duration w sekundach gotowe + ISO 8601 release_date z timezone). Studio +
# performerzy z `btn gold` linków (`/channels/<slug>/` + `/models/<slug>/`).
# 941k organic monthly (SE Ranking, comparable z porndoe 731k / porntrex 790k).
# Scraper-of-paysites (DogFart / HardX / TeamSkeet / Vixen) — wysokie expected
# Scraper-of-paysites (DogFart / HardX / TeamSkeet / Vixen), wysokie expected
# canonical match dla studio scenes. Korekta: theporndude scorecard rank 26
# ('yespornvip.com', score -0.5, auth wall) dotyczył **innej domeny** — pdude.link
# ('yespornvip.com', score -0.5, auth wall) dotyczył **innej domeny** pdude.link
# redirect do porndudecams affiliate. Prawdziwa kanoniczna domena to TLD `.vip`.
YesPornVipScraper,
# FullmoviesScraper + HDPornGGScraper — dołączone 2026-06-01. KVS engine (sponsor_groups
# FullmoviesScraper + HDPornGGScraper, dołączone 2026-06-01. KVS engine (sponsor_groups
# stack, `/videos/<slug>/` + `/latest-updates/`). Studio teraz z PREFIKSU tytułu
# ("Studio - Scene") — sidebar `/networks/` listował WSZYSTKIE sieci, więc pierwszy match
# ("Studio - Scene"), sidebar `/networks/` listował WSZYSTKIE sieci, więc pierwszy match
# zawsze Brazzers (mis-attribution, dlatego nigdy nie były włączone). Niosą paysite studio
# content (TeamSkeet/Dad Crush/Brazzers/...) z title+performer+duration → composite fuzzy.
# Nawet bez canonical match: grywalny content z inferred tagami (mission: daily tagged ingest).
FullmoviesScraper,
HDPornGGScraper,
# EpornerApiScraper — dołączony 2026-06-03 (Faza 2b alternatywa). eporner detail to
# JS-heavy KVS bez SSR metadanych (jak porntrex/hqporner — odrzucone), ALE eporner ma
# EpornerApiScraper, dołączony 2026-06-03 (Faza 2b alternatywa). eporner detail to
# JS-heavy KVS bez SSR metadanych (jak porntrex/hqporner, odrzucone), ALE eporner ma
# publiczne JSON API (api/v2/video/search): 1 call = 100 filmów z title+length_sec+
# keywords+added+thumb. ~100k filmów, deep-crawl przez crawl_page() (API, bez detail-fetch).
EpornerApiScraper,
# XVideosBrowseScraper — dołączony 2026-06-03. SSR JSON-LD (duration/title/uploadDate)
# XVideosBrowseScraper, dołączony 2026-06-03. SSR JSON-LD (duration/title/uploadDate)
# + page-parse /models/ (performerzy) + /tags/. Sample: median ~10.5min, 93% ≥3min.
# Mega-katalog ~13M → deep_crawl._PAGE_CAP["xvideoscom"]=1800 (~50k najnowszych), nie
# full-crawl. (youporn pominięty — JSON-LD bez actor/keywords, scene-perf/tagi = nav A-Z.)
# full-crawl. (youporn pominięty, JSON-LD bez actor/keywords, scene-perf/tagi = nav A-Z.)
XVideosBrowseScraper,
# HQFapScraper — PlayTube CMS (re-uploader pornhd.pet). Usunięty 2026-06-25 gdy CAŁA
# HQFapScraper, PlayTube CMS (re-uploader pornhd.pet). Usunięty 2026-06-25 gdy CAŁA
# biblioteka CDN serwowała `/upload/videos/video_down.mp4` stub; PRZYWRÓCONY 2026-07-07
# bo strona wróciła na CDN vstor.top z realnymi plikami (portable cross-IP), user request.
# HQFapScraper — ZDJĘTY 2026-08-02 (druga i ostateczna deaktywacja). Strona wdrożyła
# HQFapScraper, ZDJĘTY 2026-08-02 (druga i ostateczna deaktywacja). Strona wdrożyła
# Cloudflare z JS-challenge na /watch/: 403 z VPS, z residential IP przy pełnych
# nagłówkach przeglądarki ORAZ przez Bright Data ISP proxy — czyli blokada nie jest
# nagłówkach przeglądarki ORAZ przez Bright Data ISP proxy, czyli blokada nie jest
# po IP, tylko wymaga wykonania JS. WebView też nie przechodzi automatycznie (user
# widzi challenge: zgłoszenia 60576a84 / 244131ba / 92e9133e). Telemetria: ZERO prób
# odtworzenia od 27.07. Utrzymanie wymagałoby headless-przeglądarki na VPS —
# odtworzenia od 27.07. Utrzymanie wymagałoby headless-przeglądarki na VPS,
# nieproporcjonalne, decyzja: usunąć (jak 4k69 i pierwsze usunięcie hqfap 2026-06-25).
# HQPornerBrowseScraper — dołączony 2026-07-26. hqporner był TYLKO w
# HQPornerBrowseScraper, dołączony 2026-07-26. hqporner był TYLKO w
# ALL_DIRECT_SCRAPERS (search po performerze), więc świeże sceny wchodziły wyłącznie
# przy trafieniu na znanego performera → 0 nowych scen w 7 dni (user-report). Browse
# (listing / + ?p=N) daje ingest niezależny od stanu performerów. external_id ten sam
# co w search (`hqpornercom:<url>`), więc obie ścieżki trafiają w tę samą scenę.
HQPornerBrowseScraper,
# FourK69Scraper — USUNIĘTY 2026-06-25 (ten sam stub), NIE sprawdzany ponownie.
# JavflixScraper — JAV vertical (osobna sekcja). origin tube:javflix jest w
# PorndupeScraper, dodany 2026-09-05. Re-uploader scen studyjnych (Nubile, NF Busty,
# Family Strokes), 87% katalogu nowe dla nas, trafia w kanon. Chodzenie po id
# (listing = catch-all), dlugosc i data z api.ok.ru przy ingescie.
PorndupeScraper,
# TubePornClassicScraper, dodany 2026-09-07 (user request). Retro/vintage, TXXX
# (silnik vjav): listing to JSON API z pelnymi metadanymi (60 scen na request, bez
# fetchu per scena), wyzwanie 429 przez ciasteczko. Obsada u ~46% scen, studiow brak.
# Stream HLS przez _txxx.extract_hls + /proxy/hls.
TubePornClassicScraper,
# FourK69Scraper, USUNIĘTY 2026-06-25 (ten sam stub), NIE sprawdzany ponownie.
# JavflixScraper, JAV vertical (osobna sekcja). origin tube:javflix jest w
# JAV_ORIGINS → list_scenes wyklucza je z głównego feedu (tylko zakładka JAV).
JavflixScraper,
# VjavScraper — JAV vertical (osobna sekcja, origin tube:vjav w JAV_ORIGINS).
# VjavScraper, JAV vertical (osobna sekcja, origin tube:vjav w JAV_ORIGINS).
# TXXX network: sitemap id-walk (newest=max id) + JSON metadata API. Stream
# videofile.php → get_file HLS (portable, /proxy/hls passthrough). RE 2026-07-10.
VjavScraper,
# SupjavScraper — JAV vertical (osobna sekcja, origin tube:supjav w JAV_ORIGINS).
# SupjavScraper, JAV vertical (osobna sekcja, origin tube:supjav w JAV_ORIGINS).
# CF-blokuje VPS → browse homepage przez Bright Data proxy. Embed-aggregator:
# data-link reverse-hex → lk1.supremejav.com → hoster (extractor supjav). RE 2026-07-10.
SupjavScraper,
# NepornScraper — dołączony 2026-06-10 (user request). KVS engine (jak freshporno/
# NepornScraper, dołączony 2026-06-10 (user request). KVS engine (jak freshporno/
# porn00), /latest-updates/N/. JSON-LD (title+desc+uploadDate+thumb) + video:duration
# meta + /models/ performerzy + /categories/ tagi. Brak studio (tytuł bywa
# "- HardX Update - ..." — fuzzy match po tytule). Resolve server-side _kvs,
# "- HardX Update - ...", fuzzy match po tytule). Resolve server-side _kvs,
# finalny remote_control.php portable cross-IP.
NepornScraper,
# SuperpornScraper — dołączony 2026-06-10 (user request). superporn blokuje VPS IP
# SuperpornScraper, dołączony 2026-06-10 (user request). superporn blokuje VPS IP
# twardym CF 403 (każda impersonacja TLS), więc ingest HTML idzie przez Bright Data
# ISP proxy (BRIGHTDATA_PROXY_URL, ryczałt nie per-GB). Pierwszy scraper z proxy — `_proxy` w
# ISP proxy (BRIGHTDATA_PROXY_URL, ryczałt nie per-GB). Pierwszy scraper z proxy, `_proxy` w
# _browse_base. JSON-LD (title+desc+uploadDate+thumb+duration) + chipy pornstar/
# kategorie. Playback IP-bound → WebView (extractor superporncom → _vps_blocked_fallback).
# Bez proxy: scraper no-op (pusty iterator).
SuperpornScraper,
# porntrex/hqporner/youporn — NIE: KVS/JS bez SSR duration → niewidoczne orphany (2026-06-03).
# ShyfapScraper — wyłączony 2026-05-12 (pilot fail, 0% match — orphan factory).
# porntrex/hqporner/youporn, NIE: KVS/JS bez SSR duration → niewidoczne orphany (2026-06-03).
# ShyfapScraper, wyłączony 2026-05-12 (pilot fail, 0% match, orphan factory).
]
__all__ = [

View file

@ -1,15 +1,15 @@
"""BaseBrowseScraper — latest-vids browse mode (vs search-by-performer).
"""BaseBrowseScraper, latest-vids browse mode (vs search-by-performer).
Wzorzec: tube'y typu shyfap/freshporno/porn00/fullmovies/pornxp mają bogatą
metadata (title, studio, performers, tags, duration, release_date, description)
na detail page'u — wystarczy do canonical fuzzy match w resolverze. Browse mode
na detail page'u, wystarczy do canonical fuzzy match w resolverze. Browse mode
iteruje "latest" page (sorted by upload date) i fetchuje detail per scene.
Różnica vs `BaseSearchScraper`:
- **search**: tube wyszukuje sceny po performer name (dla performer-driven
backfill). Wymaga znanego performera.
- **browse**: tube listuje newest scenes (latest-vids endpoint). Nie wymaga
żadnego query — chodzi o świeże sceny independent of performer state.
żadnego query, chodzi o świeże sceny independent of performer state.
Browse jest komplementarny do search:
- search łapie sceny dla **znanych performerów** (TPDB/StashDB → tube)
@ -67,15 +67,15 @@ class BaseBrowseScraper(BaseDirectTubeScraper, abc.ABC):
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
"""Parsuj scene detail HTML → RawScene z metadata.
Zwraca None gdy scena niedostępna / parse fail — caller pominie ten URL,
Zwraca None gdy scena niedostępna / parse fail, caller pominie ten URL,
nie aborti całe browse."""
def crawl_page(self, page: int) -> list[RawScene] | None:
"""Crawl JEDNEJ strony listingu → lista RawScene. Wspólne dla browse_latest
(top-N) i deep_crawl (kursor). Zwraca:
None — transient fetch-fail listingu (caller: stop, NIE oznaczaj exhausted),
[] — pusty listing = koniec katalogu (caller: exhausted),
[...] — sceny z tej strony.
None , transient fetch-fail listingu (caller: stop, NIE oznaczaj exhausted),
[] pusty listing = koniec katalogu (caller: exhausted),
[...], sceny z tej strony.
API-based subclasses (np. EpornerApiScraper) override'ują crawl_page bezpośrednio
(call API zamiast listing→detail). HTML browse subclasses dostarczają
@ -120,7 +120,7 @@ class BaseBrowseScraper(BaseDirectTubeScraper, abc.ABC):
break
yield from scenes
# Stub `search()` — BaseDirectTubeScraper wymaga implementacji. Dla browse-only
# Stub `search()`, BaseDirectTubeScraper wymaga implementacji. Dla browse-only
# tubes nie supportujemy performer-driven search; zwracamy pusty iterator. Tube'y
# które chcą *oba* tryby mogą override'ować search() osobno.
def search(
@ -148,7 +148,7 @@ def compute_thumbnail_phash(thumbnail_url: str, *, referer: str | None = None, t
Format pasuje do `SceneFingerprint.value` w DB (TPDB/StashDB importują ten sam
8x8 phash). Resolver Path 3 `find_by_phash_within` matchuje Hamming ≤5 (default).
Wymaga lazy importu `imagehash`/`PIL` — żeby moduł browse_base importował się
Wymaga lazy importu `imagehash`/`PIL`, żeby moduł browse_base importował się
nawet gdy te lib-y są niedostępne (graceful degradation: phash=None → resolver
spadnie do composite scoring, jak gdyby fingerprintu nie było).
"""
@ -169,7 +169,7 @@ def compute_thumbnail_phash(thumbnail_url: str, *, referer: str | None = None, t
return None
img = Image.open(io.BytesIO(r.content))
# phash domyślnie hash_size=8 → 64-bit hash → 16 hex chars. Mode 'L' (greyscale)
# robi to wewnętrznie, ale niektóre webp/animated mogą mieć multi-frame —
# robi to wewnętrznie, ale niektóre webp/animated mogą mieć multi-frame,
# convert() bierze pierwszą klatkę, którą imagehash i tak zredukuje do grey.
return str(imagehash.phash(img.convert("RGB")))
except Exception as e:
@ -183,7 +183,7 @@ def meta_content(html: str, *, property: str | None = None, name: str | None = N
Standardowy helper dla scraperów które używają OpenGraph / ya:ovs / itp.
Cache compiled regex w module-scope dict (te same selectory powtarzają się).
NB: separate patterns dla `"..."` i `'...'` content quote — wcześniej jeden
NB: separate patterns dla `"..."` i `'...'` content quote, wcześniej jeden
`[^"\']*` regex tnął title po wewnętrznym apostrofie (np. `<meta content="She's So Insatiable">`
→ `She`, bug-report 2026-05-20). Teraz matchujemy dokładnie ten sam quote co opening.
"""
@ -191,8 +191,8 @@ def meta_content(html: str, *, property: str | None = None, name: str | None = N
if key not in _META_RE_CACHE:
attr = "property" if property else "name"
val = re.escape(property or name or "")
# double-quoted content (HTML standard) — preferred
# Pattern: <meta property="X" content="...inner..." > — inner allows apostrophes
# double-quoted content (HTML standard), preferred
# Pattern: <meta property="X" content="...inner..." > , inner allows apostrophes
_META_RE_CACHE[key] = re.compile(
rf'<meta[^>]+{attr}=["\']{val}["\'][^>]*?content="([^"]*)"'
rf'|<meta[^>]+{attr}=["\']{val}["\'][^>]*?content=\'([^\']*)\'',

View file

@ -1,4 +1,4 @@
"""BasePlayTubeScraper — wspólna baza dla tube'ów na PlayTube CMS (hqfap, 4k69).
"""BasePlayTubeScraper, wspólna baza dla tube'ów na PlayTube CMS (hqfap, 4k69).
Platforma rozpoznawalna po: `/watch/<slug>_<id>.html`, sitemap index
`/sitemaps/videos/sitemap-N.xml` (z `<lastmod>`), JSON-LD VideoObject na detail
@ -9,9 +9,9 @@ Listing NIE paginuje się GET-em (PlayTube doładowuje AJAX-em `aj/load-more/`),
więc crawl_page buduje katalog z sitemapów (sort lastmod desc = newest first)
i tnie na strony po `_PAGE_SIZE`. Działa dla browse_latest (pages 1-5) i
deep_crawl (kursor do końca katalogu). Minus: sitemap laguje ~dobę za
najświeższymi uploadami — akceptowalne przy dziennym harmonogramie.
najświeższymi uploadami, akceptowalne przy dziennym harmonogramie.
Subclass ustawia `base_url` + (opcjonalnie) override'uje `_pick_studio()` —
Subclass ustawia `base_url` + (opcjonalnie) override'uje `_pick_studio()`,
PlayTube nie ma strukturalnego pola studio na scenie, studio siedzi w
kategoriach (hqfap: suffix " Clips"; 4k69: nazwa z listy /studios).
@ -109,7 +109,7 @@ def _extract_video_object(html: str) -> dict | None:
class BasePlayTubeScraper(BaseBrowseScraper):
base_url: str # np. "https://hqfap.com" — subclass ustawia
base_url: str # np. "https://hqfap.com", subclass ustawia
def __init__(self) -> None:
super().__init__()
@ -123,9 +123,9 @@ class BasePlayTubeScraper(BaseBrowseScraper):
def _pick_studio(self, category_names: list[str]) -> str | None:
return None
# crawl_page override (jak EpornerApiScraper) — listing nie jest stronicowalny
# crawl_page override (jak EpornerApiScraper), listing nie jest stronicowalny
# przez GET, źródłem paginacji jest sitemap. _listing_url/_extract_scene_urls
# nieużywane, ale abstrakcyjne — dostarczamy no-op implementacje.
# nieużywane, ale abstrakcyjne, dostarczamy no-op implementacje.
def _listing_url(self, page: int) -> str: # pragma: no cover - nieużywane
return f"{self.base_url}/sitemap.xml"
@ -156,7 +156,7 @@ class BasePlayTubeScraper(BaseBrowseScraper):
sm = browser_get(sm_url, timeout=self._timeout)
sm.raise_for_status()
except Exception as e:
# Brak jednego sitemapa ≠ fail całości — reszta katalogu wystarczy.
# Brak jednego sitemapa ≠ fail całości, reszta katalogu wystarczy.
log.warning("%s: sitemap fetch failed %s: %s", self.sitetag, sm_url, e)
continue
for block in _URL_BLOCK_RE.findall(sm.text):
@ -227,7 +227,7 @@ class BasePlayTubeScraper(BaseBrowseScraper):
thumbnail_url = video.get("thumbnailUrl") or None
# Pille: pornstar → performer; category → studio (hook `_pick_studio`)
# albo tag. Ocenzurowane nazwy (`Te***`) pomijamy — gwiazdki to nie dane.
# albo tag. Ocenzurowane nazwy (`Te***`) pomijamy, gwiazdki to nie dane.
performers: list[RawPerformer] = []
category_names: list[str] = []
seen_perf: set[str] = set()
@ -275,7 +275,7 @@ class BasePlayTubeScraper(BaseBrowseScraper):
if ph:
fingerprints.append(RawFingerprint(kind="phash", value=ph))
# Stream: JSON-LD contentUrl wygasa (token time-bound) — NIE zapisujemy
# Stream: JSON-LD contentUrl wygasa (token time-bound), NIE zapisujemy
# stream_url; extractor per-sitetag resolvuje świeży on-demand.
playback_sources = [
RawPlaybackSource(

View file

@ -1,20 +1,20 @@
"""BaseSearchScraper — shared search-page HTML scraping logika.
"""BaseSearchScraper, shared search-page HTML scraping logika.
Wzorzec stosowany przez wszystkie tube'y discovery scrapers:
1. Build search URL z `_search_url_template` (formatowane query+page).
2. Fetch HTML curl_cffi.
3. Match `_scene_url_re` (regex z grupą `url` lub group(1) jako scene URL,
opcjonalnie `slug` lub `id` jako tytuł source).
4. Filtruj wyniki po query tokens (slug musi zawierać ≥1 token z query) —
4. Filtruj wyniki po query tokens (slug musi zawierać ≥1 token z query),
fuzzy search tube'ów często zwraca niezwiązane wyniki.
5. Yield RawScene z `external_id=f"{sitetag}:{scene_url}"`.
Subclass override:
- `sitetag: str` — np. "pornhubcom"
- `_search_url_template: str` — z `{query}` i `{page}` placeholderami
- `_scene_url_re: re.Pattern[str]` — regex z named group `url` (scene URL)
- `_title_from_match(match) -> str` — opcjonalny override (default: derive z URL slug)
- `_token_filter_text(match) -> str` — co testować na query tokens (default: cała URL)
- `sitetag: str`, np. "pornhubcom"
- `_search_url_template: str`, z `{query}` i `{page}` placeholderami
- `_scene_url_re: re.Pattern[str]`, regex z named group `url` (scene URL)
- `_title_from_match(match) -> str`, opcjonalny override (default: derive z URL slug)
- `_token_filter_text(match) -> str`, co testować na query tokens (default: cała URL)
"""
from __future__ import annotations
@ -30,7 +30,7 @@ from app.extractors import browser_get
# Image src extraction: matches src, data-src, data-original, data-lazy-src, data-lazy
# (lazy-load lib variants). Wymaga rozszerzenia obrazka żeby ograniczyć false positives
# (sprite icons, spinners) — JPG/PNG/WEBP są ~ jedynymi formatami które tube'y używają
# (sprite icons, spinners), JPG/PNG/WEBP są ~ jedynymi formatami które tube'y używają
# dla scene thumbnails.
_IMG_SRC_RE = re.compile(
r'<img[^>]+(?:src|data-src|data-original|data-lazy-src|data-lazy)=["\']'
@ -53,7 +53,7 @@ class BaseSearchScraper(BaseDirectTubeScraper):
#: Regex matchujący scene URL w search HTML. Wymagana grupa `url` (full scene URL),
#: opcjonalna grupa `slug` (do title derivation gdy dostępny w URL).
_scene_url_re: re.Pattern[str] = re.compile(r"$^") # placeholder — subclass override
_scene_url_re: re.Pattern[str] = re.compile(r"$^") # placeholder, subclass override
#: Minimalna długość tokena query do filtrowania wyników (krótsze ignorujemy żeby
#: nie matchowały niezwiązanych slugów).
@ -74,12 +74,12 @@ class BaseSearchScraper(BaseDirectTubeScraper):
})
#: Window (chars) wokół scene URL match, w którym szukamy `<img>` jako thumbnail.
#: WordPress-like tubes mają thumb w `<a href="..."><img src="...thumb.jpg"></a>` —
#: WordPress-like tubes mają thumb w `<a href="..."><img src="...thumb.jpg"></a>`,
#: ±800 chars łapie ten pattern niezawodnie.
_thumbnail_window: int = 800
def _scene_url_from_match(self, m: re.Match[str]) -> str:
"""Domyślnie group(1) — subclass override gdy regex używa named groups inaczej."""
"""Domyślnie group(1), subclass override gdy regex używa named groups inaczej."""
try:
return m.group("url")
except IndexError:
@ -103,23 +103,54 @@ class BaseSearchScraper(BaseDirectTubeScraper):
def _format_query_for_url(self, query: str) -> str:
"""Default: URL-encode (spaces → `+`). Subclass override gdy tube wymaga
innego formatu — np. KVS-style sites użyją slug (spaces → `-`).
innego formatu, np. KVS-style sites użyją slug (spaces → `-`).
"""
return urllib.parse.quote_plus(query.strip())
def _thumbnail_map(self, listing_html: str) -> dict[str, str]:
"""Opcjonalny hak: mapa scene_url -> thumbnail_url z calej strony wynikow.
Domyslnie pusta i wtedy dziala szukanie `<img>` w oknie wokol linku. Tuby,
ktore renderuja karty z JSON-a (xhamster: `window.initials` z `pageURL` i
`thumbURL`), maja miniature dla KAZDEJ karty w jednym miejscu, a okno tekstu
gubilo polowe (54% zrodel xhamster bez miniatury, zgloszenie 8ec9dc96)."""
return {}
def _fetch_scene_metadata(
self, scene_url: str
) -> tuple[RawStudio | None, list[RawPerformer], list[RawTag]] | None:
"""Optional hook — subclass może override żeby fetch'ować scene detail page
"""Optional hook, subclass może override żeby fetch'ować scene detail page
i wyciągnąć studio/performerów/tagi. Default zwraca None (skip detail fetch).
Wywoływane PER SCENE w `search()` — dodaje +1 HTTP request per match. Subclass
Wywoływane PER SCENE w `search()`, dodaje +1 HTTP request per match. Subclass
powinien rzucić wyjątki swobodnie, base łapie i kontynuuje bez metadata.
Returns: (studio, performers, tags). Każde może być None / pusta lista.
"""
return None
@staticmethod
def _slug_matches_query(slug_lower: str, query: str) -> bool:
"""Czy slug zawiera nazwę z query jako spójną frazę, z granicami słowa.
Człony rozdzielamy dowolnym niealfanumerycznym separatorem (tube używa `-`,
`_` albo skleja), a po bokach wymagamy granicy, żeby „tiffany" nie łapało się
w „stiffany". Człony krótsze niż `_query_token_min_len` pomijamy przy budowie
frazy (np. „Mia Li"), inaczej wypadłyby wszystkie sceny tej osoby.
"""
toks = [
re.escape(tok)
for tok in query.lower().split()
if len(tok) >= BaseSearchScraper._query_token_min_len
]
if not toks:
return True
phrase = r"[^a-z0-9]*".join(toks)
# Dozwolone końcowe „s": slug gubi apostrof, więc dzierżawcze „TiffaniTime's"
# zapisuje jako „tiffanitimes". Nie luzuje to bramki, bo przed tym „s" i tak
# musi stać cała fraza (samo „first-times" nadal nie przejdzie dla „Tiffani Time").
return re.search(rf"(?<![a-z0-9]){phrase}s?(?![a-z0-9])", slug_lower) is not None
def search(
self,
query: str,
@ -147,13 +178,18 @@ class BaseSearchScraper(BaseDirectTubeScraper):
}
seen: set[str] = set()
try:
thumb_map = self._thumbnail_map(r.text)
except Exception as e: # noqa: BLE001
log.debug("%s thumbnail map failed: %s", self.sitetag, e)
thumb_map = {}
yielded = 0
for m in self._scene_url_re.finditer(r.text):
scene_url = self._scene_url_from_match(m).strip()
if scene_url.startswith("//"):
scene_url = "https:" + scene_url
elif scene_url.startswith("/"):
# Relative URL — prefix host z search URL.
# Relative URL, prefix host z search URL.
base = urllib.parse.urlparse(url)
scene_url = f"{base.scheme}://{base.netloc}{scene_url}"
if scene_url in seen:
@ -164,26 +200,35 @@ class BaseSearchScraper(BaseDirectTubeScraper):
slug_lower = slug.lower()
if slug_lower in self._nav_slug_blacklist:
continue
# Strict: WSZYSTKIE query tokens muszą być w slug. Wcześniej `any()`
# przepuszczał scenę gdy choć jeden token był w slug — dla performera
# "Ava Koxxx" (query="ava koxxx") wszystkie sceny z "ava-*" slug
# (Ava Devine, Ava Addams itp.) były labelowane jako "Ava Koxxx",
# bo `any("ava" in slug)` =True. User reports: scena "ava devine
# gangbanged..." miała Ava Koxxx w DB. Fix: `all()` — slug musi
# zawierać każdy ≥3-char token z imienia performera.
if query_tokens and not all(tok in slug_lower for tok in query_tokens):
# Slug musi zawierać CAŁE nazwisko jako SPÓJNĄ FRAZĘ, nie luźno rozrzucone
# tokeny. Historia tej linijki to trzy poziomy:
#
# 1. `any(tok in slug)` — scena z „ava-devine" dostawała Ava Koxxx.
# 2. `all(tok in slug)` — lepiej, ale to wciąż test PODCIĄGU na luźnych
# tokenach, więc przy „Tiffani Time" przechodziło wszystko, co miało
# gdziekolwiek „time" (first-time, adult-time, multiple-times) plus
# cokolwiek zawierającego „tiffani". Przy „Tiffany Time" dochodziło
# jeszcze „tiffany" ⊂ „s-tiffany-love". Zgłoszenie 2026-08-13: strona
# Tiffani Time pełna scen o Tiffany Tatum, Tiffani Madison i Victorii.
# 3. (tu) fraza: „tiffani-time" musi wystąpić w slugu jako ciąg, z dowolnym
# separatorem między członami i granicą słowa po bokach.
#
# To ma znaczenie, bo `all_performers` niżej dokleja szukanego performera
# BEZWARUNKOWO do każdej sceny, która tu przejdzie. Ten warunek jest jedyną
# bramką między wyszukiwarką tuba (dopasowuje rozmyto) a obsadą w bazie.
if query_tokens and not self._slug_matches_query(slug_lower, query):
continue
title = self._title_from_slug(slug)
# Thumbnail: search ±N chars around scene_url match for nearest <img src=>.
# Większość tubes ma `<a href="<scene>"><img src="<thumb>"></a>` lub flat
# `<img src=><a href=>` — window 800 obejmuje oba.
# `<img src=><a href=>`, window 800 obejmuje oba.
window_start = max(0, m.start() - self._thumbnail_window)
window_end = min(len(r.text), m.end() + self._thumbnail_window)
window_html = r.text[window_start:window_end]
thumb_url: str | None = None
img_m = _IMG_SRC_RE.search(window_html)
thumb_url: str | None = thumb_map.get(scene_url)
img_m = None if thumb_url else _IMG_SRC_RE.search(window_html)
if img_m:
thumb_url = img_m.group(1).strip()
if thumb_url.startswith("//"):
@ -193,7 +238,7 @@ class BaseSearchScraper(BaseDirectTubeScraper):
thumb_url = f"{base.scheme}://{base.netloc}{thumb_url}"
# Opcjonalny metadata fetch (studio/dodatkowi performerzy/tagi). Default
# zwraca None — większość tube'ów ma tylko search HTML bez metadata.
# zwraca None, większość tube'ów ma tylko search HTML bez metadata.
# PornHat ma `data-setup='{...}'` w `js-ajax-{dvd,model,tag}` divach.
studio: RawStudio | None = None
extra_performers: list[RawPerformer] = []
@ -212,7 +257,7 @@ class BaseSearchScraper(BaseDirectTubeScraper):
description = meta[3]
# Performer z query zawsze obecny (driver scraping). Extra performers
# z detail page dorzucamy — dedupe po slug/name w resolverze.
# z detail page dorzucamy, dedupe po slug/name w resolverze.
all_performers = [RawPerformer(name=query.strip()), *extra_performers]
yield RawScene(

View file

@ -1,4 +1,4 @@
"""BaseDirectTubeScraper — kontrakt dla bezpośrednich scraperów tube'ów."""
"""BaseDirectTubeScraper, kontrakt dla bezpośrednich scraperów tube'ów."""
from __future__ import annotations
import abc
@ -13,7 +13,7 @@ class BaseDirectTubeScraper(abc.ABC):
żeby dziedziczyć logikę resolvera + idempotent merge per external_id."""
sitetag: str
"""Stabilny ID tube'a — używany w external_id `f"{sitetag}:{url}"`. Zgodny
"""Stabilny ID tube'a, używany w external_id `f"{sitetag}:{url}"`. Zgodny
z porn-app sitetag (hqpornercom, sxylandcom, itp.)."""
@abc.abstractmethod

View file

@ -1,4 +1,4 @@
"""eporner.com — direct HTML scrape search results.
"""eporner.com, direct HTML scrape search results.
Search: `https://www.eporner.com/search/<q>/<page>/` (1-indexed pages).
Scene URL: `https://www.eporner.com/hd-porn/<id>/<slug>/` lub `/video-<id>/<slug>/`.

View file

@ -1,11 +1,11 @@
"""eporner.com — deep-crawl przez oficjalne JSON API (api/v2/video/search).
"""eporner.com, deep-crawl przez oficjalne JSON API (api/v2/video/search).
Detail HTML eporner jest JS-heavy (brak SSR duration/title/tagów) — ALE eporner ma
Detail HTML eporner jest JS-heavy (brak SSR duration/title/tagów), ALE eporner ma
publiczne API zwracające KOMPLETNĄ metadatę w jednym callu: `title`, `length_sec`
(duration), `keywords` (tagi), `added` (data), thumb, embed, url. ~100k filmów,
`order=latest`, ~100/stronę → ~1000 szybkich calli (BEZ detail-fetch). To czyni
eporner idealnym SSR-bogatym źródłem deep-crawla (analiza 2026-06-03: porntrex/hqporner
odrzucone — KVS bez SSR duration; eporner-API je zastępuje).
odrzucone, KVS bez SSR duration; eporner-API je zastępuje).
Override `crawl_page()` (API flow). HTML-owe _listing_url/_extract/_parse to stuby
(BaseBrowseScraper ABC ich wymaga, ale nieużywane). Sitetag `epornercom` = ten sam co
@ -113,7 +113,7 @@ class EpornerApiScraper(BaseBrowseScraper):
)
return out
# HTML stuby — nieużywane (crawl_page nadpisany API-flow), ale ABC ich wymaga.
# HTML stuby, nieużywane (crawl_page nadpisany API-flow), ale ABC ich wymaga.
def _listing_url(self, page: int) -> str: # pragma: no cover
raise NotImplementedError("EpornerApiScraper używa crawl_page (API), nie HTML listingu")

View file

@ -1,6 +1,6 @@
"""fpo.xxx — latest-vids browse scraper (KVS engine).
"""fpo.xxx, latest-vids browse scraper (KVS engine).
Historia: dawniej WordPress-search scraper (`?s=`), ale fpo.xxx to KVS, nie WP —
Historia: dawniej WordPress-search scraper (`?s=`), ale fpo.xxx to KVS, nie WP,
search zwracał 0 (regex slug-URL nie pasował do `/video/<id>/`). Przerobione na
BROWSE (latest z `/new-<n>/`), 2026-06-22 (user request: ożywić zamrożone tuby).
@ -11,7 +11,7 @@ Listing tile (`/new-<n>/`):
→ tytuł, miniatura, duration, URL sceny. Performerów/tagów listing nie ma czysto
(tytuł bywa JAV-code "Imai Kaho-RKI-602 ..."), więc puste → dorabia canonical-merge.
Playback: KVS (kt_player + license_code na detail page) — token IP-bound, resolve
Playback: KVS (kt_player + license_code na detail page), token IP-bound, resolve
PO STRONIE TELEFONU (fpoxxxResolver.ts / WebView fallback, extractor `fpoxxx`).
"""
from __future__ import annotations

View file

@ -1,4 +1,4 @@
"""freshporno.org — latest-vids browse scraper.
"""freshporno.org, latest-vids browse scraper.
Pilot #2 (po shyfap fail). Hipoteza: freshporno zachowuje oryginalne studio titles
("Straighten Her Out" zamiast custom rebranding jak shyfap) → title fuzzy match
@ -9,7 +9,7 @@ URL patterns:
- Scene: `/videos/<slug>/`
Listing: świadomie `/latest-updates/` zamiast roota `/`. Root jest KVS-owym
homepage z `cache-control: no-store` i świeżym PHPSESSID per-request — serwer
homepage z `cache-control: no-store` i świeżym PHPSESSID per-request, serwer
rotuje tam blok "featured" i na zimnej sesji potrafi podać stary zestaw zamiast
najnowszych (zaobserwowane 2026-06-15: 3 dni browse-latest skipowało wszystko bo
root podawał sceny z 20 maja; freshporno.org report). `/latest-updates/` to jawny
@ -108,7 +108,7 @@ class FreshpornoScraper(BaseBrowseScraper):
# Channel = studio. Pierwszy `/channels/<slug>/` link na stronie body
# (top nav też ma channels list ale to inny pattern z `/channels/" zatrzymanym)
studio: RawStudio | None = None
# Skipnij nav linki ze stringiem "Channels" jako anchor text — bierzemy specific channel
# Skipnij nav linki ze stringiem "Channels" jako anchor text, bierzemy specific channel
for m in _CHANNEL_LINK_RE.finditer(detail_html):
slug, name = m.group(1), m.group(2).strip()
if name.lower() in ("channels", ""):
@ -120,7 +120,7 @@ class FreshpornoScraper(BaseBrowseScraper):
)
break
# Performers — wszyscy `/models/<slug>/`
# Performers, wszyscy `/models/<slug>/`
performers: list[RawPerformer] = []
seen_perf: set[str] = set()
for m in _MODEL_LINK_RE.finditer(detail_html):
@ -154,7 +154,7 @@ class FreshpornoScraper(BaseBrowseScraper):
)
# Phash z thumbnail. Wiemy że freshporno używa internal screenshots (preview.mp4.jpg)
# więc to też może nie matchować canonical phashy — ale test pokaże.
# więc to też może nie matchować canonical phashy, ale test pokaże.
fingerprints: list[RawFingerprint] = []
if thumbnail_url:
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")
@ -170,8 +170,8 @@ class FreshpornoScraper(BaseBrowseScraper):
)
]
# Release date — freshporno emituje `<meta itemprop="uploadDate" content="2026-05-20T...">`.
# To data wrzucenia na freshporno, NIE oryginalna release_date studio — ale dla
# Release date, freshporno emituje `<meta itemprop="uploadDate" content="2026-05-20T...">`.
# To data wrzucenia na freshporno, NIE oryginalna release_date studio, ale dla
# świeżych scen (uploaded niedługo po publikacji) różnica ≤ 3-7 dni, mieści się w
# `date_window_days=7` w resolverze. Bez tego pola scene NULL → match score 0 →
# duplicate scene zamiast freshporno PS dodane do TPDB canonical (bug-report

View file

@ -1,8 +1,8 @@
"""fullmovies.xxx — latest-vids browse scraper.
"""fullmovies.xxx, latest-vids browse scraper.
Identyczny engine co hdporn.gg (KVS sponsor_groups stack): `/videos/<slug>/`,
`/networks/<slug>/`, `/models/<slug>/`, `/tags/<slug>/`. og:image to `img.fullmovies.xxx/...`
— **prawdopodobnie auto-screenshot** (jak hdporn.gg → 8% match). Probe potwierdzi.
**prawdopodobnie auto-screenshot** (jak hdporn.gg → 8% match). Probe potwierdzi.
"""
from __future__ import annotations
@ -71,7 +71,7 @@ class FullmoviesScraper(BaseBrowseScraper):
# Studio z PREFIKSU tytułu ("Studio - Scene Title"), nie z sidebara /networks/.
# Sidebar listuje WSZYSTKIE sieci → `_NETWORK_LINK_RE.finditer().first()` zawsze
# zwracał pierwszą z listy (Brazzers) dla każdej sceny — mis-attribution. Tytuł
# zwracał pierwszą z listy (Brazzers) dla każdej sceny, mis-attribution. Tytuł
# po oczyszczeniu ma format "Studio - Opis" (np. "Fake Hostel - ...").
studio: RawStudio | None = None
if " - " in title:
@ -104,7 +104,7 @@ class FullmoviesScraper(BaseBrowseScraper):
tags.append(RawTag(external_id=f"fullmoviesxxx:tag:{slug}", name=name, slug=slug))
# Phash WYŁĄCZONY (pilot 2026-06-01: 0% trafień ≤5, mediana Hamming 14 do
# canonical — auto-screenshoty img.fullmovies.xxx, nie hot-linkowane studio
# canonical, auto-screenshoty img.fullmovies.xxx, nie hot-linkowane studio
# thumbnaile). Matching trzyma się na title+performer+duration (seed: 92% tagged),
# więc download thumbnaila pod phash to czysty narzut. thumbnail_url zostaje (display).
fingerprints: list[RawFingerprint] = []

View file

@ -1,4 +1,4 @@
"""fullporner.com — browse scraper. Dodany 2026-07-27.
"""fullporner.com, browse scraper. Dodany 2026-07-27.
Pełne sceny, nie klipy: mediana długości 2067 s na stronie 1, 1911 s na 40 i 1969 s
na 300, zero filmów poniżej 10 minut w próbce. To jest różnica, przez którą sexu
@ -11,17 +11,17 @@ Listing jest ściśle chronologiczny, mimo że nagłówek mówi „Featured Vide
**Metadane są cienkie i trzeba to wiedzieć wprost.** Nie ma studia. Tagów jest
mediana 3. Tytuły są w 100% przepisane pod SEO, więc nie niosą nic z tytułu
studyjnego („POV Blowjob Queen Takes It Hard and Deep" tam, gdzie kanon ma „Blacked
– Dylan Moore – Petite Blonde Squirt"). Za to data jest podana jako **unix timestamp**
- Dylan Moore - Petite Blonde Squirt"). Za to data jest podana jako **unix timestamp**
(dokładna, nie względna) i długość co do sekundy.
Dry-run resolvera na 120 scenach (w rollbacku): 37 (31%) dopięło się do scen, które
już mamy, przez `composite_auto` — composite radzi sobie mimo złych tytułów, bo waży
już mamy, przez `composite_auto`, composite radzi sobie mimo złych tytułów, bo waży
performera i długość. Reszta to nowa podaż. Zmierzony przeciek duplikatów, po
odjęciu tła z kontroli na przesuniętej długości: ~15% katalogu. Liczymy phash
miniaturki właśnie po to, żeby późniejszy `bulk_dedup` miał czym te duplikaty łapać.
**Bramka na obsadę.** Sceny bez performera (19%) w dry-runie scaliły się w 0 na 23
przypadków — bez performera composite nie ma na czym pracować, a studia tu nie ma
przypadków, bez performera composite nie ma na czym pracować, a studia tu nie ma
i tytuł jest zmyślony, więc taka scena nie ma ŻADNEGO sygnału kanonicznego. Wchodzą
tylko sceny z obsadą.
@ -55,7 +55,7 @@ _THUMB = "https://imgs.xiaoshenke.net/thumb/{}.jpg"
_SCENE_HREF_RE = re.compile(r'href="(/watch/[a-f0-9]+)"')
_TITLE_RE = re.compile(r"<h2[^>]*>\s*([^<]+?)\s*</h2>")
# `<span class="create">1785081937</span></div><div>36:33</div>` — data i długość
# `<span class="create">1785081937</span></div><div>36:33</div>`, data i długość
# siedzą obok siebie w `video-info`, więc bierzemy je jednym wzorcem.
_INFO_RE = re.compile(
r'class="create">(\d+)</span>\s*</div>\s*<div>\s*([0-9:]+)\s*</div>'
@ -64,7 +64,7 @@ _PERF_BLOCK_RE = re.compile(r"<b>\s*Pornstars?:\s*</b>(.*?)</p>", re.IGNORECASE
_PERF_RE = re.compile(r'href="/pornstar/[^"]+"[^>]*>\s*([^<]+?)\s*</a>')
_TAG_BLOCK_RE = re.compile(r'class="[^"]*tag-link[^"]*"[^>]*>(.*?)</p>', re.DOTALL)
_TAG_RE = re.compile(r'href="/category/[^"]+"[^>]*>#\s*([^<]+?)\s*</a>')
# `<iframe src="//xiaoshenke.net/video/4458723/14">` — id wspak, /14 = maska jakości.
# `<iframe src="//xiaoshenke.net/video/4458723/14">`, id wspak, /14 = maska jakości.
# Id bywa numeryczne albo szesnastkowe (`a6487a97766a6`, ~25% katalogu).
_IFRAME_RE = re.compile(r'src="(?://)?[^"]*xiaoshenke\.net/video/([0-9a-f]+)/(\d+)"')
@ -101,7 +101,7 @@ class FullPornerScraper(BaseBrowseScraper):
pb = _PERF_BLOCK_RE.search(detail_html)
if pb:
for name in _PERF_RE.findall(pb.group(1)):
# Strona pisze performerów z małej („melissa straton") — do wyświetlania
# Strona pisze performerów z małej („melissa straton"), do wyświetlania
# podnosimy, dopasowanie i tak idzie po slugu.
name = html_mod.unescape(name).strip().title()
slug = slugify(name)

View file

@ -1,4 +1,4 @@
"""fullvideosporn.com (= sextu.com pod nową marką) — browse scraper z BRAMKĄ NA OBSADĘ.
"""fullvideosporn.com (= sextu.com pod nową marką), browse scraper z BRAMKĄ NA OBSADĘ.
To NIE jest klon fullmovies.xxx: inny silnik (TXXX vs KVS), inna taksonomia, 0/140
pokrycia tytułów z naszym korpusem `tube:fullmoviesxxx`.
@ -6,15 +6,15 @@ pokrycia tytułów z naszym korpusem `tube:fullmoviesxxx`.
**BRAMKA (warunek konieczny)**: 65-75% katalogu NIE MA żadnego performera. Sceny bez
obsady mają dodatkowo mocno spunowane tytuły ("Fabulous Porn Movie Gothic Newest
Exclusive Version"), więc nie zmatchują canonical i weszłyby jako puste orphany.
Ingestujemy WYŁĄCZNIE sceny z ≥1 performerem — wtedy sygnał jest dobry: 88-89%
Ingestujemy WYŁĄCZNIE sceny z ≥1 performerem, wtedy sygnał jest dobry: 88-89%
nazwisk z próbki ma u nas performera z refem tpdb/stashdb. Zawężenie zostawia
~25-35% katalogu (~150-220k scen), czyli i tak dużo.
Pozostałe pułapki, wszystkie obsłużone niżej:
- **tytuł bierzemy z `vit:"…"` (JS playera), NIE z `og:title`/`h1`** — te bywają
- **tytuł bierzemy z `vit:"…"` (JS playera), NIE z `og:title`/`h1`** te bywają
AI-owym spinem SEO ("Redhead MILF Fucks BBC in Courtroom Parody") zamiast realnego
tytułu sceny ("Alexis Fawx In Rise Anal In The Courtroom").
- **obsadę czytamy TYLKO z `<h3 class="item">Porn-stars: …</h3>`** — na całej stronie
- **obsadę czytamy TYLKO z `<h3 class="item">Porn-stars: …</h3>`** na całej stronie
jest ~22 linków `videos.php?q=`, w sekcji obsady 1-2 realne (pułapka xxxfiles).
- sekcje `Porn Site:` / `Porn Categories:` / `Porn-stars:` to ODDZIELNE `<h3>`, więc
parsujemy je per-blok, inaczej kategorie wyciekają do studia i odwrotnie.
@ -65,7 +65,7 @@ _UNIT_DAYS = {
def _submitted_to_date(detail_html: str):
"""`Submitted: <em>3 days ago</em>` → data. Strona nie podaje daty wprost, a
to jest data uploadu na tube (nie premiery studia) — traktujemy jak inne tuby."""
to jest data uploadu na tube (nie premiery studia), traktujemy jak inne tuby."""
m = _SUBMITTED_RE.search(detail_html)
if not m:
return None
@ -127,7 +127,7 @@ class FullVideosPornScraper(BaseBrowseScraper):
return out
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
# Sekcje metadanych — każda w swoim <h3 class="item">.
# Sekcje metadanych, każda w swoim <h3 class="item">.
performers: list[RawPerformer] = []
tags: list[RawTag] = []
studio: RawStudio | None = None
@ -162,7 +162,7 @@ class FullVideosPornScraper(BaseBrowseScraper):
RawTag(external_id=f"{self.sitetag}:tag:{sl}", name=name, slug=sl)
)
# BRAMKA: bez obsady nie ingestujemy (patrz docstring — 2/3 katalogu to
# BRAMKA: bez obsady nie ingestujemy (patrz docstring, 2/3 katalogu to
# spunowane tytuły bez performera, które weszłyby jako puste orphany).
if not performers:
return None

View file

@ -1,25 +1,25 @@
"""galaxyporn.net — browse scraper (WordPress retrotube). Dodany 2026-07-26.
"""galaxyporn.net, browse scraper (WordPress retrotube). Dodany 2026-07-26.
Ripy paysite ze studiami (BrazzersExxtra, EvilAngel, NubilesPorn, RealityKings,
MYLF, BlackedRaw…). Ocena: orphan-risk LOW z twardym pomiarem — 92% performerów
MYLF, BlackedRaw…). Ocena: orphan-risk LOW z twardym pomiarem, 92% performerów
z próbki 75 ma już u nas ref tpdb/stashdb, 79% tytułów matchuje istniejące sceny.
Listing `/page/N/`, 21 scen/stronę, ~1180 stron, paginacja zdrowa (overlap p1/p2 = 0).
Metadane:
- obsada: `<div id="video-actors">` — czysta, BEZ pollution (na całej stronie sceny
- obsada: `<div id="video-actors">`, czysta, BEZ pollution (na całej stronie sceny
są dokładnie te 2-3 linki `/actor/`, nie ma sekcji Related z obcymi performerami)
- studio + data: prefiks tytułu `Studio YY MM DD Performer – Title` albo `[Studio]`
- studio + data: prefiks tytułu `Studio YY MM DD Performer - Title` albo `[Studio]`
- tagi: `<div class="tags-list">` (wycinamy slug studia, żeby nie robić tag=studio)
- thumbnail: `data-main-thumb` / og:image
**Duration — uwaga, nie ma go w HTML.** Ani listing, ani detail, ani JSON-LD, ani
**Duration, uwaga, nie ma go w HTML.** Ani listing, ani detail, ani JSON-LD, ani
payload playera nie niosą długości. NULL duration jest GROŹNY: `/scenes` filtruje
`duration_sec >= min_duration_sec`, a NULL w SQL odpada z wyniku, więc sceny byłyby
NIEWIDOCZNE w apce mimo "new: N, errors: 0" (dokładnie incydent porntrex, 6479 scen).
Dlatego liczymy ją z `thumbnail.vtt` playera (ostatni cue), 1 dodatkowy request na
scenę po odpytaniu API seekplayer. Wartość jest ~1 interwał miniatur krótsza od
realnej (2226 s vs ~2276 s na próbce, ~2%) — akceptowalne, bo alternatywą jest brak.
realnej (2226 s vs ~2276 s na próbce, ~2%), akceptowalne, bo alternatywą jest brak.
Playback: iframe `#hash` rodziny seekplayer (galaxy.upns.online / galaxy.4meplayer.pro /
sport.seekplays.com / news.upns.pro) → extractor `galaxyporncom` → istniejący
@ -58,7 +58,7 @@ _TAGS_BLOCK_RE = re.compile(r'<div class="tags-list">(.*?)</div>', re.IGNORECASE
_THUMB_RE = re.compile(r'data-main-thumb="([^"]+)"', re.IGNORECASE)
_IFRAME_RE = re.compile(r'<iframe[^>]+src="([^"]+)"', re.IGNORECASE)
# `BrazzersExxtra 26 07 26 Kayley Gunner & Elly Clutch – Swapping Off The Pervy Maid`
# `BrazzersExxtra 26 07 26 Kayley Gunner & Elly Clutch - Swapping Off The Pervy Maid`
_TITLE_STUDIO_DATE_RE = re.compile(r"^(?P<studio>[A-Za-z0-9][A-Za-z0-9._-]{1,30})\s+(?P<y>\d{2})\s+(?P<m>\d{2})\s+(?P<d>\d{2})\s+(?P<rest>.+)$")
# `[Onlyfans] Some Title` / `[Darkkotv] …`
_TITLE_BRACKET_RE = re.compile(r"^\[(?P<studio>[^\]]{2,30})\]\s*(?P<rest>.+)$")
@ -105,7 +105,7 @@ def _resolve_duration(iframe_url: str, *, timeout: float, attempts: int = 3) ->
data = json.loads(_decrypt(r.text))
thumb = (data.get("thumbnail") or "").strip()
if not thumb:
return None # payload OK, ale brak miniatur — retry nie pomoże
return None # payload OK, ale brak miniatur, retry nie pomoże
vtt_url = host + thumb if thumb.startswith("/") else thumb
vr = browser_get(vtt_url, headers=headers, timeout=timeout)
if vr.status_code != 200:
@ -145,7 +145,7 @@ class GalaxyPornScraper(BaseBrowseScraper):
if not title:
return None
# Obsada — blok wydzielony, bez scopingu na siłę (potwierdzone: cała strona
# Obsada, blok wydzielony, bez scopingu na siłę (potwierdzone: cała strona
# ma dokładnie tyle linków /actor/ ile realnych aktorów).
performers: list[RawPerformer] = []
seen_p: set[str] = set()
@ -186,7 +186,7 @@ class GalaxyPornScraper(BaseBrowseScraper):
slug=slugify(studio_name),
)
# Tagi — bez slugu studia (retrotube wrzuca studio także jako zwykły tag).
# Tagi, bez slugu studia (retrotube wrzuca studio także jako zwykły tag).
tags: list[RawTag] = []
seen_t: set[str] = set()
studio_slug = slugify(studio_name) if studio_name else ""

View file

@ -1,4 +1,4 @@
"""HDPorn92Scraper — direct HTML scrape hdporn92.com search.
"""HDPorn92Scraper, direct HTML scrape hdporn92.com search.
Search: `https://hdporn92.com/page/<n>/?s=<query>`. Scene URL format:
`https://hdporn92.com/<slug>/` (jeden segment ścieżki). Trzeba odsiać

View file

@ -1,9 +1,9 @@
"""hdporn.gg — latest-vids browse scraper.
"""hdporn.gg, latest-vids browse scraper.
Engine podobny do freshporno: `/videos/<slug>/` URL, `/networks/<slug>/` = studio,
`/models/<slug>/` = performer, `/tags/<slug>/` = tag.
Quirk: og:image to internal CDN `img.hdporn.gg/...` — przed merging do prod
Quirk: og:image to internal CDN `img.hdporn.gg/...`, przed merging do prod
sprawdzamy phash distance (gate-keeper: jeśli Hamming >5 dla >70% scen → orphan
factory, wyłącz; analogia do shyfap).
"""
@ -63,9 +63,9 @@ class HDPornGGScraper(BaseBrowseScraper):
title = meta_content(detail_html, property="og:title")
if not title:
return None
# og:title typowo zawiera ": Free HD Porn" suffix — usuń
# og:title typowo zawiera ": Free HD Porn" suffix, usuń
title = re.sub(r":\s*Free HD Porn\s*$", "", title, flags=re.IGNORECASE).strip()
# I "Brazzers - " prefix często też w title — zostaw, bo studio name w title
# I "Brazzers - " prefix często też w title, zostaw, bo studio name w title
# to silny sygnał dla fuzzy match.
description = meta_content(detail_html, property="og:description")
@ -112,7 +112,7 @@ class HDPornGGScraper(BaseBrowseScraper):
)
# Phash WYŁĄCZONY (pilot 2026-06-01: 0% trafień ≤5, mediana Hamming 14 do
# canonical — auto-screenshoty img.hdporn.gg, nie hot-linkowane studio thumbnaile).
# canonical, auto-screenshoty img.hdporn.gg, nie hot-linkowane studio thumbnaile).
# Matching trzyma się na title+performer+duration (seed: 92% tagged), więc download
# thumbnaila pod phash to czysty narzut. thumbnail_url zostaje (display).
fingerprints: list[RawFingerprint] = []

View file

@ -0,0 +1,131 @@
"""hentaigasm.com — browse scraper. Trzecie źródło wertykału hentai. Dodany 2026-08-04.
**Trzeci niezależny host w wertykale.** Te same odcinki co [[hentaimama]] i
[[xanimeporn]], ale znowu inne pliki: tutaj `hgasm1.com/<Tytuł Ze Spacjami>.mp4`,
tam `gdvid.info/N/...` i `download.xanimeporn.com/videos/1080p/[1080p]<id>.mp4`.
Żadna z tych trzech stron nie re-hostuje pozostałych.
Kryterium jak w całym wertykale: dla animacji nie istnieje kanon, więc każda pozycja
jest orphanem i to poprawne. Nie ma obsady ani canonical matcha.
**Wszystkie metadane bierzemy z RSS-a, nie ze strony odcinka**, i to nie jest skrót
tylko konieczność:
- strona odcinka NIE MA daty publikacji w żadnym standardowym miejscu (ani JSON-LD,
ani `article:published_time`, ani `<time>`), a feed ma `<pubDate>`
- `/genre/` w HTML strony to 38 linków, czyli kolejna chmura z sidebara, a feed daje
5 realnych kategorii: pierwsza to nazwa serii, reszta to gatunki
- `/page/2/` nie zwraca odcinków w ogóle, a `/feed/?paged=N` stronicuje poprawnie
i chronologicznie (strona 1 = dziś, strona 2 = tydzień wstecz)
Strona odcinka jest potrzebna wyłącznie do miniaturki, którą wyciąga z konfiguracji
JWPlayera razem z linkiem do wideo (patrz extractor `hentaigasm`).
"""
from __future__ import annotations
import html as html_mod
import logging
import re
from email.utils import parsedate_to_datetime
from app.connectors.base import RawPlaybackSource, RawScene, RawTag
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.extractors import browser_get
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://hentaigasm.com"
_ITEM_RE = re.compile(r"<item>(.*?)</item>", re.DOTALL)
_LINK_RE = re.compile(r"<link>\s*(https?://hentaigasm\.com/[^<\s]+)\s*</link>")
_TITLE_RE = re.compile(r"<title>(.*?)</title>", re.DOTALL)
_PUBDATE_RE = re.compile(r"<pubDate>(.*?)</pubDate>", re.DOTALL)
_CATEGORY_RE = re.compile(r"<category><!\[CDATA\[(.*?)\]\]></category>", re.DOTALL)
#: Miniaturka z konfiguracji JWPlayera — jedyna rzecz, po którą wchodzimy na stronę.
_JW_IMAGE_RE = re.compile(r"image:\s*[\"']([^\"']+)[\"']")
class HentaiGasmScraper(BaseBrowseScraper):
sitetag = "hentaigasm"
def _listing_url(self, page: int) -> str:
return f"{_BASE}/feed/" if page <= 1 else f"{_BASE}/feed/?paged={page}"
def _extract_scene_urls(self, listing_html: str) -> list[str]:
return list(dict.fromkeys(_LINK_RE.findall(listing_html)))
def crawl_page(self, page: int) -> list[RawScene] | None:
try:
res = browser_get(self._listing_url(page), timeout=self._timeout)
res.raise_for_status()
except Exception as e:
log.warning("%s: feed strona %d fail: %s", self.sitetag, page, e)
return None
items = _ITEM_RE.findall(res.text)
if not items:
return []
out: list[RawScene] = []
for item in items:
lm = _LINK_RE.search(item)
tm = _TITLE_RE.search(item)
if not lm or not tm:
continue
url = lm.group(1).strip()
title = html_mod.unescape(tm.group(1)).strip()
if not title:
continue
release_date = None
dm = _PUBDATE_RE.search(item)
if dm:
try:
release_date = parsedate_to_datetime(dm.group(1).strip()).date()
except (TypeError, ValueError):
pass
tags: list[RawTag] = []
seen: set[str] = set()
for raw_cat in _CATEGORY_RE.findall(item):
name = html_mod.unescape(raw_cat).strip()
slug = slugify(name)
if slug and slug not in seen:
seen.add(slug)
tags.append(
RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug)
)
thumb = None
try:
det = browser_get(url, timeout=self._timeout)
det.raise_for_status()
im = _JW_IMAGE_RE.search(det.text)
if im:
thumb = im.group(1).strip()
except Exception as e:
# Brak miniaturki nie dyskwalifikuje odcinka — reszta metadanych jest z feedu.
log.info("%s: miniaturka fail %s: %s", self.sitetag, url, e)
slug = url.rstrip("/").rsplit("/", 1)[-1]
out.append(
RawScene(
external_id=f"{self.sitetag}:{slug}",
title=title,
release_date=release_date,
url=url,
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=url,
thumbnail_url=thumb,
)
],
)
)
return out
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None: # pragma: no cover
raise NotImplementedError("hentaigasm buduje sceny z RSS-a w crawl_page")

View file

@ -0,0 +1,128 @@
"""hentaimama.io — browse scraper. Dodany 2026-08-04. Pierwsze źródło wertykału hentai.
**Ten wertykał rządzi się inaczej niż reszta i to jest celowe.** Dla hentai NIE ISTNIEJE
kanon: TPDB i StashDB nie mają animacji ani postaci rysowanych, więc każda scena stąd
jest technicznie orphanem i to jest POPRAWNE, nie błędne. Kryterium „fabryka orphanów",
którym oceniamy tuby z żywymi aktorami, tutaj po prostu nie ma zastosowania — nie ma też
obsady, więc żadnej bramki na performera. Miarą jakości jest komplet seria + numer
odcinka + gatunki, a docelowo dedup MIĘDZY stronami hentai (re-hostują się nawzajem).
Jednostka treści to ODCINEK (`/episodes/<seria>-episode-N/`), nie seria. Każdy jest
samodzielną pozycją z własnym plakatem i playbackiem, więc mapuje się na scenę tak samo
jak w wertykale JAV — bez zmian w schemacie.
Motyw jest z rodziny Dooplay, ale `DooplayConnector` się NIE nadaje: nie ma tu
`data-fl-source` ani `data-post`, na których stoi tamten parser. Player idzie przez
`POST /wp-admin/admin-ajax.php` i jest za Cloudflare — obsługuje to extractor
`hentaimama`, patrz jego docstring.
Pułapka: `/genre/` w całym HTML to 118 linków (chmura w sidebarze, ten sam wzorzec co
przy sexu i yourdailypornmovies). Gatunki bierzemy WYŁĄCZNIE z `<div class="sgeneros">`,
co daje realne 9 pozycji na odcinek zamiast 118 śmieci.
Strona nie podaje długości odcinka.
"""
from __future__ import annotations
import html as html_mod
import logging
import re
from datetime import datetime
from app.connectors.base import RawPlaybackSource, RawScene, RawTag
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://hentaimama.io"
_EPISODE_HREF_RE = re.compile(r'href="(https://hentaimama\.io/episodes/[^"]+)"')
# Sam listing `/episodes/` i jego paginacja też pasują do wzorca wyżej — bez tego
# filtra wchodziły jako „odcinek" o tytule „Recent Episodes".
_NOT_EPISODE_RE = re.compile(r"/episodes/(?:page/\d+/)?$")
# `og:title` zamiast <h1>: na stronie są trzy <h1>, dwa to logo serwisu, a trzeci bywa
# nagłówkiem sekcji. og:title daje czysty „Shoujo Ramune Episode 7".
_OG_TITLE_RE = re.compile(r"property=['\"]og:title['\"][^>]*content=['\"]([^'\"]{3,150})")
# Gatunki TYLKO stąd — patrz pułapka w docstringu.
_GENRES_BLOCK_RE = re.compile(r'<div class="sgeneros">(.*?)</div>', re.DOTALL)
_ANCHOR_TEXT_RE = re.compile(r">\s*([^<]{2,40}?)\s*</a>")
_DATE_RE = re.compile(r'<span class="date">\s*([A-Z][a-z]{2}\.?\s+\d{1,2},\s+\d{4})')
_SERIES_RE = re.compile(r'href="https://hentaimama\.io/tvshows/([a-z0-9-]+)/"')
# Uwaga: atrybuty bywają w innej kolejności niż `<meta property=... content=...>`,
# a wartość ma na końcu \r — stąd luźny wzorzec i strip() przy użyciu.
_OG_IMAGE_RE = re.compile(r"property=['\"]og:image['\"][^>]*content=['\"]([^'\"]+)")
def _parse_date(value: str | None):
if not value:
return None
for fmt in ("%b. %d, %Y", "%b %d, %Y"):
try:
return datetime.strptime(value.strip(), fmt).date()
except ValueError:
continue
return None
class HentaiMamaScraper(BaseBrowseScraper):
sitetag = "hentaimama"
def _listing_url(self, page: int) -> str:
return f"{_BASE}/episodes/" if page <= 1 else f"{_BASE}/episodes/page/{page}/"
def _extract_scene_urls(self, listing_html: str) -> list[str]:
return [
u
for u in dict.fromkeys(_EPISODE_HREF_RE.findall(listing_html))
if not _NOT_EPISODE_RE.search(u)
]
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
tm = _OG_TITLE_RE.search(detail_html)
title = html_mod.unescape(tm.group(1)).strip() if tm else ""
if not title or title.lower() == "hentaimama":
return None
tags: list[RawTag] = []
seen: set[str] = set()
gb = _GENRES_BLOCK_RE.search(detail_html)
if gb:
for name in _ANCHOR_TEXT_RE.findall(gb.group(1)):
name = html_mod.unescape(name).strip()
slug = slugify(name)
if slug and slug not in seen:
seen.add(slug)
tags.append(
RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug)
)
# Seria jako tag — pozwala grupować odcinki bez zmian w schemacie.
sm = _SERIES_RE.search(detail_html)
if sm and sm.group(1) not in seen:
series = sm.group(1).replace("-", " ").title()
tags.append(
RawTag(
external_id=f"{self.sitetag}:series:{sm.group(1)}",
name=series,
slug=slugify(series),
)
)
dm = _DATE_RE.search(detail_html)
om = _OG_IMAGE_RE.search(detail_html)
slug = scene_url.rstrip("/").rsplit("/", 1)[-1]
return RawScene(
external_id=f"{self.sitetag}:{slug}",
title=title,
release_date=_parse_date(dm.group(1) if dm else None),
url=scene_url,
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=scene_url,
thumbnail_url=om.group(1).strip() if om else None,
)
],
)

View file

@ -0,0 +1,131 @@
"""hentaiplay.net — browse scraper. Czwarte źródło wertykału hentai. Dodany 2026-08-04.
**Czwarty niezależny host w wertykale**: pliki leżą na `hentaiplanet.info`, podczas gdy
[[hentaimama]] ma `gdvid.info`, [[xanimeporn]] `download.xanimeporn.com`, a
[[hentaigasm]] `hgasm<N>.com`. Cztery strony, cztery osobne pule plików — żadna nie
re-hostuje pozostałych, mimo że katalogi odcinków mocno się pokrywają.
Kryterium jak w całym wertykale: dla animacji nie istnieje kanon, więc każda pozycja
jest orphanem i to poprawne. Bez obsady i bez canonical matcha.
**Metadane z RSS-a**, tak samo jak przy hentaigasm i z tego samego powodu: `/genre/`
w HTML strony to **2084 linki**, największa chmura z sidebara w całym wertykale
(dla porównania hentaigasm 38, hentaimama 118). Feed daje 6 realnych kategorii na
odcinek plus datę i tytuł. `/page/2/` nie zwraca odcinków, a `/feed/?paged=N`
stronicuje chronologicznie.
Strona odcinka jest potrzebna tylko do miniaturki; wideo wyciąga extractor `hentaiplay`.
"""
from __future__ import annotations
import html as html_mod
import logging
import re
from email.utils import parsedate_to_datetime
from app.connectors.base import RawPlaybackSource, RawScene, RawTag
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.extractors import browser_get
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://hentaiplay.net"
_ITEM_RE = re.compile(r"<item>(.*?)</item>", re.DOTALL)
_LINK_RE = re.compile(r"<link>\s*(https?://hentaiplay\.net/[^<\s]+?)\s*</link>")
_TITLE_RE = re.compile(r"<title>(.*?)</title>", re.DOTALL)
_PUBDATE_RE = re.compile(r"<pubDate>(.*?)</pubDate>", re.DOTALL)
_CATEGORY_RE = re.compile(r"<category><!\[CDATA\[(.*?)\]\]></category>", re.DOTALL)
# Miniaturka jest TYLKO w JSON-LD i z escapowanymi ukośnikami (`https:\/\/…`).
# `og:image` na tej stronie nie istnieje — pierwsza wersja scrapera dawała przez to
# 12/12 odcinków bez miniaturki.
_THUMB_RE = re.compile(r'"thumbnailUrl"\s*:\s*\[?\s*"([^"]{10,300})"')
#: Kategorie czysto techniczne — nie niosą nic o treści odcinka.
_SKIP_CATS = frozenset({"hentai anime", "new hentai", "english subbed", "raw"})
class HentaiPlayScraper(BaseBrowseScraper):
sitetag = "hentaiplay"
def _listing_url(self, page: int) -> str:
return f"{_BASE}/feed/" if page <= 1 else f"{_BASE}/feed/?paged={page}"
def _extract_scene_urls(self, listing_html: str) -> list[str]:
return list(dict.fromkeys(_LINK_RE.findall(listing_html)))
def crawl_page(self, page: int) -> list[RawScene] | None:
try:
res = browser_get(self._listing_url(page), timeout=self._timeout)
res.raise_for_status()
except Exception as e:
log.warning("%s: feed strona %d fail: %s", self.sitetag, page, e)
return None
items = _ITEM_RE.findall(res.text)
if not items:
return []
out: list[RawScene] = []
for item in items:
lm = _LINK_RE.search(item)
tm = _TITLE_RE.search(item)
if not lm or not tm:
continue
url = lm.group(1).strip()
title = html_mod.unescape(tm.group(1)).strip()
if not title:
continue
release_date = None
dm = _PUBDATE_RE.search(item)
if dm:
try:
release_date = parsedate_to_datetime(dm.group(1).strip()).date()
except (TypeError, ValueError):
pass
tags: list[RawTag] = []
seen: set[str] = set()
for raw_cat in _CATEGORY_RE.findall(item):
name = html_mod.unescape(raw_cat).strip()
if name.lower() in _SKIP_CATS or name.isdigit():
continue # „Hentai Anime"/„New Hentai"/rocznik nie są tagami treści
slug = slugify(name)
if slug and slug not in seen:
seen.add(slug)
tags.append(
RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug)
)
thumb = None
try:
det = browser_get(url, timeout=self._timeout)
det.raise_for_status()
im = _THUMB_RE.search(det.text)
if im:
thumb = im.group(1).strip().replace("\\/", "/")
except Exception as e:
log.info("%s: miniaturka fail %s: %s", self.sitetag, url, e)
slug = url.rstrip("/").rsplit("/", 1)[-1]
out.append(
RawScene(
external_id=f"{self.sitetag}:{slug}",
title=title,
release_date=release_date,
url=url,
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=url,
thumbnail_url=thumb,
)
],
)
)
return out
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None: # pragma: no cover
raise NotImplementedError("hentaiplay buduje sceny z RSS-a w crawl_page")

View file

@ -1,4 +1,4 @@
"""hqfap.com — latest-vids browse scraper (PlayTube CMS, patrz _playtube.py).
"""hqfap.com, latest-vids browse scraper (PlayTube CMS, patrz _playtube.py).
Dołączony 2026-06-10 (user request), usunięty 2026-06-25 (cała biblioteka CDN padła
na stub), przywrócony 2026-07-07 (strona wróciła na CDN vstor.top z realnymi plikami,

View file

@ -1,12 +1,12 @@
"""HQPornerScraper — direct HTML scrape hqporner search page.
"""HQPornerScraper, direct HTML scrape hqporner search page.
Search URL: `https://hqporner.com/?q=<query>&p=<page>`. Static HTML zwraca ~50
linków `/hdporn/<id>-<slug>.html` per strona. Tytuł deducimy ze slug'a (porn-app
data API zwraca dokładniejszy ale wymaga round-trip — dla MVP slug-derived OK,
data API zwraca dokładniejszy ale wymaga round-trip, dla MVP slug-derived OK,
resolver i tak je sciagnie z TPDB merge).
Search fuzzy: hqporner zwraca "Lola Noir" gdy szukamy "Noir" itp. Dlatego
filtrujemy wyniki po tym czy slug zawiera query (lub jego token) — analogicznie
filtrujemy wyniki po tym czy slug zawiera query (lub jego token), analogicznie
jak `fetch_scenes_for_search` w pornapp connectorze.
"""
from __future__ import annotations
@ -17,6 +17,7 @@ import urllib.parse
from collections.abc import Iterator
from app.connectors.base import RawPerformer, RawPlaybackSource, RawScene
from app.connectors.direct_scrapers._search_base import BaseSearchScraper
from app.connectors.direct_scrapers.base import BaseDirectTubeScraper
from app.extractors import browser_get
@ -55,7 +56,7 @@ class HQPornerScraper(BaseDirectTubeScraper):
# Pojedyncze nazwy ("Belladonna") nadal działają (jeden token musi być).
query_tokens = {tok for tok in query.lower().split() if len(tok) >= 3}
# Człon <3 znaki ("Lady Vi", "Mia Li", "Ai Uehara") wypada z `query_tokens`, więc
# zostaje sam pospolity człon i `all` degeneruje się do „slug zawiera 'lady'" —
# zostaje sam pospolity człon i `all` degeneruje się do „slug zawiera 'lady'",
# czyli KAŻDA scena z tym słowem (audit 2026-07-28: Lady Vi miała 116 przypisań
# z tube, z czego 115 tylko przez „lady", realnie o niej 1). Gdy nazwa ma taki
# człon, wymagamy dodatkowo CAŁEJ nazwy sklejonej bez separatorów (slug
@ -73,9 +74,13 @@ class HQPornerScraper(BaseDirectTubeScraper):
continue
seen_urls.add(scene_url)
# Title-token filter
# Nazwa musi być w slugu SPÓJNĄ FRAZĄ, nie luźnymi tokenami. Wspólna
# implementacja z `BaseSearchScraper`, żeby ta bramka nie rozjeżdżała się
# między scraperami (tu była własna kopia i przez to własny zestaw dziur:
# `all(tok in slug)` przepuszczał „tiffany" w „s-tiffany-love" i „time"
# w „first-time" — zgłoszenie 2026-08-13, strona Tiffani Time).
slug_lower = slug_part.lower()
if query_tokens and not all(tok in slug_lower for tok in query_tokens):
if query_tokens and not BaseSearchScraper._slug_matches_query(slug_lower, query):
continue
if _needs_compact and _compact not in re.sub(r"[^a-z0-9]", "", slug_lower):
continue
@ -92,7 +97,7 @@ class HQPornerScraper(BaseDirectTubeScraper):
page_url=scene_url,
)
],
# Wymuszamy hint performera = query — search per performer name znaczy
# Wymuszamy hint performera = query, search per performer name znaczy
# że scena prawie na pewno o nim. Resolver dorobi ScenePerformer link.
performers=[RawPerformer(name=query.strip())],
raw={

View file

@ -1,4 +1,4 @@
"""HQPornerBrowseScraper — latest-vids browse dla hqporner.com.
"""HQPornerBrowseScraper, latest-vids browse dla hqporner.com.
Dlaczego: hqporner był TYLKO w `ALL_DIRECT_SCRAPERS` (search po nazwisku performera),
więc nowe sceny wchodziły wyłącznie wtedy, gdy worker akurat szukał pasującego
@ -17,7 +17,7 @@ Detail (`/hdporn/<id>-<slug>.html`) ma komplet metadanych:
`external_id` MUSI być identyczny jak w `HQPornerScraper` (search): `hqpornercom:<url>`.
Inaczej ta sama scena z obu ścieżek zrobiłaby się dwoma wpisami.
Miniaturki fastporndelivery wymagają Referera (bez niego 403) — backend i tak owija je
Miniaturki fastporndelivery wymagają Referera (bez niego 403), backend i tak owija je
w `/proxy/img/`, a phash liczymy z jawnym refererem.
"""
from __future__ import annotations
@ -134,7 +134,7 @@ class HQPornerBrowseScraper(BaseBrowseScraper):
fingerprints.append(RawFingerprint(kind="phash", value=ph))
return RawScene(
# Ten sam format co HQPornerScraper.search — inaczej duplikat sceny.
# Ten sam format co HQPornerScraper.search, inaczej duplikat sceny.
external_id=f"{self.sitetag}:{scene_url}",
title=title,
duration_sec=duration_sec,

View file

@ -1,7 +1,7 @@
"""javflix.cc — JAV browse scraper (WordPress, English-subbed JAV).
"""javflix.cc, JAV browse scraper (WordPress, English-subbed JAV).
Osobna pula JAV (kody typu BKD-368, tytuły azjatyckie), origin `tube:javflix`.
NIE deduplikuje się z zachodnim katalogiem — świadomie orphan vertical (sekcja JAV).
NIE deduplikuje się z zachodnim katalogiem, świadomie orphan vertical (sekcja JAV).
Struktura (RE 2026-07-10):
- listing: `/page/N/` (WordPress archive), posty pod `/<slug>/` (np. /bkd-368-english-subtitle/)
@ -43,7 +43,18 @@ class JavflixScraper(BaseBrowseScraper):
sitetag = "javflix"
def _listing_url(self, page: int) -> str:
return f"{_BASE}/" if page <= 1 else f"{_BASE}/page/{page}/"
# `/new/` PRZED stroną główną, bo tylko tam trafiają świeże posty: audyt
# 2026-08-20 dał na `/new/` 7 nieznanych scen z 9, a na stronie głównej 0 z 16.
# Strona główna to stały zestaw, ktory od dawna mamy w calosci.
return f"{_BASE}/new/" if page <= 1 else f"{_BASE}/"
def crawl_page(self, page: int):
# Paginacji tu NIE MA, mimo ze WordPress ja udaje: `/page/2/` i `/page/3/`
# oddaja identyczna tresc co strona glowna, a `/new/page/2/` to samo co `/new/`.
# Bez tego ograniczenia browse-latest mielil ten sam zestaw piec razy na run.
if page > 2:
return []
return super().crawl_page(page)
def _extract_scene_urls(self, listing_html: str) -> list[str]:
seen: set[str] = set()
@ -76,7 +87,7 @@ class JavflixScraper(BaseBrowseScraper):
up = _itemprop(detail_html, "uploadDate")
release_date = _parse_iso_date(up) if up else None
# JAV code (BKD-368) ze sluga — kanoniczny identyfikator, dodajemy jako tag
# JAV code (BKD-368) ze sluga, kanoniczny identyfikator, dodajemy jako tag
# (searchable) bo javflix nie ma osobnego pola kodu.
slug = scene_url.rstrip("/").rsplit("/", 1)[-1]
cm = _CODE_RE.match(slug)

View file

@ -1,4 +1,4 @@
"""8kporner.com — browse scraper (PlayTube CMS, jak hqfap/4k69). Dodany 2026-07-27.
"""8kporner.com, browse scraper (PlayTube CMS, jak hqfap/4k69). Dodany 2026-07-27.
Wyróżnik na tle innych kandydatów: **oryginalne tytuły studyjne**, nie przepisane
pod SEO. Do tego realne 4K (zweryfikowane parsowaniem `avc1`: 3840x2160) i przenośny
@ -9,8 +9,8 @@ Trzy odstępstwa od `BasePlayTubeScraper`, wszystkie wymuszone przez stronę:
1. **Sitemap jest za Cloudflare** (`/sitemaps/videos/sitemap-N.xml` → 403 również
PRZEZ Bright Data proxy, sprawdzone). Baza buduje katalog właśnie z sitemapy, więc
`_load_catalog` jest nadpisany: bierzemy homepage (~111 unikatów) + `?link1=videos&
page=latest` (20). To jednocześnie SUFIT — deep crawl jest niemożliwy.
2. **Paginacja nie działa** — `?page=2` i `?page=3` zwracają bajtowo ten sam zestaw
page=latest` (20). To jednocześnie SUFIT, deep crawl jest niemożliwy.
2. **Paginacja nie działa** `?page=2` i `?page=3` zwracają bajtowo ten sam zestaw
co strona 1, a `POST /aj/load-more/latest` wymaga zalogowania (`{"status":400}`).
3. **Forma `/watch/<slug>_<id>.html` jest CF-blokowana (403)**, ale `?link1=watch&id=
<id>` przechodzi czysto. Dlatego `page_url` trzymamy w formie kanonicznej (ładny
@ -21,22 +21,22 @@ Trzy odstępstwa od `BasePlayTubeScraper`, wszystkie wymuszone przez stronę:
strona 1 (najświeższy blok) tylko 4/20, strony 2-6 od 50 do 75%. Reszta katalogu nie
ma żadnego performera i weszłaby jako puste orphany.
Bramka się broni — z 117 performerów wciągniętych pilotem **113 (97%) ma ref
Bramka się broni, z 117 performerów wciągniętych pilotem **113 (97%) ma ref
tpdb/stashdb**, najwyżej ze wszystkich źródeł dodanych w tej rundzie. Pilotowy ingest:
`seen 62, new 25, updated 37, errors 0` — czyli 60% scen przypięło się do czegoś, co
`seen 62, new 25, updated 37, errors 0`, czyli 60% scen przypięło się do czegoś, co
już mamy (to nie jest fabryka orphanów, tylko dodatkowe, lepsze jakościowo źródło).
**Studio.** Scena NIE linkuje studia osobno — na stronie sceny wszystko jest pillem
**Studio.** Scena NIE linkuje studia osobno, na stronie sceny wszystko jest pillem
`/videos/category/`, a taksonomia kategorii (2035 pozycji) miesza gatunki ze studiami.
Rozstrzyga to `/studios`: kuratorowana lista 261 nazw, które są realnymi studiami.
Bierzemy więc pierwszą kategorię obecną na tej liście. Kilkanaście pozycji z listy
to jednak zwykłe słowa („Babes", „Premium", „Swallowed") i te pomijamy, bo inaczej
powtórzylibyśmy błąd z sxyland, gdzie gatunek wylądował jako studio.
Trafień jest mało (1 na 62 w pilocie) — większość scen po prostu nie ma pilla studia,
Trafień jest mało (1 na 62 w pilocie), większość scen po prostu nie ma pilla studia,
ale kosztuje to jeden fetch na run, więc zostaje.
Miniaturki są re-hostowane i zaciemnione base64, więc phash raczej NIE trafi w
canonical — dopasowanie musi opierać się na tytule, obsadzie i długości.
canonical, dopasowanie musi opierać się na tytule, obsadzie i długości.
"""
from __future__ import annotations
@ -61,7 +61,7 @@ _SCENE_HREF_RE = re.compile(
_SCENE_ID_IN_URL_RE = re.compile(r"_(\d+)\.html")
_STUDIO_PILL_RE = re.compile(r'/videos/studio/([^"?]+)"', re.IGNORECASE)
# Pozycje z `/studios`, które są normalnymi gatunkami — jako studio dałyby fałszywkę
# Pozycje z `/studios`, które są normalnymi gatunkami, jako studio dałyby fałszywkę
# na większości scen (patrz sxyland/"MILF"). Kilka z nich to realne marki
# (Swallowed, Throated), ale przy tym wolumenie lepiej nie mieć studia niż mieć złe.
_AMBIGUOUS_STUDIOS = {
@ -119,7 +119,7 @@ class KPorner8Scraper(BasePlayTubeScraper):
def _load_catalog(self) -> list[str] | None:
"""Homepage + `?link1=videos&page=latest`. Sitemap jest CF-blokowany (patrz
docstring), więc to jedyne dostępne źródło URL-i — i zarazem sufit katalogu."""
docstring), więc to jedyne dostępne źródło URL-i, i zarazem sufit katalogu."""
if self._catalog is not None:
return self._catalog
urls: list[str] = []
@ -145,7 +145,7 @@ class KPorner8Scraper(BasePlayTubeScraper):
def crawl_page(self, page: int) -> list[RawScene] | None:
"""Jak w bazie, ale detale pobieramy przez `?link1=watch&id=` (forma `/watch/`
daje 403) i z pauzą — CF tnie przy szybszym tempie."""
daje 403) i z pauzą, CF tnie przy szybszym tempie."""
catalog = self._load_catalog()
if catalog is None:
return None
@ -174,7 +174,7 @@ class KPorner8Scraper(BasePlayTubeScraper):
scene = super()._parse_detail(scene_url, detail_html)
if scene is None:
return None
# BRAMKA: 68% scen nie ma obsady (zmierzone 8/25 z obsadą) — bez niej
# BRAMKA: 68% scen nie ma obsady (zmierzone 8/25 z obsadą), bez niej
# większość katalogu wpadłaby jako puste orphany.
if not scene.performers:
return None

View file

@ -1,4 +1,4 @@
"""latestleaks.co — direct HTML scrape.
"""latestleaks.co, direct HTML scrape.
Search: `https://latestleaks.co/page/<n>/?s=<q>`.
Scene URL: `https://latestleaks.co/<slug>/`.

View file

@ -1,4 +1,4 @@
"""latestpornvideo.com — latest-vids browse scraper.
"""latestpornvideo.com, latest-vids browse scraper.
Historia: dawniej performer-driven search scraper (`/actor/<slug>/`). Problem
(user-report 1da0375e): search-scraper ingestuje TYLKO sceny performerów, których
@ -6,7 +6,7 @@ akurat szukamy → feed strony "latest" nigdy nie wpada, w apce widać stary zes
a na stronie jest świeży. Przerobione na BROWSE (latest chronologicznie z
`/page/<n>/`, page 1 = `/`), 2026-06-22.
Listing card (zero detail-fetchy — detail page nie ma performerów ani duration):
Listing card (zero detail-fetchy, detail page nie ma performerów ani duration):
<article class="... post-<id> ... category-<cat> tag-<x> tag-<y> ...">
<a href="https://latestpornvideo.com/<id>/" title="<Tytuł>">
data-main-thumb="<Studio>-YYYY-MM-DD-...-cover.jpg"
@ -51,7 +51,7 @@ _CLASS_CAT_RE = re.compile(r"\bcategory-([a-z0-9-]+)")
_THUMB_NAME_RE = re.compile(r"/([A-Za-z0-9][A-Za-z0-9-]*?)-(\d{4})-(\d{2})-(\d{2})-", re.IGNORECASE)
# Tytuł: `<Studio> YY MM DD <rest>` (np. "MySexMobile 20 10 23 Abella Danger").
_TITLE_DATE_RE = re.compile(r"^(.*?)\s*\b(\d{2})\s+(\d{2})\s+(\d{2})\b")
# Karty homepage zawsze siedzą w kategorii "latest-porn-videos" — to nie jest tag.
# Karty homepage zawsze siedzą w kategorii "latest-porn-videos", to nie jest tag.
_CAT_SKIP = {"latest-porn-videos", "uncategorized", ""}
@ -137,7 +137,7 @@ class LatestPornVideoScraper(BaseBrowseScraper):
)
# Tagi: tylko prawdziwe kategorie (category-*), bez "latest-porn-videos".
# `tag-*` POMIJAMY — to mieszanka fragmentów imion performerów i gatunków,
# `tag-*` POMIJAMY, to mieszanka fragmentów imion performerów i gatunków,
# bez `actors-*` (jak na /actor/) nie da się ich rozdzielić → byłby szum.
tags: list[RawTag] = []
seen_tag: set[str] = set()

View file

@ -1,4 +1,4 @@
"""mypornerleak.com — performer-page listing scrape (search-based, performer-driven).
"""mypornerleak.com, performer-page listing scrape (search-based, performer-driven).
2026-06-16 fix (zamrożony od 05-07): treść serwowana jest pod subdomeną
`w8.mypornerleak.com` (load-balancer wN.), a stary regex szukał scen na gołym
@ -6,7 +6,7 @@
listingu i sceny, więc fetchujemy `mypornerleak.com/actor/<slug>/` a linki scen
normalizujemy wN.→ kanoniczny (stabilny page_url/dedup).
Wszystko z LISTINGU (bez detail-fetcha) — karta `<article>` koduje metadane:
Wszystko z LISTINGU (bez detail-fetcha), karta `<article>` koduje metadane:
- klasa `actors-<slug>` (multi) → performerzy; `category-<slug>` (multi) → tagi
- `<a href title="...">` → URL sceny + czysty tytuł
- `<span class="duration">MM:SS|HH:MM:SS`
@ -33,7 +33,9 @@ _BASE = "https://mypornerleak.com"
_ARTICLE_RE = re.compile(r'<article[^>]*\bclass="([^"]+)"', re.IGNORECASE)
_LINK_RE = re.compile(r'<a\s+href="([^"]+)"\s+title="([^"]+)"', re.IGNORECASE)
_DURATION_RE = re.compile(r'class="duration">(?:<[^>]+>)*\s*(\d{1,2}:\d{2}(?::\d{2})?)', re.IGNORECASE)
_THUMB_RE = re.compile(r'data-src="([^"]+)"', re.IGNORECASE)
# Do 2026-09 karty mialy lazy `data-src`, teraz zwykle `src` (class video-main-thumb);
# stary wzorzec zostawial 59% zrodel bez miniatury (zgloszenie 94e1da85).
_THUMB_RE = re.compile(r'<img[^>]+(?:data-src|src)="(https?://[^"]+\.(?:jpg|jpeg|png|webp)[^"]*)"', re.IGNORECASE)
_WN_HOST_RE = re.compile(r"https?://w\d+\.mypornerleak\.com", re.IGNORECASE)
_CLASS_ACTOR_RE = re.compile(r"\bactors-([a-z0-9-]+)")
_CLASS_CAT_RE = re.compile(r"\bcategory-([a-z0-9-]+)")

View file

@ -1,4 +1,4 @@
"""mypornerleak.com — latest BROWSE scraper via WordPress REST API, obok search scrapera.
"""mypornerleak.com, latest BROWSE scraper via WordPress REST API, obok search scrapera.
MyPornerLeakScraper (search) zostaje w ALL_DIRECT_SCRAPERS; ten browse dokłada
świeżość wprost z WP REST (`/wp-json/wp/v2/posts?_embed=1`). W odróżnieniu od
@ -28,6 +28,7 @@ from app.connectors.direct_scrapers._browse_base import (
compute_thumbnail_phash,
)
from app.extractors import browser_get
from app.extractors.thumb_extract import extract_thumbnail_url
from app.normalize.text import slugify
log = logging.getLogger(__name__)
@ -85,6 +86,15 @@ class MyPornerLeakBrowseScraper(BaseBrowseScraper):
emb = p.get("_embedded") or {}
fm = emb.get("wp:featuredmedia") or []
thumb = (fm[0].get("source_url") if fm and isinstance(fm[0], dict) else None) or None
if not thumb:
# Od 2026-09 REST nie ma featured media (featured_media=0, brak img w
# tresci); strona posta ma og:image (zgloszenie 94e1da85).
try:
pr = browser_get(link, timeout=15.0)
if pr.status_code == 200:
thumb = extract_thumbnail_url(pr.text)
except Exception as e: # noqa: BLE001
log.debug("mypornerleak og:image fetch failed for %s: %s", link, e)
studio: RawStudio | None = None
tags: list[RawTag] = []

View file

@ -0,0 +1,148 @@
"""naughtymachinima.com — browse scraper. Szóste źródło wertykału, drugie 3D. Dodany 2026-08-13.
Machinima i animacje SFM/Blender, treść tworzona przez użytkowników. Kanon nie istnieje
tak samo jak przy hentai i [[rule34video]], więc każda pozycja jest orphanem i to
poprawne, bez obsady i bez canonical matcha.
**Najzdrowsza jednostka treści z całego wertykału 3D.** Próbka 25 najnowszych: mediana
289 s, ZERO pozycji poniżej minuty, 18/25 powyżej progu potoku (180 s). Dla porównania
rule34video ma medianę 68 s i traci ~70% katalogu na krótkich pętlach. Bramkę zostawiamy
i tak (ten sam `min_ingest_duration_sec` co przy rule34video), żeby `seen` mówiło, ile
realnie wchodzi, ale tutaj odsiewa tylko ~28%.
**Szósty niezależny host w wertykale**: pliki leżą na `videos.naughtymachinima.com`,
obok `gdvid.info` ([[hentaimama]]), `download.xanimeporn.com` ([[xanimeporn]]),
`hgasm<N>.com` ([[hentaigasm]]), `hentaiplanet.info` ([[hentaiplay]]) i `boomio-cdn.com`
([[rule34video]]). Żadna z tych stron nie re-hostuje pozostałych.
Metadane są czyste, bez chmury z sidebara, która wyskoczyła przy siedmiu poprzednich
kandydatach: na stronie filmu jest dokładnie 7 anchorów `/tags/` i wszystkie należą do
filmu (przy „Dreamhawk: The Price Of Joining" są to dreamhawk, aerith gainsborough,
don corneo). Mimo to trzymamy się `class="tag"`, żeby ewentualna przebudowa szablonu
nie wciągnęła nam nawigacji.
**Autora (`/user/`) zapisujemy jako studio**, tak samo jak przy rule34video: dla animacji
to wytwórca, a nie performer.
Paginacja `/videos?page=N` jest chronologiczna, strony 1, 2 i 3 mają zerowe pokrycie.
Playback: jawne `<source>` bez tokenu, patrz extractor `naughtymachinima`.
"""
from __future__ import annotations
import html as html_mod
import logging
import re
from datetime import date
from app.config import get_settings
from app.connectors.base import RawPlaybackSource, RawScene, RawStudio, RawTag
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://www.naughtymachinima.com"
#: Fallback, gdy `min_ingest_duration_sec` jest wyłączone (0): tyle filtruje apka.
_APP_MIN_DURATION_SEC = 60
def _min_duration() -> int:
"""Próg zestrojony z potokiem, patrz docstring [[rule34video]]: czytamy ustawienie
zamiast wpisywać liczbę, żeby liczniki `seen`/`skipped` nie kłamały."""
return getattr(get_settings(), "min_ingest_duration_sec", 0) or _APP_MIN_DURATION_SEC
_SCENE_HREF_RE = re.compile(
r'href="(?:https?://(?:www\.)?naughtymachinima\.com)?(/video/\d+/[^"?#]+)"'
)
_TITLE_RE = re.compile(r"<h1[^>]*>\s*([^<]{2,200}?)\s*</h1>")
_DURATION_RE = re.compile(r'"duration"\s*:\s*"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?"')
_UPLOAD_DATE_RE = re.compile(r'"uploadDate"\s*:\s*"(\d{4})-(\d{2})-(\d{2})')
_THUMB_RE = re.compile(r'property="og:image"[^>]*content="([^"]{10,300})"')
_TAG_RE = re.compile(r'<a class="tag"[^>]*href="[^"]*/tags/[^"]+"[^>]*>\s*([^<]{1,60}?)\s*</a>')
#: Autor: `<a href="/user/Zector95"><img …/><span>Zector95</span>`.
_USER_RE = re.compile(r'href="[^"]*/user/([A-Za-z0-9._-]{2,40})"')
def _duration(detail_html: str) -> int | None:
m = _DURATION_RE.search(detail_html)
if not m:
return None
hours, minutes, seconds = (int(x or 0) for x in m.groups())
return (hours * 3600 + minutes * 60 + seconds) or None
class NaughtyMachinimaScraper(BaseBrowseScraper):
sitetag = "naughtymachinima"
def _listing_url(self, page: int) -> str:
return f"{_BASE}/videos" if page <= 1 else f"{_BASE}/videos?page={page}"
def _extract_scene_urls(self, listing_html: str) -> list[str]:
return [_BASE + p for p in dict.fromkeys(_SCENE_HREF_RE.findall(listing_html))]
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
tm = _TITLE_RE.search(detail_html)
if not tm:
return None
title = html_mod.unescape(tm.group(1)).strip()
if not title:
return None
duration_sec = _duration(detail_html)
if duration_sec is None or duration_sec < _min_duration():
return None
release_date = None
dm = _UPLOAD_DATE_RE.search(detail_html)
if dm:
try:
release_date = date(int(dm.group(1)), int(dm.group(2)), int(dm.group(3)))
except ValueError:
pass
thumbnail_url = None
im = _THUMB_RE.search(detail_html)
if im:
thumbnail_url = im.group(1).strip()
tags: list[RawTag] = []
seen_tag: set[str] = set()
for raw_name in _TAG_RE.findall(detail_html):
name = html_mod.unescape(raw_name).strip()
slug = slugify(name)
if slug and slug not in seen_tag:
seen_tag.add(slug)
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug))
studio = None
um = _USER_RE.search(detail_html)
if um:
name = html_mod.unescape(um.group(1)).strip()
slug = slugify(name)
if slug:
studio = RawStudio(
external_id=f"{self.sitetag}:studio:{slug}", name=name, slug=slug
)
video_id = scene_url.split("/video/")[-1].split("/")[0]
return RawScene(
external_id=f"{self.sitetag}:{video_id}",
title=title,
release_date=release_date,
duration_sec=duration_sec,
url=scene_url,
studio=studio,
performers=[], # animacja, obsada nie istnieje
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=scene_url,
duration_sec=duration_sec,
thumbnail_url=thumbnail_url,
)
],
)

View file

@ -1,22 +1,22 @@
"""neporn.com — latest-vids browse scraper (KVS engine).
"""neporn.com, latest-vids browse scraper (KVS engine).
Dołączony 2026-06-10 (user request). Paysite re-upload (HardX, DAP itp.),
~40k+ scen po numeracji video id.
Sygnały per scena:
- JSON-LD VideoObject: name, description, uploadDate, thumbnailUrl
(uwaga: bywa zmanglowany "https:https://..." — normalizujemy)
(uwaga: bywa zmanglowany "https:https://...", normalizujemy)
- `<meta property="video:duration">` = sekundy wprost
- Performerzy: `<a class="link" href="https://neporn.com/models/<slug>/">`
(nazwa w spanie z ikoną — bierzemy slug i tytułujemy). Świeże uploady NIE mają
jeszcze linków modelek — fallback: flashvars `video_tags` (na świeżych scenach
(nazwa w spanie z ikoną, bierzemy slug i tytułujemy). Świeże uploady NIE mają
jeszcze linków modelek, fallback: flashvars `video_tags` (na świeżych scenach
to czyste nazwiska, np. "emily willis, gianna dior"); bierzemy wpisy 2-3 słowa
obecne w tytule i bez generycznych fraz (stoplist), bo starsze sceny mają tam
też szum ("deep throat", "natural tits", "hd porn").
- Kategorie: linki `/categories/<slug>/` w bloku info (tagi z `/tags/` to szum
typu "hd porn", "2020" — pomijamy)
typu "hd porn", "2020", pomijamy)
Studio: brak strukturalnego pola — tytuł bywa "- HardX Update - ..." ale format
Studio: brak strukturalnego pola, tytuł bywa "- HardX Update - ..." ale format
niespójny, zostawiamy fuzzy matchowi po tytule.
Listing: standard KVS `/latest-updates/N/` (24 sceny/strona).
@ -111,7 +111,7 @@ class NepornScraper(BaseBrowseScraper):
description = (video.get("description") or "").strip() or None
thumbnail_url = (video.get("thumbnailUrl") or "").strip() or None
if thumbnail_url:
# JSON-LD emituje "https:https://cdn..." — utnij zdublowany scheme.
# JSON-LD emituje "https:https://cdn...", utnij zdublowany scheme.
thumbnail_url = re.sub(r"^https?:(?=https?://)", "", thumbnail_url)
performers: list[RawPerformer] = []
@ -128,7 +128,7 @@ class NepornScraper(BaseBrowseScraper):
)
)
# Fallback dla świeżych uploadów bez linków modelek: nazwiska z flashvars
# video_tags — 2-3 słowa, obecne w tytule, bez generycznych fraz.
# video_tags, 2-3 słowa, obecne w tytule, bez generycznych fraz.
title_cf = title.casefold()
tags_m = _VIDEO_TAGS_RE.search(detail_html)
for entry in (tags_m.group(1).split(",") if tags_m else []):
@ -160,7 +160,7 @@ class NepornScraper(BaseBrowseScraper):
seen_tag.add(slug)
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug))
# Phash z KVS screenshotów — niski hit-rate oczekiwany (jak porn00),
# Phash z KVS screenshotów, niski hit-rate oczekiwany (jak porn00),
# graceful miss → composite scoring (performer+title+duration).
fingerprints: list[RawFingerprint] = []
if thumbnail_url:

View file

@ -1,4 +1,4 @@
"""perverzija.com — latest browse scraper via WordPress REST API.
"""perverzija.com, latest browse scraper via WordPress REST API.
Historia: dawniej search scraper (`?s=`), ale 2026-06 perverzija rate-limituje search
(429) a homepage jest JS-renderowane (brak linków postów w surowym HTML) → search
@ -8,7 +8,7 @@ na stronę. VPS dociera (curl_cffi bypassuje JA3; 200 nie 403). Przerobione na b
2026-06-22 (user request).
Z REST `?_embed=1` bierzemy: tytuł, datę, miniaturę (featured_media), STUDIO
(taksonomia `category` — np. "DadCrush"/"TeamSkeet", to studyjny re-up) i tagi
(taksonomia `category`, np. "DadCrush"/"TeamSkeet", to studyjny re-up) i tagi
(`post_tag`). Performerów REST nie wystawia (custom taksonomia `stars` bez show_in_rest)
→ puste, dorabia canonical-merge (content studyjny dobrze matchuje TPDB/StashDB; tytuł
i tak ma nazwiska).

View file

@ -1,17 +1,17 @@
"""porn00.org — latest-vids browse scraper.
"""porn00.org, latest-vids browse scraper.
URL patterns:
- Listing: `/latest-vids/` (page 1), `/latest-vids/2/`, ...
- Scene: `/video/<slug>/`
- Performer: `/<slug>/` (np. `/august-skye/`) — w sekcji "Pornstars:" na detail
- Performer: `/<slug>/` (np. `/august-skye/`), w sekcji "Pornstars:" na detail
- Categories: `/category-name/<slug>/`
Sygnały dostępne:
- Title (listing card + h1 + og:title)
- Performer(s) (z sekcji "Pornstars:" na detail page — pojedynczy slug per link)
- Categories (z sekcji "Categories:" — `/category-name/<slug>/`)
- Performer(s) (z sekcji "Pornstars:" na detail page, pojedynczy slug per link)
- Categories (z sekcji "Categories:", `/category-name/<slug>/`)
- Duration (listing card `<div class="duration">MM:SS</div>`)
- Direct mp4 (KVS engine — `video_url: 'https://www.porn00.org/get_file/.../<id>.mp4'`)
- Direct mp4 (KVS engine, `video_url: 'https://www.porn00.org/get_file/.../<id>.mp4'`)
- Thumbnail (own CDN `/contents/videos_screenshots/.../1.jpg`)
BRAK:
@ -78,7 +78,7 @@ _CATEGORY_LINK_RE = re.compile(
)
# Direct mp4 stream z KVS flashvars: `video_url: 'https://.../43144.mp4/?v-acctoken=...'`.
# URL może mieć cokolwiek po `.mp4`: `/?v-acctoken=...`, `?q=720p`, itp. — bierzemy
# URL może mieć cokolwiek po `.mp4`: `/?v-acctoken=...`, `?q=720p`, itp., bierzemy
# wszystko do najbliższego `'` lub `"`.
_VIDEO_URL_RE = re.compile(
r"""video_url:\s*['"]([^'"]+\.mp4[^'"]*)['"]""", re.IGNORECASE,
@ -107,7 +107,7 @@ class Porn00Scraper(BaseBrowseScraper):
def __init__(self) -> None:
super().__init__()
# Cache listing card meta — duration + thumb + title. Detail page nie ma
# Cache listing card meta, duration + thumb + title. Detail page nie ma
# tych pól w meta (brak og:duration), więc listing jest source of truth.
self._listing_cache: dict[str, dict] = {}
@ -145,7 +145,7 @@ class Porn00Scraper(BaseBrowseScraper):
# Thumbnail: prefer og:image z detail (full-size preview), fallback listing 320x180.
thumb = meta_content(detail_html, property="og:image") or meta.get("thumb")
# Performers — porn00 konwencja `/star-name/<slug>/` (jak `/tags-name/`,
# Performers, porn00 konwencja `/star-name/<slug>/` (jak `/tags-name/`,
# `/category-name/`). Wszystkie linki tego pattern to performerzy.
performers: list[RawPerformer] = []
seen_perf: set[str] = set()
@ -177,14 +177,14 @@ class Porn00Scraper(BaseBrowseScraper):
)
)
# Direct mp4 z KVS flashvars — preferujemy 720p (video_alt_url) nad 360p (video_url).
# Direct mp4 z KVS flashvars, preferujemy 720p (video_alt_url) nad 360p (video_url).
stream_url: str | None = None
if (vm := _VIDEO_ALT_URL_RE.search(detail_html)):
stream_url = vm.group(1)
elif (vm := _VIDEO_URL_RE.search(detail_html)):
stream_url = vm.group(1)
# Phash — porn00 robi własne screenshoty (`/contents/videos_screenshots/`),
# Phash, porn00 robi własne screenshoty (`/contents/videos_screenshots/`),
# więc canonical phash match raczej fail. Próbujemy mimo to.
fingerprints: list[RawFingerprint] = []
if thumb:

View file

@ -1,4 +1,4 @@
"""porn4days.pw — direct HTML scrape.
"""porn4days.pw, direct HTML scrape.
Search: `https://porn4days.pw/page/<n>/?s=<q>`.
Scene URL: `https://porn4days.pw/<slug>/`.

View file

@ -1,22 +1,22 @@
"""pornbusy.com — browse scraper (WordPress + Rank Math). Dodany 2026-07-27.
"""pornbusy.com, browse scraper (WordPress + Rank Math). Dodany 2026-07-27.
Ripy studyjne (~60%) + JAV z kodami (~30%). Ocena orphan-risk LOW z pomiarem:
80% scen z próbki 100 ma performera, który u nas MA już ref tpdb/stashdb, a 21%
tytułów mocno matchuje sceny które już mamy (czyli się scalą, nie zorphanują).
**Paginacja homepage jest ZEPSUTA** — `/page/2/` i `/page/3/` zwracają ten sam
**Paginacja homepage jest ZEPSUTA** `/page/2/` i `/page/3/` zwracają ten sam
zestaw co strona 1 (zweryfikowane). Dlatego listing bierzemy z SITEMAPY:
`sitemap_index.xml` → 10× `post-sitemapN.xml` po ~500 URL-i, każdy z `<lastmod>`,
posortowane od najnowszych. `crawl_page` tnie ten katalog na strony po `_PAGE_SIZE`
(wzorzec jak `_playtube.BasePlayTubeScraper`).
Metadane — każde pole to osobny węzeł `itemprop`, nic nie trzeba wyłuskiwać z
Metadane, każde pole to osobny węzeł `itemprop`, nic nie trzeba wyłuskiwać z
tytułu-slug:
- title `<h1 class="entry-title">`, duration ISO, uploadDate, thumbnailUrl, description
- obsada: `<div id="video-actors">` — CZYSTA, bez pollution (na stronie sceny jest
- obsada: `<div id="video-actors">`, CZYSTA, bez pollution (na stronie sceny jest
dokładnie tyle linków `/actor/` ilu realnych aktorów; brak sekcji Related)
- kategorie + tagi: `<div class="tags-list">` (`/category/` i `/tag/`)
- studio: **brak** — pornbusy nie ma pola studia ani prefiksu w tytule
- studio: **brak** pornbusy nie ma pola studia ani prefiksu w tytule
Playback: `itemprop="embedURL"` → extractor `pornbusycom` (loadvid / seekplayer /
zpi.cx ≈ 69% katalogu).
@ -96,7 +96,7 @@ class PornBusyScraper(BaseBrowseScraper):
# deep_crawl tworzą instancję per run, więc 10 fetchy XML amortyzuje się).
self._catalog: list[str] | None = None
# Listing nie jest stronicowalny GET-em (homepage pagination zepsuta) —
# Listing nie jest stronicowalny GET-em (homepage pagination zepsuta),
# paginację robi sitemap w `crawl_page`. Te dwie metody są abstrakcyjne w bazie.
def _listing_url(self, page: int) -> str: # pragma: no cover - nieużywane
return _SITEMAP_INDEX
@ -131,7 +131,7 @@ class PornBusyScraper(BaseBrowseScraper):
if not loc:
continue
url = loc.group(1)
# Pomijamy strony taksonomii/nav — sceny to `/<slug>/` jednosegmentowe.
# Pomijamy strony taksonomii/nav, sceny to `/<slug>/` jednosegmentowe.
if any(x in url for x in ("/actor/", "/category/", "/tag/", "/page/")):
continue
lm = _LASTMOD_RE.search(block)
@ -189,7 +189,7 @@ class PornBusyScraper(BaseBrowseScraper):
dsm = _DESC_RE.search(detail_html)
description = html.unescape(dsm.group(1)).strip() if dsm else None
if description and description.strip().lower() == title.strip().lower():
description = None # opis bywa kopią tytułu — nie duplikujemy
description = None # opis bywa kopią tytułu, nie duplikujemy
performers: list[RawPerformer] = []
seen_p: set[str] = set()

View file

@ -1,13 +1,13 @@
"""porndish.com — latest browse scraper via WordPress REST API.
"""porndish.com, latest browse scraper via WordPress REST API.
Historia: dawniej search scraper (`?s=`), zamarzł 2026-05-07 (search przestał dawać
nowe sceny — 1151h cisza, watchdog GOON-16). To WordPress (g1/bimber theme), VPS
nowe sceny, 1151h cisza, watchdog GOON-16). To WordPress (g1/bimber theme), VPS
dociera, więc czysty kanał to REST API: `/wp-json/wp/v2/posts?_embed=1` daje
ustrukturyzowany JSON jednym requestem na stronę. Przerobione na browse 2026-06-24
(ten sam wzorzec co perverzija).
Z REST `_embed`: tytuł, data, miniatura (featured_media), STUDIO (taksonomia
`category` — np. "Freeuse Fantasy", content studyjny) i tagi (`post_tag` — porndish
`category`, np. "Freeuse Fantasy", content studyjny) i tagi (`post_tag`, porndish
miesza w nich performerów z gatunkami, bierzemy jak jest; canonical-merge i tak
dorabia performerów z TPDB/StashDB, a tytuł ma nazwiska). Performerów osobno nie
wyciągamy (post_tag ich nie rozdziela od gatunków bez listy known-performers).

View file

@ -1,4 +1,4 @@
"""porndoe.com — latest-vids browse scraper.
"""porndoe.com, latest-vids browse scraper.
Dołączony 2026-05-21 (theporndude audit). Jedyny verified high-value candidate
z 172 tube'ów na theporndude.com/top-porn-tube-sites + /full-porn-movies-sites.
@ -11,7 +11,7 @@ Czemu wart: każda scena ma kompletny **JSON-LD VideoObject** schema:
To wystarczy do composite fuzzy match w resolverze (studio + performer Jaccard +
date proximity + title token-set + duration). Phash hit-rate niski (porndoe robi
własne crop-thumbnaile 390x219, nie hot-linkuje studio art) — ale rich metadata
własne crop-thumbnaile 390x219, nie hot-linkuje studio art), ale rich metadata
nadrabia, jak pornxp/porn00.
URL patterns:
@ -21,7 +21,7 @@ URL patterns:
- Performer: `/pornstars-profile/<slug>`
- Tags/categories: `/categories/<slug>`
Playback: stream URL NIE jest inline w SSR HTML — player JS init dopiero po user
Playback: stream URL NIE jest inline w SSR HTML, player JS init dopiero po user
"Play" click. Dajemy playback_source z page_url + origin `tube:porndoecom`;
extractor w `_REGISTRY` mapuje na `_vps_blocked_fallback.extract` → mobile WebView
INJECTED_JS scrapuje `<video>.src` po phone IP (0 VPS bandwidth, zgodne z
@ -34,6 +34,7 @@ import logging
import re
from datetime import date, datetime
from app.config import get_settings
from app.connectors.base import (
RawFingerprint,
RawPerformer,
@ -51,7 +52,7 @@ log = logging.getLogger(__name__)
_BASE = "https://porndoe.com"
# Scene listing — `<a href="/watch/pd7a3o0e8v2b">`. Id = `pd` + alfanum.
# Scene listing, `<a href="/watch/pd7a3o0e8v2b">`. Id = `pd` + alfanum.
_SCENE_URL_RE = re.compile(r'href="(/watch/[a-z0-9]+)"', re.IGNORECASE)
_WATCH_ID_RE = re.compile(r"/watch/([a-z0-9]+)", re.IGNORECASE)
@ -66,7 +67,7 @@ _TAG_LINK_RE = re.compile(
r'href="/category/\d+/([a-z0-9\-]+)"[^>]*>([^<]+)</a>', re.IGNORECASE
)
# ISO 8601 duration — porndoe emituje "PT8M0S" (czasem "T8M0S" bez P).
# ISO 8601 duration, porndoe emituje "PT8M0S" (czasem "T8M0S" bez P).
_ISO_DUR_RE = re.compile(
r"^P?T?(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$", re.IGNORECASE
)
@ -144,6 +145,24 @@ def _extract_video_object(html: str) -> dict | None:
class PornDoeScraper(BaseBrowseScraper):
sitetag = "porndoecom"
def __init__(self) -> None:
super().__init__()
# Bright Data ISP proxy, bo porndoe zaczal oddawac 403 na IP serwerowni.
# Objawialo sie to jako "tub sie wyczerpal", a nie jako blad: ostatnia nowa
# scena wpadla 2026-08-21, watchdog krzyczal 173 h, a scraper po cichu dostawal
# 403 i zero linkow. Sprawdzone 2026-08-31: bez proxy 403 i 0 scen, przez proxy
# 200 i 31 scen z pierwszej strony.
self._proxy = get_settings().brightdata_proxy_url
if not self._proxy:
log.warning("porndoe: BRIGHTDATA_PROXY_URL nieustawione, scraper wylaczony")
def crawl_page(self, page: int):
# Bez proxy nie ma po co uderzac (gwarantowane 403). Pusta lista = "wyczerpany",
# dzieki czemu deep-crawl nie ponawia w kolko.
if not self._proxy:
return []
return super().crawl_page(page)
def _listing_url(self, page: int) -> str:
if page <= 1:
return f"{_BASE}/videos/most-recent"
@ -237,7 +256,7 @@ class PornDoeScraper(BaseBrowseScraper):
RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug)
)
# Phash z thumbnail (porndoe robi własne crop-thumbnaile — niski hit-rate
# Phash z thumbnail (porndoe robi własne crop-thumbnaile, niski hit-rate
# oczekiwany, ale graceful: brak match → resolver spada do composite scoring).
fingerprints: list[RawFingerprint] = []
if thumbnail_url:
@ -245,7 +264,7 @@ class PornDoeScraper(BaseBrowseScraper):
if ph:
fingerprints.append(RawFingerprint(kind="phash", value=ph))
# Playback — page_url do scena strony. Stream JS-rendered, więc extractor
# Playback, page_url do scena strony. Stream JS-rendered, więc extractor
# `porndoecom` → `_vps_blocked_fallback.extract` (mobile WebView scrape).
playback_sources = [
RawPlaybackSource(

View file

@ -0,0 +1,207 @@
"""porndupe.com, browse scraper po id. Dodany 2026-09-05.
Re-uploader zachodnich scen studyjnych (Nubile Films, NF Busty, Family Strokes, Bad MILFs,
Pure Taboo...), czyli treść, która TRAFIA W KANON TPDB/StashDB zamiast robić orphany.
Pomiar 2026-09-05 na 60 losowych: 52 sceny (87%) nie było w bazie po tytule i performerze.
**Dlaczego chodzenie po id, a nie listing.** Każda ścieżka listingu (`/latest-updates/`,
`/new/`, `/videos/`, `/page/2/`, KVS-owe bloki `mode=async`) oddaje IDENTYCZNĄ stronę
główną z tymi samymi 24 linkami, to catch-all. Sitemapy `videos-1..7.xml` są posortowane
ROSNĄCO i mają 3500 URL-i, a id sięgają 12 887, czyli pokrywają ~27% katalogu. Za to
`/video/<id>/` przekierowuje 301 na slug (istnieje) albo 302 na `/` (brak), więc pełny
katalog daje wzór z vjav: najnowsze = najwyższe id, strona N = kolejny wycinek malejąco.
**Długość przychodzi z OK.ru, nie z HTML.** Strona nie ma `duration` ani `uploadDate`
(JSON-LD tylko z tytułem i miniaturką), a scena bez długości znika w apce pod filtrem
60 s i nie deduplikuje się. Dlatego przy ingeście robimy to samo, co ekstraktor:
odszyfrowujemy klucz sesji i pytamy `api.ok.ru` o `duration` (ms). Jeden dodatkowy
request na scenę, ~12,9 tys. na cały katalog.
**Daty CELOWO nie bierzemy.** `created_ms` z OK.ru to data hurtowego wgrania pliku, nie
premiery: cały katalog ma dokładnie dwie (28-30.07.2024 dla id 1-6000 i 6-7.04.2025 dla
id 12000+). Wpisanie tego jako `release_date` zrobiłoby z 6 tys. scen „premiery" z jednego
dnia. Zostaje None, datę dołoży kanon przy dopasowaniu.
**Katalog jest ZAMROŻONY od kwietnia 2025** (ostatnia fala wgrań), czyli to jednorazowy
import, nie źródło świeżości. Wchodzi mimo to, bo 87% scen jest dla nas nowe i to treść
studyjna trafiająca w kanon, w odróżnieniu od odrzuconego hentaicloud (17% nowych).
Metadane z HTML: `<h1>` (czysty tytuł, bez dopisków), `/pornstar/` (jeden anchor, właściwa
osoba), `/studio/`, `/tag/` (sprawdzone na dwóch scenach: 16 i 11 tagów, 7 wspólnych,
czyli per-video, nie chmura z sidebara), `og:image`.
Playback: ekstraktor `porndupe` (OK.ru, cap 720p, `mobile_direct_ok`).
"""
from __future__ import annotations
import html as html_mod
import logging
import re
import httpx
from app.connectors.base import RawPerformer, RawPlaybackSource, RawScene, RawStudio, RawTag
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.extractors import browser_get
from app.extractors.tubes.porndupe import okru_duration_and_date, okru_video # data nieużywana, patrz docstring
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://www.porndupe.com"
_PER_PAGE = 50
#: Najwyższe id znane 2026-09-05. Od niego idziemy W GÓRĘ, żeby znaleźć bieżące maksimum.
_SEED_ID = 12887
#: Tyle kolejnych braków (302 na `/`) uznajemy za koniec katalogu przy szukaniu maksimum.
_MAX_MISS = 20
_UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/152.0.0.0 Safari/537.36"}
_TITLE_RE = re.compile(r"<h1[^>]*>\s*([^<]{2,200}?)\s*</h1>")
_OG_IMAGE_RE = re.compile(r'property="og:image"[^>]*content="([^"]+)"')
_PERF_RE = re.compile(r'href="[^"]*/pornstar/([^/"]+)/?"[^>]*>\s*([^<]{2,60}?)\s*<')
_STUDIO_RE = re.compile(r'href="[^"]*/studio/([^/"]+)/?"[^>]*>\s*([^<]{2,60}?)\s*<')
_TAG_RE = re.compile(r'<a[^>]*href="[^"]*/tag/([^/"]+)/?"[^>]*>\s*([^<]{1,50}?)\s*</a>')
_VID_RE = re.compile(r"generate_mp4\s*\([^)]*'(\d+)'\s*\)")
class PorndupeScraper(BaseBrowseScraper):
sitetag = "porndupe"
def __init__(self) -> None:
super().__init__()
self._max_id: int | None = None
# ---- ABC wymaga tych dwóch; przy chodzeniu po id są nieużywane -------------------
def _listing_url(self, page: int) -> str: # pragma: no cover
return f"{_BASE}/"
def _extract_scene_urls(self, listing_html: str) -> list[str]: # pragma: no cover
return []
# ---- maksimum id -----------------------------------------------------------------
def _exists(self, vid: int) -> bool | None:
"""True = 301 na slug, False = 302 na `/`, None = błąd sieci (nie wiadomo)."""
try:
r = httpx.get(f"{_BASE}/video/{vid}/", headers=_UA, timeout=20, follow_redirects=False)
except Exception:
return None
return r.status_code == 301 and "/video/" in r.headers.get("location", "")
def _load_max_id(self) -> None:
"""Od `_SEED_ID` w górę do `_MAX_MISS` kolejnych braków. Błąd sieci zostawia None,
co `crawl_page` tłumaczy na transient-fail (NIE na koniec katalogu; patrz vjav)."""
vid, last_ok, miss = _SEED_ID, _SEED_ID, 0
while miss < _MAX_MISS:
vid += 1
ok = self._exists(vid)
if ok is None:
log.warning("porndupe: błąd sieci przy szukaniu maksimum id (%d)", vid)
return
if ok:
last_ok, miss = vid, 0
else:
miss += 1
self._max_id = last_ok
log.info("porndupe: maksimum id = %d", last_ok)
# ---- strona = wycinek id malejąco -----------------------------------------------
def crawl_page(self, page: int) -> list[RawScene] | None:
if self._max_id is None:
self._load_max_id()
if self._max_id is None:
return None
start = self._max_id - (page - 1) * _PER_PAGE
if start < 1:
return []
out: list[RawScene] = []
for vid in range(start, max(start - _PER_PAGE, 0), -1):
try:
res = browser_get(f"{_BASE}/video/{vid}/", timeout=self._timeout, follow_redirects=True)
except Exception as e:
log.info("porndupe: fetch %d fail: %s", vid, e)
continue
final = str(getattr(res, "url", "") or "")
if getattr(res, "status_code", 200) >= 400 or "/video/" not in final:
continue # 302 na `/`, czyli brak takiego id
try:
scene = self._parse_detail(final, res.text, vid=vid)
except Exception as e:
log.info("porndupe: parse %d fail: %s", vid, e)
continue
if scene is not None:
out.append(scene)
return out
def _parse_detail(self, scene_url: str, detail_html: str, *, vid: int | None = None) -> RawScene | None:
tm = _TITLE_RE.search(detail_html)
if not tm:
return None
title = html_mod.unescape(tm.group(1)).strip()
if not title:
return None
if vid is None:
m = _VID_RE.search(detail_html)
vid = int(m.group(1)) if m else None
if vid is None:
return None
# Długość i data z OK.ru. Gdy API padnie, scena wchodzi BEZ długości i loguje to,
# zamiast cicho znikać; potok i tak nie tnie nieznanej długości.
duration_sec = None
release_date = None # celowo: created_ms to data wgrania hurtowego, patrz docstring
v = okru_video(detail_html)
if v:
duration_sec, _upload_date = okru_duration_and_date(v)
else:
log.info("porndupe: brak danych OK.ru dla id=%s", vid)
thumbnail_url = None
im = _OG_IMAGE_RE.search(detail_html)
if im:
thumbnail_url = im.group(1).strip()
performers: list[RawPerformer] = []
seen: set[str] = set()
for _slug, name in _PERF_RE.findall(detail_html):
name = html_mod.unescape(name).strip()
s = slugify(name)
if s and s not in seen:
seen.add(s)
performers.append(RawPerformer(external_id=f"{self.sitetag}:performer:{s}", name=name))
studio = None
sm = _STUDIO_RE.search(detail_html)
if sm:
sname = html_mod.unescape(sm.group(2)).strip()
if slugify(sname):
studio = RawStudio(
external_id=f"{self.sitetag}:studio:{slugify(sname)}", name=sname, slug=slugify(sname)
)
tags: list[RawTag] = []
seen_t: set[str] = set()
for _slug, name in _TAG_RE.findall(detail_html):
name = html_mod.unescape(name).strip()
s = slugify(name)
if s and s not in seen_t:
seen_t.add(s)
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{s}", name=name, slug=s))
return RawScene(
external_id=f"{self.sitetag}:{vid}",
title=title,
release_date=release_date,
duration_sec=duration_sec,
url=scene_url,
studio=studio,
performers=performers,
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=scene_url,
duration_sec=duration_sec,
thumbnail_url=thumbnail_url,
)
],
)

View file

@ -1,4 +1,4 @@
"""pornmike.com — browse scraper (CodeIgniter, JSON-LD). Dodany 2026-07-27.
"""pornmike.com, browse scraper (CodeIgniter, JSON-LD). Dodany 2026-07-27.
Ripy paysite (Tushy, Blacked Raw, Milfy, Deep Lush, Anilos) + nisze bez pokrycia
u nas (Jeffs Models, Golden Slut, Teen Erotica, Red-XXX, Erotik von Nebenan).
@ -6,7 +6,7 @@ Pomiar: 82% performerów ma u nas ref tpdb/stashdb, 19-20/20 kanałów znamy jak
Metadane w CAŁOŚCI z JSON-LD `@graph` → `ItemPage.mainEntity` (VideoObject): name,
duration, uploadDate, actor[], genre[], keywords, description, thumbnailUrl. Zero
`og:`, zero itemprop — więc nie ma ryzyka AI-spinu w og:title (choć sam tytuł i tak
`og:`, zero itemprop, więc nie ma ryzyka AI-spinu w og:title (choć sam tytuł i tak
jest przepisany pod SEO, patrz niżej).
**Świadome decyzje:**
@ -21,7 +21,7 @@ jest przepisany pod SEO, patrz niżej).
zgodnie z regułą `scenes.backfill`, żeby stary katalog nie udawał nowości.
- Paginacja: WYŁĄCZNIE `?p=N`. Forma `/newest-porn-videos/2/` daje 404, a `?page=2`
jest ignorowane (zwraca stronę 1). Na każdej stronie jest stały 10-elementowy blok
sidebara (stąd 62 linki, realnie 52 sceny) — dedup po external_id go pochłania.
sidebara (stąd 62 linki, realnie 52 sceny), dedup po external_id go pochłania.
- Ingest tylko EN (`/videos/`), pomijamy niemiecki mirror (`/de/filme/`).
**Uwaga jakościowa:** to KLIPY 5-12 min (mediana ~487 s) wobec 1800-2400 s w tubach,
@ -29,7 +29,7 @@ które przyjęliśmy. Dla ~20% katalogu (Tushy/Blacked Raw/Milfy/Anilos) będą
obok pełnych scen kanonicznych. Pozostałe ~80% to studia bez żadnego pokrycia u nas,
czyli realnie nowa podaż.
Playback: `<source>` direct mp4 (twincdn) — extractor `pornmike`, bez tokenu, bez
Playback: `<source>` direct mp4 (twincdn), extractor `pornmike`, bez tokenu, bez
Referera, przenośny cross-IP.
"""
from __future__ import annotations

View file

@ -1,9 +1,9 @@
"""PornTrex.com — direct HTML scrape search results.
"""PornTrex.com, direct HTML scrape search results.
Search: `https://www.porntrex.com/search/<q>/` (single page, brak ?page=).
Scene URL: `https://www.porntrex.com/video/<id>/<slug>/`
Porntrex pagination niespójne między widokami — używamy `?from=<offset>` gdy page>1.
Porntrex pagination niespójne między widokami, używamy `?from=<offset>` gdy page>1.
"""
from __future__ import annotations

View file

@ -1,6 +1,6 @@
"""porntrex.com — latest-vids BROWSE scraper (KVS), obok istniejącego search scrapera.
"""porntrex.com, latest-vids BROWSE scraper (KVS), obok istniejącego search scrapera.
PornTrexScraper (search, performer-driven) zostaje w ALL_DIRECT_SCRAPERS — daje
PornTrexScraper (search, performer-driven) zostaje w ALL_DIRECT_SCRAPERS, daje
pokrycie back-catalogu performerów. Ten browse dokłada gwarancję świeżości wprost
z feedu `/latest-updates/<n>/` (próg watchdog 48h zamiast 168h, nie zależy od kolejki
performerów). Wzorzec jak xvideos (search + browse równolegle).
@ -9,7 +9,7 @@ KVS listing tile:
<div ... data-item-id="<id>"><a href="https://www.porntrex.com/video/<id>/<slug>">
<img data-src="//ptx.cdntrex.com/contents/.../300x168/1.jpg" alt="<Tytuł>">
<div class="duration">MM:SS</div>
Playback: KVS, natywny extractor `porntrexcom` (token expires+md5, portable) — bez zmian.
Playback: KVS, natywny extractor `porntrexcom` (token expires+md5, portable), bez zmian.
"""
from __future__ import annotations

View file

@ -1,9 +1,9 @@
"""pornxp.ph — latest-vids browse scraper.
"""pornxp.ph, latest-vids browse scraper.
URL patterns:
- Listing: `https://pornxp.ph/` (page 1, 72 cards) lub `?p=N` (pagination).
URL-e w listing mają randomized suffix per request (`/videos/94528971225` vs
`/videos/94528971837`) — **`data-id` (np. `94528971`) jest stable** i tego
`/videos/94528971837`), **`data-id` (np. `94528971`) jest stable** i tego
używamy dla external_id zamiast całego URL.
- Detail: `/videos/<id_with_suffix>`.
- Tags: `/tags/<URL-encoded-name>`. Trzy kategorie wnioskowane heurystyką
@ -15,10 +15,10 @@ Rich signals (perfekt dla canonical match scoring):
- Performers (z tags w `<div class="tags">`, Capital + space + Capital)
- Release year (regex `Released:` na detail page bodyText)
- Duration (`<div class="item_dur">MM:SS</div>` listing card)
- Direct mp4 streams (`<source src="https://sv.porn-xp.com/.../720.mp4">`) — no hoster
- Direct mp4 streams (`<source src="https://sv.porn-xp.com/.../720.mp4">`), no hoster
- Animated preview (`data-preview="//t.porn-xp.com/.../<id>.mp4"`)
Thumbnail: `<img class="item_img" src="/<id>.jpg">` — relatywny, pornxp's own CDN.
Thumbnail: `<img class="item_img" src="/<id>.jpg">`, relatywny, pornxp's own CDN.
Phash hit-rate niskie ale studio+performer+title fuzzy match wystarczy do canonical.
"""
from __future__ import annotations
@ -40,15 +40,16 @@ from app.connectors.direct_scrapers._browse_base import (
BaseBrowseScraper,
compute_thumbnail_phash,
)
from app.normalize.text import unescape_html
log = logging.getLogger(__name__)
_BASE = "https://pornxp.ph"
# Listing card — DOTALL bo HTML cards są wieloliniowe.
# Listing card, DOTALL bo HTML cards są wieloliniowe.
# Wariant 1 (eager): `<img class="item_img" src="/<id>.jpg">`
# Wariant 2 (lazy): `<img class="item_img lazy" src="/images/fluid_spinner.svg" data-src="/<id>.jpg">`
# Łapiemy obie warianty — w `_parse_listing_thumb` preferujemy `data-src` nad `src`.
# Łapiemy obie warianty, w `_parse_listing_thumb` preferujemy `data-src` nad `src`.
_LISTING_CARD_RE = re.compile(
r'<div class="item preview"\s+data-id="(?P<id>\d+)"'
r'(?:\s+data-preview="(?P<preview>[^"]*)")?[^>]*>'
@ -61,7 +62,7 @@ _LISTING_CARD_RE = re.compile(
_IMG_SRC_RE = re.compile(r'\bsrc="([^"]+)"', re.IGNORECASE)
_IMG_DATASRC_RE = re.compile(r'\bdata-src="([^"]+)"', re.IGNORECASE)
# Detail page — tags wrapper. Sometimes <div class="tags">, sometimes inline.
# Detail page, tags wrapper. Sometimes <div class="tags">, sometimes inline.
# Bierzemy do najbliższego </div> bo tagi tej sceny są w jednym divie.
_DETAIL_TAGS_BLOCK_RE = re.compile(
r'<div class="tags">(?P<inner>.*?)</div>', re.IGNORECASE | re.DOTALL,
@ -71,8 +72,8 @@ _TAG_LINK_RE = re.compile(
)
_RELEASED_RE = re.compile(r'Released:\s*(\d{4})', re.IGNORECASE)
_H1_RE = re.compile(r'<h1[^>]*>([^<]+)</h1>', re.IGNORECASE)
# Direct mp4/m3u8 sources — preferujemy 720 nad 360. Format często protocol-relative:
# `<source src="//sv.porn-xp.com/.../720.mp4">` — normalize do `https://...` w consumerze.
# Direct mp4/m3u8 sources, preferujemy 720 nad 360. Format często protocol-relative:
# `<source src="//sv.porn-xp.com/.../720.mp4">`, normalize do `https://...` w consumerze.
_SOURCE_RE = re.compile(
r'<source\s+src="(?P<url>(?:https?:)?//[^"]+\.(?:mp4|m3u8))"',
re.IGNORECASE,
@ -99,11 +100,11 @@ def _classify_tag(name: str) -> str:
- Studio: zawiera `.` (`TheTeenBay.co`, `Clips4sale.tv`) LUB CamelCase concat
bez spacji (`LegalPorno`, `DirtyWivesClub`, `AnalMom`, `Clips4sale`)
- Performer: dokładnie 2 słowa Capital + Capital (`Alix Lynx`, `Reagan Foxx`)
- Tag/category: pozostałe — lowercase single word LUB Cap single word
- Tag/category: pozostałe, lowercase single word LUB Cap single word
(`oral`, `Lesbians`, `Incest`, `BBC`)
Edge case: single-word studio jak "Brazzers", "Vixen" → klasyfikowane jako tag.
To akceptowalne — composite score scoring tags ma niższą wagę niż studio match,
To akceptowalne, composite score scoring tags ma niższą wagę niż studio match,
więc fallback z 1+ performer match wystarczy.
"""
name = name.strip()
@ -136,7 +137,7 @@ class PornXPScraper(BaseBrowseScraper):
def __init__(self) -> None:
super().__init__()
# Cache listing card metadata per scene URL — populated w `_extract_scene_urls`,
# Cache listing card metadata per scene URL, populated w `_extract_scene_urls`,
# consumed w `_parse_detail`. Detail page sam nie ma `<div class="item_dur">`
# ani thumbnail URL, tylko h1+tags+sources. Cache reset per page (każde
# _extract_scene_urls override'uje).
@ -182,33 +183,33 @@ class PornXPScraper(BaseBrowseScraper):
),
"thumb": thumb,
"duration_sec": _parse_mmss(m.group("dur") or ""),
"title": m.group("title").strip(),
"title": unescape_html(m.group("title")),
}
out.append(url)
return out
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
# Listing-card meta (preferowane — detail page nie ma duration/thumb)
# Listing-card meta (preferowane, detail page nie ma duration/thumb)
meta = self._listing_cache.get(scene_url, {})
data_id = meta.get("data_id")
if not data_id:
# URL nie pasuje do listingu (random suffix mismatch po pagination redo).
# Wyciągnij data-id z URL: /videos/<id>... — pierwsze 8-10 cyfr.
# Wyciągnij data-id z URL: /videos/<id>..., pierwsze 8-10 cyfr.
id_match = re.search(r"/videos/(\d{6,12})", scene_url)
data_id = id_match.group(1) if id_match else None
# Title: prefer h1 over listing card title (detail h1 jest cleaner)
title = meta.get("title") or ""
if (m := _H1_RE.search(detail_html)):
title = m.group(1).strip() or title
title = unescape_html(m.group(1)) or title
if not title:
return None
duration_sec = meta.get("duration_sec")
thumb = meta.get("thumb")
# Release year — `Released: 2016`. RawScene ma `release_date` (typu `date`),
# nie samo year — wpisujemy Jan 1 jako placeholder żeby resolver miał year
# Release year, `Released: 2016`. RawScene ma `release_date` (typu `date`),
# nie samo year, wpisujemy Jan 1 jako placeholder żeby resolver miał year
# signal (date proximity scoring tylko sprawdza year w composite).
release_date: date | None = None
if (m := _RELEASED_RE.search(detail_html)):
@ -251,7 +252,7 @@ class PornXPScraper(BaseBrowseScraper):
tags.append(RawTag(external_id=ext_id, name=display, slug=slug))
# Playback: direct mp4 streams `<source src="//sv.porn-xp.com/.../720.mp4">`.
# URL-e są protocol-relative — normalize do `https:`. Preferujemy 720 nad 360.
# URL-e są protocol-relative, normalize do `https:`. Preferujemy 720 nad 360.
def _norm(u: str) -> str:
return "https:" + u if u.startswith("//") else u
@ -264,7 +265,7 @@ class PornXPScraper(BaseBrowseScraper):
break
stream_url = stream_url or all_sources[0]
# Phash z thumbnail (pornxp własny CDN — expected niski match rate, ale
# Phash z thumbnail (pornxp własny CDN, expected niski match rate, ale
# try). Reseter ścieżek do canonical odbędzie się głównie przez
# studio+performer+year+title scoring.
fingerprints: list[RawFingerprint] = []
@ -275,7 +276,7 @@ class PornXPScraper(BaseBrowseScraper):
# Normalize page_url: pornxp homepage serwuje random URL suffix per request
# (`/videos/94528971225` vs `/videos/94528971836` ten sam scene). PlaybackSource
# unique key to `(origin, page_url)` — bez normalize generujemy 3x duplikaty
# unique key to `(origin, page_url)`, bez normalize generujemy 3x duplikaty
# na każdym scrape run. Canonical URL = `/videos/<data_id>`.
canonical_url = (
f"{_BASE}/videos/{data_id}" if data_id else scene_url

View file

@ -0,0 +1,168 @@
"""rule34video.com — browse scraper. Piąte źródło wertykału, pierwsze 3D. Dodany 2026-08-13.
Inna charakterystyka niż cztery strony hentai ([[hentaimama]], [[xanimeporn]],
[[hentaigasm]], [[hentaiplay]]): to nie są odcinki serii, tylko pojedyncze animacje
SFM/Blender robione przez autorów. Kanon nie istnieje tak samo jak przy hentai, więc
każda pozycja jest orphanem i to poprawne, bez obsady i bez canonical matcha.
**Krótka treść wycina ~70% katalogu.** Rozkład 30 najnowszych: mediana 68 s, 14 pozycji
poniżej minuty, najkrótsza 7 s. Dla animacji SFM taka pętla to kompletna praca, a nie
wycinek (inaczej niż przy sexu, gdzie klipy były okrojoną wersją scen istniejących gdzie
indziej), ale nie mieści się w jednostce treści, na której stoi reszta systemu.
Bramkujemy tym samym progiem co potok (`min_ingest_duration_sec`, na prodzie 180 s),
zamiast własną liczbą. Pierwsza wersja miała 60 s (tyle filtruje apka) i przez to liczniki
kłamały: pilot na 3 stronach dał `seen 57, new 31, skipped 26`, bo `_skip_short_tube_scene`
i tak dorzynał wszystko poniżej 3 minut. Przy zestrojonym progu `seen` mówi, ile realnie
wchodzi, a to jest sygnał, którego używa watchdog i `source_stats`.
**Pułapka tagów, ODWROTNA do intuicji.** Na stronie są dwa zestawy anchorów `/tags/`:
<a class="button" href="/tags/35455/">cell (dbz)</a> ← chmura „Top Tags", 25 szt.
<a class="tag_item" href="/tags/122/">handjob</a> ← realne tagi filmu, 4 szt.
`class="button"` wygląda na „ten właściwy", a jest chmurą z sidebara: przy filmie o Lux
z League of Legends siedzą tam „cell (dbz)" i „queen of pain (dota)". Bierzemy WYŁĄCZNIE
`tag_item`, inaczej każdy film dostałby ten sam zestaw 25 śmieci.
Autor i kategorie mają własną klasę `video_meta_pill` i występują po 1-2 razy, czyli tam
chmury nie ma. **Autora zapisujemy jako studio**, bo dla 3D to on jest wytwórcą
(Monarchnsfw, jackerman, derpixon), a nie żaden performer.
Paginacja `/latest-updates/N/` jest chronologiczna i sprawdzona: strony 1, 2 i 3 mają
zerowe pokrycie (w odróżnieniu od freshporno czy streamporn.vip, gdzie listing rotował).
Playback: `get_file` z tokenem ZWIĄZANYM Z IP, patrz extractor `rule34video`.
"""
from __future__ import annotations
import html as html_mod
import logging
import re
from datetime import date
from app.connectors.base import RawPlaybackSource, RawScene, RawStudio, RawTag
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.config import get_settings
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://rule34video.com"
#: Fallback, gdy `min_ingest_duration_sec` jest wyłączone (0): tyle filtruje apka.
_APP_MIN_DURATION_SEC = 60
def _min_duration() -> int:
"""Próg zestrojony z potokiem, patrz docstring. Czytamy ustawienie zamiast wpisywać
liczbę, żeby zmiana `min_ingest_duration_sec` nie rozjechała się z tym scraperem."""
return getattr(get_settings(), "min_ingest_duration_sec", 0) or _APP_MIN_DURATION_SEC
_SCENE_HREF_RE = re.compile(r'href="(https?://rule34video\.com/video/\d+/[^"]+)"')
_TITLE_RE = re.compile(r'<h1 class="title_video">\s*([^<]{1,200}?)\s*</h1>')
_DURATION_RE = re.compile(r'"duration"\s*:\s*"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?"')
_UPLOAD_DATE_RE = re.compile(r'"uploadDate"\s*:\s*"(\d{4})-(\d{2})-(\d{2})')
_THUMB_RE = re.compile(r'"thumbnailUrl"\s*:\s*\[?\s*"([^"]{10,300})"')
#: TYLKO `tag_item`, patrz odwrócona pułapka w docstringu.
_TAG_RE = re.compile(r'<a class="tag_item"[^>]*href="[^"]*/tags/\d+/"[^>]*>\s*([^<]{1,60}?)\s*</a>')
#: Autor i kategorie — `video_meta_pill`, nazwa bywa opakowana w zagnieżdżone tagi.
_PILL_RE = re.compile(
r'<a class="[^"]*video_meta_pill[^"]*"[^>]*href="[^"]*/(models|categories)/([a-z0-9-]+)/"[^>]*>(.*?)</a>',
re.DOTALL,
)
_STRIP_TAGS_RE = re.compile(r"<[^>]+>")
def _text(fragment: str) -> str:
return html_mod.unescape(_STRIP_TAGS_RE.sub(" ", fragment)).strip()
def _duration(detail_html: str) -> int | None:
m = _DURATION_RE.search(detail_html)
if not m:
return None
hours, minutes, seconds = (int(x or 0) for x in m.groups())
total = hours * 3600 + minutes * 60 + seconds
return total or None
class Rule34VideoScraper(BaseBrowseScraper):
sitetag = "rule34video"
def _listing_url(self, page: int) -> str:
return f"{_BASE}/latest-updates/" if page <= 1 else f"{_BASE}/latest-updates/{page}/"
def _extract_scene_urls(self, listing_html: str) -> list[str]:
return list(dict.fromkeys(_SCENE_HREF_RE.findall(listing_html)))
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
tm = _TITLE_RE.search(detail_html)
if not tm:
return None
title = html_mod.unescape(tm.group(1)).strip()
if not title:
return None
duration_sec = _duration(detail_html)
if duration_sec is None or duration_sec < _min_duration():
return None # krótka pętla, potok i tak by ją odrzucił jako trailer
release_date = None
dm = _UPLOAD_DATE_RE.search(detail_html)
if dm:
try:
release_date = date(int(dm.group(1)), int(dm.group(2)), int(dm.group(3)))
except ValueError:
pass
thumbnail_url = None
im = _THUMB_RE.search(detail_html)
if im:
thumbnail_url = im.group(1).strip().replace("\\/", "/")
tags: list[RawTag] = []
seen_tag: set[str] = set()
for raw_name in _TAG_RE.findall(detail_html):
name = html_mod.unescape(raw_name).strip()
slug = slugify(name)
if slug and slug not in seen_tag:
seen_tag.add(slug)
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug))
# Autor animacji → studio (dla 3D to wytwórca, nie performer). Kategorie → tagi.
studio = None
for kind, _slug_in_url, inner in _PILL_RE.findall(detail_html):
name = _text(inner)
slug = slugify(name)
if not name or not slug:
continue
if kind == "models":
if studio is None:
studio = RawStudio(
external_id=f"{self.sitetag}:studio:{slug}", name=name, slug=slug
)
elif slug not in seen_tag:
seen_tag.add(slug)
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug))
video_id = scene_url.rstrip("/").split("/video/")[-1].split("/")[0]
return RawScene(
external_id=f"{self.sitetag}:{video_id}",
title=title,
release_date=release_date,
duration_sec=duration_sec,
url=scene_url,
studio=studio,
performers=[], # animacja, obsada nie istnieje
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=scene_url,
duration_sec=duration_sec,
thumbnail_url=thumbnail_url,
)
],
)

View file

@ -1,4 +1,4 @@
"""sexu.com — browse scraper. Dodany 2026-07-27.
"""sexu.com, browse scraper. Dodany 2026-07-27.
Najlepsze metadane z całej tegorocznej serii kandydatów. Próbka 25 najnowszych scen:
obsada 20/25 (80%), **kanał 25/25 i to realne studia** (New Sensations, Brutal X,
@ -15,7 +15,7 @@ odrzuciłaby dobre dane.
1. **Tagi trzeba wyciąć z bloku.** Linków `/tag/` jest na stronie 25, ale realnych
tagów sceny tylko 5 (reszta to sidebar „popularne wyszukiwania"). Bez zawężenia
do bloku „Tags:" wpisalibyśmy każdej scenie te same 20 śmieci.
2. **W bloku tagów siedzą też linki `/search?q=`** — to fraz-y pod SEO („pleases",
2. **W bloku tagów siedzą też linki `/search?q=`** to fraz-y pod SEO („pleases",
„hole", „indian milf"), nie tagi. Bierzemy wyłącznie `/tag/`.
**Studio.** Kanał to zwykle studio, ale przy uploadach amatorskich bywa nazwany po
@ -25,7 +25,7 @@ bo performer w tabeli studios to zanieczyszczenie, nie dana.
Data jest względna („7 hours ago", „yesterday"), więc liczymy ją wstecz od teraz.
To i tak data uploadu na tube, nie premiery studia.
Playback: `POST /api/video-info` (extractor `sexu`) — wymaga Bright Data, bo
Playback: `POST /api/video-info` (extractor `sexu`), wymaga Bright Data, bo
Cloudflare blokuje POST z VPS. Sam scrape listingu i detali idzie bez proxy.
"""
from __future__ import annotations
@ -67,7 +67,7 @@ _BLOCK_END_RE = re.compile(r"_container|player-tags__title")
def _block(detail_html: str, label: str) -> str:
"""Fragment HTML należący do bloku `<label>:` — patrz pułapka 1 w docstringu."""
"""Fragment HTML należący do bloku `<label>:`, patrz pułapka 1 w docstringu."""
start = detail_html.find(f'player-tags__title">{label}:')
if start < 0:
return ""

View file

@ -1,10 +1,10 @@
"""shyfap.net — latest-vids browse scraper.
"""shyfap.net, latest-vids browse scraper.
Browse-only (nie search-driven). Sitetag `shyfapnet`. Bogata metadata na detail
page'u (meta tags + body links): title, studio, performers, tags, duration,
description, upload_date, embed_url.
Pierwszy pilot scrapera browse-mode (2026-05-12) — weryfikacja czy detail-page
Pierwszy pilot scrapera browse-mode (2026-05-12), weryfikacja czy detail-page
metadata wystarcza do canonical match >5%. Jeśli tak → rozszerzamy o porn00,
fullmovies, pornxp, freshporno, 4k69, hdporn.gg.
@ -37,7 +37,7 @@ _PORNSTAR_LINK_RE = re.compile(
_TAG_LINK_RE = re.compile(
r'href="/tag/([a-z0-9\-]+)_t(\d+)/"[^>]*>([^<]+)', re.IGNORECASE
)
# /video/<slug>_v<id>/ — id z URL używamy jako stable internal ID (np. w external_id),
# /video/<slug>_v<id>/ id z URL używamy jako stable internal ID (np. w external_id),
# nie z meta `ya:ovs:id` żeby uniknąć rozjazdu meta vs URL.
_INTERNAL_ID_RE = re.compile(r"_v(\d+)/?$", re.IGNORECASE)
@ -46,7 +46,7 @@ class ShyfapScraper(BaseBrowseScraper):
sitetag = "shyfapnet"
def _listing_url(self, page: int) -> str:
# page 1 → /videos_1/, page 2 → /videos_1/2/ (shyfap quirk — sufiks `_1`
# page 1 → /videos_1/, page 2 → /videos_1/2/ (shyfap quirk, sufiks `_1`
# zawsze, dodatkowy `/N/` dla pagination)
if page <= 1:
return f"{_BASE}/videos_1/"
@ -108,7 +108,7 @@ class ShyfapScraper(BaseBrowseScraper):
if not embed_url and internal_id:
embed_url = f"{_BASE}/embed/{internal_id}"
# Studio — pierwszy `/studio/<slug>_s<id>/` link na stronie
# Studio, pierwszy `/studio/<slug>_s<id>/` link na stronie
studio: RawStudio | None = None
m_studio = _STUDIO_LINK_RE.search(detail_html)
if m_studio:
@ -119,7 +119,7 @@ class ShyfapScraper(BaseBrowseScraper):
slug=slug,
)
# Performers — wszyscy `/pornstar/<slug>_p<id>/` (zwykle 1-3 per scena)
# Performers, wszyscy `/pornstar/<slug>_p<id>/` (zwykle 1-3 per scena)
performers: list[RawPerformer] = []
seen_perf: set[str] = set()
for m_p in _PORNSTAR_LINK_RE.finditer(detail_html):
@ -134,7 +134,7 @@ class ShyfapScraper(BaseBrowseScraper):
)
)
# Tags — wszystkie `/tag/<slug>_t<id>/` (zwykle 10-25 per scena)
# Tags, wszystkie `/tag/<slug>_t<id>/` (zwykle 10-25 per scena)
tags: list[RawTag] = []
seen_tag: set[str] = set()
for m_t in _TAG_LINK_RE.finditer(detail_html):
@ -146,8 +146,8 @@ class ShyfapScraper(BaseBrowseScraper):
RawTag(external_id=f"shyfapnet:tag:{tid}", name=name, slug=slug)
)
# Playback source — embed_url (mobile WebView fallback). Stream extraction
# przez app/extractors/__init__.py wymaga osobnego registry entry — dla
# Playback source, embed_url (mobile WebView fallback). Stream extraction
# przez app/extractors/__init__.py wymaga osobnego registry entry, dla
# pilot scrapera zostawiamy embed-only (WebView), direct mp4 to follow-up.
playback_sources = [
RawPlaybackSource(
@ -161,7 +161,7 @@ class ShyfapScraper(BaseBrowseScraper):
# Perceptual hash z thumbnail. Resolver Path 3 (find_by_phash_within,
# Hamming ≤5) auto-merguje gdy TPDB/StashDB ma fingerprint tej samej sceny.
# Niezależne od shyfap title-rebrandingu — bierze się z frame'u sceny.
# Niezależne od shyfap title-rebrandingu, bierze się z frame'u sceny.
fingerprints: list[RawFingerprint] = []
if thumbnail_url:
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")

View file

@ -1,4 +1,4 @@
"""siska.video — latest-vids browse scraper.
"""siska.video, latest-vids browse scraper.
Historia: dawniej performer-driven search scraper (`?s=<q>`), ale siska zepsuła
wyszukiwarkę (zwraca latest niezależnie od query). Przerobione na BROWSE (latest

View file

@ -1,22 +1,22 @@
"""superporn.com — latest-vids browse scraper (przez Bright Data ISP proxy).
"""superporn.com, latest-vids browse scraper (przez Bright Data ISP proxy).
Dołączony 2026-06-10 (user request). superporn twardo blokuje VPS IP Cloudflarem
(403 na KAŻDEJ impersonacji TLS — chrome/safari/firefox), więc ingest HTML idzie
(403 na KAŻDEJ impersonacji TLS, chrome/safari/firefox), więc ingest HTML idzie
przez Bright Data ISP proxy (`settings.brightdata_proxy_url`). Gdy proxy
nieskonfigurowane → scraper no-op (pusty iterator, log warning).
Proxy używamy TYLKO do scrape HTML. Playback NIE idzie przez proxy: `<source>` mp4
(cdnst*.superporn.com) ma token IP-bound do fetchera (403 cross-IP), więc resolve
musi nastąpić po stronie telefonu — extractor `superporncom` → `_vps_blocked_fallback`
musi nastąpić po stronie telefonu, extractor `superporncom` → `_vps_blocked_fallback`
(mobile WebView ładuje stronę z residential IP telefonu, INJECTED_JS bierze video.src).
Thumbnaile (img*.superporn.com) schodzą z VPS bez proxy (image proxy działa).
Sygnały (SSR HTML):
- JSON-LD VideoObject: name, description, uploadDate, thumbnailUrl, duration
ISO 8601 (`P0DT0H38M48S`). `author` = uploader (NIE performer — pomijamy).
ISO 8601 (`P0DT0H38M48S`). `author` = uploader (NIE performer, pomijamy).
- Performerzy: chip `<a class="chip-link" href=".../pornstar/<slug>"><span>Name</span>`
- Kategorie: chip `<a class="chip-link" href="/<slug>"><span>Name</span>` (bez
`/pornstar/`) — w bloku `#collapse-categories`.
`/pornstar/`), w bloku `#collapse-categories`.
- Duration backup: `<video ... data-video-duration="2328">` (sekundy).
Listing: `?page=N` (newest-first; sitemap lastmod jest z 2024, bezużyteczny).
@ -69,7 +69,7 @@ _ISO_DUR_FULL_RE = re.compile(
def _clean(text: str) -> str:
"""superporn dwukrotnie HTML-koduje encje w JSON-LD (`&amp;#39;` zamiast `'`).
Unescape iteracyjnie (max 2) — bez over-decode dla zwykłych nazw."""
Unescape iteracyjnie (max 2), bez over-decode dla zwykłych nazw."""
for _ in range(2):
new = html.unescape(text)
if new == text:
@ -95,7 +95,7 @@ class SuperpornScraper(BaseBrowseScraper):
def __init__(self) -> None:
super().__init__()
# Bright Data ISP proxy — bez niego superporn jest nieosiągalny z VPS.
# Bright Data ISP proxy, bez niego superporn jest nieosiągalny z VPS.
self._proxy = get_settings().brightdata_proxy_url
if not self._proxy:
log.warning("superporn: BRIGHTDATA_PROXY_URL unset — scraper disabled")
@ -106,7 +106,7 @@ class SuperpornScraper(BaseBrowseScraper):
return f"{_BASE}/?page={page}"
def crawl_page(self, page: int):
# Bez proxy nie ma sensu uderzać (gwarantowany CF 403) — sygnalizuj
# Bez proxy nie ma sensu uderzać (gwarantowany CF 403), sygnalizuj
# "exhausted" (pusta lista), żeby deep-crawl nie retry'ował w kółko.
if not self._proxy:
return []
@ -164,7 +164,7 @@ class SuperpornScraper(BaseBrowseScraper):
seen_tag.add(slug)
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug))
# Phash z thumbnaila — proxy NIE potrzebny (img*.superporn.com działa z VPS).
# Phash z thumbnaila, proxy NIE potrzebny (img*.superporn.com działa z VPS).
fingerprints: list[RawFingerprint] = []
if thumbnail_url:
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")

View file

@ -1,4 +1,4 @@
"""supjav.com — JAV browse scraper (przez Bright Data proxy, CF-blokuje VPS IP).
"""supjav.com, JAV browse scraper (przez Bright Data proxy, CF-blokuje VPS IP).
Osobna pula JAV (sekcja JAV), origin `tube:supjav` w JAV_ORIGINS. supjav agreguje
embedy (serwery TV/FST/ST/VOE), gdzie realny hoster jest schowany za `data-link`
@ -10,7 +10,7 @@ Browse (RE 2026-07-10):
- karty: `<div class="post"><a href="/<id>.html" title="<title>" rel="bookmark"
class="img"><img src=".../images/YYYY/MM/<CODE>.jpg!320x216.jpg" class="thumb">`.
- Metadane bierzemy Z KARTY (tytuł/thumb/URL/kod JAV/data z roku-miesiąca ścieżki
thumb), NIE z detalu — detal przez proxy jest flaky (CF przepuszcza ~40% IP), a
thumb), NIE z detalu, detal przez proxy jest flaky (CF przepuszcza ~40% IP), a
per-post fetch byłby wolny i lossy. Studio/tagi (poza kodem) dorabia enrich później.
supjav blokuje VPS IP twardym CF → listing idzie przez Bright Data ISP proxy z retry
@ -36,7 +36,7 @@ _BASE = "https://supjav.com"
# atrybutu nie zawiera surowego "), więc tytuły z `>` (np. `<<3-Day Offer>>`) przechodzą.
# Link-obraz karty: <a href="URL" ... title="TITLE"><img src|data-original="THUMB">.
# supjav miesza kolejność atrybutów (href/rel/class="img"/title w dwóch wariantach), więc
# NIE wymagamy pozycji class="img" — wymóg `>\s*<img` i tak izoluje link-obraz od
# NIE wymagamy pozycji class="img", wymóg `>\s*<img` i tak izoluje link-obraz od
# link-tekstu (ten drugi po `>` ma tekst, nie <img>). Thumb eager `src=` lub lazy
# `data-original=` (karty poniżej folda: src="data:..." placeholder + realny data-original).
_CARD_RE = re.compile(

View file

@ -1,11 +1,11 @@
"""sxyland.com — performer-page scrape (search-based, performer-driven).
"""sxyland.com, performer-page scrape (search-based, performer-driven).
2026-06-16 fix (zamrożony od 06-07): sxyland porzucił URL scen `/<numeric_id>/<slug>/`
na rzecz `/<slug>/`, więc stary regex (wymagał cyfry w ścieżce) dawał 0. WordPress `?s=`
filtruje, ale miesza — czystsze są **strony performera** `/actor/<slug>/`
filtruje, ale miesza, czystsze są **strony performera** `/actor/<slug>/`
(performer-driven query = nazwa performera → slugify → /actor/<slug>/).
Bogate metadane (per-scene detail fetch — sxyland to WP tube, taksonomie na scenie):
Bogate metadane (per-scene detail fetch, sxyland to WP tube, taksonomie na scenie):
- performerzy: WSZYSTKIE `/actor/<slug>/` linki (z co-starami; `title="Name"`)
- tagi: `/tag/` + `/category/` (`title="Name"`); część to studia (BangBros/BLACKED/...)
- studio: heurystycznie z tagów-paysite (`_STUDIO_TAGS`); brak match → bez studio
@ -181,7 +181,7 @@ class SxyLandScraper(BaseDirectTubeScraper):
)
)
# Tagi — TYLKO z bloku tagów sceny (nie z sidebara/popular widgetu).
# Tagi, TYLKO z bloku tagów sceny (nie z sidebara/popular widgetu).
tags: list[RawTag] = []
seen_tag: set[str] = set()
block_m = _TAGS_BLOCK_RE.search(detail)

View file

@ -1,4 +1,4 @@
"""sxyprn.com — performer-page scrape (search-based, performer-driven).
"""sxyprn.com, performer-page scrape (search-based, performer-driven).
2026-06-16 fix (zamrożony od 05-07): sxyprn NIE ma free-text searcha. Stary endpoint
`?type=videos&query=<q>` oddawał TRENDING (0 trafień dla performera → strict token
@ -19,6 +19,14 @@ Metadane z karty (bez +1 fetch detalu):
- thumbnail: `data-src='//...small.jpg'`
Playback resolwuje extractor `sxyprncom` (osobno; mp4 z trafficdeposit/lulustream).
Blokada uploaderow (2026-09-04). Kilku autorow wrzuca dobre sceny z tytulem w stylu
„Tytul ft. Obsada, Obsada by Studio Kategoria, Kategoria, ..." i 25-35 hashtagami
na kazdym poscie. Kazde nazwisko z tytulu to potencjalne przypisanie performera
(over-attribution), a hashtagi wchodzily jako tagi sceny (mediana 27 zamiast 12).
Karta nie ma pola autora, ale id autora siedzi w sciezce miniatury:
`.../<token>/<ts>/<id_autora>/<id_posta>/small.jpg`. Autorow z `_BLOCKED_AUTHORS`
pomijamy w calosci, a karty z wiecej niz `_MAX_HASHTAGS` hashtagami wchodza bez tagow.
"""
from __future__ import annotations
@ -35,31 +43,42 @@ from app.connectors.base import (
)
from app.connectors.direct_scrapers._search_base import BaseSearchScraper
from app.extractors import browser_get
from app.normalize.text import slugify
from app.normalize.text import person_named_in, slugify
log = logging.getLogger(__name__)
_BASE = "https://sxyprn.com"
# js-pop anchor karty: /post/<id>.html(?sk=...&so=...&ss=latest na stronie performera)
# + aria-label (połamani performerzy — nieużywane).
# + aria-label (połamani performerzy, nieużywane).
_CARD_ANCHOR_RE = re.compile(
r"<a\s+href='/post/(?P<id>[a-f0-9]+)\.html(?:\?[^']*)?'[^>]*class='js-pop'",
re.IGNORECASE,
)
# Uwaga: duration_small ma atrybut `title='s1->c10'` ze znakiem `>` w środku, więc
# NIE używamy `[^>]*` (łamie się na tym `>`) — leniwe `.*?` do wartości HH:MM:SS.
# NIE używamy `[^>]*` (łamie się na tym `>`), leniwe `.*?` do wartości HH:MM:SS.
_DURATION_RE = re.compile(
r"duration_small.*?>\s*(\d{1,2}:\d{2}(?::\d{2})?)\s*<", re.IGNORECASE | re.DOTALL
)
_SUBCAT_RE = re.compile(r"post_el_small_subcat[^>]*>([^<]+)<", re.IGNORECASE)
_THUMB_RE = re.compile(r"data-src='(//[^']+?small\.jpg)'", re.IGNORECASE)
# post_time z tytułem — keyowane po ID, bo leży w post_control PO vid_container.
# post_time z tytułem, keyowane po ID, bo leży w post_control PO vid_container.
_POSTTIME_RE = re.compile(
r"post_time'[^>]*href='/post/{id}\.html(?:\?[^']*)?'[^>]*title='([^']*)'",
re.IGNORECASE,
)
_HASHTAG_RE = re.compile(r"#(\w[\w-]*)")
# `.../<id_autora>/<id_posta>/small.jpg`, oba 13 hex. Drugi segment MUSI byc id posta,
# inaczej to inny format miniatury i autora nie znamy.
_AUTHOR_IN_THUMB_RE = re.compile(r"/([a-f0-9]{13})/([a-f0-9]{13})/[^/]+$", re.IGNORECASE)
# id autora sxyprn -> nazwa (tylko do logow). Patrz docstring modulu.
_BLOCKED_AUTHORS: dict[str, str] = {
"69cab3467ec41": "Shizuka",
"68e92925d23d9": "Ajx",
"6a0d125a875d2": "Denzel",
}
# Powyzej tylu hashtagow to spam SEO, nie opis sceny. Mediana katalogu sxyprn to 12.
_MAX_HASHTAGS = 20
_BRACE_RE = re.compile(r"\{[^}]*\}")
_URL_TAIL_RE = re.compile(r"https?://\S+")
@ -76,6 +95,16 @@ def _parse_duration(s: str) -> int | None:
return None
def _author_id(thumb: str | None, post_id: str) -> str | None:
"""Id autora ze sciezki miniatury albo None, gdy format inny niz oczekiwany."""
if not thumb:
return None
m = _AUTHOR_IN_THUMB_RE.search(thumb)
if not m or m.group(2).lower() != post_id.lower():
return None
return m.group(1).lower()
def _clean_title(raw: str) -> tuple[str, list[str]]:
"""Zwraca (czysty tytuł, tagi). Usuwa {markery}, hashtagi i końcowy URL."""
text = html.unescape(raw)
@ -89,7 +118,7 @@ def _clean_title(raw: str) -> tuple[str, list[str]]:
class SxyPrnScraper(BaseSearchScraper):
sitetag = "sxyprncom"
# _search_url_template/_scene_url_re z bazy nieużywane — mamy custom search().
# _search_url_template/_scene_url_re z bazy nieużywane, mamy custom search().
_card_window = 2600 # od js-pop anchora do post_control z tytułem
def _performer_path(self, query: str) -> str:
@ -112,9 +141,6 @@ class SxyPrnScraper(BaseSearchScraper):
return
text = r.text
query_tokens = {
tok for tok in query.lower().split() if len(tok) >= self._query_token_min_len
}
anchors = list(_CARD_ANCHOR_RE.finditer(text))
seen: set[str] = set()
yielded = 0
@ -130,10 +156,13 @@ class SxyPrnScraper(BaseSearchScraper):
if not tm:
continue
title, tags = _clean_title(tm.group(1))
title_l = title.lower()
# Strict: scena musi realnie dotyczyć performera (oba tokeny w tytule) —
# chroni przed śmieciem na stronie i przed over-attribution.
if query_tokens and not all(tok in title_l for tok in query_tokens):
# Scena musi realnie dotyczyć performera. Sprawdzamy CAŁE nazwisko jako ciąg
# sąsiadujących członów, bo poprzedni wariant (każdy człon od trzech znaków
# gdziekolwiek w tytule) degenerował się do samego imienia, gdy nazwisko było
# krótsze: „Julia B." → człon „b." odpada na długości, zostaje „julia", i
# przypisanie łapało każdą scenę ze słowem Julia. Zgłoszenie `e28f2517`,
# 85 tys. błędnych przypisań na samym sxyprn.
if not person_named_in(title, query):
continue
if not title:
continue
@ -146,6 +175,14 @@ class SxyPrnScraper(BaseSearchScraper):
if thumb and thumb.startswith("//"):
thumb = "https:" + thumb
author = _author_id(thumb, post_id)
if author in _BLOCKED_AUTHORS:
log.debug("sxyprn: pomijam post %s autora %s", post_id, _BLOCKED_AUTHORS[author])
continue
if len(tags) > _MAX_HASHTAGS:
log.debug("sxyprn: post %s ma %d hashtagow, wchodzi bez tagow", post_id, len(tags))
tags = []
studio = None
sub_m = _SUBCAT_RE.search(window)
if sub_m:

View file

@ -0,0 +1,239 @@
"""tubepornclassic.com, browse scraper po JSON API listingu (silnik TXXX). Dodany 2026-09-07.
Retro i vintage (lata 70-00), ~168 tys. wideo, świeże wgrania codziennie (2026-09-06 przy
dodawaniu). Zachodnia treść studyjna, więc wchodzi do głównego feedu, nie do wertykału.
**Listing z API, nie z HTML.** Strona to SPA: HTML ma pusty `<title>` i zero linków do
scen. Aplikacja woła `GET /api/json/videos2/<cache>/str/latest-updates/<count>/
..<page>.all...json` (sekcja, object_id, typ, długość i data puste) i dostaje
60 rekordów Z PEŁNYMI METADANYMI: tytuł, `dir` (slug), `duration`, `post_date`,
`models` (obsada, CSV), `categories` + `tags` (CSV), `scr` (miniatura), `is_private`.
Nie ma więc żadnego fetchu per scena, jedna strona = jeden request, w odróżnieniu od
vjav (sitemap + meta per id). Koniec katalogu = 200 z `{"error":1,"code":"videos_not_found"}`
(strona 2801 wciąż oddaje rekordy mimo `pages: 2800`, licznik jest z Sphinxa i przybliżony).
**Wyzwanie 429.** Z serwerowni pierwszy request dostaje 429 i stronkę ustawiającą
ciasteczko (`_txxx.CHALLENGE_COOKIE_RE`), z ciasteczkiem 200. Trzymamy je w instancji,
gdyby i tak padło, a jest Bright Data, powtarzamy przez proxy (przez proxy 200 od razu).
**Obsada tylko u 46 % scen** (28/60 w próbce), studia brak (`content_source_name` puste
we wszystkich 60). Jan o tym wie („gorzej ze studiami i aktorami").
**Bramka na spun-tytuły BEZ obsady.** 38 % tytułów (23/60) to generator SEO w stylu
„Fabulous Sex Movie Vintage Try To Watch For Ever Seen" albo „Hottest Xxx Clip Fat Great
Will Enslaves Your Mind" (ten sam szablon co na fullvideosporn, to jedna sieć). Gdy do
takiego tytułu jest doczepione nazwisko („... - Marissa Malibu"), scena jest w apce
odnajdywalna po performerze i zostaje. Gdy nazwiska nie ma (13/60 = 22 %), nie ma po
czym jej znaleźć ani z czym zdeduplikować, więc NIE wchodzi. Zwykłe tytuły bez obsady
(19/60, „Unforgettable Sensation") wchodzą: dopasowanie po tytule ma sens, retro ma
w kanonie sporo scen.
**Miniatura:** `scr` to 240x180. Ten sam katalog ma `preview.jpg` 1280x720 (sprawdzone),
inne rozmiary w `videos_screenshots/<id>/<WxH>/1.jpg` dają 404, bierzemy preview.
**Data:** `post_date` to data wgrania na tube, nie premiery (retro z lat 80. ma
`2026-09-06`). Tak samo jak inne tuby; deep_crawl od strony 3 ustawia `backfill`.
Playback: ekstraktor `tubepornclassic` (`_txxx.extract_hls`, HLS przez /proxy/hls).
"""
from __future__ import annotations
import html as html_mod
import json
import logging
import re
from datetime import date, datetime
from app.config import get_settings
from app.connectors.base import RawPerformer, RawPlaybackSource, RawScene, RawStudio, RawTag
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.extractors import browser_get
from app.extractors.tubes._txxx import CHALLENGE_COOKIE_RE
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://tubepornclassic.com"
_PER_PAGE = 60
#: Cache po stronie serwera (sekundy). Aplikacja używa 86400; bierzemy krótszy, żeby
#: strona 1 nie była o dobę stara przy godzinnym browse.
_CACHE_LIFETIME = 3600
_THUMB_SMALL_RE = re.compile(r"/\d+x\d+/\d+\.jpg$")
#: Tagi-śmieci z pola `tags` (techniczne, nie opisowe).
_SKIP_TAGS = frozenset({"hd", "4k", "1080p", "720p"})
#: Szablon tytułu z generatora SEO: przymiotnik, potem (porn|xxx|sex|adult) i zaraz
#: (video|clip|movie|scene). Zwykłe tytuły („Unforgettable Sensation", „Oceane, Amazing
#: Blonde Bimbo, With 1 Or 3 Dicks") go nie łapią, bo brak im pary porn+video obok siebie.
_SPUN_TITLE_RE = re.compile(
r"\b(fabulous|incredible|exotic|amazing|hottest|crazy|horny|best|excellent|astonishing|"
r"exclusive|fantastic|great|wild)\b.{0,24}\b(porn|xxx|sex|adult)\b.{0,3}\b(video|clip|movie|scene)\b",
re.IGNORECASE,
)
def _parse_duration(value: str | None) -> int | None:
"""`1:16:43` / `14:17` -> sekundy."""
if not value:
return None
try:
parts = [int(p) for p in value.strip().split(":")]
except ValueError:
return None
sec = 0
for p in parts:
sec = sec * 60 + p
return sec or None
def _parse_post_date(value: str | None) -> date | None:
if not value:
return None
try:
return datetime.strptime(value.strip(), "%Y-%m-%d %H:%M:%S").date()
except ValueError:
return None
def _csv(value) -> list[str]:
if not isinstance(value, str):
return []
return [html_mod.unescape(x).strip() for x in value.split(",") if x.strip()]
class TubePornClassicScraper(BaseBrowseScraper):
sitetag = "tubepornclassic"
def __init__(self) -> None:
super().__init__()
self._cookie: str | None = None
self._proxy = get_settings().brightdata_proxy_url
# ---- fetch z wyzwaniem 429 ------------------------------------------------------
def _gated_get(self, url: str):
"""GET z ciasteczkiem wyzwania; None przy błędzie sieci albo trwałym 429."""
hdr = {"Referer": _BASE + "/", "Accept": "application/json,text/plain,*/*"}
if self._cookie:
hdr["Cookie"] = self._cookie
try:
res = browser_get(url, timeout=self._timeout, headers=hdr)
if res.status_code == 429:
ck = CHALLENGE_COOKIE_RE.search(res.text or "")
if ck is not None:
self._cookie = f"{ck.group(1)}={ck.group(2)}"
res = browser_get(url, timeout=self._timeout, headers=dict(hdr, Cookie=self._cookie))
if res.status_code == 429 and self._proxy:
res = browser_get(url, timeout=self._timeout, headers=hdr, proxy=self._proxy)
except Exception as e:
log.warning("tubepornclassic: fetch fail %s: %s", url, e)
return None
if res.status_code >= 400:
log.warning("tubepornclassic: HTTP %s na %s", res.status_code, url)
return None
return res
def _listing_url(self, page: int) -> str:
return (
f"{_BASE}/api/json/videos2/{_CACHE_LIFETIME}/str/latest-updates/{_PER_PAGE}/"
f"..{page}.all...json"
)
def crawl_page(self, page: int) -> list[RawScene] | None:
res = self._gated_get(self._listing_url(page))
if res is None:
return None # transient: NIE oznaczaj końca katalogu
try:
data = json.loads(res.text)
except Exception as e:
log.warning("tubepornclassic: listing parse fail (page %d): %s", page, e)
return None
# Za końcem katalogu API oddaje 200 z `{"error":1,"code":"videos_not_found"}`
# (bez klucza `videos`). To jest wyczerpanie, nie awaria, stąd [] a nie None.
if data.get("code") == "videos_not_found":
return []
videos = data.get("videos")
if not isinstance(videos, list):
return None
out: list[RawScene] = []
for v in videos:
if not isinstance(v, dict):
continue
try:
scene = self._parse_item(v)
except Exception as e:
log.info("tubepornclassic: parse fail id=%s: %s", v.get("video_id"), e)
continue
if scene is not None:
out.append(scene)
return out
# ---- rekord listingu -> RawScene ------------------------------------------------
def _parse_item(self, v: dict) -> RawScene | None:
if str(v.get("is_private") or "0") != "0":
return None
try:
vid = int(v.get("video_id"))
except (TypeError, ValueError):
return None
title = html_mod.unescape(v.get("title") or "").strip()
slug = (v.get("dir") or "").strip()
if not title or not slug:
return None
page_url = f"{_BASE}/videos/{vid}/{slug}/"
duration_sec = _parse_duration(v.get("duration"))
thumb = (v.get("scr") or "").strip() or None
if thumb:
thumb = _THUMB_SMALL_RE.sub("/preview.jpg", thumb)
performers: list[RawPerformer] = []
seen: set[str] = set()
for name in _csv(v.get("models")):
s = slugify(name)
if s and s not in seen:
seen.add(s)
performers.append(RawPerformer(external_id=f"{self.sitetag}:model:{s}", name=name))
if not performers and _SPUN_TITLE_RE.search(title):
return None # nieodnajdywalna: spun-tytuł i zero obsady, patrz docstring
studio = None
sname = (v.get("content_source_name") or "").strip()
if sname and slugify(sname):
studio = RawStudio(
external_id=f"{self.sitetag}:studio:{slugify(sname)}", name=sname, slug=slugify(sname)
)
tags: list[RawTag] = []
seen_t: set[str] = set()
for name in _csv(v.get("categories")) + _csv(v.get("tags")):
s = slugify(name)
if not s or s in seen_t or s in _SKIP_TAGS or len(s) > 60:
continue
seen_t.add(s)
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{s}", name=name, slug=s))
return RawScene(
external_id=f"{self.sitetag}:{vid}",
title=title,
release_date=_parse_post_date(v.get("post_date")),
duration_sec=duration_sec,
url=page_url,
studio=studio,
performers=performers,
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=page_url,
duration_sec=duration_sec,
thumbnail_url=thumb,
)
],
raw={"source": "tubepornclassic_api", "id": vid},
)
# ---- HTML stuby (ABC wymaga, nieużywane: listing i metadane idą z JSON) --------
def _extract_scene_urls(self, listing_html: str) -> list[str]: # pragma: no cover
return []
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None: # pragma: no cover
return None

View file

@ -1,15 +1,15 @@
"""vjav.com — JAV browse scraper (TXXX network). Sitemap id-walk + JSON metadata API.
"""vjav.com, JAV browse scraper (TXXX network). Sitemap id-walk + JSON metadata API.
Osobna pula JAV (tytuły azjatyckie + `title_jp`, studia typu JAVHD), origin `tube:vjav`.
Świadomie orphan vertical (sekcja JAV) — NIE deduplikuje się z zachodnim katalogiem.
Świadomie orphan vertical (sekcja JAV), NIE deduplikuje się z zachodnim katalogiem.
vjav to SPA (ktk_player + blob) — listing renderuje się JS-owo, więc HTML listingu
vjav to SPA (ktk_player + blob), listing renderuje się JS-owo, więc HTML listingu
nie ma linków do scen. Zamiast tego:
- **browse**: `sitemap.xml` -> `sitemap_vids_N.xml` (id-e wszystkich wideo). Newest
= najwyższe id (auto-increment). Cache posortowanej listy id malejąco, page N =
kolejny wycinek PER_PAGE.
- **metadata**: bogaty JSON API `GET /api/json/video/1/<floor>/<id>/<id>.json`
(floor = id//1000*1000) — title (+title_jp), duration, post_date, channel.title
(floor = id//1000*1000), title (+title_jp), duration, post_date, channel.title
(studio), models{} (performerki), categories{}+tags{}.
- **stream**: osobny ekstraktor `app/extractors/tubes/vjav.py` (videofile.php ->
get_file HLS). RE 2026-07-10.
@ -62,7 +62,7 @@ def _parse_duration(value: str | None) -> int | None:
# --- bramka jakości tytułu ----------------------------------------------------------
# vjav zaczął sypać śmieciem: pomiar 2026-08-04 na 295 scenach z doby po naprawie
# dostępu dał 78,6% tytułów typu slug-concat i ZERO przypisanej obsady. Takie sceny są
# w apce nieodnajdywalne — nie mają ani tytułu, ani performera, po którym da się trafić.
# w apce nieodnajdywalne, nie mają ani tytułu, ani performera, po którym da się trafić.
# W starym katalogu ten sam pomiar daje 27% (wcześniejsza liczba 2,9% była zaniżona,
# bo sprawdzała tylko jeden z czterech wzorców).
_JUNK_ID_JAV = re.compile(r"^\d{6,}_JAV", re.IGNORECASE) # 0008364_JAV_JapaneseAV_...
@ -74,7 +74,7 @@ def _has_cjk(text: str) -> bool:
"""Tytuły japońskie/chińskie MUSZĄ omijać heurystyki znakowe: `str.isalpha()` jest
prawdziwe dla kana i kanji, a japoński nie stawia spacji, więc reguły „za mało
samogłosek" i „brak spacji" odrzuciłyby prawdziwe tytuły na serwisie JAV. Ta pomyłka
wyszła przy pomiarze pierwszej wersji bramki — bez tego wyjątku poszłoby 708 z 708
wyszła przy pomiarze pierwszej wersji bramki, bez tego wyjątku poszłoby 708 z 708
tytułów CJK z próbki."""
return sum(1 for c in text if " " <= c <= "鿿" or "＀" <= c <= "￯") >= 3
@ -119,7 +119,7 @@ class VjavScraper(BaseBrowseScraper):
# vjav odciął IP VPS-a: 2026-08-03 KAŻDA ścieżka (sitemap, strona główna,
# /latest-updates/) oddawała 429 przy trzech próbach z rzędu, a przez proxy
# 200 od pierwszej. Źródło stało 12 dni. Bright Data jest tu więc warunkiem
# działania, nie optymalizacją — bez klucza scraper się wyłącza.
# działania, nie optymalizacją, bez klucza scraper się wyłącza.
self._proxy = get_settings().brightdata_proxy_url
if not self._proxy:
log.warning("vjav: BRAK BRIGHTDATA_PROXY_URL — vjav blokuje IP VPS-a (429)")
@ -130,7 +130,7 @@ class VjavScraper(BaseBrowseScraper):
zwrócenia [], czyli „koniec katalogu". Dlatego błąd pobrania NIE MOŻE tu
ustawiać []: przy 429 scraper raportował 0 scen i 0 błędów, więc przez 12 dni
wyglądał na zdrowy. Zostawiamy None = „nie wiadomo", co crawl_page tłumaczy na
transient-fail. Status sprawdzamy jawnie, bo `browser_get` nie rzuca na 4xx —
transient-fail. Status sprawdzamy jawnie, bo `browser_get` nie rzuca na 4xx,
i to właśnie przez to treść błędu 429 była parsowana jako XML."""
try:
res = browser_get(_SITEMAP_INDEX, timeout=self._timeout, proxy=self._proxy)
@ -238,7 +238,7 @@ class VjavScraper(BaseBrowseScraper):
Po wprowadzeniu bramki jakości pusta strona przestała znaczyć „koniec
katalogu": strona 1 bywa w 100% śmieciowa (same `0008364_JAV_...`), więc po
odsianiu zwraca [], a bazowe `latest_scenes` traktowało to jak wyczerpanie i
kończyło CAŁY run — źródło dawało `seen: 0` mimo 39 dobrych scen na stronach
kończyło CAŁY run, źródło dawało `seen: 0` mimo 39 dobrych scen na stronach
2-5. To ta sama pułapka „[] znaczy wyczerpane" co w `_load_ids`.
Tu wyczerpanie rozstrzyga długość listy id, nie liczba scen po filtrze.
@ -268,7 +268,7 @@ class VjavScraper(BaseBrowseScraper):
out: list[RawScene] = []
for vid in chunk:
try:
# Proxy także tutaj — API metadanych siedzi na tym samym hoście, więc
# Proxy także tutaj, API metadanych siedzi na tym samym hoście, więc
# obejmuje je ten sam 429 co sitemap.
res = browser_get(
self._meta_url(vid),

View file

@ -1,7 +1,7 @@
"""watchporn.to — browse scraper (KVS engine). Re-enabled 2026-07-02.
"""watchporn.to, browse scraper (KVS engine). Re-enabled 2026-07-02.
Był search-scraperem (`?s=`), zamarzł, a potem site przebudowano na KVS z nowym
layoutem. DoodStream-CAPTCHA (powód wyłączenia 2026-05-12) zniknął — teraz KVS
layoutem. DoodStream-CAPTCHA (powód wyłączenia 2026-05-12) zniknął, teraz KVS
flashvars `get_file` direct mp4 (extractor `watchporn`, VPS-side, token nie IP-bound).
Browse `/latest-updates/` → detail page:

View file

@ -0,0 +1,172 @@
"""xanimeporn.com — browse scraper. Drugie źródło wertykału hentai. Dodany 2026-08-04.
**NIE jest re-hostem hentaimamy — sprawdzone na wspólnym odcinku.** Shoujo Ramune 7
leży na obu stronach, ale to zupełnie inne pliki: hentaimama serwuje
`gdvid.info/S/shoujo-ramune-7.mp4` (jedna jakość, bez etykiety), a tutaj jest
`download.xanimeporn.com/videos/1080p/[1080p]69951.mp4` z **jawnym wyborem 1080p (222 MB)
i 720p (84 MB)**. To realna przewaga jakościowa, nie duplikat.
Kryterium jak przy [[hentaimama]]: dla animacji nie istnieje kanon, więc każda pozycja
jest orphanem i to jest poprawne. Nie ma obsady, nie ma canonical matcha — jakość mierzy
komplet seria + odcinek + jakość pliku.
**Listing idzie z RSS-a i to jedyna droga, która działa.** Sprawdzone trzy inne i każda
ma inną wadę:
- `/page/2/`, `/page/3/` — oddają ten sam zestaw co strona 1 (28 z 32 wspólnych),
czyli paginacja jest ZEPSUTA, identycznie jak przy 8kporner
- `/uncensored/` i `/censored/` — stronicują poprawnie, ale NIE są chronologiczne:
pierwsze pozycje są z 2024, a zakres sięga 2015-2026. Browse-latest nigdy nie
zobaczyłby nowego odcinka, czyli dokładnie pułapka z freshporno
- `/hentai-list/` — cały katalog (1048 pozycji) na jednej stronie, dobre do
przyszłego deep-crawlu, bezużyteczne do „najnowszych"
`/feed/` i `/feed/?paged=N` są chronologiczne (strona 1 = dziś, strona 2 = miesiąc
wstecz) i dają po 10 pozycji, co przy kilku odcinkach dziennie w zupełności wystarcza.
**Drugi filtr, też wymuszony przez dane:** slugi gatunków (`/futanari/`, `/big-tits/`,
`/betrayal/`) są takimi samymi slugami najwyższego poziomu jak odcinki i przechodziły
przez listę nazw nawigacji. Dlatego `_parse_detail` wymaga `postid-<n>` w klasie
`<body>` — mają go tylko pojedyncze wpisy, nie archiwa. Ten sam identyfikator jest
zresztą potrzebny extractorowi, więc brak = pozycja i tak bezużyteczna.
Playback: `videos.xanimeporn.com/download.php?id=<postId>&quality=…` (extractor
`xanimeporn`). Bez Cloudflare i bez proxy, w odróżnieniu od hentaimamy.
"""
from __future__ import annotations
import html as html_mod
import logging
import re
from datetime import datetime
from app.connectors.base import RawPlaybackSource, RawScene, RawTag
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.extractors import browser_get
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://xanimeporn.com"
_FEED_LINK_RE = re.compile(r"<link>(https://xanimeporn\.com/[a-z0-9][a-z0-9-]+/)</link>")
#: Tylko pojedyncze wpisy mają `postid-<n>` w klasie <body>; archiwa gatunków nie.
_POST_ID_RE = re.compile(r"\bpostid-(\d+)\b")
#: Kategoria z RSS-a — na stronie odcinka oba linki są w menu, więc czytanie ich
#: stamtąd dałoby ZAWSZE „censored" i „uncensored" naraz.
_FEED_ITEM_RE = re.compile(r"<item>(.*?)</item>", re.DOTALL)
_FEED_CATEGORY_RE = re.compile(r"<category><!\[CDATA\[([^\]]+)\]\]></category>")
_OG_TITLE_RE = re.compile(r"property=['\"]og:title['\"][^>]*content=['\"]([^'\"]{3,200})")
_PUBLISHED_RE = re.compile(
r"property=['\"]article:published_time['\"][^>]*content=['\"]([^'\"]+)"
)
_POSTER_RE = re.compile(r"posterImage:\s*'([^']+)'")
#: „Shoujo Ramune Episode 7 [Sub-ENG] | X Anime Porn" → tytuł bez sufiksu serwisu.
_TITLE_SUFFIX_RE = re.compile(r"\s*\|\s*X Anime Porn\s*$", re.IGNORECASE)
_SERIES_RE = re.compile(r"^(.*?)\s+Episode\s+\d+", re.IGNORECASE)
class XAnimePornScraper(BaseBrowseScraper):
sitetag = "xanimeporn"
def _listing_url(self, page: int) -> str:
return f"{_BASE}/feed/" if page <= 1 else f"{_BASE}/feed/?paged={page}"
def _extract_scene_urls(self, listing_html: str) -> list[str]:
return list(dict.fromkeys(_FEED_LINK_RE.findall(listing_html)))
def crawl_page(self, page: int) -> list[RawScene] | None:
"""Jak w bazie, ale kategorię (censored/uncensored) bierzemy z RSS-a, bo na
stronie odcinka oba linki siedzą w menu nawigacyjnym."""
try:
res = browser_get(self._listing_url(page), timeout=self._timeout)
res.raise_for_status()
except Exception as e:
log.warning("%s: feed strona %d fail: %s", self.sitetag, page, e)
return None
cats: dict[str, str] = {}
for item in _FEED_ITEM_RE.findall(res.text):
lm = _FEED_LINK_RE.search(item)
if not lm:
continue
for cat in _FEED_CATEGORY_RE.findall(item):
if cat.strip().lower() in ("censored", "uncensored"):
cats[lm.group(1)] = cat.strip().lower()
break
urls = self._extract_scene_urls(res.text)
if not urls:
return []
out: list[RawScene] = []
for url in urls:
try:
det = browser_get(url, timeout=self._timeout)
det.raise_for_status()
raw = self._parse_detail(url, det.text, cats.get(url, ""))
except Exception as e:
log.info("%s detail fail %s: %s", self.sitetag, url, e)
continue
if raw is not None:
out.append(raw)
return out
def _parse_detail( # type: ignore[override]
self, scene_url: str, detail_html: str, archive: str = ""
) -> RawScene | None:
# Archiwa gatunków (`/futanari/`, `/big-tits/`) mają takie same slugi jak odcinki
# i wchodziły jako pozycje bez daty i miniaturki. Tylko pojedynczy wpis ma postid.
if not _POST_ID_RE.search(detail_html):
return None
tm = _OG_TITLE_RE.search(detail_html)
if not tm:
return None
title = _TITLE_SUFFIX_RE.sub("", html_mod.unescape(tm.group(1))).strip()
if not title:
return None
release_date = None
pm = _PUBLISHED_RE.search(detail_html)
if pm:
try:
release_date = datetime.fromisoformat(pm.group(1)).date()
except ValueError:
pass
tags: list[RawTag] = []
if archive:
tags.append(
RawTag(
external_id=f"{self.sitetag}:tag:{archive}",
name=archive.capitalize(),
slug=archive,
)
)
# Seria jako tag — pozwala grupować odcinki, tak samo jak w hentaimamie.
sm = _SERIES_RE.match(title)
if sm and slugify(sm.group(1)):
series = sm.group(1).strip()
tags.append(
RawTag(
external_id=f"{self.sitetag}:series:{slugify(series)}",
name=series,
slug=slugify(series),
)
)
om = _POSTER_RE.search(detail_html)
slug = scene_url.rstrip("/").rsplit("/", 1)[-1]
return RawScene(
external_id=f"{self.sitetag}:{slug}",
title=title,
release_date=release_date,
url=scene_url,
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=scene_url,
thumbnail_url=om.group(1).strip() if om else None,
)
],
)

View file

@ -1,4 +1,4 @@
"""XHamster.com — direct HTML scrape search results.
"""XHamster.com, direct HTML scrape search results.
Search: `https://xhamster.com/search/<q>?page=<n>`
Scene URL: `https://xhamster.com/videos/<slug>-<id>`
@ -7,6 +7,13 @@ from __future__ import annotations
import re
# Karty sa renderowane z JSON-a w HTML (`window.initials`): kazda ma `pageURL` i
# `thumbURL` (526x298 webp). Szukanie `<img>` w oknie tekstu gubilo polowe scen, bo
# ten sam link wystepuje dwa razy (miniatura i tytul), a img jest tylko przy pierwszym.
_JSON_CARD_RE = re.compile(
r'"pageURL":"(https:\\/\\/xhamster\.com\\/videos\\/[^"]+)","thumbURL":"([^"]+)"'
)
from app.connectors.direct_scrapers._search_base import BaseSearchScraper
@ -17,3 +24,9 @@ class XHamsterScraper(BaseSearchScraper):
r'href="(?P<url>https://xhamster\.com/videos/(?P<slug>[a-z0-9_\-]+))"',
re.IGNORECASE,
)
def _thumbnail_map(self, listing_html: str) -> dict[str, str]:
out: dict[str, str] = {}
for page_url, thumb in _JSON_CARD_RE.findall(listing_html):
out[page_url.replace("\\/", "/")] = thumb.replace("\\/", "/")
return out

View file

@ -1,4 +1,4 @@
"""xmoviesforyou.com — direct HTML scrape.
"""xmoviesforyou.com, direct HTML scrape.
Search: WordPress `?s=<q>` (lub `/page/<n>/?s=<q>` dla pagination).
Scene URL: `https://xmoviesforyou.com/<slug>/` (single segment).

View file

@ -1,4 +1,4 @@
"""XNXX.com — direct HTML scrape search results.
"""XNXX.com, direct HTML scrape search results.
Search: `https://www.xnxx.com/search/<q>/<page-1>` (xnxx 0-indexed)
Scene URL: `https://www.xnxx.com/video-<id>/<slug>`
@ -12,7 +12,7 @@ from app.connectors.direct_scrapers._search_base import BaseSearchScraper
class XnxxScraper(BaseSearchScraper):
sitetag = "xnxxcom"
# `/<page-1>` — handle override in search() by replacing {page}.
# `/<page-1>`, handle override in search() by replacing {page}.
_search_url_template = "https://www.xnxx.com/search/{query}/{page}"
_scene_url_re = re.compile(
r'href="(?P<url>/video-[a-z0-9]+/(?P<slug>[a-z0-9_\-]+))"',

View file

@ -1,6 +1,6 @@
"""xnxx.com — BROWSE scraper (JSON-LD), obok search scrapera.
"""xnxx.com, BROWSE scraper (JSON-LD), obok search scrapera.
Detail page ma JSON-LD VideoObject (name/duration/uploadDate/thumbnail) — i TYLKO to
Detail page ma JSON-LD VideoObject (name/duration/uploadDate/thumbnail), i TYLKO to
bierzemy. Mimo wspólnego silnika z xvideos, xnxx detail NIE wystawia w SSR linków
`/models/` ani `/tags/` (0 wystąpień, ładowane JS-em) → performerów/tagi dorabia
canonical-merge + istniejący performer-search. XnxxScraper (search) zostaje; browse
@ -8,13 +8,13 @@ dokłada sygnał świeżości. Tytuł z JSON-LD bywa HTML-encoded (`&comma;`/`&e
html.unescape.
Listing: xnxx NIE ma czystego SSR `/new/` (404), ale `/best/<YYYY-MM>/<page>` jest
SSR (linki /video-<id>/ w surowym HTML). Bierzemy bieżący miesiąc — pokrywa świeży
SSR (linki /video-<id>/ w surowym HTML). Bierzemy bieżący miesiąc, pokrywa świeży
content (sortowanie best-of-month, nie ściśle chronologiczne, ale dla sygnału
świeżości wystarcza; ścisłą chronologię i tak daje performer-search). Homepage
xnxx jest JS-renderowany (0 linków w surowym HTML), stąd /best/.
Playback bez zmian (extractor `xnxxcom`). Phash pominięty (xnxx crop-thumbnaile,
0% hit do canonical — jak xvideos).
0% hit do canonical, jak xvideos).
"""
from __future__ import annotations
@ -108,7 +108,7 @@ class XnxxBrowseScraper(BaseBrowseScraper):
if isinstance(thumbnail_url, list):
thumbnail_url = thumbnail_url[0] if thumbnail_url else None
# performers/tags puste — xnxx detail nie ma ich w SSR (patrz docstring).
# performers/tags puste, xnxx detail nie ma ich w SSR (patrz docstring).
return RawScene(
external_id=f"{self.sitetag}:{scene_url}",
title=title,

View file

@ -1,4 +1,4 @@
"""XVideos.com — direct HTML scrape search results.
"""XVideos.com, direct HTML scrape search results.
Search: `https://www.xvideos.com/?k=<q>&p=<page-1>` (xvideos używa 0-indexed pages)
Scene URL: `https://www.xvideos.com/video<digits>/<slug>`
@ -13,7 +13,7 @@ from app.connectors.direct_scrapers._search_base import BaseSearchScraper
class XVideosScraper(BaseSearchScraper):
sitetag = "xvideoscom"
# 0-indexed page — w base classie computed jako `page=N`, więc override _build_url.
# 0-indexed page, w base classie computed jako `page=N`, więc override _build_url.
_search_url_template = "https://www.xvideos.com/?k={query}&p={page}"
_scene_url_re = re.compile(
r'href="(?P<url>/video[a-z0-9.\-]+/(?P<slug>[a-z0-9_\-]+))"',
@ -21,10 +21,10 @@ class XVideosScraper(BaseSearchScraper):
)
def search(self, query, *, page=1, limit=None):
# XVideos używa 0-indexed pages — `page=1` w API → `&p=0` w URL.
# XVideos używa 0-indexed pages, `page=1` w API → `&p=0` w URL.
# Override żeby base class fetch'nął zewnętrzny URL z (page-1).
# Najprościej: dostosujmy URL w override przed wywołaniem super().search().
# Ale super() używa self._search_url_template — robimy clone z poprawionym page.
# Ale super() używa self._search_url_template, robimy clone z poprawionym page.
original = self._search_url_template
self._search_url_template = original.replace("{page}", str(page - 1))
try:

View file

@ -1,14 +1,14 @@
"""xvideos.com — deep-crawl browse scraper (JSON-LD + page-parse).
"""xvideos.com, deep-crawl browse scraper (JSON-LD + page-parse).
xvideos SSR-uje JSON-LD VideoObject (duration, name, uploadDate) ORAZ na detail-stronie
linki `/models/<slug>` (performerzy tej sceny) + `/tags/<slug>` (tagi). Sample 2026-06-03
(15 scen): median ~10.5min, 93% ≥3min — dobry full-scene content (nie trailery).
(15 scen): median ~10.5min, 93% ≥3min, dobry full-scene content (nie trailery).
Mega-katalog (~13M) → deep_crawl z per-tube page-cap (xvideoscom w deep_crawl._PAGE_CAP),
żeby nie monopolizował round-robin ani nie zalał bazy. Listing: /new/<page> (newest).
Scene: /video.<hash>/<slug>. Playback: page_url + origin tube:xvideoscom (istniejący
extractor `xvideoscom` resolvuje stream mobile-side). Phash pominięty (xvideos robi
własne crop-thumbnaile — 0% hit do canonical, jak fullmovies/hdporn).
własne crop-thumbnaile, 0% hit do canonical, jak fullmovies/hdporn).
"""
from __future__ import annotations
@ -19,7 +19,7 @@ from datetime import date, datetime
from app.connectors.base import RawPerformer, RawPlaybackSource, RawScene, RawTag
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper, meta_content
from app.normalize.text import slugify
from app.normalize.text import slugify, unescape_html
log = logging.getLogger(__name__)
@ -29,7 +29,7 @@ _JSONLD_RE = re.compile(
r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', re.IGNORECASE | re.DOTALL
)
# Model anchor: `<a href="/models/<slug>" ...><span ...></span><span class="name">Display Name</span>...`.
# Nazwa siedzi w ZAGNIEŻDŻONYM <span class="name">, nie jako bezpośredni tekst anchora —
# Nazwa siedzi w ZAGNIEŻDŻONYM <span class="name">, nie jako bezpośredni tekst anchora,
# poprzedni wzorzec `>([^<]{2,60})</a>` wymagał tekstu zaraz po `>` i NIGDY nie pasował do
# obecnego markupu xvideos → wszystkie browse-scraped sceny xvideos lądowały z 0 performerami
# (bug-report 2026-06-07 "czemu nie ma aktorów, są na stronie"). `(?:(?!</a>).)*?` trzyma match
@ -99,10 +99,11 @@ class XVideosBrowseScraper(BaseBrowseScraper):
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
video = _video_object(detail_html) or {}
title = (video.get("name") or "").strip()
# JSON-LD `name` niesie encje HTML5 (`we&apos;re`, `&lbrack;AI&rsqb;`), og:title tez.
title = unescape_html(video.get("name"))
if not title:
m = _SETTITLE_RE.search(detail_html)
title = m.group(1).strip() if m else (meta_content(detail_html, property="og:title") or "").strip()
title = unescape_html(m.group(1) if m else meta_content(detail_html, property="og:title"))
if not title:
return None

View file

@ -1,4 +1,4 @@
"""xxxfree.watch — direct HTML scrape.
"""xxxfree.watch, direct HTML scrape.
Domain: `xxxfree.watch` (sitetag `xxxfreewatch` is legacy from porn-app DEFAULT_SITETAGS).

View file

@ -1,25 +1,25 @@
"""yesporn.vip — latest-vids browse scraper.
"""yesporn.vip, latest-vids browse scraper.
Dołączony 2026-05-27. Identyfikowany przez user audit jako "scraper-of-paysites"
(DogFart / HardX / TeamSkeet / Vixen / Brazzers content). Wcześniejszy theporndude
audit pomylił domeny: `yespornvip.com` (z theporndude rankingu) redirectuje przez
pdude.link do `porndudecams.com` affiliate spam — kanoniczna domena ma TLD `.vip`.
pdude.link do `porndudecams.com` affiliate spam, kanoniczna domena ma TLD `.vip`.
Czemu wart (parity z porndoe):
- **JSON-LD VideoObject** w każdym scene page: name, description, uploadDate
(ISO `YYYY-MM-DDTHH:MM:SS`), duration (ISO `PT0H39M00S`), thumbnailUrl
(BunnyCDN: `yesnn.b-cdn.net/contents/videos_screenshots/...`).
- **`<meta property="video:duration" content="2340">`** — durations już w sekundach
- **`<meta property="video:duration" content="2340">`** durations już w sekundach
(fallback gdy ISO-duration parse fail).
- **`<meta property="video:release_date">`** — ISO 8601 z timezone, redundant z
- **`<meta property="video:release_date">`** ISO 8601 z timezone, redundant z
JSON-LD uploadDate ale czystszy format.
- **`<meta property="video:tag">`** (multiple) — kanoniczna lista tagów (np.
- **`<meta property="video:tag">`** (multiple), kanoniczna lista tagów (np.
"Big Ass", "Threesome"). Główne źródło tagów; alternatywnie DOM ma `btn gold`
linki ale te miksują performerów/studio z tagami.
- **Studio + Performers**: oba w sekcji `<a class="btn gold" href="/channels/<slug>/">`
(studio, singular) i `<a class="btn gold" href="/models/<slug>/">` (performerzy,
multiple). Slugi mają stable per-type salt (`*-i459s7` dla modeli, `*-7p72tp`
dla channels) — zachowują się jak hash z site-version, ale stabilne przez
dla channels), zachowują się jak hash z site-version, ale stabilne przez
sesje.
External_id strategia: `yespornvip:<numeric_video_id>` (`/video/69841/...` → `69841`).
@ -31,11 +31,11 @@ URL patterns:
- Scene: `/video/<id>/<slug>/` (id numeryczny, slug = title slug + 6-char salt)
- Studio: `/channels/<slug>/`
- Performer: `/models/<slug>/`
- Search: `/search/<query>/` (nie używane w browse-mode — można dorobić jako
- Search: `/search/<query>/` (nie używane w browse-mode, można dorobić jako
osobny tryb dla performer-driven backfill jeśli będzie potrzeba)
Playback: download endpoint `/view_video_download.php?id=<id>&format=<480|720|1080>`
z `data-attach-session="PHPSESSID"` — wymaga session cookie, więc nie direct mp4
z `data-attach-session="PHPSESSID"`, wymaga session cookie, więc nie direct mp4
z server-side. Plus jest `embedUrl: /embed/<id>` w JSON-LD. Extractor →
`_vps_blocked_fallback.extract` (zgodne z pre-public bandwidth/anonymity policy):
mobile WebView fetcha embed z phone IP, INJECTED_JS scrape'uje `<video>.src`.
@ -66,7 +66,7 @@ log = logging.getLogger(__name__)
_BASE = "https://yesporn.vip"
# Listing — scene URLs w listing HTML: `<a href="https://yesporn.vip/video/<id>/<slug>/">`.
# Listing, scene URLs w listing HTML: `<a href="https://yesporn.vip/video/<id>/<slug>/">`.
# Slug `<slug>` zawiera stable per-type salt (`*-npu57w` dla videos).
_SCENE_URL_RE = re.compile(
r'href="(https://yesporn\.vip/video/(\d+)/[a-z0-9\-]+/)"',
@ -87,13 +87,13 @@ _PERFORMER_LINK_RE = re.compile(
)
_HTML_TAG_RE = re.compile(r"<[^>]+>")
# JSON-LD VideoObject — pełny blok między `<script type="application/ld+json">` tagami.
# JSON-LD VideoObject, pełny blok między `<script type="application/ld+json">` tagami.
_JSONLD_RE = re.compile(
r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
re.IGNORECASE | re.DOTALL,
)
# `<meta property="video:tag" content="Big Ass">` — multiple, jeden tag per meta.
# `<meta property="video:tag" content="Big Ass">`, multiple, jeden tag per meta.
_META_TAG_RE = re.compile(
r'<meta\s+property=["\']video:tag["\']\s+content=["\']([^"\']+)["\']',
re.IGNORECASE,
@ -122,7 +122,7 @@ def _parse_iso_date(value: str | None) -> date | None:
"""`2026-05-26T19:23:29Z` / `2026-05-26T19:23:29.EDT` → date."""
if not value:
return None
# yesporn emituje `.EDT` jako "timezone" w JSON-LD uploadDate — strip żeby
# yesporn emituje `.EDT` jako "timezone" w JSON-LD uploadDate, strip żeby
# `fromisoformat` nie crash'ował. video:release_date meta ma czysty `Z`.
cleaned = re.sub(r"\.[A-Z]{2,4}$", "", value.strip())
try:
@ -265,7 +265,7 @@ class YesPornVipScraper(BaseBrowseScraper):
# lowercase. Legit kategorie są zawsze Title Case ("Big Ass", "Deep
# Throat", "Blonde", "Gangbang") lub UPPER ("MILF", "BBW"). Potwierdzone
# w 20-scene dry-run 2026-05-27. Trade-off: stracimy hipotetyczne legit
# lowercase tagi (np. "interracial" gdyby site je nie capitalize'ował) —
# lowercase tagi (np. "interracial" gdyby site je nie capitalize'ował),
# akceptowalne bo tags mają wagę tylko 0.05 w composite scoring resolvera.
tags: list[RawTag] = []
seen_tag: set[str] = set()
@ -283,17 +283,17 @@ class YesPornVipScraper(BaseBrowseScraper):
RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug)
)
# Phash z thumbnailUrl — BunnyCDN `yesnn.b-cdn.net` hostuje 1.jpg per scene.
# Phash z thumbnailUrl, BunnyCDN `yesnn.b-cdn.net` hostuje 1.jpg per scene.
# Hit-rate vs canonical TPDB/StashDB nieznany do pilot run; graceful: brak
# phash → resolver spada do composite scoring (studio + performer + date +
# duration + title token-set) — wszystkie dostępne dzięki JSON-LD.
# duration + title token-set), wszystkie dostępne dzięki JSON-LD.
fingerprints: list[RawFingerprint] = []
if thumbnail_url:
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")
if ph:
fingerprints.append(RawFingerprint(kind="phash", value=ph))
# Playback — page_url do strony sceny. Direct mp4 (`view_video_download.php`)
# Playback, page_url do strony sceny. Direct mp4 (`view_video_download.php`)
# wymaga PHPSESSID cookie (data-attach-session attribute), więc nie usable
# server-side. Extractor `yespornvip` → `_vps_blocked_fallback.extract`:
# mobile WebView z phone IP łapie session natively, INJECTED_JS scrape.

Some files were not shown because too many files have changed in this diff Show more