From a4486980a49df0db567a1ef30ec03d00bcd90276 Mon Sep 17 00:00:00 2001 From: goon-foss Date: Tue, 11 Aug 2026 11:51:57 +0200 Subject: [PATCH] style: myslniki em/en z komentarzy i docstringow (calosc repo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/api/admin.py | 10 +- app/api/admin_html.py | 10 +- app/api/blacklist.py | 8 +- app/api/bug_reports.py | 10 +- app/api/device.py | 2 +- app/api/expo_updates.py | 10 +- app/api/favorites.py | 32 ++-- app/api/me.py | 8 +- app/api/movies.py | 10 +- app/api/playback.py | 84 +++++----- app/api/playback_events.py | 8 +- app/api/saved_searches.py | 12 +- app/api/scene_favorites.py | 8 +- app/api/scenes.py | 82 +++++----- app/api/schemas.py | 4 +- app/api/seo.py | 34 ++-- app/api/sources.py | 22 +-- app/api/stream_proxy.py | 76 ++++----- app/api/taxonomies.py | 30 ++-- app/api/watch.py | 8 +- app/auth.py | 4 +- app/config.py | 42 ++--- app/connectors/__init__.py | 18 +-- app/connectors/base.py | 12 +- app/connectors/direct_scrapers/__init__.py | 146 +++++++++--------- .../direct_scrapers/_browse_base.py | 26 ++-- app/connectors/direct_scrapers/_playtube.py | 18 +-- .../direct_scrapers/_search_base.py | 40 ++--- app/connectors/direct_scrapers/base.py | 4 +- app/connectors/direct_scrapers/eporner.py | 2 +- app/connectors/direct_scrapers/eporner_api.py | 8 +- app/connectors/direct_scrapers/fpoxxx.py | 6 +- app/connectors/direct_scrapers/freshporno.py | 14 +- app/connectors/direct_scrapers/fullmovies.py | 8 +- app/connectors/direct_scrapers/fullporner.py | 14 +- .../direct_scrapers/fullvideosporn.py | 14 +- app/connectors/direct_scrapers/galaxyporn.py | 20 +-- app/connectors/direct_scrapers/hdporn92.py | 2 +- app/connectors/direct_scrapers/hdporngg.py | 10 +- app/connectors/direct_scrapers/hqfap.py | 2 +- app/connectors/direct_scrapers/hqporner.py | 10 +- .../direct_scrapers/hqporner_browse.py | 6 +- app/connectors/direct_scrapers/javflix.py | 6 +- app/connectors/direct_scrapers/kporner8.py | 24 +-- app/connectors/direct_scrapers/latestleaks.py | 2 +- .../direct_scrapers/latestpornvideo.py | 8 +- .../direct_scrapers/mypornerleak.py | 4 +- .../direct_scrapers/mypornerleak_browse.py | 2 +- app/connectors/direct_scrapers/neporn.py | 18 +-- app/connectors/direct_scrapers/perverzija.py | 4 +- app/connectors/direct_scrapers/porn00.py | 20 +-- app/connectors/direct_scrapers/porn4days.py | 2 +- app/connectors/direct_scrapers/pornbusy.py | 16 +- app/connectors/direct_scrapers/porndish.py | 6 +- app/connectors/direct_scrapers/porndoe.py | 14 +- app/connectors/direct_scrapers/pornmike.py | 8 +- app/connectors/direct_scrapers/porntrex.py | 4 +- .../direct_scrapers/porntrex_browse.py | 6 +- app/connectors/direct_scrapers/pornxp.py | 38 ++--- app/connectors/direct_scrapers/sexu.py | 8 +- app/connectors/direct_scrapers/shyfap.py | 20 +-- app/connectors/direct_scrapers/siska.py | 2 +- app/connectors/direct_scrapers/superporn.py | 18 +-- app/connectors/direct_scrapers/supjav.py | 6 +- app/connectors/direct_scrapers/sxyland.py | 8 +- app/connectors/direct_scrapers/sxyprn.py | 12 +- app/connectors/direct_scrapers/vjav.py | 20 +-- app/connectors/direct_scrapers/watchporn.py | 4 +- app/connectors/direct_scrapers/xhamster.py | 2 +- .../direct_scrapers/xmoviesforyou.py | 2 +- app/connectors/direct_scrapers/xnxx.py | 4 +- app/connectors/direct_scrapers/xnxx_browse.py | 10 +- app/connectors/direct_scrapers/xvideos.py | 8 +- .../direct_scrapers/xvideos_browse.py | 8 +- .../direct_scrapers/xxxfreewatch.py | 2 +- app/connectors/direct_scrapers/yesporn.py | 32 ++-- app/connectors/direct_scrapers/youperv.py | 8 +- app/connectors/direct_scrapers/youporn.py | 2 +- .../direct_scrapers/youporn_browse.py | 8 +- app/connectors/dooplay.py | 42 ++--- app/connectors/freeomovie.py | 6 +- app/connectors/paradisehill.py | 32 ++-- app/connectors/tpdb.py | 26 ++-- app/connectors/yourdailypornmovies.py | 12 +- app/enrich/tpdb_movies.py | 10 +- app/extractors/__init__.py | 110 ++++++------- app/extractors/_fetch.py | 8 +- app/extractors/_models.py | 4 +- app/extractors/duration_extract.py | 8 +- app/extractors/hoster.py | 48 +++--- app/extractors/hosters/loadvid.py | 6 +- app/extractors/hosters/mixdrop.py | 6 +- app/extractors/hosters/seekplayer_engine.py | 16 +- app/extractors/hosters/streamtape.py | 12 +- app/extractors/hosters/voe.py | 12 +- app/extractors/iframe_pick.py | 10 +- app/extractors/tag_extract.py | 12 +- app/extractors/thumb_extract.py | 4 +- app/extractors/tubes/_embed_iframe.py | 102 ++++++------ app/extractors/tubes/_kvs.py | 16 +- app/extractors/tubes/_kvs_source.py | 4 +- app/extractors/tubes/_source_getfile.py | 10 +- app/extractors/tubes/_vps_blocked_fallback.py | 4 +- app/extractors/tubes/_ytdlp.py | 20 +-- app/extractors/tubes/eporner.py | 4 +- app/extractors/tubes/freshporno.py | 4 +- app/extractors/tubes/fullmovies.py | 4 +- app/extractors/tubes/fullporner.py | 10 +- app/extractors/tubes/fullvideosporn.py | 12 +- app/extractors/tubes/galaxyporn.py | 8 +- app/extractors/tubes/hdporngg.py | 2 +- app/extractors/tubes/hqfap.py | 8 +- app/extractors/tubes/hqporner.py | 18 +-- app/extractors/tubes/javflix.py | 4 +- app/extractors/tubes/kporner8.py | 6 +- app/extractors/tubes/latestpornvideo.py | 4 +- app/extractors/tubes/neporn.py | 4 +- app/extractors/tubes/paradisehill.py | 4 +- app/extractors/tubes/perverzija.py | 10 +- app/extractors/tubes/porn00.py | 4 +- app/extractors/tubes/pornbusy.py | 8 +- app/extractors/tubes/pornmike.py | 4 +- app/extractors/tubes/porntrex.py | 8 +- app/extractors/tubes/pornxp.py | 6 +- app/extractors/tubes/sexu.py | 10 +- app/extractors/tubes/supjav.py | 10 +- app/extractors/tubes/sxyprn.py | 2 +- app/extractors/tubes/vjav.py | 16 +- app/extractors/tubes/watchporn.py | 2 +- app/extractors/tubes/xhamster.py | 8 +- app/extractors/tubes/yespornvip.py | 2 +- app/extractors/tubes/youperv.py | 4 +- app/ingest.py | 10 +- app/main.py | 4 +- app/models/blacklist.py | 2 +- app/models/bug_report.py | 10 +- app/models/external_record.py | 2 +- app/models/favorite_movie.py | 2 +- app/models/favorite_performer.py | 4 +- app/models/favorite_scene.py | 2 +- app/models/favorite_studio.py | 2 +- app/models/movie.py | 4 +- app/models/movie_playback_source.py | 8 +- app/models/phash_blacklist.py | 4 +- app/models/play_progress.py | 4 +- app/models/playback_event.py | 8 +- app/models/playback_source.py | 2 +- app/models/saved_search.py | 4 +- app/models/scene.py | 4 +- app/models/source_stats.py | 6 +- app/models/tag.py | 2 +- app/normalize/scenes.py | 10 +- app/normalize/tag_categories.py | 2 +- app/normalize/tag_inference.py | 16 +- app/normalize/text.py | 2 +- app/resolve/movie_match.py | 10 +- app/resolve/movie_merge.py | 2 +- app/resolve/movie_resolver.py | 10 +- app/resolve/movie_score.py | 20 +-- app/resolve/performer_resolver.py | 2 +- app/resolve/scene_match.py | 6 +- app/resolve/scene_merge.py | 4 +- app/resolve/scene_resolver.py | 34 ++-- app/resolve/scene_score.py | 2 +- app/resolve/scoring.py | 34 ++-- app/resolve/studio_title_parser.py | 28 ++-- app/resolve/tag_resolver.py | 6 +- app/scheduler/browse_latest.py | 4 +- app/scheduler/bulk_dedup.py | 26 ++-- app/scheduler/deep_crawl.py | 16 +- app/scheduler/hetzner_monitor.py | 4 +- app/scheduler/ingest_watchdog.py | 14 +- app/scheduler/jobs.py | 76 ++++----- app/scheduler/junk_performers.py | 22 +-- app/scheduler/performer_driven.py | 28 ++-- app/scheduler/phash_blacklist.py | 8 +- app/scheduler/source_stats.py | 20 +-- app/scheduler/taxonomy_counts.py | 10 +- app/scheduler/thumb_dedup.py | 2 +- app/scheduler/title_duration_dedup.py | 4 +- app/scheduler/worker.py | 44 +++--- mobile/App.tsx | 48 +++--- mobile/src/ErrorBoundary.tsx | 4 +- mobile/src/SceneActionsContext.tsx | 6 +- mobile/src/api.ts | 14 +- mobile/src/changelog.ts | 4 +- mobile/src/components/BugReportFAB.tsx | 16 +- mobile/src/components/GoonWordmark.tsx | 12 +- mobile/src/components/MigrationBanner.tsx | 4 +- mobile/src/components/MovieFiltersSheet.tsx | 12 +- mobile/src/components/MoviePosterCard.tsx | 2 +- mobile/src/components/OnboardingModal.tsx | 8 +- mobile/src/components/SceneGridSkeleton.tsx | 4 +- mobile/src/components/SceneTile.tsx | 16 +- mobile/src/components/Thumb.tsx | 4 +- mobile/src/components/WhatsNewModal.tsx | 4 +- mobile/src/lib/aesGcm.ts | 12 +- mobile/src/lib/agegate.ts | 4 +- mobile/src/lib/alertGuard.ts | 2 +- mobile/src/lib/appVersion.ts | 8 +- mobile/src/lib/backend.ts | 2 +- mobile/src/lib/donate.ts | 12 +- mobile/src/lib/doodstream.ts | 20 +-- mobile/src/lib/epornerResolver.ts | 2 +- mobile/src/lib/filemoonHoster.ts | 12 +- mobile/src/lib/formatCount.ts | 4 +- mobile/src/lib/fpoxxxResolver.ts | 2 +- mobile/src/lib/getfileResolver.ts | 6 +- mobile/src/lib/keepAwake.ts | 8 +- mobile/src/lib/onboardingBus.ts | 2 +- mobile/src/lib/packerHoster.ts | 16 +- mobile/src/lib/seenStore.ts | 4 +- mobile/src/lib/sxyprnResolver.ts | 2 +- mobile/src/lib/voeResolver.ts | 2 +- mobile/src/migrationNotice.ts | 2 +- mobile/src/native/antiTamper.ts | 4 +- mobile/src/native/apkInstaller.ts | 6 +- mobile/src/navigation.tsx | 26 ++-- mobile/src/screens/AgeGateScreen.tsx | 2 +- mobile/src/screens/AppLockScreen.tsx | 2 +- mobile/src/screens/AppLockSettingsScreen.tsx | 2 +- mobile/src/screens/BlacklistScreen.tsx | 6 +- mobile/src/screens/LoginScreen.tsx | 6 +- mobile/src/screens/MovieDetailScreen.tsx | 14 +- mobile/src/screens/MoviesScreen.tsx | 4 +- mobile/src/screens/PerformerScenesScreen.tsx | 20 +-- mobile/src/screens/PlaybackQualityModal.tsx | 4 +- mobile/src/screens/PlayerScreen.tsx | 138 ++++++++--------- mobile/src/screens/SceneDetailScreen.tsx | 42 ++--- mobile/src/screens/ScenesFilterModal.tsx | 8 +- mobile/src/screens/ScenesScreen.tsx | 8 +- mobile/src/screens/SiteScenesScreen.tsx | 8 +- mobile/src/screens/SitesScreen.tsx | 6 +- mobile/src/screens/StudioMoviesScreen.tsx | 2 +- mobile/src/screens/StudioScenesScreen.tsx | 8 +- mobile/src/screens/SubScoreBars.tsx | 2 +- mobile/src/storage.ts | 14 +- mobile/src/theme.ts | 36 ++--- mobile/src/types.ts | 12 +- scripts/_extract_apk_sig_hash.py | 2 +- scripts/_patch_manifest.py | 2 +- scripts/audit_false_merges.py | 8 +- scripts/audit_teaser_only.py | 6 +- scripts/auto_merge_freshporno_to_canonical.py | 6 +- scripts/backfill_durations.py | 2 +- scripts/backfill_freshporno_dates.py | 2 +- scripts/backfill_freshporno_titles.py | 4 +- scripts/backfill_inferred_tags.py | 8 +- scripts/backfill_paradisehill_movies.py | 2 +- scripts/backfill_paradisehill_tags.py | 4 +- scripts/backfill_phash_tube.py | 8 +- .../backfill_scene_duration_from_playback.py | 2 +- scripts/backfill_scene_performer_source.py | 4 +- scripts/backfill_scene_thumbnails.py | 6 +- scripts/backfill_xvideos_performers.py | 2 +- scripts/bulk_auto_merge.py | 12 +- scripts/bulk_rescrape_hqporner.py | 4 +- scripts/bulk_resolve_merges.py | 4 +- scripts/check_hetzner_traffic.py | 2 +- scripts/debug_pornxp_listing.py | 4 +- scripts/debug_tpdb_performer.py | 2 +- scripts/dedup_favorite_performers.py | 10 +- scripts/dedup_n2_canonical.py | 4 +- scripts/fill_tpdb_refs_batch.py | 4 +- scripts/find_underfilled_performers.py | 2 +- scripts/generate_icons.py | 16 +- scripts/merge_dup2_tags.py | 4 +- scripts/merge_phash_exact_dupes.py | 12 +- scripts/migrate_paradisehill_to_movies.py | 10 +- scripts/phash_benchmark.py | 2 +- scripts/phash_dedup_scenes.py | 10 +- scripts/pilot_browse_scrapers.py | 2 +- scripts/pilot_porndoe_deepcrawl.py | 4 +- scripts/probe_browse_scraper.py | 4 +- scripts/probe_mangoporn_hosters.py | 2 +- scripts/probe_pv_extract.py | 2 +- scripts/publish_update.py | 12 +- scripts/refresh_sxyprn_thumbs.py | 2 +- scripts/reingest_pandamovies_hosts.py | 6 +- scripts/review_performer_attributions.py | 22 +-- scripts/smoke_test.py | 22 +-- scripts/stashdb_studio_backfill.py | 4 +- scripts/studio_retrofix.py | 6 +- scripts/test_porndoe_scraper.py | 2 +- scripts/test_resolve_endpoint.py | 2 +- scripts/theporndude_curl_triage.py | 4 +- scripts/theporndude_movies_pipeline.py | 2 +- scripts/title_levenshtein_benchmark.py | 12 +- scripts/tpdb_backfill.py | 6 +- scripts/tpdb_backfill_status.py | 2 +- scripts/tpdb_studio_backfill.py | 6 +- 291 files changed, 1745 insertions(+), 1745 deletions(-) diff --git a/app/api/admin.py b/app/api/admin.py index 54f7845..702b097 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -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: diff --git a/app/api/admin_html.py b/app/api/admin_html.py index 99514db..2090c6a 100644 --- a/app/api/admin_html.py +++ b/app/api/admin_html.py @@ -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(): diff --git a/app/api/blacklist.py b/app/api/blacklist.py index 4975fe8..0cc5d76 100644 --- a/app/api/blacklist.py +++ b/app/api/blacklist.py @@ -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}. """ diff --git a/app/api/bug_reports.py b/app/api/bug_reports.py index 547eb5e..aba9cda 100644 --- a/app/api/bug_reports.py +++ b/app/api/bug_reports.py @@ -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") diff --git a/app/api/device.py b/app/api/device.py index 2ea605f..bc70dbf 100644 --- a/app/api/device.py +++ b/app/api/device.py @@ -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). """ diff --git a/app/api/expo_updates.py b/app/api/expo_updates.py index 43b7415..849a4b4 100644 --- a/app/api/expo_updates.py +++ b/app/api/expo_updates.py @@ -8,9 +8,9 @@ Każdy update wgrany przez `scripts/publish_update.py` ląduje w `app/static/expo-updates///`. Plik `app/static/expo-updates//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//` — + `asset` to relative path względem `static/expo-updates//`, zwykle `/_expo/static/js/android/.js` lub `/assets/`. 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" diff --git a/app/api/favorites.py b/app/api/favorites.py index 7af8f97..c469f2f 100644 --- a/app/api/favorites.py +++ b/app/api/favorites.py @@ -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 @@ -61,7 +61,7 @@ def _new_counts(session: Session, device_id: str, *, kind: str) -> dict: 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. + {group_id: n}, bez streamowania N*200 wierszy do Pythona. kind="performer": grupuje po ScenePerformer.performer_id, join favorite_performers. kind="studio": grupuje po Scene.studio_id, join favorite_studios. Studia potrzebują @@ -134,7 +134,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 @@ -198,7 +198,7 @@ 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) @@ -216,7 +216,7 @@ 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") @@ -274,7 +274,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() @@ -416,7 +416,7 @@ def mark_studio_seen( # ── 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). diff --git a/app/api/me.py b/app/api/me.py index b6b73df..bcb9bfd 100644 --- a/app/api/me.py +++ b/app/api/me.py @@ -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"), @@ -59,7 +59,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 +114,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) ) diff --git a/app/api/movies.py b/app/api/movies.py index ee93b6a..2089f65 100644 --- a/app/api/movies.py +++ b/app/api/movies.py @@ -1,4 +1,4 @@ -"""GET /movies — lista i szczegóły filmów.""" +"""GET /movies, lista i szczegóły filmów.""" from __future__ import annotations import uuid @@ -138,8 +138,8 @@ def list_movies( 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 +150,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", diff --git a/app/api/playback.py b/app/api/playback.py index 5ec7b88..4f416c6 100644 --- a/app/api/playback.py +++ b/app/api/playback.py @@ -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,10 @@ _TIME_BOUND_CDN_RE = re.compile( re.IGNORECASE, ) -# IP-BOUND CDN signature — token bind do requester IP, cross-IP fetch = 403. +# 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 +82,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 +122,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 +150,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 +162,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 +174,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 +191,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 +203,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 +225,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///pp//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 (`` 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,7 +246,7 @@ 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: links.append( StreamLink( @@ -297,7 +297,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 +362,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 +382,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 +397,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 +410,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 +446,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 +462,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 +498,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. diff --git a/app/api/playback_events.py b/app/api/playback_events.py index a71184e..6138ac0 100644 --- a/app/api/playback_events.py +++ b/app/api/playback_events.py @@ -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:' — zgodne z playback_sources.origin. Bez tego ping bezużyteczny. + # 'tube:', 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) diff --git a/app/api/saved_searches.py b/app/api/saved_searches.py index ad07a24..b12c462 100644 --- a/app/api/saved_searches.py +++ b/app/api/saved_searches.py @@ -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 @@ -30,7 +30,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 +71,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 diff --git a/app/api/scene_favorites.py b/app/api/scene_favorites.py index 72876bd..d99a1f8 100644 --- a/app/api/scene_favorites.py +++ b/app/api/scene_favorites.py @@ -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 diff --git a/app/api/scenes.py b/app/api/scenes.py index dfeeba1..7863b8b 100644 --- a/app/api/scenes.py +++ b/app/api/scenes.py @@ -1,4 +1,4 @@ -"""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 @@ -70,7 +70,7 @@ def _default_scene_count(session: Session) -> int: return total -# JAV vertical — osobna sekcja. Sceny z tych originów NIE wchodzą do głównego feedu +# 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. @@ -89,11 +89,11 @@ def _jav_source_exists(): # 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 @@ -283,7 +283,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,7 +337,7 @@ def list_scenes( base = base.where(~live_playback_exists()) if origin: - # Substring match na origin — 'hqporner' złapie 'tube:hqpornercom'. + # Substring match na origin, 'hqporner' złapie 'tube:hqpornercom'. base = base.where( exists( select(1).where( @@ -350,14 +350,14 @@ def list_scenes( # 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" + # 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()) elif origin is None: base = base.where(~_jav_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) @@ -373,7 +373,7 @@ 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', + # 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_ @@ -416,13 +416,13 @@ 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 @@ -436,7 +436,7 @@ def list_scenes( 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( @@ -451,10 +451,10 @@ def list_scenes( 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. @@ -506,14 +506,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 +527,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,7 +584,7 @@ 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: rows = session.execute( @@ -642,7 +642,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/`) zamiast martwego trafficdeposit URL — token żyje ~1h, + (`/proxy/sxyprn-thumb/`) 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 +655,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/` — zapisany podpis wygasa i CDN + on-demand endpoint `/proxy/youporn-thumb/`, 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,7 +667,7 @@ 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) było tu wpisane 2026-08-02, bo host oddawał 200 @@ -678,12 +678,12 @@ def _is_rotting_thumb(url: str) -> bool: # 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 + # 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. @@ -693,14 +693,14 @@ def _is_rotting_thumb(url: str) -> bool: 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 @@ -717,7 +717,7 @@ def _build_scenes_out_batch( ) -> 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 +760,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,7 +788,7 @@ 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) if light: @@ -806,7 +806,7 @@ 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). @@ -847,7 +847,7 @@ def _build_scenes_out_batch( 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. + # (SceneDetail re-fetchuje pełne źródła), dummy sentinel. pb_by_scene[sid].append( PlaybackSourceOut( id=uuid.UUID(int=0), origin="", page_url="", @@ -999,7 +999,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 +1022,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/) — TAK JAK lista (_build_scenes_out_batch). Bez tego + # (/proxy/sxyprn-thumb/), 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 +1034,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) @@ -1090,7 +1090,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 +1142,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,7 +1273,7 @@ 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 @@ -1309,11 +1309,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 +1379,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 +1476,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 +1497,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, diff --git a/app/api/schemas.py b/app/api/schemas.py index 08879ef..73aa31b 100644 --- a/app/api/schemas.py +++ b/app/api/schemas.py @@ -67,7 +67,7 @@ class SceneOut(BaseModel): external_refs: list[ExternalRefOut] = [] playback_sources: list[PlaybackSourceOut] = [] # 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 +87,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}+". diff --git a/app/api/seo.py b/app/api/seo.py index 4ceda17..977c365 100644 --- a/app/api/seo.py +++ b/app/api/seo.py @@ -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 /.html i oczekuje body 'google-site-verification: .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()), diff --git a/app/api/sources.py b/app/api/sources.py index 478459b..148817a 100644 --- a/app/api/sources.py +++ b/app/api/sources.py @@ -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). @@ -42,7 +42,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,28 +51,28 @@ 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).""" @@ -119,7 +119,7 @@ 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( diff --git a/app/api/stream_proxy.py b/app/api/stream_proxy.py index 83c4a4c..2e74fc5 100644 --- a/app/api/stream_proxy.py +++ b/app/api/stream_proxy.py @@ -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. @@ -41,8 +41,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 +90,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 @@ -170,10 +170,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 +190,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 +220,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 +255,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}/.. """ out: list[str] = [] @@ -284,7 +284,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,7 +310,7 @@ 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} @@ -320,11 +320,11 @@ async def proxy_image( _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 +334,18 @@ 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 >= 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 +362,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/ przy serwowaniu. Cache resolved poster URL ~40min # (< 1h TTL). Klient dostaje STABILNY /proxy/sxyprn-thumb/ → cache'uje bajty na # stałe (treść postera niezmienna), więc fetchujemy stronę post ~raz per post. @@ -376,10 +376,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_.mov/plain/rs:fit:1280:720/vts:N?hash=...&validto=`) — taki URL +# (`.../original_.mov/plain/rs:fit:1280:720/vts:N?hash=...&validto=`), 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/`. _YOUPORN_POSTER_CACHE: dict[str, tuple[str, float]] = {} _YOUPORN_POSTER_TTL = 1800 @@ -417,7 +417,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 (`&`) — bez unescape + # JSON-LD escapuje `\/`, a og:image ma encje HTML (`&`), bez unescape # podpis w query jest zepsuty i CDN oddaje 403. poster = _htmllib.unescape(m.group(1).strip().replace("\\/", "/")) if poster.startswith("//"): @@ -492,7 +492,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 @@ -622,7 +622,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/`.""" @@ -674,7 +674,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 +684,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,7 +707,7 @@ 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), @@ -719,7 +719,7 @@ async def proxy_stream( 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 +728,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 +741,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() @@ -769,7 +769,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 +792,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 +818,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 +845,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 +854,5 @@ 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) + # 4xx other (403 itp.), raise żeby Sentry zarejestrował (może bug naszego kodu) raise HTTPException(status_code=502, detail=f"upstream {status}") diff --git a/app/api/taxonomies.py b/app/api/taxonomies.py index d41a5d7..885ea9c 100644 --- a/app/api/taxonomies.py +++ b/app/api/taxonomies.py @@ -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) @@ -107,7 +107,7 @@ def list_tags( 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 +146,7 @@ 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 + # 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) @@ -236,13 +236,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 +280,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: @@ -340,13 +340,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 +360,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 +418,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 +468,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 +486,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 +504,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,7 +586,7 @@ def refresh_performer( performer_id: uuid.UUID, session: Annotated[Session, Depends(get_session)], ) -> PerformerRefreshOut: - """On-demand search across all tubes dla pojedynczego performera. Synchronous — + """On-demand search across all tubes dla pojedynczego performera. Synchronous, blokujemy aż search skończy. Mobile pokazuje spinner. Rate-guard: jeśli refresh był < 60s temu, zwraca cached result (HTTP 429-style @@ -606,7 +606,7 @@ 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 + # 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). diff --git a/app/api/watch.py b/app/api/watch.py index da193af..94ec34c 100644 --- a/app/api/watch.py +++ b/app/api/watch.py @@ -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 @@ -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") diff --git a/app/auth.py b/app/auth.py index 2936078..e481470 100644 --- a/app/auth.py +++ b/app/auth.py @@ -1,7 +1,7 @@ """API key authentication. Klucz przyjmowany z header `X-API-Key` lub `Authorization: Bearer `. -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: diff --git a/app/config.py b/app/config.py index 0bd5119..e40b6d7 100644 --- a/app/config.py +++ b/app/config.py @@ -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 — ) 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 +143,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 +165,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 +188,14 @@ 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) — + # 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. diff --git a/app/connectors/__init__.py b/app/connectors/__init__.py index de14ea1..d28c6e3 100644 --- a/app/connectors/__init__.py +++ b/app/connectors/__init__.py @@ -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,7 +31,7 @@ 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, @@ -43,10 +43,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 @@ -56,15 +56,15 @@ def get_movie_connectors() -> list[tuple[str, type]]: ("mangoporn", MangopornConnector), ("streampornvip", StreampornVipConnector), ("pandamovies", PandamoviesConnector), - # freeomovie.to (bestia theme, nie dooplay) — mirror: świeże filmy DVD, + # 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), ] diff --git a/app/connectors/base.py b/app/connectors/base.py index b512be4..43596e8 100644 --- a/app/connectors/base.py +++ b/app/connectors/base.py @@ -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 diff --git a/app/connectors/direct_scrapers/__init__.py b/app/connectors/direct_scrapers/__init__.py index ac5b58b..957c686 100644 --- a/app/connectors/direct_scrapers/__init__.py +++ b/app/connectors/direct_scrapers/__init__.py @@ -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 @@ -55,21 +55,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 +78,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-/` 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 @@ -154,62 +154,62 @@ 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 `
` (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 `
` 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 + # 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 +224,105 @@ 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 " 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//`. # 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: ` 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 # + `` per scena (Goon ma # duration w sekundach gotowe + ISO 8601 release_date z timezone). Studio + # performerzy z `btn gold` linków (`/channels//` + `/models//`). # 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//` + `/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:`), 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 + # 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__ = [ diff --git a/app/connectors/direct_scrapers/_browse_base.py b/app/connectors/direct_scrapers/_browse_base.py index fe1fcf6..3728a83 100644 --- a/app/connectors/direct_scrapers/_browse_base.py +++ b/app/connectors/direct_scrapers/_browse_base.py @@ -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. `` → `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: — inner allows apostrophes + # double-quoted content (HTML standard), preferred + # Pattern: , inner allows apostrophes _META_RE_CACHE[key] = re.compile( rf']+{attr}=["\']{val}["\'][^>]*?content="([^"]*)"' rf'|]+{attr}=["\']{val}["\'][^>]*?content=\'([^\']*)\'', diff --git a/app/connectors/direct_scrapers/_playtube.py b/app/connectors/direct_scrapers/_playtube.py index 832a4c2..40daa18 100644 --- a/app/connectors/direct_scrapers/_playtube.py +++ b/app/connectors/direct_scrapers/_playtube.py @@ -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/_.html`, sitemap index `/sitemaps/videos/sitemap-N.xml` (z ``), 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( diff --git a/app/connectors/direct_scrapers/_search_base.py b/app/connectors/direct_scrapers/_search_base.py index 2ee269b..8e62b5f 100644 --- a/app/connectors/direct_scrapers/_search_base.py +++ b/app/connectors/direct_scrapers/_search_base.py @@ -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']+(?: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 `` jako thumbnail. - #: WordPress-like tubes mają thumb w `` — + #: WordPress-like tubes mają thumb w ``, #: ±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,17 +103,17 @@ 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 _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. @@ -153,7 +153,7 @@ class BaseSearchScraper(BaseDirectTubeScraper): 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: @@ -165,11 +165,11 @@ class BaseSearchScraper(BaseDirectTubeScraper): 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 + # 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 + # 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): continue @@ -178,7 +178,7 @@ class BaseSearchScraper(BaseDirectTubeScraper): # Thumbnail: search ±N chars around scene_url match for nearest . # Większość tubes ma `` lub flat - # `` — window 800 obejmuje oba. + # ``, 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] @@ -193,7 +193,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 +212,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( diff --git a/app/connectors/direct_scrapers/base.py b/app/connectors/direct_scrapers/base.py index aba2391..4561eb8 100644 --- a/app/connectors/direct_scrapers/base.py +++ b/app/connectors/direct_scrapers/base.py @@ -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 diff --git a/app/connectors/direct_scrapers/eporner.py b/app/connectors/direct_scrapers/eporner.py index 26b0ab7..2954642 100644 --- a/app/connectors/direct_scrapers/eporner.py +++ b/app/connectors/direct_scrapers/eporner.py @@ -1,4 +1,4 @@ -"""eporner.com — direct HTML scrape search results. +"""eporner.com, direct HTML scrape search results. Search: `https://www.eporner.com/search///` (1-indexed pages). Scene URL: `https://www.eporner.com/hd-porn///` lub `/video-//`. diff --git a/app/connectors/direct_scrapers/eporner_api.py b/app/connectors/direct_scrapers/eporner_api.py index 141d8d5..adc55c7 100644 --- a/app/connectors/direct_scrapers/eporner_api.py +++ b/app/connectors/direct_scrapers/eporner_api.py @@ -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") diff --git a/app/connectors/direct_scrapers/fpoxxx.py b/app/connectors/direct_scrapers/fpoxxx.py index 650dd51..dc82a45 100644 --- a/app/connectors/direct_scrapers/fpoxxx.py +++ b/app/connectors/direct_scrapers/fpoxxx.py @@ -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//`). Przerobione na BROWSE (latest z `/new-/`), 2026-06-22 (user request: ożywić zamrożone tuby). @@ -11,7 +11,7 @@ Listing tile (`/new-/`): → 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 diff --git a/app/connectors/direct_scrapers/freshporno.py b/app/connectors/direct_scrapers/freshporno.py index 462bf62..076e560 100644 --- a/app/connectors/direct_scrapers/freshporno.py +++ b/app/connectors/direct_scrapers/freshporno.py @@ -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//` 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//` 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//` + # Performers, wszyscy `/models//` 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 ``. - # To data wrzucenia na freshporno, NIE oryginalna release_date studio — ale dla + # Release date, freshporno emituje ``. + # 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 diff --git a/app/connectors/direct_scrapers/fullmovies.py b/app/connectors/direct_scrapers/fullmovies.py index e04fffe..56e5c4a 100644 --- a/app/connectors/direct_scrapers/fullmovies.py +++ b/app/connectors/direct_scrapers/fullmovies.py @@ -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//`, `/networks//`, `/models//`, `/tags//`. 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] = [] diff --git a/app/connectors/direct_scrapers/fullporner.py b/app/connectors/direct_scrapers/fullporner.py index 4e86049..11e2434 100644 --- a/app/connectors/direct_scrapers/fullporner.py +++ b/app/connectors/direct_scrapers/fullporner.py @@ -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"]*>\s*([^<]+?)\s*") -# `1785081937
` — data i długość +# `1785081937
`, data i długość # siedzą obok siebie w `video-info`, więc bierzemy je jednym wzorcem. _INFO_RE = re.compile( r'class="create">(\d+)\s*\s*
\s*([0-9:]+)\s*
' @@ -64,7 +64,7 @@ _PERF_BLOCK_RE = re.compile(r"\s*Pornstars?:\s*(.*?)

", re.IGNORECASE _PERF_RE = re.compile(r'href="/pornstar/[^"]+"[^>]*>\s*([^<]+?)\s*
') _TAG_BLOCK_RE = re.compile(r'class="[^"]*tag-link[^"]*"[^>]*>(.*?)

', re.DOTALL) _TAG_RE = re.compile(r'href="/category/[^"]+"[^>]*>#\s*([^<]+?)\s*') -# `";` # Server-side widzimy tylko pusty `
` (iframe wstrzykuje się JS-em -# po kliknięciu "Video Player 1" button). Match na `src=\"\"` w raw HTML source — +# po kliknięciu "Video Player 1" button). Match na `src=\"\"` w raw HTML source, # `\"` to backslash-quote z JSON-style escape'owania, `\/` to backslash-slash. # Po match'u czyścimy `\/` → `/` w captured URL. _JS_ESCAPED_IFRAME_SRC_RE = re.compile( @@ -151,18 +151,18 @@ _DATA_EMBED_RE = re.compile( re.IGNORECASE, ) -# Blacklista hosterów — wyłączamy je z fallbacku iframe→hoster (WebView). Dwie kategorie: +# Blacklista hosterów, wyłączamy je z fallbacku iframe→hoster (WebView). Dwie kategorie: # -# 1) Dead — w 100% przypadków zwracają 403/404/CAPTCHA, mobile pokazuje czarny ekran. +# 1) Dead, w 100% przypadków zwracają 403/404/CAPTCHA, mobile pokazuje czarny ekran. # xtapes.porn → 301 → camcaps.to → 403 (zmigrowane 2026-04, infrastruktura zniknęła). # -# 2) Malware — działają, ale serwują drive-by downloads (.reg/.exe/.msi) i pop-unders +# 2) Malware, działają, ale serwują drive-by downloads (.reg/.exe/.msi) i pop-unders # przez chain reklamowy. yt-dlp odmawia ich ekstrakcji (oznaczone jako piracy), więc -# nie ma mode'u "direct mp4 do ExoPlayera bez WebView" — jedyna opcja to WebView, +# nie ma mode'u "direct mp4 do ExoPlayera bez WebView", jedyna opcja to WebView, # a WebView pozwala JS-owi hostera ściągnąć user execu plik. Bezpieczniej wyciąć je # całkowicie i pokazać user'owi page_url aggregator tube'a (rzadziej malicious niż # sam hoster). User nadal może tam wybrać alt player jeśli istnieje. -# streamtape.com — notorious dla drive-by .reg downloads (zgłoszone 2026-05-07 +# streamtape.com, notorious dla drive-by .reg downloads (zgłoszone 2026-05-07 # porn4days.pw → streamtape.com/e/ → popup + ściąganie .reg). # Hostname boundary: `(?:^|//|\.)` PRZED domain + `/` PO żeby `/filemoon.html` # w path nie matchował, tylko prawdziwy hostname (`https://filemoon.to/...` lub @@ -177,7 +177,7 @@ DEAD_HOSTER_RE = re.compile( r'|streamtape\.[a-z]+|streamta\.pe|streamtap\.com|streamcrypt\.net' # malware r'|scloud\.ninja|stape\.fun|tapecontent\.net|streamtapeadblock\.[a-z]+' # streamtape mirrors r'|openload\.co|openload\.io|oload\.[a-z]+' # openload (offline od 2019) - # filemoon — NIE jest na blacklist. ~2026-05 rebrand na SPA "Byse Frontend" + # filemoon, NIE jest na blacklist. ~2026-05 rebrand na SPA "Byse Frontend" # zabił stary P.A.C.K.E.R.-JWPlayer embed (stąd wcześniejsze błędne uznanie # za "globalny shutdown"), ale video żyje za prywatnym JSON API. RE 2026-05-22: # POST /api/videos//embed/playback {"fingerprint":{}} → AES-256-GCM → @@ -188,12 +188,12 @@ DEAD_HOSTER_RE = re.compile( re.IGNORECASE, ) -# CAPTCHA-walled hosterzy — DoodStream variants serwują 5KB Cloudflare Turnstile +# CAPTCHA-walled hosterzy, DoodStream variants serwują 5KB Cloudflare Turnstile # challenge response na server-side requests z VPS IP. Na phone (T-Mobile/PLAY) # czasem przechodzi, mobile-side resolver (mobile/src/lib/doodstream.ts) ma szansę. # ALE: gdy scene ma alt-hostera (np. luluvid, filemoon), tamten zazwyczaj nie ma # CAPTCHA gate → ExoPlayer odpala bezpośrednio. Sortujemy więc DoodStream NA KONIEC -# listy — Stage 1 (server-side extract) i Stage 2 (mobile hoster picker) tryują +# listy, Stage 1 (server-side extract) i Stage 2 (mobile hoster picker) tryują # najpierw clean hosty, fallback na Dood. Wzorzec sync z mobile/src/lib/doodstream.ts. CAPTCHA_HOSTER_RE = re.compile( r'(?:' @@ -203,7 +203,7 @@ CAPTCHA_HOSTER_RE = re.compile( re.IGNORECASE, ) -# IP-BOUND CDN URLs — stream URLs które bindują się do requester IP (VPS resolve +# IP-BOUND CDN URLs, stream URLs które bindują się do requester IP (VPS resolve # → mobile 403). Stage 1 server-side ekstrakta `extract_stream_from_hoster` zwraca # tę URL, ale mobile direct nie pobierze. Lepiej dropować mp4/m3u8 wynik i upaść # na hoster fallback (mobile WebView wyciągnie URL z phone IP/session). @@ -215,14 +215,14 @@ _IP_BOUND_CDN_RE = re.compile( r"premilkyway\.com" # latestpornvideo r"|tnmr\.org" # mypornerleak (legacy CDN) r"|acek-cdn\.com" # mypornerleak (current CDN, shared KVS infra) - # xtremestream.xyz (perverzija) — `player/xs1.php?data=` to NIE direct mp4 tylko + # xtremestream.xyz (perverzija), `player/xs1.php?data=` to NIE direct mp4 tylko # IP-bound player endpoint (403 cross-IP z VPS). Stage 1 zwracał go jako type=mp4 # → natywny player ładował HTML-player w nieskończoność (reports 06-24 "perverzija # nie działa"/"loading w nieskończoność"). Skip → hoster fallback: WebView ładuje # index.php playera z residential IP telefonu, xs1.php gra w jego sesji. r"|xtremestream\.[a-z]{2,8}" # URL signature shared across these CDNs: `/hls2///.../master.m3u8?t=&s=&e=&srv=&asn=` - # — `asn` query param = Autonomous System Number bind. Generic match jako safety net. + # `asn` query param = Autonomous System Number bind. Generic match jako safety net. r")\b", re.IGNORECASE, ) @@ -243,10 +243,10 @@ def _is_player_iframe(url: str) -> bool: return False if DEAD_HOSTER_RE.search(url): return False - # Player path — `/e/`, `/embed/`, `/video/embed/` + # Player path, `/e/`, `/embed/`, `/video/embed/` if PLAYER_PATH_RE.search(url): return True - # Bare slug — `/` (no folder, no query) — sdefx.cloud-style. + # Bare slug, `/` (no folder, no query), sdefx.cloud-style. parsed = urlparse(url if url.startswith("http") else "https:" + url.lstrip("/")) if BARE_SLUG_PATH_RE.match(parsed.path) and not parsed.query: return True @@ -279,13 +279,13 @@ def _extract_direct_stream_urls(page_html: str, page_url: str) -> list[StreamSou seen.add(url) if DEAD_HOSTER_RE.search(url) or AD_DOMAIN_RE.search(url): continue - # Filter pseudo-direct URLs: file hosters (rapidgator/k2s — premium auth gate) - # i embed pages z .mp4 suffix (playmogo/dood /d/ — to HTML, nie video). + # Filter pseudo-direct URLs: file hosters (rapidgator/k2s, premium auth gate) + # i embed pages z .mp4 suffix (playmogo/dood /d/ to HTML, nie video). if _NOT_DIRECT_STREAM_RE.search(url): continue # KVS false-positives (report c156e4b7/8db71220, watchporn przez _embed_iframe): # preview_/videos_screenshots to krótkie trailery (404), a surowy get_file/ bez - # podpisanego query-tokena daje 403 — kt_player dokłada token z license_code (patrz + # podpisanego query-tokena daje 403, kt_player dokłada token z license_code (patrz # natywny watchporn.extract). Żadne z nich to pełne wideo → pomijamy. low = url.lower() if "videos_screenshots/" in low or "/preview_" in low: @@ -328,7 +328,7 @@ def extract( # Stage 0.5: direct .mp4/.m3u8 URLs on page (porn4days→iceyfile, niektóre xmoviesforyou # native players). Wymagamy quality marker w path (`p.mp4`) żeby uniknąć # thumbnail-preview false-positives. Jeśli znaleziono → zwracamy, skipping iframe - # processing — direct stream URL > WebView fallback. + # processing, direct stream URL > WebView fallback. direct_sources = _extract_direct_stream_urls(page_html, page_url) if direct_sources: log.info("embed_iframe: found %d direct stream URLs on %s", @@ -336,7 +336,7 @@ def extract( return direct_sources # Znajdź WSZYSTKIE iframe-y które wyglądają na player. Wcześniej braliśmy tylko - # pierwszy, ale niektóre tubes (siskavideo) mają kilku hosterów na stronie — + # pierwszy, ale niektóre tubes (siskavideo) mają kilku hosterów na stronie, # gdy pierwszy ma CF challenge / ad-heavy player (playmogo), drugi (luluvid) # może być cleaner. Dedupe po URL żeby nie dublować tego samego playera. # Trzymamy też raw_iframes (pre-filter) żeby odróżnić "page nie ma iframe-a" od @@ -364,7 +364,7 @@ def extract( # JS-hidden backup servers (porn4days SERVER_URL pattern). Niektóre tube'y # renderują tylko 1 iframe a backup hosterów trzymają w `const SERVER2_URL = "..."` # JS variable + clickable "Server 2" button. Bez tego ekstraktor widzi tylko - # SERVER1 (najczęściej iceyfile/streamtape) — gdy ten 404 / malware-blocked, + # SERVER1 (najczęściej iceyfile/streamtape), gdy ten 404 / malware-blocked, # cały scrape ginie. Backupy mogą być na czystych hosterach (turbovidhls/veev.to). for m in _JS_SERVER_URL_RE.finditer(page_html): candidate = m.group("url").strip() @@ -416,7 +416,7 @@ def extract( iframe_urls.append(candidate) # Escape'owane iframe src w JS string literals (porndish pattern). Iframe wstrzykuje - # się do DOM po kliknięciu user'a — server-side HTML widzimy tylko `
`, + # się do DOM po kliknięciu user'a, server-side HTML widzimy tylko `
`, # a iframe URL jest w `const doodstreamContent = "