From ad0284585bae6e6438713ef5c45e5c385e4b3b06 Mon Sep 17 00:00:00 2001 From: goon-foss Date: Wed, 20 May 2026 10:10:22 +0200 Subject: [PATCH] Initial commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Goon — self-hosted aggregator for adult-content scene metadata. Indexes scenes from TPDB, StashDB, and 30+ public adult tube sites. Cross-source deduplication via perceptual hash + Levenshtein distance. FastAPI backend + APScheduler worker + React Native (Expo) mobile client. FOSS, ad-free, donation-funded. See README for details. --- .env.example | 29 + .github/workflows/backend-tests.yml | 32 + .github/workflows/build-apk.yml | 85 + .gitignore | 77 + CONTRIBUTING.md | 120 + DISCLAIMER.md | 62 + Dockerfile | 22 + LICENSE | 21 + README.md | 261 + alembic.ini | 45 + alembic/env.py | 52 + alembic/init/00_extensions.sql | 3 + alembic/script.py.mako | 25 + alembic/versions/20260502_0001_initial.py | 313 + .../20260502_0002_playback_sources.py | 67 + .../versions/20260503_0003_playback_dead.py | 41 + .../20260504_0004_animated_thumbnail.py | 34 + .../20260504_0005_favorite_performers.py | 38 + alembic/versions/20260504_0006_blacklists.py | 41 + .../versions/20260504_0007_play_progress.py | 42 + .../20260506_0008_performer_search_meta.py | 42 + alembic/versions/20260506_0009_movies.py | 146 + .../versions/20260506_0010_favorite_scenes.py | 39 + .../20260507_0011_origin_pornapp_to_tube.py | 60 + .../20260508_0012_favorite_studios.py | 48 + alembic/versions/20260509_0013_bug_reports.py | 52 + .../versions/20260509_0014_favorite_movies.py | 46 + .../20260510_0015_bug_reports_movie_id.py | 38 + .../20260512_0016_realdebrid_cache.py | 44 + .../20260512_0017_drop_realdebrid_cache.py | 37 + app/__init__.py | 0 app/api/__init__.py | 0 app/api/admin.py | 332 + app/api/admin_html.py | 206 + app/api/blacklist.py | 116 + app/api/bug_reports.py | 155 + app/api/expo_updates.py | 104 + app/api/favorites.py | 457 + app/api/movies.py | 275 + app/api/playback.py | 540 + app/api/scene_favorites.py | 83 + app/api/scenes.py | 960 ++ app/api/schemas.py | 127 + app/api/stream_proxy.py | 553 + app/api/taxonomies.py | 597 + app/api/watch.py | 159 + app/auth.py | 46 + app/config.py | 116 + app/connectors/__init__.py | 48 + app/connectors/base.py | 187 + app/connectors/direct_scrapers/__init__.py | 166 + .../direct_scrapers/_browse_base.py | 195 + .../direct_scrapers/_search_base.py | 238 + app/connectors/direct_scrapers/base.py | 27 + app/connectors/direct_scrapers/eporner.py | 18 + app/connectors/direct_scrapers/fpoxxx.py | 22 + app/connectors/direct_scrapers/freshporno.py | 177 + app/connectors/direct_scrapers/fullmovies.py | 129 + app/connectors/direct_scrapers/hdporn92.py | 87 + app/connectors/direct_scrapers/hdporngg.py | 142 + app/connectors/direct_scrapers/hqporner.py | 94 + app/connectors/direct_scrapers/latestleaks.py | 19 + .../direct_scrapers/latestpornvideo.py | 19 + .../direct_scrapers/mypornerleak.py | 19 + app/connectors/direct_scrapers/perverzija.py | 21 + app/connectors/direct_scrapers/porn00.py | 215 + app/connectors/direct_scrapers/porn4days.py | 19 + app/connectors/direct_scrapers/porndish.py | 19 + app/connectors/direct_scrapers/pornditt.py | 26 + app/connectors/direct_scrapers/pornhat.py | 99 + app/connectors/direct_scrapers/pornhub.py | 24 + app/connectors/direct_scrapers/porntrex.py | 33 + app/connectors/direct_scrapers/pornxp.py | 304 + app/connectors/direct_scrapers/redtube.py | 22 + app/connectors/direct_scrapers/shyfap.py | 183 + app/connectors/direct_scrapers/siska.py | 19 + app/connectors/direct_scrapers/sxyland.py | 78 + app/connectors/direct_scrapers/sxyprn.py | 24 + app/connectors/direct_scrapers/watchporn.py | 19 + app/connectors/direct_scrapers/xhamster.py | 19 + .../direct_scrapers/xmoviesforyou.py | 19 + app/connectors/direct_scrapers/xnxx.py | 28 + app/connectors/direct_scrapers/xvideos.py | 33 + .../direct_scrapers/xxxfreewatch.py | 21 + app/connectors/direct_scrapers/youporn.py | 22 + app/connectors/direct_scrapers/zerodayxx.py | 119 + app/connectors/dooplay.py | 466 + app/connectors/paradisehill.py | 325 + app/connectors/stashdb.py | 405 + app/connectors/tpdb.py | 329 + app/db.py | 35 + app/extractors/__init__.py | 157 + app/extractors/_fetch.py | 120 + app/extractors/_models.py | 48 + app/extractors/duration_extract.py | 91 + app/extractors/hoster.py | 343 + app/extractors/hosters/__init__.py | 6 + app/extractors/hosters/mixdrop.py | 82 + app/extractors/hosters/seekplayer_engine.py | 153 + app/extractors/hosters/streamtape.py | 117 + app/extractors/hosters/voe.py | 172 + app/extractors/iframe_pick.py | 109 + app/extractors/tag_extract.py | 239 + app/extractors/thumb_extract.py | 81 + app/extractors/tubes/__init__.py | 5 + app/extractors/tubes/_embed_iframe.py | 520 + app/extractors/tubes/_kvs_source.py | 83 + app/extractors/tubes/_vps_blocked_fallback.py | 34 + app/extractors/tubes/_ytdlp.py | 163 + app/extractors/tubes/eporner.py | 94 + app/extractors/tubes/freshporno.py | 63 + app/extractors/tubes/fullmovies.py | 68 + app/extractors/tubes/hqporner.py | 114 + app/extractors/tubes/latestpornvideo.py | 16 + app/extractors/tubes/paradisehill.py | 96 + app/extractors/tubes/porn00.py | 50 + app/extractors/tubes/pornhat.py | 86 + app/extractors/tubes/pornxp.py | 54 + app/extractors/tubes/sxyprn.py | 85 + app/ingest.py | 360 + app/main.py | 124 + app/models/__init__.py | 67 + app/models/base.py | 38 + app/models/blacklist.py | 47 + app/models/bug_report.py | 50 + app/models/external_record.py | 42 + app/models/favorite_movie.py | 27 + app/models/favorite_performer.py | 30 + app/models/favorite_scene.py | 24 + app/models/favorite_studio.py | 27 + app/models/ingest_run.py | 40 + app/models/merge_candidate.py | 45 + app/models/movie.py | 116 + app/models/movie_playback_source.py | 56 + app/models/performer.py | 81 + app/models/play_progress.py | 33 + app/models/playback_source.py | 68 + app/models/scene.py | 109 + app/models/source.py | 26 + app/models/studio.py | 57 + app/models/tag.py | 18 + app/normalize/__init__.py | 0 app/normalize/movies.py | 66 + app/normalize/scenes.py | 167 + app/normalize/tag_categories.py | 150 + app/normalize/tag_inference.py | 186 + app/normalize/text.py | 61 + app/resolve/__init__.py | 0 app/resolve/movie_match.py | 60 + app/resolve/movie_resolver.py | 372 + app/resolve/movie_score.py | 155 + app/resolve/performer_resolver.py | 145 + app/resolve/scene_match.py | 167 + app/resolve/scene_merge.py | 224 + app/resolve/scene_resolver.py | 611 + app/resolve/scene_score.py | 117 + app/resolve/scoring.py | 274 + app/resolve/studio_resolver.py | 118 + app/resolve/studio_title_parser.py | 141 + app/resolve/tag_resolver.py | 49 + app/scheduler/__init__.py | 0 app/scheduler/browse_latest.py | 70 + app/scheduler/bulk_dedup.py | 472 + app/scheduler/jobs.py | 227 + app/scheduler/performer_driven.py | 612 + app/scheduler/worker.py | 293 + app/templates/base.html | 66 + app/templates/candidate_detail.html | 83 + app/templates/candidates_list.html | 47 + docker-compose.yml | 62 + landing/index.html | 304 + mobile/.gitignore | 7 + mobile/App.tsx | 327 + mobile/README.md | 71 + mobile/android/.gitignore | 16 + mobile/android/app/build.gradle | 193 + mobile/android/app/debug.keystore | Bin 0 -> 2257 bytes mobile/android/app/proguard-rules.pro | 14 + .../android/app/src/debug/AndroidManifest.xml | 7 + .../android/app/src/main/AndroidManifest.xml | 42 + .../java/com/goon/mobile/AntiTamperModule.kt | 64 + .../java/com/goon/mobile/AntiTamperPackage.kt | 14 + .../com/goon/mobile/ApkInstallerModule.kt | 146 + .../com/goon/mobile/ApkInstallerPackage.kt | 14 + .../main/java/com/goon/mobile/MainActivity.kt | 61 + .../java/com/goon/mobile/MainApplication.kt | 57 + .../res/drawable/ic_launcher_background.xml | 6 + .../res/drawable/rn_edit_text_material.xml | 37 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 2613 bytes .../mipmap-hdpi/ic_launcher_foreground.webp | Bin 0 -> 12110 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 3450 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 1556 bytes .../mipmap-mdpi/ic_launcher_foreground.webp | Bin 0 -> 6759 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 2110 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 3745 bytes .../mipmap-xhdpi/ic_launcher_foreground.webp | Bin 0 -> 18195 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 4969 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 6402 bytes .../mipmap-xxhdpi/ic_launcher_foreground.webp | Bin 0 -> 33146 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 8340 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 9250 bytes .../ic_launcher_foreground.webp | Bin 0 -> 50583 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 11949 bytes .../app/src/main/res/values-night/colors.xml | 1 + .../app/src/main/res/values/colors.xml | 6 + .../app/src/main/res/values/strings.xml | 5 + .../app/src/main/res/values/styles.xml | 17 + .../main/res/xml/network_security_config.xml | 38 + mobile/android/build.gradle | 41 + mobile/android/gradle.properties | 58 + .../android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + mobile/android/gradlew | 252 + mobile/android/gradlew.bat | 94 + mobile/android/sentry.properties | 4 + mobile/android/settings.gradle | 38 + mobile/app.json | 54 + mobile/assets/adaptive-icon.png | Bin 0 -> 151728 bytes mobile/assets/favicon.png | Bin 0 -> 1842 bytes mobile/assets/icon.png | Bin 0 -> 69093 bytes mobile/assets/splash.png | Bin 0 -> 37997 bytes mobile/babel.config.js | 6 + mobile/package-lock.json | 12222 ++++++++++++++++ mobile/package.json | 48 + mobile/src/ClientContext.tsx | 14 + mobile/src/ErrorBoundary.tsx | 80 + mobile/src/api.ts | 473 + mobile/src/components/BugReportFAB.tsx | 321 + mobile/src/components/FavoriteSceneRow.tsx | 146 + mobile/src/components/MovieFiltersSheet.tsx | 396 + mobile/src/components/MoviePosterCard.tsx | 85 + mobile/src/components/Thumb.tsx | 74 + mobile/src/lib/agegate.ts | 39 + mobile/src/lib/appVersion.ts | 20 + mobile/src/lib/applock.ts | 74 + mobile/src/lib/donate.ts | 71 + mobile/src/lib/doodstream.ts | 206 + mobile/src/lib/seenStore.ts | 28 + mobile/src/native/antiTamper.ts | 39 + mobile/src/native/apkInstaller.ts | 48 + mobile/src/navigation.tsx | 208 + mobile/src/screens/AgeGateScreen.tsx | 294 + mobile/src/screens/AppLockScreen.tsx | 218 + mobile/src/screens/AppLockSettingsScreen.tsx | 312 + mobile/src/screens/DonateScreen.tsx | 255 + mobile/src/screens/FavoritesScreen.tsx | 584 + mobile/src/screens/LoginScreen.tsx | 232 + mobile/src/screens/MovieDetailScreen.tsx | 315 + mobile/src/screens/MoviesScreen.tsx | 201 + mobile/src/screens/PerformerScenesScreen.tsx | 441 + mobile/src/screens/PerformersScreen.tsx | 293 + mobile/src/screens/PinEntry.tsx | 133 + mobile/src/screens/PlaybackQualityModal.tsx | 132 + mobile/src/screens/PlayerScreen.tsx | 1263 ++ mobile/src/screens/SceneDetailScreen.tsx | 823 ++ mobile/src/screens/ScenesFilterModal.tsx | 398 + mobile/src/screens/ScenesScreen.tsx | 430 + mobile/src/screens/StudioScenesScreen.tsx | 170 + mobile/src/screens/SubScoreBars.tsx | 136 + mobile/src/screens/TagScenesScreen.tsx | 116 + mobile/src/screens/TagsScreen.tsx | 238 + mobile/src/storage.ts | 26 + mobile/src/theme.ts | 26 + mobile/src/types.ts | 348 + mobile/tsconfig.json | 13 + pyproject.toml | 69 + scripts/add_performer_tpdb_ref.py | 49 + scripts/backfill_durations.py | 96 + scripts/backfill_phash_tube.py | 148 + scripts/backfill_scene_thumbnails.py | 153 + scripts/bulk_auto_merge.py | 268 + scripts/bulk_rescrape_hqporner.py | 173 + scripts/bulk_resolve_merges.py | 152 + scripts/check_hetzner_traffic.py | 167 + scripts/compare_performer_canon.py | 190 + scripts/debug_pornxp_listing.py | 34 + scripts/debug_tpdb_performer.py | 81 + scripts/dedup_favorite_performers.py | 368 + scripts/dump_pornapp_sites.ps1 | 54 + scripts/fill_tpdb_refs_batch.py | 113 + scripts/find_underfilled_performers.py | 98 + scripts/generate_icons.py | 140 + scripts/killall_bulk_rescrape.py | 39 + scripts/merge_tags.py | 82 + scripts/migrate_paradisehill_to_movies.py | 173 + scripts/phash_benchmark.py | 153 + scripts/phash_dedup_scenes.py | 157 + scripts/pilot_browse_scrapers.py | 116 + scripts/probe_all_extract.py | 46 + scripts/probe_browse_scraper.py | 113 + scripts/probe_lpv_extract.py | 20 + scripts/probe_mangoporn_hosters.py | 104 + scripts/probe_perverzija.py | 14 + scripts/probe_pv_extract.py | 6 + scripts/probe_scene.py | 20 + scripts/publish_update.py | 169 + scripts/reingest_pandamovies_hosts.py | 121 + scripts/repair_dooplay_movies.py | 118 + scripts/repair_truncated_titles.py | 87 + scripts/reresolve_freshporno_orphans.py | 99 + scripts/restore_canonical_titles.py | 145 + scripts/smoke_test.py | 660 + scripts/sql/hdporn92_delist.sql | 42 + scripts/sql/xxxfreewatch_delist.sql | 40 + scripts/stashdb_studio_backfill.py | 65 + scripts/status_tubes.py | 83 + scripts/studio_retrofix.py | 167 + scripts/test_cross_ip.py | 48 + scripts/test_resolve_endpoint.py | 59 + scripts/title_levenshtein_benchmark.py | 245 + scripts/tpdb_backfill.py | 191 + scripts/tpdb_backfill_status.py | 66 + scripts/tpdb_studio_backfill.py | 72 + streamtape_sample.md | 54 + tests/__init__.py | 0 tests/fixtures/stashdb_scene.json | 58 + tests/fixtures/tpdb_scene.json | 57 + tests/test_auth.py | 66 + tests/test_ingest_hash.py | 28 + tests/test_normalize.py | 50 + tests/test_normalize_scenes.py | 60 + tests/test_normalize_scenes_propagates_m3.py | 32 + tests/test_scoring.py | 156 + tests/test_stashdb_connector.py | 86 + tests/test_stashdb_parser.py | 82 + tests/test_tpdb_connector.py | 77 + tests/test_tpdb_parser.py | 86 + 329 files changed, 51795 insertions(+) create mode 100644 .env.example create mode 100644 .github/workflows/backend-tests.yml create mode 100644 .github/workflows/build-apk.yml create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 DISCLAIMER.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 alembic.ini create mode 100644 alembic/env.py create mode 100644 alembic/init/00_extensions.sql create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/20260502_0001_initial.py create mode 100644 alembic/versions/20260502_0002_playback_sources.py create mode 100644 alembic/versions/20260503_0003_playback_dead.py create mode 100644 alembic/versions/20260504_0004_animated_thumbnail.py create mode 100644 alembic/versions/20260504_0005_favorite_performers.py create mode 100644 alembic/versions/20260504_0006_blacklists.py create mode 100644 alembic/versions/20260504_0007_play_progress.py create mode 100644 alembic/versions/20260506_0008_performer_search_meta.py create mode 100644 alembic/versions/20260506_0009_movies.py create mode 100644 alembic/versions/20260506_0010_favorite_scenes.py create mode 100644 alembic/versions/20260507_0011_origin_pornapp_to_tube.py create mode 100644 alembic/versions/20260508_0012_favorite_studios.py create mode 100644 alembic/versions/20260509_0013_bug_reports.py create mode 100644 alembic/versions/20260509_0014_favorite_movies.py create mode 100644 alembic/versions/20260510_0015_bug_reports_movie_id.py create mode 100644 alembic/versions/20260512_0016_realdebrid_cache.py create mode 100644 alembic/versions/20260512_0017_drop_realdebrid_cache.py create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/admin.py create mode 100644 app/api/admin_html.py create mode 100644 app/api/blacklist.py create mode 100644 app/api/bug_reports.py create mode 100644 app/api/expo_updates.py create mode 100644 app/api/favorites.py create mode 100644 app/api/movies.py create mode 100644 app/api/playback.py create mode 100644 app/api/scene_favorites.py create mode 100644 app/api/scenes.py create mode 100644 app/api/schemas.py create mode 100644 app/api/stream_proxy.py create mode 100644 app/api/taxonomies.py create mode 100644 app/api/watch.py create mode 100644 app/auth.py create mode 100644 app/config.py create mode 100644 app/connectors/__init__.py create mode 100644 app/connectors/base.py create mode 100644 app/connectors/direct_scrapers/__init__.py create mode 100644 app/connectors/direct_scrapers/_browse_base.py create mode 100644 app/connectors/direct_scrapers/_search_base.py create mode 100644 app/connectors/direct_scrapers/base.py create mode 100644 app/connectors/direct_scrapers/eporner.py create mode 100644 app/connectors/direct_scrapers/fpoxxx.py create mode 100644 app/connectors/direct_scrapers/freshporno.py create mode 100644 app/connectors/direct_scrapers/fullmovies.py create mode 100644 app/connectors/direct_scrapers/hdporn92.py create mode 100644 app/connectors/direct_scrapers/hdporngg.py create mode 100644 app/connectors/direct_scrapers/hqporner.py create mode 100644 app/connectors/direct_scrapers/latestleaks.py create mode 100644 app/connectors/direct_scrapers/latestpornvideo.py create mode 100644 app/connectors/direct_scrapers/mypornerleak.py create mode 100644 app/connectors/direct_scrapers/perverzija.py create mode 100644 app/connectors/direct_scrapers/porn00.py create mode 100644 app/connectors/direct_scrapers/porn4days.py create mode 100644 app/connectors/direct_scrapers/porndish.py create mode 100644 app/connectors/direct_scrapers/pornditt.py create mode 100644 app/connectors/direct_scrapers/pornhat.py create mode 100644 app/connectors/direct_scrapers/pornhub.py create mode 100644 app/connectors/direct_scrapers/porntrex.py create mode 100644 app/connectors/direct_scrapers/pornxp.py create mode 100644 app/connectors/direct_scrapers/redtube.py create mode 100644 app/connectors/direct_scrapers/shyfap.py create mode 100644 app/connectors/direct_scrapers/siska.py create mode 100644 app/connectors/direct_scrapers/sxyland.py create mode 100644 app/connectors/direct_scrapers/sxyprn.py create mode 100644 app/connectors/direct_scrapers/watchporn.py create mode 100644 app/connectors/direct_scrapers/xhamster.py create mode 100644 app/connectors/direct_scrapers/xmoviesforyou.py create mode 100644 app/connectors/direct_scrapers/xnxx.py create mode 100644 app/connectors/direct_scrapers/xvideos.py create mode 100644 app/connectors/direct_scrapers/xxxfreewatch.py create mode 100644 app/connectors/direct_scrapers/youporn.py create mode 100644 app/connectors/direct_scrapers/zerodayxx.py create mode 100644 app/connectors/dooplay.py create mode 100644 app/connectors/paradisehill.py create mode 100644 app/connectors/stashdb.py create mode 100644 app/connectors/tpdb.py create mode 100644 app/db.py create mode 100644 app/extractors/__init__.py create mode 100644 app/extractors/_fetch.py create mode 100644 app/extractors/_models.py create mode 100644 app/extractors/duration_extract.py create mode 100644 app/extractors/hoster.py create mode 100644 app/extractors/hosters/__init__.py create mode 100644 app/extractors/hosters/mixdrop.py create mode 100644 app/extractors/hosters/seekplayer_engine.py create mode 100644 app/extractors/hosters/streamtape.py create mode 100644 app/extractors/hosters/voe.py create mode 100644 app/extractors/iframe_pick.py create mode 100644 app/extractors/tag_extract.py create mode 100644 app/extractors/thumb_extract.py create mode 100644 app/extractors/tubes/__init__.py create mode 100644 app/extractors/tubes/_embed_iframe.py create mode 100644 app/extractors/tubes/_kvs_source.py create mode 100644 app/extractors/tubes/_vps_blocked_fallback.py create mode 100644 app/extractors/tubes/_ytdlp.py create mode 100644 app/extractors/tubes/eporner.py create mode 100644 app/extractors/tubes/freshporno.py create mode 100644 app/extractors/tubes/fullmovies.py create mode 100644 app/extractors/tubes/hqporner.py create mode 100644 app/extractors/tubes/latestpornvideo.py create mode 100644 app/extractors/tubes/paradisehill.py create mode 100644 app/extractors/tubes/porn00.py create mode 100644 app/extractors/tubes/pornhat.py create mode 100644 app/extractors/tubes/pornxp.py create mode 100644 app/extractors/tubes/sxyprn.py create mode 100644 app/ingest.py create mode 100644 app/main.py create mode 100644 app/models/__init__.py create mode 100644 app/models/base.py create mode 100644 app/models/blacklist.py create mode 100644 app/models/bug_report.py create mode 100644 app/models/external_record.py create mode 100644 app/models/favorite_movie.py create mode 100644 app/models/favorite_performer.py create mode 100644 app/models/favorite_scene.py create mode 100644 app/models/favorite_studio.py create mode 100644 app/models/ingest_run.py create mode 100644 app/models/merge_candidate.py create mode 100644 app/models/movie.py create mode 100644 app/models/movie_playback_source.py create mode 100644 app/models/performer.py create mode 100644 app/models/play_progress.py create mode 100644 app/models/playback_source.py create mode 100644 app/models/scene.py create mode 100644 app/models/source.py create mode 100644 app/models/studio.py create mode 100644 app/models/tag.py create mode 100644 app/normalize/__init__.py create mode 100644 app/normalize/movies.py create mode 100644 app/normalize/scenes.py create mode 100644 app/normalize/tag_categories.py create mode 100644 app/normalize/tag_inference.py create mode 100644 app/normalize/text.py create mode 100644 app/resolve/__init__.py create mode 100644 app/resolve/movie_match.py create mode 100644 app/resolve/movie_resolver.py create mode 100644 app/resolve/movie_score.py create mode 100644 app/resolve/performer_resolver.py create mode 100644 app/resolve/scene_match.py create mode 100644 app/resolve/scene_merge.py create mode 100644 app/resolve/scene_resolver.py create mode 100644 app/resolve/scene_score.py create mode 100644 app/resolve/scoring.py create mode 100644 app/resolve/studio_resolver.py create mode 100644 app/resolve/studio_title_parser.py create mode 100644 app/resolve/tag_resolver.py create mode 100644 app/scheduler/__init__.py create mode 100644 app/scheduler/browse_latest.py create mode 100644 app/scheduler/bulk_dedup.py create mode 100644 app/scheduler/jobs.py create mode 100644 app/scheduler/performer_driven.py create mode 100644 app/scheduler/worker.py create mode 100644 app/templates/base.html create mode 100644 app/templates/candidate_detail.html create mode 100644 app/templates/candidates_list.html create mode 100644 docker-compose.yml create mode 100644 landing/index.html create mode 100644 mobile/.gitignore create mode 100644 mobile/App.tsx create mode 100644 mobile/README.md create mode 100644 mobile/android/.gitignore create mode 100644 mobile/android/app/build.gradle create mode 100644 mobile/android/app/debug.keystore create mode 100644 mobile/android/app/proguard-rules.pro create mode 100644 mobile/android/app/src/debug/AndroidManifest.xml create mode 100644 mobile/android/app/src/main/AndroidManifest.xml create mode 100644 mobile/android/app/src/main/java/com/goon/mobile/AntiTamperModule.kt create mode 100644 mobile/android/app/src/main/java/com/goon/mobile/AntiTamperPackage.kt create mode 100644 mobile/android/app/src/main/java/com/goon/mobile/ApkInstallerModule.kt create mode 100644 mobile/android/app/src/main/java/com/goon/mobile/ApkInstallerPackage.kt create mode 100644 mobile/android/app/src/main/java/com/goon/mobile/MainActivity.kt create mode 100644 mobile/android/app/src/main/java/com/goon/mobile/MainApplication.kt create mode 100644 mobile/android/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 mobile/android/app/src/main/res/drawable/rn_edit_text_material.xml create mode 100644 mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp create mode 100644 mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp create mode 100644 mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp create mode 100644 mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp create mode 100644 mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp create mode 100644 mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 mobile/android/app/src/main/res/values-night/colors.xml create mode 100644 mobile/android/app/src/main/res/values/colors.xml create mode 100644 mobile/android/app/src/main/res/values/strings.xml create mode 100644 mobile/android/app/src/main/res/values/styles.xml create mode 100644 mobile/android/app/src/main/res/xml/network_security_config.xml create mode 100644 mobile/android/build.gradle create mode 100644 mobile/android/gradle.properties create mode 100644 mobile/android/gradle/wrapper/gradle-wrapper.jar create mode 100644 mobile/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 mobile/android/gradlew create mode 100644 mobile/android/gradlew.bat create mode 100644 mobile/android/sentry.properties create mode 100644 mobile/android/settings.gradle create mode 100644 mobile/app.json create mode 100644 mobile/assets/adaptive-icon.png create mode 100644 mobile/assets/favicon.png create mode 100644 mobile/assets/icon.png create mode 100644 mobile/assets/splash.png create mode 100644 mobile/babel.config.js create mode 100644 mobile/package-lock.json create mode 100644 mobile/package.json create mode 100644 mobile/src/ClientContext.tsx create mode 100644 mobile/src/ErrorBoundary.tsx create mode 100644 mobile/src/api.ts create mode 100644 mobile/src/components/BugReportFAB.tsx create mode 100644 mobile/src/components/FavoriteSceneRow.tsx create mode 100644 mobile/src/components/MovieFiltersSheet.tsx create mode 100644 mobile/src/components/MoviePosterCard.tsx create mode 100644 mobile/src/components/Thumb.tsx create mode 100644 mobile/src/lib/agegate.ts create mode 100644 mobile/src/lib/appVersion.ts create mode 100644 mobile/src/lib/applock.ts create mode 100644 mobile/src/lib/donate.ts create mode 100644 mobile/src/lib/doodstream.ts create mode 100644 mobile/src/lib/seenStore.ts create mode 100644 mobile/src/native/antiTamper.ts create mode 100644 mobile/src/native/apkInstaller.ts create mode 100644 mobile/src/navigation.tsx create mode 100644 mobile/src/screens/AgeGateScreen.tsx create mode 100644 mobile/src/screens/AppLockScreen.tsx create mode 100644 mobile/src/screens/AppLockSettingsScreen.tsx create mode 100644 mobile/src/screens/DonateScreen.tsx create mode 100644 mobile/src/screens/FavoritesScreen.tsx create mode 100644 mobile/src/screens/LoginScreen.tsx create mode 100644 mobile/src/screens/MovieDetailScreen.tsx create mode 100644 mobile/src/screens/MoviesScreen.tsx create mode 100644 mobile/src/screens/PerformerScenesScreen.tsx create mode 100644 mobile/src/screens/PerformersScreen.tsx create mode 100644 mobile/src/screens/PinEntry.tsx create mode 100644 mobile/src/screens/PlaybackQualityModal.tsx create mode 100644 mobile/src/screens/PlayerScreen.tsx create mode 100644 mobile/src/screens/SceneDetailScreen.tsx create mode 100644 mobile/src/screens/ScenesFilterModal.tsx create mode 100644 mobile/src/screens/ScenesScreen.tsx create mode 100644 mobile/src/screens/StudioScenesScreen.tsx create mode 100644 mobile/src/screens/SubScoreBars.tsx create mode 100644 mobile/src/screens/TagScenesScreen.tsx create mode 100644 mobile/src/screens/TagsScreen.tsx create mode 100644 mobile/src/storage.ts create mode 100644 mobile/src/theme.ts create mode 100644 mobile/src/types.ts create mode 100644 mobile/tsconfig.json create mode 100644 pyproject.toml create mode 100644 scripts/add_performer_tpdb_ref.py create mode 100644 scripts/backfill_durations.py create mode 100644 scripts/backfill_phash_tube.py create mode 100644 scripts/backfill_scene_thumbnails.py create mode 100644 scripts/bulk_auto_merge.py create mode 100644 scripts/bulk_rescrape_hqporner.py create mode 100644 scripts/bulk_resolve_merges.py create mode 100644 scripts/check_hetzner_traffic.py create mode 100644 scripts/compare_performer_canon.py create mode 100644 scripts/debug_pornxp_listing.py create mode 100644 scripts/debug_tpdb_performer.py create mode 100644 scripts/dedup_favorite_performers.py create mode 100644 scripts/dump_pornapp_sites.ps1 create mode 100644 scripts/fill_tpdb_refs_batch.py create mode 100644 scripts/find_underfilled_performers.py create mode 100644 scripts/generate_icons.py create mode 100644 scripts/killall_bulk_rescrape.py create mode 100644 scripts/merge_tags.py create mode 100644 scripts/migrate_paradisehill_to_movies.py create mode 100644 scripts/phash_benchmark.py create mode 100644 scripts/phash_dedup_scenes.py create mode 100644 scripts/pilot_browse_scrapers.py create mode 100644 scripts/probe_all_extract.py create mode 100644 scripts/probe_browse_scraper.py create mode 100644 scripts/probe_lpv_extract.py create mode 100644 scripts/probe_mangoporn_hosters.py create mode 100644 scripts/probe_perverzija.py create mode 100644 scripts/probe_pv_extract.py create mode 100644 scripts/probe_scene.py create mode 100644 scripts/publish_update.py create mode 100644 scripts/reingest_pandamovies_hosts.py create mode 100644 scripts/repair_dooplay_movies.py create mode 100644 scripts/repair_truncated_titles.py create mode 100644 scripts/reresolve_freshporno_orphans.py create mode 100644 scripts/restore_canonical_titles.py create mode 100644 scripts/smoke_test.py create mode 100644 scripts/sql/hdporn92_delist.sql create mode 100644 scripts/sql/xxxfreewatch_delist.sql create mode 100644 scripts/stashdb_studio_backfill.py create mode 100644 scripts/status_tubes.py create mode 100644 scripts/studio_retrofix.py create mode 100644 scripts/test_cross_ip.py create mode 100644 scripts/test_resolve_endpoint.py create mode 100644 scripts/title_levenshtein_benchmark.py create mode 100644 scripts/tpdb_backfill.py create mode 100644 scripts/tpdb_backfill_status.py create mode 100644 scripts/tpdb_studio_backfill.py create mode 100644 streamtape_sample.md create mode 100644 tests/__init__.py create mode 100644 tests/fixtures/stashdb_scene.json create mode 100644 tests/fixtures/tpdb_scene.json create mode 100644 tests/test_auth.py create mode 100644 tests/test_ingest_hash.py create mode 100644 tests/test_normalize.py create mode 100644 tests/test_normalize_scenes.py create mode 100644 tests/test_normalize_scenes_propagates_m3.py create mode 100644 tests/test_scoring.py create mode 100644 tests/test_stashdb_connector.py create mode 100644 tests/test_stashdb_parser.py create mode 100644 tests/test_tpdb_connector.py create mode 100644 tests/test_tpdb_parser.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7dfc8ae --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +POSTGRES_USER=goon +POSTGRES_PASSWORD=goon +POSTGRES_DB=goon +POSTGRES_PORT=5432 + +API_PORT=8000 + +DATABASE_URL=postgresql+psycopg://goon:goon@localhost:5432/goon + +# TPDB (theporndb.net) — required for canonical scene metadata + performer canonicalization. +# Get token from your TPDB account settings. +TPDB_API_TOKEN= +TPDB_BASE_URL=https://api.theporndb.net + +# StashDB — second canonical source. Required for full performer/scene cross-source dedup. +STASHDB_API_KEY= +STASHDB_GRAPHQL_URL=https://stashdb.org/graphql + +LOG_LEVEL=INFO + +# Comma-separated list of API keys. Empty = auth disabled (only safe for localhost). +# Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))" +API_KEYS= + +# Sentry observability — empty = init no-op (no telemetry sent). +# Set your own DSN if you self-host Sentry or use cloud free tier. +SENTRY_DSN= +SENTRY_ENVIRONMENT=dev +SENTRY_TRACES_SAMPLE_RATE=0.1 diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml new file mode 100644 index 0000000..0a6ae09 --- /dev/null +++ b/.github/workflows/backend-tests.yml @@ -0,0 +1,32 @@ +name: Backend tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e .[dev] + + - name: Lint (ruff) + run: ruff check app/ tests/ + + - name: Run pytest + run: pytest --tb=short diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml new file mode 100644 index 0000000..ec20d5d --- /dev/null +++ b/.github/workflows/build-apk.yml @@ -0,0 +1,85 @@ +name: Build Android APK + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: mobile/package-lock.json + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Gradle cache + uses: gradle/actions/setup-gradle@v4 + + - name: Install npm dependencies + working-directory: mobile + run: npm ci + + - name: Pre-bundle JS for debug embedding + # Default RN debug builds don't embed JS bundle (expects Metro server). + # We explicitly run Expo's `export:embed` so the resulting APK works + # standalone on a phone without Metro running. This is also where + # `EXPO_PUBLIC_*` env vars get inlined into the bundle. + working-directory: mobile + env: + EXPO_PUBLIC_SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + EXPO_PUBLIC_SENTRY_ENVIRONMENT: ${{ vars.SENTRY_ENVIRONMENT || 'production' }} + run: | + mkdir -p android/app/src/main/assets android/app/src/main/res + node node_modules/@expo/cli/build/bin/cli export:embed \ + --platform android \ + --dev false \ + --bundle-output android/app/src/main/assets/index.android.bundle \ + --assets-dest android/app/src/main/res + + - name: Build debug APK + working-directory: mobile/android + run: ./gradlew assembleDebug --no-daemon + env: + NODE_OPTIONS: --max_old_space_size=4096 + + - name: Rename APK with version + id: rename + working-directory: mobile/android/app/build/outputs/apk/debug + run: | + REF_NAME="${{ github.ref_name }}" + # Sanitize ref → safe filename component + VERSION="${REF_NAME//[^a-zA-Z0-9._-]/_}" + mv app-debug.apk "goon-${VERSION}-debug.apk" + echo "apk=mobile/android/app/build/outputs/apk/debug/goon-${VERSION}-debug.apk" >> "$GITHUB_OUTPUT" + + - name: Upload APK artifact + uses: actions/upload-artifact@v4 + with: + name: goon-apk-${{ github.ref_name }} + path: ${{ steps.rename.outputs.apk }} + retention-days: 30 + + - name: Attach APK to GitHub Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + files: ${{ steps.rename.outputs.apk }} + fail_on_unmatched_files: true + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3726b50 --- /dev/null +++ b/.gitignore @@ -0,0 +1,77 @@ +.env +.env.local +mobile/.env +mobile/.env.local +*.pyc +__pycache__/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.coverage +htmlcov/ +*.egg-info/ +build/ +dist/ +.venv/ +venv/ +.idea/ +.vscode/ +*.sqlite +*.db + +# Personal operational notes (deploy state, in-progress notes per session) +DEPLOY_BACKLOG.md + +# Mobile (Expo / React Native) +mobile/node_modules/ +mobile/.expo/ +mobile/dist/ +mobile/web-build/ +mobile/android/.gradle/ +mobile/android/app/build/ +mobile/android/build/ +mobile/ios/build/ +mobile/ios/Pods/ +mobile/*.jks +mobile/*.keystore + +# Mobile build artefakty (regenerowane przy `gradlew assembleDebug` przez expo +# `export:embed`). NIE commitować — psuje rebuilds (gradle merguje stale bundle +# zamiast generować świeży, patrz session 2026-05-07). +mobile/android/app/src/main/assets/index.android.bundle +mobile/android/app/src/main/res/drawable-*/ +mobile/android/app/src/main/res/raw/ + +# yt-dlp / scrapers cache +.yt-dlp-cache/ + +# Reverse-engineered third-party APKs (AIO Streamer dekompilacja — kept locally for +# debugging the legacy porn-app auth flow, but MUST NOT enter public git history; +# distributing decompiled proprietary code violates copyright/EULA). +re/ + +# DB dumps (operacyjne backupy, mogą zawierać user data) +*.dump +*.sql.gz + +# Built APKs (release/debug binaries — distributed via GitHub Releases instead) +*.apk + +# Claude Code session data (transcripts/agents — local only) +.claude/ + +# Operacyjne logi inputu / debug per-session +*.log + +# Per-user runtime artefakty NIE do publicznego repo +.iclaude +wa-logs.txt +mcp-logs.txt + +# ADB / development debug artefakty (screenshots, ui dumps) +.tmp_adb/ + +# Operational deploy scripts — moved to a private companion repo. Public repo +# should NOT contain SSH commands, systemd units, or smoke-test playbooks +# referencing concrete hosts. +deploy/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..220c35c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,120 @@ +# Contributing to Goon + +## Development setup + +Goon backend is Python 3.12+, FastAPI + SQLAlchemy + APScheduler + Postgres. +Mobile client is React Native + Expo. + +### Backend + +```bash +# Create virtualenv +python -m venv .venv +. .venv/bin/activate # or .venv\Scripts\activate on Windows + +# Install with dev extras +pip install -e .[dev] + +# Bring up postgres (or use docker-compose; see README) +# Adjust DATABASE_URL in .env if needed +cp .env.example .env + +# Run migrations +alembic upgrade head + +# Run API +uvicorn app.main:app --reload --port 8000 + +# Run worker (separate terminal) +python -m app.scheduler.worker # full scheduler +python -m app.scheduler.worker --once --source=tpdb --limit=50 # one-shot ingest +``` + +### Mobile + +```bash +cd mobile +npm install +npm start # opens Expo dev server +``` + +## Tests + +```bash +pytest # full suite (~70 tests, <5s) +pytest tests/test_resolve_*.py -v +ruff check app/ +mypy app/ # optional, CI-only +``` + +PRs must pass `pytest` + `ruff check`. Run them locally before pushing. + +## Code style + +- **Formatting**: ruff (config in `pyproject.toml`). Line length 100. +- **Type hints**: required on public functions. `from __future__ import annotations` + in every module. +- **Docstrings**: write the **why**, not the **what**. Reference real bugs/incidents + when explaining non-obvious code paths. +- **Comments**: only when the code can't speak for itself. Prefer renaming a + variable over adding a comment that explains it. +- **No dead code, no commented-out code, no TODO without an issue link.** +- **Polish or English in comments**: existing code is mostly Polish in + comments and English in code (function/class/var names). New code can be + either, but be consistent within a file. + +## Adding a new tube extractor / scraper + +If you want Goon to support an additional adult tube site: + +1. **Stream extractor** (`app/extractors/tubes/`): given a scene page URL, + return a list of `StreamSource` (m3u8/mp4 URLs with quality labels). + - Mainstream tubes: try `_ytdlp.extract` (yt-dlp covers ~30 tubes out of + the box — just register the sitetag in `app/extractors/__init__.py`). + - WordPress-like tubes with embed iframe: register `_embed_iframe.extract`. + - Custom player / signed URLs / token rotation: write your own per-tube + module (see `hqporner.py`, `eporner.py`, `sxyprn.py` as references). + +2. **Discovery scraper** (`app/connectors/direct_scrapers/`): subclass + `BaseSearchScraper`, set `sitetag`, `_search_url_template`, `_scene_url_re`. + Most aggregator tubes can fit in 10-20 lines (see `xmoviesforyou.py`). + +3. **Register** the scraper class in `ALL_DIRECT_SCRAPERS` in + `app/connectors/direct_scrapers/__init__.py`. + +4. **Test** with one performer name that you know has scenes on that tube: + ```bash + python -m app.scheduler.worker --once --strategy=performer-driven \ + --performers="Some Performer" --sitetags= + ``` + +## Database migrations + +Use Alembic: + +```bash +alembic revision -m "describe change" # new migration +alembic upgrade head # apply +alembic downgrade -1 # roll back one +``` + +Every migration must have a working `downgrade()`. We don't ship squashed +migrations — full history is the source of truth. + +## What we won't merge + +- **Adult-content moderation features** (auto-tagging by detected acts, + content filtering by performer attributes, etc.) — out of scope. +- **Hardcoded credentials, API keys, or device IDs** in source — must be + env-driven. +- **Bypassing tube paywalls / DRM / auth** — Goon only scrapes publicly + accessible search pages. +- **Telemetry or analytics that report user activity to third parties**. + Sentry is opt-in (`SENTRY_DSN` empty by default). +- **Public deployment recipes** (e.g. nginx config for an open instance). + Goon is self-hosted only — see [DISCLAIMER.md](./DISCLAIMER.md). + +## License + +By contributing, you agree your contributions are licensed under the MIT +License (see [LICENSE](./LICENSE)). diff --git a/DISCLAIMER.md b/DISCLAIMER.md new file mode 100644 index 0000000..4949660 --- /dev/null +++ b/DISCLAIMER.md @@ -0,0 +1,62 @@ +# Disclaimer + +## Adult Content (18+) + +Goon is a self-hosted aggregator for adult-content scene metadata. The software +itself contains no media — it indexes metadata from third-party sources +(TheporndB, StashDB, public adult tube sites) and links to those sources for +playback. + +By using, hosting, or distributing this software you affirm that: + +- You are at least 18 years of age (or the age of legal majority in your + jurisdiction, whichever is greater). +- Adult content is legal to view, store metadata about, and access in your + jurisdiction. +- You are solely responsible for compliance with all applicable laws, + including (but not limited to) record-keeping requirements (e.g. 18 U.S.C. + § 2257 in the United States) and content classification rules. + +## Self-Hosting Only + +This software is intended for **self-hosting on infrastructure you control**. +Operating a public-facing instance accessible to unauthenticated users is +**not the intended use case** and may expose you to legal liability for +content delivery, age verification, and data protection. + +If you operate a publicly accessible instance you are entirely responsible for +implementing the age verification, geo-restrictions, content moderation, ToS, +and privacy controls that your jurisdiction requires. + +## Third-Party Sources + +Goon scrapes publicly accessible search/listing pages from adult tube sites +to build its index. By configuring those scrapers and pointing them at a +target tube you accept that: + +- Tube sites' Terms of Service may prohibit automated access. Respect their + rate limits and `robots.txt`. Goon does not bypass paywalls, authentication, + or DRM. +- Tube sites may at any time change their HTML, block your IP, or disable + features Goon depends on. Discovery and stream resolution are best-effort. +- The metadata Goon stores (titles, performer names, duration, thumbnails) + is sourced from those tubes and may contain inaccuracies, NSFW filenames, + or content the tube has since removed. Reporting takedown requests is your + responsibility — Goon ships no takedown workflow. + +## No Warranty + +This software is provided "AS IS" without warranty of any kind. See +[LICENSE](./LICENSE) for full terms. The authors and contributors are not +liable for any damages, losses, or legal consequences arising from use of +this software. + +## Reporting Issues + +For security issues affecting the software itself (auth bypass, RCE, secret +leak): open a private security advisory on the GitHub repository. + +For takedown requests, content concerns, or jurisdiction-specific compliance +questions: contact the operator of the specific instance — Goon contributors +are not in a position to take action on third-party content surfaced by +self-hosted deployments. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fb93195 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PYTHONPATH=/srv + +WORKDIR /srv + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential \ + && rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml ./ +RUN pip install --upgrade pip \ + && pip install -e .[dev] + +COPY app ./app +COPY alembic ./alembic +COPY alembic.ini ./ + +EXPOSE 8000 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7f55c91 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Goon contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f1a1e53 --- /dev/null +++ b/README.md @@ -0,0 +1,261 @@ +# Goon + +Self-hosted aggregator for adult-content scene metadata. Indexes scenes from +TheporndB, StashDB, and 30+ public adult tube sites; deduplicates across +sources; serves an API + mobile (React Native) client for browsing and +linking out to playback. + +> **18+ ONLY · Self-hosted only · See [DISCLAIMER.md](./DISCLAIMER.md) before +> hosting an instance.** + +--- + +## What it does + +- **Multi-source ingest**: pulls canonical scene/performer/studio metadata from + TPDB and StashDB on a delta cron, merges duplicates by performer + title + + date heuristics (perceptual hash + Levenshtein title distance). +- **Tube discovery**: per-performer search across 30+ public adult tube sites + (mainstream + aggregators). Each tube is scraped directly via HTTP — no + proprietary API dependencies. +- **Stream resolution on demand**: when a user clicks Watch, the API extracts + a fresh m3u8/mp4 URL from the tube's page (or falls back to embed link for + WebView playback). Mainstream tubes use yt-dlp; aggregator tubes use a + generic P.A.C.K.E.R. unpacker for JWPlayer-based hosters + (StreamWish/doodporn/mixdrop/...). +- **Mobile client** (Expo / React Native): scene grid, performer pages, watch + history, favorites, hold-to-preview animated thumbnails. +- **Performer-driven backfill**: a continuous worker walks performers ordered + by `last_searched_at NULLS FIRST` and back-fills tube scenes for the + longest-stale performer first. + +## What it doesn't do + +- Host or store any media. Scene metadata + thumbnail URLs only. +- Bypass paywalls, authentication, geo-blocks, or DRM. +- Provide age verification, ToS gating, or moderation for public deployments. + See [DISCLAIMER.md](./DISCLAIMER.md). +- Phone home. Sentry telemetry is opt-in (env var, empty by default). + +--- + +## Quick start + +### 1. Run the backend (Docker) + +```bash +git clone goon +cd goon +cp .env.example .env +# Edit .env: +# - TPDB_API_TOKEN (theporndb.net account → API tokens) +# - STASHDB_API_KEY (stashdb.org account → API keys) +# - API_KEYS (generate one: python -c "import secrets; print(secrets.token_urlsafe(32))") + +docker compose up -d +``` + +Three services come up: `db` (Postgres 16), `api` (FastAPI on `:8000`, +auto-applies migrations on startup), `worker` (APScheduler running TPDB/StashDB +delta + performer-driven backfill). + +Verify: `curl localhost:8000/health` → `{"status":"ok"}`. + +### 2. Install the mobile app (Android) + +Download the latest debug APK from +[GitHub Releases](../../releases/latest) → `goon-vX.Y.Z-debug.apk`, install on +your Android device (allow "Install from unknown sources" for the browser / +file manager you used to download). + +On first launch the app shows the age-gate disclaimer (must be accepted), then +a login screen. Enter: +- **Backend URL**: `http://:8000` (e.g. your LAN IP, or + `http://localhost:8000` if running on the device — uncommon) +- **API key**: one of the values you put in `API_KEYS` in `.env` + +That's it. + +### Local Python (no Docker) + +```bash +python -m venv .venv && . .venv/bin/activate # or .\.venv\Scripts\activate on Windows +pip install -e .[dev] +cp .env.example .env # edit creds +alembic upgrade head +uvicorn app.main:app --port 8000 +``` + +### Worker (manual one-shot ingest) + +```bash +# Foreground APScheduler with all jobs +python -m app.scheduler.worker + +# One-shot: +python -m app.scheduler.worker --once --source=tpdb --limit=200 +python -m app.scheduler.worker --once --strategy=performer-driven --top-n=20 +python -m app.scheduler.worker --once --strategy=performer-driven \ + --performers="Lola Noir,Mia Malkova" +``` + +### Building the APK locally + +```bash +cd mobile +npm install +cd android +./gradlew assembleDebug +# output: mobile/android/app/build/outputs/apk/debug/app-debug.apk +``` + +Or just push a `v*` tag — GitHub Actions builds and attaches the APK to the +Release ([.github/workflows/build-apk.yml](./.github/workflows/build-apk.yml)). + +### Sentry telemetry (optional) + +Default behavior: **no telemetry**. Sentry only initializes when a DSN is +present at runtime/build time. + +To enable Sentry for **your** instance (errors only, no PII, no replay): + +- **Backend**: set `SENTRY_DSN=https://...` in `.env` (gitignored). + Optionally `SENTRY_ENVIRONMENT=production` and `SENTRY_TRACES_SAMPLE_RATE=0.1`. +- **Mobile (local builds)**: create `mobile/.env` (gitignored) with + `EXPO_PUBLIC_SENTRY_DSN=https://...`. Expo SDK 49+ auto-inlines `EXPO_PUBLIC_*` + vars into the JS bundle at build time. +- **Mobile (CI builds)**: add a GitHub repository secret named `SENTRY_DSN`. + The APK workflow exports it as `EXPO_PUBLIC_SENTRY_DSN` to gradle. Without + the secret, the APK ships with telemetry disabled (forks of this repo don't + inherit your DSN). + +Sentry init is gated by `if (SENTRY_DSN) { Sentry.init(...) }` — empty DSN +means the SDK is loaded as dead code but never sends a single request. + +--- + +## Configuration + +All runtime config is environment variables (see [.env.example](./.env.example) +for the full list). Highlights: + +| Var | Default | Required? | Notes | +|---|---|---|---| +| `DATABASE_URL` | `postgresql+psycopg://goon:goon@localhost:5432/goon` | Yes | Postgres 14+ | +| `TPDB_API_TOKEN` | _empty_ | For TPDB ingest | Get from theporndb.net account | +| `STASHDB_API_KEY` | _empty_ | For StashDB ingest | Get from stashdb.org account | +| `API_KEYS` | _empty_ | Recommended | CSV of allowed API keys; empty = no auth (localhost-only) | +| `SENTRY_DSN` | _empty_ | No | Empty = no telemetry. Use your own DSN if you want crash reports. | +| `LOG_LEVEL` | `INFO` | No | DEBUG for verbose tube scraping logs | + +Scheduler tuning (set to `0` to disable a job): + +| Var | Default | Description | +|---|---|---| +| `GOON_SCHED_TPDB_HOURS` | `6` | TPDB delta interval | +| `GOON_SCHED_STASHDB_HOURS` | `6` | StashDB delta interval | +| `GOON_SCHED_PERFORMER_DRIVEN_HOURS` | `12` | Top-N performer ingest | +| `GOON_SCHED_PERFORMER_CONTINUOUS_SECONDS` | `15` | Continuous backfill tick | + +--- + +## Architecture (high level) + +``` +┌──────────┐ delta cron ┌────────────┐ +│ TPDB │────────────────▶│ │ +└──────────┘ │ │ +┌──────────┐ │ ingest │ +│ StashDB │────────────────▶│ pipeline │──┐ +└──────────┘ │ │ │ cross-source +┌──────────┐ performer- │ │ ▼ dedup + +│ ~25 tube │ driven ┌───▶│ │ ┌─────────┐ +│ sites │ search │ └────────────┘ │ Postgres│ +└──────────┘────────────┘ └─────────┘ + │ + ┌─────────────────────────────────────────────┤ + ▼ ▼ +┌──────────────┐ ┌──────────────┐ +│ FastAPI │ │ Worker │ +│ /scenes │◀────── on Watch click ───│ scheduler │ +│ /performers │ resolve stream URL │ (APScheduler)│ +│ /playback │ (yt-dlp / hoster └──────────────┘ +└──────────────┘ packer) + │ + ▼ +┌──────────────┐ +│ Expo mobile │ +│ (Android) │ +└──────────────┘ +``` + +Key modules: + +- [`app/connectors/`](./app/connectors/) — TPDB, StashDB, dooplay (movies), + paradisehill (movies), [`direct_scrapers/`](./app/connectors/direct_scrapers/) + (25 tube discovery scrapers). +- [`app/extractors/`](./app/extractors/) — stream URL resolution per tube. + yt-dlp wrapper + custom + generic embed-iframe + P.A.C.K.E.R. unpacker. +- [`app/resolve/`](./app/resolve/) — cross-source scene merging (phash, title + similarity, performer overlap, release date window). +- [`app/scheduler/`](./app/scheduler/) — APScheduler jobs + + [`performer_driven.py`](./app/scheduler/performer_driven.py) (the core + ingest strategy: completeness > recency). +- [`mobile/`](./mobile/) — Expo / React Native client. + +## Tube coverage + +Discovery + stream resolution registered for ~33 sources: + +**Mainstream tubes:** pornhub, redtube, xhamster, xvideos, xnxx, youporn, +eporner, hqporner, sxyprn, porntrex, pornhat. + +**Aggregators / mirrors:** xmoviesforyou, watchporn, siska, porn4days, +porndish, xxxfreewatch, latestleaks, latestpornvideo, mypornerleak, +porndittcom, hdporn92, sxyland, 0dayxx, perverzija, fpoxxx, porn00, pornxp, +hdporngg, fullmovies, freshporno, shyfap. + +**Movie sites:** paradisehill (primary) + dooplay mirrors (mangoporn, +streamporn, pandamovies). + +If you want to add another tube, see [CONTRIBUTING.md](./CONTRIBUTING.md). + +--- + +## Support the project + +Goon is free, open-source, and ad-free. It stays that way because donations +cover the VPS, the TPDB/StashDB tokens, and the time. **Crypto only** — +mainstream processors refuse adult projects, even FOSS tooling. + +In-app: **Scenes → ♥** opens a screen with QR codes for Monero, Bitcoin, and +USDT (TRC-20). + +Addresses are hardcoded in +[`mobile/src/lib/donate.ts`](./mobile/src/lib/donate.ts) so a compromised +server cannot swap them mid-donation. Verify the value on-screen against the +copy in this repo before sending. + +--- + +## Roadmap + +Near-term: + +- Browse-by-performer + sort-by-studio +- Multi-tag filter (AND / OR) +- Continue-watching rail (position sync across devices) +- Stash local-server bridge — sync favorites/watchlist with a self-hosted Stash +- iOS sideload via TestFlight invite + +Mid-term: + +- Web companion (read-only browser frontend over the same API) +- BTCPay Server invoicing for one-time / recurring donations +- Performer-alert notifications (server push when a favorited performer drops a new scene) + +--- + +## License + +MIT — see [LICENSE](./LICENSE). diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..1118bc4 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,45 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os +file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s + +[post_write_hooks] +hooks = ruff +ruff.type = console_scripts +ruff.entrypoint = ruff +ruff.options = format REVISION_SCRIPT_FILENAME + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..a33850a --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,52 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.config import get_settings +from app.models import Base + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +settings = get_settings() +config.set_main_option("sqlalchemy.url", settings.database_url) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/init/00_extensions.sql b/alembic/init/00_extensions.sql new file mode 100644 index 0000000..e1d3beb --- /dev/null +++ b/alembic/init/00_extensions.sql @@ -0,0 +1,3 @@ +CREATE EXTENSION IF NOT EXISTS pg_trgm; +CREATE EXTENSION IF NOT EXISTS unaccent; +CREATE EXTENSION IF NOT EXISTS pgcrypto; diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..3a15569 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: str | None = ${repr(down_revision)} +branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} +depends_on: str | Sequence[str] | None = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/20260502_0001_initial.py b/alembic/versions/20260502_0001_initial.py new file mode 100644 index 0000000..15a18a7 --- /dev/null +++ b/alembic/versions/20260502_0001_initial.py @@ -0,0 +1,313 @@ +"""initial schema + +Revision ID: 0001_initial +Revises: +Create Date: 2026-05-02 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0001_initial" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +# `create_type=False` bo enum tworzymy raz jawnie poniżej; używanie tej samej instancji +# w wielu kolumnach z `create_type=True` próbowałoby tworzyć typ wielokrotnie. +SOURCE_KIND = postgresql.ENUM( + "tpdb", "stashdb", "scraper", "porn_app", "manual", + name="source_kind", create_type=False, +) +ENTITY_KIND = postgresql.ENUM( + "scene", "performer", "studio", "tag", + name="entity_kind", create_type=False, +) +PERFORMER_GENDER = postgresql.ENUM( + "female", "male", "transgender_female", "transgender_male", + "non_binary", "intersex", "unknown", + name="performer_gender", create_type=False, +) +FINGERPRINT_KIND = postgresql.ENUM( + "phash", "oshash", "md5", name="fingerprint_kind", create_type=False, +) +MERGE_KIND = postgresql.ENUM( + "scene", "performer", "studio", name="merge_kind", create_type=False, +) +MERGE_STATUS = postgresql.ENUM( + "pending", "auto_merged", "merged", "rejected", + name="merge_status", create_type=False, +) +INGEST_STATUS = postgresql.ENUM( + "running", "success", "partial", "failed", + name="ingest_status", create_type=False, +) + + +def upgrade() -> None: + op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;") + op.execute("CREATE EXTENSION IF NOT EXISTS unaccent;") + op.execute("CREATE EXTENSION IF NOT EXISTS pgcrypto;") + + SOURCE_KIND.create(op.get_bind(), checkfirst=True) + ENTITY_KIND.create(op.get_bind(), checkfirst=True) + PERFORMER_GENDER.create(op.get_bind(), checkfirst=True) + FINGERPRINT_KIND.create(op.get_bind(), checkfirst=True) + MERGE_KIND.create(op.get_bind(), checkfirst=True) + MERGE_STATUS.create(op.get_bind(), checkfirst=True) + INGEST_STATUS.create(op.get_bind(), checkfirst=True) + + op.create_table( + "sources", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("kind", SOURCE_KIND, nullable=False), + sa.Column("name", sa.String(128), nullable=False, unique=True), + sa.Column("base_url", sa.String(512)), + sa.Column("auth_secret_ref", sa.String(128)), + sa.Column("weight", sa.Float, nullable=False, server_default="1.0"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + ) + + op.create_table( + "studios", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("name", sa.String(256), nullable=False), + sa.Column("name_normalized", sa.String(256), nullable=False), + sa.Column("slug", sa.String(256), nullable=False, unique=True), + sa.Column("parent_studio_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("studios.id", ondelete="SET NULL")), + sa.Column("network", sa.String(256)), + sa.Column("homepage_url", sa.String(512)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + ) + op.create_index("ix_studios_name_normalized", "studios", ["name_normalized"]) + op.execute( + "CREATE INDEX ix_studios_name_normalized_trgm ON studios " + "USING GIN (name_normalized gin_trgm_ops);" + ) + + op.create_table( + "studio_aliases", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("studio_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("studios.id", ondelete="CASCADE"), nullable=False), + sa.Column("alias", sa.String(256), nullable=False), + sa.Column("alias_normalized", sa.String(256), nullable=False), + sa.Column("source_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("sources.id", ondelete="SET NULL")), + sa.UniqueConstraint("studio_id", "alias_normalized", name="uq_studio_aliases_studio_id_alias_normalized"), + ) + op.create_index("ix_studio_aliases_studio_id", "studio_aliases", ["studio_id"]) + op.create_index("ix_studio_aliases_alias_normalized", "studio_aliases", ["alias_normalized"]) + op.execute( + "CREATE INDEX ix_studio_aliases_alias_normalized_trgm ON studio_aliases " + "USING GIN (alias_normalized gin_trgm_ops);" + ) + + op.create_table( + "studio_external_refs", + sa.Column("source_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("sources.id", ondelete="CASCADE"), primary_key=True), + sa.Column("external_id", sa.String(256), primary_key=True), + sa.Column("studio_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("studios.id", ondelete="CASCADE"), nullable=False), + sa.Column("confidence", sa.Float, nullable=False, server_default="1.0"), + sa.Column("first_seen", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.Column("last_seen", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + ) + op.create_index("ix_studio_external_refs_studio_id", "studio_external_refs", ["studio_id"]) + + op.create_table( + "performers", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("canonical_name", sa.String(256), nullable=False), + sa.Column("name_normalized", sa.String(256), nullable=False), + sa.Column("slug", sa.String(256), nullable=False, unique=True), + sa.Column("gender", PERFORMER_GENDER), + sa.Column("birth_date", sa.Date), + sa.Column("country", sa.String(64)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + ) + op.create_index("ix_performers_name_normalized", "performers", ["name_normalized"]) + op.execute( + "CREATE INDEX ix_performers_name_normalized_trgm ON performers " + "USING GIN (name_normalized gin_trgm_ops);" + ) + + op.create_table( + "performer_aliases", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("performer_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("performers.id", ondelete="CASCADE"), nullable=False), + sa.Column("alias", sa.String(256), nullable=False), + sa.Column("alias_normalized", sa.String(256), nullable=False), + sa.Column("source_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("sources.id", ondelete="SET NULL")), + sa.UniqueConstraint("performer_id", "alias_normalized", name="uq_performer_aliases_performer_id_alias_normalized"), + ) + op.create_index("ix_performer_aliases_performer_id", "performer_aliases", ["performer_id"]) + op.create_index("ix_performer_aliases_alias_normalized", "performer_aliases", ["alias_normalized"]) + op.execute( + "CREATE INDEX ix_performer_aliases_alias_normalized_trgm ON performer_aliases " + "USING GIN (alias_normalized gin_trgm_ops);" + ) + + op.create_table( + "performer_external_refs", + sa.Column("source_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("sources.id", ondelete="CASCADE"), primary_key=True), + sa.Column("external_id", sa.String(256), primary_key=True), + sa.Column("performer_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("performers.id", ondelete="CASCADE"), nullable=False), + sa.Column("confidence", sa.Float, nullable=False, server_default="1.0"), + sa.Column("first_seen", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.Column("last_seen", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + ) + op.create_index("ix_performer_external_refs_performer_id", "performer_external_refs", ["performer_id"]) + + op.create_table( + "tags", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("name", sa.String(128), nullable=False), + sa.Column("slug", sa.String(128), nullable=False, unique=True), + sa.Column("parent_tag_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tags.id", ondelete="SET NULL")), + sa.Column("description", sa.String(1024)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + ) + + op.create_table( + "scenes", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("title", sa.String(512), nullable=False), + sa.Column("title_normalized", sa.String(512), nullable=False), + sa.Column("slug", sa.String(512)), + sa.Column("release_date", sa.Date), + sa.Column("studio_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("studios.id", ondelete="SET NULL")), + sa.Column("duration_sec", sa.Integer), + sa.Column("description", sa.Text), + sa.Column("code", sa.String(128)), + sa.Column("director", sa.String(256)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + ) + op.create_index("ix_scenes_title_normalized", "scenes", ["title_normalized"]) + op.execute( + "CREATE INDEX ix_scenes_title_normalized_trgm ON scenes " + "USING GIN (title_normalized gin_trgm_ops);" + ) + op.create_index("ix_scenes_release_date", "scenes", ["release_date"]) + op.create_index("ix_scenes_slug", "scenes", ["slug"]) + op.create_index("ix_scenes_studio_id", "scenes", ["studio_id"]) + op.create_index("ix_scenes_code", "scenes", ["code"]) + op.create_index("ix_scenes_studio_release_date", "scenes", ["studio_id", "release_date"]) + + op.create_table( + "scene_external_refs", + sa.Column("source_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("sources.id", ondelete="CASCADE"), primary_key=True), + sa.Column("external_id", sa.String(256), primary_key=True), + sa.Column("scene_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("scenes.id", ondelete="CASCADE"), nullable=False), + sa.Column("confidence", sa.Float, nullable=False, server_default="1.0"), + sa.Column("url", sa.String(1024)), + sa.Column("first_seen", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.Column("last_seen", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + ) + op.create_index("ix_scene_external_refs_scene_id", "scene_external_refs", ["scene_id"]) + + op.create_table( + "scene_fingerprints", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("scene_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("scenes.id", ondelete="CASCADE"), nullable=False), + sa.Column("kind", FINGERPRINT_KIND, nullable=False), + sa.Column("value", sa.String(128), nullable=False), + sa.Column("source_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("sources.id", ondelete="SET NULL")), + sa.UniqueConstraint("scene_id", "kind", "value", name="uq_scene_fingerprints_scene_id_kind_value"), + ) + op.create_index("ix_scene_fingerprints_scene_id", "scene_fingerprints", ["scene_id"]) + op.create_index("ix_scene_fingerprints_value", "scene_fingerprints", ["value"]) + + op.create_table( + "scene_performers", + sa.Column("scene_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("scenes.id", ondelete="CASCADE"), primary_key=True), + sa.Column("performer_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("performers.id", ondelete="CASCADE"), primary_key=True), + sa.Column("role", sa.String(64)), + sa.Column("position", sa.Integer), + sa.Column("as_alias", sa.String(256)), + ) + + op.create_table( + "scene_tags", + sa.Column("scene_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("scenes.id", ondelete="CASCADE"), primary_key=True), + sa.Column("tag_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True), + sa.Column("source_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("sources.id", ondelete="SET NULL")), + ) + + op.create_table( + "external_records", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("source_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("sources.id", ondelete="CASCADE"), nullable=False), + sa.Column("entity_kind", ENTITY_KIND, nullable=False), + sa.Column("external_id", sa.String(256), nullable=False), + sa.Column("raw", postgresql.JSONB, nullable=False), + sa.Column("raw_hash", sa.LargeBinary(32), nullable=False), + sa.Column("fetched_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.Column("last_seen_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.UniqueConstraint("source_id", "entity_kind", "external_id", name="uq_external_records_source_id_entity_kind_external_id"), + ) + op.create_index("ix_external_records_source_id", "external_records", ["source_id"]) + + op.create_table( + "merge_candidates", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("kind", MERGE_KIND, nullable=False), + sa.Column("left_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("right_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("score", sa.Float, nullable=False), + sa.Column("reasons", postgresql.JSONB, nullable=False, server_default="{}"), + sa.Column("status", MERGE_STATUS, nullable=False, server_default="pending"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.Column("resolved_at", sa.DateTime(timezone=True)), + sa.Column("resolved_by", sa.String(128)), + ) + op.create_index("ix_merge_candidates_left_id", "merge_candidates", ["left_id"]) + op.create_index("ix_merge_candidates_right_id", "merge_candidates", ["right_id"]) + op.create_index("ix_merge_candidates_status", "merge_candidates", ["status"]) + + op.create_table( + "ingest_runs", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("source_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("sources.id", ondelete="CASCADE"), nullable=False), + sa.Column("started_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.Column("finished_at", sa.DateTime(timezone=True)), + sa.Column("status", INGEST_STATUS, nullable=False, server_default="running"), + sa.Column("records_seen", sa.Integer, nullable=False, server_default="0"), + sa.Column("records_new", sa.Integer, nullable=False, server_default="0"), + sa.Column("records_updated", sa.Integer, nullable=False, server_default="0"), + sa.Column("errors", postgresql.JSONB), + ) + op.create_index("ix_ingest_runs_source_id", "ingest_runs", ["source_id"]) + + +def downgrade() -> None: + op.drop_table("ingest_runs") + op.drop_table("merge_candidates") + op.drop_table("external_records") + op.drop_table("scene_tags") + op.drop_table("scene_performers") + op.drop_table("scene_fingerprints") + op.drop_table("scene_external_refs") + op.drop_table("scenes") + op.drop_table("tags") + op.drop_table("performer_external_refs") + op.drop_table("performer_aliases") + op.drop_table("performers") + op.drop_table("studio_external_refs") + op.drop_table("studio_aliases") + op.drop_table("studios") + op.drop_table("sources") + + INGEST_STATUS.drop(op.get_bind(), checkfirst=True) + MERGE_STATUS.drop(op.get_bind(), checkfirst=True) + MERGE_KIND.drop(op.get_bind(), checkfirst=True) + FINGERPRINT_KIND.drop(op.get_bind(), checkfirst=True) + PERFORMER_GENDER.drop(op.get_bind(), checkfirst=True) + ENTITY_KIND.drop(op.get_bind(), checkfirst=True) + SOURCE_KIND.drop(op.get_bind(), checkfirst=True) diff --git a/alembic/versions/20260502_0002_playback_sources.py b/alembic/versions/20260502_0002_playback_sources.py new file mode 100644 index 0000000..0cee2a4 --- /dev/null +++ b/alembic/versions/20260502_0002_playback_sources.py @@ -0,0 +1,67 @@ +"""playback_sources table for tube/aggregator video links + +Revision ID: 0002_playback_sources +Revises: 0001_initial +Create Date: 2026-05-02 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0002_playback_sources" +down_revision: str | None = "0001_initial" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "playback_sources", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column( + "scene_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("scenes.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("origin", sa.String(64), nullable=False), + sa.Column("page_url", sa.String(2048), nullable=False), + sa.Column("embed_url", sa.String(2048)), + sa.Column("stream_url", sa.String(2048)), + sa.Column("quality", sa.String(16)), + sa.Column("duration_sec", sa.Integer), + sa.Column("thumbnail_url", sa.String(2048)), + sa.Column( + "last_seen_at", + sa.DateTime(timezone=True), + server_default=sa.text("NOW()"), + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("NOW()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("NOW()"), + nullable=False, + ), + sa.UniqueConstraint("origin", "page_url", name="uq_playback_sources_origin_page_url"), + ) + op.create_index("ix_playback_sources_scene_id", "playback_sources", ["scene_id"]) + op.create_index("ix_playback_sources_origin", "playback_sources", ["origin"]) + + +def downgrade() -> None: + op.drop_table("playback_sources") diff --git a/alembic/versions/20260503_0003_playback_dead.py b/alembic/versions/20260503_0003_playback_dead.py new file mode 100644 index 0000000..a56b9b3 --- /dev/null +++ b/alembic/versions/20260503_0003_playback_dead.py @@ -0,0 +1,41 @@ +"""playback_sources.dead_at + dead_reason — flagging dead tube links + +Revision ID: 0003_playback_dead +Revises: 0002_playback_sources +Create Date: 2026-05-03 + +Gdy resolve endpoint dostanie 404 "Video is offline" / "deleted" z porn-app, +oznaczamy ten playback_source jako martwy. API filtruje go z `_build_scene_out`, +mobile go nie pokazuje. has_playback=true filter też wymaga `dead_at IS NULL`. +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0003_playback_dead" +down_revision: str | None = "0002_playback_sources" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "playback_sources", + sa.Column("dead_at", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column( + "playback_sources", + sa.Column("dead_reason", sa.String(length=512), nullable=True), + ) + op.create_index( + "ix_playback_sources_dead_at", + "playback_sources", + ["dead_at"], + ) + + +def downgrade() -> None: + op.drop_index("ix_playback_sources_dead_at", table_name="playback_sources") + op.drop_column("playback_sources", "dead_reason") + op.drop_column("playback_sources", "dead_at") diff --git a/alembic/versions/20260504_0004_animated_thumbnail.py b/alembic/versions/20260504_0004_animated_thumbnail.py new file mode 100644 index 0000000..e2eceb5 --- /dev/null +++ b/alembic/versions/20260504_0004_animated_thumbnail.py @@ -0,0 +1,34 @@ +"""playback_sources.animated_thumbnail_url — animowane miniaturki dla hold-to-preview + +Revision ID: 0004_animated_thumbnail +Revises: 0003_playback_dead +Create Date: 2026-05-04 + +Mobile (`ScenesScreen`, `MergeQueueScreen`) ma hold-to-preview: po przytrzymaniu kciuka +na thumbie pokazuje animowany webp/gif zamiast statycznego obrazka. Pole jest opcjonalne — +nie każde źródło tube je dostarcza; jeśli null → mobile fallbackuje do `thumbnail_url`. + +Bez tej kolumny endpointy które ją zwracały (admin merge-candidates, scene detail) musiały +być sztucznie ograniczane (vide DEPLOY_BACKLOG.md). Po tej migracji można wrócić do +pełnej projekcji w `app/api/admin.py`. +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0004_animated_thumbnail" +down_revision: str | None = "0003_playback_dead" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "playback_sources", + sa.Column("animated_thumbnail_url", sa.String(length=2048), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("playback_sources", "animated_thumbnail_url") diff --git a/alembic/versions/20260504_0005_favorite_performers.py b/alembic/versions/20260504_0005_favorite_performers.py new file mode 100644 index 0000000..7e8310a --- /dev/null +++ b/alembic/versions/20260504_0005_favorite_performers.py @@ -0,0 +1,38 @@ +"""favorite_performers — ulubione performerki (single-user, in-app) + +Revision ID: 0005_favorite_performers +Revises: 0004_animated_thumbnail +Create Date: 2026-05-04 + +Single-user system (brak users), więc tabelka to po prostu zbiór performer_id które +user oznaczył jako ulubione, plus `last_seen_at` żeby mobile mogło policzyć ile nowych +scen pojawiło się od ostatniego oglądania (badge w toolbar/Favorites screen). + +Multi-user można dodać potem (kolumna user_id + composite PK), bez breaking change. +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0005_favorite_performers" +down_revision: str | None = "0004_animated_thumbnail" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "favorite_performers", + sa.Column("performer_id", sa.dialects.postgresql.UUID(as_uuid=True), + sa.ForeignKey("performers.id", ondelete="CASCADE"), + primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), + server_default=sa.func.now(), nullable=False), + sa.Column("last_seen_at", sa.DateTime(timezone=True), + server_default=sa.func.now(), nullable=False), + ) + + +def downgrade() -> None: + op.drop_table("favorite_performers") diff --git a/alembic/versions/20260504_0006_blacklists.py b/alembic/versions/20260504_0006_blacklists.py new file mode 100644 index 0000000..b30388b --- /dev/null +++ b/alembic/versions/20260504_0006_blacklists.py @@ -0,0 +1,41 @@ +"""Blacklists — performers/studios/tags do globalnego ukrywania. + +Revision ID: 0006_blacklists +Revises: 0005_favorite_performers +Create Date: 2026-05-04 + +Single-user; analogicznie do favorite_performers ale negative — sceny które MAJĄ +blacklisted performer / studio / tag są wykluczane ze wszystkich list (scenes, +search, performer/tag scenes). +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0006_blacklists" +down_revision: str | None = "0005_favorite_performers" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + for tbl, parent_tbl, parent_col in [ + ("blacklisted_performers", "performers", "performer_id"), + ("blacklisted_studios", "studios", "studio_id"), + ("blacklisted_tags", "tags", "tag_id"), + ]: + op.create_table( + tbl, + sa.Column(parent_col, sa.dialects.postgresql.UUID(as_uuid=True), + sa.ForeignKey(f"{parent_tbl}.id", ondelete="CASCADE"), + primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), + server_default=sa.func.now(), nullable=False), + ) + + +def downgrade() -> None: + op.drop_table("blacklisted_tags") + op.drop_table("blacklisted_studios") + op.drop_table("blacklisted_performers") diff --git a/alembic/versions/20260504_0007_play_progress.py b/alembic/versions/20260504_0007_play_progress.py new file mode 100644 index 0000000..1b44098 --- /dev/null +++ b/alembic/versions/20260504_0007_play_progress.py @@ -0,0 +1,42 @@ +"""scene_play_progress — pozycja odtwarzania per scena (continue watching). + +Revision ID: 0007_play_progress +Revises: 0006_blacklists +Create Date: 2026-05-04 + +Single-user; tabela trzyma ostatnio oglądane sceny + (gdy player zwróci) pozycję +w sekundach. Continue watching rail na home pobiera top-N ostatnich. +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0007_play_progress" +down_revision: str | None = "0006_blacklists" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "scene_play_progress", + sa.Column("scene_id", sa.dialects.postgresql.UUID(as_uuid=True), + sa.ForeignKey("scenes.id", ondelete="CASCADE"), + primary_key=True), + sa.Column("position_sec", sa.Integer(), nullable=False, server_default="0"), + sa.Column("duration_sec", sa.Integer(), nullable=True), + sa.Column("finished", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("last_played_at", sa.DateTime(timezone=True), + server_default=sa.func.now(), nullable=False), + ) + op.create_index( + "ix_scene_play_progress_last_played_at", + "scene_play_progress", + ["last_played_at"], + ) + + +def downgrade() -> None: + op.drop_index("ix_scene_play_progress_last_played_at", table_name="scene_play_progress") + op.drop_table("scene_play_progress") diff --git a/alembic/versions/20260506_0008_performer_search_meta.py b/alembic/versions/20260506_0008_performer_search_meta.py new file mode 100644 index 0000000..2b4cadf --- /dev/null +++ b/alembic/versions/20260506_0008_performer_search_meta.py @@ -0,0 +1,42 @@ +"""Performer.last_searched_at + search_run_count — backfill queue dla per-performer search. + +Revision ID: 0008_performer_search_meta +Revises: 0007_play_progress +Create Date: 2026-05-06 + +Continuous worker iteruje performerów ORDER BY last_searched_at NULLS FIRST, +search_run_count ASC. Performerów którzy nigdy nie byli searchowani idą pierwsi. +Po pełnym sweep'ie kolejka cyklicznie wraca do najstarszych. +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0008_performer_search_meta" +down_revision: str | None = "0007_play_progress" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "performers", + sa.Column("last_searched_at", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column( + "performers", + sa.Column("search_run_count", sa.Integer(), nullable=False, server_default="0"), + ) + # Index dla queue: NULLS FIRST + search_run_count ASC. PostgreSQL btree + # default DESC ma NULLS FIRST. Asc - NULLS LAST. Robimy explicit. + op.execute( + "CREATE INDEX ix_performers_search_priority " + "ON performers (last_searched_at ASC NULLS FIRST, search_run_count ASC)" + ) + + +def downgrade() -> None: + op.drop_index("ix_performers_search_priority", table_name="performers") + op.drop_column("performers", "search_run_count") + op.drop_column("performers", "last_searched_at") diff --git a/alembic/versions/20260506_0009_movies.py b/alembic/versions/20260506_0009_movies.py new file mode 100644 index 0000000..fd7d63e --- /dev/null +++ b/alembic/versions/20260506_0009_movies.py @@ -0,0 +1,146 @@ +"""movies kanon + bliźniacze tabele do scen + +Revision ID: 0009_movies +Revises: 0008_performer_search_meta +Create Date: 2026-05-06 + +Schema dla full-length adult films (paradisehill + mirrory). Movies różnią się od +scen: 60-180min runtime, multi-chapter struktura, więcej metadanych (director, +year, country, rating). Performers/studios/tags reusable (te same osoby/studia +występują w scenach i w filmach). + +Nowe entity_kind: 'movie'. Nowe merge_kind: 'movie'. Movie-fingerprints rzadko +istnieją (movies nie mają standardowego pHash w industry), więc fingerprint table +pomijamy — dedup pójdzie po composite key (title+year+studio+cast Jaccard). +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0009_movies" +down_revision: str | None = "0008_performer_search_meta" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # Rozszerz enumy o 'movie' + op.execute("ALTER TYPE entity_kind ADD VALUE IF NOT EXISTS 'movie'") + op.execute("ALTER TYPE merge_kind ADD VALUE IF NOT EXISTS 'movie'") + + op.create_table( + "movies", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("title", sa.String(512), nullable=False), + sa.Column("title_normalized", sa.String(512), nullable=False), + sa.Column("slug", sa.String(512)), + sa.Column("release_year", sa.Integer), + sa.Column("release_date", sa.Date), + sa.Column("studio_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("studios.id", ondelete="SET NULL")), + sa.Column("director", sa.String(256)), + sa.Column("country", sa.String(64)), + sa.Column("duration_sec", sa.Integer), + sa.Column("description", sa.Text), + sa.Column("poster_url", sa.String(2048)), + sa.Column("backdrop_url", sa.String(2048)), + # Rating jako float (paradisehill ma like_count + rating 0-10; trzymamy + # uśredniony rating z primary source'a, jeśli dostępny). + sa.Column("rating", sa.Float), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + ) + op.create_index("ix_movies_title_normalized", "movies", ["title_normalized"]) + op.execute( + "CREATE INDEX ix_movies_title_normalized_trgm ON movies " + "USING GIN (title_normalized gin_trgm_ops);" + ) + op.create_index("ix_movies_release_year", "movies", ["release_year"]) + op.create_index("ix_movies_release_date", "movies", ["release_date"]) + op.create_index("ix_movies_slug", "movies", ["slug"]) + op.create_index("ix_movies_studio_id", "movies", ["studio_id"]) + op.create_index("ix_movies_studio_year", "movies", ["studio_id", "release_year"]) + + op.create_table( + "movie_external_refs", + sa.Column("source_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("sources.id", ondelete="CASCADE"), primary_key=True), + sa.Column("external_id", sa.String(256), primary_key=True), + sa.Column("movie_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("movies.id", ondelete="CASCADE"), nullable=False), + sa.Column("confidence", sa.Float, nullable=False, server_default="1.0"), + sa.Column("url", sa.String(1024)), + sa.Column("first_seen", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.Column("last_seen", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + ) + op.create_index("ix_movie_external_refs_movie_id", "movie_external_refs", ["movie_id"]) + + op.create_table( + "movie_performers", + sa.Column("movie_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("movies.id", ondelete="CASCADE"), primary_key=True), + sa.Column("performer_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("performers.id", ondelete="CASCADE"), primary_key=True), + sa.Column("role", sa.String(64)), + sa.Column("position", sa.Integer), + sa.Column("as_alias", sa.String(256)), + ) + + op.create_table( + "movie_tags", + sa.Column("movie_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("movies.id", ondelete="CASCADE"), primary_key=True), + sa.Column("tag_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True), + sa.Column("source_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("sources.id", ondelete="SET NULL")), + ) + + # Chaptery — opcjonalna tabela dla filmów rozbitych na sceny/segmenty + # (paradisehill czasem ma timestamp markers, np. "Scene 1: 00:00-15:32"). + # Każdy chapter MOŻE linkować do istniejącego Scene (jeśli ta scena też jest + # samodzielnie znana z TPDB/StashDB), albo żyje tylko jako anchor w movie. + op.create_table( + "movie_chapters", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("movie_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("movies.id", ondelete="CASCADE"), nullable=False), + sa.Column("chapter_index", sa.Integer, nullable=False), + sa.Column("title", sa.String(512)), + sa.Column("start_sec", sa.Integer), + sa.Column("end_sec", sa.Integer), + sa.Column("scene_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("scenes.id", ondelete="SET NULL")), + sa.UniqueConstraint("movie_id", "chapter_index", name="uq_movie_chapters_movie_id_chapter_index"), + ) + op.create_index("ix_movie_chapters_movie_id", "movie_chapters", ["movie_id"]) + + # Playback sources dla movies — analog do playback_sources, oddzielna tabela + # bo nie chcemy mieszać scene_id/movie_id w jednym FK column. Reuse origin + # konwencji ('paradisehill', 'psyplay:streamporn', 'wp_movies:speedporn', itp.). + op.create_table( + "movie_playback_sources", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("movie_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("movies.id", ondelete="CASCADE"), nullable=False), + sa.Column("origin", sa.String(64), nullable=False), + sa.Column("page_url", sa.String(2048), nullable=False), + sa.Column("embed_url", sa.String(2048)), + sa.Column("stream_url", sa.String(2048)), + sa.Column("quality", sa.String(16)), + sa.Column("duration_sec", sa.Integer), + sa.Column("thumbnail_url", sa.String(2048)), + sa.Column("animated_thumbnail_url", sa.String(2048)), + sa.Column("last_seen_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.Column("dead_at", sa.DateTime(timezone=True)), + sa.Column("dead_reason", sa.String(512)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.UniqueConstraint("origin", "page_url", name="uq_movie_playback_sources_origin_page_url"), + ) + op.create_index("ix_movie_playback_sources_movie_id", "movie_playback_sources", ["movie_id"]) + op.create_index("ix_movie_playback_sources_origin", "movie_playback_sources", ["origin"]) + op.create_index("ix_movie_playback_sources_dead_at", "movie_playback_sources", ["dead_at"]) + + +def downgrade() -> None: + op.drop_table("movie_playback_sources") + op.drop_table("movie_chapters") + op.drop_table("movie_tags") + op.drop_table("movie_performers") + op.drop_table("movie_external_refs") + op.drop_table("movies") + # Postgres nie pozwala usuwać wartości z enum-a w prosty sposób — zostawiamy + # 'movie' w entity_kind / merge_kind. Niewielki overhead w katalogu enum-ów + # (rząd bajtów per typ), bezpieczniejsze niż próby DROP VALUE. diff --git a/alembic/versions/20260506_0010_favorite_scenes.py b/alembic/versions/20260506_0010_favorite_scenes.py new file mode 100644 index 0000000..6022b8a --- /dev/null +++ b/alembic/versions/20260506_0010_favorite_scenes.py @@ -0,0 +1,39 @@ +"""favorite_scenes table + +Revision ID: 0010_favorite_scenes +Revises: 0009_movies +Create Date: 2026-05-06 +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0010_favorite_scenes" +down_revision: str | None = "0009_movies" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "favorite_scenes", + sa.Column( + "scene_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("scenes.id", ondelete="CASCADE"), + primary_key=True, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("NOW()"), + nullable=False, + ), + ) + op.create_index("ix_favorite_scenes_created_at", "favorite_scenes", ["created_at"]) + + +def downgrade() -> None: + op.drop_table("favorite_scenes") diff --git a/alembic/versions/20260507_0011_origin_pornapp_to_tube.py b/alembic/versions/20260507_0011_origin_pornapp_to_tube.py new file mode 100644 index 0000000..7b02120 --- /dev/null +++ b/alembic/versions/20260507_0011_origin_pornapp_to_tube.py @@ -0,0 +1,60 @@ +"""playback_sources.origin: rename `pornapp:*` → `tube:*` + +Revision ID: 0011_origin_pornapp_to_tube +Revises: 0010_favorite_scenes +Create Date: 2026-05-07 + +Po usunięciu zależności od porn-app.com API, prefix `pornapp:` w `playback_sources.origin` +jest myląca historyczna nazwa — discovery + stream resolve teraz idzie bezpośrednio przez +direct scrapery i `app.extractors`. Zmieniamy prefix na neutralny `tube:` żeby nazwa +odzwierciedlała architekturę (sitetag pozostaje bez zmian — `tube:hqpornercom` itd.). + +Idempotent: WHERE klauzula zapobiega podwójnemu rename. Operuje też na +`movie_playback_sources` (analogiczna kolumna z M5 movies). + +Backend `app/api/playback.py` rozumie oba prefixy (`pornapp:` legacy + `tube:`) +podczas okresu transition — po tej migracji można pozostawić tylko `tube:` sprawdzenie. +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0011_origin_pornapp_to_tube" +down_revision: str | None = "0010_favorite_scenes" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute( + sa.text( + "UPDATE playback_sources " + "SET origin = 'tube:' || SUBSTRING(origin FROM 9) " + "WHERE origin LIKE 'pornapp:%'" + ) + ) + op.execute( + sa.text( + "UPDATE movie_playback_sources " + "SET origin = 'tube:' || SUBSTRING(origin FROM 9) " + "WHERE origin LIKE 'pornapp:%'" + ) + ) + + +def downgrade() -> None: + op.execute( + sa.text( + "UPDATE playback_sources " + "SET origin = 'pornapp:' || SUBSTRING(origin FROM 6) " + "WHERE origin LIKE 'tube:%'" + ) + ) + op.execute( + sa.text( + "UPDATE movie_playback_sources " + "SET origin = 'pornapp:' || SUBSTRING(origin FROM 6) " + "WHERE origin LIKE 'tube:%'" + ) + ) diff --git a/alembic/versions/20260508_0012_favorite_studios.py b/alembic/versions/20260508_0012_favorite_studios.py new file mode 100644 index 0000000..bbbcb58 --- /dev/null +++ b/alembic/versions/20260508_0012_favorite_studios.py @@ -0,0 +1,48 @@ +"""favorite_studios — ulubione studia (single-user, in-app) + +Revision ID: 0012_favorite_studios +Revises: 0011_origin_pornapp_to_tube +Create Date: 2026-05-08 + +Mirror `favorite_performers` ze studio_id zamiast performer_id. Single-user, więc +tabelka to po prostu zbiór studio_id które user oznaczył jako ulubione, plus +`last_seen_at` — mobile liczy ile nowych scen pojawiło się w danym studio od +ostatniego oglądania (badge w Favorites). +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0012_favorite_studios" +down_revision: str | None = "0011_origin_pornapp_to_tube" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "favorite_studios", + sa.Column( + "studio_id", + sa.dialects.postgresql.UUID(as_uuid=True), + sa.ForeignKey("studios.id", ondelete="CASCADE"), + primary_key=True, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "last_seen_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + + +def downgrade() -> None: + op.drop_table("favorite_studios") diff --git a/alembic/versions/20260509_0013_bug_reports.py b/alembic/versions/20260509_0013_bug_reports.py new file mode 100644 index 0000000..f8fa44d --- /dev/null +++ b/alembic/versions/20260509_0013_bug_reports.py @@ -0,0 +1,52 @@ +"""bug_reports — in-app bug reporting (mobile FAB → POST /bug-reports) + +Revision ID: 0013_bug_reports +Revises: 0012_favorite_studios +Create Date: 2026-05-09 + +User wpisuje opis + appka kapturuje screen (react-native-view-shot omija +FLAG_SECURE) → wysyła POST. Backend trzyma w tabeli, admin_html ma listę. +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0013_bug_reports" +down_revision: str | None = "0012_favorite_studios" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "bug_reports", + sa.Column( + "id", + sa.dialects.postgresql.UUID(as_uuid=True), + primary_key=True, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column("screen_name", sa.String(64), nullable=True), + sa.Column("app_version", sa.String(32), nullable=True), + sa.Column( + "scene_id", + sa.dialects.postgresql.UUID(as_uuid=True), + sa.ForeignKey("scenes.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("message", sa.Text, nullable=False), + sa.Column("screenshot_b64", sa.Text, nullable=True), + sa.Column("resolved", sa.Boolean, nullable=False, server_default=sa.false()), + ) + op.create_index("ix_bug_reports_created_at", "bug_reports", ["created_at"]) + + +def downgrade() -> None: + op.drop_index("ix_bug_reports_created_at", table_name="bug_reports") + op.drop_table("bug_reports") diff --git a/alembic/versions/20260509_0014_favorite_movies.py b/alembic/versions/20260509_0014_favorite_movies.py new file mode 100644 index 0000000..a027e34 --- /dev/null +++ b/alembic/versions/20260509_0014_favorite_movies.py @@ -0,0 +1,46 @@ +"""favorite_movies — single-user favorites + last_seen_at dla NEW badge. + +Revision ID: 0014_favorite_movies +Revises: 0013_bug_reports +Create Date: 2026-05-09 + +Mirror `favorite_studios` z movie_id zamiast studio_id. NEW badge w mobile +liczone client-side: movie.created_at > favorite.last_seen_at. +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0014_favorite_movies" +down_revision: str | None = "0013_bug_reports" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "favorite_movies", + sa.Column( + "movie_id", + sa.dialects.postgresql.UUID(as_uuid=True), + sa.ForeignKey("movies.id", ondelete="CASCADE"), + primary_key=True, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "last_seen_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + + +def downgrade() -> None: + op.drop_table("favorite_movies") diff --git a/alembic/versions/20260510_0015_bug_reports_movie_id.py b/alembic/versions/20260510_0015_bug_reports_movie_id.py new file mode 100644 index 0000000..a6b473c --- /dev/null +++ b/alembic/versions/20260510_0015_bug_reports_movie_id.py @@ -0,0 +1,38 @@ +"""bug_reports — dodaj movie_id (FK movies, nullable) + +Revision ID: 0015_bug_reports_movie_id +Revises: 0014_favorite_movies +Create Date: 2026-05-10 + +Mobile Player przekazuje movie_id w nav params jako `sceneId` (legacy hack na +progress tracking, który dla movies zwraca 404 i mobile to ignoruje). Bug-report +flow inserted to przy POST jako scene_id, FK violation crash → 500. + +Fix: rozszerz tabelę o movie_id, backend smart-routes po lookup (jeśli scene_id +nie istnieje w scenes ALE istnieje w movies, zapisz jako movie_id). +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0015_bug_reports_movie_id" +down_revision: str | None = "0014_favorite_movies" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "bug_reports", + sa.Column( + "movie_id", + sa.dialects.postgresql.UUID(as_uuid=True), + sa.ForeignKey("movies.id", ondelete="SET NULL"), + nullable=True, + ), + ) + + +def downgrade() -> None: + op.drop_column("bug_reports", "movie_id") diff --git a/alembic/versions/20260512_0016_realdebrid_cache.py b/alembic/versions/20260512_0016_realdebrid_cache.py new file mode 100644 index 0000000..c4a5819 --- /dev/null +++ b/alembic/versions/20260512_0016_realdebrid_cache.py @@ -0,0 +1,44 @@ +"""realdebrid_cache — direct stream URL cache dla RD /unrestrict/link wyników + +Revision ID: 0016_realdebrid_cache +Revises: 0015_bug_reports_movie_id +Create Date: 2026-05-12 + +RD direct linki technically valid ~7 dni, ale cache'ujemy 24h (configurable +przez RD_CACHE_TTL_HOURS) żeby oszczędzać API quota przy replay tej samej +sceny. +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0016_realdebrid_cache" +down_revision: str | None = "0015_bug_reports_movie_id" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "realdebrid_cache", + sa.Column("hoster_url", sa.Text(), primary_key=True), + sa.Column("direct_url", sa.Text(), nullable=False), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column("expires_at", sa.TIMESTAMP(timezone=True), nullable=False), + ) + op.create_index( + "ix_realdebrid_cache_expires_at", + "realdebrid_cache", + ["expires_at"], + ) + + +def downgrade() -> None: + op.drop_index("ix_realdebrid_cache_expires_at", table_name="realdebrid_cache") + op.drop_table("realdebrid_cache") diff --git a/alembic/versions/20260512_0017_drop_realdebrid_cache.py b/alembic/versions/20260512_0017_drop_realdebrid_cache.py new file mode 100644 index 0000000..15907fc --- /dev/null +++ b/alembic/versions/20260512_0017_drop_realdebrid_cache.py @@ -0,0 +1,37 @@ +"""drop realdebrid_cache table — RD nie wykorzystywany (Hetzner IP blocked) + +Revision ID: 0017_drop_realdebrid_cache +Revises: 0016_realdebrid_cache +Create Date: 2026-05-12 + +Real-Debrid integration cofnięta — Hetzner VPS IP blokowany globalnie przez +RD anti-abuse, a 95% relevantnych hosterów (streamtape/playmogo/dood/mixdrop/ +filemoon/iceyfile) są DOWN lub UNSUPPORTED w RD list. Tylko voe.sx + file +hosters UP, nie pokrywa naszego streaming use case. +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0017_drop_realdebrid_cache" +down_revision: str | None = "0016_realdebrid_cache" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.drop_index("ix_realdebrid_cache_expires_at", table_name="realdebrid_cache") + op.drop_table("realdebrid_cache") + + +def downgrade() -> None: + op.create_table( + "realdebrid_cache", + sa.Column("hoster_url", sa.Text(), primary_key=True), + sa.Column("direct_url", sa.Text(), nullable=False), + sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, + server_default=sa.text("now()")), + sa.Column("expires_at", sa.TIMESTAMP(timezone=True), nullable=False), + ) + op.create_index("ix_realdebrid_cache_expires_at", "realdebrid_cache", ["expires_at"]) diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/admin.py b/app/api/admin.py new file mode 100644 index 0000000..54f7845 --- /dev/null +++ b/app/api/admin.py @@ -0,0 +1,332 @@ +"""Admin API: lista pending merge candidates + side-by-side detail + resolve.""" +from __future__ import annotations + +import uuid +from typing import Annotated, Literal + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, ConfigDict +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.api.scenes import _build_scene_out +from app.api.schemas import SceneOut +from app.auth import require_api_key +from app.db import get_session +from app.models.external_record import ExternalRecord +from app.models.merge_candidate import MergeCandidate, MergeKind, MergeStatus +from app.models.playback_source import PlaybackSource +from app.models.scene import Scene, SceneExternalRef +from app.models.source import Source, SourceKind +from app.resolve.scene_merge import MergeError, resolve_candidate + +router = APIRouter( + prefix="/admin", + tags=["admin"], + dependencies=[Depends(require_api_key)], +) + + +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).""" + if kind == SourceKind.tpdb: + for k in ("image", "poster"): + v = raw.get(k) + if isinstance(v, str) and v.startswith("http"): + return v + bg = raw.get("background") + if isinstance(bg, dict): + v = bg.get("large") or bg.get("medium") or bg.get("full") + if isinstance(v, str) and v.startswith("http"): + return v + elif kind == SourceKind.stashdb: + # 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): + for k in ("screenshot", "image", "preview"): + v = paths.get(k) + if isinstance(v, str) and v.startswith("http"): + return v + return None + + +# ---- schemas -------------------------------------------------------------- + +class MergeCandidateSummary(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + kind: str + left_id: uuid.UUID + right_id: uuid.UUID + score: float + status: str + left_title: str | None = None + right_title: str | None = None + left_thumbnail_url: str | None = None + left_animated_thumbnail_url: str | None = None + right_thumbnail_url: str | None = None + right_animated_thumbnail_url: str | None = None + + +class MergeCandidateListOut(BaseModel): + items: list[MergeCandidateSummary] + total: int + page: int + per_page: int + + +class MergeCandidateDetail(BaseModel): + id: uuid.UUID + kind: str + score: float + status: str + reasons: dict + left: SceneOut | None + right: SceneOut | None + + +class ResolveBody(BaseModel): + action: Literal["merge", "reject"] + keep: Literal["left", "right"] = "left" + resolved_by: str | None = None + + +class ResolveResult(BaseModel): + id: uuid.UUID + status: str + keep_id: uuid.UUID | None = None + drop_id: uuid.UUID | None = None + + +# ---- endpoints ------------------------------------------------------------ + +@router.get("/merge-candidates", response_model=MergeCandidateListOut) +def list_candidates( + session: Annotated[Session, Depends(get_session)], + status: Annotated[str, Query(pattern="^(pending|auto_merged|merged|rejected|all)$")] = "pending", + kind: Annotated[str, Query(pattern="^(scene|performer|studio|all)$")] = "scene", + page: Annotated[int, Query(ge=1)] = 1, + per_page: Annotated[int, Query(ge=1, le=200)] = 50, +) -> MergeCandidateListOut: + base = select(MergeCandidate) + if status != "all": + base = base.where(MergeCandidate.status == MergeStatus(status)) + if kind != "all": + base = base.where(MergeCandidate.kind == MergeKind(kind)) + + total = session.execute(select(func.count()).select_from(base.subquery())).scalar_one() + + rows = ( + session.execute( + base.order_by(MergeCandidate.score.desc(), MergeCandidate.created_at.desc()) + .offset((page - 1) * per_page) + .limit(per_page) + ) + .scalars() + .all() + ) + + # Pre-fetch tytułów scen (gdy kind=scene) dla wygodnego podglądu + titles: dict[uuid.UUID, str] = {} + scene_ids = {r.left_id for r in rows if r.kind == MergeKind.scene} | { + r.right_id for r in rows if r.kind == MergeKind.scene + } + if scene_ids: + for sid, title in session.execute( + select(Scene.id, Scene.title).where(Scene.id.in_(scene_ids)) + ): + titles[sid] = title + + # 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, + # ale dla triage to wystarcza. + thumbs: dict[uuid.UUID, str] = {} + animated_thumbs: dict[uuid.UUID, str] = {} + if scene_ids: + for sid, static_url, animated_url in session.execute( + select( + PlaybackSource.scene_id, + PlaybackSource.thumbnail_url, + PlaybackSource.animated_thumbnail_url, + ).where(PlaybackSource.scene_id.in_(scene_ids)) + ): + if static_url and sid not in thumbs: + thumbs[sid] = static_url + if animated_url and sid not in animated_thumbs: + animated_thumbs[sid] = animated_url + + # Fallback: dla scen TPDB/StashDB-only (brak playback_source) wyciągamy + # poster URL z external_records.raw['image' | 'poster' | 'paths.screenshot']. + # Bez tego merge queue ma 70%+ wpisów bez thumb (canonical TPDB↔StashDB pary). + missing = [sid for sid in scene_ids if sid not in thumbs] + if missing: + ext_rows = session.execute( + select(SceneExternalRef.scene_id, ExternalRecord.raw, Source.kind) + .join( + ExternalRecord, + (ExternalRecord.source_id == SceneExternalRef.source_id) + & (ExternalRecord.external_id == SceneExternalRef.external_id), + ) + .join(Source, Source.id == SceneExternalRef.source_id) + .where(SceneExternalRef.scene_id.in_(missing)) + .where(ExternalRecord.entity_kind == "scene") + ).all() + for sid, raw, kind in ext_rows: + if sid in thumbs or not isinstance(raw, dict): + continue + url = _raw_to_thumb(raw, kind) + if url: + thumbs[sid] = url + + items = [ + MergeCandidateSummary( + id=r.id, + kind=r.kind.value, + left_id=r.left_id, + right_id=r.right_id, + score=r.score, + status=r.status.value, + left_title=titles.get(r.left_id), + right_title=titles.get(r.right_id), + left_thumbnail_url=thumbs.get(r.left_id), + right_thumbnail_url=thumbs.get(r.right_id), + left_animated_thumbnail_url=animated_thumbs.get(r.left_id), + right_animated_thumbnail_url=animated_thumbs.get(r.right_id), + ) + for r in rows + ] + return MergeCandidateListOut(items=items, total=total, page=page, per_page=per_page) + + +@router.get("/merge-candidates/{candidate_id}", response_model=MergeCandidateDetail) +def get_candidate( + candidate_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], +) -> MergeCandidateDetail: + cand = session.get(MergeCandidate, candidate_id) + if cand is None: + raise HTTPException(status_code=404, detail="merge candidate not found") + + left_out = right_out = None + if cand.kind == MergeKind.scene: + left_scene = session.get(Scene, cand.left_id) + right_scene = session.get(Scene, cand.right_id) + if left_scene is not None: + left_out = _build_scene_out(session, left_scene) + if right_scene is not None and right_scene.id != cand.left_id: + right_out = _build_scene_out(session, right_scene) + + return MergeCandidateDetail( + id=cand.id, + kind=cand.kind.value, + score=cand.score, + status=cand.status.value, + reasons=cand.reasons or {}, + left=left_out, + right=right_out, + ) + + +@router.post("/merge-candidates/{candidate_id}/resolve", response_model=ResolveResult) +def resolve( + candidate_id: uuid.UUID, + body: ResolveBody, + session: Annotated[Session, Depends(get_session)], +) -> ResolveResult: + try: + cand = resolve_candidate( + session, + candidate_id=candidate_id, + action=body.action, + keep_left=(body.keep == "left"), + resolved_by=body.resolved_by, + ) + except MergeError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + keep_id = drop_id = None + if body.action == "merge": + keep_id = cand.left_id if body.keep == "left" else cand.right_id + drop_id = cand.right_id if body.keep == "left" else cand.left_id + + return ResolveResult(id=cand.id, status=cand.status.value, keep_id=keep_id, drop_id=drop_id) + + +# ---- Bandwidth monitor ----------------------------------------------------- + + +class BandwidthCdnRow(BaseModel): + cdn: str + bytes: int + pretty: str + + +class BandwidthStats(BaseModel): + """Per-CDN bytes-out z VPS proxy (rolling buckets). Restart api resetuje. + Hetzner widoczne tylko gdy HETZNER_API_TOKEN i HETZNER_SERVER_ID w env.""" + last_1h: list[BandwidthCdnRow] + last_24h: list[BandwidthCdnRow] + last_7d: list[BandwidthCdnRow] + total_bytes_1h: int + total_bytes_24h: int + total_bytes_7d: int + hetzner: dict | None = None + + +def _fmt_bytes(b: int) -> str: + if b < 1024: + return f"{b} B" + val = float(b) + for u in ("KB", "MB", "GB", "TB"): + val /= 1024 + if val < 1024: + return f"{val:.2f} {u}" + return f"{val:.2f} PB" + + +@router.get("/bandwidth", response_model=BandwidthStats) +def bandwidth_stats() -> BandwidthStats: + """Per-CDN VPS proxy bytes-out + Hetzner traffic stats. + + 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 + from app.config import get_settings + + def _rows(stats: dict[str, int]) -> list[BandwidthCdnRow]: + return [ + BandwidthCdnRow(cdn=cdn, bytes=b, pretty=_fmt_bytes(b)) + for cdn, b in stats.items() + ] + + s_1h = get_bandwidth_stats(1) + 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_data = None + settings = get_settings() + if settings.hetzner_api_token and settings.hetzner_server_id: + import json + from pathlib import Path + cache_path = Path("/tmp/hetzner_traffic.json") + if cache_path.exists(): + try: + hetzner_data = json.loads(cache_path.read_text()) + except Exception: + pass + + return BandwidthStats( + last_1h=_rows(s_1h), + last_24h=_rows(s_24h), + last_7d=_rows(s_7d), + total_bytes_1h=sum(s_1h.values()), + total_bytes_24h=sum(s_24h.values()), + total_bytes_7d=sum(s_7d.values()), + hetzner=hetzner_data, + ) diff --git a/app/api/admin_html.py b/app/api/admin_html.py new file mode 100644 index 0000000..99514db --- /dev/null +++ b/app/api/admin_html.py @@ -0,0 +1,206 @@ +"""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) + zwraca fragment HTML z potwierdzeniem +""" +from __future__ import annotations + +import uuid +from pathlib import Path +from typing import Annotated + +from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.api.scenes import _build_scene_out +from app.auth import require_api_key +from app.db import get_session +from app.models.merge_candidate import MergeCandidate, MergeKind, MergeStatus +from app.models.scene import Scene +from app.resolve.scene_merge import MergeError, resolve_candidate + +_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates" +_STATIC_DIR = Path(__file__).resolve().parent.parent / "static" + +templates = Jinja2Templates(directory=str(_TEMPLATES_DIR)) + + +def _score_class(score: float) -> str: + if score >= 0.92: + return "high" + if score >= 0.75: + return "mid" + return "low" + + +templates.env.globals["score_class"] = _score_class + + +router = APIRouter( + prefix="/ui", + tags=["ui"], + dependencies=[Depends(require_api_key)], +) + + +@router.get("/", response_class=HTMLResponse) +def list_view( + request: Request, + session: Annotated[Session, Depends(get_session)], + status: Annotated[str, Query(pattern="^(pending|auto_merged|merged|rejected|all)$")] = "pending", + page: Annotated[int, Query(ge=1)] = 1, +) -> HTMLResponse: + per_page = 50 + + base = select(MergeCandidate).where(MergeCandidate.kind == MergeKind.scene) + if status != "all": + base = base.where(MergeCandidate.status == MergeStatus(status)) + + total = session.execute(select(func.count()).select_from(base.subquery())).scalar_one() + rows = ( + session.execute( + base.order_by(MergeCandidate.score.desc(), MergeCandidate.created_at.desc()) + .offset((page - 1) * per_page) + .limit(per_page) + ) + .scalars() + .all() + ) + + titles: dict[uuid.UUID, str] = {} + scene_ids = {r.left_id for r in rows} | {r.right_id for r in rows} + if scene_ids: + for sid, title in session.execute( + select(Scene.id, Scene.title).where(Scene.id.in_(scene_ids)) + ): + titles[sid] = title + + items = [ + { + "id": r.id, + "kind": r.kind.value, + "left_id": r.left_id, + "right_id": r.right_id, + "score": r.score, + "status": r.status.value, + "left_title": titles.get(r.left_id), + "right_title": titles.get(r.right_id), + } + for r in rows + ] + + label_map = { + "pending": "Pending", + "auto_merged": "Auto-merged", + "merged": "Merged", + "rejected": "Rejected", + "all": "All", + } + + return templates.TemplateResponse( + request, + "candidates_list.html", + { + "items": items, + "total": total, + "page": page, + "per_page": per_page, + "status": status, + "status_label": label_map[status], + }, + ) + + +@router.get("/candidate/{candidate_id}", response_class=HTMLResponse) +def detail_view( + candidate_id: uuid.UUID, + request: Request, + session: Annotated[Session, Depends(get_session)], +) -> HTMLResponse: + cand = session.get(MergeCandidate, candidate_id) + if cand is None: + raise HTTPException(status_code=404, detail="merge candidate not found") + + left_out = right_out = None + if cand.kind == MergeKind.scene: + left_scene = session.get(Scene, cand.left_id) + right_scene = session.get(Scene, cand.right_id) + if left_scene is not None: + left_out = _build_scene_out(session, left_scene) + if right_scene is not None and right_scene.id != cand.left_id: + right_out = _build_scene_out(session, right_scene) + + return templates.TemplateResponse( + request, + "candidate_detail.html", + { + "cand": { + "id": cand.id, + "kind": cand.kind.value, + "score": cand.score, + "status": cand.status.value, + "reasons": cand.reasons or {}, + "left": left_out, + "right": right_out, + "left_id": cand.left_id, + "right_id": cand.right_id, + }, + }, + ) + + +@router.post("/candidate/{candidate_id}/resolve", response_class=HTMLResponse) +def resolve_form( + candidate_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], + action: Annotated[str, Form()], +) -> HTMLResponse: + if action not in {"merge_keep_left", "merge_keep_right", "reject"}: + raise HTTPException(status_code=400, detail=f"invalid action: {action}") + + api_action = "reject" if action == "reject" else "merge" + keep_left = action != "merge_keep_right" + + try: + resolve_candidate( + session, + candidate_id=candidate_id, + action=api_action, + keep_left=keep_left, + resolved_by="ui", + ) + except MergeError as exc: + return HTMLResponse( + f'
' + f"error: {exc}
", + status_code=400, + ) + + label = { + "merge_keep_left": "Merged into LEFT", + "merge_keep_right": "Merged into RIGHT", + "reject": "Rejected (kept both)", + }[action] + + return HTMLResponse( + f'
' + f"{label}. " + f'← back to list
' + ) + + +def mount_static(app) -> None: # pragma: no cover - dev convenience + # 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. + import mimetypes + mimetypes.add_type("application/vnd.android.package-archive", ".apk") + if _STATIC_DIR.exists(): + app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") diff --git a/app/api/blacklist.py b/app/api/blacklist.py new file mode 100644 index 0000000..33774d3 --- /dev/null +++ b/app/api/blacklist.py @@ -0,0 +1,116 @@ +"""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ń + +`kind` ∈ {performer, studio, tag}. +""" +from __future__ import annotations + +import uuid +from typing import Annotated, Literal + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.auth import require_api_key +from app.db import get_session +from app.models.blacklist import ( + BlacklistedPerformer, + BlacklistedStudio, + BlacklistedTag, +) +from app.models.performer import Performer +from app.models.studio import Studio +from app.models.tag import Tag + +router = APIRouter( + prefix="/blacklist", tags=["blacklist"], dependencies=[Depends(require_api_key)] +) + +Kind = Literal["performer", "studio", "tag"] + + +class BlacklistEntry(BaseModel): + id: uuid.UUID + name: str # canonical_name (performer) / name (studio/tag) + slug: str | None = None + + +class BlacklistOut(BaseModel): + performers: list[BlacklistEntry] + studios: list[BlacklistEntry] + tags: list[BlacklistEntry] + + +@router.get("", response_model=BlacklistOut) +def list_blacklist( + session: Annotated[Session, Depends(get_session)], +) -> BlacklistOut: + perfs = session.execute( + select(BlacklistedPerformer.performer_id, Performer.canonical_name, Performer.slug) + .join(Performer, Performer.id == BlacklistedPerformer.performer_id) + .order_by(Performer.canonical_name) + ).all() + studios = session.execute( + select(BlacklistedStudio.studio_id, Studio.name, Studio.slug) + .join(Studio, Studio.id == BlacklistedStudio.studio_id) + .order_by(Studio.name) + ).all() + tags = session.execute( + select(BlacklistedTag.tag_id, Tag.name, Tag.slug) + .join(Tag, Tag.id == BlacklistedTag.tag_id) + .order_by(Tag.name) + ).all() + return BlacklistOut( + performers=[BlacklistEntry(id=r[0], name=r[1], slug=r[2]) for r in perfs], + studios=[BlacklistEntry(id=r[0], name=r[1], slug=r[2]) for r in studios], + tags=[BlacklistEntry(id=r[0], name=r[1], slug=r[2]) for r in tags], + ) + + +def _kind_to_entity(kind: Kind): + if kind == "performer": + return BlacklistedPerformer, Performer, "performer_id" + if kind == "studio": + return BlacklistedStudio, Studio, "studio_id" + if kind == "tag": + return BlacklistedTag, Tag, "tag_id" + raise HTTPException(status_code=400, detail="kind must be performer|studio|tag") + + +@router.post("/{kind}/{entity_id}", status_code=status.HTTP_200_OK) +def add_blacklist( + kind: Kind, + entity_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], +) -> dict: + bl_model, parent_model, fk = _kind_to_entity(kind) + if session.get(parent_model, entity_id) is None: + raise HTTPException(status_code=404, detail=f"{kind} not found") + if session.get(bl_model, entity_id) is not None: + return {"kind": kind, "id": str(entity_id), "created": False} + session.add(bl_model(**{fk: entity_id})) + session.commit() + return {"kind": kind, "id": str(entity_id), "created": True} + + +@router.delete("/{kind}/{entity_id}", status_code=status.HTTP_204_NO_CONTENT) +def remove_blacklist( + kind: Kind, + entity_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], +) -> None: + bl_model, _, _ = _kind_to_entity(kind) + row = session.get(bl_model, entity_id) + if row is None: + return # idempotent + session.delete(row) + session.commit() diff --git a/app/api/bug_reports.py b/app/api/bug_reports.py new file mode 100644 index 0000000..42df868 --- /dev/null +++ b/app/api/bug_reports.py @@ -0,0 +1,155 @@ +"""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ć. + +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: +- mały ekran scene-list: ~200-400KB +- duży scene-detail z thumbnail: ~600KB-1MB +""" +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from sqlalchemy import desc, func, select +from sqlalchemy.orm import Session + +from app.auth import require_api_key +from app.db import get_session +from app.models.bug_report import BugReport +from app.models.movie import Movie +from app.models.scene import Scene + +router = APIRouter(tags=["bug-reports"], dependencies=[Depends(require_api_key)]) + + +_MAX_SCREENSHOT_BYTES = 1_500_000 # raw base64 chars; ~1.1MB binary po dekodowaniu + + +class BugReportCreate(BaseModel): + message: str = Field(min_length=1, max_length=5000) + screen_name: str | None = Field(default=None, max_length=64) + app_version: str | None = Field(default=None, max_length=32) + scene_id: uuid.UUID | None = None + screenshot_b64: str | None = Field(default=None, max_length=_MAX_SCREENSHOT_BYTES) + + +class BugReportOut(BaseModel): + id: uuid.UUID + created_at: datetime + screen_name: str | None + app_version: str | None + scene_id: uuid.UUID | None + movie_id: uuid.UUID | None + message: str + has_screenshot: bool + resolved: bool + + +class BugReportListOut(BaseModel): + items: list[BugReportOut] + total: int + + +@router.post("/bug-reports", status_code=status.HTTP_201_CREATED) +def create_bug_report( + payload: BugReportCreate, + session: Annotated[Session, Depends(get_session)], +) -> dict[str, str]: + # Smart-route entity_id: mobile Player używa `sceneId` param zarówno dla + # scen jak i movies (legacy progress tracking hack). Bez tego INSERT FK + # violation crashował 500 (zgłoszone 2026-05-10). Sprawdź obie tabele. + scene_id: uuid.UUID | None = None + movie_id: uuid.UUID | None = None + if payload.scene_id is not None: + if session.get(Scene, payload.scene_id) is not None: + 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 + + br = BugReport( + id=uuid.uuid4(), + message=payload.message.strip(), + screen_name=payload.screen_name, + app_version=payload.app_version, + scene_id=scene_id, + movie_id=movie_id, + screenshot_b64=payload.screenshot_b64, + ) + session.add(br) + session.commit() + return {"id": str(br.id)} + + +@router.get("/bug-reports", response_model=BugReportListOut) +def list_bug_reports( + session: Annotated[Session, Depends(get_session)], + limit: int = 50, + offset: int = 0, + include_resolved: bool = False, +) -> BugReportListOut: + q = select(BugReport).order_by(desc(BugReport.created_at)) + cnt_q = select(func.count(BugReport.id)) + if not include_resolved: + q = q.where(BugReport.resolved.is_(False)) + cnt_q = cnt_q.where(BugReport.resolved.is_(False)) + rows = session.scalars(q.limit(limit).offset(offset)).all() + total = session.scalar(cnt_q) or 0 + items = [ + BugReportOut( + id=r.id, + created_at=r.created_at, + screen_name=r.screen_name, + app_version=r.app_version, + scene_id=r.scene_id, + movie_id=r.movie_id, + message=r.message, + has_screenshot=bool(r.screenshot_b64), + resolved=r.resolved, + ) + for r in rows + ] + return BugReportListOut(items=items, total=total) + + +@router.get("/bug-reports/{bug_id}/screenshot") +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.""" + br = session.get(BugReport, bug_id) + if br is None: + raise HTTPException(status_code=404, detail="not found") + return {"screenshot_b64": br.screenshot_b64} + + +@router.post("/bug-reports/{bug_id}/resolve") +def resolve_bug_report( + bug_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], +) -> dict[str, str]: + br = session.get(BugReport, bug_id) + if br is None: + raise HTTPException(status_code=404, detail="not found") + br.resolved = True + session.commit() + return {"status": "resolved"} + + +@router.delete("/bug-reports/{bug_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_bug_report( + bug_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], +) -> None: + br = session.get(BugReport, bug_id) + if br is None: + raise HTTPException(status_code=404, detail="not found") + session.delete(br) + session.commit() diff --git a/app/api/expo_updates.py b/app/api/expo_updates.py new file mode 100644 index 0000000..1f93dd2 --- /dev/null +++ b/app/api/expo_updates.py @@ -0,0 +1,104 @@ +"""Expo Updates serving endpoints (OTA JS bundle distribution). + +Mobile sprawdza `/expo-updates/manifest` przy każdym launch (lub on-foreground). +Serwer zwraca aktualny manifest dla danego `expo-runtime-version`. Mobile pobiera +launchAsset (bundle) + assets, zapisuje, restartuje aplikację z nowym bundle. + +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. +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 +manifestu. Jeśli kiedyś trzeba twardo: dorobić expo-updates code signing key. +""" +from __future__ import annotations + +import json +import logging +from pathlib import Path + +from fastapi import APIRouter, Header, HTTPException, Query +from fastapi.responses import FileResponse, JSONResponse, Response + +log = logging.getLogger(__name__) + +router = APIRouter(tags=["expo-updates"]) + +_STATIC_DIR = Path(__file__).resolve().parent.parent / "static" / "expo-updates" + + +@router.get("/expo-updates/manifest") +def get_manifest( + expo_runtime_version: str | None = Header(default=None, alias="expo-runtime-version"), + 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). + + 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 + Mode), więc nie potrzebujemy multipart. + """ + runtime = expo_runtime_version or "1.0" + runtime_dir = _STATIC_DIR / runtime + current_file = runtime_dir / "current.json" + if not current_file.exists(): + return Response(status_code=204) + + try: + current = json.loads(current_file.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as e: + log.warning("expo-updates: bad current.json for runtime=%s: %s", runtime, e) + return Response(status_code=204) + + update_id = current.get("update_id") + if not update_id: + return Response(status_code=204) + + manifest_file = runtime_dir / update_id / "manifest.json" + if not manifest_file.exists(): + log.warning("expo-updates: current points to missing update %s", update_id) + return Response(status_code=204) + + try: + manifest = json.loads(manifest_file.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as e: + log.error("expo-updates: bad manifest.json for %s: %s", update_id, e) + return Response(status_code=204) + + return JSONResponse( + manifest, + headers={ + "expo-protocol-version": "1", + "expo-sfv-version": "0", + "cache-control": "private, max-age=0", + "content-type": "application/json; charset=utf-8", + }, + ) + + +@router.get("/expo-updates/asset") +def get_asset( + asset: str = Query(..., description="Relative path do pliku w runtime dir"), + runtimeVersion: str = Query("1.0"), + platform: str = Query("android"), +) -> Response: + """Serwuje pojedynczy asset (JS bundle, image, font) z update directory. + + `asset` to relative path względem `static/expo-updates//` — + zwykle `/_expo/static/js/android/.js` lub + `/assets/`. Path traversal blocked przez resolve+is_relative. + """ + runtime_dir = (_STATIC_DIR / runtimeVersion).resolve() + target = (runtime_dir / asset).resolve() + if not str(target).startswith(str(runtime_dir)): + 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. + media_type = None + if target.suffix in (".js", ".bundle"): + media_type = "application/javascript" + return FileResponse(target, media_type=media_type) diff --git a/app/api/favorites.py b/app/api/favorites.py new file mode 100644 index 0000000..f49e6ac --- /dev/null +++ b/app/api/favorites.py @@ -0,0 +1,457 @@ +"""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 (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 + +"Nowa scena" = scena której Scene.created_at > favorite.last_seen_at: + - dla performerki: ScenePerformer.performer_id = X + - dla studio: Scene.studio_id = X +""" +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.auth import require_api_key +from app.db import get_session +from app.models.favorite_movie import FavoriteMovie +from app.models.favorite_performer import FavoritePerformer +from app.models.favorite_studio import FavoriteStudio +from app.models.movie import Movie +from app.models.performer import Performer +from app.models.playback_source import PlaybackSource +from app.models.scene import Scene, ScenePerformer +from app.models.studio import Studio + +router = APIRouter( + prefix="/favorites", tags=["favorites"], dependencies=[Depends(require_api_key)] +) + + +class FavoriteOut(BaseModel): + performer_id: uuid.UUID + canonical_name: str + slug: str | None + scene_count: int + new_count: int # sceny od last_seen_at + last_seen_at: datetime + created_at: datetime + + +class FavoriteListOut(BaseModel): + items: list[FavoriteOut] + total: int + new_total: int # suma new_count po wszystkich — dla badge w toolbar + + +@router.get("", response_model=FavoriteListOut) +def list_favorites( + session: Annotated[Session, Depends(get_session)], +) -> FavoriteListOut: + rows = session.execute( + select(FavoritePerformer, Performer) + .join(Performer, Performer.id == FavoritePerformer.performer_id) + .order_by(Performer.canonical_name) + ).all() + if not rows: + return FavoriteListOut(items=[], total=0, new_total=0) + + perf_ids = [perf.id for _, perf in rows] + last_seen_by_perf = {fav.performer_id: fav.last_seen_at for fav, _ in rows} + + # Batch: scene_count per performer — filtrujemy `has_live_playback` żeby badge + # `N scenes` zgadzał się z tym co widać w PerformerScenes (mobile filtruje + # `has_playback=true`). TPDB/StashDB sync wstawia metadata-only stubs które wlicz + # by się w 2062 dla Aletta Ocean ale w profilu pokazuje tylko 499 oglądalnych. + from sqlalchemy import and_, exists + _scene_count_live_playback = exists().where( + and_( + PlaybackSource.scene_id == ScenePerformer.scene_id, + PlaybackSource.dead_at.is_(None), + ) + ) + scene_counts: dict = dict( + session.execute( + select(ScenePerformer.performer_id, func.count(ScenePerformer.scene_id)) + .where(ScenePerformer.performer_id.in_(perf_ids)) + .where(_scene_count_live_playback) + .group_by(ScenePerformer.performer_id) + ).all() + ) + + # Batch: new_count per performer — sceny z created_at > last_seen_at favorite'a. + # Każda performerka ma INNY last_seen_at, więc warunek per-row. Trick: GREATEST jest + # nieważny — robimy CASE per row z mapowaniem perf_id → last_seen przez VALUES list. + # Prościej: jeden join + WHERE z OR po wszystkich (perf_id=X AND created_at>ts_X) — + # ale to N OR-ów. Najczystsze rozwiązanie: zapytaj per-row ale wszystkie naraz w + # SQL używając IN tuple lub sub-query. Tu korzystamy z faktu że N=14 typowo, więc + # robimy unionall albo prosty (perf_id, last_seen_at) JOIN. + new_counts: dict = {} + if perf_ids: + # Liczymy TYLKO sceny z żywym playback_source (has_live_playback). Powód: + # TPDB/StashDB sync wstawia metadata-only stubs (52 scen Danielle Renae jednego + # dnia z 0 playback) — bumpują created_at, badge `+N`, ale w PerformerScenes + # mobile filtruje `has_playback=true` → 0 widocznych. Result: user widzi +48 + # ale w profilu nic nowego. Filter aligns count z faktycznie oglądalnym + # contentem ("new znalezisko" = scena którą da się odtworzyć). + from sqlalchemy import and_, exists + live_playback = exists().where( + and_( + PlaybackSource.scene_id == Scene.id, + PlaybackSource.dead_at.is_(None), + ) + ) + per_scene_rows = session.execute( + select(ScenePerformer.performer_id, Scene.created_at) + .join(Scene, Scene.id == ScenePerformer.scene_id) + .where(ScenePerformer.performer_id.in_(perf_ids)) + .where(live_playback) + ).all() + for pid, created_at in per_scene_rows: + if created_at is None: + continue + if created_at > last_seen_by_perf.get(pid): + new_counts[pid] = new_counts.get(pid, 0) + 1 + + items: list[FavoriteOut] = [] + new_total = 0 + for fav, perf in rows: + nc = new_counts.get(perf.id, 0) + new_total += nc + items.append( + FavoriteOut( + performer_id=perf.id, + canonical_name=perf.canonical_name, + slug=perf.slug, + scene_count=scene_counts.get(perf.id, 0), + new_count=nc, + last_seen_at=fav.last_seen_at, + created_at=fav.created_at, + ) + ) + return FavoriteListOut(items=items, total=len(items), new_total=new_total) + + +class FavoriteAddOut(BaseModel): + performer_id: uuid.UUID + created: bool + + +@router.post( + "/{performer_id}", + response_model=FavoriteAddOut, + status_code=status.HTTP_200_OK, +) +def add_favorite( + performer_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], +) -> FavoriteAddOut: + perf = session.get(Performer, performer_id) + if perf is None: + raise HTTPException(status_code=404, detail="performer not found") + existing = session.get(FavoritePerformer, performer_id) + if existing is not None: + return FavoriteAddOut(performer_id=performer_id, created=False) + session.add(FavoritePerformer(performer_id=performer_id)) + session.commit() + return FavoriteAddOut(performer_id=performer_id, created=True) + + +@router.delete("/{performer_id}", status_code=status.HTTP_204_NO_CONTENT) +def remove_favorite( + performer_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], +) -> None: + fav = session.get(FavoritePerformer, performer_id) + if fav is None: + # idempotent — brak ulubionego = nie ma nic do usunięcia, success + return + session.delete(fav) + session.commit() + + +class SeenOut(BaseModel): + performer_id: uuid.UUID + last_seen_at: datetime + + +@router.post("/{performer_id}/seen", response_model=SeenOut) +def mark_seen( + performer_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], +) -> SeenOut: + fav = session.get(FavoritePerformer, performer_id) + if fav is None: + raise HTTPException(status_code=404, detail="not in favorites") + fav.last_seen_at = datetime.now(UTC) + session.commit() + return SeenOut(performer_id=performer_id, last_seen_at=fav.last_seen_at) + + +# ---------- Studios ---------- + +class FavoriteStudioOut(BaseModel): + studio_id: uuid.UUID + name: str + slug: str + network: str | None = None + scene_count: int + new_count: int + last_seen_at: datetime + created_at: datetime + + +class FavoriteStudioListOut(BaseModel): + items: list[FavoriteStudioOut] + total: int + new_total: int + + +@router.get("/studios", response_model=FavoriteStudioListOut) +def list_favorite_studios( + session: Annotated[Session, Depends(get_session)], +) -> FavoriteStudioListOut: + rows = session.execute( + select(FavoriteStudio, Studio) + .join(Studio, Studio.id == FavoriteStudio.studio_id) + .order_by(Studio.name) + ).all() + if not rows: + return FavoriteStudioListOut(items=[], total=0, new_total=0) + + studio_ids = [st.id for _, st in rows] + last_seen_by_studio = {fav.studio_id: fav.last_seen_at for fav, _ in rows} + + # has_live_playback filter — patrz `list_favorites` (performers) wyżej. + from sqlalchemy import and_, exists + _studio_count_live_playback = exists().where( + and_( + PlaybackSource.scene_id == Scene.id, + PlaybackSource.dead_at.is_(None), + ) + ) + scene_counts: dict = dict( + session.execute( + select(Scene.studio_id, func.count(Scene.id)) + .where(Scene.studio_id.in_(studio_ids)) + .where(_studio_count_live_playback) + .group_by(Scene.studio_id) + ).all() + ) + + new_counts: dict = {} + if studio_ids: + # has_live_playback filter — patrz `list_favorites` (performers) wyżej. + from sqlalchemy import and_, exists + live_playback = exists().where( + and_( + PlaybackSource.scene_id == Scene.id, + PlaybackSource.dead_at.is_(None), + ) + ) + per_scene_rows = session.execute( + select(Scene.studio_id, Scene.created_at) + .where(Scene.studio_id.in_(studio_ids)) + .where(live_playback) + ).all() + for sid, created_at in per_scene_rows: + if created_at is None: + continue + if created_at > last_seen_by_studio.get(sid): + new_counts[sid] = new_counts.get(sid, 0) + 1 + + items: list[FavoriteStudioOut] = [] + new_total = 0 + for fav, st in rows: + nc = new_counts.get(st.id, 0) + new_total += nc + items.append( + FavoriteStudioOut( + studio_id=st.id, + name=st.name, + slug=st.slug, + network=st.network, + scene_count=scene_counts.get(st.id, 0), + new_count=nc, + last_seen_at=fav.last_seen_at, + created_at=fav.created_at, + ) + ) + return FavoriteStudioListOut(items=items, total=len(items), new_total=new_total) + + +class FavoriteStudioAddOut(BaseModel): + studio_id: uuid.UUID + created: bool + + +@router.post( + "/studios/{studio_id}", + response_model=FavoriteStudioAddOut, + status_code=status.HTTP_200_OK, +) +def add_favorite_studio( + studio_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], +) -> FavoriteStudioAddOut: + st = session.get(Studio, studio_id) + if st is None: + raise HTTPException(status_code=404, detail="studio not found") + existing = session.get(FavoriteStudio, studio_id) + if existing is not None: + return FavoriteStudioAddOut(studio_id=studio_id, created=False) + session.add(FavoriteStudio(studio_id=studio_id)) + session.commit() + return FavoriteStudioAddOut(studio_id=studio_id, created=True) + + +@router.delete("/studios/{studio_id}", status_code=status.HTTP_204_NO_CONTENT) +def remove_favorite_studio( + studio_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], +) -> None: + fav = session.get(FavoriteStudio, studio_id) + if fav is None: + return + session.delete(fav) + session.commit() + + +class SeenStudioOut(BaseModel): + studio_id: uuid.UUID + last_seen_at: datetime + + +@router.post("/studios/{studio_id}/seen", response_model=SeenStudioOut) +def mark_studio_seen( + studio_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], +) -> SeenStudioOut: + fav = session.get(FavoriteStudio, studio_id) + if fav is None: + raise HTTPException(status_code=404, detail="not in favorites") + fav.last_seen_at = datetime.now(UTC) + session.commit() + return SeenStudioOut(studio_id=studio_id, last_seen_at=fav.last_seen_at) + + +# ── 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 +# 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). + + +class FavoriteMovieOut(BaseModel): + movie_id: uuid.UUID + title: str + slug: str | None + poster_url: str | None + release_year: int | None + studio_name: str | None + last_seen_at: datetime + created_at: datetime + + +class FavoriteMovieListOut(BaseModel): + items: list[FavoriteMovieOut] + total: int + + +@router.get("/movies", response_model=FavoriteMovieListOut) +def list_favorite_movies( + session: Annotated[Session, Depends(get_session)], +) -> FavoriteMovieListOut: + rows = session.execute( + select(FavoriteMovie, Movie, Studio) + .join(Movie, Movie.id == FavoriteMovie.movie_id) + .outerjoin(Studio, Studio.id == Movie.studio_id) + .order_by(Movie.title) + ).all() + items = [ + FavoriteMovieOut( + movie_id=movie.id, + title=movie.title, + slug=movie.slug, + poster_url=movie.poster_url, + release_year=movie.release_year, + studio_name=studio.name if studio else None, + last_seen_at=fav.last_seen_at, + created_at=fav.created_at, + ) + for fav, movie, studio in rows + ] + return FavoriteMovieListOut(items=items, total=len(items)) + + +class FavoriteMovieAddOut(BaseModel): + movie_id: uuid.UUID + created: bool + + +@router.post( + "/movies/{movie_id}", + response_model=FavoriteMovieAddOut, + status_code=status.HTTP_200_OK, +) +def add_favorite_movie( + movie_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], +) -> FavoriteMovieAddOut: + movie = session.get(Movie, movie_id) + if movie is None: + raise HTTPException(status_code=404, detail="movie not found") + existing = session.get(FavoriteMovie, movie_id) + if existing is not None: + return FavoriteMovieAddOut(movie_id=movie_id, created=False) + session.add(FavoriteMovie(movie_id=movie_id)) + session.commit() + return FavoriteMovieAddOut(movie_id=movie_id, created=True) + + +@router.delete("/movies/{movie_id}", status_code=status.HTTP_204_NO_CONTENT) +def remove_favorite_movie( + movie_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], +) -> None: + fav = session.get(FavoriteMovie, movie_id) + if fav is None: + return + session.delete(fav) + session.commit() + + +class SeenMovieOut(BaseModel): + movie_id: uuid.UUID + last_seen_at: datetime + + +@router.post("/movies/{movie_id}/seen", response_model=SeenMovieOut) +def mark_movie_seen( + movie_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], +) -> SeenMovieOut: + fav = session.get(FavoriteMovie, movie_id) + if fav is None: + raise HTTPException(status_code=404, detail="not in favorites") + fav.last_seen_at = datetime.now(UTC) + session.commit() + return SeenMovieOut(movie_id=movie_id, last_seen_at=fav.last_seen_at) diff --git a/app/api/movies.py b/app/api/movies.py new file mode 100644 index 0000000..58a9f29 --- /dev/null +++ b/app/api/movies.py @@ -0,0 +1,275 @@ +"""GET /movies — lista i szczegóły filmów.""" +from __future__ import annotations + +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import exists, func, select +from sqlalchemy.orm import Session + +from app.api.schemas import ( + ExternalRefOut, + MovieChapterOut, + MovieListOut, + MovieOut, + PerformerOut, + PlaybackSourceOut, + StudioOut, + TagOut, +) +from app.auth import require_api_key +from app.db import get_session +from app.models.movie import ( + Movie, + MovieChapter, + MovieExternalRef, + MoviePerformer, + MovieTag, +) +from app.models.favorite_movie import FavoriteMovie +from app.models.movie_playback_source import MoviePlaybackSource +from app.models.performer import Performer +from app.models.source import Source +from app.models.studio import Studio +from app.models.tag import Tag + +router = APIRouter(prefix="/movies", tags=["movies"], dependencies=[Depends(require_api_key)]) + +_VALID_SORTS = {"created_at", "release_year", "release_date", "title", "rating"} + + +def _split_csv(raw: str | None) -> list[str]: + if not raw: + return [] + return [s.strip() for s in raw.split(",") if s.strip()] + + +@router.get("", response_model=MovieListOut) +def list_movies( + session: Annotated[Session, Depends(get_session)], + q: str | None = Query(default=None, description="Title search (trgm)"), + studio_slugs: str | None = Query(default=None, description="Comma-separated studio slugs (OR)"), + tags: str | None = Query(default=None, description="Comma-separated tag slugs (AND)"), + performer_ids: str | None = Query(default=None, description="Comma-separated performer UUIDs (AND)"), + year_from: int | None = Query(default=None, ge=1900, le=2100), + year_to: int | None = Query(default=None, ge=1900, le=2100), + has_playback: bool | None = Query(default=None), + sort: str = Query(default="created_at"), + page: int = Query(default=1, ge=1), + per_page: int = Query(default=50, ge=1, le=200), +) -> MovieListOut: + if sort not in _VALID_SORTS: + raise HTTPException(status_code=400, detail=f"sort must be one of {sorted(_VALID_SORTS)}") + + base = select(Movie) + + if q: + base = base.where(Movie.title_normalized.ilike(f"%{q.lower()}%")) + + studio_slug_list = _split_csv(studio_slugs) + if studio_slug_list: + base = base.where( + Movie.studio_id.in_(select(Studio.id).where(Studio.slug.in_(studio_slug_list))) + ) + + for slug in _split_csv(tags): + base = base.where( + exists( + select(1).select_from(MovieTag).join(Tag, Tag.id == MovieTag.tag_id) + .where(MovieTag.movie_id == Movie.id, Tag.slug == slug) + ) + ) + + perf_id_strings = _split_csv(performer_ids) + if perf_id_strings: + try: + perf_ids = [uuid.UUID(s) for s in perf_id_strings] + except ValueError as e: + raise HTTPException(status_code=400, detail=f"invalid performer UUID: {e}") from e + for pid in perf_ids: + base = base.where( + exists( + select(1).select_from(MoviePerformer).where( + MoviePerformer.movie_id == Movie.id, + MoviePerformer.performer_id == pid, + ) + ) + ) + + if year_from is not None: + base = base.where(Movie.release_year >= year_from) + if year_to is not None: + base = base.where(Movie.release_year <= year_to) + + if has_playback is True: + base = base.where( + exists( + select(1).where( + MoviePlaybackSource.movie_id == Movie.id, + MoviePlaybackSource.dead_at.is_(None), + ) + ) + ) + + total = session.execute( + select(func.count()).select_from(base.subquery()) + ).scalar_one() + + if sort == "created_at": + base = base.order_by(Movie.created_at.desc()) + elif sort == "release_year": + base = base.order_by(Movie.release_year.desc().nulls_last(), Movie.created_at.desc()) + elif sort == "release_date": + base = base.order_by(Movie.release_date.desc().nulls_last(), Movie.created_at.desc()) + elif sort == "title": + base = base.order_by(Movie.title_normalized.asc()) + elif sort == "rating": + base = base.order_by(Movie.rating.desc().nulls_last(), Movie.created_at.desc()) + + base = base.limit(per_page).offset((page - 1) * per_page) + + movies = session.execute(base).scalars().all() + items = [_movie_to_out(session, m) for m in 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). +# Ranking ustalony ad-hoc 2026-05-09 (extract_stream_from_hoster na 5 sample +# random per origin). +_MOVIE_PREFERRED_ORIGINS = ( + "mangoporn:luluvid", # KVS, działa + "mangoporn:mixdrop", # po domain fix może działać + "mangoporn:voe", # czasem yt-dlp łapie + "mangoporn", + "streamporn", + "pandamovies", +) +# 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, +# ~5% URLów żyje. +_MOVIE_DROP_ORIGINS = frozenset({ + "mangoporn:rapidgator", + "mangoporn:nitroflare", + "mangoporn:frdl", +}) +# Raw landing origins ukrywane gdy są sub-hosters (zob. komentarz w get_movie). +_MOVIE_LANDING_HIDE = frozenset({"mangoporn", "pandamovies", "streamporn"}) + + +def _movie_origin_priority(origin: str) -> int: + try: + return _MOVIE_PREFERRED_ORIGINS.index(origin) + except ValueError: + return 500 # neutralne (paradisehill, mangoporn:* nieklasyfikowane) + + +@router.get("/{movie_id}", response_model=MovieOut) +def get_movie( + movie_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], +) -> MovieOut: + movie = session.get(Movie, movie_id) + if movie is None: + raise HTTPException(status_code=404, detail="movie not found") + return _movie_to_out(session, movie) + + +def _movie_to_out(session: Session, movie: Movie) -> MovieOut: + studio_out: StudioOut | None = None + if movie.studio_id: + studio = session.get(Studio, movie.studio_id) + if studio is not None: + studio_out = StudioOut.model_validate(studio) + + performer_rows = session.execute( + select(Performer, MoviePerformer.as_alias) + .join(MoviePerformer, MoviePerformer.performer_id == Performer.id) + .where(MoviePerformer.movie_id == movie.id) + .order_by(MoviePerformer.position.asc().nulls_last()) + ).all() + performers = [ + PerformerOut( + id=p.id, + canonical_name=p.canonical_name, + slug=p.slug, + gender=p.gender.value if p.gender else None, + as_alias=alias, + ) + for p, alias in performer_rows + ] + + tag_rows = session.execute( + select(Tag).join(MovieTag, MovieTag.tag_id == Tag.id) + .where(MovieTag.movie_id == movie.id) + .order_by(Tag.name.asc()) + ).scalars().all() + tags = [TagOut.model_validate(t) for t in tag_rows] + + chapter_rows = session.execute( + select(MovieChapter).where(MovieChapter.movie_id == movie.id) + .order_by(MovieChapter.chapter_index.asc()) + ).scalars().all() + chapters = [MovieChapterOut.model_validate(c) for c in chapter_rows] + + ref_rows = session.execute( + select(MovieExternalRef, Source.name) + .join(Source, Source.id == MovieExternalRef.source_id) + .where(MovieExternalRef.movie_id == movie.id) + ).all() + external_refs = [ + ExternalRefOut( + source=name, + external_id=ref.external_id, + url=ref.url, + last_seen=ref.last_seen, + ) + for ref, name in ref_rows + ] + + pb_rows = session.execute( + select(MoviePlaybackSource) + .where(MoviePlaybackSource.movie_id == movie.id) + .where(MoviePlaybackSource.dead_at.is_(None)) + .order_by(MoviePlaybackSource.created_at.desc()) + ).scalars().all() + pb_rows = [p for p in pb_rows if p.origin not in _MOVIE_DROP_ORIGINS] + # Bug-report 2026-05-16: raw landing origins (`mangoporn`/`pandamovies`/ + # `streamporn` BEZ `:host`) otwierały WebView z reklamami pełnoekranowymi + # i myliły usera. Ukrywamy raw landing GDY ten sam movie ma co najmniej + # jeden sub-host entry (origin zawiera `:`). Jeśli movie nie ma sub-hosters + # (bo theme HTML się zmienił lub regex nie złapał), zostawiamy landing jako + # last-resort. + has_subhost = any(":" in p.origin for p in pb_rows) + if has_subhost: + pb_rows = [p for p in pb_rows if p.origin not in _MOVIE_LANDING_HIDE] + pb_rows = sorted(pb_rows, key=lambda p: _movie_origin_priority(p.origin)) + playback_sources = [PlaybackSourceOut.model_validate(p) for p in pb_rows] + + is_fav = session.get(FavoriteMovie, movie.id) is not None + + return MovieOut( + id=movie.id, + title=movie.title, + slug=movie.slug, + release_year=movie.release_year, + release_date=movie.release_date, + duration_sec=movie.duration_sec, + description=movie.description, + director=movie.director, + country=movie.country, + rating=movie.rating, + poster_url=movie.poster_url, + backdrop_url=movie.backdrop_url, + studio=studio_out, + performers=performers, + tags=tags, + chapters=chapters, + external_refs=external_refs, + playback_sources=playback_sources, + created_at=movie.created_at, + is_favorite=is_fav, + ) diff --git a/app/api/playback.py b/app/api/playback.py new file mode 100644 index 0000000..9bc8474 --- /dev/null +++ b/app/api/playback.py @@ -0,0 +1,540 @@ +"""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 +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 +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 +nie pokaże martwego buttonu. +""" +from __future__ import annotations + +import logging +import re +import uuid +from datetime import UTC, datetime +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.api.schemas import PlaybackSourceOut +from app.auth import require_api_key +from app.db import get_session +from app.extractors import ( + HosterDead, + StreamSource, + TubePageError, + extract_stream_from_hoster, + try_extract, +) +from app.models.playback_source import PlaybackSource + +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), +# 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 +# +# 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 +_TIME_BOUND_CDN_RE = re.compile( + r"\b(?:" + r"xvideos-cdn|xnxx-cdn|phncdn|ypncdn|rdtcdn" # mainstream + r"|privatehost" # pornhat + r")\.[a-z]{2,4}" + r"|(?:^|/)(?:sxyprn\.com|[\w\-]+\.eporner\.com)/", + re.IGNORECASE, +) + +# 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. +_IP_BOUND_CDN_RE = re.compile( + r"\b(?:" + r"premilkyway\.com" # latestpornvideo + r"|tnmr\.org" # mypornerleak legacy + luluvid movies (cdn-tnmr.org) + r"|acek-cdn\.com" # mypornerleak current + r")\b", + re.IGNORECASE, +) + + +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 + 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 + `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 + otwiera w WebView. Type: 'hoster'. + """ + + stream_url: str | None = None + embed_url: str | None = None + direct_url: str | None = None + headers: dict[str, str] | None = None + quality: str | None = None + type: str | None = None # mime/ext, np. 'video/mp4', 'application/x-mpegURL' + raw: dict[str, Any] | None = None + + +class ResolveOut(BaseModel): + source: PlaybackSourceOut + best: StreamLink | None = None + links: list[StreamLink] = [] + + +movies_router = APIRouter( + prefix="/movies", tags=["movies-playback"], dependencies=[Depends(require_api_key)] +) + + +@movies_router.post("/{movie_id}/playback/{playback_id}/resolve", response_model=ResolveOut) +def resolve_movie_playback( + movie_id: uuid.UUID, + playback_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], +) -> ResolveOut: + """Movies playback resolve — analog do `/scenes/{id}/playback/{pb}/resolve`. + + Origin patterns: + - 'paradisehill' → tylko page_url (Yii2 player wymaga login session, więc + mobile dostaje page_url jako embed_url, otwiera w WebView). + - 'mangoporn:host', 'streamporn:host', 'pandamovies:host' → embed_url to URL + embedu hostera (doodstream/lulustream/rpmplay/itp.). Próbujemy lokalnie + wyciągnąć direct stream URL przez generic packer (`extract_stream_from_hoster`), + z fallback na embed-only gdy się nie uda. Mobile w PlayerScreen.WebViewMode + wyciągnie wtedy URL JS-em (jak ze scenami). + """ + from app.models.movie_playback_source import MoviePlaybackSource + + pb = session.get(MoviePlaybackSource, playback_id) + if pb is None or pb.movie_id != movie_id: + raise HTTPException(status_code=404, detail="movie playback source not found") + if pb.dead_at is not None: + raise HTTPException( + status_code=410, + detail=f"playback dead: {pb.dead_reason or 'unknown'}", + ) + + referer = pb.page_url + links: list[StreamLink] = [] + + if pb.origin == "paradisehill": + # Tylko WebView fallback — paradisehill player wymaga session login dla streamu. + links = [ + StreamLink( + stream_url=None, + embed_url=pb.page_url, + quality=pb.quality, + type="hoster", + raw={"origin": pb.origin}, + ) + ] + else: + # dooplay mirror sources: spróbuj direct stream extract z hoster URL + target = pb.embed_url or pb.page_url + stream: str | None = None + try: + stream = extract_stream_from_hoster(target, referer=referer) + except HosterDead as e: + # 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"). + pb.dead_at = datetime.now(UTC) + pb.dead_reason = str(e)[:512] + session.commit() + log.info("marked movie 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 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, + # mobile direct = 403. Skip stream, fallback na embed_url (mobile WebView). + log.info( + "movie playback %s: stream URL IP-bound CDN — skip, WebView fallback", + pb.id, + ) + stream = None + if stream: + type_hint = "m3u8" if ".m3u8" in stream.lower() else "mp4" + # Hostery których CDN wymaga Chrome JA3 (mxcontent dla mixdrop): + # proxy MUSI użyć curl_cffi impersonate inaczej 403. `proxy_impersonate=True` + # idzie przez `raw` → `_proxify_link` ustawi token `i=1`. + cdn_needs_impersonate = "mxcontent.net" in stream.lower() + raw_meta: dict = {"origin": pb.origin, "host": target} + if cdn_needs_impersonate: + raw_meta["proxy_impersonate"] = True + # Mixdrop: same-session cookies + chrome JA3 wymagane dla mp4. + # Backend extract zamknął sesję — proxy musi re-fetchować + # embed page w fresh curl_cffi session żeby re-extract mp4 + # z aktualnymi cookies. + raw_meta["refetch_url"] = target + raw_meta["refetch_hoster"] = "mixdrop" + links.append( + StreamLink( + stream_url=stream, + embed_url=None, + quality=pb.quality, + type=type_hint, + raw=raw_meta, + ) + ) + # Zawsze dorzucamy embed jako fallback — mobile WebView może wyłapać URL z JS-a + if pb.embed_url: + links.append( + StreamLink( + stream_url=None, + embed_url=pb.embed_url, + quality=pb.quality, + type="hoster", + raw={"origin": pb.origin}, + ) + ) + + if not links: + raise HTTPException(status_code=502, detail="no playable links") + + links = [_proxify_link(link, referer) for link in links] + best = _pick_best(links) if links else None + return ResolveOut( + source=PlaybackSourceOut.model_validate(pb), + best=best, + links=links, + ) + + +def _requester_tag(request: Request) -> str: + """Audit tag dla mark-dead: IP (X-Forwarded-For preferred dla nginx proxy) + + skrócony User-Agent. Zapisywane w dead_reason + log dla post-mortem + gdyby leaked APK key był używany do masowego psucia danych.""" + fwd = request.headers.get("x-forwarded-for", "") + ip = fwd.split(",")[0].strip() if fwd else (request.client.host if request.client else "?") + ua = (request.headers.get("user-agent") or "")[:40] + return f"ip={ip} ua={ua}" + + +@router.post( + "/{scene_id}/playback/{playback_id}/mark-dead", + status_code=status.HTTP_204_NO_CONTENT, +) +def mark_playback_dead( + scene_id: uuid.UUID, + playback_id: uuid.UUID, + request: Request, + session: Annotated[Session, Depends(get_session)], +) -> None: + """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 + flow działał tylko dla resolve failures (HosterDead/TubePageError). User + może teraz oznaczać linki które działają backendowi (200 OK) ale są broken + w praktyce (np. źle zmatchowana scena, ad-redirect, hoster zwraca placeholder). + + Audit: zapisujemy requester IP+UA w dead_reason+log żeby leaked APK key + nie mógł silently masowo niszczyć danych bez ścieżki dochodzenia. + """ + pb = session.get(PlaybackSource, playback_id) + if pb is None or pb.scene_id != scene_id: + raise HTTPException(status_code=404, detail="playback source not found for scene") + if pb.dead_at is None: + tag = _requester_tag(request) + pb.dead_at = datetime.now(UTC) + pb.dead_reason = f"user-marked dead (mobile long-press) {tag}"[:512] + session.commit() + log.info("user marked playback %s dead (origin=%s %s)", pb.id, pb.origin, tag) + + +@movies_router.post( + "/{movie_id}/playback/{playback_id}/mark-dead", + status_code=status.HTTP_204_NO_CONTENT, +) +def mark_movie_playback_dead( + movie_id: uuid.UUID, + playback_id: uuid.UUID, + request: Request, + session: Annotated[Session, Depends(get_session)], +) -> None: + """User-triggered mark dead dla movie playback (long-press w MovieDetail).""" + from app.models.movie_playback_source import MoviePlaybackSource + + pb = session.get(MoviePlaybackSource, playback_id) + if pb is None or pb.movie_id != movie_id: + raise HTTPException(status_code=404, detail="movie playback source not found") + if pb.dead_at is None: + tag = _requester_tag(request) + pb.dead_at = datetime.now(UTC) + pb.dead_reason = f"user-marked dead (mobile long-press) {tag}"[:512] + session.commit() + log.info("user marked movie playback %s dead (origin=%s %s)", pb.id, pb.origin, tag) + + +@router.post("/{scene_id}/playback/{playback_id}/resolve", response_model=ResolveOut) +def resolve_playback( + scene_id: uuid.UUID, + playback_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], +) -> ResolveOut: + pb = session.get(PlaybackSource, playback_id) + if pb is None or pb.scene_id != scene_id: + raise HTTPException(status_code=404, detail="playback source not found for scene") + if pb.dead_at is not None: + raise HTTPException( + status_code=410, + detail=f"playback source marked dead: {pb.dead_reason or 'unknown'}", + ) + + page_url = pb.page_url + + sitetag: str | None = None + if pb.origin.startswith("pornapp:"): + # 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] + + if sitetag is None: + raise HTTPException( + status_code=501, + detail=f"resolve not implemented for origin '{pb.origin}'", + ) + + try: + sources = try_extract(sitetag, page_url) + except HosterDead as e: + pb.dead_at = datetime.now(UTC) + pb.dead_reason = str(e)[:512] + session.commit() + 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. + if e.status_code in (404, 410): + reason = f"tube page {e.status_code} {pb.page_url}" + pb.dead_at = datetime.now(UTC) + pb.dead_reason = reason[:512] + session.commit() + log.info("marked playback %s dead (origin=%s reason=%s)", pb.id, pb.origin, reason) + raise HTTPException(status_code=410, detail=f"playback dead: {reason}") from e + log.warning("tube fetch http error %s for %s", e.status_code, pb.page_url) + raise HTTPException( + status_code=502, + detail=f"tube fetch failed: HTTP {e.status_code}", + ) from e + + if not sources: + # 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). + # + # Permanent dead idzie TYLKO z explicit signals: + # - HosterDead exception (hoster page mówi "video deleted") + # - TubePageError 404/410 (page nie istnieje) + # Reszta = transient, mobile dostaje 501 → user może retry. + log.info( + "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 + # jest dla permanent removal. 503 = transient, user może retry. + # 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, + detail="extraction failed temporarily — retry possible", + ) + + # Per-source referer: niektóre extractory (yt-dlp, embed-iframe) zwracają stream + # URL którego CDN expectuje Referera embed page'a (host iframe), nie oryginalnej + # strony tube'a. Np. 0dayxx page → watchporn.to/embed iframe → stream URL chce + # `Referer: watchporn.to/` (z `Referer: 0dayxx.com` CDN zwraca 410). StreamSource. + # referer trzyma tę informację; fallback na page_url gdy extractor nie ustawił. + proxified: list[StreamLink] = [] + for s in sources: + link = _stream_source_to_link(s) + proxified.append(_proxify_link(link, s.referer or page_url)) + links = proxified + best = _pick_best(links) if links else None + return ResolveOut( + source=PlaybackSourceOut.model_validate(pb), + best=best, + links=links, + ) + + +DEFAULT_PLAYER_UA = ( + "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36" +) + + +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) + - `headers`: Referer + User-Agent dla direct fetch + Mobile player próbuje direct PIERWSZY, fallback na stream_url po błędzie 403/410. + """ + if not link.stream_url: + return link + from app.api.stream_proxy import make_token + + raw_url = link.stream_url + # Extractor flags w raw: + # - proxy_impersonate: curl_cffi chrome JA3 (mxcontent etc.) + # - refetch_url: embed URL do re-extract gdy token expired (same-session + # cookie binding dla mixdrop). Bez tego mp4 token + brak cookies → 403. + 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ć. + # 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. + # 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") + token = make_token( + raw_url, referer, impersonate=use_impersonate, + refresh=refetch_url, refresh_hoster=refetch_hoster, + ) + # Decyzja na BASIE link.type (zaufanie do extractora), z fallback path-hint. + # Pornhat: raw URL `.../get_file/.../.mp4/` ale CDN 302 → HLS manifest. + # Extractor markuje type='m3u8' żeby ExoPlayer użył HlsMediaSource (bez tego + # path `.mp4` mylił player → "no extractors"). + type_lower = (link.type or "").lower() + if type_lower in {"m3u8", "hls", "mpd"}: + ext = "m3u8" if type_lower in {"m3u8", "hls"} else "mpd" + elif ".m3u8" in raw_url.lower(): + ext = "m3u8" + elif ".mpd" in raw_url.lower(): + ext = "mpd" + else: + ext = "mp4" + proxied = f"/proxy/{token}/play.{ext}" + # `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 + # → 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 + # → 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. + is_manifest_type = type_lower in {"m3u8", "hls", "mpd"} + if use_impersonate or force_proxy or (is_manifest_type and not mobile_direct_ok): + direct_for_mobile = proxied + else: + direct_for_mobile = raw_url + return StreamLink( + stream_url=proxied, + embed_url=link.embed_url, + direct_url=direct_for_mobile, + headers={"Referer": referer, "User-Agent": DEFAULT_PLAYER_UA}, + quality=link.quality, + type=link.type, + raw=link.raw, + ) + + +def _stream_source_to_link(s: StreamSource) -> StreamLink: + """Mapowanie StreamSource (z extractorów) na StreamLink (response API). + + Hoster type → embed_url (mobile otworzy WebView). mp4/m3u8/mpd → stream_url + (mobile odtworzy w native playerze przez /proxy). + """ + is_hoster = (s.type or "").lower() == "hoster" + return StreamLink( + stream_url=None if is_hoster else s.link, + embed_url=s.link if is_hoster else None, + quality=s.quality, + type=s.type, + raw=s.raw, + ) + + +def _pick_best(links: list[StreamLink]) -> StreamLink | None: + """Wybiera najlepszą jakość. Preferencje: + 1. Najpierw direct video (`stream_url` niepuste); fallback na embed-only gdy żaden + nie ma direct (mobile pokaże "Open in browser"). + 2. Najwyższe quality (parsowane jako int z '720p' / '1080p' / '4k') + 3. Preferuj mp4 nad m3u8 jeśli ten sam quality (mp4 łatwiejsze dla MX Player) + """ + direct = [link for link in links if link.stream_url] + pool = direct or [link for link in links if link.embed_url] + if not pool: + return None + + def score(link: StreamLink) -> tuple[int, int]: + q_int = _quality_to_int(link.quality) + url_low = (link.stream_url or link.embed_url or "").lower() + type_low = (link.type or "").lower() + is_mp4 = ".mp4" in url_low or "mp4" in type_low or "direct" in type_low + type_priority = 1 if is_mp4 else 0 + return (q_int, type_priority) + + return max(pool, key=score) + + +_QUALITY_DIGITS_RE = re.compile(r"\d+") + + +def _quality_to_int(q: str | None) -> int: + """Wyciąga liczbę pikseli z różnych formatów: '720p', '1080p Full HD', '4K', 'HD'.""" + if not q: + return 0 + s = q.lower().strip() + if "4k" in s or "uhd" in s: + return 2160 + if "2k" in s or "qhd" in s: + return 1440 + m = _QUALITY_DIGITS_RE.search(s) + if m: + return int(m.group(0)) + if "fhd" in s: + return 1080 + if "hd" in s: + return 720 + if "sd" in s: + return 480 + return 0 diff --git a/app/api/scene_favorites.py b/app/api/scene_favorites.py new file mode 100644 index 0000000..9700609 --- /dev/null +++ b/app/api/scene_favorites.py @@ -0,0 +1,83 @@ +"""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ń +""" +from __future__ import annotations + +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.api.scenes import _build_scene_out +from app.api.schemas import SceneOut +from app.auth import require_api_key +from app.db import get_session +from app.models.favorite_scene import FavoriteScene +from app.models.scene import Scene + +router = APIRouter( + prefix="/scene-favorites", + tags=["scene-favorites"], + dependencies=[Depends(require_api_key)], +) + + +class SceneFavoriteListOut(BaseModel): + items: list[SceneOut] + total: int + + +class SceneFavoriteToggleOut(BaseModel): + scene_id: uuid.UUID + favorited: bool + + +@router.get("", response_model=SceneFavoriteListOut) +def list_scene_favorites( + session: Annotated[Session, Depends(get_session)], +) -> SceneFavoriteListOut: + rows = ( + session.execute( + select(Scene, FavoriteScene) + .join(FavoriteScene, FavoriteScene.scene_id == Scene.id) + .order_by(FavoriteScene.created_at.desc()) + ) + .all() + ) + items = [_build_scene_out(session, scene) for scene, _ in rows] + return SceneFavoriteListOut(items=items, total=len(items)) + + +@router.post( + "/{scene_id}", + response_model=SceneFavoriteToggleOut, + status_code=status.HTTP_201_CREATED, +) +def add_scene_favorite( + scene_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], +) -> SceneFavoriteToggleOut: + scene = session.get(Scene, scene_id) + if scene is None: + raise HTTPException(status_code=404, detail="scene not found") + existing = session.get(FavoriteScene, scene_id) + if existing is None: + session.add(FavoriteScene(scene_id=scene_id)) + return SceneFavoriteToggleOut(scene_id=scene_id, favorited=True) + + +@router.delete("/{scene_id}", status_code=status.HTTP_204_NO_CONTENT) +def remove_scene_favorite( + scene_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], +) -> None: + fav = session.get(FavoriteScene, scene_id) + if fav is not None: + session.delete(fav) diff --git a/app/api/scenes.py b/app/api/scenes.py new file mode 100644 index 0000000..4a75c8f --- /dev/null +++ b/app/api/scenes.py @@ -0,0 +1,960 @@ +"""GET /scenes — lista i szczegóły scen z bazy kanonicznej.""" +from __future__ import annotations + +import logging +import re +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel +from sqlalchemy import distinct, exists, func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.auth import require_api_key + +from app.api.schemas import ( + ExternalRefOut, + PerformerOut, + PlaybackSourceOut, + SceneListOut, + SceneOut, + StudioOut, + TagOut, +) +from app.db import get_session +from app.models.favorite_scene import FavoriteScene +from app.models.performer import Performer +from app.models.play_progress import ScenePlayProgress +from app.models.playback_source import PlaybackSource +from app.models.scene import Scene, SceneExternalRef, ScenePerformer, SceneTag +from app.models.source import Source, SourceKind +from app.models.studio import Studio +from app.models.tag import Tag + +log = logging.getLogger(__name__) + +router = APIRouter(prefix="/scenes", tags=["scenes"], dependencies=[Depends(require_api_key)]) + + +_VALID_SORTS = {"created_at", "release_date", "title", "studio"} + + +def _split_csv(raw: str | None) -> list[str]: + if not raw: + return [] + return [s.strip() for s in raw.split(",") if s.strip()] + + +@router.get("", response_model=SceneListOut) +def list_scenes( + session: Annotated[Session, Depends(get_session)], + q: str | None = Query(default=None, description="Wyszukiwanie po title_normalized (trgm)"), + studio_slug: str | None = Query(default=None, description="DEPRECATED — użyj studio_slugs"), + studio_slugs: str | None = Query( + default=None, description="Comma-separated studio slugs (OR)" + ), + tags: str | None = Query( + default=None, + description="Comma-separated tag slugs (AND — scena musi mieć wszystkie wybrane tagi)", + ), + performer_ids: str | None = Query( + default=None, + description="Comma-separated performer UUIDs (AND — scena musi mieć wszystkich wybranych performerów)", + ), + has_playback: bool | None = Query( + default=None, description="True: tylko sceny z ≥1 playback_source" + ), + has_animated_thumbnail: bool | None = Query( + default=None, + description="True: tylko sceny z ≥1 playback_source z animated_thumbnail_url (hold-to-preview)", + ), + min_duration_sec: int | None = Query(default=None, ge=0), + max_duration_sec: int | None = Query(default=None, ge=0), + released_within_days: int | None = Query( + default=None, ge=1, + description="Tylko sceny released w ostatnich N dniach", + ), + min_quality_p: int | None = Query( + default=None, ge=1, + description=( + "Minimum quality (pixele wysokości — 2160 = 4K, 1080 = FullHD). Filtruje " + "po PlaybackSource.quality (string typu '720p' / '1080p Full HD')." + ), + ), + include_stubs: bool = Query( + default=False, + description=( + "False (default): ukrywa sceny-szkielety bez release_date, < 10min, " + "z jedynym playback z hqporner (~7-min Brazzers trailer clipy zalewają katalog)." + ), + ), + sort: str = Query(default="created_at", description="created_at|release_date|title|studio"), + page: int = Query(default=1, ge=1), + per_page: int = Query(default=50, ge=1, le=200), +) -> SceneListOut: + if sort not in _VALID_SORTS: + raise HTTPException(status_code=400, detail=f"sort must be one of {sorted(_VALID_SORTS)}") + + base = select(Scene) + + if q: + base = base.where(Scene.title_normalized.ilike(f"%{q.lower()}%")) + + studio_slug_list = _split_csv(studio_slugs) + if studio_slug: + studio_slug_list.append(studio_slug) + if studio_slug_list: + base = base.where( + Scene.studio_id.in_( + select(Studio.id).where(Studio.slug.in_(studio_slug_list)) + ) + ) + + tag_slug_list = _split_csv(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. + for slug in tag_slug_list: + base = base.where( + exists( + select(1) + .select_from(SceneTag) + .join(Tag, Tag.id == SceneTag.tag_id) + .where(SceneTag.scene_id == Scene.id, Tag.slug == slug) + ) + ) + + perf_id_strings = _split_csv(performer_ids) + if perf_id_strings: + try: + perf_ids = [uuid.UUID(s) for s in perf_id_strings] + except ValueError as e: + raise HTTPException(status_code=400, detail=f"invalid performer UUID: {e}") from e + # AND między performerami (analogicznie do tagów). + for pid in perf_ids: + base = base.where( + exists( + select(1) + .select_from(ScenePerformer) + .where( + ScenePerformer.scene_id == Scene.id, + ScenePerformer.performer_id == pid, + ) + ) + ) + + if has_playback is True: + # Tylko sceny z choć jednym ŻYWYM playback_source. + base = base.where( + exists( + select(1).where( + PlaybackSource.scene_id == Scene.id, + PlaybackSource.dead_at.is_(None), + ) + ) + ) + elif has_playback is False: + base = base.where( + ~exists( + select(1).where( + PlaybackSource.scene_id == Scene.id, + PlaybackSource.dead_at.is_(None), + ) + ) + ) + + # Blacklisty — globalne wykluczenia. Jeśli scena ma JAKIEGOKOLWIEK blacklisted + # performera, jest na blacklisted studio, lub ma JAKIKOLWIEK blacklisted tag → out. + from app.models.blacklist import ( + BlacklistedPerformer, + BlacklistedStudio, + BlacklistedTag, + ) + base = base.where( + ~exists( + select(1) + .select_from(ScenePerformer) + .join(BlacklistedPerformer, BlacklistedPerformer.performer_id == ScenePerformer.performer_id) + .where(ScenePerformer.scene_id == Scene.id) + ) + ) + base = base.where( + ~Scene.studio_id.in_(select(BlacklistedStudio.studio_id)) + ) + base = base.where( + ~exists( + select(1) + .select_from(SceneTag) + .join(BlacklistedTag, BlacklistedTag.tag_id == SceneTag.tag_id) + .where(SceneTag.scene_id == Scene.id) + ) + ) + + if has_animated_thumbnail: + base = base.where( + exists( + select(1).where( + PlaybackSource.scene_id == Scene.id, + PlaybackSource.dead_at.is_(None), + PlaybackSource.animated_thumbnail_url.isnot(None), + ) + ) + ) + + if min_duration_sec is not None: + base = base.where(Scene.duration_sec >= min_duration_sec) + if max_duration_sec is not None: + base = base.where(Scene.duration_sec <= max_duration_sec) + + if released_within_days is not None: + from datetime import date, timedelta + cutoff = date.today() - timedelta(days=released_within_days) + base = base.where(Scene.release_date >= cutoff) + + if min_quality_p is not None: + # PlaybackSource.quality to wolny string — szukamy liczb w prefixie ('1080p', + # '1080p Full HD', '2160p'). Heurystyka: wystarczy że scena ma JEDEN żywy + # playback z quality liczbą >= min. '4K'/'UHD' aliasujemy na 2160. + from sqlalchemy import Integer, cast, or_ + numeric_q = cast( + func.coalesce(func.substring(PlaybackSource.quality, r"\d+"), "0"), + Integer, + ) + conds = [numeric_q >= min_quality_p] + if min_quality_p <= 2160: + conds.append(PlaybackSource.quality.ilike("%4k%")) + conds.append(PlaybackSource.quality.ilike("%uhd%")) + base = base.where( + exists( + select(1).where( + PlaybackSource.scene_id == Scene.id, + PlaybackSource.dead_at.is_(None), + PlaybackSource.quality.isnot(None), + or_(*conds), + ) + ) + ) + + if not include_stubs: + # Stub scene heuristic: tube-only scena BEZ release_date AND BEZ canonical + # (TPDB/StashDB) ref AND BEZ żadnego ScenePerformer linka. ScenePerformer + # dodaje continuous worker (search-by-name → wymusza link), więc per-performer + # search-result NIGDY nie jest stub. To filtruje tylko anonymous tube-only + # sceny z newUrl/categories ingestu które nie zostały zsyntowane z performerem. + canonical_exists = exists( + select(1) + .select_from(SceneExternalRef) + .join(Source, Source.id == SceneExternalRef.source_id) + .where(SceneExternalRef.scene_id == Scene.id) + .where(Source.kind.in_([SourceKind.tpdb, SourceKind.stashdb])) + ) + has_performer = exists( + select(1).where(ScenePerformer.scene_id == Scene.id) + ) + # NOT stub gdy: ma canonical_ref OR ma release_date OR ma performera + base = base.where( + Scene.release_date.is_not(None) | canonical_exists | has_performer + ) + + # Count: dla dużych baz (~400k scen) pełny count z 3 nested EXISTS bierze ~5s. + # Liczymy total na uproszczonym query (bez stub-filter w count) — daje ~5% off + # ale jest akceptowalne dla user-facing pagination header. Items query NADAL + # ma stub-filter, więc lista pokazuje poprawne sceny. Liczba w header jest + # przybliżoną górną granicą — co dla 400k scen i tak nie ma sensu reading dokładnie. + if not include_stubs and not q and not studio_slug_list and not tags and not perf_id_strings: + # Fast path: typowy default request (lista bez filtra) — count tylko po + # has_playback (single EXISTS, dobrze zindeksowany). + count_query = select(func.count()).select_from( + select(Scene.id).where( + exists( + select(1).where( + PlaybackSource.scene_id == Scene.id, + PlaybackSource.dead_at.is_(None), + ) + ) + ).subquery() + ) + total = session.execute(count_query).scalar_one() + else: + total = session.execute(select(func.count()).select_from(base.subquery())).scalar_one() + + # Sort: zawsze tie-break po created_at desc dla determinizmu paginacji. + if sort == "release_date": + ordered = base.order_by( + Scene.release_date.desc().nullslast(), Scene.created_at.desc() + ) + elif sort == "title": + ordered = base.order_by(Scene.title_normalized.asc(), Scene.created_at.desc()) + elif sort == "studio": + # Sceny bez studio na końcu; w obrębie studio — najświeższe pierwsze. + ordered = ( + base.outerjoin(Studio, Studio.id == Scene.studio_id) + .order_by( + Studio.name_normalized.asc().nullslast(), + Scene.release_date.desc().nullslast(), + Scene.created_at.desc(), + ) + ) + else: # created_at + ordered = base.order_by( + Scene.created_at.desc(), Scene.release_date.desc().nullslast() + ) + + rows = ( + session.execute(ordered.offset((page - 1) * per_page).limit(per_page)) + .scalars() + .all() + ) + + items = _build_scenes_out_batch(session, list(rows)) + + return SceneListOut(items=items, total=total, page=page, per_page=per_page) + + +@router.get("/{scene_id}", response_model=SceneOut) +def get_scene( + scene_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], +) -> SceneOut: + scene = session.get(Scene, scene_id) + if scene is None: + raise HTTPException(status_code=404, detail="scene not found") + return _build_scene_out(session, scene) + + +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. + 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 + są stabilne, krótkie ttl by tylko niepotrzebnie zaśmiecało cache.""" + from app.api.stream_proxy import make_token + token = make_token(url, referer, ttl_sec=30 * 24 * 3600) + # Path zachowuje rozszerzenie żeby HTTP Content-Type był rozpoznany. + import os as _os + ext = _os.path.splitext(url.split("?")[0])[1].lstrip(".") or "jpg" + return f"/proxy/img/{token}/img.{ext}" + + +def _build_scenes_out_batch(session: Session, scenes: list[Scene]) -> 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 + 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. + """ + from collections import defaultdict + if not scenes: + return [] + + scene_ids = [s.id for s in scenes] + studio_ids = list({s.studio_id for s in scenes if s.studio_id is not None}) + + # 1) Studios + studios_by_id: dict = {} + if studio_ids: + for st in session.execute( + select(Studio).where(Studio.id.in_(studio_ids)) + ).scalars(): + studios_by_id[st.id] = st + + # 2) Performers + perf_rows = session.execute( + select(ScenePerformer, Performer) + .join(Performer, Performer.id == ScenePerformer.performer_id) + .where(ScenePerformer.scene_id.in_(scene_ids)) + .order_by(ScenePerformer.position.asc().nullslast()) + ).all() + performers_by_scene: dict = defaultdict(list) + for sp, p in perf_rows: + performers_by_scene[sp.scene_id].append( + PerformerOut( + id=p.id, + canonical_name=p.canonical_name, + slug=p.slug, + gender=p.gender.value if p.gender else None, + as_alias=sp.as_alias, + ) + ) + + # 3) Tags + tag_rows = session.execute( + select(SceneTag.scene_id, Tag) + .join(Tag, Tag.id == SceneTag.tag_id) + .where(SceneTag.scene_id.in_(scene_ids)) + ).all() + tags_by_scene: dict = defaultdict(list) + for sid, t in tag_rows: + tags_by_scene[sid].append(TagOut.model_validate(t)) + + # 4) External refs + sources + ref_rows = session.execute( + select(SceneExternalRef, Source) + .join(Source, Source.id == SceneExternalRef.source_id) + .where(SceneExternalRef.scene_id.in_(scene_ids)) + ).all() + refs_by_scene: dict = defaultdict(list) + for ref, src in ref_rows: + refs_by_scene[ref.scene_id].append( + ExternalRefOut( + source=src.name, + external_id=ref.external_id, + url=ref.url, + last_seen=ref.last_seen, + ) + ) + + # 5) Playback sources + pb_rows = session.execute( + select(PlaybackSource) + .where( + PlaybackSource.scene_id.in_(scene_ids), + PlaybackSource.dead_at.is_(None), + ) + .order_by(PlaybackSource.origin.asc()) + ).scalars().all() + pb_by_scene: dict = defaultdict(list) + for p in pb_rows: + out = PlaybackSourceOut.model_validate(p) + if out.thumbnail_url and _needs_proxy(out.thumbnail_url): + out.thumbnail_url = _wrap_image_proxy(out.thumbnail_url, p.page_url) + if out.animated_thumbnail_url and _needs_proxy(out.animated_thumbnail_url): + out.animated_thumbnail_url = _wrap_image_proxy(out.animated_thumbnail_url, p.page_url) + pb_by_scene[p.scene_id].append(out) + + # 6) Progress + progress_by_scene: dict = {} + for prog in session.execute( + select(ScenePlayProgress).where(ScenePlayProgress.scene_id.in_(scene_ids)) + ).scalars(): + progress_by_scene[prog.scene_id] = prog + + # 7) Favorites + fav_scene_ids: set = set( + session.execute( + select(FavoriteScene.scene_id).where( + FavoriteScene.scene_id.in_(scene_ids) + ) + ).scalars() + ) + + out: list[SceneOut] = [] + for scene in scenes: + studio_out = None + if scene.studio_id is not None and scene.studio_id in studios_by_id: + studio_out = StudioOut.model_validate(studios_by_id[scene.studio_id]) + progress = progress_by_scene.get(scene.id) + out.append( + SceneOut( + id=scene.id, + title=scene.title, + slug=scene.slug, + release_date=scene.release_date, + duration_sec=scene.duration_sec, + description=scene.description, + code=scene.code, + director=scene.director, + studio=studio_out, + performers=performers_by_scene.get(scene.id, []), + tags=tags_by_scene.get(scene.id, []), + external_refs=refs_by_scene.get(scene.id, []), + playback_sources=pb_by_scene.get(scene.id, []), + created_at=scene.created_at, + last_played_at=progress.last_played_at if progress else None, + finished=progress.finished if progress else False, + position_sec=progress.position_sec if progress else 0, + is_favorite=scene.id in fav_scene_ids, + ) + ) + return out + + +def _build_scene_out(session: Session, scene: Scene) -> SceneOut: + studio_out: StudioOut | None = None + if scene.studio_id is not None: + st = session.get(Studio, scene.studio_id) + if st is not None: + studio_out = StudioOut.model_validate(st) + + performer_rows = session.execute( + select(ScenePerformer, Performer) + .join(Performer, Performer.id == ScenePerformer.performer_id) + .where(ScenePerformer.scene_id == scene.id) + .order_by(ScenePerformer.position.asc().nullslast()) + ).all() + performers_out: list[PerformerOut] = [] + for sp, performer in performer_rows: + performers_out.append( + PerformerOut( + id=performer.id, + canonical_name=performer.canonical_name, + slug=performer.slug, + gender=performer.gender.value if performer.gender else None, + as_alias=sp.as_alias, + ) + ) + + tag_rows = ( + session.execute( + select(Tag).join(SceneTag, SceneTag.tag_id == Tag.id).where(SceneTag.scene_id == scene.id) + ) + .scalars() + .all() + ) + tags_out = [TagOut.model_validate(t) for t in tag_rows] + + ref_rows = session.execute( + select(SceneExternalRef, Source) + .join(Source, Source.id == SceneExternalRef.source_id) + .where(SceneExternalRef.scene_id == scene.id) + ).all() + refs_out = [ + ExternalRefOut( + source=src.name, + external_id=ref.external_id, + url=ref.url, + last_seen=ref.last_seen, + ) + for ref, src in ref_rows + ] + + playback_rows = ( + session.execute( + select(PlaybackSource) + .where( + PlaybackSource.scene_id == scene.id, + PlaybackSource.dead_at.is_(None), # ukryj martwe linki + ) + .order_by(PlaybackSource.origin.asc()) + ) + .scalars() + .all() + ) + playback_out: list[PlaybackSourceOut] = [] + for p in playback_rows: + out = PlaybackSourceOut.model_validate(p) + # Wrap thumbnail URL-e przez backend image proxy gdy CDN wymaga Refera + # (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) + if out.animated_thumbnail_url and _needs_proxy(out.animated_thumbnail_url): + out.animated_thumbnail_url = _wrap_image_proxy(out.animated_thumbnail_url, p.page_url) + playback_out.append(out) + + progress = session.get(ScenePlayProgress, scene.id) + is_fav = session.get(FavoriteScene, scene.id) is not None + + return SceneOut( + id=scene.id, + title=scene.title, + slug=scene.slug, + release_date=scene.release_date, + duration_sec=scene.duration_sec, + description=scene.description, + code=scene.code, + director=scene.director, + studio=studio_out, + performers=performers_out, + tags=tags_out, + external_refs=refs_out, + playback_sources=playback_out, + created_at=scene.created_at, + last_played_at=progress.last_played_at if progress else None, + finished=progress.finished if progress else False, + position_sec=progress.position_sec if progress else 0, + is_favorite=is_fav, + ) + + +@router.delete("/{scene_id}/tags/{tag_id}", status_code=status.HTTP_204_NO_CONTENT) +def remove_tag_from_scene( + scene_id: uuid.UUID, + tag_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], +) -> 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ą + z niego korzystać. Sam tag zostaje w słowniku tagów. + """ + rel = session.execute( + select(SceneTag).where(SceneTag.scene_id == scene_id, SceneTag.tag_id == tag_id) + ).scalar_one_or_none() + if rel is None: + return + session.delete(rel) + session.commit() + + +@router.delete( + "/{scene_id}/performers/{performer_id}", status_code=status.HTTP_204_NO_CONTENT +) +def remove_performer_from_scene( + scene_id: uuid.UUID, + performer_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], +) -> None: + """Usuwa relację scene↔performer (false-match dedup zostawił nie tą osobę). + + Idempotent. Sama Performer zostaje. Użyteczne np. gdy fuzzy match aliasu + "Bella" wciągnął Anna Bella sceny pod Bad Bella, lub Miss Teela na xnxx + została przypisana do scen w których jej nie ma (zgłoszenia 2026-05-10). + """ + from app.models.scene import ScenePerformer + + rel = session.execute( + select(ScenePerformer).where( + ScenePerformer.scene_id == scene_id, + ScenePerformer.performer_id == performer_id, + ) + ).scalar_one_or_none() + if rel is None: + return + session.delete(rel) + session.commit() + + +class EnrichTagsOut(BaseModel): + scene_id: uuid.UUID + added: int + tube_used: str | None + tags: list[str] + + +@router.post("/{scene_id}/enrich-tags", response_model=EnrichTagsOut) +def enrich_tags_from_tube( + scene_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], +) -> EnrichTagsOut: + """Pobiera page HTML z dowolnego tube playback_source dla tej sceny i scrape'uje + tagi (categories/tags). Dodaje brakujące do scene_tags. + + Mobile wywołuje to przy otwarciu SceneDetail jeśli scena ma 0 tagów AND ma + tube source z obsługiwanym extractorem (porntrex/youporn/xvideos/xnxx/redtube/ + xhamster/eporner). + + Idempotent: ponowne wywołanie z tymi samymi tagami nic nie robi (UNIQUE PK + scene_tags). Konkretne tube źródło wybierane wg priority listy (mainstream + bardziej rzetelne niż aggregator). + """ + from app.extractors._fetch import browser_get + from app.extractors._models import TubePageError + from app.extractors.tag_extract import EXTRACTORS, extract_tags + from app.models.playback_source import PlaybackSource + from app.models.tag import Tag + from app.normalize.scenes import NormalizedTag + from app.normalize.text import slugify + from app.resolve.tag_resolver import resolve_tag + + scene = session.get(Scene, scene_id) + if scene is None: + raise HTTPException(status_code=404, detail="scene not found") + + # Priority: mainstream tubes (bogate metadane) > niche (mniej tagów albo garbage). + PRIORITY = ["xhamstercom", "porntrexcom", "epornercom", "youporncom", + "xvideoscom", "xnxxcom", "redtubecom", "pornhatcom"] + sources = session.execute( + select(PlaybackSource).where( + PlaybackSource.scene_id == scene_id, + PlaybackSource.dead_at.is_(None), + ) + ).scalars().all() + + # Wybierz pierwsze źródło wg priority listy które ma supported extractor + chosen: PlaybackSource | None = None + for tag in PRIORITY: + for src in sources: + if src.origin == f"tube:{tag}": + chosen = src + break + if chosen: + break + if chosen is None: + # Fallback: dowolne źródło z extractorem + for src in sources: + if src.origin.startswith("tube:"): + sitetag = src.origin.split(":", 1)[1] + if sitetag in EXTRACTORS: + chosen = src + break + + if chosen is None: + return EnrichTagsOut(scene_id=scene_id, added=0, tube_used=None, tags=[]) + + sitetag = chosen.origin.split(":", 1)[1] + try: + r = browser_get(chosen.page_url, timeout=15.0, follow_redirects=True) + r.raise_for_status() + except (TubePageError, Exception) as e: + log.warning("enrich-tags fetch failed for %s: %s", chosen.page_url, e) + return EnrichTagsOut(scene_id=scene_id, added=0, tube_used=sitetag, tags=[]) + + tag_names = extract_tags(sitetag, r.text) + if not tag_names: + return EnrichTagsOut(scene_id=scene_id, added=0, tube_used=sitetag, tags=[]) + + # 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 + # (scene_id, tag_id), drugi flush dostaje UniqueViolation (GOON-H, 4 events + # w 10h mimo wcześniejszego seen_tag_ids fix). ON CONFLICT skip'uje silently. + from sqlalchemy.dialects.postgresql import insert as pg_insert + added = 0 + seen_tag_ids: set = set() + for name in tag_names: + norm = NormalizedTag(name=name, slug=slugify(name), external_id=None) + tag = resolve_tag(session, norm=norm) + if tag is None or tag.id in seen_tag_ids: + continue + seen_tag_ids.add(tag.id) + stmt = ( + pg_insert(SceneTag.__table__) + .values(scene_id=scene_id, tag_id=tag.id, source_id=None) + .on_conflict_do_nothing(index_elements=["scene_id", "tag_id"]) + ) + result = session.execute(stmt) + # rowcount == 1 gdy faktycznie wstawiony, 0 gdy ON CONFLICT skip + if result.rowcount and result.rowcount > 0: + added += 1 + session.commit() + return EnrichTagsOut(scene_id=scene_id, added=added, tube_used=sitetag, tags=tag_names) + + +class EnrichDurationOut(BaseModel): + scene_id: uuid.UUID + duration_sec: int | None + tube_used: str | None + + +@router.post("/{scene_id}/enrich-duration", response_model=EnrichDurationOut) +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 + 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 + niego sceny z weak title-only score są capowane na 0.85 (review queue). + + Idempotent: zwraca aktualne duration_sec jeśli już ustawione. + """ + from app.extractors._fetch import browser_get + from app.extractors._models import TubePageError + from app.extractors.duration_extract import extract_duration_sec + from app.models.playback_source import PlaybackSource + + scene = session.get(Scene, scene_id) + if scene is None: + raise HTTPException(status_code=404, detail="scene not found") + + if scene.duration_sec is not None: + return EnrichDurationOut( + scene_id=scene_id, duration_sec=scene.duration_sec, tube_used=None + ) + + sources = session.execute( + select(PlaybackSource).where( + PlaybackSource.scene_id == scene_id, + PlaybackSource.dead_at.is_(None), + PlaybackSource.origin.like("tube:%"), + ) + ).scalars().all() + + for src in sources: + try: + r = browser_get(src.page_url, timeout=15.0, follow_redirects=True) + r.raise_for_status() + except (TubePageError, Exception) as e: + log.debug("enrich-duration fetch failed for %s: %s", src.page_url, e) + continue + d = extract_duration_sec(r.text) + if d is not None and d > 0: + scene.duration_sec = d + # Zapisz też na poziomie playback_source dla parity (przyda się jeśli + # potem dorobimy per-source duration mismatch detection). + if src.duration_sec is None: + src.duration_sec = d + session.commit() + return EnrichDurationOut( + scene_id=scene_id, + duration_sec=d, + tube_used=src.origin.split(":", 1)[1] if ":" in src.origin else None, + ) + + return EnrichDurationOut(scene_id=scene_id, duration_sec=None, tube_used=None) + + +class EnrichStudioOut(BaseModel): + scene_id: uuid.UUID + studio_id: uuid.UUID | None + studio_name: str | None + tube_used: str | None + + +@router.post("/{scene_id}/enrich-studio", response_model=EnrichStudioOut) +def enrich_studio_from_tube( + scene_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], +) -> EnrichStudioOut: + """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 + tylko pornhat (najczystsze studio metadata wśród free tubes). + """ + import json as _json + + from app.extractors._fetch import browser_get + from app.extractors._models import TubePageError + from app.models.playback_source import PlaybackSource + from app.models.studio import Studio + from app.normalize.text import slugify + + scene = session.get(Scene, scene_id) + if scene is None: + raise HTTPException(status_code=404, detail="scene not found") + + if scene.studio_id is not None: + existing = session.get(Studio, scene.studio_id) + return EnrichStudioOut( + scene_id=scene_id, + studio_id=scene.studio_id, + studio_name=existing.name if existing else None, + tube_used=None, + ) + + chosen = session.execute( + select(PlaybackSource).where( + PlaybackSource.scene_id == scene_id, + PlaybackSource.dead_at.is_(None), + PlaybackSource.origin == "tube:pornhatcom", + ) + ).scalars().first() + if chosen is None: + return EnrichStudioOut(scene_id=scene_id, studio_id=None, studio_name=None, tube_used=None) + + try: + r = browser_get(chosen.page_url, timeout=15.0, follow_redirects=True) + r.raise_for_status() + except (TubePageError, Exception) as e: + log.warning("enrich-studio fetch failed for %s: %s", chosen.page_url, e) + return EnrichStudioOut(scene_id=scene_id, studio_id=None, studio_name=None, tube_used="pornhatcom") + + m = re.search( + r"class=\"info-video js-ajax-dvd[^\"]*\"[^>]*data-setup='([^']+)'", + r.text, re.IGNORECASE, + ) + if m is None: + return EnrichStudioOut(scene_id=scene_id, studio_id=None, studio_name=None, tube_used="pornhatcom") + try: + data = _json.loads(m.group(1)) + except _json.JSONDecodeError: + return EnrichStudioOut(scene_id=scene_id, studio_id=None, studio_name=None, tube_used="pornhatcom") + + name = (data.get("title") or "").strip() + if not name: + return EnrichStudioOut(scene_id=scene_id, studio_id=None, studio_name=None, tube_used="pornhatcom") + slug = (data.get("dir") or "").strip() or slugify(name) + + studio = session.execute( + select(Studio).where(Studio.slug == slug) + ).scalar_one_or_none() + if studio is None: + studio = session.execute( + select(Studio).where(Studio.name == name) + ).scalar_one_or_none() + if studio is None: + studio = Studio(name=name, slug=slug) + session.add(studio) + session.flush() + scene.studio_id = studio.id + session.commit() + return EnrichStudioOut( + scene_id=scene_id, studio_id=studio.id, studio_name=studio.name, tube_used="pornhatcom" + ) + + +class EnrichThumbOut(BaseModel): + scene_id: uuid.UUID + thumbnail_url: str | None + tube_used: str | None + sources_updated: int + + +@router.post("/{scene_id}/enrich-thumbnail", response_model=EnrichThumbOut) +def enrich_thumbnail_from_tube( + scene_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], +) -> EnrichThumbOut: + """Pobiera detail page z dowolnego tube playback_source bez thumbnail_url + i wyciąga miniaturkę (og:image / twitter:image / LD-JSON thumbnailUrl / + KVS html5player). + + Update'uje WSZYSTKIE PlaybackSource'y dla tej sceny które nie mają thumb, + żeby kolejne otwarcia listy widziały miniaturę niezależnie od source pick. + Mobile auto-wywoła to przy otwarciu SceneDetail bez thumb (jak duration). + """ + from app.extractors._fetch import browser_get + from app.extractors._models import TubePageError + from app.extractors.thumb_extract import extract_thumbnail_url + from app.models.playback_source import PlaybackSource + + scene = session.get(Scene, scene_id) + if scene is None: + raise HTTPException(status_code=404, detail="scene not found") + + sources = session.execute( + select(PlaybackSource).where( + PlaybackSource.scene_id == scene_id, + PlaybackSource.dead_at.is_(None), + PlaybackSource.origin.like("tube:%"), + ) + ).scalars().all() + + sources_with_thumb = [s for s in sources if s.thumbnail_url] + if sources_with_thumb: + # już mamy — idempotent return. + return EnrichThumbOut( + scene_id=scene_id, + thumbnail_url=sources_with_thumb[0].thumbnail_url, + tube_used=None, + sources_updated=0, + ) + + for src in sources: + try: + r = browser_get(src.page_url, timeout=15.0, follow_redirects=True) + r.raise_for_status() + except (TubePageError, Exception) as e: + log.debug("enrich-thumbnail fetch failed for %s: %s", src.page_url, e) + continue + thumb = extract_thumbnail_url(r.text) + if thumb: + # Zapisz na wszystkich źródłach bez thumb (oszczędza duplikat fetch) + updated = 0 + for s in sources: + if not s.thumbnail_url: + s.thumbnail_url = thumb + updated += 1 + session.commit() + return EnrichThumbOut( + scene_id=scene_id, + thumbnail_url=thumb, + tube_used=src.origin.split(":", 1)[1] if ":" in src.origin else None, + sources_updated=updated, + ) + + return EnrichThumbOut( + scene_id=scene_id, thumbnail_url=None, tube_used=None, sources_updated=0 + ) diff --git a/app/api/schemas.py b/app/api/schemas.py new file mode 100644 index 0000000..160d8d8 --- /dev/null +++ b/app/api/schemas.py @@ -0,0 +1,127 @@ +"""Pydantic schemas eksportowane przez API.""" +from __future__ import annotations + +import uuid +from datetime import date, datetime + +from pydantic import BaseModel, ConfigDict + + +class StudioOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + name: str + slug: str + network: str | None = None + + +class PerformerOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + canonical_name: str + slug: str + gender: str | None = None + as_alias: str | None = None + + +class TagOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + name: str + slug: str + + +class ExternalRefOut(BaseModel): + source: str + external_id: str + url: str | None = None + last_seen: datetime | None = None + + +class PlaybackSourceOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + origin: str + page_url: str + embed_url: str | None = None + stream_url: str | None = None + quality: str | None = None + duration_sec: int | None = None + thumbnail_url: str | None = None + animated_thumbnail_url: str | None = None + + +class SceneOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + title: str + slug: str | None = None + release_date: date | None = None + duration_sec: int | None = None + description: str | None = None + code: str | None = None + director: str | None = None + studio: StudioOut | None = None + performers: list[PerformerOut] = [] + tags: list[TagOut] = [] + 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 + # `created_at > last_seen_at` (favorite) → badge. + created_at: datetime | None = None + # Watched indicator (z `scene_play_progress`): mobile dim'uje kafelek gdy + # `finished=True`, pokazuje progress bar gdy `position_sec > 0`. + last_played_at: datetime | None = None + finished: bool = False + position_sec: int = 0 + is_favorite: bool = False + + +class SceneListOut(BaseModel): + items: list[SceneOut] + total: int + page: int + per_page: int + + +class MovieChapterOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + chapter_index: int + title: str | None = None + start_sec: int | None = None + end_sec: int | None = None + scene_id: uuid.UUID | None = None + + +class MovieOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + title: str + slug: str | None = None + release_year: int | None = None + release_date: date | None = None + duration_sec: int | None = None + description: str | None = None + director: str | None = None + country: str | None = None + rating: float | None = None + poster_url: str | None = None + backdrop_url: str | None = None + studio: StudioOut | None = None + performers: list[PerformerOut] = [] + tags: list[TagOut] = [] + chapters: list[MovieChapterOut] = [] + external_refs: list[ExternalRefOut] = [] + playback_sources: list[PlaybackSourceOut] = [] + # Used by mobile MoviesScreen NEW badge (created_at > client-stored seenSince) + # and MovieDetail favorite star. + created_at: datetime | None = None + is_favorite: bool = False + + +class MovieListOut(BaseModel): + items: list[MovieOut] + total: int + page: int + per_page: int diff --git a/app/api/stream_proxy.py b/app/api/stream_proxy.py new file mode 100644 index 0000000..80c40e7 --- /dev/null +++ b/app/api/stream_proxy.py @@ -0,0 +1,553 @@ +"""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 +*przez backend* (tym samym IP co podczas extracji) → CDN sprawdza signature +poprawnie i serwuje content. + +Flow: + 1. /resolve packuje (url, referer) w token (HMAC-podpisany). + 2. Mobile dostaje `stream_url = /proxy/{token}/master.m3u8` (lub `.mp4`). + 3. ExoPlayer woła backend → backend strumieniuje content z origin URL. + 4. HLS: m3u8 manifest jest rewrited tak, że dziecięce segmenty/playlisty + też idą przez proxy (chained tokens). + +Token: base64url(json({u: url, r: referer, exp: unix_ts})) + HMAC-SHA256 +podpisany shared secret z env (`STREAM_PROXY_SECRET`). TTL 4h żeby gracz mógł +oglądać dłuższe sceny + pause/seek bez ryzyka expired token. +""" +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import logging +import os +import re +import time +from typing import Annotated +from urllib.parse import urljoin, urlparse + +import httpx +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi.responses import Response, StreamingResponse + +from app.auth import require_api_key + +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). +# Critical dla widzenia gdzie VPS bandwidth wycieka przed Hetzner overage. +from collections import defaultdict +from threading import Lock + +_bw_counters: dict[str, dict[int, int]] = defaultdict(lambda: defaultdict(int)) +_bw_lock = Lock() + + +def _record_proxy_bytes(target_url: str, n_bytes: int) -> None: + """Append n_bytes to current hour bucket for given target CDN domain. + Auto-prunes buckets older than 7 days. Thread-safe.""" + if n_bytes <= 0: + return + try: + host = urlparse(target_url).hostname or "unknown" + except Exception: + host = "unknown" + hour = int(time.time() // 3600) + with _bw_lock: + _bw_counters[host][hour] += n_bytes + # Prune >7d (keep counter map small) + cutoff = hour - 168 + old = [h for h in _bw_counters[host] if h < cutoff] + for h in old: + del _bw_counters[host][h] + + +def get_bandwidth_stats(hours: int = 24) -> dict[str, int]: + """Returns {cdn_domain: bytes_out_in_last_N_hours}, sorted desc by bytes.""" + now_hour = int(time.time() // 3600) + cutoff = now_hour - hours + result: dict[str, int] = {} + with _bw_lock: + for cdn, buckets in _bw_counters.items(): + total = sum(b for h, b in buckets.items() if h > cutoff) + if total > 0: + result[cdn] = total + return dict(sorted(result.items(), key=lambda kv: -kv[1])) + +DEFAULT_UA = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36" +) +TOKEN_TTL_SEC = 4 * 60 * 60 # 4h +HOP_BY_HOP = { + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", + "content-encoding", + "content-length", +} + + +def _secret() -> bytes: + s = os.environ.get("STREAM_PROXY_SECRET") or os.environ.get("API_KEYS", "") + if not s: + raise RuntimeError("STREAM_PROXY_SECRET (or API_KEYS) must be set") + return s.encode("utf-8") + + +def make_token( + url: str, + referer: str | None = None, + ttl_sec: int = TOKEN_TTL_SEC, + *, + refresh: str | None = None, + refresh_hoster: str | None = None, + impersonate: bool = False, +) -> str: + """Build proxy 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 + dispatch do dedicated re-extract logic. + `impersonate`: użyć curl_cffi chrome120 zamiast httpx (dla hosterów z JA3 bot + detection — mxcontent, cloudflare-protected). + """ + payload: dict = {"u": url, "r": referer or "", "e": int(time.time()) + ttl_sec} + if refresh: + payload["rf"] = refresh + if refresh_hoster: + payload["rh"] = refresh_hoster + if impersonate: + payload["i"] = 1 + raw = json.dumps(payload, separators=(",", ":")).encode("utf-8") + body = base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + sig = base64.urlsafe_b64encode( + hmac.new(_secret(), raw, hashlib.sha256).digest() + ).rstrip(b"=").decode("ascii") + return f"{body}.{sig}" + + +def parse_token(token: str) -> dict: + try: + body_b64, sig_b64 = token.split(".", 1) + except ValueError: + raise HTTPException(status_code=400, detail="malformed token") from None + raw = base64.urlsafe_b64decode(body_b64 + "==") + expected = base64.urlsafe_b64encode( + hmac.new(_secret(), raw, hashlib.sha256).digest() + ).rstrip(b"=").decode("ascii") + if not hmac.compare_digest(expected, sig_b64): + raise HTTPException(status_code=403, detail="bad token sig") + payload = json.loads(raw) + if int(payload.get("e", 0)) < int(time.time()): + raise HTTPException(status_code=410, detail="token expired") + return payload + + +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 + `Honies_№2.html`) wcześniej throw'ował UnicodeEncodeError (GOON-A). `quote` + z `safe=":/?#[]@!$&'()*+,;=%"` zostawia URI structure nietkniętą, tylko + enkoduje znaki spoza ASCII.""" + try: + from urllib.parse import quote + return quote(url, safe=":/?#[]@!$&'()*+,;=%~") + except Exception: + return url + + +def _build_headers(referer: str | None) -> dict[str, str]: + h = { + "User-Agent": DEFAULT_UA, + "Accept": "*/*", + "Accept-Language": "en-US,en;q=0.9", + } + if referer: + h["Referer"] = _ascii_safe_url(referer) + try: + host = urlparse(referer).hostname + if host: + h["Origin"] = _ascii_safe_url("https://" + host) + except Exception: + pass + return h + + +_M3U8_URI_RE = re.compile(r'(URI=")([^"]+)(")', re.IGNORECASE) + + +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 + Każdy URL → token + /proxy/{token}/.. + """ + out: list[str] = [] + for raw_line in content.splitlines(): + line = raw_line.strip() + if not line: + out.append(raw_line) + continue + if line.startswith("#"): + # Match URI="..." inside #EXT-X-KEY / #EXT-X-MEDIA / etc. + def _sub(m: re.Match) -> str: + inner = urljoin(base_url, m.group(2)) + t = make_token(inner, referer) + return f'{m.group(1)}/proxy/{t}/seg{m.group(3)}' + new_line = _M3U8_URI_RE.sub(_sub, raw_line) + out.append(new_line) + continue + # Resource URI line + absolute = urljoin(base_url, line) + t = make_token(absolute, referer) + # Zachowaj rozszerzenie żeby ExoPlayer rozpoznał content-type: + ext = os.path.splitext(urlparse(absolute).path)[1].lstrip(".") or "ts" + out.append(f"/proxy/{t}/seg.{ext}") + return "\n".join(out) + "\n" + + +@router.get("/sign") +def sign_url( + _api: Annotated[None, Depends(require_api_key)], + url: str = Query(...), + 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.""" + return {"token": make_token(url, referer), "expires_in": TOKEN_TTL_SEC} + + +@router.get("/img/{token}/{_basename:path}") +async def proxy_image( + token: str, + _basename: str, + request: Request, +) -> Response: + """Image proxy — używany dla thumbnaili z CDN-ów wymagających Referera + (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ć.""" + payload = parse_token(token) + target = payload["u"] + referer = payload["r"] or None + headers = _build_headers(referer) + timeout = httpx.Timeout(connect=10.0, read=30.0, write=15.0, pool=5.0) + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + 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 + # 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. + 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 + # info log + 502 pass-through bez exception. + log.info("img proxy upstream %d for %s", r.status_code, target) + return Response( + content=b"", + status_code=502 if r.status_code >= 500 else r.status_code, + media_type="image/jpeg", + ) + ct = r.headers.get("content-type", "image/jpeg") + return Response( + content=r.content, + media_type=ct, + headers={"Cache-Control": "public, max-age=86400"}, + ) + + +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). + """ + import re + from yt_dlp.utils import decode_packed_codes + embed_headers = { + "User-Agent": DEFAULT_UA, + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + } + try: + r = await session.get(embed_url, headers=embed_headers, impersonate="chrome120", + timeout=15, allow_redirects=True) + if r.status_code != 200: + return None + m = re.search(r"eval\(function\(p,a,c,k,e,d\)\{.+?\}\(.+?\)\)", r.text, re.DOTALL) + if not m: + return None + decoded = decode_packed_codes(m.group(0)) + url_m = re.search(r'MDCore\.wurl\s*=\s*"([^"]+\.mp4[^"]*)"', decoded) + if not url_m: + return None + url = url_m.group(1) + if url.startswith("//"): + url = "https:" + url + return url + except Exception as e: + log.warning("refetch mixdrop failed for %s: %s", embed_url, e) + return None + + +async def _curl_cffi_stream( + target: str, + headers: dict, + *, + refetch_url: str | None = None, + refetch_hoster: str | None = None, +) -> Response: + """Fallback dla hosterów które detect plain httpx JA3 jako bot (mxcontent, + cloudflare-protected CDNs). curl_cffi async z chrome120 impersonate ma + identyczny TLS fingerprint jak prawdziwy Chrome → CDN go przepuszcza. + + Gdy `refetch_url` ustawione i mp4 GET zwraca 4xx, re-fetcha embed page + w SAME session żeby odświeżyć cookies + dostać nowy mp4 URL (same-session + bind dla mxcontent). Bez tego mixdrop mp4 token expires + brak cookies → 403. + """ + from curl_cffi.requests import AsyncSession + + session = AsyncSession() + try: + # Dla mixdrop: ZAWSZE refetch embed jako PIERWSZE (przed mp4) żeby session + # miała fresh cookies. Initial mp4 attempt z expired/old token + brak + # cookies = 403 + anti-bot flag w cookies → blokuje retry też. + if refetch_url and refetch_hoster == "mixdrop": + new_mp4 = await _refetch_mixdrop_url(session, refetch_url) + if new_mp4: + target = new_mp4 + log.info("mixdrop fresh-extract mp4 %s", new_mp4[:80]) + + upstream = await session.get( + target, + headers=headers, + impersonate="chrome120", + stream=True, + timeout=120, + allow_redirects=True, + ) + log.info("mixdrop mp4 fetch %s → %d", target[:60], upstream.status_code) + if upstream.status_code >= 400: + await session.close() + return _upstream_error_response(upstream.status_code, dict(upstream.headers), target) + + out_headers = { + k: v for k, v in upstream.headers.items() if k.lower() not in HOP_BY_HOP + } + + async def streamer(): + bytes_out = 0 + try: + async for chunk in upstream.aiter_content(): + bytes_out += len(chunk) + yield chunk + finally: + await session.close() + _record_proxy_bytes(target, bytes_out) + + return StreamingResponse( + streamer(), + status_code=upstream.status_code, + headers=out_headers, + media_type=upstream.headers.get("content-type", "application/octet-stream"), + ) + except Exception as e: + try: + await session.close() + except Exception: + pass + log.warning("curl_cffi proxy failed for %s: %s", target, e) + raise HTTPException(status_code=502, detail=f"proxy error: {e}") from e + + +@router.get("/{token}/{_basename:path}") +async def proxy_stream( + token: str, + _basename: str, + request: Request, +) -> Response: + payload = parse_token(token) + target = payload["u"] + referer = payload["r"] or None + use_impersonate = bool(payload.get("i")) + refetch_url = payload.get("rf") + refetch_hoster = payload.get("rh") + + # Forwardujemy Range header (HLS/MP4 player robi byte-range fetches dla seek/preload) + headers = _build_headers(referer) + range_h = request.headers.get("range") + if range_h: + headers["Range"] = range_h + + 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. + # Token `i=1` flag ustawiana przez extractor dla tych hostów (mixdrop.py). + if use_impersonate: + return await _curl_cffi_stream( + target, headers, + refetch_url=refetch_url, refetch_hoster=refetch_hoster, + ) + + # Krótszy timeout na request, ale długi read żeby streaming nie zerwał + timeout = httpx.Timeout(connect=15.0, read=120.0, write=30.0, pool=10.0) + parsed = urlparse(target) + path_lower = parsed.path.lower() + # Path-hint dla wstępnej decyzji, ale FINAL decyzja po content-type response. + # Powód: pornhat `get_file/.../.mp4/` 302 → CDN m3u8 manifest mimo `.mp4` + # w path. Bez content-type check proxy traktuje jako binary, mobile dostaje + # m3u8 z RAW CDN URLs (IP-bound do VPS) → "no extractors" w ExoPlayer. + path_suggests_m3u8 = path_lower.endswith(".m3u8") + + client = httpx.AsyncClient(timeout=timeout, follow_redirects=True) + try: + # Sprobój streaming send PIERWSZY — sprawdź content-type po headers, + # potem decyzja: rewrite manifest vs stream binary. + upstream = await client.send( + client.build_request(method, target, headers=headers), + stream=True, + follow_redirects=True, + ) + if upstream.status_code >= 400: + status = upstream.status_code + ups_headers = dict(upstream.headers) + await upstream.aclose() + await client.aclose() + return _upstream_error_response(status, ups_headers, target) + + ct = (upstream.headers.get("content-type") or "").lower() + is_m3u8 = ( + path_suggests_m3u8 + or "mpegurl" in ct + or "application/x-mpegurl" in ct + ) + if is_m3u8: + # Manifest content — buffer fully, rewrite, return as m3u8. + body = await upstream.aread() + await upstream.aclose() + await client.aclose() + try: + rewritten = _rewrite_m3u8(body.decode("utf-8", errors="replace"), + base_url=str(upstream.url), referer=referer) + except Exception as e: + log.warning("m3u8 rewrite failed for %s: %s", target, e) + raise HTTPException(status_code=502, detail="manifest rewrite failed") from e + return Response( + content=rewritten, + media_type="application/vnd.apple.mpegurl", + headers={"Cache-Control": "no-store"}, + ) + + out_headers = { + k: v for k, v in upstream.headers.items() if k.lower() not in HOP_BY_HOP + } + + async def streamer(): + bytes_out = 0 + try: + async for chunk in upstream.aiter_raw(): + bytes_out += len(chunk) + yield chunk + finally: + await upstream.aclose() + await client.aclose() + _record_proxy_bytes(target, bytes_out) + + return StreamingResponse( + streamer(), + status_code=upstream.status_code, + headers=out_headers, + media_type=upstream.headers.get("content-type", "application/octet-stream"), + ) + except HTTPException: + 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). + # 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) + return Response( + content=f"upstream unreachable: {type(e).__name__}", + status_code=503, + headers={"Retry-After": "5"}, + media_type="text/plain", + ) + except Exception as e: + await client.aclose() + log.warning("proxy failed for %s: %s", target, e) + raise HTTPException(status_code=502, detail=f"proxy error: {e}") from e + + +def _upstream_error_response( + status: int, + upstream_headers: dict, + target: str, +) -> Response: + """Mapuje upstream HTTP error na nasz 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. + - **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.). + """ + retry_after = upstream_headers.get("retry-after") or upstream_headers.get("Retry-After") + if status == 429: + log.info("proxy upstream 429 for %s (Retry-After=%s)", target, retry_after) + out_headers: dict[str, str] = {"Cache-Control": "no-store"} + if retry_after: + out_headers["Retry-After"] = str(retry_after) + else: + out_headers["Retry-After"] = "10" + return Response( + content="upstream rate limited", + status_code=429, + headers=out_headers, + media_type="text/plain", + ) + if status in (404, 410): + log.info("proxy upstream %d for %s", status, target) + return Response( + content=f"upstream {status}", + status_code=status, + media_type="text/plain", + ) + if 500 <= status < 600: + # 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( + content=f"upstream {status}", + status_code=502, + headers={"Retry-After": "5"}, + media_type="text/plain", + ) + # 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 new file mode 100644 index 0000000..fe05f14 --- /dev/null +++ b/app/api/taxonomies.py @@ -0,0 +1,597 @@ +"""GET /tags, /performers, /studios — listy taxonomies do filtrów na mobile. + +Każdy endpoint wspiera: + - q: substring search po name_normalized (trgm fallback ilike) + - order: 'name' (alfabetycznie) | 'popular' lub 'scene_count' (po liczbie scen desc) + - page/per_page + +Zwraca też scene_count żeby UI pokazywał "(123)" przy każdym tagu/performerze/studio. +""" +from __future__ import annotations + +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, ConfigDict +from sqlalchemy import and_, exists, func, select +from sqlalchemy.orm import Session + +from app.auth import require_api_key +from app.db import get_session +from app.models.movie import Movie, MovieTag +from app.models.movie_playback_source import MoviePlaybackSource +from app.models.performer import Performer +from app.models.playback_source import PlaybackSource +from app.models.scene import ScenePerformer, SceneTag +from app.models.studio import Studio +from app.models.tag import Tag + +router = APIRouter(tags=["taxonomies"], dependencies=[Depends(require_api_key)]) + + +# ---- Schemas ---------------------------------------------------------- + +class TagCount(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + name: str + slug: str + scene_count: int = 0 + + +class TagListOut(BaseModel): + items: list[TagCount] + total: int + page: int + per_page: int + + +class PerformerCount(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + canonical_name: str + slug: str + gender: str | None = None + scene_count: int = 0 + + +class PerformerListOut(BaseModel): + items: list[PerformerCount] + total: int + page: int + per_page: int + + +class StudioCount(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + name: str + slug: str + network: str | None = None + scene_count: int = 0 + + +class StudioListOut(BaseModel): + items: list[StudioCount] + total: int + page: int + per_page: int + + +# ---- Endpoints -------------------------------------------------------- + +@router.get("/tags", response_model=TagListOut) +def list_tags( + session: Annotated[Session, Depends(get_session)], + q: str | None = Query(default=None), + order: str = Query(default="popular", description="popular|name"), + page: int = Query(default=1, ge=1), + per_page: int = Query(default=50, ge=1, le=500), + for_movies: bool = Query( + default=False, + description=( + "True: zlicza wystąpienia tagu w movies (z live MoviePlaybackSource) " + "zamiast w scenes. UI używa do filtrowania movie genres." + ), + ), + only_with_content: bool = Query( + default=False, + description=( + "True: ukrywa tagi z 0 wystąpieniami w wybranym typie (scenes/movies)." + " Filtruje krótkie listy filtrów żeby nie pokazywać tagów-sierot." + ), + ), +) -> TagListOut: + if order not in ("popular", "scene_count", "name"): + raise HTTPException(status_code=400, detail="order must be 'popular' or 'name'") + + if for_movies: + # Movie tag count — zliczamy tylko Movies z ≥1 live MoviePlaybackSource. + # Tag-bez-żadnego-movie zwraca 0 (LEFT OUTER JOIN przez coalesce). + _movie_live = exists().where( + and_( + MoviePlaybackSource.movie_id == MovieTag.movie_id, + MoviePlaybackSource.dead_at.is_(None), + ) + ) + count_sub = ( + select(MovieTag.tag_id, func.count(MovieTag.movie_id).label("c")) + .where(_movie_live) + .group_by(MovieTag.tag_id) + .subquery() + ) + else: + # has_live_playback filter — zliczamy tylko sceny które user faktycznie zobaczy + # (TPDB/StashDB metadata-only stubs są do mergowania, nie do oglądania). + _live_playback = exists().where( + and_( + PlaybackSource.scene_id == SceneTag.scene_id, + PlaybackSource.dead_at.is_(None), + ) + ) + count_sub = ( + select(SceneTag.tag_id, func.count(SceneTag.scene_id).label("c")) + .where(_live_playback) + .group_by(SceneTag.tag_id) + .subquery() + ) + base = ( + select(Tag, func.coalesce(count_sub.c.c, 0).label("scene_count")) + .outerjoin(count_sub, count_sub.c.tag_id == Tag.id) + ) + if q: + base = base.where(Tag.name.ilike(f"%{q}%")) + if only_with_content: + # exists() w outerjoin nie inner-joinowałby pustych tagów. Dlatego osobny + # exists check: pasują tylko tagi z ≥1 w subquery. + base = base.where(count_sub.c.tag_id.is_not(None)) + + total = session.execute( + select(func.count()).select_from(base.subquery()) + ).scalar_one() + + if order in ("popular", "scene_count"): + ordered = base.order_by(func.coalesce(count_sub.c.c, 0).desc(), Tag.name.asc()) + else: + ordered = base.order_by(Tag.name.asc()) + + rows = session.execute( + ordered.offset((page - 1) * per_page).limit(per_page) + ).all() + + items = [ + TagCount(id=t.id, name=t.name, slug=t.slug, scene_count=int(c)) + for t, c in rows + ] + return TagListOut(items=items, total=total, page=page, per_page=per_page) + + +@router.get("/performers", response_model=PerformerListOut) +def list_performers( + session: Annotated[Session, Depends(get_session)], + q: str | None = Query(default=None, description="substring po name_normalized"), + order: str = Query(default="scene_count", description="scene_count|name"), + page: int = Query(default=1, ge=1), + per_page: int = Query(default=50, ge=1, le=500), +) -> PerformerListOut: + if order not in ("scene_count", "popular", "name"): + raise HTTPException(status_code=400, detail="order must be 'scene_count' or 'name'") + + # has_live_playback filter — patrz list_tags wyżej. + _perf_live_playback = exists().where( + and_( + PlaybackSource.scene_id == ScenePerformer.scene_id, + PlaybackSource.dead_at.is_(None), + ) + ) + count_sub = ( + select(ScenePerformer.performer_id, func.count(ScenePerformer.scene_id).label("c")) + .where(_perf_live_playback) + .group_by(ScenePerformer.performer_id) + .subquery() + ) + base = ( + select(Performer, func.coalesce(count_sub.c.c, 0).label("scene_count")) + .outerjoin(count_sub, count_sub.c.performer_id == Performer.id) + ) + if q: + base = base.where(Performer.name_normalized.ilike(f"%{q.lower()}%")) + + total = session.execute( + select(func.count()).select_from(base.subquery()) + ).scalar_one() + + if order in ("scene_count", "popular"): + ordered = base.order_by( + func.coalesce(count_sub.c.c, 0).desc(), Performer.canonical_name.asc() + ) + else: + ordered = base.order_by(Performer.canonical_name.asc()) + + rows = session.execute( + ordered.offset((page - 1) * per_page).limit(per_page) + ).all() + + items = [ + PerformerCount( + id=p.id, + canonical_name=p.canonical_name, + slug=p.slug, + gender=p.gender.value if p.gender else None, + scene_count=int(c), + ) + for p, c in rows + ] + return PerformerListOut(items=items, total=total, page=page, per_page=per_page) + + +@router.get("/studios", response_model=StudioListOut) +def list_studios( + session: Annotated[Session, Depends(get_session)], + q: str | None = Query(default=None), + order: str = Query(default="name", description="name|scene_count"), + page: int = Query(default=1, ge=1), + per_page: int = Query(default=50, ge=1, le=500), + for_movies: bool = Query( + default=False, + description="True: zlicza tylko studia mające ≥1 movie z live playback.", + ), + only_with_content: bool = Query( + default=False, + description="True: ukrywa studia z 0 wystąpieniami w wybranym typie.", + ), +) -> StudioListOut: + 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: + _movie_live = exists().where( + and_( + MoviePlaybackSource.movie_id == Movie.id, + MoviePlaybackSource.dead_at.is_(None), + ) + ) + count_sub = ( + select(Movie.studio_id, func.count(Movie.id).label("c")) + .where(Movie.studio_id.is_not(None)) + .where(_movie_live) + .group_by(Movie.studio_id) + .subquery() + ) + else: + # has_live_playback filter — patrz list_tags wyżej. + _studio_live_playback = exists().where( + and_( + PlaybackSource.scene_id == Scene.id, + PlaybackSource.dead_at.is_(None), + ) + ) + count_sub = ( + select(Scene.studio_id, func.count(Scene.id).label("c")) + .where(Scene.studio_id.is_not(None)) + .where(_studio_live_playback) + .group_by(Scene.studio_id) + .subquery() + ) + base = ( + select(Studio, func.coalesce(count_sub.c.c, 0).label("scene_count")) + .outerjoin(count_sub, count_sub.c.studio_id == Studio.id) + ) + if q: + base = base.where(Studio.name.ilike(f"%{q}%")) + if only_with_content: + base = base.where(count_sub.c.studio_id.is_not(None)) + + total = session.execute( + select(func.count()).select_from(base.subquery()) + ).scalar_one() + + if order in ("scene_count", "popular"): + ordered = base.order_by(func.coalesce(count_sub.c.c, 0).desc(), Studio.name.asc()) + else: + ordered = base.order_by(Studio.name_normalized.asc()) + + rows = session.execute( + ordered.offset((page - 1) * per_page).limit(per_page) + ).all() + + items = [ + StudioCount( + id=s.id, + name=s.name, + slug=s.slug, + network=s.network, + scene_count=int(c), + ) + for s, c in rows + ] + return StudioListOut(items=items, total=total, page=page, per_page=per_page) + + +# ---- Performer refresh on-demand -------------------------------------- + +class PerformerRefreshOut(BaseModel): + performer_id: uuid.UUID + canonical_name: str + counters: dict[str, dict[str, int]] + new_scenes: int + last_searched_at: str | None + + +class PerformerRescrapeOut(BaseModel): + performer_id: uuid.UUID + canonical_name: str + scenes_total: int + scenes_processed: int + thumbs_added: int + tags_added: int + failures: int + capped: bool + cap_reason: str | None = None + + +# Hard caps żeby request się nie wisiał i nginx (60s read timeout) nie 504'ował +# 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_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. +_TAG_PRIORITY = [ + "xhamstercom", "porntrexcom", "epornercom", "youporncom", + "xvideoscom", "xnxxcom", "redtubecom", "pornhatcom", +] + + +@router.post("/performers/{performer_id}/rescrape", response_model=PerformerRescrapeOut) +def rescrape_performer_scenes( + performer_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], +) -> PerformerRescrapeOut: + """Re-scrapuje miniaturki + tagi z tube pages dla scen performera (bulk). + + Bug-report 2026-05-16 (6fcaa5f4): per-scene enrich działa on-demand, ale dla + całej listy (np. 200 scen xhamstera) user musiałby kliknąć każdą osobno. + + Cap'owane: max `_RESCRAPE_MAX_SCENES` (50) lub `_RESCRAPE_WALL_SEC` (45s), + żeby nginx 60s read timeout nie 504'ował partial commit. Większe ilości + wymagają wielu kliknięć (idempotent, scene z thumb się skipuje). + + Idempotent: scena która ma już thumb i ≥3 tagi jest pomijana. + """ + import time as _time + import httpx as _httpx + from app.extractors._fetch import browser_get + from app.extractors._models import TubePageError + from app.extractors.tag_extract import EXTRACTORS as TAG_EXTRACTORS, extract_tags + from app.extractors.thumb_extract import extract_thumbnail_url + from app.models.playback_source import PlaybackSource + from app.models.scene import Scene, SceneTag + from app.normalize.scenes import NormalizedTag + from app.normalize.text import slugify + from app.resolve.tag_resolver import resolve_tag + from sqlalchemy.dialects.postgresql import insert as pg_insert + + perf = session.get(Performer, performer_id) + if perf is None: + raise HTTPException(status_code=404, detail="performer not found") + + # 1) ID-only query — sceny ze ≥1 alive tube playback. + scene_ids = session.execute( + select(Scene.id) + .join(ScenePerformer, ScenePerformer.scene_id == Scene.id) + .where(ScenePerformer.performer_id == performer_id) + .where( + exists().where( + PlaybackSource.scene_id == Scene.id, + PlaybackSource.dead_at.is_(None), + PlaybackSource.origin.like("tube:%"), + ) + ) + .limit(_RESCRAPE_MAX_SCENES) + ).scalars().all() + scenes_total = len(scene_ids) + + if not scene_ids: + return PerformerRescrapeOut( + performer_id=performer_id, + canonical_name=perf.canonical_name, + scenes_total=0, scenes_processed=0, + thumbs_added=0, tags_added=0, failures=0, + capped=False, + ) + + # 2) Batch fetch: wszystkie alive tube playback_sources dla tych scen w 1 query. + pb_rows = session.execute( + select(PlaybackSource) + .where(PlaybackSource.scene_id.in_(scene_ids)) + .where(PlaybackSource.dead_at.is_(None)) + .where(PlaybackSource.origin.like("tube:%")) + ).scalars().all() + sources_by_scene: dict = {} + for s in pb_rows: + sources_by_scene.setdefault(s.scene_id, []).append(s) + + # 3) Batch fetch tag counts per scene (1 query zamiast N). + tag_counts = dict(session.execute( + select(SceneTag.scene_id, func.count()) + .where(SceneTag.scene_id.in_(scene_ids)) + .group_by(SceneTag.scene_id) + ).all()) + + thumbs_added = 0 + tags_added = 0 + failures = 0 + scenes_processed = 0 + capped = False + cap_reason: str | None = None + started = _time.monotonic() + # Narrow exception set — łapiemy TYLKO oczekiwane network/parse failures. + # `Exception` catch-all blokował KeyboardInterrupt + maskował pool exhaustion. + NET_EXC = (TubePageError, _httpx.HTTPError, OSError, ValueError) + + for scene_id in scene_ids: + if _time.monotonic() - started > _RESCRAPE_WALL_SEC: + capped = True + cap_reason = f"wall-clock {_RESCRAPE_WALL_SEC}s reached" + break + + sources = sources_by_scene.get(scene_id, []) + if not sources: + continue + + scenes_processed += 1 + 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 + # insert nie odpaliłby outer transaction; bez nested rollback całe N scen + # po niej miałoby PendingRollbackError. + sp = session.begin_nested() + try: + if not has_thumb: + thumb_added_here = False + for src in sources: + try: + r = browser_get(src.page_url, timeout=10.0, follow_redirects=True) + except NET_EXC as e: + log.debug("rescrape thumb fetch fail %s: %s", src.page_url, e) + continue + if r.status_code >= 400: + continue + 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 + # cross-attribution (np. xhamster thumb na porntrex entry). + # `scene.thumbnail_url` w UI bierze pierwszy z thumb (mobile + # find()), więc 1 wystarczy. + session.execute( + PlaybackSource.__table__.update() + .where(PlaybackSource.id == src.id) + .where(PlaybackSource.thumbnail_url.is_(None)) + .values(thumbnail_url=thumb) + ) + thumbs_added += 1 + thumb_added_here = True + break + if not thumb_added_here: + failures += 1 + + if existing_tag_count < _TAG_RESCRAPE_THRESHOLD: + chosen = None + for tag in _TAG_PRIORITY: + for src in sources: + if src.origin == f"tube:{tag}": + chosen = src + break + if chosen: + break + if chosen is None: + for src in sources: + sitetag_part = src.origin.split(":", 1)[1] + if sitetag_part in TAG_EXTRACTORS: + chosen = src + break + if chosen is not None: + sitetag_part = chosen.origin.split(":", 1)[1] + try: + r = browser_get(chosen.page_url, timeout=10.0, follow_redirects=True) + if r.status_code < 400: + tag_names = extract_tags(sitetag_part, r.text) + else: + tag_names = [] + except NET_EXC as e: + log.debug("rescrape tags fetch fail %s: %s", chosen.page_url, e) + tag_names = [] + seen_tag_ids: set = set() + for name in tag_names: + norm = NormalizedTag(name=name, slug=slugify(name), external_id=None) + tag = resolve_tag(session, norm=norm) + if tag is None or tag.id in seen_tag_ids: + continue + seen_tag_ids.add(tag.id) + stmt = ( + pg_insert(SceneTag.__table__) + .values(scene_id=scene_id, tag_id=tag.id) + .on_conflict_do_nothing(index_elements=["scene_id", "tag_id"]) + ) + result = session.execute(stmt) + if result.rowcount: + tags_added += 1 + sp.commit() + session.commit() + except Exception as e: + sp.rollback() + log.warning("rescrape scene %s failed: %s", scene_id, e) + failures += 1 + + return PerformerRescrapeOut( + performer_id=performer_id, + canonical_name=perf.canonical_name, + scenes_total=scenes_total, + scenes_processed=scenes_processed, + thumbs_added=thumbs_added, + tags_added=tags_added, + failures=failures, + capped=capped, + cap_reason=cap_reason, + ) + + +@router.post("/performers/{performer_id}/refresh", response_model=PerformerRefreshOut) +def refresh_performer( + performer_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], +) -> PerformerRefreshOut: + """On-demand search across all tubes dla pojedynczego performera. Synchronous — + blokujemy aż search skończy. Mobile pokazuje spinner. + + Rate-guard: jeśli refresh był < 60s temu, zwraca cached result (HTTP 429-style + detail). Continuous worker w tle też robi swoje, więc cache jest częsty. + """ + from datetime import UTC as _UTC, datetime as _dt, timedelta as _td + + perf = session.get(Performer, performer_id) + if perf is None: + raise HTTPException(status_code=404, detail="performer not found") + + if perf.last_searched_at is not None: + elapsed = _dt.now(_UTC) - perf.last_searched_at + if elapsed < _td(seconds=60): + raise HTTPException( + status_code=429, + detail=f"recently searched {int(elapsed.total_seconds())}s ago, try in a bit", + ) + + # Lazy import — performer_driven ma ciężki connector tree + from app.scheduler.performer_driven import run_performer_driven + + # NOTE: ten request blokuje request thread API na 30-90s (search across ~25 tubes). + # Akceptowalne dla self-hosted single-user. W razie potrzeby dorobić task queue. + counters_obj = run_performer_driven( + performer_ids=[performer_id], + top_n=0, + per_performer_limit=200, + ) + + # Update last_searched_at + counter (tak samo jak continuous worker) + perf.last_searched_at = _dt.now(_UTC) + perf.search_run_count = (perf.search_run_count or 0) + 1 + session.commit() + + new_total = sum(s.get("new", 0) for s in counters_obj.per_source.values()) + return PerformerRefreshOut( + performer_id=performer_id, + canonical_name=perf.canonical_name, + counters=counters_obj.per_source, + new_scenes=new_total, + last_searched_at=perf.last_searched_at.isoformat() if perf.last_searched_at else None, + ) diff --git a/app/api/watch.py b/app/api/watch.py new file mode 100644 index 0000000..915f9a0 --- /dev/null +++ b/app/api/watch.py @@ -0,0 +1,159 @@ +"""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ą + +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 +(position_sec / duration_sec). +""" +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel +from sqlalchemy import desc, select +from sqlalchemy.orm import Session + +from app.api.scenes import _build_scene_out +from app.api.schemas import SceneOut +from app.auth import require_api_key +from app.db import get_session +from app.models.play_progress import ScenePlayProgress +from app.models.scene import Scene + +router = APIRouter(tags=["watch"], dependencies=[Depends(require_api_key)]) + + +class ProgressIn(BaseModel): + position_sec: int = 0 + duration_sec: int | None = None + finished: bool = False + + +class ProgressOut(BaseModel): + scene_id: uuid.UUID + position_sec: int + duration_sec: int | None + finished: bool + last_played_at: datetime + + +@router.post("/scenes/{scene_id}/progress", response_model=ProgressOut) +def upsert_progress( + scene_id: uuid.UUID, + body: ProgressIn, + session: Annotated[Session, Depends(get_session)], +) -> ProgressOut: + 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 + # (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 + + now = datetime.now(UTC) + position_sec = max(0, body.position_sec) + finished = body.finished or ( + bool(body.duration_sec) + and body.duration_sec > 0 + and position_sec >= int(body.duration_sec * 0.95) + ) + stmt = ( + pg_insert(ScenePlayProgress) + .values( + scene_id=scene_id, + position_sec=position_sec, + duration_sec=body.duration_sec, + finished=finished, + last_played_at=now, + ) + .on_conflict_do_update( + index_elements=["scene_id"], + set_={ + "position_sec": position_sec, + # duration_sec: zachowaj istniejący gdy body nie podaje + "duration_sec": ( + body.duration_sec + if body.duration_sec is not None + else ScenePlayProgress.duration_sec + ), + "finished": finished, + "last_played_at": now, + }, + ) + ) + session.execute(stmt) + session.commit() + row = session.get(ScenePlayProgress, scene_id) + assert row is not None + return ProgressOut( + scene_id=scene_id, + position_sec=row.position_sec, + duration_sec=row.duration_sec, + finished=row.finished, + last_played_at=row.last_played_at, + ) + + +@router.delete( + "/scenes/{scene_id}/progress", + status_code=status.HTTP_204_NO_CONTENT, +) +def remove_progress( + scene_id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], +) -> None: + row = session.get(ScenePlayProgress, scene_id) + if row is None: + return + session.delete(row) + session.commit() + + +class WatchEntry(BaseModel): + scene: SceneOut + position_sec: int + duration_sec: int | None + finished: bool + last_played_at: datetime + + +class WatchListOut(BaseModel): + items: list[WatchEntry] + + +@router.get("/watch/recent", response_model=WatchListOut) +def list_recent( + session: Annotated[Session, Depends(get_session)], + limit: int = Query(default=10, ge=1, le=50), + include_finished: bool = Query(default=False), +) -> WatchListOut: + """Top-N scen po last_played_at desc. Domyślnie pomija sceny finished + (user nie chce widzieć już dograne w continue rail).""" + stmt = ( + select(ScenePlayProgress, Scene) + .join(Scene, Scene.id == ScenePlayProgress.scene_id) + .order_by(desc(ScenePlayProgress.last_played_at)) + .limit(limit) + ) + if not include_finished: + stmt = stmt.where(ScenePlayProgress.finished.is_(False)) + + items: list[WatchEntry] = [] + for prog, scene in session.execute(stmt).all(): + items.append( + WatchEntry( + scene=_build_scene_out(session, scene), + position_sec=prog.position_sec, + duration_sec=prog.duration_sec, + finished=prog.finished, + last_played_at=prog.last_played_at, + ) + ) + return WatchListOut(items=items) diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..2936078 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,46 @@ +"""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). + +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. +Re-packaging APK innym keystorem (debug → release) wykryty natychmiast. +""" +from __future__ import annotations + +from fastapi import Header, HTTPException, status + +from app.config import get_settings + + +def require_api_key( + x_api_key: str | None = Header(default=None, alias="X-API-Key"), + authorization: str | None = Header(default=None), + x_app_signature: str | None = Header(default=None, alias="X-App-Signature"), +) -> None: + settings = get_settings() + + if settings.app_sig_check_enabled: + sig = (x_app_signature or "").strip().lower().replace(":", "") + if not sig or sig not in settings.allowed_app_sig_hashes: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="invalid or missing app signature", + ) + + if not settings.auth_enabled: + return # local/dev — wszystko otwarte + + candidate: str | None = None + if x_api_key: + candidate = x_api_key.strip() + elif authorization and authorization.lower().startswith("bearer "): + candidate = authorization[7:].strip() + + if not candidate or candidate not in settings.api_keys: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid or missing API key", + headers={"WWW-Authenticate": "Bearer"}, + ) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..fe1862d --- /dev/null +++ b/app/config.py @@ -0,0 +1,116 @@ +from functools import lru_cache + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore", case_sensitive=False) + + database_url: str = Field( + default="postgresql+psycopg://goon:goon@localhost:5432/goon", + validation_alias="DATABASE_URL", + ) + + tpdb_api_token: str | None = Field(default=None, validation_alias="TPDB_API_TOKEN") + tpdb_base_url: str = Field( + default="https://api.theporndb.net", validation_alias="TPDB_BASE_URL" + ) + + stashdb_api_key: str | None = Field(default=None, validation_alias="STASHDB_API_KEY") + stashdb_graphql_url: str = Field( + default="https://stashdb.org/graphql", validation_alias="STASHDB_GRAPHQL_URL" + ) + + log_level: str = Field(default="INFO", validation_alias="LOG_LEVEL") + + + # 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") + sentry_traces_sample_rate: float = Field( + default=0.1, validation_alias="SENTRY_TRACES_SAMPLE_RATE" + ) + + api_keys_raw: str = Field(default="", validation_alias="API_KEYS") + """Lista API keys oddzielona przecinkami. Pusta = auth wyłączony (tylko dev/local).""" + + allowed_app_sig_hashes_raw: str = Field(default="", validation_alias="ALLOWED_APP_SIG_HASH") + """Whitelist SHA256 (hex) podpisów APK akceptowane przez backend. Każdy request mobile + wysyła `X-App-Signature` z hashem signing certu (PackageManager.GET_SIGNING_CERTIFICATES). + Pusta = check wyłączony (dev/wstępny rollout). Lista = comma-separated lowercase hex. + Re-packaging APK innym keystorem zmienia hash → 403.""" + + auto_merge_threshold: float = 0.92 + review_threshold: float = 0.75 + fingerprint_hamming_max: int = 5 + title_token_set_min: int = 88 + date_window_days: int = 7 + + # APScheduler (M5). Każdy 0/None = job wyłączony. + sched_tpdb_hours: int = Field(default=6, validation_alias="GOON_SCHED_TPDB_HOURS") + sched_stashdb_hours: int = Field(default=6, validation_alias="GOON_SCHED_STASHDB_HOURS") + sched_performer_driven_hours: int = Field( + default=12, validation_alias="GOON_SCHED_PERFORMER_DRIVEN_HOURS" + ) + sched_performer_driven_top_n: int = Field( + default=20, validation_alias="GOON_SCHED_PERFORMER_DRIVEN_TOP_N" + ) + # Continuous worker. interval=15s + max_instances=1 + coalesce=True ⇒ effective rate + # = max(15, real_tick_duration). Real tick ~50-80s przy full coverage. Set to 0 to disable. + sched_performer_continuous_seconds: int = Field( + default=15, validation_alias="GOON_SCHED_PERFORMER_CONTINUOUS_SECONDS" + ) + sched_performer_continuous_refresh_days: int = Field( + default=30, validation_alias="GOON_SCHED_PERFORMER_CONTINUOUS_REFRESH_DAYS" + ) + # Movie ingest — paradisehill (primary) + dooplay mirrory (mangoporn/streamporn/ + # pandamovies). Każdy connector zapisuje swój `Source` i robi delta od ostatniego + # successful run. Set to 0 to disable. Domyślnie 24h: movie sites rosną wolniej + # niż tube'y (~5-30 nowych dziennie), nie ma sensu wymiatać częściej. + sched_movie_ingest_hours: int = Field( + default=24, validation_alias="GOON_SCHED_MOVIE_INGEST_HOURS" + ) + # Browse-latest scheduler: freshporno/porn00/pornxp newest scenes raz dziennie. + sched_browse_latest_hours: int = Field( + default=24, validation_alias="GOON_SCHED_BROWSE_LATEST_HOURS" + ) + sched_browse_latest_max_pages: int = Field( + default=5, validation_alias="GOON_SCHED_BROWSE_LATEST_MAX_PAGES" + ) + + # 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. + 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") + + @property + def api_keys(self) -> set[str]: + return {k.strip() for k in self.api_keys_raw.split(",") if k.strip()} + + @property + def auth_enabled(self) -> bool: + return bool(self.api_keys) + + @property + def allowed_app_sig_hashes(self) -> set[str]: + return { + h.strip().lower().replace(":", "") + for h in self.allowed_app_sig_hashes_raw.split(",") + if h.strip() + } + + @property + def app_sig_check_enabled(self) -> bool: + return bool(self.allowed_app_sig_hashes) + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/app/connectors/__init__.py b/app/connectors/__init__.py new file mode 100644 index 0000000..db3d9df --- /dev/null +++ b/app/connectors/__init__.py @@ -0,0 +1,48 @@ +"""Connector registry helpers. + +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ć +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, + 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 + nowy connector przy następnym tick (24h domyślnie). +4. Do ad-hoc backfillu: `python -m app.scheduler.worker --once --strategy=movies + --performers=`. + +## Czemu paradisehill first + +Paradisehill jest jedynym sourcem z chapter markerami i pełnym metadata (director, +rating, country) → idealnie kanoniczny. Dooplay mirrory rzadko mają chaptery i +release_year zwykle pusty. Resolver `resolve_movie` po title-similarity matchuje +mirror → primary paradisehill, dodając tylko playback sources (mangoporn:luluvid, +:voe, …) które rozpakowują się na bezpośredni stream URL przez +`extract_stream_from_hoster`. +""" +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. + """ + from app.connectors.dooplay import ( + MangopornConnector, + PandamoviesConnector, + StreampornConnector, + ) + from app.connectors.paradisehill import ParadisehillConnector + + return [ + ("paradisehill", ParadisehillConnector), + ("streamporn", StreampornConnector), + ("pandamovies", PandamoviesConnector), + ("mangoporn", MangopornConnector), + ] diff --git a/app/connectors/base.py b/app/connectors/base.py new file mode 100644 index 0000000..b512be4 --- /dev/null +++ b/app/connectors/base.py @@ -0,0 +1,187 @@ +"""Kontrakt connectora źródła + neutralne DTO surowych rekordów. + +Connector odpowiada za: paginację, retry, autoryzację, deltę. Zwraca strumień RawScene +(z ewentualnymi pre-rozwiniętymi performerami/studiem/tagami w polach inline). Cała +mechanika DB i normalizacji żyje wyżej w pipeline'ie ingest. +""" +from __future__ import annotations + +import abc +from collections.abc import Iterator +from datetime import date, datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.source import SourceKind + + +class RawTag(BaseModel): + model_config = ConfigDict(extra="allow") + external_id: str | None = None + name: str + slug: str | None = None + + +class RawStudio(BaseModel): + model_config = ConfigDict(extra="allow") + external_id: str | None = None + name: str + slug: str | None = None + parent_external_id: str | None = None + parent_name: str | None = None + network: str | None = None + homepage_url: str | None = None + + +class RawPerformer(BaseModel): + model_config = ConfigDict(extra="allow") + external_id: str | None = None + name: str + aliases: list[str] = Field(default_factory=list) + gender: str | None = None + birth_date: date | None = None + country: str | None = None + as_alias_in_scene: str | None = None # imię użyte w tej konkretnej scenie (np. „Mia M.") + + +class RawFingerprint(BaseModel): + kind: str # phash | oshash | md5 + value: str + + +class RawPlaybackSource(BaseModel): + """Link do odtworzenia sceny z konkretnego tube/agregatora.""" + + model_config = ConfigDict(extra="allow") + + origin: str + """Krótka nazwa źródła, np. 'tube:hqpornercom', 'mangoporn:doodstream'.""" + + page_url: str + """URL strony tube'a z player'em (deep link).""" + + embed_url: str | None = None + stream_url: str | None = None + quality: str | None = None + duration_sec: int | None = None + thumbnail_url: str | None = None + animated_thumbnail_url: str | None = None + + +class RawScene(BaseModel): + model_config = ConfigDict(extra="allow") + + external_id: str + title: str + description: str | None = None + release_date: date | None = None + duration_sec: int | None = None + code: str | None = None + director: str | None = None + url: str | None = None + + studio: RawStudio | None = None + performers: list[RawPerformer] = Field(default_factory=list) + tags: list[RawTag] = Field(default_factory=list) + fingerprints: list[RawFingerprint] = Field(default_factory=list) + playback_sources: list[RawPlaybackSource] = Field(default_factory=list) + + cross_source_refs: dict[str, str] = Field(default_factory=dict) + """Mapowanie source_name → external_id deklarowane przez to źródło. Używane do path 2 + w resolverze (cross-source UUID match). Klucz zgadza się z `Source.name` w DB + (np. 'tpdb', 'stashdb').""" + + raw: dict[str, Any] = Field(default_factory=dict) + """Oryginalny payload z API — leci do external_records.raw.""" + + +class BaseConnector(abc.ABC): + """Każde źródło dziedziczy. `kind` mapuje 1:1 na SourceKind w DB.""" + + kind: SourceKind + name: str + + @abc.abstractmethod + def fetch_scenes( + self, + *, + since: datetime | None = None, + limit: int | None = None, + ) -> Iterator[RawScene]: + """Yield po jednej scenie. `since` to delta filter (opcjonalna, fallback do full).""" + raise NotImplementedError + + +# --------------------------------------------------------------------------- +# 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, + indeksowane przez `chapter_index`. Może linkować do separate scene (jeśli ta scena + znana z TPDB/StashDB) — tym zajmuje się normalizator wyżej.""" + + model_config = ConfigDict(extra="allow") + + chapter_index: int + title: str | None = None + start_sec: int | None = None + end_sec: int | None = None + + +class RawMovie(BaseModel): + """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 + primary, ewentualnie inne tube'y). + """ + + model_config = ConfigDict(extra="allow") + + external_id: str + title: str + description: str | None = None + release_year: int | None = None + release_date: date | None = None + duration_sec: int | None = None + director: str | None = None + country: str | None = None + rating: float | None = None + poster_url: str | None = None + backdrop_url: str | None = None + url: str | None = None + + studio: RawStudio | None = None + performers: list[RawPerformer] = Field(default_factory=list) + tags: list[RawTag] = Field(default_factory=list) + chapters: list[RawMovieChapter] = Field(default_factory=list) + playback_sources: list[RawPlaybackSource] = Field(default_factory=list) + + cross_source_refs: dict[str, str] = Field(default_factory=dict) + + raw: dict[str, Any] = Field(default_factory=dict) + + +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. + """ + + kind: SourceKind + name: str + + @abc.abstractmethod + def fetch_movies( + self, + *, + since: datetime | None = None, + limit: int | None = None, + ) -> Iterator[RawMovie]: + """Yield po jednym filmie. `since` opcjonalne, fallback do full crawl.""" + raise NotImplementedError diff --git a/app/connectors/direct_scrapers/__init__.py b/app/connectors/direct_scrapers/__init__.py new file mode 100644 index 0000000..b65f05a --- /dev/null +++ b/app/connectors/direct_scrapers/__init__.py @@ -0,0 +1,166 @@ +"""Direct tube scrapers. + +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='pornapp')` (legacy nazwa — kept for DB compat) z external_id +`f"{sitetag}:{url}"`. Resolver mergeuje idempotentnie po tym kluczu. + +Search-based ścieżka (per performer name); category browse'ng przez `categoriesUrl` +overrides w pornapp connector był specyficzny dla porn-app API i zostanie usunięty. + +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 +wyników, sprawdź real search HTML + popraw template/regex w odpowiednim pliku. +""" +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.hqporner import HQPornerScraper +from app.connectors.direct_scrapers.latestleaks import LatestLeaksScraper +from app.connectors.direct_scrapers.latestpornvideo import LatestPornVideoScraper +from app.connectors.direct_scrapers.mypornerleak import MyPornerLeakScraper +from app.connectors.direct_scrapers.perverzija import PerverzijaScraper +from app.connectors.direct_scrapers.porn4days import Porn4DaysScraper +from app.connectors.direct_scrapers.pornditt import PornDittScraper +from app.connectors.direct_scrapers.porndish import PornDishScraper +from app.connectors.direct_scrapers.pornhat import PornHatScraper # noqa: F401 — kept for backref; ingest disabled +from app.connectors.direct_scrapers.pornhub import PornHubScraper +from app.connectors.direct_scrapers.porntrex import PornTrexScraper +from app.connectors.direct_scrapers.redtube import RedTubeScraper +from app.connectors.direct_scrapers.siska import SiskaScraper +from app.connectors.direct_scrapers.sxyland import SxyLandScraper +from app.connectors.direct_scrapers.sxyprn import SxyPrnScraper +from app.connectors.direct_scrapers.watchporn import WatchPornScraper +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.youporn import YouPornScraper +from app.connectors.direct_scrapers.zerodayxx import ZeroDayXXScraper + +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 + # (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 — wyłączony 2026-05-12 (source quality report): 25,596 scen, 0.1% canonical + # match. Slug-concat tytuły (`bella reese big butt ready to be filled with cum analized`) bez + # `[Studio]` lub `Studio - Perf - Title` prefixu (parse rate 3%) → resolver nie ma żadnego + # signalu do matchu. Wraps watchporn ale dziedziczy stripped metadata. Solo orphany usunięte + # (~21k scen) — plik scrapera + extractor zostają (istniejące playback_sources nadal się + # resolvują). + # Mainstream (URL templates well-known) + # PornHubScraper — wyłączony 2026-05-12 (analiza źródeł): 23,750 scen scrapnietych, + # tylko 105 (0.4%) match z TPDB/StashDB. PH hostuje głównie własne shortened + # clipy + amateur upload — nigdy nie zmatchują studio canonical content. Plik + # zostaje (extractor `pornhubcom` używa go w playback resolve dla istniejących + # playback_sources). + # RedTubeScraper — wyłączony 2026-05-12 (analiza źródeł): 20,127 scen, 82 match + # (0.4%). Same powody co PH (skrócone clipy + amateur upload). + XVideosScraper, + XnxxScraper, + XHamsterScraper, + 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. + # 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 — wyłączony 2026-05-12 (user bug-report). Wszystkie iframes to + # DoodStream variants (playmogo/d0000d/dooood/mivalyo) z CAPTCHA gate. WebView na + # mobile = black screen (player JS nie inicjalizuje się przez Turnstile). 16% + # scen solo (no backup tube), 84% multi-source — user może użyć innego tube. yt-dlp + # nie wspiera DoodStream ("Piracy"), własny resolver TBD jeśli warto. + # SiskaScraper — wyłączony 2026-05-16 (filemoon shutdown). Każda siska scena + # embeduje filemoon iframe; filemoon.to/sx/nl serwują od ~2026-05 placeholder + # "Byse Frontend" SPA bez player JS. 14,839 playback_sources mass-marked dead. + # Plik scrapera + extractor zostają (mobile spróbuje resolve → DEAD_HOSTER_RE + # filemoon blacklist → None → 503 — fine, te scenes są też dead_at-filtered). + # SiskaScraper, + # 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, + # 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, + # 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 + # może łapać sceny popularnych performerów których jeszcze nie mamy w TPDB. + # PornHatScraper — wyłączony 2026-05-18. 9,799 scen, 0.2% canonical match, 100% solo-orphan. + # Pure orphan factory — auto-screenshot thumbs nie matchują phash do canonical, slug tytuły + # nie matchują rapidfuzz, brak duration/date signals. KEEP `pornhatcom` extractor i istniejące + # playback_sources żywe — mobile może je odtwarzać; disable tylko future ingest. + # PornDittScraper — wyłączony 2026-05-12 (bug-report 64356e9b). Każdy link + # produkował nową Scene row zamiast matchować do istniejącej kanonicznej + # (TPDB/StashDB) bo pornditt ma weak signal: title + cz. performera, brak + # fingerprintu/duration/date → composite_score zawsze poniżej auto_merge + # threshold (0.92). Plik scrapera + extractor zostają (istniejące playback_sources + # nadal się resolvują, _REGISTRY w app/extractors/__init__.py odpala + # `porndittcom` → _embed_iframe.extract). Re-enable wymaga albo + # "alternative-source mode" w resolverze (match-only, never create new), + # albo bogatszej extracji metadanych (duration + fingerprint). + # Special + SxyPrnScraper, + PerverzijaScraper, + FpoxxxScraper, +] + +# 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 +# (phash Hamming 12-16). Plus rebranduje tytuły. **Wyłączony.** +# - 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 +from app.connectors.direct_scrapers.pornxp import PornXPScraper # noqa: E402 +from app.connectors.direct_scrapers.shyfap import ShyfapScraper # noqa: E402, F401 + +ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [ + FreshpornoScraper, + # 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 + # 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, + # ShyfapScraper — wyłączony 2026-05-12 (pilot fail, 0% match — orphan factory). + # Follow-up: dorobić te tubey i sprawdzić phash distance: + # - fullmovies.xxx (channel/network/pornstars/categories, brak duration) + # - 4k69.com + hdporn.gg (klony freshporno — prawdopodobnie ten sam phash hit rate) +] + +__all__ = [ + "BaseDirectTubeScraper", + "BaseBrowseScraper", + "ALL_DIRECT_SCRAPERS", + "ALL_BROWSE_SCRAPERS", +] diff --git a/app/connectors/direct_scrapers/_browse_base.py b/app/connectors/direct_scrapers/_browse_base.py new file mode 100644 index 0000000..26e791e --- /dev/null +++ b/app/connectors/direct_scrapers/_browse_base.py @@ -0,0 +1,195 @@ +"""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 +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. + +Browse jest komplementarny do search: + - search łapie sceny dla **znanych performerów** (TPDB/StashDB → tube) + - browse łapie **świeże sceny** których performer może być new dla nas + (nowicjuszka w branży nie jeszcze w TPDB → mamy ją z browse → później + canonical TPDB ingest mergeuje) + +Subclass dostarcza HTML parsing (listing → scene URLs + detail → RawScene). +""" +from __future__ import annotations + +import abc +import io +import logging +import re +from collections.abc import Iterator + +import httpx + +from app.connectors.base import RawFingerprint, RawPlaybackSource, RawScene +from app.connectors.direct_scrapers.base import BaseDirectTubeScraper +from app.extractors import browser_get + +log = logging.getLogger(__name__) + + +class BaseBrowseScraper(BaseDirectTubeScraper, abc.ABC): + """Subclass dostarcza listing/detail parsing. Base flow: + 1. for page in 1..max_pages: + 2. GET listing_url(page) + 3. extract scene URLs + 4. for each URL: + 5. GET scene detail page + 6. parse → RawScene with rich metadata + 7. yield + """ + + _timeout: float = 30.0 + """HTTP timeout per request.""" + + @abc.abstractmethod + def _listing_url(self, page: int) -> str: + """URL listing page'a 'latest-vids' (page 1 = newest).""" + + @abc.abstractmethod + def _extract_scene_urls(self, listing_html: str) -> list[str]: + """Lista absolutnych URL-i scen z listing HTML, w kolejności od najnowszej.""" + + @abc.abstractmethod + 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, + nie aborti całe browse.""" + + def latest_scenes(self, *, max_pages: int = 5) -> Iterator[RawScene]: + """Iteruje sceny od najnowszych: page 1..max_pages × N scen/page. + + Domyślnie max_pages=5 → ~100 scen per tube per run (shyfap, freshporno + ~20 scen/page). Schedulowane raz dziennie → catch-up po 24h przerwie. + + Dedup po external_id zachodzi w resolverze (path 1 same_source) — gdy + scena już była, update last_seen + skip. Więc bezpieczne nawet gdy te + same N scen pojawia się przez kilka dni. + """ + # search() nie jest implementowany przez subclass dla browse-only tube'ów — + # `BaseDirectTubeScraper.search` to abstrakt, więc dodajemy stub żeby + # przepuścić abc, ale faktyczna ścieżka pracy idzie przez latest_scenes(). + for page in range(1, max_pages + 1): + url = self._listing_url(page) + try: + res = browser_get(url, timeout=self._timeout) + html = res.text if hasattr(res, "text") else res + except Exception as e: + log.warning("%s browse listing fetch failed (page %d): %s", self.sitetag, page, e) + break + + urls = self._extract_scene_urls(html) + if not urls: + log.info("%s browse: empty listing page %d, stopping", self.sitetag, page) + break + + log.info("%s browse page %d: %d scene URLs", self.sitetag, page, len(urls)) + for scene_url in urls: + try: + res = browser_get(scene_url, timeout=self._timeout) + detail_html = res.text if hasattr(res, "text") else res + except Exception as e: + log.info("%s detail fetch failed %s: %s", self.sitetag, scene_url, e) + continue + + try: + raw = self._parse_detail(scene_url, detail_html) + except Exception as e: + log.warning("%s detail parse failed %s: %s", self.sitetag, scene_url, e) + continue + + if raw is not None: + yield raw + + # 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( + self, + query: str, + *, + page: int = 1, + limit: int | None = None, + ) -> Iterator[RawScene]: + return iter(()) + + +_META_RE_CACHE: dict[str, re.Pattern[str]] = {} + + +_PHASH_UA = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36" +) + + +def compute_thumbnail_phash(thumbnail_url: str, *, referer: str | None = None, timeout: float = 15.0) -> str | None: + """Download thumbnail + return 64-bit perceptual hash (16-char hex) lub None. + + 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ę + nawet gdy te lib-y są niedostępne (graceful degradation: phash=None → resolver + spadnie do composite scoring, jak gdyby fingerprintu nie było). + """ + try: + from PIL import Image + import imagehash + except ImportError: + log.warning("imagehash/Pillow nie zainstalowane — phash skipped") + return None + + headers = {"User-Agent": _PHASH_UA} + if referer: + headers["Referer"] = referer + try: + with httpx.Client(timeout=timeout, follow_redirects=True) as c: + r = c.get(thumbnail_url, headers=headers) + if r.status_code != 200 or not r.content: + 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 — + # convert() bierze pierwszą klatkę, którą imagehash i tak zredukuje do grey. + return str(imagehash.phash(img.convert("RGB"))) + except Exception as e: + log.info("phash compute failed for %s: %s", thumbnail_url, e) + return None + + +def meta_content(html: str, *, property: str | None = None, name: str | None = None) -> str | None: + """Wyciąga zawartość lub . + + 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 + `[^"\']*` regex tnął title po wewnętrznym apostrofie (np. `` + → `She`, bug-report 2026-05-20). Teraz matchujemy dokładnie ten sam quote co opening. + """ + key = f"prop:{property}" if property else f"name:{name}" + 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 + _META_RE_CACHE[key] = re.compile( + rf']+{attr}=["\']{val}["\'][^>]*?content="([^"]*)"' + rf'|]+{attr}=["\']{val}["\'][^>]*?content=\'([^\']*)\'', + re.IGNORECASE, + ) + m = _META_RE_CACHE[key].search(html) + if not m: + return None + val = m.group(1) if m.group(1) is not None else m.group(2) + return val.strip() if val else None diff --git a/app/connectors/direct_scrapers/_search_base.py b/app/connectors/direct_scrapers/_search_base.py new file mode 100644 index 0000000..b21e6f6 --- /dev/null +++ b/app/connectors/direct_scrapers/_search_base.py @@ -0,0 +1,238 @@ +"""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) — + 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) +""" +from __future__ import annotations + +import logging +import re +import urllib.parse +from collections.abc import Iterator + +from app.connectors.base import RawPerformer, RawPlaybackSource, RawScene, RawStudio, RawTag +from app.connectors.direct_scrapers.base import BaseDirectTubeScraper +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ą +# dla scene thumbnails. +_IMG_SRC_RE = re.compile( + r']+(?:src|data-src|data-original|data-lazy-src|data-lazy)=["\']' + r'((?://|https?://)[^"\']+\.(?:jpg|jpeg|png|webp|gif)[^"\']*)', + re.IGNORECASE, +) + +log = logging.getLogger(__name__) + + +class BaseSearchScraper(BaseDirectTubeScraper): + """Subclass dostarcza URL template + regex; reszta scraping flow shared. + + Domyślny user agent / headers wystarczą dla ~większości tubes; te które wymagają + specyficznych (np. CF protected) override'ują `_search_headers()` lub fetch całość. + """ + + #: Format URL search page'a, z `{query}` (quote_plus'ed) + `{page}` (int). + _search_url_template: str = "" + + #: 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 + + #: Minimalna długość tokena query do filtrowania wyników (krótsze ignorujemy żeby + #: nie matchowały niezwiązanych slugów). + _query_token_min_len: int = 3 + + #: Search HTTP timeout. + _timeout: float = 30.0 + + #: Slugi do odrzucenia (URL-e nawigacyjne / footer linki które matchują regex + #: ale nie są scenami). Przydatne dla WordPress-like tubes gdzie scene URL + #: pattern (`//`) zbiega się z `/categories/`, `/actors/` itp. + _nav_slug_blacklist: frozenset[str] = frozenset({ + "actors", "actor", "actress", "categories", "category", "tags", "tag", + "feed", "dmca", "contact-us", "contact", "comments", "wp-content", + "wp-admin", "wp-includes", "wp-login.php", "page", "?filter", "?s", + "about", "about-us", "privacy", "privacy-policy", "tos", "terms", + "2257", "18-u-s-c-2257", "sitemap", "sitemap.xml", + }) + + #: Window (chars) wokół scene URL match, w którym szukamy `` jako thumbnail. + #: 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.""" + try: + return m.group("url") + except IndexError: + return m.group(1) + + def _slug_from_match(self, m: re.Match[str], scene_url: str) -> str: + """Slug do filtrowania query tokens + derivation tytułu. Default: ostatni segment URL. + + Subclass override gdy regex daje explicit named group `slug`. + """ + if "slug" in m.groupdict(): + slug = m.group("slug") + if slug: + return slug + # Fallback: parsuj URL + path = urllib.parse.urlparse(scene_url).path.rstrip("/") + return path.split("/")[-1] if path else "" + + def _title_from_slug(self, slug: str) -> str: + return slug.replace("_", " ").replace("-", " ").strip() + + 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 → `-`). + """ + 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 + 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 + powinien rzucić wyjątki swobodnie, base łapie i kontynuuje bez metadata. + + Returns: (studio, performers, tags). Każde może być None / pusta lista. + """ + return None + + def search( + self, + query: str, + *, + page: int = 1, + limit: int | None = None, + ) -> Iterator[RawScene]: + if not self._search_url_template: + raise NotImplementedError(f"{type(self).__name__}._search_url_template not set") + + q = self._format_query_for_url(query) + url = self._search_url_template.format(query=q, page=page) + + try: + r = browser_get(url, timeout=self._timeout) + except Exception as e: + log.warning("%s search fetch failed: %s", self.sitetag, e) + return + if r.status_code != 200: + log.debug("%s search %s status=%d", self.sitetag, url, r.status_code) + return + + query_tokens = { + tok for tok in query.lower().split() if len(tok) >= self._query_token_min_len + } + + seen: set[str] = set() + yielded = 0 + for m in self._scene_url_re.finditer(r.text): + scene_url = self._scene_url_from_match(m).strip() + if scene_url.startswith("//"): + scene_url = "https:" + scene_url + elif scene_url.startswith("/"): + # Relative URL — prefix host z search URL. + base = urllib.parse.urlparse(url) + scene_url = f"{base.scheme}://{base.netloc}{scene_url}" + if scene_url in seen: + continue + seen.add(scene_url) + + slug = self._slug_from_match(m, scene_url) + slug_lower = slug.lower() + if slug_lower in self._nav_slug_blacklist: + continue + # Strict: WSZYSTKIE query tokens muszą być w slug. Wcześniej `any()` + # przepuszczał scenę gdy choć jeden token był w slug — dla performera + # "Ava Koxxx" (query="ava koxxx") wszystkie sceny z "ava-*" slug + # (Ava Devine, Ava Addams itp.) były labelowane jako "Ava Koxxx", + # bo `any("ava" in slug)` =True. User reports: scena "ava devine + # gangbanged..." miała Ava Koxxx w DB. Fix: `all()` — slug musi + # zawierać każdy ≥3-char token z imienia performera. + if query_tokens and not all(tok in slug_lower for tok in query_tokens): + continue + + title = self._title_from_slug(slug) + + # Thumbnail: search ±N chars around scene_url match for nearest . + # Większość tubes ma `` lub flat + # `` — window 800 obejmuje oba. + window_start = max(0, m.start() - self._thumbnail_window) + window_end = min(len(r.text), m.end() + self._thumbnail_window) + window_html = r.text[window_start:window_end] + thumb_url: str | None = None + img_m = _IMG_SRC_RE.search(window_html) + if img_m: + thumb_url = img_m.group(1).strip() + if thumb_url.startswith("//"): + thumb_url = "https:" + thumb_url + elif thumb_url.startswith("/"): + base = urllib.parse.urlparse(url) + 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. + # PornHat ma `data-setup='{...}'` w `js-ajax-{dvd,model,tag}` divach. + studio: RawStudio | None = None + extra_performers: list[RawPerformer] = [] + tags: list[RawTag] = [] + try: + meta = self._fetch_scene_metadata(scene_url) + except Exception as e: + log.debug("%s metadata fetch failed for %s: %s", self.sitetag, scene_url, e) + meta = None + if meta is not None: + studio, extra_performers, tags = meta + + # Performer z query zawsze obecny (driver scraping). Extra performers + # z detail page dorzucamy — dedupe po slug/name w resolverze. + all_performers = [RawPerformer(name=query.strip()), *extra_performers] + + yield RawScene( + external_id=f"{self.sitetag}:{scene_url}", + title=title, + url=scene_url, + playback_sources=[ + RawPlaybackSource( + origin=f"tube:{self.sitetag}", + page_url=scene_url, + thumbnail_url=thumb_url, + ) + ], + performers=all_performers, + studio=studio, + tags=tags, + raw={ + "source": f"direct_scraper:{self.sitetag}", + "query": query, + "page": page, + "url": scene_url, + "search_url": url, + "thumbnail_url": thumb_url, + }, + ) + yielded += 1 + if limit is not None and yielded >= limit: + return diff --git a/app/connectors/direct_scrapers/base.py b/app/connectors/direct_scrapers/base.py new file mode 100644 index 0000000..7354eb0 --- /dev/null +++ b/app/connectors/direct_scrapers/base.py @@ -0,0 +1,27 @@ +"""BaseDirectTubeScraper — kontrakt dla bezpośrednich scraperów tube'ów.""" +from __future__ import annotations + +import abc +from collections.abc import Iterator + +from app.connectors.base import RawScene + + +class BaseDirectTubeScraper(abc.ABC): + """Kontrakt direct scrapera. Wszystkie scrapery feedują do `Source(name='pornapp')` + żeby dziedziczyć logikę resolvera + idempotent merge per external_id.""" + + sitetag: str + """Stabilny ID tube'a — używany w external_id `f"{sitetag}:{url}"`. Zgodny + z porn-app sitetag (hqpornercom, sxylandcom, itp.).""" + + @abc.abstractmethod + def search( + self, + query: str, + *, + page: int = 1, + limit: int | None = None, + ) -> Iterator[RawScene]: + """Search tube po query (zwykle: nazwa performera). Yield RawScene per wynik.""" + raise NotImplementedError diff --git a/app/connectors/direct_scrapers/eporner.py b/app/connectors/direct_scrapers/eporner.py new file mode 100644 index 0000000..26b0ab7 --- /dev/null +++ b/app/connectors/direct_scrapers/eporner.py @@ -0,0 +1,18 @@ +"""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-//`. +""" +from __future__ import annotations + +import re + +from app.connectors.direct_scrapers._search_base import BaseSearchScraper + + +class EpornerScraper(BaseSearchScraper): + sitetag = "epornercom" + _search_url_template = "https://www.eporner.com/search/{query}/{page}/" + _scene_url_re = re.compile( + r'href="(?P/(?:hd-porn|video-[a-z0-9]+)/(?:[a-zA-Z0-9]+/)?(?P[a-zA-Z0-9_\-]+))/?"', + ) diff --git a/app/connectors/direct_scrapers/fpoxxx.py b/app/connectors/direct_scrapers/fpoxxx.py new file mode 100644 index 0000000..f53f1f7 --- /dev/null +++ b/app/connectors/direct_scrapers/fpoxxx.py @@ -0,0 +1,22 @@ +"""fpoxxx — direct HTML scrape search results. + +UWAGA: dokładna domena fpoxxx (sitetag w bazie) niekoniecznie zawiera "com" ani +"net" — porn-app DEFAULT_SITETAGS używa "fpoxxx" jako sitetag. Best-guess: fpo.xxx. + +Search: `https://fpo.xxx/page//?s=` (WordPress). +Scene URL: `https://fpo.xxx//`. +""" +from __future__ import annotations + +import re + +from app.connectors.direct_scrapers._search_base import BaseSearchScraper + + +class FpoxxxScraper(BaseSearchScraper): + sitetag = "fpoxxx" + _search_url_template = "https://fpo.xxx/page/{page}/?s={query}" + _scene_url_re = re.compile( + r'href="(?Phttps://fpo\.xxx/(?P[a-z0-9][a-z0-9\-]+))/"', + re.IGNORECASE, + ) diff --git a/app/connectors/direct_scrapers/freshporno.py b/app/connectors/direct_scrapers/freshporno.py new file mode 100644 index 0000000..dda0dc5 --- /dev/null +++ b/app/connectors/direct_scrapers/freshporno.py @@ -0,0 +1,177 @@ +"""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 +do canonical zadziała. Bonus: channel = studio 1:1 (Pure Taboo, Brazzers, etc.). + +URL patterns: + - Listing: `/` (page 1), `/2/`, `/3/`, ... (last `/391/` w czasie pisania) + - Scene: `/videos//` + - Channels: `/channels//` (= studio) + - Models: `/models//` (= performer) + - Tags: `/tags//` (= category) +""" +from __future__ import annotations + +import re +from datetime import date, datetime, timedelta +from urllib.parse import urljoin + +from app.connectors.base import ( + RawFingerprint, + RawPerformer, + RawPlaybackSource, + RawScene, + RawStudio, + RawTag, +) +from app.connectors.direct_scrapers._browse_base import ( + BaseBrowseScraper, + compute_thumbnail_phash, + meta_content, +) + +_BASE = "https://freshporno.org" +_SCENE_URL_RE = re.compile(r'href="(https://freshporno\.org/videos/[a-z0-9\-]+/)"', re.IGNORECASE) +_CHANNEL_LINK_RE = re.compile( + r'href="https://freshporno\.org/channels/([a-z0-9\-]+)/"[^>]*>([^<]+)', re.IGNORECASE +) +_MODEL_LINK_RE = re.compile( + r'href="https://freshporno\.org/models/([a-z0-9\-]+)/"[^>]*>([^<]+)', re.IGNORECASE +) +_TAG_LINK_RE = re.compile( + r'href="https://freshporno\.org/tags/([a-z0-9\-]+)/"[^>]*>([^<]+)', re.IGNORECASE +) +# Duration via
+# +# +# +# Title +#
34:34
+#
+_LISTING_CARD_RE = re.compile( + r'
' + r'.*?(?P[^<]+)
', + re.IGNORECASE | re.DOTALL, +) + +# Performer link pattern (porn00 konwencja): `/star-name//` +# (analogicznie do `/category-name/`, `/tags-name/`). +_PERFORMER_LINK_RE = re.compile( + r']*>([^<]+)', + re.IGNORECASE, +) + +# Categories: Name +_CATEGORY_LINK_RE = re.compile( + r']*>([^<]+)', + re.IGNORECASE, +) + +# Direct mp4 stream z KVS flashvars: `video_url: 'https://.../43144.mp4/?v-acctoken=...'`. +# URL może mieć cokolwiek po `.mp4`: `/?v-acctoken=...`, `?q=720p`, itp. — bierzemy +# wszystko do najbliższego `'` lub `"`. +_VIDEO_URL_RE = re.compile( + r"""video_url:\s*['"]([^'"]+\.mp4[^'"]*)['"]""", re.IGNORECASE, +) +# Wariant 720p (KVS często serwuje 360p domyślnie + 720p w `video_alt_url`). +_VIDEO_ALT_URL_RE = re.compile( + r"""video_alt_url:\s*['"]([^'"]+\.mp4[^'"]*)['"]""", re.IGNORECASE, +) + + +def _parse_mmss(s: str) -> int | None: + """`34:34` → 2074, `1:20:37` → 4837.""" + parts = s.strip().split(":") + try: + if len(parts) == 2: + return int(parts[0]) * 60 + int(parts[1]) + if len(parts) == 3: + return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2]) + except ValueError: + return None + return None + + +class Porn00Scraper(BaseBrowseScraper): + sitetag = "porn00org" + + def __init__(self) -> None: + super().__init__() + # Cache listing card meta — duration + thumb + title. Detail page nie ma + # tych pól w meta (brak og:duration), więc listing jest source of truth. + self._listing_cache: dict[str, dict] = {} + + def _listing_url(self, page: int) -> str: + if page <= 1: + return f"{_BASE}/latest-vids/" + return f"{_BASE}/latest-vids/{page}/" + + def _extract_scene_urls(self, listing_html: str) -> list[str]: + self._listing_cache = {} + seen: set[str] = set() + out: list[str] = [] + for m in _LISTING_CARD_RE.finditer(listing_html): + url = m.group("url") + if url in seen: + continue + seen.add(url) + self._listing_cache[url] = { + "title": m.group("title").strip(), + "thumb": m.group("thumb"), + "duration_sec": _parse_mmss(m.group("dur") or ""), + } + out.append(url) + return out + + def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None: + meta = self._listing_cache.get(scene_url, {}) + + # Title: og:title preferowane (cleaner), fallback do listing meta. + title = meta_content(detail_html, property="og:title") or meta.get("title") + if not title: + return None + + duration_sec = meta.get("duration_sec") + # Thumbnail: prefer og:image z detail (full-size preview), fallback listing 320x180. + thumb = meta_content(detail_html, property="og:image") or meta.get("thumb") + + # Performers — porn00 konwencja `/star-name//` (jak `/tags-name/`, + # `/category-name/`). Wszystkie linki tego pattern to performerzy. + performers: list[RawPerformer] = [] + seen_perf: set[str] = set() + for pm in _PERFORMER_LINK_RE.finditer(detail_html): + slug = pm.group(1).lower() + if slug in seen_perf or not (2 <= len(slug) <= 60): + continue + seen_perf.add(slug) + performers.append( + RawPerformer( + external_id=f"{self.sitetag}:performer:{slug}", + name=pm.group(2).strip(), + ) + ) + + # Categories → tags + tags: list[RawTag] = [] + seen_tag: set[str] = set() + for cm in _CATEGORY_LINK_RE.finditer(detail_html): + slug = cm.group(1).lower() + if slug in seen_tag: + continue + seen_tag.add(slug) + tags.append( + RawTag( + external_id=f"{self.sitetag}:tag:{slug}", + name=cm.group(2).strip(), + slug=slug, + ) + ) + + # Direct mp4 z KVS flashvars — preferujemy 720p (video_alt_url) nad 360p (video_url). + stream_url: str | None = None + if (vm := _VIDEO_ALT_URL_RE.search(detail_html)): + stream_url = vm.group(1) + elif (vm := _VIDEO_URL_RE.search(detail_html)): + stream_url = vm.group(1) + + # Phash — porn00 robi własne screenshoty (`/contents/videos_screenshots/`), + # więc canonical phash match raczej fail. Próbujemy mimo to. + fingerprints: list[RawFingerprint] = [] + if thumb: + ph = compute_thumbnail_phash(thumb, referer=_BASE + "/") + if ph: + fingerprints.append(RawFingerprint(kind="phash", value=ph)) + + playback_sources = [ + RawPlaybackSource( + origin=f"tube:{self.sitetag}", + page_url=scene_url, + duration_sec=duration_sec, + thumbnail_url=thumb, + stream_url=stream_url, + ) + ] + + return RawScene( + external_id=f"{self.sitetag}:{scene_url}", + title=title, + duration_sec=duration_sec, + url=scene_url, + studio=None, # porn00 brak studio signal + performers=performers, + tags=tags, + fingerprints=fingerprints, + playback_sources=playback_sources, + ) diff --git a/app/connectors/direct_scrapers/porn4days.py b/app/connectors/direct_scrapers/porn4days.py new file mode 100644 index 0000000..e7e8e1a --- /dev/null +++ b/app/connectors/direct_scrapers/porn4days.py @@ -0,0 +1,19 @@ +"""porn4days.pw — direct HTML scrape. + +Search: `https://porn4days.pw/page//?s=`. +Scene URL: `https://porn4days.pw//`. +""" +from __future__ import annotations + +import re + +from app.connectors.direct_scrapers._search_base import BaseSearchScraper + + +class Porn4DaysScraper(BaseSearchScraper): + sitetag = "porn4dayspw" + _search_url_template = "https://porn4days.pw/page/{page}/?s={query}" + _scene_url_re = re.compile( + r'href="(?Phttps://porn4days\.pw/(?P[a-z0-9][a-z0-9\-]+))/"', + re.IGNORECASE, + ) diff --git a/app/connectors/direct_scrapers/porndish.py b/app/connectors/direct_scrapers/porndish.py new file mode 100644 index 0000000..f25dbb7 --- /dev/null +++ b/app/connectors/direct_scrapers/porndish.py @@ -0,0 +1,19 @@ +"""porndish.com — direct HTML scrape. + +Search: `https://porndish.com/page//?s=`. +Scene URL: `https://porndish.com//`. +""" +from __future__ import annotations + +import re + +from app.connectors.direct_scrapers._search_base import BaseSearchScraper + + +class PornDishScraper(BaseSearchScraper): + sitetag = "porndishcom" + _search_url_template = "https://porndish.com/page/{page}/?s={query}" + _scene_url_re = re.compile( + r'href="(?Phttps://porndish\.com/(?P[a-z0-9][a-z0-9\-]+))/"', + re.IGNORECASE, + ) diff --git a/app/connectors/direct_scrapers/pornditt.py b/app/connectors/direct_scrapers/pornditt.py new file mode 100644 index 0000000..938c115 --- /dev/null +++ b/app/connectors/direct_scrapers/pornditt.py @@ -0,0 +1,26 @@ +"""pornditt.com — direct HTML scrape. + +KVS-style site (kt_player engine). Search URL: `/search//?from=` z slug-style +zapytaniem (spacje → `-`). Sceny renderują się na subdomenie `v.pornditt.com/videos///`, +więc regex matchuje oba (z i bez `v.` prefix). + +Sitetag `porndittcom` (legacy z porn-app DEFAULT_SITETAGS — suffix-stripped name). +""" +from __future__ import annotations + +import re + +from app.connectors.direct_scrapers._search_base import BaseSearchScraper + + +class PornDittScraper(BaseSearchScraper): + sitetag = "porndittcom" + _search_url_template = "https://pornditt.com/search/{query}/?from={page}" + _scene_url_re = re.compile( + r'href="(?Phttps://(?:v\.)?pornditt\.com/videos/(?P\d+)/(?P[a-z0-9\-]+))/"', + re.IGNORECASE, + ) + + def _format_query_for_url(self, query: str) -> str: + # KVS slug: lowercase, spacja/interpunkcja → `-`. URL-encoded (`+`) tu nie zadziała. + return re.sub(r"[^a-z0-9]+", "-", query.lower()).strip("-") diff --git a/app/connectors/direct_scrapers/pornhat.py b/app/connectors/direct_scrapers/pornhat.py new file mode 100644 index 0000000..24e12e4 --- /dev/null +++ b/app/connectors/direct_scrapers/pornhat.py @@ -0,0 +1,99 @@ +"""pornhat.com — search-mode scraper (performer-driven backfill). + +KVS engine. Search URL: `/search//` z `+` jako space separator. Scene URLs +to `/video//` (slug bez ID prefix, w przeciwieństwie do 3Movs/OK.xxx). Slug +zawiera tokens query gdy match jest relevant, więc filtruje się automatycznie. + +Auto-screenshot thumbnaile (`static.pornhat.com/contents/videos_screenshots/.../1.jpg`) +— do canonical match przez phash NIE nadają się (sprawdzone w probe 2026-05-12, 8%). +Ale wartość scrapera: discovering nowych scen performera których inne tube'y/canonical +nie mają. Mostly orphan ingest, ale dla popular performers może łapać studio scenes +których nie mamy w TPDB jeszcze. + +Metadata enrich: scene page ma `class="info-video js-ajax-{dvd,model,tag}"` div'y +z `data-setup='{"title": ..., "url": ..., "dir": ...}'` JSON. Parsujemy w +`_fetch_scene_metadata()` żeby insertować studio (dvd), dodatkowych performerów +(models), i tagi do każdej sceny. +""" +from __future__ import annotations + +import json +import logging +import re + +from app.connectors.base import RawPerformer, RawStudio, RawTag +from app.connectors.direct_scrapers._search_base import BaseSearchScraper +from app.extractors import browser_get + +log = logging.getLogger(__name__) + + +# `class="info-video js-ajax-"` ... `data-setup=''`. JSON jest +# single-quoted (HTML attribute), z double-quotes wewnątrz dla string values. +# `\1` w replacement: backreference do `` żeby wiedzieć co matchujemy. +_AJAX_DATA_RE = re.compile( + r"class=\"info-video js-ajax-(?Pdvd|model|tag)[^\"]*\"[^>]*data-setup='(?P[^']+)'", + re.IGNORECASE, +) + + +class PornHatScraper(BaseSearchScraper): + sitetag = "pornhatcom" + # Pagination KVS-style: /search/// (page=1 ALSO works z explicit `/1/`) + _search_url_template = "https://www.pornhat.com/search/{query}/{page}/" + # PornHat search HTML używa relative hrefs `/video//`. BaseSearchScraper + # automatycznie konwertuje relative → absolute via urlparse(search_url).netloc. + _scene_url_re = re.compile( + r'href="(?P(?:https://www\.pornhat\.com)?/video/(?P[a-z0-9\-]+)/)"', + re.IGNORECASE, + ) + + def _format_query_for_url(self, query: str) -> str: + # KVS: lowercase + spaces → `-` (slug-style), działa też `+` + return query.strip().lower().replace(" ", "-") + + def _fetch_scene_metadata( + self, scene_url: str + ) -> tuple[RawStudio | None, list[RawPerformer], list[RawTag]] | None: + """Fetch scene detail + parse `js-ajax-{dvd,model,tag}` data-setup JSON.""" + try: + r = browser_get(scene_url, timeout=self._timeout) + if r.status_code != 200: + return None + except Exception as e: + log.debug("pornhat detail fetch failed %s: %s", scene_url, e) + return None + + studio: RawStudio | None = None + performers: list[RawPerformer] = [] + tags: list[RawTag] = [] + + for m in _AJAX_DATA_RE.finditer(r.text): + kind = m.group("kind").lower() + try: + data = json.loads(m.group("json")) + except json.JSONDecodeError: + continue + name = (data.get("title") or "").strip() + slug = (data.get("dir") or "").strip() or None + if not name: + continue + if kind == "dvd": + # `dvd` to studio/series wrapper (np. "Adult Time"). Pierwsze + # wystąpienie bierzemy jako studio sceny — rzadko jest ich więcej. + if studio is None: + studio = RawStudio( + external_id=f"pornhatcom:dvd:{slug or name.lower()}", + name=name, + slug=slug, + ) + elif kind == "model": + performers.append(RawPerformer(name=name)) + elif kind == "tag": + tags.append(RawTag( + external_id=f"pornhatcom:tag:{slug or name.lower()}", + name=name, + slug=slug, + )) + + return studio, performers, tags diff --git a/app/connectors/direct_scrapers/pornhub.py b/app/connectors/direct_scrapers/pornhub.py new file mode 100644 index 0000000..464534d --- /dev/null +++ b/app/connectors/direct_scrapers/pornhub.py @@ -0,0 +1,24 @@ +"""PornHub.com — direct HTML scrape search results. + +Search: `https://www.pornhub.com/video/search?search=&page=` +Scene URL: `https://www.pornhub.com/view_video.php?viewkey=` +""" +from __future__ import annotations + +import re + +from app.connectors.direct_scrapers._search_base import BaseSearchScraper + + +class PornHubScraper(BaseSearchScraper): + sitetag = "pornhubcom" + _search_url_template = "https://www.pornhub.com/video/search?search={query}&page={page}" + _scene_url_re = re.compile( + r'href="(?P/view_video\.php\?viewkey=[A-Za-z0-9]+)"', + ) + + def _slug_from_match(self, m, scene_url): + # Pornhub URL nie ma slugu — używamy viewkey jako slug do query token filtering. + # Tytuł będzie derived z viewkey (krótki ID), ale faktyczny title backfilluje + # się przy resolve (yt-dlp ma metadata). + return m.group("url").split("=")[-1] diff --git a/app/connectors/direct_scrapers/porntrex.py b/app/connectors/direct_scrapers/porntrex.py new file mode 100644 index 0000000..8a5f25c --- /dev/null +++ b/app/connectors/direct_scrapers/porntrex.py @@ -0,0 +1,33 @@ +"""PornTrex.com — direct HTML scrape search results. + +Search: `https://www.porntrex.com/search//` (single page, brak ?page=). +Scene URL: `https://www.porntrex.com/video///` + +Porntrex pagination niespójne między widokami — używamy `?from=` gdy page>1. +""" +from __future__ import annotations + +import re + +from app.connectors.direct_scrapers._search_base import BaseSearchScraper + + +class PornTrexScraper(BaseSearchScraper): + sitetag = "porntrexcom" + _search_url_template = "https://www.porntrex.com/search/{query}/" + _scene_url_re = re.compile( + r'href="(?Phttps://www\.porntrex\.com/video/\d+/(?P[a-z0-9_\-]+))/?"', + re.IGNORECASE, + ) + + def search(self, query, *, page=1, limit=None): + # Porntrex używa offset w URL gdy page > 1: `/search//?from_videos=` + if page > 1: + original = self._search_url_template + self._search_url_template = f"{original.rstrip('/')}/?from_videos={page}" + try: + yield from super().search(query, page=page, limit=limit) + finally: + self._search_url_template = original + else: + yield from super().search(query, page=page, limit=limit) diff --git a/app/connectors/direct_scrapers/pornxp.py b/app/connectors/direct_scrapers/pornxp.py new file mode 100644 index 0000000..382fc0b --- /dev/null +++ b/app/connectors/direct_scrapers/pornxp.py @@ -0,0 +1,304 @@ +"""pornxp.ph — latest-vids browse scraper. + +URL patterns: + - Listing: `https://pornxp.ph/` (page 1, 72 cards) lub `?p=N` (pagination). + URL-e w listing mają randomized suffix per request (`/videos/94528971225` vs + `/videos/94528971837`) — **`data-id` (np. `94528971`) jest stable** i tego + używamy dla external_id zamiast całego URL. + - Detail: `/videos/`. + - Tags: `/tags/`. Trzy kategorie wnioskowane heurystyką + z `_classify_tag` (studio vs performer vs tag). + +Rich signals (perfekt dla canonical match scoring): + - Title (`
` w listing card + `

` na detail) + - Studio (z `
` pierwszy tag z `.com`/`.co` LUB CamelCase concat) + - Performers (z tags w `
`, Capital + space + Capital) + - Release year (regex `Released:` na detail page bodyText) + - Duration (`
MM:SS
` listing card) + - Direct mp4 streams (``) — no hoster + - Animated preview (`data-preview="//t.porn-xp.com/.../.mp4"`) + +Thumbnail: `` — relatywny, pornxp's own CDN. +Phash hit-rate niskie ale studio+performer+title fuzzy match wystarczy do canonical. +""" +from __future__ import annotations + +import logging +import re +from datetime import date +from urllib.parse import unquote, urljoin + +from app.connectors.base import ( + RawFingerprint, + RawPerformer, + RawPlaybackSource, + RawScene, + RawStudio, + RawTag, +) +from app.connectors.direct_scrapers._browse_base import ( + BaseBrowseScraper, + compute_thumbnail_phash, +) + +log = logging.getLogger(__name__) + +_BASE = "https://pornxp.ph" + +# Listing card — DOTALL bo HTML cards są wieloliniowe. +# Wariant 1 (eager): `` +# Wariant 2 (lazy): `` +# Łapiemy obie warianty — w `_parse_listing_thumb` preferujemy `data-src` nad `src`. +_LISTING_CARD_RE = re.compile( + r'
]*>' + r'\s*]*>' + r'.*?[^>]+)>' + r'.*?
(?P[^<]+)
' + r'.*?
(?P[^<]+)</div>', + re.IGNORECASE | re.DOTALL, +) +_IMG_SRC_RE = re.compile(r'\bsrc="([^"]+)"', re.IGNORECASE) +_IMG_DATASRC_RE = re.compile(r'\bdata-src="([^"]+)"', re.IGNORECASE) + +# Detail page — tags wrapper. Sometimes <div class="tags">, sometimes inline. +# Bierzemy do najbliższego </div> bo tagi tej sceny są w jednym divie. +_DETAIL_TAGS_BLOCK_RE = re.compile( + r'<div class="tags">(?P<inner>.*?)</div>', re.IGNORECASE | re.DOTALL, +) +_TAG_LINK_RE = re.compile( + r'<a\s+href="/tags/([^"]+)"[^>]*>([^<]+)</a>', re.IGNORECASE, +) +_RELEASED_RE = re.compile(r'Released:\s*(\d{4})', re.IGNORECASE) +_H1_RE = re.compile(r'<h1[^>]*>([^<]+)</h1>', re.IGNORECASE) +# Direct mp4/m3u8 sources — preferujemy 720 nad 360. Format często protocol-relative: +# `<source src="//sv.porn-xp.com/.../720.mp4">` — normalize do `https://...` w consumerze. +_SOURCE_RE = re.compile( + r'<source\s+src="(?P<url>(?:https?:)?//[^"]+\.(?:mp4|m3u8))"', + re.IGNORECASE, +) + + +def _parse_mmss(s: str) -> int | None: + """`16:12` → 972, `1:20:37` → 4837. None gdy format niepoprawny.""" + parts = s.strip().split(":") + try: + if len(parts) == 2: + return int(parts[0]) * 60 + int(parts[1]) + if len(parts) == 3: + return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2]) + except ValueError: + return None + return None + + +def _classify_tag(name: str) -> str: + """Zwraca 'studio' | 'performer' | 'tag'. + + Heurystyka oparta na sample analysis pornxp.ph tagów: + - Studio: zawiera `.` (`TheTeenBay.co`, `Clips4sale.tv`) LUB CamelCase concat + bez spacji (`LegalPorno`, `DirtyWivesClub`, `AnalMom`, `Clips4sale`) + - Performer: dokładnie 2 słowa Capital + Capital (`Alix Lynx`, `Reagan Foxx`) + - Tag/category: pozostałe — lowercase single word LUB Cap single word + (`oral`, `Lesbians`, `Incest`, `BBC`) + + Edge case: single-word studio jak "Brazzers", "Vixen" → klasyfikowane jako tag. + To akceptowalne — composite score scoring tags ma niższą wagę niż studio match, + więc fallback z 1+ performer match wystarczy. + """ + name = name.strip() + if not name: + return "tag" + if "." in name: + return "studio" + if " " in name: + parts = name.split() + if len(parts) == 2 and all(p[:1].isupper() for p in parts if p): + return "performer" + return "tag" + # No spaces: + # ALL-uppercase (BBC, POV, BDSM, MILF) → tag (skróty/akronimy) + if name.isupper(): + return "tag" + # CamelCase mix (LegalPorno, AnalMom, DirtyWivesClub) → studio + if any(c.isupper() for c in name[1:]): + return "studio" + return "tag" + + +def _slugify(name: str) -> str: + """`Alix Lynx` → `alix-lynx`. Lowercase, spaces→hyphens, alphanum only.""" + return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + + +class PornXPScraper(BaseBrowseScraper): + sitetag = "pornxpph" + + def __init__(self) -> None: + super().__init__() + # Cache listing card metadata per scene URL — populated w `_extract_scene_urls`, + # consumed w `_parse_detail`. Detail page sam nie ma `<div class="item_dur">` + # ani thumbnail URL, tylko h1+tags+sources. Cache reset per page (każde + # _extract_scene_urls override'uje). + self._listing_cache: dict[str, dict] = {} + + def _listing_url(self, page: int) -> str: + # Page 1 = homepage. Pagination `?p=N` (sprawdzone 2026-05-17 chrome devtools). + if page <= 1: + return f"{_BASE}/" + return f"{_BASE}/?p={page}" + + def _extract_scene_urls(self, listing_html: str) -> list[str]: + """Zwraca listę URL-i scen + cache'uje meta z listing card (duration, thumb, + title, data-id) w `self._listing_cache[url]`.""" + self._listing_cache = {} + seen: set[str] = set() + out: list[str] = [] + for m in _LISTING_CARD_RE.finditer(listing_html): + rel_url = m.group("url") + url = urljoin(_BASE, rel_url) + if url in seen: + continue + seen.add(url) + # Parse img_attrs: prefer data-src (lazy-load actual URL) nad src + # (placeholder spinner.svg dla lazy variant). Eager cards mają tylko src. + img_attrs = m.group("img_attrs") or "" + thumb = None + if (dm := _IMG_DATASRC_RE.search(img_attrs)): + thumb = dm.group(1) + elif (sm := _IMG_SRC_RE.search(img_attrs)): + src = sm.group(1) + # Skipnij placeholder spinner jeśli nie ma data-src. + if "spinner" not in src.lower(): + thumb = src + if thumb and not thumb.startswith("http"): + thumb = urljoin(_BASE, thumb) + self._listing_cache[url] = { + "data_id": m.group("id"), + "preview_mp4": ( + "https:" + m.group("preview") + if m.group("preview") and m.group("preview").startswith("//") + else m.group("preview") + ), + "thumb": thumb, + "duration_sec": _parse_mmss(m.group("dur") or ""), + "title": m.group("title").strip(), + } + out.append(url) + return out + + def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None: + # Listing-card meta (preferowane — detail page nie ma duration/thumb) + meta = self._listing_cache.get(scene_url, {}) + data_id = meta.get("data_id") + if not data_id: + # URL nie pasuje do listingu (random suffix mismatch po pagination redo). + # Wyciągnij data-id z URL: /videos/<id>... — pierwsze 8-10 cyfr. + id_match = re.search(r"/videos/(\d{6,12})", scene_url) + data_id = id_match.group(1) if id_match else None + + # Title: prefer h1 over listing card title (detail h1 jest cleaner) + title = meta.get("title") or "" + if (m := _H1_RE.search(detail_html)): + title = m.group(1).strip() or title + if not title: + return None + + duration_sec = meta.get("duration_sec") + thumb = meta.get("thumb") + + # Release year — `Released: 2016`. RawScene ma `release_date` (typu `date`), + # nie samo year — wpisujemy Jan 1 jako placeholder żeby resolver miał year + # signal (date proximity scoring tylko sprawdza year w composite). + release_date: date | None = None + if (m := _RELEASED_RE.search(detail_html)): + try: + year = int(m.group(1)) + if 1970 <= year <= 2100: + release_date = date(year, 1, 1) + except ValueError: + pass + + # Tags: tylko block <div class="tags">...</div> tej sceny (nie related). + studio: RawStudio | None = None + performers: list[RawPerformer] = [] + tags: list[RawTag] = [] + seen_perf_slugs: set[str] = set() + seen_tag_slugs: set[str] = set() + if (block := _DETAIL_TAGS_BLOCK_RE.search(detail_html)): + for tag_m in _TAG_LINK_RE.finditer(block.group("inner")): + url_part = tag_m.group(1) + name = tag_m.group(2).strip() + # URL-encoded space → real space. Niektóre tagi mają `%20`. + decoded_name = unquote(url_part).strip() + # Display name z anchor preferowane (czasem rożni się od URL slug). + display = name or decoded_name + kind = _classify_tag(display) + slug = _slugify(display) + if not slug: + continue + ext_id = f"{self.sitetag}:{kind}:{slug}" + if kind == "studio": + if studio is None: # pierwszy studio-tag wygrywa + studio = RawStudio(external_id=ext_id, name=display, slug=slug) + elif kind == "performer": + if slug not in seen_perf_slugs: + seen_perf_slugs.add(slug) + performers.append(RawPerformer(external_id=ext_id, name=display)) + else: + if slug not in seen_tag_slugs: + seen_tag_slugs.add(slug) + tags.append(RawTag(external_id=ext_id, name=display, slug=slug)) + + # Playback: direct mp4 streams `<source src="//sv.porn-xp.com/.../720.mp4">`. + # URL-e są protocol-relative — normalize do `https:`. Preferujemy 720 nad 360. + def _norm(u: str) -> str: + return "https:" + u if u.startswith("//") else u + + stream_url: str | None = None + all_sources = [_norm(m.group("url")) for m in _SOURCE_RE.finditer(detail_html)] + if all_sources: + for u in all_sources: + if "720" in u: + stream_url = u + break + stream_url = stream_url or all_sources[0] + + # Phash z thumbnail (pornxp własny CDN — expected niski match rate, ale + # try). Reseter ścieżek do canonical odbędzie się głównie przez + # studio+performer+year+title scoring. + fingerprints: list[RawFingerprint] = [] + if thumb: + ph = compute_thumbnail_phash(thumb, referer=_BASE + "/") + if ph: + fingerprints.append(RawFingerprint(kind="phash", value=ph)) + + # Normalize page_url: pornxp homepage serwuje random URL suffix per request + # (`/videos/94528971225` vs `/videos/94528971836` ten sam scene). PlaybackSource + # unique key to `(origin, page_url)` — bez normalize generujemy 3x duplikaty + # na każdym scrape run. Canonical URL = `/videos/<data_id>`. + canonical_url = ( + f"{_BASE}/videos/{data_id}" if data_id else scene_url + ) + playback_sources = [ + RawPlaybackSource( + origin=f"tube:{self.sitetag}", + page_url=canonical_url, + duration_sec=duration_sec, + thumbnail_url=thumb, + stream_url=stream_url, + ) + ] + + return RawScene( + external_id=f"{self.sitetag}:{data_id}" if data_id else f"{self.sitetag}:{scene_url}", + title=title, + release_date=release_date, + duration_sec=duration_sec, + url=scene_url, + studio=studio, + performers=performers, + tags=tags, + fingerprints=fingerprints, + playback_sources=playback_sources, + ) diff --git a/app/connectors/direct_scrapers/redtube.py b/app/connectors/direct_scrapers/redtube.py new file mode 100644 index 0000000..3876f24 --- /dev/null +++ b/app/connectors/direct_scrapers/redtube.py @@ -0,0 +1,22 @@ +"""RedTube.com — direct HTML scrape search results. + +Search: `https://www.redtube.com/?search=<q>&page=<n>` +Scene URL: `https://www.redtube.com/<id>` (slug nie ma w URL — viewkey-only). +""" +from __future__ import annotations + +import re + +from app.connectors.direct_scrapers._search_base import BaseSearchScraper + + +class RedTubeScraper(BaseSearchScraper): + sitetag = "redtubecom" + _search_url_template = "https://www.redtube.com/?search={query}&page={page}" + _scene_url_re = re.compile( + r'href="(?P<url>https://www\.redtube\.com/(?P<slug>\d+))"', + ) + + def _title_from_slug(self, slug): + # Numeric ID jako tytuł nie ma sensu — placeholder, title backfill przy resolve. + return f"redtube:{slug}" diff --git a/app/connectors/direct_scrapers/shyfap.py b/app/connectors/direct_scrapers/shyfap.py new file mode 100644 index 0000000..e505d88 --- /dev/null +++ b/app/connectors/direct_scrapers/shyfap.py @@ -0,0 +1,183 @@ +"""shyfap.net — latest-vids browse scraper. + +Browse-only (nie search-driven). Sitetag `shyfapnet`. Bogata metadata na detail +page'u (meta tags + body links): title, studio, performers, tags, duration, +description, upload_date, embed_url. + +Pierwszy pilot scrapera browse-mode (2026-05-12) — weryfikacja czy detail-page +metadata wystarcza do canonical match >5%. Jeśli tak → rozszerzamy o porn00, +fullmovies, pornxp, freshporno, 4k69, hdporn.gg. + +URL patterns: + - Listing: `/videos_1/` (page 1), `/videos_1/<n>/` (page 2+) + - Scene: `/video/<slug>_v<id>/` + - Embed: `/embed/<id>` (z og:video meta) +""" +from __future__ import annotations + +import re +from datetime import date, datetime +from urllib.parse import urljoin + +from app.connectors.base import RawFingerprint, RawPerformer, RawPlaybackSource, RawScene, RawStudio, RawTag +from app.connectors.direct_scrapers._browse_base import ( + BaseBrowseScraper, + compute_thumbnail_phash, + meta_content, +) + +_BASE = "https://www.shyfap.net" +_SCENE_URL_RE = re.compile(r'href="(/video/[a-z0-9\-]+_v\d+/)"', re.IGNORECASE) +_STUDIO_LINK_RE = re.compile( + r'href="/studio/([a-z0-9\-]+)_s(\d+)/"[^>]*>([^<]+)', re.IGNORECASE +) +_PORNSTAR_LINK_RE = re.compile( + r'href="/pornstar/([a-z0-9\-]+)_p(\d+)/"[^>]*>([^<]+)', re.IGNORECASE +) +_TAG_LINK_RE = re.compile( + r'href="/tag/([a-z0-9\-]+)_t(\d+)/"[^>]*>([^<]+)', re.IGNORECASE +) +# /video/<slug>_v<id>/ — id z URL używamy jako stable internal ID (np. w external_id), +# nie z meta `ya:ovs:id` żeby uniknąć rozjazdu meta vs URL. +_INTERNAL_ID_RE = re.compile(r"_v(\d+)/?$", re.IGNORECASE) + + +class ShyfapScraper(BaseBrowseScraper): + sitetag = "shyfapnet" + + def _listing_url(self, page: int) -> str: + # page 1 → /videos_1/, page 2 → /videos_1/2/ (shyfap quirk — sufiks `_1` + # zawsze, dodatkowy `/N/` dla pagination) + if page <= 1: + return f"{_BASE}/videos_1/" + return f"{_BASE}/videos_1/{page}/" + + def _extract_scene_urls(self, listing_html: str) -> list[str]: + seen: set[str] = set() + out: list[str] = [] + for m in _SCENE_URL_RE.finditer(listing_html): + rel = m.group(1) + if rel in seen: + continue + seen.add(rel) + out.append(urljoin(_BASE, rel)) + return out + + def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None: + # Title from og:title (fallback do <title> regex) + title = meta_content(detail_html, property="og:title") + if not title: + m = re.search(r"<title>([^<|]+)(?:\s*[-|])", detail_html, re.IGNORECASE) + if m: + title = m.group(1).strip() + if not title: + return None + + description = meta_content(detail_html, property="og:description") or meta_content( + detail_html, name="description" + ) + + # Duration: <meta property="video:duration" content="2436"> (seconds) + duration_sec: int | None = None + dur_str = meta_content(detail_html, property="video:duration") + if dur_str and dur_str.isdigit(): + duration_sec = int(dur_str) + + # Upload date: <meta property="ya:ovs:upload_date" content="2021-12-07T09:07:11+03:00"> + # To upload date do shyfap, NIE prawdziwa data release sceny. Jednak lepsza niż None + # bo zwykle uploaduje się w ciągu dni od release studia → dla date_proximity w + # resolverze (window 7 dni) zwykle wystarczy do match. + release_date: date | None = None + upload_str = meta_content(detail_html, property="ya:ovs:upload_date") + if upload_str: + try: + release_date = datetime.fromisoformat(upload_str).date() + except ValueError: + pass + + # Thumbnail: og:image + thumbnail_url = meta_content(detail_html, property="og:image") + + # Internal ID z URL → external_id stabilny + embed URL fallback + internal_id: str | None = None + m = _INTERNAL_ID_RE.search(scene_url) + if m: + internal_id = m.group(1) + # Embed URL: og:video (zwykle /embed/<id>) + embed_url = meta_content(detail_html, property="og:video") + if not embed_url and internal_id: + embed_url = f"{_BASE}/embed/{internal_id}" + + # Studio — pierwszy `/studio/<slug>_s<id>/` link na stronie + studio: RawStudio | None = None + m_studio = _STUDIO_LINK_RE.search(detail_html) + if m_studio: + slug, sid, name = m_studio.group(1), m_studio.group(2), m_studio.group(3).strip() + studio = RawStudio( + external_id=f"shyfapnet:studio:{sid}", + name=name, + slug=slug, + ) + + # Performers — wszyscy `/pornstar/<slug>_p<id>/` (zwykle 1-3 per scena) + performers: list[RawPerformer] = [] + seen_perf: set[str] = set() + for m_p in _PORNSTAR_LINK_RE.finditer(detail_html): + slug, pid, name = m_p.group(1), m_p.group(2), m_p.group(3).strip() + if pid in seen_perf: + continue + seen_perf.add(pid) + performers.append( + RawPerformer( + external_id=f"shyfapnet:performer:{pid}", + name=name, + ) + ) + + # Tags — wszystkie `/tag/<slug>_t<id>/` (zwykle 10-25 per scena) + tags: list[RawTag] = [] + seen_tag: set[str] = set() + for m_t in _TAG_LINK_RE.finditer(detail_html): + slug, tid, name = m_t.group(1), m_t.group(2), m_t.group(3).strip() + if tid in seen_tag: + continue + seen_tag.add(tid) + tags.append( + RawTag(external_id=f"shyfapnet:tag:{tid}", name=name, slug=slug) + ) + + # Playback source — embed_url (mobile WebView fallback). Stream extraction + # przez app/extractors/__init__.py wymaga osobnego registry entry — dla + # pilot scrapera zostawiamy embed-only (WebView), direct mp4 to follow-up. + playback_sources = [ + RawPlaybackSource( + origin=f"tube:{self.sitetag}", + page_url=scene_url, + embed_url=embed_url, + duration_sec=duration_sec, + thumbnail_url=thumbnail_url, + ) + ] + + # Perceptual hash z thumbnail. Resolver Path 3 (find_by_phash_within, + # Hamming ≤5) auto-merguje gdy TPDB/StashDB ma fingerprint tej samej sceny. + # Niezależne od shyfap title-rebrandingu — bierze się z frame'u sceny. + fingerprints: list[RawFingerprint] = [] + if thumbnail_url: + ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/") + if ph: + fingerprints.append(RawFingerprint(kind="phash", value=ph)) + + return RawScene( + external_id=f"{self.sitetag}:{scene_url}", + title=title, + description=description, + duration_sec=duration_sec, + release_date=release_date, + url=scene_url, + studio=studio, + performers=performers, + tags=tags, + fingerprints=fingerprints, + playback_sources=playback_sources, + ) diff --git a/app/connectors/direct_scrapers/siska.py b/app/connectors/direct_scrapers/siska.py new file mode 100644 index 0000000..5c08384 --- /dev/null +++ b/app/connectors/direct_scrapers/siska.py @@ -0,0 +1,19 @@ +"""siska.video — direct HTML scrape. + +Search: `https://siska.video/page/<n>/?s=<q>`. +Scene URL: `https://siska.video/<slug>/`. +""" +from __future__ import annotations + +import re + +from app.connectors.direct_scrapers._search_base import BaseSearchScraper + + +class SiskaScraper(BaseSearchScraper): + sitetag = "siskavideo" + _search_url_template = "https://siska.video/page/{page}/?s={query}" + _scene_url_re = re.compile( + r'href="(?P<url>https://siska\.video/(?P<slug>[a-z0-9][a-z0-9\-]+))/"', + re.IGNORECASE, + ) diff --git a/app/connectors/direct_scrapers/sxyland.py b/app/connectors/direct_scrapers/sxyland.py new file mode 100644 index 0000000..0a602a2 --- /dev/null +++ b/app/connectors/direct_scrapers/sxyland.py @@ -0,0 +1,78 @@ +"""SxyLandScraper — direct HTML scrape sxyland.com search. + +Search: `https://sxyland.com/?s=<query>` zwraca wyniki w formacie +`https://sxyland.com/<numeric_id>/<slug>/`. Filtrujemy linki bez numeric ID +(legal pages typu /18-u-s-c-2257/). +""" +from __future__ import annotations + +import logging +import re +import urllib.parse +from collections.abc import Iterator + +from app.connectors.base import RawPerformer, RawPlaybackSource, RawScene +from app.connectors.direct_scrapers.base import BaseDirectTubeScraper +from app.extractors import browser_get + +log = logging.getLogger(__name__) + + +_SCENE_URL_RE = re.compile(r'href="(https://sxyland\.com/(\d+)/([^"/]+))/?"') + + +class SxyLandScraper(BaseDirectTubeScraper): + sitetag = "sxylandcom" + + def search( + self, + query: str, + *, + page: int = 1, + limit: int | None = None, + ) -> Iterator[RawScene]: + q = urllib.parse.quote_plus(query.strip()) + url = f"https://sxyland.com/page/{page}/?s={q}" + try: + r = browser_get(url, timeout=30) + except Exception as e: + log.warning("sxyland search fetch failed: %s", e) + return + if r.status_code != 200: + return + + query_tokens = {tok for tok in query.lower().split() if len(tok) >= 3} + + seen: set[str] = set() + yielded = 0 + for m in _SCENE_URL_RE.finditer(r.text): + scene_url = m.group(1) + "/" + slug = m.group(3) + if scene_url in seen: + continue + seen.add(scene_url) + + slug_lower = slug.lower() + if query_tokens and not any(tok in slug_lower for tok in query_tokens): + continue + + title = slug.replace("-", " ").strip() + + yield RawScene( + external_id=f"sxylandcom:{scene_url}", + title=title, + url=scene_url, + playback_sources=[ + RawPlaybackSource(origin="tube:sxylandcom", page_url=scene_url) + ], + performers=[RawPerformer(name=query.strip())], + raw={ + "source": "direct_scraper:sxyland", + "query": query, + "page": page, + "url": scene_url, + }, + ) + yielded += 1 + if limit is not None and yielded >= limit: + return diff --git a/app/connectors/direct_scrapers/sxyprn.py b/app/connectors/direct_scrapers/sxyprn.py new file mode 100644 index 0000000..71e86d6 --- /dev/null +++ b/app/connectors/direct_scrapers/sxyprn.py @@ -0,0 +1,24 @@ +"""sxyprn.com — direct HTML scrape search results. + +Sxyprn search jest oparte na `?type=videos&query=<q>` GET endpoint który zwraca +HTML strony z linkami. Scene URL format: `https://sxyprn.com/post/<post_id>.html`. + +Page'owanie sxyprn niespójne — często single-page results dla query (~24 wyników). +""" +from __future__ import annotations + +import re + +from app.connectors.direct_scrapers._search_base import BaseSearchScraper + + +class SxyPrnScraper(BaseSearchScraper): + sitetag = "sxyprncom" + _search_url_template = "https://sxyprn.com/?type=videos&query={query}&page={page}" + _scene_url_re = re.compile( + r'href="(?P<url>/post/(?P<slug>[a-z0-9]+))\.html"', + ) + + def _title_from_slug(self, slug: str) -> str: + # sxyprn post ID to nieczytelny hash — placeholder, title backfill przy resolve. + return f"sxyprn:{slug}" diff --git a/app/connectors/direct_scrapers/watchporn.py b/app/connectors/direct_scrapers/watchporn.py new file mode 100644 index 0000000..5c3afd1 --- /dev/null +++ b/app/connectors/direct_scrapers/watchporn.py @@ -0,0 +1,19 @@ +"""watchporn.to — direct HTML scrape. + +Search: `https://watchporn.to/page/<n>/?s=<q>` (WordPress). +Scene URL: `https://watchporn.to/videos/<slug>/`. +""" +from __future__ import annotations + +import re + +from app.connectors.direct_scrapers._search_base import BaseSearchScraper + + +class WatchPornScraper(BaseSearchScraper): + sitetag = "watchporn" + _search_url_template = "https://watchporn.to/page/{page}/?s={query}" + _scene_url_re = re.compile( + r'href="(?P<url>https://watchporn\.to/videos/(?P<slug>[a-z0-9][a-z0-9\-]+))/"', + re.IGNORECASE, + ) diff --git a/app/connectors/direct_scrapers/xhamster.py b/app/connectors/direct_scrapers/xhamster.py new file mode 100644 index 0000000..77ab4e4 --- /dev/null +++ b/app/connectors/direct_scrapers/xhamster.py @@ -0,0 +1,19 @@ +"""XHamster.com — direct HTML scrape search results. + +Search: `https://xhamster.com/search/<q>?page=<n>` +Scene URL: `https://xhamster.com/videos/<slug>-<id>` +""" +from __future__ import annotations + +import re + +from app.connectors.direct_scrapers._search_base import BaseSearchScraper + + +class XHamsterScraper(BaseSearchScraper): + sitetag = "xhamstercom" + _search_url_template = "https://xhamster.com/search/{query}?page={page}" + _scene_url_re = re.compile( + r'href="(?P<url>https://xhamster\.com/videos/(?P<slug>[a-z0-9_\-]+))"', + re.IGNORECASE, + ) diff --git a/app/connectors/direct_scrapers/xmoviesforyou.py b/app/connectors/direct_scrapers/xmoviesforyou.py new file mode 100644 index 0000000..c501712 --- /dev/null +++ b/app/connectors/direct_scrapers/xmoviesforyou.py @@ -0,0 +1,19 @@ +"""xmoviesforyou.com — direct HTML scrape. + +Search: WordPress `?s=<q>` (lub `/page/<n>/?s=<q>` dla pagination). +Scene URL: `https://xmoviesforyou.com/<slug>/` (single segment). +""" +from __future__ import annotations + +import re + +from app.connectors.direct_scrapers._search_base import BaseSearchScraper + + +class XMoviesForYouScraper(BaseSearchScraper): + sitetag = "xmoviesforyoucom" + _search_url_template = "https://xmoviesforyou.com/page/{page}/?s={query}" + _scene_url_re = re.compile( + r'href="(?P<url>https://xmoviesforyou\.com/(?P<slug>[a-z0-9][a-z0-9\-]+))/"', + re.IGNORECASE, + ) diff --git a/app/connectors/direct_scrapers/xnxx.py b/app/connectors/direct_scrapers/xnxx.py new file mode 100644 index 0000000..d6b1265 --- /dev/null +++ b/app/connectors/direct_scrapers/xnxx.py @@ -0,0 +1,28 @@ +"""XNXX.com — direct HTML scrape search results. + +Search: `https://www.xnxx.com/search/<q>/<page-1>` (xnxx 0-indexed) +Scene URL: `https://www.xnxx.com/video-<id>/<slug>` +""" +from __future__ import annotations + +import re + +from app.connectors.direct_scrapers._search_base import BaseSearchScraper + + +class XnxxScraper(BaseSearchScraper): + sitetag = "xnxxcom" + # `/<page-1>` — handle override in search() by replacing {page}. + _search_url_template = "https://www.xnxx.com/search/{query}/{page}" + _scene_url_re = re.compile( + r'href="(?P<url>/video-[a-z0-9]+/(?P<slug>[a-z0-9_\-]+))"', + re.IGNORECASE, + ) + + def search(self, query, *, page=1, limit=None): + original = self._search_url_template + self._search_url_template = original.replace("{page}", str(page - 1)) + try: + yield from super().search(query, page=page, limit=limit) + finally: + self._search_url_template = original diff --git a/app/connectors/direct_scrapers/xvideos.py b/app/connectors/direct_scrapers/xvideos.py new file mode 100644 index 0000000..7223d9b --- /dev/null +++ b/app/connectors/direct_scrapers/xvideos.py @@ -0,0 +1,33 @@ +"""XVideos.com — direct HTML scrape search results. + +Search: `https://www.xvideos.com/?k=<q>&p=<page-1>` (xvideos używa 0-indexed pages) +Scene URL: `https://www.xvideos.com/video<digits>/<slug>` +""" +from __future__ import annotations + +import re +import urllib.parse + +from app.connectors.direct_scrapers._search_base import BaseSearchScraper + + +class XVideosScraper(BaseSearchScraper): + sitetag = "xvideoscom" + # 0-indexed page — w base classie computed jako `page=N`, więc override _build_url. + _search_url_template = "https://www.xvideos.com/?k={query}&p={page}" + _scene_url_re = re.compile( + r'href="(?P<url>/video[a-z0-9.\-]+/(?P<slug>[a-z0-9_\-]+))"', + re.IGNORECASE, + ) + + def search(self, query, *, page=1, limit=None): + # XVideos używa 0-indexed pages — `page=1` w API → `&p=0` w URL. + # Override żeby base class fetch'nął zewnętrzny URL z (page-1). + # Najprościej: dostosujmy URL w override przed wywołaniem super().search(). + # Ale super() używa self._search_url_template — robimy clone z poprawionym page. + original = self._search_url_template + self._search_url_template = original.replace("{page}", str(page - 1)) + try: + yield from super().search(query, page=page, limit=limit) + finally: + self._search_url_template = original diff --git a/app/connectors/direct_scrapers/xxxfreewatch.py b/app/connectors/direct_scrapers/xxxfreewatch.py new file mode 100644 index 0000000..79a0800 --- /dev/null +++ b/app/connectors/direct_scrapers/xxxfreewatch.py @@ -0,0 +1,21 @@ +"""xxxfree.watch — direct HTML scrape. + +Domain: `xxxfree.watch` (sitetag `xxxfreewatch` is legacy from porn-app DEFAULT_SITETAGS). + +Search: `https://xxxfree.watch/page/<n>/?s=<q>`. +Scene URL: `https://xxxfree.watch/<slug>/`. +""" +from __future__ import annotations + +import re + +from app.connectors.direct_scrapers._search_base import BaseSearchScraper + + +class XxxFreeWatchScraper(BaseSearchScraper): + sitetag = "xxxfreewatch" + _search_url_template = "https://xxxfree.watch/page/{page}/?s={query}" + _scene_url_re = re.compile( + r'href="(?P<url>https://xxxfree\.watch/(?P<slug>[a-z0-9][a-z0-9\-]+))/"', + re.IGNORECASE, + ) diff --git a/app/connectors/direct_scrapers/youporn.py b/app/connectors/direct_scrapers/youporn.py new file mode 100644 index 0000000..4d699d9 --- /dev/null +++ b/app/connectors/direct_scrapers/youporn.py @@ -0,0 +1,22 @@ +"""YouPorn.com — direct HTML scrape search results. + +Search: `https://www.youporn.com/search/?query=<q>&page=<n>` +Scene URL: `https://www.youporn.com/watch/<id>/<slug>/` +""" +from __future__ import annotations + +import re + +from app.connectors.direct_scrapers._search_base import BaseSearchScraper + + +class YouPornScraper(BaseSearchScraper): + sitetag = "youporncom" + _search_url_template = "https://www.youporn.com/search/?query={query}&page={page}" + _scene_url_re = re.compile( + r'href="(?P<url>/watch/(?P<id>\d+)/(?P<slug>[a-z0-9_\-]+))/?"', + re.IGNORECASE, + ) + + def _slug_from_match(self, m, scene_url): + return m.group("slug") diff --git a/app/connectors/direct_scrapers/zerodayxx.py b/app/connectors/direct_scrapers/zerodayxx.py new file mode 100644 index 0000000..629aa1a --- /dev/null +++ b/app/connectors/direct_scrapers/zerodayxx.py @@ -0,0 +1,119 @@ +"""ZeroDayXXScraper — direct HTML scrape 0dayxx.com search. + +Search: `https://0dayxx.com/page/<n>/?s=<query>`. Scene URL format: +`https://0dayxx.com/0day-porn-video/<slug>/` (lub czasem `/<category>/<slug>/`). +""" +from __future__ import annotations + +import logging +import re +import urllib.parse +from collections.abc import Iterator + +from app.connectors.base import RawPerformer, RawPlaybackSource, RawScene +from app.connectors.direct_scrapers.base import BaseDirectTubeScraper +from app.extractors import browser_get + +log = logging.getLogger(__name__) + + +_SCENE_URL_RE = re.compile( + r'href="(https://0dayxx\.com/(?:0day-porn-video|latest-porn-videos|porn-(?:bf|videos))/([^"/]+))/?"' +) +_OG_TITLE_RE = re.compile( + r'<meta\s+property="og:title"\s+content="([^"]+)"', re.IGNORECASE +) +_OG_IMAGE_RE = re.compile( + r'<meta\s+property="og:image"\s+content="([^"]+)"', re.IGNORECASE +) + + +def _fetch_detail(scene_url: str) -> tuple[str | None, str | None]: + """Pobiera 0dayxx detail page i wyciąga (real_title, thumbnail_url). + + 0dayxx jest wrapperem (embeduje watchporn.to/inne), więc duration/tagi tu + nie są — siedzą na watchporn.to. og:image jednak jest na 0dayxx i daje + miniaturkę z poprawnym wymiarem (200x200 — mała, ale lepsza niż żadna). + + Bez tego fetch'u sceny 0dayxx trafiały do dedupu z slug'iem jako title + + bez thumbnail_url — czyli z dwoma najsłabszymi sygnałami na raz, co + powodowało albo brak match'y albo false-positive merge'y (zgłoszone + 2026-05-09). + """ + try: + r = browser_get(scene_url, timeout=20) + except Exception as e: + log.debug("0dayxx detail fetch failed for %s: %s", scene_url, e) + return None, None + if r.status_code != 200: + return None, None + title = None + thumb = None + if (m := _OG_TITLE_RE.search(r.text)): + # Strip ` | 0dayxx.com Daily...` suffix (powtórki og:title czasem mają go). + title = m.group(1).split("|")[0].strip() + if (m := _OG_IMAGE_RE.search(r.text)): + thumb = m.group(1).strip() + return title, thumb + + +class ZeroDayXXScraper(BaseDirectTubeScraper): + sitetag = "0dayxxcom" + + def search( + self, + query: str, + *, + page: int = 1, + limit: int | None = None, + ) -> Iterator[RawScene]: + q = urllib.parse.quote_plus(query.strip()) + url = f"https://0dayxx.com/page/{page}/?s={q}" + try: + r = browser_get(url, timeout=30) + except Exception as e: + log.warning("0dayxx search fetch failed: %s", e) + return + if r.status_code != 200: + return + + query_tokens = {tok for tok in query.lower().split() if len(tok) >= 3} + + seen: set[str] = set() + yielded = 0 + for m in _SCENE_URL_RE.finditer(r.text): + scene_url = m.group(1) + "/" + slug = m.group(2) + if scene_url in seen: + continue + seen.add(scene_url) + + slug_lower = slug.lower() + if query_tokens and not any(tok in slug_lower for tok in query_tokens): + continue + + real_title, thumb = _fetch_detail(scene_url) + title = real_title or slug.replace("-", " ").strip() + + yield RawScene( + external_id=f"0dayxxcom:{scene_url}", + title=title, + url=scene_url, + playback_sources=[ + RawPlaybackSource( + origin="tube:0dayxxcom", + page_url=scene_url, + thumbnail_url=thumb, + ) + ], + performers=[RawPerformer(name=query.strip())], + raw={ + "source": "direct_scraper:0dayxx", + "query": query, + "page": page, + "url": scene_url, + }, + ) + yielded += 1 + if limit is not None and yielded >= limit: + return diff --git a/app/connectors/dooplay.py b/app/connectors/dooplay.py new file mode 100644 index 0000000..f1af4b5 --- /dev/null +++ b/app/connectors/dooplay.py @@ -0,0 +1,466 @@ +"""dooplay (a.k.a. PsyPlay) WordPress theme scraper — generic dla mangoporn/streamporn/pandamovies. + +Te 3 strony to dokładnie ten sam template (theme=dooplay + PsyPlay player plugin), +więc parametryzujemy connector po `(base_url, source_name)` i odpalamy 3 instancje. + +Listing: `/movies/page/N/` zwraca <a href="/movies/<slug>/"> per item. +Detail: `/movies/<slug>/` ma rich meta: + - <h1> tytuł (w class="data" wrapper) + - <a href="/year/YYYY/" rel="tag"> rok produkcji + - <a href="/studios/<slug>/" rel="tag"> studio + - <span class='duration'>NN mins.</span> długość + - <a href="/pornstar/<slug>/"> cast (multi) + - <a href="/genre/<slug>/"> tagi (multi) + - <div itemprop="description"><p>...</p></div> opis + - <span class="dt_rating_vgs" itemprop="ratingValue">N</span> rating 0-10 + - <li ... data-fl-source="<embed_url>"><a href="<embed_link>">Host</a></li> player options + +Player ma multi-host options (DoodStream, LuluStream, RPMShare etc.) — każdy embed +URL idzie jako osobny `playback_source` z origin=`{site}:{host}` żeby później mobile +mógł wybrać czyim embedem chce odpalić scenę. +""" +from __future__ import annotations + +import logging +import re +from collections.abc import Iterator +from datetime import date, datetime +from typing import Any + +import httpx + +from app.connectors.base import ( + BaseMovieConnector, + RawMovie, + RawPerformer, + RawPlaybackSource, + RawStudio, + RawTag, +) +from app.extractors import browser_get +from app.models.source import SourceKind + +log = logging.getLogger(__name__) + +USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36" +) + + +# ---- selektory (theme-agnostic — działa dla dowolnego dooplay) ----------- + +# Listing item — dwa wzorce w zależności od witryny: +# 1. mangoporn: zwykłe <a href="https://site/movies/<slug>/"> bez klasy +# (theme wyrendurował SEO-friendly URL bezpośrednio w grid) +# 2. streamporn/pandamovies: <a class="ml-mask jt" href="<base>/<slug>/"> +# (slug bez /movies/ prefix, np. /watch-xxx-...-adult-movie-online-free/) +# Łapiemy oba przez alternatywę. +_LIST_ITEM_RE = re.compile( + r'<a\s+href="(?P<url>https?://[^"]+)"[^>]*\bclass="ml-mask\b[^"]*"' + r"|" + r'<a\s+href="(?P<url2>https?://[^"]+/movies/[a-z0-9-]+/)"', + re.IGNORECASE, +) +# Tolerantny title — mangoporn (dooplay) używa <h1> w class="data", streamporn/pandamovies +# (raw PsyPlay theme) używają <h3 itemprop="name">. Łapiemy oba przez itemprop="name". +_TITLE_RE = re.compile( + r'<h[1-6][^>]*\sitemprop="name"[^>]*>([^<]+)</h[1-6]>' + r'|class="data"[^>]*>\s*<h[1-6][^>]*>([^<]+)</h[1-6]>', + re.IGNORECASE | re.DOTALL, +) +# dooplay uses /year/, raw PsyPlay uses /release-year/. Same dla pozostałych slugów — +# różne thema dziedziczą podstawowy markup ale customizują URL słowniki. +_YEAR_RE = re.compile( + r'/(?:year|release-year)/(\d{4})/"\s*rel="tag"', re.IGNORECASE +) +_STUDIO_RE = re.compile( + r'href="https?://[^/]+/(?:studios?|director)/([a-z0-9-]+)/"\s+rel="tag"[^>]*>([^<]+)</a>', + re.IGNORECASE, +) +# Duration: span class='duration' (dooplay) lub <p><strong>Duration:</strong> X hrs. Y mins.</p> (PsyPlay) +_DURATION_SPAN_RE = re.compile( + r"<span\s+class=['\"]duration['\"][^>]*>([^<]+)</span>", re.IGNORECASE +) +_DURATION_TEXT_RE = re.compile( + r"<strong>\s*Duration:\s*</strong>\s*([^<]+)<", re.IGNORECASE +) +# Release date: span class='release_date' (dooplay) lub <p><strong>Released Date:</strong> X</p> (PsyPlay) +_RELEASE_DATE_SPAN_RE = re.compile( + r"<span\s+class=['\"]release_date['\"]'?[^>]*>([^<]+)</span>", re.IGNORECASE +) +_RELEASE_DATE_TEXT_RE = re.compile( + r"<strong>\s*Released?\s*Date:\s*</strong>\s*([^<]+)<", re.IGNORECASE +) +_DESCRIPTION_RE = re.compile( + r'itemprop="description"[^>]*>(.*?)</div>', re.IGNORECASE | re.DOTALL +) +_RATING_RE = re.compile( + r'itemprop="ratingValue"[^>]*>([\d.]+)</span>', re.IGNORECASE +) +# Cast: dooplay /pornstar/, PsyPlay /actor/ +_PORNSTAR_RE = re.compile( + r'href="https?://[^/]+/(?:pornstar|actor)/([a-z0-9-]+)/"\s+rel="tag"[^>]*>([^<]+)</a>', + re.IGNORECASE, +) +# Genre: same /genre(s)/ w obu themach +_GENRE_TAG_RE = re.compile( + r'href="https?://[^/]+/genres?/([a-z0-9-]+)/"\s+rel="tag"[^>]*>([^<]+)</a>', + re.IGNORECASE, +) +# Player options: data-fl-source jest oryginalnym embed URL hostera, data-fl-url +# to page URL u hostera. Stare theme (mangoporn): `<li class="hosts-buttons-wpx">`. +# Nowe theme (pandamovies od ~2026-04): `<div class="Rtable1-cell" data-fl-url=... +# data-fl-source=...>`. Trzeba też tolerować order-independent attrs — nowe theme +# emituje url BEFORE source, stare odwrotnie. Łapiemy oba wzorce dwoma osobnymi +# regexami i konsolidujemy w `_iter_player_options`. +_PLAYER_OPTION_RE = re.compile( + r'<li[^>]*\bclass="hosts-buttons-wpx"[^>]*' + r'(?:data-fl-source="(?P<source>[^"]*)"[^>]*)?' + r'(?:data-fl-url="(?P<page>[^"]*)"[^>]*)?' + r'>\s*<a[^>]*href="(?P<href>[^"]+)"[^>]*' + r'(?:[^<]*<img[^>]+>)?\s*([^<]+?)\s*</a>', + re.IGNORECASE | re.DOTALL, +) +# Nowy markup pandamovies: `<div class="Rtable1-cell" data-fl-* ...><a href=...>HostName</a></div>`. +# Attrs są w kolejności url→source, source często pusty (`data-fl-source=""` dla +# doodstream/mixdrop/easyvidplayer). Capturujemy CAŁY opening tag w group(1) +# żeby data-fl-source należał gwarantowanie do TEGO konkretnego div (wcześniejszy +# window-lookback 600 chars mógł pickować poprzedni cell — cross-attribution +# doodstream→mixdrop entry, code-review #14). +_PLAYER_OPTION_DIV_RE = re.compile( + r'(<div[^>]*\bclass="Rtable1-cell"[^>]*>)\s*' + r'<a[^>]*href="(?P<href>[^"]+)"[^>]*' + r'(?:[^<]*<img[^>]+>)?\s*([^<]+?)\s*</a>', + re.IGNORECASE | re.DOTALL, +) +_DATA_FL_SOURCE_RE = re.compile(r'data-fl-source="([^"]*)"', re.IGNORECASE) +# Poster — JSON-LD `thumbnailUrl` jest najbardziej stabilny (każdy dooplay/PsyPlay +# theme z SEO ma JSON-LD VideoObject schema). Fallback na class="poster" img dla starych +# instalacji bez schema. Trzeci fallback: og:image meta tag. +_POSTER_JSONLD_RE = re.compile( + r'"thumbnailUrl"\s*:\s*"([^"]+\.(?:jpg|jpeg|png|webp)[^"]*)"', re.IGNORECASE +) +_POSTER_RE = re.compile( + r'class="poster"[^>]*>\s*<img\s+[^>]*src="([^"]+)"', re.IGNORECASE +) +_POSTER_OG_RE = re.compile( + r'<meta\s+property="og:image"\s+content="([^"]+)"', re.IGNORECASE +) +_DURATION_MINS_RE = re.compile(r"(\d+)\s*min", re.IGNORECASE) + + +class DooplayConnector(BaseMovieConnector): + """Generic dooplay scraper. Instantiated per-site via subclasses below.""" + + kind = SourceKind.scraper + base_url: str + name: str + + def __init__(self, *, timeout: float = 30.0): + if not getattr(self, "base_url", None): + raise RuntimeError(f"{type(self).__name__} requires class-level `base_url`") + if not getattr(self, "name", None): + raise RuntimeError(f"{type(self).__name__} requires class-level `name`") + self._timeout = timeout + + def close(self) -> None: + pass + + def _fetch(self, url: str) -> str: + """browser_get z chrome120 impersonation — psyplay sites czasem blokują + czysty httpx (Python TLS fingerprint) zwracając 500/403. curl_cffi fixuje to.""" + if not url.startswith("http"): + url = self.base_url.rstrip("/") + url + headers = { + "User-Agent": USER_AGENT, + "Accept-Language": "en-US,en;q=0.9", + "Accept": "text/html,application/xhtml+xml", + "Referer": self.base_url + "/", + } + r = browser_get(url, headers=headers, timeout=self._timeout, follow_redirects=True) + if r.status_code >= 400: + raise httpx.HTTPStatusError( + f"{r.status_code} for {url}", + request=None, # type: ignore[arg-type] + response=httpx.Response(r.status_code, text=r.text[:200]), + ) + return r.text + + def fetch_movies( + self, + *, + since: datetime | None = None, + limit: int | None = None, + ) -> Iterator[RawMovie]: + seen = 0 + page = 1 + seen_urls: set[str] = set() + while True: + try: + urls = list(self._fetch_listing(page)) + except httpx.HTTPError as e: + log.warning("%s listing page=%d failed: %s", self.name, page, e) + return + if not urls: + log.info("%s: empty page=%d, stop", self.name, page) + return + for url in urls: + if url in seen_urls: + continue + seen_urls.add(url) + try: + movie = self._fetch_detail(url) + except httpx.HTTPError as e: + log.warning("%s detail %s failed: %s", self.name, url, e) + continue + if movie is None: + continue + yield movie + seen += 1 + if limit is not None and seen >= limit: + return + page += 1 + + def _fetch_listing(self, page: int) -> Iterator[str]: + path = self._listing_path(page) + text = self._fetch(path) + from urllib.parse import urlparse + site_host = urlparse(self.base_url).hostname + for m in _LIST_ITEM_RE.finditer(text): + url = m.group("url") or m.group("url2") + if not url: + continue + try: + if urlparse(url).hostname != site_host: + continue + except Exception: + continue + yield url + + def _listing_path(self, page: int) -> str: + return "/movies/" if page == 1 else f"/movies/page/{page}/" + + def _fetch_detail(self, url: str) -> RawMovie | None: + from urllib.parse import urlparse + path = urlparse(url).path.rstrip("/") + slug = path.split("/")[-1] or "root" + text = self._fetch(url) + return _parse_dooplay_detail( + slug=slug, page_url=url, html=text, + source_name=self.name, base_url=self.base_url, + ) + + +def _parse_dooplay_detail( + *, slug: str, html: str, source_name: str, base_url: str, page_url: str | None = None +) -> RawMovie | None: + m_title = _TITLE_RE.search(html) + if not m_title: + log.warning("%s: no title in %s", source_name, slug) + return None + title = _decode_html((m_title.group(1) or m_title.group(2)).strip()) + + m_year = _YEAR_RE.search(html) + release_year = int(m_year.group(1)) if m_year else None + + studio: RawStudio | None = None + m_studio = _STUDIO_RE.search(html) + if m_studio: + studio_slug = m_studio.group(1) + studio_name = _decode_html(m_studio.group(2).strip()) + studio = RawStudio( + external_id=f"{source_name}:{studio_slug}", + name=studio_name, + slug=studio_slug, + ) + + duration_sec: int | None = None + m_dur = _DURATION_SPAN_RE.search(html) or _DURATION_TEXT_RE.search(html) + if m_dur: + text = m_dur.group(1) + # Może być "32 mins." (dooplay) albo "1 hrs. 12 mins." (PsyPlay) + m_h = re.search(r"(\d+)\s*hr", text, re.IGNORECASE) + m_m = re.search(r"(\d+)\s*min", text, re.IGNORECASE) + if m_h or m_m: + duration_sec = (int(m_h.group(1)) * 3600 if m_h else 0) + (int(m_m.group(1)) * 60 if m_m else 0) + + release_date: date | None = None + m_rd = _RELEASE_DATE_SPAN_RE.search(html) or _RELEASE_DATE_TEXT_RE.search(html) + if m_rd: + text = m_rd.group(1).strip() + for fmt in ("%B %d, %Y", "%b %d, %Y", "%Y-%m-%d"): + try: + release_date = datetime.strptime(text, fmt).date() + break + except ValueError: + continue + + description: str | None = None + m_desc = _DESCRIPTION_RE.search(html) + if m_desc: + description = _decode_html(_strip_tags(m_desc.group(1))).strip() or None + + rating: float | None = None + m_rating = _RATING_RE.search(html) + if m_rating: + try: + rating = float(m_rating.group(1)) + except ValueError: + pass + + poster_url: str | None = None + for rgx in (_POSTER_JSONLD_RE, _POSTER_RE, _POSTER_OG_RE): + m = rgx.search(html) + if m: + candidate = m.group(1).strip() + if candidate and "blank.gif" not in candidate and "no-poster" not in candidate: + poster_url = candidate + break + + # Performers — tylko sekcja "Pornstars" ma /pornstar/<slug>/ linki, dooplay + # filtruje cast w tej sekcji. Jaccard może łapać dubel ale dedup robimy w + # resolverze (po performer_id). + performers = [ + RawPerformer( + external_id=f"{source_name}:{m.group(1)}", + name=_decode_html(m.group(2).strip()), + ) + for m in _PORNSTAR_RE.finditer(html) + ] + + tags = [ + RawTag( + external_id=f"{source_name}:{m.group(1)}", + name=_decode_html(m.group(2).strip()), + slug=m.group(1), + ) + for m in _GENRE_TAG_RE.finditer(html) + ] + + if page_url is None: + page_url = f"{base_url}/movies/{slug}/" + + # Playback sources: każdy host (Doodstream/Lulu/RPM/...) jako osobny entry. + # Dedup po href żeby ten sam host nie wpadł 2x. Raw landing page (origin= + # source_name, bez :host) appendujemy TYLKO gdy nie ma żadnych sub-hosters — + # inaczej myli usera (otwiera WebView z reklamami zamiast video; bug-report + # 2026-05-16: "mangoporn przekierowuje do strony, reklama full screen"). + playback_sources: list[RawPlaybackSource] = [] + seen_hrefs: set[str] = set() + + # Hostery file-download (non-streamable) + malware. Mobile player nie potrafi + # ich odtworzyć — rapidgator/nitroflare/frdl serwują .zip/.rar/.mp4 do download + # (premium login required), streamtape ma malware drive-by .reg. Skipujemy + # przy ingest żeby nie zaśmiecać UI martwym contentem (bug-report 2026-05-18). + SKIP_HOSTERS = {"rapidgator", "nitroflare", "nitro", "frdl", "streamtape"} + + def _emit_host_entry(href: str, source: str | None) -> None: + href = href.strip() + if not href or href in seen_hrefs: + return + seen_hrefs.add(href) + try: + from urllib.parse import urlparse + host = urlparse(href).hostname or "unknown" + host_short = host.split(".")[-2] if host.count(".") >= 1 else host + except Exception: + host_short = "unknown" + if host_short.lower() in SKIP_HOSTERS: + return + playback_sources.append( + RawPlaybackSource( + origin=f"{source_name}:{host_short}", + page_url=href, + embed_url=source or href, + thumbnail_url=poster_url, + duration_sec=duration_sec, + ) + ) + + # Stary `<li class="hosts-buttons-wpx">` markup (mangoporn). + for m in _PLAYER_OPTION_RE.finditer(html): + _emit_host_entry(m.group("href") or "", (m.group("source") or "").strip() or None) + + # Nowy `<div class="Rtable1-cell">` markup (pandamovies od ~2026-04 + nowe + # streamporn instances). data-fl-source jest opcjonalny — capturujemy CAŁY + # opening tag w group(1), data-fl-source extract z TEGO tagu (nie z window + # lookback po HTMLu, bo to mogło pickować poprzedni cell). + for m in _PLAYER_OPTION_DIV_RE.finditer(html): + href = m.group("href") or "" + opening_tag = m.group(1) + src_match = _DATA_FL_SOURCE_RE.search(opening_tag) + source = (src_match.group(1).strip() if src_match else "") or None + _emit_host_entry(href, source) + + if not playback_sources: + # Brak sub-hosters znalezionych — fallback do landing page (mobile otworzy + # w WebView). Robimy to TYLKO gdy nie ma alternatyw, inaczej landing jest + # niepotrzebnym ad-pageiem. + playback_sources.append( + RawPlaybackSource( + origin=source_name, + page_url=page_url, + thumbnail_url=poster_url, + ) + ) + + return RawMovie( + external_id=slug, + title=title, + description=description, + release_year=release_year, + release_date=release_date, + duration_sec=duration_sec, + rating=rating, + poster_url=poster_url, + url=page_url, + studio=studio, + performers=performers, + tags=tags, + playback_sources=playback_sources, + raw={"slug": slug, "html_len": len(html)}, + ) + + +# ---- per-site instances ---------------------------------------------------- + +class StreampornConnector(DooplayConnector): + name = "streamporn" + base_url = "https://streamporn.nl" + + +class PandamoviesConnector(DooplayConnector): + name = "pandamovies" + base_url = "https://pandamovies.pw" + + +class MangopornConnector(DooplayConnector): + name = "mangoporn" + base_url = "https://mangoporn.net" + + +# --------------------------------------------------------------------------- +# Helpers (zduplikowane z paradisehill.py — celowo, żeby connectory były niezależne) +# --------------------------------------------------------------------------- + +_TAG_RE = re.compile(r"<[^>]+>") + + +def _strip_tags(s: str) -> str: + return _TAG_RE.sub("", s) + + +_HTML_ENTITIES = { + "&": "&", "<": "<", ">": ">", """: '"', "'": "'", + "'": "'", " ": " ", "’": "'", "‘": "'", + "”": '"', "“": '"', "…": "...", "—": "—", "–": "–", +} + + +def _decode_html(s: str) -> str: + for k, v in _HTML_ENTITIES.items(): + s = s.replace(k, v) + s = re.sub(r"&#(\d+);", lambda m: chr(int(m.group(1))), s) + s = re.sub(r"&#x([0-9a-fA-F]+);", lambda m: chr(int(m.group(1), 16)), s) + return s diff --git a/app/connectors/paradisehill.py b/app/connectors/paradisehill.py new file mode 100644 index 0000000..f2f07e1 --- /dev/null +++ b/app/connectors/paradisehill.py @@ -0,0 +1,325 @@ +"""Paradisehill connector — primary source dla movies (full-length adult films). + +Site notes: +- Age-gate: wymagany cookie `is18=1` (POST /is18/ zwraca 400 z curla, ale samo dorzucenie + cookie do GET-a działa — site jest tolerancyjny). +- Listing: `/all/?sort=created_at&page=N` — paginacja po 28 filmów, mikro-data Schema.org Movie. +- Detail: `/<hex_id>/` — pełne meta + Video.js playlist (chaptery jako "Part 1/2/3"). + +Co ekstraktujemy: +- Schema.org microdata: name, description, director, datePublished (upload), image, thumbnailUrl +- Studio: link `/studio/<id>/{name}` (tylko link dostarcza nazwę i external_id) +- Genres: ze Schema.org `itemprop="genre"` (pierwszy = movie's main genre) +- Year: parsowany z description gdy obecny ("This 1999 film..."), bo `datePublished` to upload_date +- Chapters: liczba `<li>...Part N</li>` w playliście Video.js +- Playback: na MVP `page_url` only — Video.js playlist URL jest dynamicznie ładowany przez JS + i wymaga login session. Mobile może otworzyć page w WebView (degradacja lepsza niż brak). + +External_id: hex slug z URL-a (np. `259448f6b75ee` z `/259448f6b75ee/`). +""" +from __future__ import annotations + +import logging +import re +from collections.abc import Iterator +from datetime import UTC, date, datetime +from typing import Any + +import httpx + +from app.connectors.base import ( + BaseMovieConnector, + RawMovie, + RawMovieChapter, + RawPerformer, + RawPlaybackSource, + RawStudio, + RawTag, +) +from app.models.source import SourceKind + +log = logging.getLogger(__name__) + +BASE_URL = "https://paradisehill.cc" +USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36" +) +LISTING_PATH = "/all/" # ?sort=created_at&page=N +SOURCE_NAME = "paradisehill" + + +# Microdata extraction — Schema.org tagi są stabilne i niezagubione przy lekkich +# zmianach themu (yii2 widget renderuje je inwariantnie). +_TITLE_RE = re.compile( + r'<h1\s+class="title-inside"\s+itemprop="name">([^<]+)</h1>', re.IGNORECASE +) +_DIRECTOR_RE = re.compile(r'itemprop="director">([^<]+)</', re.IGNORECASE) +_DESCRIPTION_RE = re.compile( + r'itemprop="description">([^<]+(?:<[^>]+>[^<]+)*)</span>', re.IGNORECASE | re.DOTALL +) +_DATE_PUBLISHED_RE = re.compile( + r'itemprop="datePublished"\s+content="([^"]+)"', re.IGNORECASE +) +_POSTER_RE = re.compile( + r'<img\s+itemprop="image"\s+src="(/images/[^"]+)"', re.IGNORECASE +) +_THUMBNAIL_RE = re.compile( + r'<img\s+itemprop="thumbnailUrl"\s+src="(/images/[^"]+)"', re.IGNORECASE +) +_STUDIO_LINK_RE = re.compile(r'<a\s+href="/studio/(\d+)/"[^>]*>([^<]+)</a>', re.IGNORECASE) +_CHAPTER_RE = re.compile( + r'<a\s+href="#"\s+class="js-list-item"\s+data-index="(\d+)">([^<]+)</a>', + re.IGNORECASE, +) +# Listing page item: +_LIST_ITEM_RE = re.compile( + r'<div\s+class="item\s+list-film-item"[^>]*>\s*' + r'<a\s+href="/([0-9a-f]+)/"[^>]*>', + re.IGNORECASE, +) +# Year w description: szukamy 4-cyfrowego roku w sensownym zakresie +_YEAR_IN_DESC_RE = re.compile(r"\b(19[5-9]\d|20[0-3]\d)\b") +# Year w tytule (np. "Title (1999)") +_YEAR_IN_TITLE_RE = re.compile(r"\((\d{4})\)") + + +class ParadisehillConnector(BaseMovieConnector): + kind = SourceKind.scraper + name = SOURCE_NAME + + def __init__(self, *, timeout: float = 30.0): + self._client = httpx.Client( + base_url=BASE_URL, + timeout=timeout, + follow_redirects=True, + headers={ + "User-Agent": USER_AGENT, + # Wszystkie requesty wymagają is18 cookie. Pre-set żeby ominąć age-gate. + "Cookie": "is18=1", + "Accept-Language": "en-US,en;q=0.9", + "Accept": "text/html,application/xhtml+xml", + }, + ) + + def close(self) -> None: + self._client.close() + + def fetch_movies( + self, + *, + since: datetime | None = None, + limit: int | None = None, + ) -> Iterator[RawMovie]: + """Crawluje listing `/all/?sort=created_at` chronologicznie (najnowsze first). + + `since`: stop gdy datePublished < since. `limit`: stop po N filmach. + Aktualnie 28 movies/page; site rośnie ~5/dzień, więc pełen crawl to ~tysiące + stron — w prod używamy `since` żeby zobaczyć tylko delta od poprzedniego runa. + """ + seen = 0 + page = 1 + while True: + try: + ids = list(self._fetch_listing_page(page)) + except httpx.HTTPError as e: + log.warning("paradisehill listing page=%d failed: %s", page, e) + return + + if not ids: + log.info("paradisehill: empty listing page=%d, stop", page) + return + + for mid in ids: + try: + movie = self._fetch_detail(mid) + except httpx.HTTPError as e: + log.warning("paradisehill detail %s failed: %s", mid, e) + continue + if movie is None: + continue + + # `since` filter — datePublished poniżej threshold = stop crawla, + # bo listing jest chronologiczny. since z `_last_successful_finished_at` + # jest TZ-aware (UTC); combine() daje naive — przywróć UTC tzinfo żeby + # porównanie nie crashowało. + if since is not None and movie.release_date is not None: + rd_dt = datetime.combine( + movie.release_date, datetime.min.time(), tzinfo=UTC + ) + if rd_dt < since: + log.info( + "paradisehill: hit since boundary at %s (%s), stop", + mid, movie.release_date, + ) + return + + yield movie + seen += 1 + if limit is not None and seen >= limit: + return + + page += 1 + + def _fetch_listing_page(self, page: int) -> Iterator[str]: + """Yielduje hex IDs filmów na danej stronie.""" + url = f"{LISTING_PATH}?sort=created_at&page={page}" + r = self._client.get(url) + r.raise_for_status() + for m in _LIST_ITEM_RE.finditer(r.text): + yield m.group(1) + + def _fetch_detail(self, hex_id: str) -> RawMovie | None: + url = f"/{hex_id}/" + r = self._client.get(url) + r.raise_for_status() + return _parse_detail(hex_id, r.text) + + +def _parse_detail(hex_id: str, html: str) -> RawMovie | None: + """Parsuje detail HTML → RawMovie. Zwraca None gdy brak title (skopany template).""" + m_title = _TITLE_RE.search(html) + if not m_title: + log.warning("paradisehill: no title in detail %s", hex_id) + return None + title = _decode_html(m_title.group(1).strip()) + + m_director = _DIRECTOR_RE.search(html) + director = _decode_html(m_director.group(1).strip()) if m_director else None + if director and director.lower() in ("unknown", "n/a", "-"): + director = None + + m_desc = _DESCRIPTION_RE.search(html) + description = _decode_html(_strip_tags(m_desc.group(1)).strip()) if m_desc else None + + release_date: date | None = None + m_date = _DATE_PUBLISHED_RE.search(html) + if m_date: + try: + release_date = datetime.fromisoformat(m_date.group(1)).date() + except ValueError: + pass + + # Year — najpierw z tytułu, potem z opisu. datePublished to upload date paradisehill + # (np. 2026-05) a nie production year (np. 1999) — useless dla year filtering. + release_year: int | None = None + m_yt = _YEAR_IN_TITLE_RE.search(title) + if m_yt: + release_year = int(m_yt.group(1)) + elif description: + m_yd = _YEAR_IN_DESC_RE.search(description) + if m_yd: + release_year = int(m_yd.group(1)) + + poster_url: str | None = None + m_poster = _POSTER_RE.search(html) + if m_poster: + poster_url = BASE_URL + m_poster.group(1) + backdrop_url: str | None = None + m_thumb = _THUMBNAIL_RE.search(html) + if m_thumb: + backdrop_url = BASE_URL + m_thumb.group(1) + + studio: RawStudio | None = None + m_studio = _STUDIO_LINK_RE.search(html) + if m_studio: + studio = RawStudio( + external_id=f"paradisehill:{m_studio.group(1)}", + name=_decode_html(m_studio.group(2).strip()), + ) + + # Genre — pierwszy itemprop="genre" w samym block-inside (nie w recommendations). + # Recommended films też mają itemprop="genre" więc match limity do block-inside. + tags: list[RawTag] = [] + block_match = re.search( + r'<div\s+class="block-inside"[^>]*itemtype="http://schema\.org/Movie"[^>]*>' + r'(.*?)</div>\s*</div>\s*<div\s+class="similar', + html, + re.DOTALL, + ) + block = block_match.group(1) if block_match else html[:8000] + for m_genre in re.finditer(r'itemprop="genre"[^>]*>([^<]+)</', block, re.IGNORECASE): + name = _decode_html(m_genre.group(1).strip()) + if name and len(tags) < 10: + tags.append(RawTag(name=name, slug=_slugify(name))) + + chapters: list[RawMovieChapter] = [] + for m_ch in _CHAPTER_RE.finditer(html): + chapters.append( + RawMovieChapter( + chapter_index=int(m_ch.group(1)), + title=_decode_html(m_ch.group(2).strip()), + ) + ) + + page_url = f"{BASE_URL}/{hex_id}/" + playback_sources = [ + RawPlaybackSource( + origin=SOURCE_NAME, + page_url=page_url, + thumbnail_url=poster_url, + ) + ] + + return RawMovie( + external_id=hex_id, + title=title, + description=description, + release_year=release_year, + release_date=release_date, + director=director, + poster_url=poster_url, + backdrop_url=backdrop_url, + url=page_url, + studio=studio, + performers=[], # Paradisehill rzadko ma cast linki — uzupełnimy przez mirrory. + tags=tags, + chapters=chapters, + playback_sources=playback_sources, + raw={"hex_id": hex_id, "html_len": len(html)}, + ) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_TAG_RE = re.compile(r"<[^>]+>") + + +def _strip_tags(s: str) -> str: + return _TAG_RE.sub("", s) + + +_HTML_ENTITIES = { + "&": "&", + "<": "<", + ">": ">", + """: '"', + "'": "'", + "'": "'", + " ": " ", + "’": "'", + "‘": "'", + "”": '"', + "“": '"', + "…": "...", + "—": "—", + "–": "–", +} + + +def _decode_html(s: str) -> str: + for k, v in _HTML_ENTITIES.items(): + s = s.replace(k, v) + # Numeric entities + s = re.sub(r"&#(\d+);", lambda m: chr(int(m.group(1))), s) + s = re.sub(r"&#x([0-9a-fA-F]+);", lambda m: chr(int(m.group(1), 16)), s) + return s + + +_SLUG_RE = re.compile(r"[^a-z0-9]+") + + +def _slugify(s: str) -> str: + return _SLUG_RE.sub("-", s.lower()).strip("-") or "tag" diff --git a/app/connectors/stashdb.py b/app/connectors/stashdb.py new file mode 100644 index 0000000..a79cc7e --- /dev/null +++ b/app/connectors/stashdb.py @@ -0,0 +1,405 @@ +"""StashDB GraphQL connector. + +Endpoint: https://stashdb.org/graphql (auth: header `ApiKey: <key>`) + +Query używamy `queryScenes(input: {sort, direction, page, per_page})`. StashDB nie udostępnia +typowego date-since filtra w SceneQueryInput, więc deltę robimy klient-side: sortujemy po +UPDATED_AT DESC i przerywamy gdy `updated < since`. + +Schema fields kluczowe (wg https://github.com/stashapp/stash-box/blob/master/graphql/schema/schema.graphql): + Scene { id title details date duration director code urls{url site{name}} + studio{id name parent{id name}} + performers{ as performer{ id name aliases gender birthdate{date} country } } + tags{ id name } + fingerprints{ hash algorithm duration } } + +Cross-reference do TPDB: `urls[].site.name` zwykle zawiera "ThePornDB" + URL z UUID +(format: https://theporndb.net/scenes/<uuid>). Wyciągamy ten UUID jako tpdb cross-ref; +ingest_orchestrator może go potem użyć do path 2 (cross-source UUID). +""" +from __future__ import annotations + +import logging +import re +from collections.abc import Iterator +from datetime import UTC, date, datetime +from typing import Any + +import httpx +from tenacity import ( + retry, + retry_if_exception_type, + stop_after_attempt, + wait_exponential, +) + +from app.config import get_settings +from app.connectors.base import ( + BaseConnector, + RawFingerprint, + RawPerformer, + RawScene, + RawStudio, + RawTag, +) +from app.models.source import SourceKind + +log = logging.getLogger(__name__) + + +SCENES_QUERY = """ +query QScenes($input: SceneQueryInput!) { + queryScenes(input: $input) { + count + scenes { + id + title + details + release_date + date + duration + director + code + updated + urls { url site { name } } + studio { + id name + parent { id name } + } + performers { + as + performer { + id + name + aliases + gender + birthdate { date } + country + } + } + tags { id name } + fingerprints { hash algorithm duration } + } + } +} +""" + +# UUID v4-ish pattern (relaxed) +_UUID_RE = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.I) + + +class StashDBConnector(BaseConnector): + kind = SourceKind.stashdb + name = "stashdb" + + def __init__( + self, + *, + api_key: str | None = None, + url: str | None = None, + per_page: int = 100, + timeout: float = 30.0, + ) -> None: + settings = get_settings() + self.api_key = api_key or settings.stashdb_api_key + if not self.api_key: + raise RuntimeError("STASHDB_API_KEY is not set") + self.url = url or settings.stashdb_graphql_url + self.per_page = per_page + self.timeout = timeout + + def _client(self) -> httpx.Client: + return httpx.Client( + headers={ + "ApiKey": self.api_key, + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "goon/0.1", + }, + timeout=self.timeout, + ) + + @retry( + retry=retry_if_exception_type((httpx.TransportError, httpx.HTTPStatusError)), + wait=wait_exponential(multiplier=1, min=2, max=30), + stop=stop_after_attempt(5), + reraise=True, + ) + def _post(self, client: httpx.Client, payload: dict[str, Any]) -> dict[str, Any]: + resp = client.post(self.url, json=payload) + if resp.status_code == 429: + raise httpx.HTTPStatusError("rate limited", request=resp.request, response=resp) + resp.raise_for_status() + body = resp.json() + if errors := body.get("errors"): + raise RuntimeError(f"stashdb graphql errors: {errors}") + return body["data"] + + def fetch_scenes( + self, + *, + since: datetime | None = None, + limit: int | None = None, + ) -> Iterator[RawScene]: + yield from self._paginate( + extra_input={"sort": "UPDATED_AT", "direction": "DESC"}, + since=since, + limit=limit, + ) + + def find_performer_id_by_name(self, name: str) -> str | None: + """queryPerformers(input: {name: <name>}) → pierwszy result. + + StashDB GraphQL `name` to filter substring (case-insensitive). Zwracamy id + performera o exact match (case-insensitive) jeśli jest, inaczej pierwszy z listy. + """ + query = ( + "query QPerformers($input: PerformerQueryInput!) {" + " queryPerformers(input: $input) { performers { id name } }" + "}" + ) + variables = {"input": {"name": name, "per_page": 5}} + with self._client() as client: + try: + data = self._post(client, {"query": query, "variables": variables}) + except Exception as e: + log.warning("stashdb queryPerformers name=%s failed: %s", name, e) + return None + performers = (data.get("queryPerformers") or {}).get("performers") or [] + if not performers: + return None + target = name.strip().lower() + for p in performers: + if (p.get("name") or "").strip().lower() == target: + return p.get("id") + return performers[0].get("id") + + def fetch_scenes_for_performer( + self, + performer_external_id: str, + *, + limit: int | None = None, + ) -> Iterator[RawScene]: + """Wszystkie sceny StashDB dla performera o podanym kanonicznym UUID. + + StashDB SceneQueryInput.performers = MultiIDCriterionInput { value, modifier }. + Modifier INCLUDES = scena ma WSZYSTKIE wymienione UUID-y; przy 1 UUID = po prostu + sceny tego performera. + """ + yield from self._paginate( + extra_input={ + "performers": { + "value": [performer_external_id], + "modifier": "INCLUDES", + }, + "sort": "DATE", + "direction": "DESC", + }, + since=None, # przy performer-scoped pull bierzemy całą historię + limit=limit, + ) + + def fetch_scenes_for_studio( + self, + studio_external_id: str, + *, + limit: int | None = None, + ) -> Iterator[RawScene]: + """Wszystkie sceny StashDB dla studio o podanym kanonicznym UUID. + + Analogiczne do fetch_scenes_for_performer ale `studios` zamiast `performers`. + StashDB SceneQueryInput.studios = MultiIDCriterionInput { value, modifier }. + """ + yield from self._paginate( + extra_input={ + "studios": { + "value": [studio_external_id], + "modifier": "INCLUDES", + }, + "sort": "DATE", + "direction": "DESC", + }, + since=None, + limit=limit, + ) + + def _paginate( + self, + *, + extra_input: dict[str, Any], + since: datetime | None, + limit: int | None, + ) -> Iterator[RawScene]: + emitted = 0 + page = 1 + with self._client() as client: + while True: + variables = { + "input": { + "page": page, + "per_page": self.per_page, + **extra_input, + } + } + data = self._post(client, {"query": SCENES_QUERY, "variables": variables}) + payload = data.get("queryScenes") or {} + scenes = payload.get("scenes") or [] + if not scenes: + return + + for raw in scenes: + if since is not None and _updated_before(raw, since): + return + parsed = _parse_scene(raw) + if parsed is None: + continue + yield parsed + emitted += 1 + if limit is not None and emitted >= limit: + return + + if len(scenes) < self.per_page: + return + page += 1 + + +def _updated_before(raw: dict[str, Any], since: datetime) -> bool: + upd = raw.get("updated") + if not upd: + return False + try: + ts = datetime.fromisoformat(upd.replace("Z", "+00:00")) + except ValueError: + return False + if ts.tzinfo is None: + ts = ts.replace(tzinfo=UTC) + return ts < since + + +def _parse_date(value: Any) -> date | None: + if not value: + return None + if isinstance(value, date): + return value + text = str(value).strip() + if not text: + return None + try: + return date.fromisoformat(text[:10]) + except ValueError: + return None + + +def _parse_studio(raw: dict[str, Any] | None) -> RawStudio | None: + if not raw: + return None + parent = raw.get("parent") or {} + return RawStudio( + external_id=raw.get("id"), + name=raw.get("name") or "Unknown", + slug=None, + parent_external_id=parent.get("id"), + parent_name=parent.get("name"), + ) + + +def _parse_performer(raw: dict[str, Any]) -> RawPerformer | None: + perf = raw.get("performer") or {} + name = perf.get("name") + if not name: + return None + aliases = perf.get("aliases") or [] + if isinstance(aliases, str): + aliases = [a.strip() for a in aliases.split(",") if a.strip()] + bd_obj = perf.get("birthdate") or {} + bd = bd_obj.get("date") if isinstance(bd_obj, dict) else None + return RawPerformer( + external_id=perf.get("id"), + name=name, + aliases=[a for a in aliases if isinstance(a, str)], + gender=(perf.get("gender") or "").lower() or None, + birth_date=_parse_date(bd), + country=perf.get("country"), + as_alias_in_scene=raw.get("as") if raw.get("as") and raw.get("as") != name else None, + ) + + +def _parse_tag(raw: dict[str, Any]) -> RawTag | None: + name = raw.get("name") + if not name: + return None + return RawTag(external_id=raw.get("id"), name=name, slug=None) + + +def _parse_fingerprint(raw: dict[str, Any]) -> RawFingerprint | None: + h = raw.get("hash") + algo = (raw.get("algorithm") or "").lower() + if not h or algo not in {"phash", "oshash", "md5"}: + return None + return RawFingerprint(kind=algo, value=h) + + +def _extract_cross_refs(urls: list[dict[str, Any]] | None) -> dict[str, str]: + """Z `scene.urls` wyciąga znane cross-source ID-ki, np. tpdb_id. + + Returns: dict[source_name, external_id]. Source name ma być stabilne + (lower, np. 'tpdb' / 'theporndb'). + """ + out: dict[str, str] = {} + for u in urls or []: + url = u.get("url") or "" + site_name = ((u.get("site") or {}).get("name") or "").strip().lower() + if not url: + continue + # ThePornDB: .../scenes/<uuid> + if "theporndb" in site_name or "porndb" in url.lower(): + m = _UUID_RE.search(url) + if m: + out["tpdb"] = m.group(0) + return out + + +def _parse_scene(raw: dict[str, Any]) -> RawScene | None: + external_id = raw.get("id") + title = raw.get("title") + if not external_id or not title: + log.warning("stashdb scene without id/title — skipping") + return None + + performers = [] + for p in raw.get("performers") or []: + parsed = _parse_performer(p) + if parsed is not None: + performers.append(parsed) + + tags = [] + for t in raw.get("tags") or []: + parsed_t = _parse_tag(t) + if parsed_t is not None: + tags.append(parsed_t) + + fingerprints = [] + for fp in raw.get("fingerprints") or []: + parsed_fp = _parse_fingerprint(fp) + if parsed_fp is not None: + fingerprints.append(parsed_fp) + + cross_refs = _extract_cross_refs(raw.get("urls")) + rel = _parse_date(raw.get("release_date") or raw.get("date")) + + return RawScene( + external_id=str(external_id), + title=title, + description=raw.get("details"), + release_date=rel, + duration_sec=int(raw["duration"]) if raw.get("duration") else None, + code=raw.get("code"), + director=raw.get("director"), + url=None, + studio=_parse_studio(raw.get("studio")), + performers=performers, + tags=tags, + fingerprints=fingerprints, + cross_source_refs=cross_refs, + raw=raw, + ) diff --git a/app/connectors/tpdb.py b/app/connectors/tpdb.py new file mode 100644 index 0000000..5a80877 --- /dev/null +++ b/app/connectors/tpdb.py @@ -0,0 +1,329 @@ +"""ThePornDB REST connector. + +API: https://api.theporndb.net (auth: Bearer token) +Lista scen: GET /scenes?per_page=200&page=N&date={YYYY-MM-DD} (delta filter) +Format: {data: [...], meta: {current_page, last_page, per_page, total}} + +Sceny TPDB zwracają już rozwiniętych performerów (`performers[]`), studio (`site`) i tagi (`tags[]`). +W związku z tym pojedyncze GET /scenes wystarcza do MVP — nie musimy uderzać oddzielnie po performera. + +Format performera w scenie: + - performer.id — ID przypisania performer↔scene (NIE używać do dedup) + - performer.name — imię w tej konkretnej scenie (może być alias, np. „Mia M.") + - performer.parent.id — kanoniczne UUID performerki w TPDB → external_id + - performer.parent.name / .extra.gender / .extra.birthday — kanoniczne metadane + +Format studia: scene.site = {id, name, slug, parent: {...}, network: {...}} +""" +from __future__ import annotations + +import logging +from collections.abc import Iterator +from datetime import date, datetime +from typing import Any + +import httpx +from tenacity import ( + retry, + retry_if_exception, + retry_if_exception_type, + stop_after_attempt, + wait_exponential, +) + + +def _is_retryable_http_error(exc: BaseException) -> bool: + """Retry transport errors + 5xx + 429; NIE retry 4xx (404/422 = permanent). + + 401/403 NIE są retryowalne tutaj — TPDB token expiry musiałby być + obsłużony jako auth refresh (TODO gdyby zaczęły się pojawiać). Aktualnie + expire'a się raz na rok, więc nie warto kombinować. + """ + if isinstance(exc, httpx.TransportError): + return True + if isinstance(exc, httpx.HTTPStatusError): + sc = exc.response.status_code + return sc == 429 or sc >= 500 + return False + +from app.config import get_settings +from app.connectors.base import ( + BaseConnector, + RawPerformer, + RawScene, + RawStudio, + RawTag, +) +from app.models.source import SourceKind + +log = logging.getLogger(__name__) + + +class TPDBConnector(BaseConnector): + kind = SourceKind.tpdb + name = "tpdb" + + def __init__( + self, + *, + token: str | None = None, + base_url: str | None = None, + per_page: int = 100, + timeout: float = 30.0, + ) -> None: + settings = get_settings() + self.token = token or settings.tpdb_api_token + if not self.token: + raise RuntimeError("TPDB_API_TOKEN is not set") + self.base_url = (base_url or settings.tpdb_base_url).rstrip("/") + self.per_page = per_page + self.timeout = timeout + + def _client(self) -> httpx.Client: + return httpx.Client( + base_url=self.base_url, + headers={ + "Authorization": f"Bearer {self.token}", + "Accept": "application/json", + "User-Agent": "goon/0.1", + }, + timeout=self.timeout, + ) + + @retry( + retry=retry_if_exception(_is_retryable_http_error), + wait=wait_exponential(multiplier=1, min=2, max=30), + stop=stop_after_attempt(5), + reraise=True, + ) + def _get(self, client: httpx.Client, path: str, params: dict[str, Any]) -> dict[str, Any]: + resp = client.get(path, params=params) + if resp.status_code == 429: + # let tenacity retry — but raise something it knows + raise httpx.HTTPStatusError("rate limited", request=resp.request, response=resp) + resp.raise_for_status() + return resp.json() + + def fetch_scenes( + self, + *, + since: datetime | None = None, + limit: int | None = None, + ) -> Iterator[RawScene]: + params: dict[str, Any] = {"per_page": self.per_page} + if since is not None: + params["date"] = since.date().isoformat() + + yield from self._paginate_scenes(params, limit=limit) + + def fetch_scenes_for_performer( + self, + performer_external_id: str, + *, + limit: int | None = None, + ) -> Iterator[RawScene]: + """Pobiera wszystkie sceny TPDB dla performera o podanym kanonicznym ID. + + TPDB API: GET /performers/<id>/scenes — dedykowany endpoint. + (Inne warianty są broken: /scenes?performers[]=<uuid> zwraca zawsze total=0, + /scenes?performer_id=<uuid> → 422.) + + 404 = performer usunięty z TPDB (np. b959ccbb 2026-05-16 Sentry GOON-N). + Wcześniej leciało raise → exception bąbelek do scheduler.performer_driven + → cały run failed. Teraz warn + yield empty — caller widzi 0 scen i + kontynuuje z następnym performer. + """ + try: + yield from self._paginate_scenes( + {"per_page": self.per_page}, + limit=limit, + path=f"/performers/{performer_external_id}/scenes", + ) + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + log.warning( + "tpdb performer %s removed (404) — skipping", + performer_external_id, + ) + return + raise + + def fetch_scenes_for_site( + self, + site_external_id: str, + *, + limit: int | None = None, + ) -> Iterator[RawScene]: + """Pobiera wszystkie sceny TPDB dla site/studio o podanym ID. + + TPDB API: GET /sites/<id>/scenes — dedykowany endpoint analogiczny + do /performers/<id>/scenes. Bez paginacji limit zwraca total scenes + z meta.total (Brazzers=272, Naughty America=631 w czasie pisania). + + 404 = site usunięty z TPDB — analogicznie do fetch_scenes_for_performer. + """ + try: + yield from self._paginate_scenes( + {"per_page": self.per_page}, + limit=limit, + path=f"/sites/{site_external_id}/scenes", + ) + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + log.warning( + "tpdb site %s removed (404) — skipping", + site_external_id, + ) + return + raise + + def find_performer_id_by_name(self, name: str) -> str | None: + """GET /performers?q=<name> → pierwszy match. None gdy brak.""" + with self._client() as client: + try: + payload = self._get(client, "/performers", {"q": name, "per_page": 5}) + except httpx.HTTPStatusError as e: + log.warning("tpdb /performers q=%s failed: %s", name, e) + return None + data = payload.get("data") or [] + if not data: + return None + for item in data: + # exact (case-insensitive) match preferowany; fallback do pierwszego + if (item.get("name") or "").strip().lower() == name.strip().lower(): + return str(item.get("id")) if item.get("id") else None + first = data[0] + return str(first.get("id")) if first.get("id") else None + + def _paginate_scenes( + self, + params: dict[str, Any], + *, + limit: int | None, + path: str = "/scenes", + ) -> Iterator[RawScene]: + emitted = 0 + page = 1 + with self._client() as client: + while True: + params["page"] = page + payload = self._get(client, path, params) + data = payload.get("data") or [] + if not data: + return + for raw in data: + scene = _parse_scene(raw) + if scene is None: + continue + yield scene + emitted += 1 + if limit is not None and emitted >= limit: + return + + meta = payload.get("meta") or {} + last_page = meta.get("last_page") or page + if page >= last_page: + return + page += 1 + + +def _parse_date(value: Any) -> date | None: + if not value: + return None + if isinstance(value, date): + return value + text = str(value).strip() + if not text: + return None + # TPDB dates: "YYYY-MM-DD" lub ISO datetime + try: + return date.fromisoformat(text[:10]) + except ValueError: + return None + + +def _parse_studio(raw: dict[str, Any] | None) -> RawStudio | None: + if not raw: + return None + parent = raw.get("parent") or {} + network = raw.get("network") or {} + return RawStudio( + external_id=str(raw["id"]) if raw.get("id") is not None else None, + name=raw.get("name") or "Unknown", + slug=raw.get("short_name") or raw.get("slug"), + parent_external_id=str(parent["id"]) if parent.get("id") is not None else None, + parent_name=parent.get("name"), + network=network.get("name") if isinstance(network, dict) else None, + homepage_url=raw.get("url") or raw.get("home"), + ) + + +def _parse_performer(raw: dict[str, Any]) -> RawPerformer | None: + parent = raw.get("parent") or {} + extra = parent.get("extras") or parent.get("extra") or {} + canonical_id = parent.get("id") or raw.get("id") + canonical_name = parent.get("name") or raw.get("name") + if not canonical_name: + return None + aliases_field = parent.get("aliases") or extra.get("aliases") or [] + if isinstance(aliases_field, str): + aliases = [a.strip() for a in aliases_field.split(",") if a.strip()] + else: + aliases = [a for a in aliases_field if isinstance(a, str)] + return RawPerformer( + external_id=str(canonical_id) if canonical_id is not None else None, + name=canonical_name, + aliases=aliases, + gender=(extra.get("gender") or parent.get("gender") or "").lower() or None, + birth_date=_parse_date(extra.get("birthday")), + country=extra.get("birthplace") or extra.get("country"), + as_alias_in_scene=raw.get("name") if raw.get("name") != canonical_name else None, + ) + + +def _parse_tag(raw: dict[str, Any]) -> RawTag | None: + name = raw.get("name") + if not name: + return None + return RawTag( + external_id=str(raw["id"]) if raw.get("id") is not None else None, + name=name, + slug=raw.get("slug"), + ) + + +def _parse_scene(raw: dict[str, Any]) -> RawScene | None: + external_id = raw.get("id") + title = raw.get("title") + if not external_id or not title: + log.warning("tpdb scene without id/title — skipping (keys=%s)", list(raw)[:8]) + return None + + performers: list[RawPerformer] = [] + for p in raw.get("performers") or []: + parsed = _parse_performer(p) + if parsed is not None: + performers.append(parsed) + + tags: list[RawTag] = [] + for t in raw.get("tags") or []: + parsed_t = _parse_tag(t) + if parsed_t is not None: + tags.append(parsed_t) + + + return RawScene( + external_id=str(external_id), + title=title, + description=raw.get("description"), + release_date=_parse_date(raw.get("date")), + duration_sec=int(raw["duration"]) if raw.get("duration") else None, + code=raw.get("external_id"), + director=raw.get("director"), + url=raw.get("url"), + studio=_parse_studio(raw.get("site")), + performers=performers, + tags=tags, + fingerprints=[], # TPDB nie publikuje pHashy w głównym endpoint + raw=raw, + ) diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..591b134 --- /dev/null +++ b/app/db.py @@ -0,0 +1,35 @@ +from collections.abc import Iterator +from contextlib import contextmanager + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.config import get_settings + +_settings = get_settings() + +engine = create_engine( + _settings.database_url, + pool_pre_ping=True, + future=True, +) + +SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False, future=True) + + +@contextmanager +def session_scope() -> Iterator[Session]: + session = SessionLocal() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + + +def get_session() -> Iterator[Session]: + with session_scope() as session: + yield session diff --git a/app/extractors/__init__.py b/app/extractors/__init__.py new file mode 100644 index 0000000..e577245 --- /dev/null +++ b/app/extractors/__init__.py @@ -0,0 +1,157 @@ +"""Stream URL extractors per-tube. + +Public API: + - `try_extract(sitetag, page_url) -> list[StreamSource] | None` + - `StreamSource` (dataclass) + - `HosterDead` (exception) + - `extract_stream_from_hoster(iframe_url, *, referer)` — generic packer-based hoster extract + - `fetch_tube_html(url)` — Chrome TLS fingerprint fetch (curl_cffi) + - `browser_get(url)` — low-level + +Architektura: każdy tube ma osobny moduł `app.extractors.tubes.<tube>` który eksportuje +`extract(page_url) -> list[StreamSource] | None`. Registry niżej mapuje sitetag → +modułowy extractor. `try_extract()` to thin wrapper z exception handlingiem. + +Po removalu porn-app dependency, ten moduł jest jedynym mechanizmem rozwiązywania +streamów — playback.py nie wpada już do porn-app /stream API. +""" +from __future__ import annotations + +import logging +from collections.abc import Callable + +from app.extractors._fetch import browser_get, fetch_tube_html +from app.extractors._models import HosterDead, StreamSource, TubePageError +from app.extractors.hoster import extract_stream_from_hoster, unpack_packer +from app.extractors.tubes import ( + _embed_iframe, + _vps_blocked_fallback, + _ytdlp, + eporner, + freshporno, + hqporner, + latestpornvideo, + paradisehill, + porn00, + pornhat, + pornxp, + sxyprn, +) + +log = logging.getLogger(__name__) + + +# Sitetag → extractor function. Sitetag pasuje do format'u z origin: `pornapp:<sitetag>` +# (lub po Fazie 2 migracji: `tube:<sitetag>`). +# +# Mainstream tubes (pornhub/xvideos/xnxx/xhamster/redtube/youporn/porntrex) używają +# yt-dlp jako extractor — battle-tested, aktualizowane przez upstream przy zmianach +# HTML. Aggregator tubes (xmoviesforyou/watchporn/siska/...) używają generic +# embed-iframe extractor (page → /e/<id> iframe → P.A.C.K.E.R. unpack). Custom kod +# tylko tam gdzie tube ma niestandardowy schemat (eporner XHR, sxyprn URL transform). +_REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = { + # Custom (zoptymalizowane / niestandardowy player) + # hqporner — CDN URL (bigcdn.cc, video.flyflv.com z `ip=` parametrem) IP-bound do + # requestera. VPS resolve daje 200 ale mobile direct = 404/403. Switch na WebView + # fallback: mobile pobiera embed iframe (mydaddy.cc/hqwo.cc) z phone IP, FluidPlayer + # JS decoduje mp4 URL z mobile session. Plus INJECTED_JS skanuje `<source>.src`. + # ~32k scen (drugi po porntrex największy single saving). Verified 2026-05-18. + "hqpornercom": _vps_blocked_fallback.extract, + "epornercom": eporner.extract, + "sxyprncom": sxyprn.extract, + # Mainstream tubes — yt-dlp + # NB: 2026-05-18 cross-IP test potwierdził że xvideos/xnxx/pornhub/youporn/redtube + # CDN URLs są **time-bound** (nie IP-bound) — mobile_direct_ok auto-detect w + # playback.py daje mobile direct fetch, zero VPS bandwidth. + "pornhubcom": _ytdlp.extract, + "redtubecom": _ytdlp.extract, + "xvideoscom": _ytdlp.extract, + "xnxxcom": _ytdlp.extract, + "youporncom": _ytdlp.extract, + # porntrex KVS get_file — `kt_ips=<vps_ip>` cookie + single-use token (410 po reuse). + # CDN IP-bound do VPS, mobile direct = 403. Switch na _vps_blocked_fallback: + # mobile WebView z phone IP → KVS player JS dekoduje video.src → INJECTED_JS scrape. + # 137k scen oszczędzone z VPS bandwidth (largest single saving). + "porntrexcom": _vps_blocked_fallback.extract, + # VPS-blocked tubes — KVS / Cloudflare blokuje Hetzner IP, ale działają z residential + # IP (potwierdzone Chrome DevTools MCP 2026-05-15). Mobile WebView + INJECTED_JS + # (PlayerScreen.tsx:805) skanuje <video>.src + XHR — łapie URL po decode-ie player JS. + "xhamstercom": _vps_blocked_fallback.extract, + "porndittcom": _vps_blocked_fallback.extract, + "fpoxxx": _vps_blocked_fallback.extract, + "sxylandcom": _vps_blocked_fallback.extract, + # Aggregator tubes — generic embed-iframe → hoster unpacker + "latestpornvideocom": latestpornvideo.extract, + "xmoviesforyoucom": _embed_iframe.extract, + "watchporn": _embed_iframe.extract, + "siskavideo": _embed_iframe.extract, + "porn4dayspw": _embed_iframe.extract, + "porndishcom": _embed_iframe.extract, + # xxxfreewatch — DELISTED 2026-05-18. 790 solo-orphan scen, 0% match, CF-walled z VPS. + "latestleaksco": _embed_iframe.extract, + "mypornerleakcom": _embed_iframe.extract, + # PornHat — dedicated extractor: tylko `<source>` z player area (skip sidebar + # trailer URLs `_preview*.mp4`), dedupe po filename. Get_file 302 → CDN, proxy + # follow_redirects=True wymagane (fix w stream_proxy.py). + "pornhatcom": pornhat.extract, + # Freshporno KVS — `cv=` HMAC signed token IP-bound. Server-side resolve dało + # 200 z VPS, ale laptop dostał 302+SSL error → token validate'uje requester IP. + # Switch na WebView fallback: mobile pobiera embed page, KVS player decoduje + # video_url w-page, ExoPlayer dostaje URL z phone session. ~15k scen. + "freshpornoorg": _vps_blocked_fallback.extract, + # porn00 / pornxp — force_proxy=True wprost (IP-bound CDN). Switch na WebView + # fallback. Niski volume (84 scen), trivial saving ale konsystencja flow. + "porn00org": _vps_blocked_fallback.extract, + "pornxpph": _vps_blocked_fallback.extract, + # Direct-scraping tubes (mają też search scraper w connectors/direct_scrapers/) + # — używają identycznego embed-iframe pattern dla streamingu. + # hdporn92com — DELISTED 2026-05-18. Scene pages to SEO shell bez player iframe, + # JS hijackuje kliki na popunder. Wszystkie playback_sources mass-marked dead. + # 0dayxx wraps watchporn.to embed. watchporn.to/get_file/ token IP-bound (302→410 + # cross-IP). Switch na WebView fallback. ~5k scen. + "0dayxxcom": _vps_blocked_fallback.extract, + # CF-protected tube — curl_cffi w fetch_tube_html bypassa JA3, embed-iframe pattern. + "perverzijacom": _embed_iframe.extract, + # Special: WebView-only (Yii2 session-bound player). + "paradisehillcc": paradisehill.extract, +} + + +def try_extract(sitetag: str, page_url: str) -> list[StreamSource] | None: + """Próbuje rozwiązać stream URL dla danego tube'a + page_url. + + Zwraca listę StreamSource (różne quality/kontener) lub None gdy: + - brak extractora dla tego sitetag + - extractor zwrócił None / nie znalazł URL'a + + Raises HosterDead gdy embed page wprost mówi że video deleted/not found — + caller (playback.py) łapie i oznacza playback_source.dead_at. + """ + extractor = _REGISTRY.get(sitetag) + if extractor is None: + return None + try: + return extractor(page_url) + except (HosterDead, TubePageError): + raise + except Exception as e: + log.warning("extractor for %s failed on %s: %s", sitetag, page_url, e) + return None + + +def supported_sitetags() -> tuple[str, ...]: + """Zwraca listę sitetag-ów które mają zarejestrowany extractor.""" + return tuple(_REGISTRY.keys()) + + +__all__ = [ + "try_extract", + "supported_sitetags", + "StreamSource", + "HosterDead", + "TubePageError", + "extract_stream_from_hoster", + "unpack_packer", + "fetch_tube_html", + "browser_get", +] diff --git a/app/extractors/_fetch.py b/app/extractors/_fetch.py new file mode 100644 index 0000000..232467e --- /dev/null +++ b/app/extractors/_fetch.py @@ -0,0 +1,120 @@ +"""Browser-impersonation HTTP fetcher dla tube'ów blokujących Pythonowy TLS fingerprint. + +Niektóre Cloudflare-fronted tube'y (np. perverzija) blokują httpx na podstawie JA3 +TLS hash (charakterystycznego dla Pythonowego stacka), zwracając 403 nawet z dobrym +UA + Referer. `curl_cffi` używa libcurl + skompilowanej wersji TLS lib z prawdziwego +Chrome'a, dzięki czemu ja3 hash jest identyczny jak browser → CF wpuszcza. + +Fallback na httpx tylko gdy curl_cffi nie zainstalowany (zachowujemy backwards-compat +w razie problemów z buildem libcurl-impersonate). +""" +from __future__ import annotations + +import logging +from collections.abc import Mapping +from dataclasses import dataclass +from urllib.parse import urlparse + +import httpx + +from app.extractors._models import TubePageError + +log = logging.getLogger(__name__) + +try: + from curl_cffi import requests as _cf_requests # type: ignore[import-not-found] + _HAS_CURL_CFFI = True +except ImportError: # pragma: no cover + _HAS_CURL_CFFI = False + log.warning("curl_cffi not installed — fallback to httpx (CF-protected tubes will fail)") + + +_DEFAULT_IMPERSONATE = "chrome120" +_DEFAULT_UA = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36" +) + + +@dataclass +class FetchResult: + """Mini response-like object — drop-in dla httpx.Response w naszych use case'ach.""" + + status_code: int + text: str + url: str + + def raise_for_status(self) -> None: + if 400 <= self.status_code < 600: + raise TubePageError(self.status_code, self.url) + + +def browser_get( + url: str, + *, + headers: Mapping[str, str] | None = None, + timeout: float = 60.0, + follow_redirects: bool = True, + impersonate: str = _DEFAULT_IMPERSONATE, +) -> FetchResult: + """GET z Chrome TLS fingerprint (curl_cffi). Spada do httpx gdy curl_cffi brak.""" + if not _HAS_CURL_CFFI: + with httpx.Client(timeout=timeout, follow_redirects=follow_redirects) as http: + r = http.get(url, headers=dict(headers or {})) + return FetchResult(status_code=r.status_code, text=r.text, url=str(r.url)) + + r = _cf_requests.get( + url, + headers=dict(headers or {}), + timeout=timeout, + impersonate=impersonate, + allow_redirects=follow_redirects, + ) + return FetchResult(status_code=r.status_code, text=r.text, url=str(r.url)) + + +def fetch_tube_html(url: str, *, timeout: float = 60.0, max_retries: int = 2) -> str: + """Fetch HTML strony tube'a z Chrome UA + retry dla transient failures. + + Standalone replacement dla `PornAppClient.fetch_tube_html`. Używa curl_cffi + (browser_get) żeby ominąć JA3 fingerprint blocks na CF-fronted tube'ach. + + Retry: 5xx i empty body retry max_retries razy z exponential backoff (0.5s, 1s). + Dla freshporno itp. które czasem zwracają 503/empty — bez retry user dostawał + "extractor None" z transient hiccup. + """ + import time as _time + host = urlparse(url).hostname or "" + headers = { + "User-Agent": _DEFAULT_UA, + "Accept": "text/html,application/xhtml+xml", + "Accept-Language": "en-US,en;q=0.9", + "x-site": host, + } + last_err: Exception | None = None + for attempt in range(max_retries + 1): + try: + resp = browser_get(url, headers=headers, timeout=timeout, follow_redirects=True) + except Exception as e: + last_err = e + log.info("fetch_tube_html attempt %d/%d for %s: %s", attempt + 1, max_retries + 1, url, e) + if attempt < max_retries: + _time.sleep(0.5 * (attempt + 1)) + continue + raise + # Retry on 5xx (transient server error) lub puste body (CDN cache miss) + if 500 <= resp.status_code < 600 or (resp.status_code == 200 and len(resp.text) < 500): + if attempt < max_retries: + log.info("fetch_tube_html %s attempt %d/%d: status=%d len=%d — retry", + url, attempt + 1, max_retries + 1, resp.status_code, len(resp.text)) + _time.sleep(0.5 * (attempt + 1)) + continue + if resp.status_code >= 400: + raise TubePageError(resp.status_code, url) + return resp.text + if last_err: + raise last_err + raise TubePageError(0, url) + + +__all__ = ["browser_get", "fetch_tube_html", "FetchResult", "_DEFAULT_UA"] diff --git a/app/extractors/_models.py b/app/extractors/_models.py new file mode 100644 index 0000000..f583f99 --- /dev/null +++ b/app/extractors/_models.py @@ -0,0 +1,48 @@ +"""Stream source DTO + wspólne wyjątki extractorów.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass +class StreamSource: + """Pojedynczy resolved stream URL. + + Mapuje na `StreamLink` w playback API (api/playback.py) — `link` → `stream_url`, + `quality` → `quality`, `type` → `type`. + + `referer` — opcjonalny override Referera używanego przez stream_proxy. Niektóre + CDN-y (KVS-style watchporn.to, fpo.xxx itp.) zwracają 410/403 gdy Referer nie + pasuje do *embed page'a* (np. proxy używa `Referer: 0dayxx.com` ale CDN expectuje + `Referer: watchporn.to`). Gdy None → caller (playback.py) używa `page_url`. + """ + + link: str + quality: str | None = None + type: str | None = None # 'mp4' | 'm3u8' | 'mpd' | 'hoster' + raw: dict[str, Any] | None = None + referer: str | None = None + + +class HosterDead(Exception): + """Hoster embed page mówi że video jest skasowane / nie istnieje. + + Caller w playback.py łapie i oznacza `playback_source.dead_at`. + """ + + +class TubePageError(Exception): + """Tube page fetch zwrócił HTTP error (404/410/5xx). + + Caller (playback.py) może oznaczyć dead_at jeśli 404/410. Trzymamy `status_code` + + `url` w atrybutach żeby caller nie musiał parsować message stringa. + """ + + def __init__(self, status_code: int, url: str): + super().__init__(f"HTTP {status_code} for {url}") + self.status_code = status_code + self.url = url + + +__all__ = ["StreamSource", "HosterDead", "TubePageError"] diff --git a/app/extractors/duration_extract.py b/app/extractors/duration_extract.py new file mode 100644 index 0000000..4076f9f --- /dev/null +++ b/app/extractors/duration_extract.py @@ -0,0 +1,91 @@ +"""Universal duration extractor for tube pages. + +Direct scrapery (xvideos, xnxx, youporn, porntrex, …) są search-only — pobierają +listing i wycioskują tylko URL + slug-as-title. Duration pojawia się dopiero na +detail page i jest dostępne w jednym z patternów: + +1. **OpenGraph numeric** (youporn, redtube, eporner): + `<meta property="og:video:duration" content="992">` — sekundy. +2. **OpenGraph ISO 8601** (rzadkie): + `<meta property="og:video:duration" content="PT16M32S">`. +3. **Schema.org VideoObject LD-JSON** (xvideos, xnxx, KVS-based): + `"duration": "PT00H07M10S"` w JSON-LD `<script type="application/ld+json">`. +4. **itemprop microdata** (sxyland, 0dayxx, niektóre WordPress): + `<meta itemprop="duration" content="P0DT0H21M13S">` — ISO 8601 z opcjonalnym + `P<days>D` prefix + opcjonalnym `T` blokiem HMS. + +Funkcja zwraca pierwszy znaleziony match jako int seconds, lub None. +""" +from __future__ import annotations + +import re + +_OG_DURATION_RE = re.compile( + r'<meta\s+property="(?:og:(?:video:)?|video:)duration"\s+content="([^"]+)"', + re.IGNORECASE, +) +_LD_DURATION_RE = re.compile(r'"duration"\s*:\s*"(P[0-9DTHMS]+)"', re.IGNORECASE) +_ITEMPROP_DURATION_RE = re.compile( + r'itemprop="duration"[^>]*content="([^"]+)"', re.IGNORECASE +) +# Hqporner-style meta description: "Video duration is 6min 55sec" lub "1h 23min 5sec". +# Generic — pasuje też do innych tube'ów które dorzucają w meta opis duration prozą. +_META_DESC_DURATION_RE = re.compile( + r'(?:duration\s+is\s+|<meta\s+name="description"\s+content="[^"]*duration\s+is\s+)' + r'(?:(\d+)\s*h(?:our)?s?)?\s*(?:(\d+)\s*min)?\s*(?:(\d+)\s*sec)?', + re.IGNORECASE, +) +# Generalized ISO 8601: P[<n>D][T[<n>H][<n>M][<n>S]]. Pokrywa `PT16M32S`, +# `PT00H07M10S`, `P0DT0H21M13S` jednocześnie. Dni są rzadko sensowne (>24h scena), +# ale zachowujemy bo niektóre tube'y wpisują P0D dla porządku. +_ISO_DURATION_RE = re.compile( + r"^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$", re.IGNORECASE +) + + +def _parse_iso8601(value: str) -> int | None: + """`P0DT0H21M13S` → 1273, `PT00H07M10S` → 430. None gdy format niepasujący + LUB total == 0 (sygnał placeholder bez duration).""" + m = _ISO_DURATION_RE.match(value.strip()) + if not m: + return None + d, h, mi, s = (int(g) if g else 0 for g in m.groups()) + total = d * 86400 + h * 3600 + mi * 60 + s + return total if total > 0 else None + + +def extract_duration_sec(html: str) -> int | None: + """Zwraca duration w sekundach lub None gdy żaden wzorzec nie pasuje. + + Kolejność: OG numeric → OG ISO → LD-JSON ISO → itemprop ISO. Pierwsze pasujące + z `total > 0` wygrywa. + """ + if not html: + return None + + if (m := _OG_DURATION_RE.search(html)): + v = m.group(1).strip() + if v.isdigit(): + n = int(v) + if n > 0: + return n + if v.upper().startswith("P") and (parsed := _parse_iso8601(v)) is not None: + return parsed + + if (m := _LD_DURATION_RE.search(html)): + if (parsed := _parse_iso8601(m.group(1))) is not None: + return parsed + + if (m := _ITEMPROP_DURATION_RE.search(html)): + v = m.group(1).strip() + if v.upper().startswith("P") and (parsed := _parse_iso8601(v)) is not None: + return parsed + + # Hqporner: "Video duration is 6min 55sec" w meta description. + if (m := _META_DESC_DURATION_RE.search(html)): + h, mi, s = (int(g) if g else 0 for g in m.groups()) + total = h * 3600 + mi * 60 + s + if total > 0: + return total + + return None diff --git a/app/extractors/hoster.py b/app/extractors/hoster.py new file mode 100644 index 0000000..9ae6ecd --- /dev/null +++ b/app/extractors/hoster.py @@ -0,0 +1,343 @@ +"""Generic hoster (StreamWish/doodporn/mixdrop/filemoon/luluvdo) stream URL extractor. + +Hostery embed-page'y stosują JWPlayer + P.A.C.K.E.R. obfuskację: + eval(function(p,a,c,k,e,d){...}('PAYLOAD', BASE, COUNT, 'kw1|kw2|...'.split('|'),...)) +i chowają `sources: [{file: "https://...m3u8"}]` w packed JS. + +Tu jest: + - `unpack_packer(js)` — dekoder P.A.C.K.E.R. + - `extract_stream_from_hoster(iframe_url, *, referer)` — fetch embed → unpack → m3u8/mp4 + +Te funkcje są używane przez: + 1. Per-tube extractors (latestpornvideo, hqporner fallback) — page → embed iframe → tu + 2. Movies playback (api/playback.py movies_router) — direct hoster URL → tu + +Nie ma już zależności od PornAppClient / porn-app API. +""" +from __future__ import annotations + +import logging +import re + +from app.extractors._fetch import _DEFAULT_UA, browser_get +from app.extractors._models import HosterDead + +log = logging.getLogger(__name__) + + +# P.A.C.K.E.R. javascript unpacker — odwraca obfuskację wzorca: +# eval(function(p,a,c,k,e,d){while(c--)if(k[c])p=p.replace(...);return p} +# ('PAYLOAD', BASE, COUNT, 'kw1|kw2|...'.split('|'), 0, {})) +# StreamWish, doodporn, mixdrop, filemoon — wszystkie używają tego packera do schowania +# `sources: [{file: "https://...m3u8"}]` w JWPlayer config. +_PACKER_ARGS_RE = re.compile( + r"\}\s*\(\s*'((?:\\'|[^'])+)'\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*'((?:\\'|[^'])*)'\s*\.split\('\|'\)", + re.DOTALL, +) + + +def _base_n(token: str, base: int) -> int | None: + """Parsuje token jako liczbę w bazie 'base' (max 62 dla a-zA-Z0-9).""" + try: + result = 0 + for ch in token: + if ch.isdigit(): + d = ord(ch) - ord("0") + elif "a" <= ch <= "z": + d = ord(ch) - ord("a") + 10 + elif "A" <= ch <= "Z": + d = ord(ch) - ord("A") + 36 + else: + return None + if d >= base: + return None + result = result * base + d + return result + except Exception: + return None + + +def unpack_packer(js: str) -> str | None: + """Unpack P.A.C.K.E.R. obfuscated JS. Zwraca None gdy wzorca nie ma.""" + m = _PACKER_ARGS_RE.search(js) + if not m: + return None + payload, base_str, count_str, kw_str = m.groups() + base = int(base_str) + count = int(count_str) + keywords = kw_str.split("|") + payload = payload.replace("\\'", "'").replace('\\"', '"').replace("\\\\", "\\") + + def replace_token(match: re.Match[str]) -> str: + token = match.group(0) + idx = _base_n(token, base) + if idx is None or idx >= count or idx >= len(keywords): + return token + kw = keywords[idx] + return kw if kw else token + + return re.sub(r"\b\w+\b", replace_token, payload) + + +_HOSTER_FILE_RE = re.compile( + r'(?:["\']?file["\']?|sources?)\s*[:=]\s*["\'](https?://[^"\']+\.(?:m3u8|mp4|mpd)[^"\']*)["\']', + re.IGNORECASE, +) + + +# Ad-rolls embedded w player config (xtremestream.xyz, niektóre KVS forki). +# Bez filtra extractor wracał preroll.mp4 jako "scena" → user widział 20s reklamy +# zamiast filmu (zgłoszone 2026-05-10, bug-report #30c4d3cf perverzija). +# Pattern obejmuje typowe nazwy ad-rolli + CDN-y które serwują reklamy +# (opencdn.b-cdn.net to bunnycdn alias dla reklam). +_AD_VIDEO_RE = re.compile( + r"/(?:preroll|midroll|postroll|preplay|ads?|advert|promo)\d*\.(?:mp4|m3u8|webm)" + r"|opencdn\.b-cdn\.net/video/(?:pre|mid|post|ad)", + re.IGNORECASE, +) + + +def _looks_like_ad(url: str) -> bool: + return bool(_AD_VIDEO_RE.search(url)) + +# Niektóre hostery (doodporn) chowają mp4/m3u8 w słowniku zmiennych i odwołują się do +# nich w `sources: [{file: links.hls2}]`. Wtedy regex powyżej nie złapie. Drugi pass +# bierze pierwszy `.m3u8|.mp4|.mpd` URL z całego unpacked HTML — heurystyka, ale +# pierwszy taki URL to zwykle master playlist video. +_HOSTER_FALLBACK_URL_RE = re.compile( + r'https?://[^\s"\'<>]+\.(?:m3u8|mp4|mpd)(?:\?[^\s"\'<>]*)?', + re.IGNORECASE, +) + + +# Sygnatury "video not found" / "deleted" które hostery wstawiają w HTML embed page. +# Gdy widzimy te markery, to wiemy że link jest martwy — raise HosterDead, caller w +# playback.py oznaczy playback_source.dead_at. +_HOSTER_DEAD_PATTERNS = ( + "Video not found", + "video not found", + "Video Not Found", + "File was deleted", + "video is deleted", + "Video is deleted", + "This video is no longer available", +) + + +# KVS (Kernel Video Sharing) player markers — kt_player.js + license_code w HTML. +# Używają go fpo.xxx, 0day.kim, hdporn92, sxyland, i wiele innych WordPress-based +# tubes. KVS encryptuje URL `function/0/<encrypted>` license_code'em — regex fallback +# (`_HOSTER_FALLBACK_URL_RE`) złapie zamiast tego URL `event_reporting2` (tracking +# pixel zwracający 1×1 GIF zamiast video). Jak widzimy markery KVS, idziemy od razu +# do yt-dlp którego generic extractor poprawnie deszyfruje URL. +_KVS_MARKERS = ("kt_player(", "license_code") + + +# File hosters / known dead — rapidgator/nitroflare/frdl wymagają premium account +# (zwracają HTML z formularzem logowania zamiast video). Zwróć None bez fetch'u — +# caller w movies playback dorzuci embed-only fallback i mobile i tak otworzy +# WebView (gdzie user może zalogować się premium jeśli chce). +# Streamtape USUNIĘTY z blacklistu 2026-05-15 — ma dedicated extractor (innerHTML +# substring decode → /get_video → 302 → tapecontent.net mp4). Większość 12k URLów +# w naszej DB jest DMCA-dead ale ~5% żyje. +_FILE_HOSTER_RE = re.compile( + r"(?:rapidgator|nitroflare|filer\.net|frdl\.[a-z]+|" + r"streamcrypt\.net|" + r"openload\.co|openload\.io|oload\.[a-z]+)", # openload offline od 2019 + re.IGNORECASE, +) + + + + +def extract_stream_from_hoster( + iframe_url: str, + *, + referer: str, + timeout: float = 60.0, +) -> str | None: + """Fetch hoster embed HTML → unpack P.A.C.K.E.R. JS → wyłuskaj video URL. + + Działa dla większości popularnych hosterów (StreamWish, doodporn, mixdrop, filemoon) + bo wszyscy oni hostują JWPlayer z `sources` w packed JS. Zwraca pierwszy znaleziony + URL .m3u8 / .mp4 / .mpd lub None gdy nie udało się wyciągnąć. + + Raises HosterDead gdy embed page wprost mówi że video deleted/not found. + """ + if _FILE_HOSTER_RE.search(iframe_url): + log.debug("hoster %s: file-hoster blacklist (premium-walled), skipping", iframe_url) + return None + # Per-hoster dedicated extractors (specific URL shapes / decode patterns). + # Mixdrop: P.A.C.K.E.R. → MDCore.wurl protocol-relative `//host/v2/<id>.mp4?s=...` + # — generic packer fallback regex `https?://...\.mp4` mija ten URL (no scheme). + if re.search(r"(?:mixdrop|m1xdrop|mxdrop)\.[a-z]+/", iframe_url, re.IGNORECASE): + from app.extractors.hosters import mixdrop + sources = mixdrop.extract(iframe_url, timeout=timeout) + if sources: + return sources[0].link + # Fall through to generic logic gdyby dedicated zwrócił None. + # Streamtape: 4 `document.getElementById(...).innerHTML = prefix + (...).substring(N)` + # assignmenty, z czego 2 są DECOY z połamanym hostname. Dedicated decode picks + # correct one + builds `/get_video?id=...&token=...` URL. + if re.search(r"streamtape\.[a-z]+/", iframe_url, re.IGNORECASE): + from app.extractors.hosters import streamtape + sources = streamtape.extract(iframe_url, timeout=timeout) + if sources: + return sources[0].link + return None # streamtape ma własną HosterDead obsługę — generic fallback by się sypał + # Shared SPA+AES-CBC engine: embedseek/seekplayer/rpmplay/upns/player4me/easyvidplayer + # — wszystkie używają tego samego silnika (`/api/v1/video` z AES-CBC encrypted + # m3u8 source). Razem ~159k playback sources w DB. + from app.extractors.hosters import seekplayer_engine + if seekplayer_engine.matches(iframe_url): + sources = seekplayer_engine.extract(iframe_url, timeout=timeout) + if sources: + return sources[0].link + return None + # voe.sx: JS redirect do losowego mirroru → custom 7-step decoder + # (ROT13 → strip 7 magic seps → atob → -3 shift → reverse → atob → JSON.parse) + # → HLS m3u8 + mp4 fallback. ~21k movies. + if re.search( + r"//(?:voe\.sx|" + r"rebeccasciencestreet\.[a-z]+|" + r"darnobedienceupscale\.[a-z]+|" + r"[a-z]+upscale\.com|[a-z]+street\.com)/", + iframe_url, + re.IGNORECASE, + ): + from app.extractors.hosters import voe + sources = voe.extract(iframe_url, timeout=timeout) + if sources: + return sources[0].link + return None + headers = { + "User-Agent": _DEFAULT_UA, + "Accept": "text/html,application/xhtml+xml", + "Accept-Language": "en-US,en;q=0.9", + "Referer": referer, + } + try: + r = browser_get(iframe_url, headers=headers, timeout=timeout, follow_redirects=True) + r.raise_for_status() + except Exception as e: + log.warning("hoster fetch %s failed: %s", iframe_url, e) + return None + html = r.text + + if any(p in html for p in _HOSTER_DEAD_PATTERNS): + raise HosterDead(f"hoster {iframe_url} reports video deleted/not found") + + def _first_non_ad(pattern: re.Pattern[str], text: str, group: int = 1) -> str | None: + """Iterate matches, pomiń preroll/ad URLs. Zwraca pierwszy clean lub None.""" + for m in pattern.finditer(text): + url = m.group(group) + if not _looks_like_ad(url): + return url + return None + + # 1) Direct match w raw HTML (gdy hoster nie zaobfuskował) + if (url := _first_non_ad(_HOSTER_FILE_RE, html, 1)): + return url + + # KVS player → idź od razu do yt-dlp żeby ominąć regex-fallback który łapie + # gif-trap URL `event_reporting2`. yt-dlp generic deszyfruje `function/0/<enc>` + # license_code'em i zwraca prawdziwy `get_file/<N>/...mp4` URL. + is_kvs = all(marker in html for marker in _KVS_MARKERS) + if is_kvs: + ytdlp_url = _try_ytdlp_hoster(iframe_url, timeout=timeout) + if ytdlp_url and not _looks_like_ad(ytdlp_url): + return ytdlp_url + log.warning("hoster %s: KVS markers but yt-dlp failed", iframe_url) + return None + + # 2) Unpack P.A.C.K.E.R. → match na unpacked, najpierw structurally, + # potem fallback na pierwszy m3u8/mp4 w stringu. + unpacked = unpack_packer(html) + if unpacked: + if (url := _first_non_ad(_HOSTER_FILE_RE, unpacked, 1)): + return url + if (url := _first_non_ad(_HOSTER_FALLBACK_URL_RE, unpacked, 0)): + return url + + # 3) Fallback na raw HTML (URL może być poza packerem) + if (url := _first_non_ad(_HOSTER_FALLBACK_URL_RE, html, 0)): + return url + + # 4) yt-dlp last resort — battle-tested extractory dla streamtape, dood, mixdrop, + # filemoon, voe, vidoza, etc. Nie używamy go domyślnie (slow + lots of HTTP), + # tylko gdy nasze własne metody zawiodły. + ytdlp_url = _try_ytdlp_hoster(iframe_url, timeout=timeout) + if ytdlp_url: + return ytdlp_url + + log.warning( + "hoster %s: no video URL in embed (packer unpack=%s, yt-dlp fail)", + iframe_url, + unpacked is not None, + ) + return None + + +def _try_ytdlp_hoster(iframe_url: str, *, timeout: float) -> str | None: + """yt-dlp wrapper dla hosters których nasz P.A.C.K.E.R. unpacker nie ogarnął. + + yt-dlp ma extractory dla popularnych hosterów (streamtape, dood, mixdrop, filemoon, + voe, vidoza, streamwish, ...) — bezpośredni dostęp do `_extract_info`. Te extractory + robią multi-step AJAX / token rotation / regex unpacking dla każdego hostera. + + Catch-all exception handling: jeśli yt-dlp nie ma extractora dla tego hostera lub + coś się sypie (timeout, anti-bot blokada, format change), wracamy None i caller + spadnie do hoster-fallback (mobile WebView). + """ + try: + from yt_dlp import YoutubeDL + except ImportError: + return None + + ydl_opts = { + "quiet": True, + "no_warnings": True, + "skip_download": True, + "noplaylist": True, + "socket_timeout": int(timeout), + } + try: + with YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(iframe_url, download=False) + except Exception as e: + log.debug("yt-dlp hoster fallback failed for %s: %s", iframe_url, type(e).__name__) + return None + + if info is None: + return None + + def _looks_like_video_url(u: str | None) -> bool: + if not u: + return False + if _looks_like_ad(u): + return False + low = u.lower() + # Standardowe formaty video. yt-dlp generic czasem zwraca page URL jako + # "info[url]" gdy nie rozpoznał stream'a (np. xtremestream.xyz player + # bez KVS markers). Bez tego checka extractor wracał iframe URL jako + # "stream", mobile próbował go odtwarzać przez ExoPlayer i dostawał + # "fake video" lub błąd (zgłoszone 2026-05-10 #30c4d3cf perverzija). + return any(ext in low for ext in (".m3u8", ".mp4", ".mpd", ".webm", ".ts")) + + # Best video format URL — yt-dlp już rankuje formats, pierwszy w `formats` zwykle jest + # najlepszy, albo `info["url"]` dla single-format extractorów. + formats = info.get("formats") or [info] + for fmt in formats: + if not isinstance(fmt, dict): + continue + url = fmt.get("url") + if _looks_like_video_url(url): + return url + # Fallback: top-level URL — ale tylko gdy faktycznie wygląda na video. + top = info.get("url") + if _looks_like_video_url(top): + return top + return None + + +__all__ = ["extract_stream_from_hoster", "unpack_packer", "HosterDead"] diff --git a/app/extractors/hosters/__init__.py b/app/extractors/hosters/__init__.py new file mode 100644 index 0000000..8ecd72d --- /dev/null +++ b/app/extractors/hosters/__init__.py @@ -0,0 +1,6 @@ +"""Per-hoster dedicated extractors (mixdrop, voe, luluvid, etc.). + +Dispatched z `app.extractors.hoster.extract_stream_from_hoster` na podstawie +URL hostname. Każdy moduł exportuje `extract(iframe_url, *, timeout)` → list[StreamSource] +lub None. +""" diff --git a/app/extractors/hosters/mixdrop.py b/app/extractors/hosters/mixdrop.py new file mode 100644 index 0000000..1ac01b9 --- /dev/null +++ b/app/extractors/hosters/mixdrop.py @@ -0,0 +1,82 @@ +"""Mixdrop embed hoster — P.A.C.K.E.R. eval → MDCore.wurl direct mp4. + +Pattern (verified 2026-05-15 via curl_cffi impersonate=chrome120): + 1. Fetch `https://mixdrop.my/e/<id>` → 200 z 95KB body, redirect 301 do + `https://m1xdrop.bz/e/<id>` (current TLD). + 2. Body zawiera P.A.C.K.E.R. obfuscated JS block: + `eval(function(p,a,c,k,e,d){...}('...packed...',N,N,'...|...'.split('|'),0,{}))` + 3. yt-dlp's `decode_packed_codes()` rozkrywa do ~390 chars JavaScript: + `MDCore.wurl="//a-delivery22.mxcontent.net/v2/<id>.mp4?s=<sig>&e=<exp>&_t=<ts>"` + 4. URL na `mxcontent.net` zwraca **direct mp4** (Content-Type: video/mp4, + Content-Length: ~485MB) — działa z Hetzner VPS IP, brak token IP-bind. + +`s` to signed token (HMAC?), `e` to expiry timestamp (unix sec), `_t` to +issued timestamp. Token jest valid ~24h od `_t`. Refetching embed page po +expiry zwraca nowy URL. + +Active mango movies: 203 playbacks origin='mangoporn:mixdrop' w DB. +""" +from __future__ import annotations + +import logging +import re + +from app.extractors._fetch import browser_get +from app.extractors._models import StreamSource + +log = logging.getLogger(__name__) + + +_PACKER_RE = re.compile( + r"eval\(function\(p,a,c,k,e,d\)\{.+?\}\(.+?\)\)", + re.DOTALL, +) +_MP4_URL_RE = re.compile(r'MDCore\.wurl\s*=\s*"([^"]+\.mp4[^"]*)"') + + +def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | None: + res = browser_get(page_url, timeout=timeout) + if res.status_code != 200 or not res.text: + log.info("mixdrop: fetch fail status=%s url=%s", res.status_code, page_url) + return None + + m = _PACKER_RE.search(res.text) + if not m: + log.info("mixdrop: no P.A.C.K.E.R. block in %s (page changed?)", page_url) + return None + + try: + from yt_dlp.utils import decode_packed_codes + decoded = decode_packed_codes(m.group(0)) + except Exception as e: + log.warning("mixdrop: decode_packed_codes failed: %s", e) + return None + + url_match = _MP4_URL_RE.search(decoded) + if not url_match: + log.info("mixdrop: no MDCore.wurl in decoded payload (len=%d)", len(decoded)) + return None + + raw_url = url_match.group(1) + # URL z mixdrop często jest protocol-relative (`//a-delivery22...`). + if raw_url.startswith("//"): + raw_url = "https:" + raw_url + + return [ + StreamSource( + link=raw_url, + quality=None, # mixdrop nie listuje quality variants w MDCore + type="mp4", + referer="https://mixdrop.my/", + # mxcontent CDN wymaga **same-session cookies** z embed page + + # Chrome JA3. Backend `extract` zamyka sesję po fetch → mobile + # próbuje mp4 bez cookies → 403. Proxy MUSI re-fetchować embed + # w fresh curl_cffi session, extract nowy mp4 URL, stream. + # `refetch_url` w raw → token field `rf` → proxy refresh logic. + raw={ + "proxy_impersonate": True, + "refetch_url": page_url, # embed page do re-extract + "refetch_hoster": "mixdrop", + }, + ) + ] diff --git a/app/extractors/hosters/seekplayer_engine.py b/app/extractors/hosters/seekplayer_engine.py new file mode 100644 index 0000000..4e2d5e1 --- /dev/null +++ b/app/extractors/hosters/seekplayer_engine.py @@ -0,0 +1,153 @@ +"""Common engine extractor for: embedseek, seekplayer, rpmplay, upns, player4me, easyvidplayer. + +Wszyscy używają tego samego silnika (Vite-built React SPA + AES-CBC encrypted API ++ HLS-based streaming). Hostname domains different ale shared backend. + +Pattern (verified 2026-05-15 z residential PL + VPS Hetzner FI): + +1. Embed URL = `https://<sub>.<host>.<tld>/#<hash_id>` — hash fragment to video ID. + SPA shell `Loading...` body load'uje `/assets/index-<n>.js` bundle. + +2. JS fetcha `/api/v1/video?id=<hash_id>&w=<W>&h=<H>&r=` (W,H z window.screen). + Response: hex-encoded AES-CBC(key=`kiemtienmua911ca`, iv=`1234567890oiuytr`) + ciphertext, ~5KB. PKCS7 padded. + +3. Plaintext JSON zawiera: + - `source`: signed m3u8 URL na CDN edge IP (np. `185.237.107.146/v4/<sig>/<exp>/ty/<hash>/master.m3u8?v=...`) + - `cf`: Cloudflare-fronted fallback URL (.txt z listą m3u8 paths) + - `metric.ipAddress`: IP visitora (signed token IP-bound do tego IP) + - `metric.cfDomain`: CF domain dla fallback + - `title`, `poster`, `thumbnail`, ... + +4. `source` URL jest signed z visitor IP. Z VPS fetch zwraca master.m3u8 z signed + token tied to VPS IP — proxy fetcha segments z tym samym tokenem, działa. + CDN port 443 z `verify=False` (self-signed IP cert). + +5. Wszystkie hostery share te same wartości KEY/IV. Wewnętrzna obfuskacja JS + maskuje to lookupem `ue(773)`, `ue(686)` itp. — derived bytes są zawsze + identyczne dla każdej domeny. + +Hostery covered (origin counts w DB, 2026-05-15): + - embedseek (20271), seekplayer (20271) — mirror sites, dzielą hash_id + - rpmplay (15317) + - upns (14287) + - player4me (41040) + - easyvidplayer (47588) + +Razem ~159k playback sources. +""" +from __future__ import annotations + +import json +import logging +import re +from urllib.parse import urlparse + +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +from app.extractors._fetch import _DEFAULT_UA, browser_get +from app.extractors._models import HosterDead, StreamSource + +log = logging.getLogger(__name__) + +_KEY = b"kiemtienmua911ca" +_IV = b"1234567890oiuytr" + +# Hostname matching: 6 base hosts × subdomains × TLD variants. +# Examples: +# my.embedseek.online, vip.seekplayer.vip, my.rpmplay.online, +# my.upns.online, vip.player4me.vip, p.easyvidplayer.com +_HOST_RE = re.compile( + r"^(?:[a-z0-9]+\.)?(?:embedseek|seekplayer|rpmplay|upns|player4me|easyvidplayer)\." + r"(?:online|vip|com|net|io|me|tv)$", + re.IGNORECASE, +) + + +def matches(url: str) -> bool: + try: + host = urlparse(url).hostname or "" + except Exception: + return False + return bool(_HOST_RE.match(host)) + + +def _decrypt(hex_str: str) -> str: + ct = bytes.fromhex(hex_str) + cipher = Cipher(algorithms.AES(_KEY), modes.CBC(_IV)) + dec = cipher.decryptor() + pt = dec.update(ct) + dec.finalize() + unpadder = padding.PKCS7(128).unpadder() + return (unpadder.update(pt) + unpadder.finalize()).decode("utf-8", errors="replace") + + +def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | None: + parsed = urlparse(page_url) + if not parsed.hostname or not _HOST_RE.match(parsed.hostname): + return None + # hash_id w `#<id>` fragmencie; gdy klient przekazał bez `#` (np. po nav.replace), + # spróbujemy też `?id=` query param. + hash_id = parsed.fragment.strip() + if not hash_id and parsed.query: + from urllib.parse import parse_qs + qs = parse_qs(parsed.query) + hash_id = (qs.get("id") or [""])[0] + if not hash_id: + log.info("seekplayer-engine: no hash_id w %s", page_url) + return None + + host = f"{parsed.scheme}://{parsed.hostname}" + api_url = f"{host}/api/v1/video?id={hash_id}&w=1920&h=1080&r=" + + headers = { + "User-Agent": _DEFAULT_UA, + "Accept": "*/*", + "Referer": f"{host}/", + } + r = browser_get(api_url, headers=headers, timeout=timeout) + if r.status_code in (404, 410): + raise HosterDead(f"seekplayer-engine {page_url}: HTTP {r.status_code}") + if r.status_code != 200 or not r.text: + log.info("seekplayer-engine: api fail %s status=%s", api_url, r.status_code) + return None + + try: + plaintext = _decrypt(r.text) + except Exception as e: + log.warning("seekplayer-engine: decrypt fail dla %s: %s", api_url, e) + return None + + try: + data = json.loads(plaintext) + except Exception as e: + log.warning("seekplayer-engine: JSON parse fail dla %s: %s", api_url, e) + return None + + # Hostery same-engine wracają `{"error": "..."}` gdy video nie istnieje. + if isinstance(data, dict) and data.get("error"): + raise HosterDead(f"seekplayer-engine {page_url}: {data['error']}") + + source = (data.get("source") or "").strip() + cf = (data.get("cf") or "").strip() + + # Source: IP-bound m3u8 URL na CDN edge (np. `185.237.107.146/v4/<sig>/<exp>/ty/<hash>/master.m3u8`). + # Token signed dla VPS IP — proxy poda segmenty z tego samego IP, OK. + # CDN servuje cert na IP — fetch wymaga verify=False (stream_proxy.py ma już + # taką gałąź dla IP-host m3u8). + sources: list[StreamSource] = [] + if source: + sources.append( + StreamSource( + link=source, + quality=None, + type="m3u8", + referer=f"{host}/", + raw={ + "proxy_no_verify": True, + "cf_fallback": cf or None, + "engine": "seekplayer", + }, + ) + ) + return sources or None diff --git a/app/extractors/hosters/streamtape.py b/app/extractors/hosters/streamtape.py new file mode 100644 index 0000000..830532a --- /dev/null +++ b/app/extractors/hosters/streamtape.py @@ -0,0 +1,117 @@ +"""Streamtape embed → direct mp4 extractor. + +Pattern (verified 2026-05-15 z residential, live URL `/e/PZqBZp4OomF0Q61`): + +1. Embed `/e/<id>` zwraca 89KB body z 4 `document.getElementById(...).innerHTML` + assignmentami konstruującymi pełen URL do `/get_video`. Każdy uses ten sam + pattern: + + document.getElementById('robotlink').innerHTML = + '//streamtape.com/get_video' + + ('<junk>?id=<id>&expires=...&ip=...&token=...').substring(N).substring(M); + + Junk to 3-4 znaki przed `?` — substring(N).substring(M) je odcina. + +2. Po sklejeniu fetch `https://streamtape.com/get_video?id=...&token=...` → + 302 → `https://<cluster>.tapecontent.net/radosgw/<id>/<signed_path>/<title>.mp4` + (direct mp4, video/mp4 ~500MB, brak IP-bind). + +3. Body czasem zwraca `Video not found! Maybe it got deleted by the creator!` + — większość URLów w naszej DB (12k mass-DMCA'd 2026-05-15). Wtedy raise + HosterDead, caller w playback.py oznaczy dead_at. + +Live URL coverage probed 2026-05-15: ~5% URLów żyje, reszta `Video not found`. +""" +from __future__ import annotations + +import logging +import re + +from app.extractors._fetch import _DEFAULT_UA, browser_get +from app.extractors._models import HosterDead, StreamSource + +log = logging.getLogger(__name__) + +# Match: `getElementById('xlink').innerHTML = "<prefix>" + '' + ('<suffix>').substring(N).substring(M);` +# Streamtape generuje 4 assignmenty (ideoolink x2 + botlink + robotlink) — 2 są DECOYs +# z połamanym hostname (`.comb`, `.cob`) i tylko botlink/robotlink dają prawdziwy URL. +# Prefix może być fragmentem: `/streamtape.com`, `//streamtape.co`, `//streamtape.com/g` +# — `get_video` często jest split między prefix i suffix po slice'ach. Decyzja na +# podstawie KOMBINOWANEGO output containing exact `streamtape.com/get_video?`. +_ASSIGN_RE = re.compile( + r"document\.getElementById\(['\"](?P<elem>[a-z]+link)['\"]\)\.innerHTML\s*=\s*" + r"['\"](?P<prefix>[^'\"]*streamtape[^'\"]*)['\"]" + r"\s*\+\s*(?:['\"]{2}\s*\+\s*)?" + r"\(['\"](?P<suffix>[^'\"]+)['\"]\)" + r"(?P<slices>(?:\.substring\(\d+\))+)", + re.IGNORECASE, +) +_SUBSTRING_RE = re.compile(r"\.substring\((\d+)\)") +_NOT_FOUND_RE = re.compile(r"Video\s+not\s+found", re.IGNORECASE) + + +def _apply_slices(suffix: str, slices_str: str) -> str: + out = suffix + for m in _SUBSTRING_RE.finditer(slices_str): + n = int(m.group(1)) + out = out[n:] + return out + + +def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | None: + headers = { + "User-Agent": _DEFAULT_UA, + "Accept": "text/html,application/xhtml+xml", + "Accept-Language": "en-US,en;q=0.9", + } + r = browser_get(page_url, headers=headers, timeout=timeout) + if r.status_code in (404, 410): + raise HosterDead(f"streamtape {page_url}: HTTP {r.status_code}") + if r.status_code != 200 or not r.text: + log.info("streamtape: fetch fail %s status=%s", page_url, r.status_code) + return None + + if _NOT_FOUND_RE.search(r.text): + raise HosterDead(f"streamtape {page_url}: Video not found") + + # Spróbuj wszystkie 4 assignmenty — pierwszy poprawny URL wygrywa. + # `get_video` może być w prefix (residential variant) lub split prefix+suffix + # (VPS variant gdzie decoy assignmenty produkują `.comb/get_video`). + final_url: str | None = None + for m in _ASSIGN_RE.finditer(r.text): + prefix = m.group("prefix").strip() + suffix = m.group("suffix") + slices = m.group("slices") + tail = _apply_slices(suffix, slices) + combined = prefix + tail + # Normalize: dodaj `https:` jeśli URL zaczyna się od `//` + if combined.startswith("//"): + url = "https:" + combined + elif combined.startswith("/"): + url = "https:/" + combined # `/streamtape.com/...` → `https://streamtape.com/...` + else: + url = combined + # Walidacja — odsiewa decoys (`streamtape.comb`, `streamtape.cob`). + if ( + "streamtape.com/get_video?" in url + and "id=" in url + and "token=" in url + ): + final_url = url + break + + if not final_url: + log.info("streamtape: no valid innerHTML assignment found in %s", page_url) + return None + + return [ + StreamSource( + link=final_url, + quality=None, + type="mp4", + referer=page_url, + # /get_video zwraca 302 do tapecontent.net direct mp4. Proxy musi + # follow redirect (stream_proxy domyślnie follow_redirects=True). + raw={"redirect_via": "streamtape_get_video"}, + ) + ] diff --git a/app/extractors/hosters/voe.py b/app/extractors/hosters/voe.py new file mode 100644 index 0000000..faa5ac9 --- /dev/null +++ b/app/extractors/hosters/voe.py @@ -0,0 +1,172 @@ +"""voe.sx embed → direct m3u8 extractor. + +Pattern (verified 2026-05-15 z VPS Hetzner FI): + +1. `voe.sx/e/<id>` zwraca 759-byte HTML z JS redirect: + + window.location.href = 'https://<random>.com/e/<id>' + + Mirror domena rotuje (rebeccasciencestreet.com, darnobedienceupscale.com itp.) — + bierzemy LITERAL `Location` z window.location.href assignment. + +2. Mirror embed page 137KB zawiera `<script type="application/json">["DROH@$nJjm..."]</script>` + — pojedyncza zakodowana string z chunkami 4-char rozdzielonymi 7 distinct + 2-char delimiterami: `@$`, `^^`, `~@`, `%?`, `*~`, `!!`, `#&`. + +3. Loader `/js/loader.bc4a6543429.js` (83KB obfuscator.io) wczytuje payload przez + `querySelectorAll('script[type=application/json]')[i].textContent`, parsuje + JSON, dekoduje 7-step pipeline (RE'd 2026-05-15): + + a. ROT13 letters + b. replace each of 7 magic separators with "_" + c. strip underscores + d. base64 decode (atob) + e. shift each char DOWN by 3 (charCode - 3) + f. reverse string + g. base64 decode AGAIN + h. JSON.parse + +4. Decoded JSON ma `source` (HLS m3u8 na `cloudwindow-route.com` z signed token) + + `fallback: [{file: ".mp4", type: "mp4", label: "720"}]`. URL signed + z `i=<visitor IP first 2 octets>` — z VPS dostajemy `i=46.62` (Hetzner). + Token IP-bound do tych pierwszych 2 oktetów (luźne) — proxy działa. + +5. CDN host losowy ale wzorzec stały. Wymaga Referer = voe.sx (lub mirror) bo + token z `?node=` valida. + +21607 movies origin='mangoporn:voe' w DB. +""" +from __future__ import annotations + +import base64 +import json +import logging +import re + +from app.extractors._fetch import _DEFAULT_UA, browser_get +from app.extractors._models import HosterDead, StreamSource + +log = logging.getLogger(__name__) + +_REDIRECT_RE = re.compile(r"window\.location\.href\s*=\s*['\"]([^'\"]+)['\"]") +_PAYLOAD_RE = re.compile(r'<script\s+type=["\']application/json["\']>(\[.+?\])</script>', re.DOTALL) +_MAGIC_SEPS = ("@$", "^^", "~@", "%?", "*~", "!!", "#&") + + +def _rot13(s: str) -> str: + out = [] + for ch in s: + c = ord(ch) + if 0x41 <= c <= 0x5A: + c = (c - 0x41 + 13) % 26 + 0x41 + elif 0x61 <= c <= 0x7A: + c = (c - 0x61 + 13) % 26 + 0x61 + out.append(chr(c)) + return "".join(out) + + +def _decode_payload(payload: str) -> dict | None: + """7-step pipeline z loader.bc4a6543429.js (RE 2026-05-15).""" + try: + s = _rot13(payload) + for sep in _MAGIC_SEPS: + s = s.replace(sep, "_") + s = s.replace("_", "") + # 1st atob — uses latin-1 to preserve all 256 byte values for shift step. + b = base64.b64decode(s + "=" * (-len(s) % 4)).decode("latin-1") + shifted = "".join(chr(ord(c) - 3) for c in b) + reversed_str = shifted[::-1] + plaintext = base64.b64decode(reversed_str + "=" * (-len(reversed_str) % 4)).decode( + "utf-8", errors="replace" + ) + return json.loads(plaintext) + except Exception as e: + log.warning("voe: decode pipeline fail: %s", e) + return None + + +def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | None: + headers = { + "User-Agent": _DEFAULT_UA, + "Accept": "text/html,application/xhtml+xml", + "Accept-Language": "en-US,en;q=0.9", + } + r = browser_get(page_url, headers=headers, timeout=timeout) + if r.status_code in (404, 410): + raise HosterDead(f"voe {page_url}: HTTP {r.status_code}") + if r.status_code != 200 or not r.text: + log.info("voe: stage1 fail %s status=%s", page_url, r.status_code) + return None + + # Stage 1: follow JS redirect do losowego mirroru. + target_url = page_url + if "window.location.href" in r.text: + m = _REDIRECT_RE.search(r.text) + if not m: + log.info("voe: redirect script ale brak href w %s", page_url) + return None + mirror_url = m.group(1) + # JS sprawdza `permanentToken` w localStorage; bez niego idzie na pierwszy + # match (`rebeccasciencestreet` itp). Wszyscy mają identyczny content. + r2 = browser_get(mirror_url, headers=headers, timeout=timeout) + if r2.status_code in (404, 410): + raise HosterDead(f"voe mirror {mirror_url}: HTTP {r2.status_code}") + if r2.status_code != 200 or not r2.text: + log.info("voe: mirror fail %s status=%s", mirror_url, r2.status_code) + return None + target_url = mirror_url + r = r2 + + # Stage 2: extract & decode JSON payload. + pm = _PAYLOAD_RE.search(r.text) + if not pm: + if "Video not found" in r.text or "Video has been removed" in r.text: + raise HosterDead(f"voe {target_url}: video not found") + log.info("voe: no application/json payload w %s", target_url) + return None + try: + arr = json.loads(pm.group(1)) + payload = arr[0] if isinstance(arr, list) and arr else None + except Exception as e: + log.warning("voe: JSON list parse fail %s: %s", target_url, e) + return None + if not isinstance(payload, str): + return None + + config = _decode_payload(payload) + if not config: + return None + + source = (config.get("source") or "").strip() + fallback = config.get("fallback") or [] + if isinstance(fallback, dict): + fallback = [fallback] + + # Voe CDN URL ma `i=<2-octet IP prefix>` — token loose-bound do IP range. + # Proxy z VPS dostaje signed dla `i=46.62`, fetch działa. + referer = "https://voe.sx/" + sources: list[StreamSource] = [] + if source: + sources.append( + StreamSource( + link=source, + quality=None, + type="m3u8", + referer=referer, + raw={"engine": "voe"}, + ) + ) + # Dorzucamy mp4 fallback gdy m3u8 by zawiódł. + for fb in fallback: + if isinstance(fb, dict) and fb.get("file"): + sources.append( + StreamSource( + link=fb["file"], + quality=fb.get("label"), + type="mp4", + referer=referer, + raw={"engine": "voe", "fallback": True}, + ) + ) + + return sources or None diff --git a/app/extractors/iframe_pick.py b/app/extractors/iframe_pick.py new file mode 100644 index 0000000..c792dbe --- /dev/null +++ b/app/extractors/iframe_pick.py @@ -0,0 +1,109 @@ +"""Wybór najlepszego iframe-hostera z page HTML tube'a typu wrapper. + +Tube'y typu siska/perverzija/latestpornvideo nie hostują player'a same — embedują +zewnętrznych hosterów (luluvid, doodporn, mixdrop, streamtape, ...). Detail page +zawiera typowo 2-5 iframes: + - ad-iframes (willingcease.com, popads, discord, about:blank) + - dead hosters (streamtape malware, openload offline) + - file-hosters (rapidgator, nitroflare — premium walled) + - "fake" hosters typu playmogo (niszowe forki, brak extractora) + - real KVS hosters (luluvid, doodporn, mixdrop, voe — `extract_stream_from_hoster` + radzi sobie z nimi przez KVS markers + yt-dlp generic) + +`extract_best_iframe()` filtruje śmieci i preferuje hostery known-working z naszej +implementacji. Używane przez scrapery (siska, latestpornvideo, perverzija) ŚW +2-etapowym scrape: page listing → URL sceny → fetch detail → pick iframe → save +jako `embed_url` w RawPlaybackSource. +""" +from __future__ import annotations + +import re + +from app.extractors.tubes._embed_iframe import DEAD_HOSTER_RE + +# Ad / popup iframes — popunder networks + Discord widget + about:blank. +# Niektóre tube'y embedują kilka takich PRZED prawdziwym player'em żeby +# zmonetyzować ruch — naszego extractora trzeba przeskoczyć żeby trafić na video. +_AD_IFRAME_RE = re.compile( + r"willingcease\.com" + r"|popads|popunder|adsterra" + r"|discord\.com/widget" + r"|about:blank", + re.IGNORECASE, +) + +# Hostery których NIGDY nie obsłużymy direct extract (premium / file storage / +# proprietary players bez KVS / playmogo-style niche). Mobile WebView może je +# otworzyć, ale priorytet w pick'u to im trafić jako fallback NA OSTATNIM +# miejscu, nie pierwszym. +_LOW_PRIORITY_HOSTERS_RE = re.compile( + r"rapidgator|nitroflare|filer\.net|frdl\." + r"|playmogo\.com" # generic player redirect, brak ext + r"|easyvidplayer|embedseek|upns\.online|seekplayer|rpmplay", + re.IGNORECASE, +) + +# Hostery które MAMY potwierdzone że działają z `extract_stream_from_hoster`. +# Kolejność = priority list (lewy = najbardziej preferowany). +_PREFERRED_HOSTERS = ( + "luluvid", + "lulustream", + "doodporn", + "doodstream", + "dood.la", + "mixdrop", + "voe.sx", # czasem działa +) + + +def _iframe_priority(url: str) -> int: + """Lower = better. 0..N dla preferred, 500 dla nieznanych, 1000 low-priority.""" + if _LOW_PRIORITY_HOSTERS_RE.search(url): + return 1000 + for i, h in enumerate(_PREFERRED_HOSTERS): + if h in url.lower(): + return i + return 500 + + +_IFRAME_SRC_RE = re.compile(r'<iframe[^>]+src=["\']([^"\']+)["\']', re.IGNORECASE) + + +def extract_best_iframe(html: str, *, base_url: str | None = None) -> str | None: + """Zwraca URL pierwszego iframe NIE-ad, NIE-dead, posortowanego po priority. + + `base_url` używany do resolwowania protocol-relative (//host/path) i relative + URL-i (/embed/xxx) na absolute. Jeśli None i iframe jest relative, jest + pomijany. + """ + if not html: + return None + + urls: list[str] = [] + for m in _IFRAME_SRC_RE.finditer(html): + src = m.group(1).strip() + if not src or src.lower() == "about:blank": + continue + if src.startswith("//"): + src = "https:" + src + elif src.startswith("/") and base_url: + from urllib.parse import urljoin + src = urljoin(base_url, src) + urls.append(src) + + if not urls: + return None + + clean = [] + for u in urls: + if _AD_IFRAME_RE.search(u): + continue + if DEAD_HOSTER_RE.search(u): + continue + clean.append(u) + + if not clean: + return None + + clean.sort(key=_iframe_priority) + return clean[0] diff --git a/app/extractors/tag_extract.py b/app/extractors/tag_extract.py new file mode 100644 index 0000000..3cad349 --- /dev/null +++ b/app/extractors/tag_extract.py @@ -0,0 +1,239 @@ +"""Per-tube tag/category scraper z page HTML. + +yt-dlp generic nie zwraca tagów dla większości tubes (formaty się różnią). +Każda funkcja `extract_<tube>(html) -> list[str]` parsuje HTML konkretnego tube'a +żeby wyciągnąć kategoryczne tagi sceny (nie auto-generated n-gramy z tytułu). + +Mapowanie sitetag → extractor jest w `EXTRACTORS` na końcu. `extract_tags(sitetag, html)` +to public entrypoint. + +UWAGA: nie zwracamy auto-generated word-ngramów (KVS bywa generuje "Tania", +"Tania Amazon", "Amazon Shaft"... — bez wartości semantycznej). Dlatego pornditt +NIE jest tu obsłużone — KVS tagi to garbage. +""" +from __future__ import annotations + +import json +import re +from collections.abc import Callable + +# Common helpers +def _clean_name(name: str) -> str: + return re.sub(r"\s+", " ", name).strip() + + +def _dedupe(items: list[str], cap: int = 20) -> list[str]: + seen: set[str] = set() + out: list[str] = [] + for it in items: + key = it.lower().strip() + if not key or key in seen: + continue + seen.add(key) + out.append(it) + if len(out) >= cap: + break + return out + + +# ---- per-tube extractors -------------------------------------------------- + +def extract_porntrex(html: str) -> list[str]: + """porntrex: <span>Categories:</span><div class="items-holder js-categories"><a class="js-cat" href="/categories/<slug>/">NAME</a>... + `Tags:` block na porntrex zawiera auto-generated n-gramy ("Tania", "L H") — pomijamy. + """ + m = re.search(r'Categories:</span>\s*<div[^>]+>(.*?)</div>', html, re.DOTALL | re.IGNORECASE) + if not m: + return [] + block = m.group(1) + names = re.findall(r'<a[^>]+href="[^"]*?/categories/[^"]+"[^>]*>([^<]+)</a>', block, re.IGNORECASE) + return _dedupe([_clean_name(n) for n in names]) + + +def extract_youporn(html: str) -> list[str]: + """youporn: <a href="/category/<slug>/">NAME</a>""" + matches = re.findall( + r'<a[^>]+href="/category/[a-z0-9-]+/?"[^>]*>\s*([^<]+?)\s*</a>', + html, re.IGNORECASE, + ) + return _dedupe([_clean_name(n) for n in matches]) + + +def extract_xvideos(html: str) -> list[str]: + """xvideos: <a href="/tags/<slug>/">name</a> (slug-style names — przerobimy na Title Case).""" + matches = re.findall( + r'<a[^>]+href="[^"]*?/tags/([a-z0-9-]+)/?"[^>]*>', + html, re.IGNORECASE, + ) + # slug "ass-to-mouth" → "Ass To Mouth" + return _dedupe([s.replace("-", " ").title() for s in matches]) + + +def extract_xnxx(html: str) -> list[str]: + """xnxx: tagi w JSON `"video_tags":["ass","deep-throat","rimming",...]` wewnątrz + html5player config (NIE w `/tags/` URL-ach jak xvideos — to są różne systemy + pomimo tego że oba należą do WGCZ Holding). + """ + m = re.search(r'"video_tags"\s*:\s*\[([^\]]+)\]', html) + if not m: + return [] + slugs = re.findall(r'"([^"]+)"', m.group(1)) + return _dedupe([s.replace("-", " ").title() for s in slugs if s]) + + +def extract_redtube(html: str) -> list[str]: + """redtube: <a class="...video_carousel_category..." href="/redtube/<slug>">NAME</a>""" + matches = re.findall( + r'<a[^>]+href="/redtube/[a-z0-9-]+"[^>]*>\s*([^<]+?)\s*</a>', + html, re.IGNORECASE, + ) + cleaned = [_clean_name(n) for n in matches] + cleaned = [n for n in cleaned if n.lower() not in {"categories", "tags", "category", "tag"}] + return _dedupe(cleaned) + + +def _find_balanced_array(html: str, start_after: str) -> str | None: + """Po napotkaniu `"tags":[` znajdź matching `]` honorując nested {}/[]/strings. + Zwraca zawartość arraya (bez nawiasów) albo None. + """ + idx = html.find(start_after) + if idx == -1: + return None + pos = idx + len(start_after) # right after `[` + depth = 1 + in_str = False + escape = False + n = len(html) + while pos < n: + ch = html[pos] + if escape: + escape = False + elif ch == "\\": + escape = True + elif ch == '"' and not escape: + in_str = not in_str + elif not in_str: + if ch in "[{": + depth += 1 + elif ch in "]}": + depth -= 1 + if depth == 0: + return html[idx + len(start_after):pos] + pos += 1 + return None + + +def extract_xhamster(html: str) -> list[str]: + """xhamster: rich JSON w `<script>` z `"tags":[{...isCategory:true...}, ...]`. + Bierzemy `isCategory:true` (prawdziwe kategorie sceny) + `isTag:true` (drobne tagi + typu "Hard", "Horny"). Pomijamy `isPornstar:true` (performerów mamy z innego źródła). + Używamy bracket-balanced parser bo array jest długi z nested objects (`features:null`). + """ + blob = _find_balanced_array(html, '"tags":[') + if blob is None: + return [] + try: + items = json.loads("[" + blob + "]") + except json.JSONDecodeError: + return [] + out: list[str] = [] + for it in items: + if not isinstance(it, dict): + continue + if it.get("isCategory") or it.get("isTag"): + name = it.get("nameEn") or it.get("name") + if name: + out.append(_clean_name(name)) + return _dedupe(out) + + +def extract_pornhat(html: str) -> list[str]: + """pornhat: `class="info-video js-ajax-tag btn" data-setup='{"title": ...}'`.""" + matches = re.findall( + r"class=\"info-video js-ajax-tag[^\"]*\"[^>]*data-setup='([^']+)'", + html, re.IGNORECASE, + ) + names: list[str] = [] + for raw in matches: + try: + data = json.loads(raw) + except json.JSONDecodeError: + continue + title = (data.get("title") or "").strip() + if title: + names.append(title) + return _dedupe(names) + + +def extract_eporner(html: str) -> list[str]: + """eporner: `<li class="vit-category">` + `<li class="vit-tag">` zawierają linki do + kategorii/tagów sceny. Pomijamy `vit-uploader` / `vit-suggester` itp.""" + matches = re.findall( + r'<li class="vit-(?:category|tag)"[^>]*>\s*<a[^>]+>([^<]+)</a>', + html, re.IGNORECASE, + ) + cleaned = [_clean_name(n) for n in matches] + # "Uncategorized" pomijamy + cleaned = [n for n in cleaned if n.lower() != "uncategorized"] + return _dedupe(cleaned) + + +def extract_hqporner(html: str) -> list[str]: + """hqporner: tagi siedzą w meta description po "Tags related to this video:". + + Detail page jest "thin" (~26KB) bez tag links w body — wszystko jest tylko + w `<meta name="description">` w formie zdania: + `Watch porn video <Title>. Video duration is Xmin Ysec. Tags related to + this video: handjob, babe, for cash, blowjob, black hair.` + + Plus HTML comment na początku page: + `<!--+handjob +mofos network +public pickup 1080p_handjob+mofosnetwork+publicpickup-->` + """ + out: list[str] = [] + # Primary: meta description + if (m := re.search( + r'<meta\s+name="description"\s+content="[^"]*Tags related to this video:\s*([^."]+)', + html, re.IGNORECASE, + )): + for tok in m.group(1).split(","): + t = _clean_name(tok) + if t: + out.append(t) + + # Fallback: HTML opening comment `<!--+tag1 +tag2 ...-->` + if not out: + if (m := re.search(r"<!--((?:\+[\w \-]+)+)", html)): + for tok in m.group(1).split("+"): + t = _clean_name(tok) + if t: + out.append(t) + return _dedupe(out) + + +# ---- registry -------------------------------------------------------------- + +EXTRACTORS: dict[str, Callable[[str], list[str]]] = { + "porntrexcom": extract_porntrex, + "youporncom": extract_youporn, + "xvideoscom": extract_xvideos, + "xnxxcom": extract_xnxx, + "redtubecom": extract_redtube, + "xhamstercom": extract_xhamster, + "epornercom": extract_eporner, + "pornhatcom": extract_pornhat, + "hqpornercom": extract_hqporner, +} + + +def extract_tags(sitetag: str, html: str) -> list[str]: + """Zwraca clean listę tagów dla danego tube'a, lub [] gdy brak extractora/tagów.""" + fn = EXTRACTORS.get(sitetag) + if fn is None: + return [] + try: + return fn(html) + except Exception: + return [] + + +__all__ = ["extract_tags", "EXTRACTORS"] diff --git a/app/extractors/thumb_extract.py b/app/extractors/thumb_extract.py new file mode 100644 index 0000000..846fffe --- /dev/null +++ b/app/extractors/thumb_extract.py @@ -0,0 +1,81 @@ +"""Universal thumbnail URL extractor for tube pages. + +Direct scrapery (search-only) zwracają RawScene z thumbnail_url=None dla większości +tube'ów (xnxx, hdporn92, sxyland, sxyprn). Detail page zawiera URL miniatury w +jednym z patternów: + +1. **OpenGraph** (najbardziej powszechne): + `<meta property="og:image" content="https://cdn.../thumb.jpg">` +2. **Twitter Card** (fallback gdy og:image brak): + `<meta name="twitter:image" content="...">` +3. **Schema.org VideoObject LD-JSON**: + `"thumbnailUrl": "https://..."` lub `"thumbnailUrl": ["url1", "url2"]` +4. **html5player** (KVS-based — xnxx/xvideos): + `html5player.setThumbUrl('https://thumb-cdn77.xnxx-cdn.com/.../t.jpg')` + +Funkcja zwraca pierwszy znaleziony URL (string), lub None gdy żaden nie pasuje. +""" +from __future__ import annotations + +import re + +_OG_IMAGE_RE = re.compile( + r'<meta\s+property="og:image"\s+content="([^"]+)"', re.IGNORECASE +) +_TWITTER_IMAGE_RE = re.compile( + r'<meta\s+name="twitter:image(?::src)?"\s+content="([^"]+)"', re.IGNORECASE +) +_LD_THUMB_RE = re.compile( + r'"thumbnailUrl"\s*:\s*(?:"([^"]+)"|\[\s*"([^"]+)")', re.IGNORECASE +) +_KVS_THUMB_RE = re.compile( + r"setThumbUrl\(\s*['\"]([^'\"]+)['\"]", re.IGNORECASE +) +# hqporner: stare WordPress bez og:image / schema. Poster `_main.jpg` siedzi w +# atrybucie onclick (`changeImage('//cdn/_main.jpg', 'cover_<id>')`) lub w preload +# array (`preload_<id> = ['//.../_1.jpg', ...]`). Wyciągamy `_main.jpg` z CDN-a +# fastporndelivery.hqporner.com — to canonical poster, frames `_1..N.jpg` to +# hover animation. +_HQPORNER_THUMB_RE = re.compile( + r"['\"](//[a-z0-9.\-]*hqporner[a-z0-9.\-]*/imgs/[^'\"]+_main\.jpg)['\"]", + re.IGNORECASE, +) + + +def extract_thumbnail_url(html: str) -> str | None: + """Zwraca pierwszą znalezioną miniaturkę URL lub None. + + Kolejność: OG → Twitter → LD-JSON → KVS html5player. og:image jest + najpopularniejszy (większość WordPress + KVS-based tubes); pozostałe to + fallback dla niszowych templatek. + """ + if not html: + return None + + if (m := _OG_IMAGE_RE.search(html)): + url = m.group(1).strip() + if url and not url.startswith("data:"): + return url + + if (m := _TWITTER_IMAGE_RE.search(html)): + url = m.group(1).strip() + if url and not url.startswith("data:"): + return url + + if (m := _LD_THUMB_RE.search(html)): + url = (m.group(1) or m.group(2) or "").strip() + if url: + return url + + if (m := _KVS_THUMB_RE.search(html)): + url = m.group(1).strip() + if url: + return url + + if (m := _HQPORNER_THUMB_RE.search(html)): + url = m.group(1).strip() + if url: + # Protocol-relative `//cdn/path` → https. + return "https:" + url if url.startswith("//") else url + + return None diff --git a/app/extractors/tubes/__init__.py b/app/extractors/tubes/__init__.py new file mode 100644 index 0000000..700ccca --- /dev/null +++ b/app/extractors/tubes/__init__.py @@ -0,0 +1,5 @@ +"""Per-tube stream URL extractors. + +Każdy moduł `<tube>.py` eksportuje funkcję `extract(page_url) -> list[StreamSource] | None`. +Registry w `app.extractors.__init__` mapuje sitetag → extract(). +""" diff --git a/app/extractors/tubes/_embed_iframe.py b/app/extractors/tubes/_embed_iframe.py new file mode 100644 index 0000000..8e3fa7b --- /dev/null +++ b/app/extractors/tubes/_embed_iframe.py @@ -0,0 +1,520 @@ +"""Generic embed-iframe stream extractor. + +Wzorzec stosowany przez większość aggregator tubes (latestpornvideo, xmoviesforyou, +watchporn, siska, porn4days, porndish, xxxfreewatch, latestleaks, mypornerleak, +porndittcom, fpoxxx, hdporn92, sxyland, 0dayxx, ...): + + 1. Fetch page HTML + 2. Znajdź `<iframe src=...>` wskazujący na hoster (StreamWish/doodporn/luluvdo/ + mediafire/sdefx/playmogo/...). Iframe path moze być `/e/<id>`, `/embed/<id>`, + `/video/embed/<id>` LUB goły slug `/<id>` (sdefx.cloud, niektóre playmogo). + 3. Filtruj iframe-y reklamowe (ad domains + `?key=`, `?idzone=` parametry). + 4. Spróbuj `extract_stream_from_hoster` (P.A.C.K.E.R. unpack JWPlayer) → m3u8/mp4. + 5. Jeśli nie wyciągnęło URL (CAPTCHA, multi-step JS, anti-bot), fallback do + hoster type — mobile otworzy w WebView, hoster JS sam zrobi swoje. + +`referer` dla hoster fetchu to `https://<page_host>/` — komputujemy automatycznie +z `page_url`, więc rejestrujemy ten sam extractor pod wieloma sitetag-ami bez +osobnych config'ów. +""" +from __future__ import annotations + +import logging +import re +from urllib.parse import urlparse + +from app.extractors._fetch import fetch_tube_html +from app.extractors._models import StreamSource +from app.extractors.hoster import extract_stream_from_hoster + +log = logging.getLogger(__name__) + + +# Każdy iframe na stronie — szeroki match. Filtrowanie reklamowych zachodzi niżej. +# Generic TLD `[a-z]{2,8}` żeby pokryć egzotyczne hosting domains (.yt, .ws, .li, +# .stream, .pro, .cloud, .live). +ANY_IFRAME_RE = re.compile( + r'<iframe[^>]+src=["\'](?P<url>(?:https?:)?(?://)?[a-z0-9.-]+\.[a-z]{2,8}/[^"\']+)', + re.IGNORECASE, +) + +# Path patterns for "this is a player embed" — covers most hoster URL shapes: +# /e/<id>, /embed/<id>, /video/embed/<id> → StreamWish, doodporn, dood.yt, luluvdo +# /embed-<id>(.html)? → xtapes.porn, niektóre stare hosty +# /player/<id>(.php)?(?...)? → xtremestream.xyz, niektóre custom hosty +# /v/<id>, /watch/<id> → emergency catch — niektóre tubes +PLAYER_PATH_RE = re.compile( + r'/(?:e|embed|video/embed|v|watch|t|f|d|stream)/[a-zA-Z0-9_\-]+' + r'|/embed-[a-zA-Z0-9_\-]+' + r'|/player(?:/[a-zA-Z0-9_/.\-]+)?', + re.IGNORECASE, +) + +# Bare-slug player path (sdefx.cloud, niektóre playmogo): single path segment after host, +# alphanumeric + dash. Żadnego `/folder/file` ani query stringa — to wykrycie hosterów +# które nie używają standardowego `/e/<id>` patternu. +BARE_SLUG_PATH_RE = re.compile(r'^/[a-zA-Z0-9_\-]{4,}/?$') + +# Ad iframes — domeny reklam + parametry (idzone, key=) — odsiewamy nawet jeśli mają +# inne path patterns. Lista ze stron które rzeczywiście aggregator tubes serwują. +AD_DOMAIN_RE = re.compile( + r'(?:willingcease|propellerads|popads|popcash|trafficstars|exoclick|adsterra|' + r'happyleafmotion|adskeeper|hilltopads|juicyads|trafficjunky|adblade|' + # Cam-widget / smartpop / pop-under sieci embed'owane przez aggregator tubes + # (porndish→mavrtracktor, xmoviesforyou→adtng+bluetrafficstream). Brak ich w + # liście powodował że `raw_iframes_count` rosło → fall-through do "all blacklisted" + # → None zamiast page-as-hoster fallback (WebView na page'a aggregatora). + r'mavrtracktor|adtng|bluetrafficstream|smartpop|chaturbate|stripchat|streamate|' + r'mypornclub|cdntrafficstars|trafficfactory|popcrn|popmyads|adcash)\.[a-z]{2,8}', + re.IGNORECASE, +) +AD_QUERY_RE = re.compile(r'[?&](?:idzone|adkey|key|aff_id|adsrc)=', re.IGNORECASE) + +# Direct stream URLs na page — niektóre aggregator tubes (porn4days, xmoviesforyou) +# wstawiają full mp4/m3u8 URL bezpośrednio w HTML/JS (download link, video.js source, +# JWPlayer config). Skanujemy je przed iframe processing — direct URL > iframe wymagający +# WebView. Wymagamy że path zawiera quality marker (`<digits>p.mp4`) lub `.m3u8` żeby +# uniknąć false-positives z thumbnail/preview generation endpoints. +_DIRECT_MP4_RE = re.compile( + r'(?P<url>https?://[a-zA-Z0-9.\-]+\.[a-z]{2,8}(?:/[^"\'\s<>]+)?/[^"\'\s<>]*?' + r'(?:480p|720p|1080p|2160p|360p|240p|144p)[^"\'\s<>]*\.mp4(?:\?[^"\'\s<>]*)?)', + re.IGNORECASE, +) +_DIRECT_M3U8_RE = re.compile( + r'(?P<url>https?://[a-zA-Z0-9.\-]+\.[a-z]{2,8}/[^"\'\s<>]+?\.m3u8(?:\?[^"\'\s<>]*)?)', + re.IGNORECASE, +) + +# Hosty którym `_DIRECT_*_RE` regex matchuje (URL kończy się .mp4 z quality +# marker w path) ALE które NIE serwują direct stream: +# 1. File hosters (rapidgator/k2s/mediafire/...) — file storage, premium auth gate +# Pattern: `https://rapidgator.net/file/<hash>/scene.1080p.mp4` — to download URL, +# mobile dostaje login page (HTML), nie video. +# 2. Embed page'y z mp4-suffix path (playmogo/dood/filemoon /d/<id>/<filename>.mp4) — +# faktycznie HTML embed page, CAPTCHA-walled. +# Bez filter Stage 0.5 zwracał tę URL jako type=mp4 i ExoPlayer dostawał HTML zamiast +# video → black screen / playback failed. +_NOT_DIRECT_STREAM_RE = re.compile( + r'(?:' + r'rapidgator\.net|rg\.to|k2s\.cc|keep2share|nitroflare|turbobit|hexupload|' + r'1fichier|uploaded\.(?:net|to)|ul\.to|mega\.(?:co\.)?nz|mediafire|fastleech|' + # DoodStream rebrand /d/ download pages (HTML, not mp4) + r'playmogo\.|d0000d|dooood|d0o0d|do0od|do7go|doodstream|doodporn|dood\.(?:la|li|ws|so|to|watch|work|yt|re)|' + # File hide embed pages + r'streamhide|vidhide|filemoon|kerapoxy|moonseries' + r')', + re.IGNORECASE, +) + +# JS-defined server URLs — niektóre aggregator tubes (porn4days) trzymają backup +# hosterów w JavaScript variables, a iframe renderuje tylko pierwszy. Reszta jest +# dostępna przez clickable "Server 2"/"Server 3" buttons które JS-em swapują src +# iframe'a. Pattern: `const SERVER<N>_URL = "https://..."` lub `var server<N> =`. +# Dla nas to są dodatkowe iframe URLs które trzeba zebrać jak inne — extract_stream_from_hoster +# spróbuje je rozpakować, a fallback to type=hoster dla mobile WebView. +_JS_SERVER_URL_RE = re.compile( + r'(?:const|let|var)?\s*(?:SERVER\d+_URL|server\d+(?:_url)?|src\d+|stream_?\d+|video_?\d+)\s*=\s*' + r'["\'](?P<url>https?://[^"\']+)["\']', + re.IGNORECASE, +) + +# Anchor-href hoster links — xmoviesforyou pattern: scene page nie ma <iframe> playera, +# tylko serię `<a href="https://playmogo.com/d/...">MIXDROP</a>` download buttons. +# Match na anchor wskazujący na typowe hoster domeny — daje fallback gdy brak iframe. +_ANCHOR_HOSTER_RE = re.compile( + r'<a\s+[^>]*href=["\'](?P<url>https?://(?:' + r'playmogo|luluvid|doodporn|doodstream|dood\.[a-z]+|streamtape|streamta\.pe|' + r'filemoon|streamwish|sdefx|veev|turbovidhls|gounlimited|iceyfile|hlswish|' + r'mixdrop|voe|vidoza|mediafire|asnwish|obeywish|streamruby|hqq\.[a-z]+|' + r'feurl|streamhide|krakenfiles|earnvids|jollytuna|peekvids|playerwish' + r')\.[a-z]{2,8}/[^"\']+)["\']', + re.IGNORECASE, +) + +# Iframe src ukryty w escape'owanym JS string literal — porndish pattern: +# `const doodstreamContent = "<iframe ... src=\"https://playmogo.com/e/xyz\" ...></iframe>";` +# Server-side widzimy tylko pusty `<div id="iframeHolder">` (iframe wstrzykuje się JS-em +# po kliknięciu "Video Player 1" button). Match na `src=\"<url>\"` 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( + r'src=\\"(?P<url>https?:[^"]+?)\\"', + re.IGNORECASE, +) +# Data attribute pattern (mypornerleak's `data-embed="https://cdnstream.top/e/..."`). +# Tube renderuje `<div class="iframeholder" data-embed="...">` + JS które po user click +# wstawia iframe ze stored URL'em. Bez tego nasz ANY_IFRAME_RE widzi tylko pusty +# holder + ad iframes → falls back na page-as-hoster (mobile WebView na aggregator +# stronę = full-screen ad redirect, bug-report f1a01585 2026-05-17). +_DATA_EMBED_RE = re.compile( + r'data-embed=["\'](?P<url>(?:https?:)?(?://)?[a-z0-9.-]+\.[a-z]{2,8}/[^"\']+)', + re.IGNORECASE, +) + +# 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. +# 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 +# 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, +# 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 +# porn4days.pw → streamtape.com/e/<id> → popup + ściąganie .reg). +# Hostname boundary: `(?:^|//|\.)` PRZED domain + `/` PO żeby `/filemoon.html` +# w path nie matchował, tylko prawdziwy hostname (`https://filemoon.to/...` lub +# `cdn.filemoon.to/...`). Bez tego DEAD_HOSTER_RE potencjalnie false-positive +# blacklistował legit URL-e z fragmentami w ścieżce (code-review #17). +DEAD_HOSTER_RE = re.compile( + r'(?:^|//|\.)' + r'(?:' + # camcaps.to dead. xtapes.porn ZNIESIONE 2026-05-15 (Chrome DevTools verify: + # → reelshdd.com/<id>.mp4 z residential IP = działa, tylko VPS blocked). + r'camcaps\.to' # dead + 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.* — wszystkie mirrory (filemoon.to/sx/nl/in/ru/co + aliasy + # kerapoxy.cc, lvturbo.com) serwują od ~2026-05 ten sam SPA "Byse Frontend" + # placeholder bez player JS. Globalny shutdown. Siska/perverzija/xmoviesforyou + # mają filemoon jako default embed → wszystkie sceny przez ten path = dead + # iframe (bug-report 16966e77 2026-05-16 "Niby 404 ale graficzne"). Blacklist + # eliminuje próby + wymusza fallback na alt hostera / TubePageError None. + r'|filemoon\.[a-z]{2,4}|kerapoxy\.cc|lvturbo\.com|emturbovid\.com' # dead 2026-05 + r')' + r'(?:[:/]|$)', # port, path, lub end-of-string + re.IGNORECASE, +) + +# 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ą +# najpierw clean hosty, fallback na Dood. Wzorzec sync z mobile/src/lib/doodstream.ts. +CAPTCHA_HOSTER_RE = re.compile( + r'(?:' + r'(?:playmogo|doodstream|doodporn|ds2play|ds2video|d000d|d0o0d|do0od|do7go|dooood|d0000d)\.[a-z]{2,8}' + r'|dood\.(?:la|li|ws|so|to|watch|work|yt|re)' + r')', + re.IGNORECASE, +) + +# 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). +# +# Bandwidth cost (public release): te tubes by szli całością przez VPS proxy. +# Skip ich z Stage 1 → mobile WebView → 0 VPS bandwidth. +_IP_BOUND_CDN_RE = re.compile( + r"\b(?:" + r"premilkyway\.com" # latestpornvideo + r"|tnmr\.org" # mypornerleak (legacy CDN) + r"|acek-cdn\.com" # mypornerleak (current CDN, shared KVS infra) + # URL signature shared across these CDNs: `/hls2/<XX>/<scene_id>/.../master.m3u8?t=<token>&s=<ts>&e=<exp>&srv=<srv>&asn=` + # — `asn` query param = Autonomous System Number bind. Generic match jako safety net. + r")\b", + re.IGNORECASE, +) + + +def _hoster_priority(url: str) -> int: + """Niższa wartość = wcześniej w liście. CAPTCHA-walled hosty (DoodStream variants) na końcu.""" + if CAPTCHA_HOSTER_RE.search(url): + return 1 + return 0 + + +def _is_player_iframe(url: str) -> bool: + """Heurystyka: czy iframe wygląda na player a nie reklamę.""" + if AD_DOMAIN_RE.search(url): + return False + if AD_QUERY_RE.search(url): + return False + if DEAD_HOSTER_RE.search(url): + return False + # Player path — `/e/`, `/embed/`, `/video/embed/` + if PLAYER_PATH_RE.search(url): + return True + # Bare slug — `<host>/<id>` (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 + return False + + +def _extract_direct_stream_urls(page_html: str, page_url: str) -> list[StreamSource]: + """Stage 0.5: scan page for direct mp4/m3u8 URLs (porn4days→iceyfile/gounlimited + download links, xmoviesforyou native player). Returns deduplicated sources + ordered by quality desc. + + Returns empty list jeśli żadnych nie znaleziono lub wszystkie są na blacklisted + hosterach (streamtape itp.). + """ + seen: set[str] = set() + sources: list[tuple[int, StreamSource]] = [] # (quality_int, source) for sorting + + quality_map = { + "2160p": 2160, "1080p": 1080, "720p": 720, + "480p": 480, "360p": 360, "240p": 240, "144p": 144, + } + + page_host = (urlparse(page_url).hostname or "").lstrip("www.") + page_referer = f"https://{page_host}/" if page_host else page_url + + for m in _DIRECT_MP4_RE.finditer(page_html): + url = m.group("url") + if url in seen: + continue + 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). + if _NOT_DIRECT_STREAM_RE.search(url): + continue + # Quality wykrycie z nazwy pliku + q_int = 0 + q_label = "mp4" + for q_str, q_val in quality_map.items(): + if q_str in url.lower(): + q_int = q_val + q_label = q_str + break + sources.append((q_int, StreamSource(link=url, type="mp4", quality=q_label, referer=page_referer))) + + for m in _DIRECT_M3U8_RE.finditer(page_html): + url = m.group("url") + if url in seen: + continue + seen.add(url) + if DEAD_HOSTER_RE.search(url) or AD_DOMAIN_RE.search(url): + continue + if _NOT_DIRECT_STREAM_RE.search(url): + continue + # m3u8 traktujemy jako wyższą jakość (adaptive) + sources.append((10000, StreamSource(link=url, type="m3u8", quality="auto", referer=page_referer))) + + sources.sort(key=lambda x: -x[0]) + return [s for _, s in sources] + + +def extract( + page_url: str, + *, + timeout: float = 60.0, +) -> list[StreamSource] | None: + page_html = fetch_tube_html(page_url, timeout=timeout) + + # Stage 0.5: direct .mp4/.m3u8 URLs on page (porn4days→iceyfile, niektóre xmoviesforyou + # native players). Wymagamy quality marker w path (`<N>p.mp4`) żeby uniknąć + # thumbnail-preview false-positives. Jeśli znaleziono → zwracamy, skipping iframe + # 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", + len(direct_sources), page_url) + 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 — + # 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 + # "page miał iframe ale został zablacklistowany jako malware/dead". + raw_iframes_count = 0 + iframe_urls: list[str] = [] + seen: set[str] = set() + for m in ANY_IFRAME_RE.finditer(page_html): + candidate = m.group("url").strip() + # Reklamowe iframe-y nie liczą się jako "raw iframe" (są zawsze obecne). + if AD_DOMAIN_RE.search(candidate) or AD_QUERY_RE.search(candidate): + continue + raw_iframes_count += 1 + if not _is_player_iframe(candidate): + continue + if candidate.startswith("//"): + candidate = "https:" + candidate + elif not candidate.startswith("http"): + candidate = "https://" + candidate + if candidate in seen: + continue + seen.add(candidate) + iframe_urls.append(candidate) + + # JS-hidden backup servers (porn4days SERVER<N>_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, + # 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() + if AD_DOMAIN_RE.search(candidate) or AD_QUERY_RE.search(candidate): + continue + if not _is_player_iframe(candidate): + continue + if candidate.startswith("//"): + candidate = "https:" + candidate + if candidate in seen: + continue + seen.add(candidate) + iframe_urls.append(candidate) + + # Anchor-href hoster links (xmoviesforyou pattern). Tube page nie ma iframe playera, + # tylko `<a href="https://playmogo.com/d/...">` download buttons. Bez tego + # extractor zwraca tylko page-as-hoster → WebView na catalog page, user musi + # manualnie klikać hoster button. Po wyciągnięciu user dostaje hoster bezpośrednio + # w mobile sources list. + for m in _ANCHOR_HOSTER_RE.finditer(page_html): + candidate = m.group("url").strip() + if AD_DOMAIN_RE.search(candidate) or AD_QUERY_RE.search(candidate): + continue + if not _is_player_iframe(candidate): + continue + if candidate.startswith("//"): + candidate = "https:" + candidate + if candidate in seen: + continue + seen.add(candidate) + iframe_urls.append(candidate) + + # Data attribute embed (mypornerleak: `data-embed="https://cdnstream.top/e/..."`). + # Iframe sam wstawia się przez muliframe.js po user click; HTML server-side + # ma tylko placeholder div + data-embed URL. + for m in _DATA_EMBED_RE.finditer(page_html): + candidate = m.group("url").strip() + if AD_DOMAIN_RE.search(candidate) or AD_QUERY_RE.search(candidate): + continue + if not _is_player_iframe(candidate): + continue + if candidate.startswith("//"): + candidate = "https:" + candidate + elif not candidate.startswith("http"): + candidate = "https://" + candidate + if candidate in seen: + continue + seen.add(candidate) + 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 `<div id="iframeHolder">`, + # a iframe URL jest w `const doodstreamContent = "<iframe ... src=\"https:\/\/...\" ...>"`. + # Po match'u czyścimy backslash escape: `\/` → `/`, `\\\"` → `"` (jeśli) + for m in _JS_ESCAPED_IFRAME_SRC_RE.finditer(page_html): + candidate = m.group("url").strip() + # Unescape JSON-style: \/ → /, \" → " + candidate = candidate.replace("\\/", "/").replace('\\"', '"') + if AD_DOMAIN_RE.search(candidate) or AD_QUERY_RE.search(candidate): + continue + if not _is_player_iframe(candidate): + continue + if candidate.startswith("//"): + candidate = "https:" + candidate + if candidate in seen: + continue + seen.add(candidate) + iframe_urls.append(candidate) + + # Stable-sort: CAPTCHA-walled hosty (DoodStream variants) NA KONIEC. Zachowujemy + # DOM order wśród non-CAPTCHA — preferred player tube'a (zazwyczaj pierwszy) zostaje. + # Powód: siska/xmoviesforyou wstawiają playmogo (dood) jako pierwszy iframe, luluvid + # jako drugi — bez sortu mobile bierze playmogo → CAPTCHA → czarny ekran. Z sortem + # luluvid (lub inny clean hoster) ląduje na pozycji 1, ExoPlayer ma szansę. + iframe_urls.sort(key=_hoster_priority) + + if not iframe_urls: + # Wszystkie iframe-y na stronie zostały odsiane (DEAD_HOSTER_RE — np. streamtape, + # xtapes.porn). Page-as-hoster nie pomoże bo WebView na aggregator page i tak + # załaduje ten sam (zablacklistowany) hoster. Lepiej zwrócić None żeby mobile + # dostał 501 i pokazał error — user wybierze inną źródło, nie dostanie malware. + if raw_iframes_count > 0: + log.info( + "embed_iframe: %d iframes in %s but all blacklisted (malware/dead) — None", + raw_iframes_count, page_url, + ) + return None + # Brak iframe-ów w ogóle (JS-only render np. mypornerleak's muliframe.js, + # CloudFlare JS challenge na xxxfree.watch, login-walled tubes). Page-as-hoster + # ma sens — WebView przejdzie CF challenge, wyrenderuje JS, da user'owi player. + log.info("embed_iframe: no iframes in %s — page-as-hoster fallback", page_url) + page_host_label = (urlparse(page_url).hostname or "").lstrip("www.").split(".")[0] or "page" + return [StreamSource(link=page_url, type="hoster", quality=f"{page_host_label} (page)")] + + host = urlparse(page_url).hostname or "" + referer = f"https://{host}/" if host else page_url + + # Stage 0: yt-dlp generic na PAGE URL (nie iframe). Niektóre tube'y (pornditt, + # latestleaks) mają KVS player config (`kt_player(`, `license_code:`) wprost na + # scene page'u — embed iframe to czysta CSS shell. yt-dlp generic potrafi + # zdeszyfrować KVS URL z page'a, więc wolimy to niż WebView fallback. + # Referer = pełny page URL (nie host root) — KVS get_file/ URL jest signed + # względem konkretnej page'a, host root daje 410. + if all(marker in page_html for marker in ("kt_player(", "license_code")): + from app.extractors.hoster import _try_ytdlp_hoster + ytdlp_url = _try_ytdlp_hoster(page_url, timeout=timeout) + if ytdlp_url: + type_hint = "m3u8" if ".m3u8" in ytdlp_url.lower() else "mp4" + return [StreamSource(link=ytdlp_url, type=type_hint, referer=page_url)] + + # Stage 1: spróbuj wyekstraktować direct video URL z każdego iframe'a po kolei. + # Direct mp4/m3u8 idzie PIERWSZY w wyniku (ExoPlayer natywnie >> WebView), ale + # NIE pomijamy reszty iframe — dodajemy je jako hoster fallback. Powód: niektóre + # CDN-y file storage (vidnest.live, iceyfile) blokują Hetzner ASN — Stage 1 wyciąga + # URL z embed page, ale ani VPS proxy ani mobile go nie pobierze (No route to host + # albo 403). Bez iframe fallbacku mobile dostaje 503 i utyka. Z fallbackiem chain: + # direct → proxy → iframe1 (WebView) → iframe2 (WebView) → page (WebView). + for idx, iframe_url in enumerate(iframe_urls): + try: + stream_url = extract_stream_from_hoster( + iframe_url, referer=referer, timeout=timeout, + ) + except Exception as e: + log.warning("embed_iframe: extract failed for %s: %s", iframe_url, e) + continue + if stream_url: + # IP-bound CDN check — skip Stage 1 result, force fallback na hoster + # (mobile WebView pobiera embed page z phone IP). Critical dla public + # release: premilkyway/tnmr.org bind token do requester IP. + if _IP_BOUND_CDN_RE.search(stream_url): + log.info( + "embed_iframe: stream URL %s is IP-bound CDN — skip Stage 1, fall to hoster", + stream_url[:80], + ) + continue + type_hint = "m3u8" if ".m3u8" in stream_url.lower() else "mp4" + iframe_host = urlparse(iframe_url).hostname or "" + stream_referer = f"https://{iframe_host}/" if iframe_host else iframe_url + sources = [StreamSource(link=stream_url, type=type_hint, referer=stream_referer)] + # Dodaj pozostałe iframe-y (z wyłączeniem tego z którego wyciągnięto stream) + # jako hoster fallback. WebView załaduje embed page z własną sesją/IP/cookies, + # może pobrać video gdy VPS proxy blocked. + for u in iframe_urls: + if u == iframe_url: + continue + host_label = (urlparse(u).hostname or "").lstrip("www.").split(".")[0] or None + sources.append(StreamSource(link=u, type="hoster", quality=host_label)) + page_host_label = (urlparse(page_url).hostname or "").lstrip("www.").split(".")[0] or "page" + sources.append(StreamSource(link=page_url, type="hoster", quality=f"{page_host_label} (page)")) + return sources + + # Stage 2: wszystkie iframe-y zwróć jako hoster sources — mobile dostaje listę + # alternatyw, użytkownik może switchować gdy pierwszy ma overlay/CF challenge. + # Pierwszy w liście ma być najczystszy — ale bez wiedzy a priori które są ad-heavy + # zostawiamy kolejność DOM (zwykle authoring tube wstawia preferred player jako + # pierwszy, a drugi jako backup). Quality ustawiamy na nazwę hosta iframe'a żeby + # PlaybackQualityModal pokazał użytkownikowi rozróżnialne opcje (np. "playmogo" + # vs "luluvid"). Na końcu page_url jako safety-net — gdy wszystkie iframe'y są + # martwe (np. sxyland → xtapes.porn 301→camcaps.to 403), mobile WebView otworzy + # główną stronę aggregator tube'a, gdzie user może wybrać alternatywny player. + sources = [] + for u in iframe_urls: + host_label = (urlparse(u).hostname or "").lstrip("www.").split(".")[0] or None + sources.append(StreamSource(link=u, type="hoster", quality=host_label)) + page_host_label = (urlparse(page_url).hostname or "").lstrip("www.").split(".")[0] or "page" + sources.append(StreamSource(link=page_url, type="hoster", quality=f"{page_host_label} (page)")) + return sources diff --git a/app/extractors/tubes/_kvs_source.py b/app/extractors/tubes/_kvs_source.py new file mode 100644 index 0000000..02c831d --- /dev/null +++ b/app/extractors/tubes/_kvs_source.py @@ -0,0 +1,83 @@ +"""Shared KVS engine extractor: `<source src="...get_file/.../<scene_id>_<quality>.mp4/">`. + +KVS (Kernel Video Sharing) to commercial CMS używany przez wiele tube'ów. Player +emituje `<source>` tagi z URL `<host>/get_file/<bucket_id>/<token>/<X>/<scene_id>/<scene_id>[_<quality>p].mp4/`. +Token jest IP-bound (signed dla VPS który fetchował embed). + +Różnice per-tube: + - pornhat: get_file 302 → HLS m3u8 manifest. Type='m3u8'. + - freshporno: get_file 302 → direct mp4 CDN (remote_control.php?...&file=...). Type='mp4'. + +Generic `_embed_iframe.extract` Stage 0.5 łapie WSZYSTKIE mp4 URLs z page HTML — +w tym `_preview*.mp4` z sidebar suggested videos (różne scene IDs z innym tokenem +→ 404 po fetch). Plus duplikaty `<source>` (multi-CDN load balancing). + +Tu wyciągamy tylko `<source>` z player area, filtrujemy `_preview` URL-e, dedupe +po basename — żeby user nie widział 18 entries quality modal dla jednej sceny. +""" +from __future__ import annotations + +import logging +import re +from urllib.parse import urlparse + +from app.extractors._fetch import fetch_tube_html +from app.extractors._models import StreamSource + +log = logging.getLogger(__name__) + +_SOURCE_RE = re.compile( + r'<source[^>]+src="(?P<url>https?://[^"]+/get_file/[^"]+\.mp4/?)"', + re.IGNORECASE, +) +_QUALITY_RE = re.compile(r"_(?P<q>\d{3,4}p)\.mp4(?:/|$|\?)") + + +def extract_kvs_sources( + page_url: str, + *, + stream_type: str = "mp4", + timeout: float = 60.0, + log_tag: str = "kvs", +) -> list[StreamSource] | None: + """Wyciąga `<source>` URLs z page'a KVS, dedupe + skip preview trailers. + + Args: + stream_type: 'mp4' (freshporno direct mp4) lub 'm3u8' (pornhat HLS manifest). + log_tag: prefix dla log lines (tube name). + """ + html = fetch_tube_html(page_url, timeout=timeout) + + sources = _SOURCE_RE.findall(html) + if not sources: + log.info("%s: no <source> tags found on %s", log_tag, page_url) + return None + + sources = [u for u in sources if "_preview" not in urlparse(u).path] + if not sources: + log.info("%s: all sources were _preview trailers on %s", log_tag, page_url) + return None + + seen_keys: set[str] = set() + result: list[StreamSource] = [] + for url in sources: + path = urlparse(url).path + parts = [p for p in path.split("/") if p] + key = parts[-1] if parts else url + if key in seen_keys: + continue + seen_keys.add(key) + q_match = _QUALITY_RE.search(url) + quality = q_match.group("q") if q_match else None + result.append(StreamSource(link=url, type=stream_type, quality=quality)) + + def _quality_key(s: StreamSource) -> int: + if not s.quality: + return -1 + try: + return int(s.quality.rstrip("p")) + except ValueError: + return -1 + + result.sort(key=_quality_key, reverse=True) + return result diff --git a/app/extractors/tubes/_vps_blocked_fallback.py b/app/extractors/tubes/_vps_blocked_fallback.py new file mode 100644 index 0000000..f3de1b3 --- /dev/null +++ b/app/extractors/tubes/_vps_blocked_fallback.py @@ -0,0 +1,34 @@ +"""Hoster fallback dla tubes które blokują VPS IP ale działają z residential IP. + +Wzorzec (potwierdzony 2026-05-15 przez Chrome DevTools MCP live debug): + - xhamster.com: HLS via xplayer.js + obfuscated URLs w window.initials JSON + - pornditt: KVS kt_player + license_code decoder client-side → <video.src> + - fpo.xxx: KVS kt_player jak pornditt — identyczny pattern + - sxylandcom → xtapes.porn → reelshdd.com: iframe redirect chain do direct mp4 + +WSZYSTKIE działają z desktop/mobile residential IP. Tylko Hetzner Cloud VPS IP +blocked (Cloudflare-level dla xhamster, CDN token IP-bound dla KVS hostów). + +Strategia: extractor zwraca `type='hoster'` z scene URL jako embed → mobile +WebView fallback. WebView ma residential ISP IP, INJECTED_JS w PlayerScreen.tsx +skanuje DOM co 1s (linia 805) wyciągając `<video>.src` + XHR/fetch m3u8 — to +łapie URL po decode-ie player JS i posyła do RN przez postMessage. ExoPlayer +swap do native player z prawdziwym URL. + +Bez tego fallbacku użytkownik widzi błąd "chwilowy problem z hosterem" (503) +zamiast działającego playera. +""" +from __future__ import annotations + +from app.extractors._models import StreamSource + + +def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource]: + return [ + StreamSource( + link=page_url, + quality=None, + type="hoster", + referer=page_url, + ) + ] diff --git a/app/extractors/tubes/_ytdlp.py b/app/extractors/tubes/_ytdlp.py new file mode 100644 index 0000000..64c1825 --- /dev/null +++ b/app/extractors/tubes/_ytdlp.py @@ -0,0 +1,163 @@ +"""yt-dlp wrapper — generic stream URL extractor dla mainstream tubes. + +yt-dlp ma battle-tested extractory dla pornhub, xvideos, xnxx, xhamster, redtube, +youporn, porntrex i ~30 innych — pełna lista w yt_dlp/extractor/_extractors.py. +Tu używamy go jako jeden adapter dla mainstream tubes których nie ma sensu pisać +od zera (zmieniają HTML co kilka miesięcy, mają anti-bot, obfuscation w JS playerach). + +Output yt-dlp: + - `info["url"]` lub `info["formats"]` — formats lista zawiera wszystkie quality variants + - każdy format ma `url`, `format_id`, `height`, `ext`, `protocol` + +Mapowanie format → StreamSource: + - `protocol == 'm3u8' / 'm3u8_native' / 'hls'` → type='m3u8' + - `ext == 'mp4'` → type='mp4' + - `ext == 'webm'` → type='webm' + - quality = `f"{height}p"` jeśli height present, else `format_id` +""" +from __future__ import annotations + +import logging +from typing import Any + +from app.extractors._models import StreamSource, TubePageError + +log = logging.getLogger(__name__) + + +def _format_to_source(fmt: dict[str, Any]) -> StreamSource | None: + url = fmt.get("url") + if not url: + return None + + protocol = (fmt.get("protocol") or "").lower() + ext = (fmt.get("ext") or "").lower() + if "m3u8" in protocol or "hls" in protocol or ext == "m3u8": + type_hint: str | None = "m3u8" + elif ext == "mp4": + type_hint = "mp4" + elif ext == "webm": + type_hint = "webm" + elif ext == "mpd" or "dash" in protocol: + type_hint = "mpd" + else: + type_hint = ext or None + + height = fmt.get("height") + if isinstance(height, int) and height > 0: + quality = f"{height}p" + else: + quality = fmt.get("format_note") or fmt.get("format_id") + + # yt-dlp w `http_headers` zwraca Referer pasujący do CDN — np. dla 0dayxx → + # watchporn.to embed iframe → `Referer: https://watchporn.to/embed/143412`. + # Bez tego CDN watchporn.to/get_file/... zwraca 410 (cookie binding). + referer = (fmt.get("http_headers") or {}).get("Referer") + + return StreamSource(link=url, quality=quality, type=type_hint, raw=fmt, referer=referer) + + +def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None: + """Wywołuje yt-dlp w extract-only mode (bez pobierania) i mapuje formats na StreamSource. + + Raises TubePageError gdy yt-dlp dostał 404/410 dla tube page. + """ + from yt_dlp import YoutubeDL + from yt_dlp.networking.impersonate import ImpersonateTarget + from yt_dlp.utils import DownloadError, ExtractorError + + # Chrome UA + TLS impersonation — bez tego xhamster (i kilka innych) Cloudflare + # zwraca 403 dla default `yt-dlp/<version>` UA. `impersonate` wymaga curl_cffi + # (downgrade do 0.14 wymagany — 0.15 łamie yt-dlp's `_AVAILABLE_IMPERSONATE_TARGETS` + # check). yt-dlp 2026.03.17 wymaga `ImpersonateTarget` OBJECT, nie string — wczesnie + # przekazywałem `"chrome"` co poprzedni release przyjmował, teraz AssertionError + # w `is_supported_target()` (bug-report 2026-05-16: youporn/xnxx/xvideos broken). + ydl_opts = { + "quiet": True, + "no_warnings": True, + "skip_download": True, + "noplaylist": True, + "socket_timeout": int(timeout), + "impersonate": ImpersonateTarget("chrome"), + "http_headers": { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36" + ), + "Accept-Language": "en-US,en;q=0.9", + }, + } + + try: + with YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(page_url, download=False) + except DownloadError as e: + msg = str(e).lower() + # yt-dlp opakowuje HTTP errors w DownloadError — wykrywamy 404/410 po treści. + if "http error 404" in msg or "http error 410" in msg or "video unavailable" in msg or "removed" in msg: + raise TubePageError(404, page_url) from e + log.warning("yt-dlp DownloadError on %s: %s", page_url, e) + return None + except ExtractorError as e: + log.warning("yt-dlp ExtractorError on %s: %s", page_url, e) + return None + except Exception as e: + log.warning("yt-dlp unexpected error on %s: %s", page_url, e) + return None + + if info is None: + return None + + formats = info.get("formats") or [] + sources: list[StreamSource] = [] + for fmt in formats: + if not isinstance(fmt, dict): + continue + s = _format_to_source(fmt) + if s is not None: + sources.append(s) + + # Niektóre tubes zwracają single-format info bez "formats" listy. + if not sources: + single = _format_to_source(info) + if single is not None: + sources.append(single) + + return _dedupe_formats(sources) or None + + +def _dedupe_formats(sources: list[StreamSource]) -> list[StreamSource]: + """Dedupe yt-dlp formats per (quality, type) — niektóre tubes (xhamster) zwracają + 24+ formatów: każda jakość × {mp4, hls} × kilka CDN mirrors. Większość mirror'ów + jest IP-bound albo geo-restricted i daje 502/404. yt-dlp ordering: worst→best, + czyli OSTATNI wpis dla danej (quality, type) jest najwyższego bitrate'a/preferencji. + Bierzemy go. + + Output: dla każdej jakości jeden HLS + jeden MP4 (jeśli istnieje), HLS preferred. + Sortujemy descending po quality (1080p → 144p) bo gracz domyślnie bierze pierwszy. + """ + if not sources: + return sources + + # Grupowanie: (quality, type) → ostatni StreamSource + by_key: dict[tuple[str | None, str | None], StreamSource] = {} + for s in sources: + key = (s.quality, s.type) + by_key[key] = s + + # Ranking: HLS przed MP4 (HLS ma adaptive segments → lepszy fallback gdy CDN flaky). + # Quality numeric sort descending — "1080p" → 1080, "720p" → 720, "240p" → 240. + def _quality_int(q: str | None) -> int: + if not q: + return 0 + try: + return int(q.rstrip("pP").rstrip()) + except ValueError: + return 0 + + def _type_rank(t: str | None) -> int: + return {"m3u8": 0, "mp4": 1, "webm": 2, "mpd": 3}.get(t or "", 9) + + deduped = list(by_key.values()) + deduped.sort(key=lambda s: (-_quality_int(s.quality), _type_rank(s.type))) + return deduped diff --git a/app/extractors/tubes/eporner.py b/app/extractors/tubes/eporner.py new file mode 100644 index 0000000..ef1c1d2 --- /dev/null +++ b/app/extractors/tubes/eporner.py @@ -0,0 +1,94 @@ +"""eporner.com — direct stream extractor. + +Page → wyciągnij `EP.video.player.vid` + `EP.video.player.hash` → XHR +`/xhr/video/<vid>?hash=<base36>` → JSON z multi-quality mp4 URL'ami (240p–1080p). + +Bez hasha gvideo.eporner.com URL'e zwracają 403 (są podpisane krótkoterminowymi +tokenami które XHR zwraca świeże). Algorytm hex→base36 ekwiwalentny temu z +aplikacji AIO Streamer (`rf1.java`): + BigInteger(hex[0:8],16).toString(36) + BigInteger(hex[8:16],16).toString(36) + + BigInteger(hex[16:24],16).toString(36) + BigInteger(hex[24:32],16).toString(36) + +URL-e są bound do IP requestera (więc resolver musi pobiegać z VPS-a, nie z +klienta) i ważne ~kilka godzin. +""" +from __future__ import annotations + +import json +import logging +import re + +from app.extractors._fetch import _DEFAULT_UA, browser_get, fetch_tube_html +from app.extractors._models import StreamSource + +log = logging.getLogger(__name__) + + +_VID_RE = re.compile(r"EP\.video\.player\.vid\s*=\s*'([^']+)'") +_HASH_RE = re.compile(r"EP\.video\.player\.hash\s*=\s*'([0-9a-fA-F]{32})'") + + +def _hash_to_b36(hex_hash: str) -> str: + """Konwertuje 32-znakowy hex hash na base36 w 4 chunkach po 8 znaków.""" + parts = [] + for i in (0, 8, 16, 24): + n = int(hex_hash[i : i + 8], 16) + if n == 0: + parts.append("0") + continue + s = "" + while n > 0: + d = n % 36 + ch = chr(ord("0") + d) if d < 10 else chr(ord("a") + d - 10) + s = ch + s + n //= 36 + parts.append(s) + return "".join(parts) + + +def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None: + html = fetch_tube_html(page_url, timeout=timeout) + m_vid = _VID_RE.search(html) + m_hash = _HASH_RE.search(html) + if not m_vid or not m_hash: + log.warning("eporner: no vid/hash in %s", page_url) + return None + vid = m_vid.group(1) + hash_b36 = _hash_to_b36(m_hash.group(1)) + xhr_url = ( + f"https://www.eporner.com/xhr/video/{vid}?hash={hash_b36}" + "&domain=www.eporner.com&pixelRatio=1&playerWidth=0&playerHeight=0" + "&fallback=false&embed=false&supportedFormats=mp4" + ) + headers = { + "User-Agent": _DEFAULT_UA, + "Referer": page_url, + "Accept": "*/*", + "X-Requested-With": "XMLHttpRequest", + } + try: + r = browser_get(xhr_url, headers=headers, timeout=timeout, follow_redirects=True) + r.raise_for_status() + data = json.loads(r.text) + except Exception as e: + log.warning("eporner xhr fetch %s failed: %s", xhr_url, e) + return None + + if not data.get("available", True): + log.info("eporner xhr %s: available=false", vid) + return None + + mp4_dict = (data.get("sources") or {}).get("mp4") or {} + sources: list[StreamSource] = [] + for label, info in mp4_dict.items(): + if not isinstance(info, dict): + continue + src = info.get("src") + if not src: + continue + sources.append(StreamSource(link=src, quality=label, type="mp4")) + + if not sources: + log.warning("eporner xhr %s: no mp4 sources in JSON", vid) + return None + return sources diff --git a/app/extractors/tubes/freshporno.py b/app/extractors/tubes/freshporno.py new file mode 100644 index 0000000..990b75c --- /dev/null +++ b/app/extractors/tubes/freshporno.py @@ -0,0 +1,63 @@ +"""freshporno.org — KVS engine, BEZ `<source>` tagów. + +Page używa kt_player (KVS Flash + JS legacy player) — URLs są wewnątrz JavaScript +flashvars JSON (`video_url: 'function/0/<URL>'`) i w `<a href="...?download=true">` +linkach z labelem "MP4 720p" / "MP4 480p". + +Bierzemy anchor pattern bo ma WSZYSTKIE quality z explicit labelem (vs flashvars +ma tylko main+alt, max 2 jakości). `<a href="...get_file/...mp4/?download=true...">MP4 <q>p, ...` + +Sidebar suggested videos używają `data-preview="...get_file/.../<id>_preview.mp4"` — +inny pattern (nie `<a href>`), więc anchor regex je naturalnie pomija. + +CDN token IP-bound do VPS — mobile dostanie 403 na direct, fallback proxy działa. +get_file 302 → `cdn4.freshporno.org/remote_control.php?...&file=<path>` direct mp4 +(nie HLS). Type='mp4'. +""" +from __future__ import annotations + +import logging +import re + +from app.extractors._fetch import fetch_tube_html +from app.extractors._models import StreamSource + +log = logging.getLogger(__name__) + +# `<a href="<URL>?download=true...">MP4 <quality>p, <size>` — main + alt streams. +_ANCHOR_QUALITY_RE = re.compile( + r'<a\s+[^>]*href="(?P<url>https?://[^"]+/get_file/[^"]+\.mp4/)\?download=true[^"]*"' + r'[^>]*>\s*MP4\s+(?P<q>\d{3,4}p)', + re.IGNORECASE, +) + + +def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None: + html = fetch_tube_html(page_url, timeout=timeout) + + seen_keys: set[str] = set() + result: list[StreamSource] = [] + for m in _ANCHOR_QUALITY_RE.finditer(html): + url = m.group("url") + quality = m.group("q") + # Dedupe po basename (path bez query string). + basename = url.rstrip("/").split("/")[-1] + if basename in seen_keys: + continue + seen_keys.add(basename) + result.append(StreamSource(link=url, type="mp4", quality=quality)) + + if not result: + log.info("freshporno: no MP4 anchor matches on %s", page_url) + return None + + def _quality_key(s: StreamSource) -> int: + if not s.quality: + return -1 + try: + return int(s.quality.rstrip("p")) + except ValueError: + return -1 + + result.sort(key=_quality_key, reverse=True) + return result diff --git a/app/extractors/tubes/fullmovies.py b/app/extractors/tubes/fullmovies.py new file mode 100644 index 0000000..a12d072 --- /dev/null +++ b/app/extractors/tubes/fullmovies.py @@ -0,0 +1,68 @@ +"""fullmovies.xxx — direct mp4 sources extractor. + +Detail page ma `<video class="video-js">` z multiple `<source>` (per quality): + `<source src='https://www.fullmovies.xxx/get_file/<token>/<dir>/<id>/<id>_2160m.mp4/' type='video/mp4' label="2160p" selected="true">` + `<source src='.../<id>_720m.mp4/' type='video/mp4' label="720p">` + `<source src='.../<id>_480m.mp4/' type='video/mp4' label="480p">` + +URL pattern: `https://www.fullmovies.xxx/get_file/<signed_token>/<dir>/<id>/<id>_<q>m.mp4/` +- Trailing slash — server odsyła 302 na CDN. +- `<signed_token>` IP-bound do requester (jak HQPorner /get_file/). Mobile direct = 403. +- force_proxy=True wymusza wszystko przez goon proxy (proxy follows redirect na CDN). + +Quality labels: 2160p / 1080p / 720p / 480p / 360p. +""" +from __future__ import annotations + +import logging +import re + +from app.extractors._fetch import fetch_tube_html +from app.extractors._models import StreamSource + +log = logging.getLogger(__name__) + +# Single-quoted attribute (apostrophes inside src=). Quality from `label="<q>"`. +_SOURCE_RE = re.compile( + r"""<source\s+src=['"](?P<url>https?://[^'"]+\.mp4/?)['"]""" + r"""\s+type=['"]video/mp4['"]""" + r"""\s+label=['"](?P<q>[^'"]+)['"]""", + re.IGNORECASE, +) + + +def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None: + html = fetch_tube_html(page_url, timeout=timeout) + seen: set[str] = set() + result: list[StreamSource] = [] + # fullmovies /get_file/ URL ma signed token IP-bound do requester. Bez force_proxy + # mobile dostaje 403. Proxy follows 302 na CDN. + proxy_flag = {"force_proxy": True} + for m in _SOURCE_RE.finditer(html): + url = m.group("url") + if url in seen: + continue + seen.add(url) + result.append( + StreamSource( + link=url, + type="mp4", + quality=m.group("q"), + referer=f"{page_url}", + raw=proxy_flag, + ) + ) + + if not result: + log.info("fullmovies: no <source> tags on %s", page_url) + return None + + # Sort by quality desc (2160p > 1080p > 720p > 480p > 360p) + def _q(s: StreamSource) -> int: + try: + return int((s.quality or "0").rstrip("p")) + except ValueError: + return 0 + + result.sort(key=_q, reverse=True) + return result diff --git a/app/extractors/tubes/hqporner.py b/app/extractors/tubes/hqporner.py new file mode 100644 index 0000000..d8ae91d --- /dev/null +++ b/app/extractors/tubes/hqporner.py @@ -0,0 +1,114 @@ +"""hqporner.com — direct stream extractor. + +Page → iframe (mydaddy.cc lub hqwo.cc — hosting się zmienia w czasie) → wyciągnij +mp4 URL-e z `<source>` tagów lub innych miejsc w HTML/JS playera. + +Dwie generacje hostera (oba aktywne dla różnych scen): + +1. **Stara: mydaddy.cc/video/<hash>/** — FluidPlayer wrapper z `<source>` tagami + bezpośrednio w HTML iframe: + `<source src="//s12.bigcdn.cc/.../360.mp4" title="360p">` + 720p + 1080p. + +2. **Nowa: hqwo.cc/player/<hash>?img=<base64>** — `<source>` tagi są wewnątrz + JavaScript string literal (`$("#jw").html("<video>...<source src=\"...\">")`). + Quotes są escaped (`\"`), więc plain regex na `<source[^>]+src="..."` + nie matchuje. Trzeba odescape'ować HTML przed regex match. + + URL pattern: `https://hqwo.cc/pubs/<pub_id>/<quality>.mp4` gdzie pub_id jest + inny niż player_hash w iframe URL — generowany serwerem per request. + +Fallback gdy oba zawiodą: hoster type → mobile otworzy w WebView (FluidPlayer +JS wyciągnie URL po user click). +""" +from __future__ import annotations + +import logging +import re + +from app.extractors._fetch import _DEFAULT_UA, browser_get, fetch_tube_html +from app.extractors._models import StreamSource +from app.extractors.hoster import extract_stream_from_hoster + +log = logging.getLogger(__name__) + + +_IFRAME_RE = re.compile( + r'<div[^>]+id=["\']?playerWrapper["\']?[^>]*>.*?<iframe[^>]+src=["\']([^"\']+)', + re.IGNORECASE | re.DOTALL, +) + +# Match `<source src="...mp4" title="...">` z opcjonalnym title. Po unescape +# (`\"` → `"`) ten regex łapie zarówno raw HTML (mydaddy.cc) jak i JS-embedded +# HTML (hqwo.cc). +_SOURCE_RE = re.compile( + r'<source[^>]+src=["\']((?://|https?://)[^"\']+\.mp4[^"\']*)["\'](?:[^>]+title=["\']([^"\']+))?', + re.IGNORECASE, +) + + +def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None: + page_html = fetch_tube_html(page_url, timeout=timeout) + m = _IFRAME_RE.search(page_html) + if not m: + log.warning("hqporner: no iframe in %s", page_url) + return None + iframe_src = m.group(1).strip() + if iframe_src.startswith("//"): + iframe_src = "https:" + iframe_src + elif iframe_src.startswith("/"): + iframe_src = f"https://hqporner.com{iframe_src}" + + headers = { + "User-Agent": _DEFAULT_UA, + "Accept": "text/html,application/xhtml+xml", + "Accept-Language": "en-US,en;q=0.9", + "Referer": "https://hqporner.com/", + } + try: + r = browser_get(iframe_src, headers=headers, timeout=timeout, follow_redirects=True) + r.raise_for_status() + except Exception as e: + log.warning("hqporner iframe fetch %s failed: %s", iframe_src, e) + return None + + # Hqwo.cc embeds `<source>` tags inside `$.html("<video>...<source src=\"...\">")` + # JS string literals — quotes are escaped. Plain HTML in mydaddy.cc has raw quotes. + # Unescape commonly-escaped sequences so the same regex handles both shapes. + iframe_html = ( + r.text.replace('\\"', '"').replace("\\'", "'").replace("\\\\", "\\") + ) + + # CDN-y (bigcdn.cc, hqwo.cc) bindują URL do Referera embed iframe'a (host hqwo.cc / + # mydaddy.cc), nie hqporner.com. Trzymamy referer = host iframe'a dla proxy. + from urllib.parse import urlparse as _urlparse + iframe_host = _urlparse(iframe_src).hostname or "" + iframe_referer = f"https://{iframe_host}/" if iframe_host else iframe_src + + # De-dup by URL: hqwo.cc emits `<source>` tags twice (adblock + non-adblock branches). + seen_urls: set[str] = set() + sources: list[StreamSource] = [] + for sm in _SOURCE_RE.finditer(iframe_html): + url = sm.group(1).strip() + if url.startswith("//"): + url = "https:" + url + if url in seen_urls: + continue + seen_urls.add(url) + title = (sm.group(2) or "").strip() + sources.append(StreamSource(link=url, quality=title or None, type="mp4", referer=iframe_referer)) + + if sources: + return sources + + # Fallback 1: niektóre mydaddy.cc iframes używają packed JS (JWPlayer). + stream_url = extract_stream_from_hoster( + iframe_src, referer="https://hqporner.com/", timeout=timeout, + ) + if stream_url: + type_hint = "m3u8" if ".m3u8" in stream_url.lower() else "mp4" + return [StreamSource(link=stream_url, type=type_hint, referer=iframe_referer)] + + # Fallback 2: oddaj iframe URL jako hoster type — mobile otworzy w WebView, + # FluidPlayer JS sam wyciągnie URL po user click / przejściu adblock check. + log.info("hqporner: using hoster fallback for %s", iframe_src) + return [StreamSource(link=iframe_src, type="hoster")] diff --git a/app/extractors/tubes/latestpornvideo.py b/app/extractors/tubes/latestpornvideo.py new file mode 100644 index 0000000..6deaebd --- /dev/null +++ b/app/extractors/tubes/latestpornvideo.py @@ -0,0 +1,16 @@ +"""latestpornvideo.com — direct stream extractor. + +Page → embed iframe (StreamWish/doodporn/luluvdo/medixiru/cdnvids/iceyfile) +→ generic hoster extractor (unpack JS → m3u8/mp4). + +Reuses generic `_embed_iframe.extract` — wzorzec jest identyczny dla większości +aggregator tubes; różnice (jeśli kiedykolwiek wystąpią) idą tutaj jako custom override. +""" +from __future__ import annotations + +from app.extractors._models import StreamSource +from app.extractors.tubes._embed_iframe import extract as _extract_embed + + +def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None: + return _extract_embed(page_url, timeout=timeout) diff --git a/app/extractors/tubes/paradisehill.py b/app/extractors/tubes/paradisehill.py new file mode 100644 index 0000000..2f99162 --- /dev/null +++ b/app/extractors/tubes/paradisehill.py @@ -0,0 +1,96 @@ +"""paradisehill.cc — direct mp4 extractor. + +Paradisehill embed strony renderują video.js z `og:video` meta tagiem wskazującym +na `/player/<id>/` iframe. Ten iframe zawiera inline JS: + + var videoList = [ + {"sources":[{"src":"https://v1.paradisehill.cc/video/<hash>_part1.mp4","type":"video/mp4"}]}, + {"sources":[{"src":"...part2.mp4",...}]}, + ... + ]; + +Wieloczęściowe filmy są dzielone na part1..partN (~20-30 min każda). v1.paradisehill.cc +serwuje direct mp4 z Referer = paradisehill scene page; nie ma session auth ani token +bind (zweryfikowane 2026-05-15 z VPS Hetzner, status 200, ISO Media MP4). + +Zwracamy listę StreamSource — jeden per part. Mobile player uznaje pierwszy element +(`best`) za główny; jeśli kiedyś potrzebowalibyśmy chapter switching, parts są w +`raw["parts"]` jako URL-e. +""" +from __future__ import annotations + +import json +import logging +import re +from urllib.parse import urljoin + +from app.extractors._fetch import browser_get, _DEFAULT_UA +from app.extractors._models import HosterDead, StreamSource + +log = logging.getLogger(__name__) + +_OG_VIDEO_RE = re.compile(r'<meta\s+property=["\']og:video["\']\s+content=["\']([^"\']+)["\']', re.IGNORECASE) +_VIDEOLIST_RE = re.compile(r'var\s+videoList\s*=\s*(\[[^;]+\]);', re.DOTALL) +_MP4_RE = re.compile(r'https?://[^\s"\'<>]+\.mp4(?:\?[^\s"\'<>]*)?', re.IGNORECASE) + + +def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None: + headers = { + "User-Agent": _DEFAULT_UA, + "Accept": "text/html,application/xhtml+xml", + "Accept-Language": "en-US,en;q=0.9", + } + r = browser_get(page_url, headers=headers, timeout=timeout) + if r.status_code == 404 or r.status_code == 410: + raise HosterDead(f"paradisehill {page_url}: HTTP {r.status_code}") + if r.status_code != 200 or not r.text: + log.info("paradisehill: page fetch fail %s status=%s", page_url, r.status_code) + return None + + m = _OG_VIDEO_RE.search(r.text) + if not m: + log.info("paradisehill: no og:video meta in %s", page_url) + return None + player_url = urljoin(page_url, m.group(1)) + + r2 = browser_get(player_url, headers={**headers, "Referer": page_url}, timeout=timeout) + if r2.status_code != 200 or not r2.text: + log.info("paradisehill: player iframe fail %s status=%s", player_url, r2.status_code) + return None + + vl = _VIDEOLIST_RE.search(r2.text) + parts: list[str] = [] + if vl: + try: + data = json.loads(vl.group(1)) + for item in data: + for src in (item.get("sources") or []): + u = src.get("src") + if u and u not in parts: + parts.append(u) + except json.JSONDecodeError as e: + log.info("paradisehill: videoList JSON decode fail in %s: %s", player_url, e) + + if not parts: + for m in _MP4_RE.finditer(r2.text): + u = m.group(0) + if u not in parts: + parts.append(u) + + if not parts: + log.info("paradisehill: no mp4 in player iframe %s", player_url) + return None + + referer = page_url + sources: list[StreamSource] = [] + for i, url in enumerate(parts): + sources.append( + StreamSource( + link=url, + quality=None, + type="mp4", + referer=referer, + raw={"part_index": i, "total_parts": len(parts), "parts": parts} if len(parts) > 1 else None, + ) + ) + return sources diff --git a/app/extractors/tubes/porn00.py b/app/extractors/tubes/porn00.py new file mode 100644 index 0000000..e480460 --- /dev/null +++ b/app/extractors/tubes/porn00.py @@ -0,0 +1,50 @@ +"""porn00.org — KVS engine extractor. + +Detail page wbudowuje stream URLs w JS flashvars block: + - `video_url: 'https://.../get_file/.../<id>.mp4/?v-acctoken=...'` (default, 360p) + - `video_alt_url: 'https://.../get_file/.../<id>_720p.mp4/?v-acctoken=...'` (alt, 720p) + +CDN token (`v-acctoken=...`) jest IP-bound do VPS, mobile direct fetch → 403. +playback.py wraps URL przez stream_proxy z `Referer: <page_url>` — działa. + +Get_file → 302 → direct mp4 (jak freshporno). Type='mp4'. +""" +from __future__ import annotations + +import logging +import re + +from app.extractors._fetch import fetch_tube_html +from app.extractors._models import StreamSource + +log = logging.getLogger(__name__) + +_VIDEO_URL_RE = re.compile( + r"""video_url:\s*['"]([^'"]+\.mp4[^'"]*)['"]""", re.IGNORECASE, +) +_VIDEO_ALT_URL_RE = re.compile( + r"""video_alt_url:\s*['"]([^'"]+\.mp4[^'"]*)['"]""", re.IGNORECASE, +) + + +def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None: + html = fetch_tube_html(page_url, timeout=timeout) + result: list[StreamSource] = [] + + # Preferujemy alt (720p) przed default (360p). + # CDN token `v-acctoken` jest IP-bound do VPS. Mobile direct fetch ZAWSZE → 403, + # więc oznacz force_proxy żeby player od razu używał proxified URL bez prób direct. + # Bez tego: każdy playback = "mrugnięcie" (direct fail → fallback na proxy). + proxy_flag = {"force_proxy": True} + if (m := _VIDEO_ALT_URL_RE.search(html)): + result.append(StreamSource(link=m.group(1), type="mp4", quality="720p", raw=proxy_flag)) + if (m := _VIDEO_URL_RE.search(html)): + url = m.group(1) + if not result or result[0].link != url: + result.append(StreamSource(link=url, type="mp4", quality="360p", raw=proxy_flag)) + + if not result: + log.info("porn00: no video_url flashvars on %s", page_url) + return None + + return result diff --git a/app/extractors/tubes/pornhat.py b/app/extractors/tubes/pornhat.py new file mode 100644 index 0000000..202453e --- /dev/null +++ b/app/extractors/tubes/pornhat.py @@ -0,0 +1,86 @@ +"""pornhat.com — KVS engine. get_file 302 → HLS m3u8 manifest. + +**2026-05-18 bandwidth optimization**: pornhat CDN tokens (`cdn.privatehost.com`) są +**time-bound, nie IP-bound** (`?sign=<HMAC>&exp_time=<unix>`). Zweryfikowane Chrome +DevTools MCP — VPS-resolved URL działa z każdego IP, bez Referer header. Zamiast +zwracać `pornhat.com/get_file/` URL (mobile dostaje go i robi 302 chain przez VPS +proxy), robimy server-side resolve i zwracamy końcowy manifest URL z signed token. + +Mobile ExoPlayer otrzymuje: + `https://nvms12.cdn.privatehost.com/hls/contents/.../?sign=...&exp_time=...` +i pobiera manifest + segments direct z CDN. **Zero VPS bandwidth** (poza ~5KB +initial resolve fetch). + +`mobile_direct_ok=True` w `raw` mówi playback.py że dla type=m3u8 ten URL jest OK +dla `direct_url=raw_url` (zazwyczaj m3u8 by szły przez proxy). + +Token wygasa za ~30-120 min od resolve (depends na lra param). User pause+resume +po >2h może dostać 403 → mobile fallback na proxified URL re-resolve'a. +""" +from __future__ import annotations + +import logging + +import httpx + +from app.extractors._models import StreamSource +from app.extractors.tubes._kvs_source import extract_kvs_sources + +log = logging.getLogger(__name__) + + +def _resolve_get_file_redirect(get_file_url: str, *, timeout: float = 15.0) -> str | None: + """Follow 302 chain pornhat.com/get_file/ → cdn.privatehost.com/hls/... + + Returns final manifest URL z signed token, lub None gdy fail. + """ + try: + with httpx.Client( + timeout=timeout, + follow_redirects=True, + headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Referer": "https://www.pornhat.com/", + }, + ) as c: + r = c.head(get_file_url) + final = str(r.url) + if "cdn.privatehost.com" in final and ".m3u8" not in final: + # Generic master URL: /hls/contents/... CDN serves jako m3u8 mime + # nawet bez .m3u8 w path (sprawdzone Content-Type). + return final + if ".m3u8" in final: + return final + log.info("pornhat resolve: unexpected final URL %s", final) + return None + except Exception as e: + log.warning("pornhat resolve %s failed: %s", get_file_url, e) + return None + + +def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None: + sources = extract_kvs_sources( + page_url, stream_type="m3u8", timeout=timeout, log_tag="pornhat" + ) + if not sources: + return None + + # Resolve każdy get_file URL → CDN signed manifest URL. Mobile dostaje direct. + resolved: list[StreamSource] = [] + for s in sources: + final = _resolve_get_file_redirect(s.link) + if final: + resolved.append( + StreamSource( + link=final, + type="m3u8", + quality=s.quality, + referer=s.referer, + raw={"mobile_direct_ok": True}, + ) + ) + else: + # Fallback: keep original (proxy will re-resolve) + resolved.append(s) + + return resolved diff --git a/app/extractors/tubes/pornxp.py b/app/extractors/tubes/pornxp.py new file mode 100644 index 0000000..6971546 --- /dev/null +++ b/app/extractors/tubes/pornxp.py @@ -0,0 +1,54 @@ +"""pornxp.ph — direct mp4 sources extractor. + +Detail page ma `<video>` z multiple `<source>` różnych jakości: + - `<source src="//sv.porn-xp.com/.../360.mp4">` + - `<source src="//sv.porn-xp.com/.../720.mp4">` + +URL protocol-relative (`//`) — normalize do `https:`. CDN może być IP-bound (token +w path), playback.py i tak proxifies. Type='mp4'. +""" +from __future__ import annotations + +import logging +import re + +from app.extractors._fetch import fetch_tube_html +from app.extractors._models import StreamSource + +log = logging.getLogger(__name__) + +# `<source src="(?:https:)?//sv.porn-xp.com/.../(360|720|1080).mp4">` +_SOURCE_RE = re.compile( + r'<source\s+src="(?P<url>(?:https?:)?//[^"]+/(?P<q>\d{3,4})\.mp4)"', + re.IGNORECASE, +) + + +def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None: + html = fetch_tube_html(page_url, timeout=timeout) + seen: set[str] = set() + result: list[StreamSource] = [] + # sv.porn-xp.com URL ma signed token w path — IP-bound. Mobile direct → 403, + # od razu używaj proxy żeby uniknąć mrugnięcia. + proxy_flag = {"force_proxy": True} + for m in _SOURCE_RE.finditer(html): + url = m.group("url") + if url.startswith("//"): + url = "https:" + url + if url in seen: + continue + seen.add(url) + result.append(StreamSource(link=url, type="mp4", quality=f"{m.group('q')}p", raw=proxy_flag)) + + if not result: + log.info("pornxp: no <source> tags on %s", page_url) + return None + + # Sort by quality desc (720p > 360p) + def _q(s: StreamSource) -> int: + try: + return int((s.quality or "0").rstrip("p")) + except ValueError: + return 0 + result.sort(key=_q, reverse=True) + return result diff --git a/app/extractors/tubes/sxyprn.py b/app/extractors/tubes/sxyprn.py new file mode 100644 index 0000000..30cc570 --- /dev/null +++ b/app/extractors/tubes/sxyprn.py @@ -0,0 +1,85 @@ +"""sxyprn.com — direct stream extractor. + +Page → `data-vnfo` JSON → URL transform algorytmem boo/ssut51/preda → .vid mp4. + +Sxyprn URL'e w `data-vnfo` mają format `/cdn/c8/<seg3>/<seg4>/<ts>/<seg6>/<seg7>.vid` +który wymaga rebuildu zanim CDN zaserwuje video bytes (bez tego endpoint zwraca tylko +pojedynczy timestamp 10B jako placeholder/probe response). Algorytm z `main2.js`: + + tmp[1] += "8/" + base64url("<ssut51(tmp[6])>-sxyprn.com-<ssut51(tmp[7])>") + tmp[5] -= ssut51(tmp[6]) + ssut51(tmp[7]) # preda + +gdzie ssut51 sumuje cyfry w stringu. Po tym joinujemy → finalny URL serwujący mp4. + +Każdy fetch strony zwraca FRESH signed URL (różne tokeny i timestamp). +""" +from __future__ import annotations + +import base64 +import json +import logging +import re + +from app.extractors._fetch import fetch_tube_html +from app.extractors._models import StreamSource + +log = logging.getLogger(__name__) + + +_VNFO_RE = re.compile(r"data-vnfo='([^']+)'") + + +def _ssut51(s: str) -> int: + """Sumuje wszystkie cyfry w stringu.""" + return sum(int(c) for c in s if c.isdigit()) + + +def _boo(ss: int, es: int) -> str: + """base64url-safe `<ss>-sxyprn.com-<es>` z `=`→`.`.""" + raw = f"{ss}-sxyprn.com-{es}".encode() + return ( + base64.b64encode(raw) + .decode() + .replace("+", "-") + .replace("/", "_") + .replace("=", ".") + ) + + +def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None: + html = fetch_tube_html(page_url, timeout=timeout) + m = _VNFO_RE.search(html) + if not m: + log.warning("sxyprn: no data-vnfo in %s", page_url) + return None + try: + vnfo = json.loads(m.group(1)) + except json.JSONDecodeError: + log.warning("sxyprn: bad vnfo JSON in %s", page_url) + return None + if not isinstance(vnfo, dict) or not vnfo: + return None + + sources: list[StreamSource] = [] + for _pid, src in vnfo.items(): + if not isinstance(src, str) or not src.startswith("/cdn/"): + continue + tmp = src.split("/") + if len(tmp) < 8: + log.warning("sxyprn: short path (%d segs) %s", len(tmp), src) + continue + try: + s6 = _ssut51(tmp[6]) + s7 = _ssut51(tmp[7]) + tmp[1] += "8" + "/" + _boo(s6, s7) + tmp[5] = str(int(tmp[5]) - s6 - s7) + except (ValueError, IndexError) as e: + log.warning("sxyprn: transform failed for %s: %s", src, e) + continue + final_path = "/".join(tmp) + full = "https://sxyprn.com" + final_path + sources.append(StreamSource(link=full, type="mp4")) + + if not sources: + return None + return sources diff --git a/app/ingest.py b/app/ingest.py new file mode 100644 index 0000000..fcb8280 --- /dev/null +++ b/app/ingest.py @@ -0,0 +1,360 @@ +"""Orchestracja per-source ingest. + +Cykl: + 1. Otwórz IngestRun (status=running). + 2. Iteruj `connector.fetch_scenes(since=last_successful_run-buffer, limit=limit)`. + 3. Dla każdej RawScene: + - hash = sha256(canonical_json(raw)) + - upsert do `external_records`: jeśli (source, kind, external_id) istnieje i hash niezmieniony → tylko `last_seen_at`. Inaczej → zapis i przekaż dalej. + - normalize_scene → resolve_scene (commit incrementally? na razie commit per-record). + 4. Zamknij IngestRun (records_seen / new / updated / errors). + +Idempotencja: + - external_records ma UNIQUE(source_id, entity_kind, external_id) — INSERT … ON CONFLICT DO UPDATE. + - resolver dla istniejącego SceneExternalRef tylko aktualizuje pola. +""" +from __future__ import annotations + +import hashlib +import json +import logging +import uuid +from collections.abc import Iterable +from datetime import UTC, datetime, timedelta + +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.orm import Session + +from app.connectors.base import BaseConnector, BaseMovieConnector, RawMovie, RawScene +from app.db import session_scope +from app.models.external_record import EntityKind, ExternalRecord +from app.models.ingest_run import IngestRun, IngestStatus +from app.models.source import Source, SourceKind +from app.normalize.movies import normalize_movie +from app.normalize.scenes import normalize_scene +from app.resolve.movie_resolver import resolve_movie +from app.resolve.scene_resolver import resolve_scene + +log = logging.getLogger(__name__) + + +def _canonical_json(payload: dict) -> bytes: + return json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str).encode() + + +def _hash_raw(payload: dict) -> bytes: + return hashlib.sha256(_canonical_json(payload)).digest() + + +def get_or_create_source( + session: Session, *, kind: SourceKind, name: str, base_url: str | None = None +) -> Source: + src = session.execute(select(Source).where(Source.name == name)).scalar_one_or_none() + if src is not None: + return src + src = Source(kind=kind, name=name, base_url=base_url) + session.add(src) + session.flush() + return src + + +def _last_successful_finished_at(session: Session, source_id: uuid.UUID) -> datetime | None: + return session.execute( + select(IngestRun.finished_at) + .where( + IngestRun.source_id == source_id, + IngestRun.status == IngestStatus.success, + IngestRun.finished_at.is_not(None), + ) + .order_by(IngestRun.finished_at.desc()) + .limit(1) + ).scalar_one_or_none() + + +def _upsert_external_record( + session: Session, + *, + source_id: uuid.UUID, + entity_kind: EntityKind, + external_id: str, + raw: dict, + raw_hash: bytes, + now: datetime, +) -> tuple[bool, bool]: + """Returns (is_new, hash_changed).""" + existing = session.execute( + select(ExternalRecord.raw_hash).where( + ExternalRecord.source_id == source_id, + ExternalRecord.entity_kind == entity_kind, + ExternalRecord.external_id == external_id, + ) + ).scalar_one_or_none() + + is_new = existing is None + hash_changed = is_new or bytes(existing) != raw_hash + + stmt = ( + pg_insert(ExternalRecord) + .values( + source_id=source_id, + entity_kind=entity_kind, + external_id=external_id, + raw=raw, + raw_hash=raw_hash, + fetched_at=now, + last_seen_at=now, + ) + .on_conflict_do_update( + index_elements=["source_id", "entity_kind", "external_id"], + set_={ + "raw": raw, + "raw_hash": raw_hash, + "fetched_at": now, + "last_seen_at": now, + } + if hash_changed + else {"last_seen_at": now}, + ) + ) + session.execute(stmt) + return is_new, hash_changed + + +def ingest_from_connector( + connector: BaseConnector, + *, + limit: int | None = None, + use_delta: bool = True, + delta_buffer: timedelta = timedelta(hours=1), +) -> dict[str, int]: + """Uruchamia jeden cykl ingest dla danego connectora. Zwraca counters.""" + + counters = {"seen": 0, "new": 0, "updated": 0, "skipped": 0, "errors": 0} + + with session_scope() as session: + source = get_or_create_source(session, kind=connector.kind, name=connector.name) + since: datetime | None = None + if use_delta: + last = _last_successful_finished_at(session, source.id) + if last is not None: + since = last - delta_buffer + + run = IngestRun(source_id=source.id, status=IngestStatus.running) + session.add(run) + session.flush() + run_id = run.id + source_id = source.id + + log.info( + "ingest start source=%s since=%s limit=%s run_id=%s", + connector.name, + since.isoformat() if since else "FULL", + limit, + run_id, + ) + + failed = False + error_payload: dict | None = None + + try: + for raw in connector.fetch_scenes(since=since, limit=limit): + counters["seen"] += 1 + try: + _process_scene(source_id=source_id, raw_scene=raw, counters=counters) + except Exception as exc: # pragma: no cover - obronnie + counters["errors"] += 1 + log.exception("ingest scene failed external_id=%s: %s", raw.external_id, exc) + if counters["errors"] > 50: + error_payload = {"message": "too many errors, aborting", "count": counters["errors"]} + raise + except Exception as exc: + failed = True + if error_payload is None: + error_payload = {"message": str(exc), "type": type(exc).__name__} + log.exception("ingest run failed: %s", exc) + + with session_scope() as session: + run = session.get(IngestRun, run_id) + assert run is not None + run.finished_at = datetime.now(UTC) + if failed: + run.status = IngestStatus.failed + elif counters["errors"] > 0: + run.status = IngestStatus.partial + else: + run.status = IngestStatus.success + run.records_seen = counters["seen"] + run.records_new = counters["new"] + run.records_updated = counters["updated"] + if error_payload is not None: + run.errors = error_payload + + log.info("ingest done source=%s counters=%s", connector.name, counters) + return counters + + +def _process_scene(*, source_id: uuid.UUID, raw_scene: RawScene, counters: dict[str, int]) -> None: + payload = raw_scene.raw or raw_scene.model_dump(mode="json") + raw_hash = _hash_raw(payload) + now = datetime.now(UTC) + + with session_scope() as session: + is_new, hash_changed = _upsert_external_record( + session, + source_id=source_id, + entity_kind=EntityKind.scene, + external_id=raw_scene.external_id, + raw=payload, + raw_hash=raw_hash, + now=now, + ) + if not hash_changed: + counters["skipped"] += 1 + return + + norm = normalize_scene(raw_scene) + result = resolve_scene(session, norm=norm, source_id=source_id) + + if result.was_created: + counters["new"] += 1 + else: + counters["updated"] += 1 + log.debug( + "scene resolved external=%s path=%s scene_id=%s score=%s", + raw_scene.external_id, + result.path, + result.scene.id, + result.score, + ) + + +def ingest_movies_from_connector( + connector: BaseMovieConnector, + *, + limit: int | None = None, + use_delta: bool = True, + delta_buffer: timedelta = timedelta(hours=1), +) -> dict[str, int]: + """Ingest movies (paradisehill / psyplay / wp_movies). Symetryczne do + ingest_from_connector ale dla `RawMovie` → `Movie` przez `resolve_movie`. + + Source row zaszyfrowuje kind+name z connectora; IngestRun trzymamy w tej + samej tabeli `ingest_runs` (typ entity rozróżniamy po `source.name` / + `external_records.entity_kind=movie`).""" + counters = {"seen": 0, "new": 0, "updated": 0, "skipped": 0, "errors": 0} + + with session_scope() as session: + source = get_or_create_source(session, kind=connector.kind, name=connector.name) + since: datetime | None = None + if use_delta: + last = _last_successful_finished_at(session, source.id) + if last is not None: + since = last - delta_buffer + run = IngestRun(source_id=source.id, status=IngestStatus.running) + session.add(run) + session.flush() + run_id = run.id + source_id = source.id + + log.info( + "ingest-movies start source=%s since=%s limit=%s run_id=%s", + connector.name, + since.isoformat() if since else "FULL", + limit, + run_id, + ) + + failed = False + error_payload: dict | None = None + + try: + for raw in connector.fetch_movies(since=since, limit=limit): + counters["seen"] += 1 + try: + _process_movie(source_id=source_id, raw_movie=raw, counters=counters) + except Exception as exc: + counters["errors"] += 1 + log.exception("ingest movie failed external_id=%s: %s", raw.external_id, exc) + if counters["errors"] > 50: + error_payload = {"message": "too many errors, aborting", "count": counters["errors"]} + raise + except Exception as exc: + failed = True + if error_payload is None: + error_payload = {"message": str(exc), "type": type(exc).__name__} + log.exception("ingest-movies run failed: %s", exc) + + with session_scope() as session: + run = session.get(IngestRun, run_id) + assert run is not None + run.finished_at = datetime.now(UTC) + if failed: + run.status = IngestStatus.failed + elif counters["errors"] > 0: + run.status = IngestStatus.partial + else: + run.status = IngestStatus.success + run.records_seen = counters["seen"] + run.records_new = counters["new"] + run.records_updated = counters["updated"] + if error_payload is not None: + run.errors = error_payload + + log.info("ingest-movies done source=%s counters=%s", connector.name, counters) + return counters + + +def _process_movie(*, source_id: uuid.UUID, raw_movie: RawMovie, counters: dict[str, int]) -> None: + payload = raw_movie.raw or raw_movie.model_dump(mode="json") + raw_hash = _hash_raw(payload) + now = datetime.now(UTC) + + with session_scope() as session: + is_new, hash_changed = _upsert_external_record( + session, + source_id=source_id, + entity_kind=EntityKind.movie, + external_id=raw_movie.external_id, + raw=payload, + raw_hash=raw_hash, + now=now, + ) + if not hash_changed: + counters["skipped"] += 1 + return + + norm = normalize_movie(raw_movie) + result = resolve_movie(session, norm=norm, source_id=source_id) + + if result.was_created: + counters["new"] += 1 + else: + counters["updated"] += 1 + log.debug( + "movie resolved external=%s path=%s movie_id=%s", + raw_movie.external_id, + result.path, + result.movie.id, + ) + + +def ingest_iter( + raws: Iterable[RawScene], + *, + source_kind: SourceKind, + source_name: str, +) -> dict[str, int]: + """Pomocnik do testów / scrapów ad-hoc bez connectora.""" + counters = {"seen": 0, "new": 0, "updated": 0, "skipped": 0, "errors": 0} + with session_scope() as session: + source = get_or_create_source(session, kind=source_kind, name=source_name) + source_id = source.id + for raw in raws: + counters["seen"] += 1 + try: + _process_scene(source_id=source_id, raw_scene=raw, counters=counters) + except Exception: + counters["errors"] += 1 + log.exception("ingest_iter failed external_id=%s", raw.external_id) + return counters diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..aa706a2 --- /dev/null +++ b/app/main.py @@ -0,0 +1,124 @@ +import logging + +import sentry_sdk +from fastapi import FastAPI +from sentry_sdk.integrations.fastapi import FastApiIntegration +from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration +from sentry_sdk.integrations.starlette import StarletteIntegration +from sqlalchemy import text + +from app.api.admin import router as admin_router +from app.api.admin_html import mount_static +from app.api.admin_html import router as admin_html_router +from app.api.blacklist import router as blacklist_router +from app.api.bug_reports import router as bug_reports_router +from app.api.expo_updates import router as expo_updates_router +from app.api.favorites import router as favorites_router +from app.api.movies import router as movies_router +from app.api.playback import movies_router as movies_playback_router +from app.api.playback import router as playback_router +from app.api.scene_favorites import router as scene_favorites_router +from app.api.scenes import router as scenes_router +from app.api.stream_proxy import router as stream_proxy_router +from app.api.taxonomies import router as taxonomies_router +from app.api.watch import router as watch_router +from app.config import get_settings +from app.db import session_scope + +_settings = get_settings() +logging.basicConfig(level=_settings.log_level) + +# Sentry: init przed FastAPI() żeby SDK przechwycił request handlery od pierwszego. +# Pusty DSN → SDK no-op (dev/local). Integracje: FastApi (request context, transactions), +# Starlette (middleware-level errors), SQLAlchemy (slow queries jako spans). +if _settings.sentry_dsn: + def _sentry_before_send(event, hint): + """Filter expected/transient HTTPException z proxy + playback resolve. + + Sentry FastAPI integration loguje WSZYSTKIE HTTPException jako error. + Dla nas 502/503/504 to expected behavior gdy upstream CDN unreachable + albo extractor zwraca None (legitnie). Spam-flooduje issue list + (GOON-3 16ev/5h, GOON-G 8ev, GOON-4 6ev). + """ + exc_info = hint.get("exc_info") if hint else None + if not exc_info: + return event + exc = exc_info[1] + from fastapi import HTTPException as _HTTPExc + if isinstance(exc, _HTTPExc) and exc.status_code in {502, 503, 504}: + return None # drop + return event + + sentry_sdk.init( + dsn=_settings.sentry_dsn, + environment=_settings.sentry_environment, + traces_sample_rate=_settings.sentry_traces_sample_rate, + # send_default_pii=False — bezpieczne, headers/cookies/IP nie idą do Sentry. + # Zostawiamy default (False) bo niektóre tube'y mają potencjalnie wrażliwe URL'e. + integrations=[ + StarletteIntegration(transaction_style="endpoint"), + FastApiIntegration(transaction_style="endpoint"), + SqlalchemyIntegration(), + ], + before_send=_sentry_before_send, + release="goon@0.1.8", + ) + +app = FastAPI(title="goon", version="0.1.8") +app.include_router(scenes_router) +app.include_router(movies_router) +app.include_router(playback_router) +app.include_router(movies_playback_router) +app.include_router(scene_favorites_router) +app.include_router(stream_proxy_router) +app.include_router(taxonomies_router) +app.include_router(favorites_router) +app.include_router(blacklist_router) +app.include_router(bug_reports_router) +app.include_router(expo_updates_router) +app.include_router(watch_router) +app.include_router(admin_router) +app.include_router(admin_html_router) +mount_static(app) + + +@app.get("/healthz") +def healthz() -> dict[str, str]: + return {"status": "ok"} + + +@app.get("/version") +def version() -> dict[str, str | None]: + """Mobile sprawdza po starcie żeby wykryć dostępność nowszej wersji APK. + + Zwraca: + - `version`: kanoniczna wersja serwera (źródło prawdy o latest APK) + - `apk_url`: bezpośredni link do najnowszego APK (self-hosted) lub None + gdy tylko external (GitHub Releases). Mobile otwiera w browser przy update. + + APK jest serwowany ze statycznego endpointu `/static/app-debug.apk` jeśli + istnieje (gradle build → scp na VPS → tu serwujemy). Brak pliku → `apk_url=None`, + mobile pokazuje tylko "newer version available" bez direct link. + """ + import os + from pathlib import Path + + apk_path = Path(__file__).resolve().parent / "static" / "app-release.apk" + apk_url: str | None = None + if apk_path.exists(): + # `BACKEND_PUBLIC_URL` z env to URL pod którym mobile może hit'nąć backend + # (production: https://goon.example.com lub IP:port). Default — relatywny URL, + # mobile sklei z baseUrl. + public_url = os.environ.get("BACKEND_PUBLIC_URL", "").rstrip("/") + apk_url = f"{public_url}/static/app-release.apk" if public_url else "/static/app-release.apk" + return {"version": "0.1.8", "apk_url": apk_url} + + +@app.get("/readyz") +def readyz() -> dict[str, object]: + try: + with session_scope() as session: + session.execute(text("SELECT 1")) + return {"status": "ready", "db": "ok"} + except Exception as exc: + return {"status": "degraded", "db": "error", "error": str(exc)} diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..53888df --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,67 @@ +from app.models.base import Base +from app.models.blacklist import BlacklistedPerformer, BlacklistedStudio, BlacklistedTag +from app.models.bug_report import BugReport +from app.models.external_record import ExternalRecord +from app.models.favorite_movie import FavoriteMovie +from app.models.favorite_performer import FavoritePerformer +from app.models.favorite_scene import FavoriteScene +from app.models.favorite_studio import FavoriteStudio +from app.models.ingest_run import IngestRun +from app.models.merge_candidate import MergeCandidate +from app.models.movie import ( + Movie, + MovieChapter, + MovieExternalRef, + MoviePerformer, + MovieTag, +) +from app.models.movie_playback_source import MoviePlaybackSource +from app.models.performer import Performer, PerformerAlias, PerformerExternalRef +from app.models.play_progress import ScenePlayProgress +from app.models.playback_source import PlaybackSource +from app.models.scene import ( + Scene, + SceneExternalRef, + SceneFingerprint, + ScenePerformer, + SceneTag, +) +from app.models.source import Source +from app.models.studio import Studio, StudioAlias, StudioExternalRef +from app.models.tag import Tag + +__all__ = [ + "Base", + "BlacklistedPerformer", + "BlacklistedStudio", + "BlacklistedTag", + "BugReport", + "ExternalRecord", + "FavoriteMovie", + "FavoritePerformer", + "FavoriteScene", + "FavoriteStudio", + "IngestRun", + "MergeCandidate", + "Movie", + "MovieChapter", + "MovieExternalRef", + "MoviePerformer", + "MoviePlaybackSource", + "MovieTag", + "Performer", + "PerformerAlias", + "PerformerExternalRef", + "ScenePlayProgress", + "PlaybackSource", + "Scene", + "SceneExternalRef", + "SceneFingerprint", + "ScenePerformer", + "SceneTag", + "Source", + "Studio", + "StudioAlias", + "StudioExternalRef", + "Tag", +] diff --git a/app/models/base.py b/app/models/base.py new file mode 100644 index 0000000..b9f5a36 --- /dev/null +++ b/app/models/base.py @@ -0,0 +1,38 @@ +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, MetaData, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +NAMING_CONVENTION = { + "ix": "ix_%(table_name)s_%(column_0_N_name)s", + "uq": "uq_%(table_name)s_%(column_0_N_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_N_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + + +class Base(DeclarativeBase): + metadata = MetaData(naming_convention=NAMING_CONVENTION) + + +class UUIDPKMixin: + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + server_default=func.gen_random_uuid(), + ) + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) diff --git a/app/models/blacklist.py b/app/models/blacklist.py new file mode 100644 index 0000000..6daf149 --- /dev/null +++ b/app/models/blacklist.py @@ -0,0 +1,47 @@ +"""Blacklists — performer/studio/tag globalne ukrywania.""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class BlacklistedPerformer(Base): + __tablename__ = "blacklisted_performers" + performer_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("performers.id", ondelete="CASCADE"), + primary_key=True, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + +class BlacklistedStudio(Base): + __tablename__ = "blacklisted_studios" + studio_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("studios.id", ondelete="CASCADE"), + primary_key=True, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + +class BlacklistedTag(Base): + __tablename__ = "blacklisted_tags" + tag_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tags.id", ondelete="CASCADE"), + primary_key=True, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) diff --git a/app/models/bug_report.py b/app/models/bug_report.py new file mode 100644 index 0000000..7207001 --- /dev/null +++ b/app/models/bug_report.py @@ -0,0 +1,50 @@ +"""In-app bug reports — composed wewnątrz mobile, wysłane przez POST /bug-reports. + +Powód: użytkownik nie może łatwo zgłaszać bugów bo Android FLAG_SECURE blokuje +screenshoty (NSFW content). Przepisywanie tytułów ręcznie z telefonu na Google +Keep jest powolne. Tu mobile sam screen capture'uje (przez react-native-view-shot +omija FLAG_SECURE) i wysyła z metadata. +""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, Text, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class BugReport(Base): + __tablename__ = "bug_reports" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + # Free-form context: {"screen": "SceneDetail", "build_version": "0.1.2", ...} + screen_name: Mapped[str | None] = mapped_column(String(64), nullable=True) + app_version: Mapped[str | None] = mapped_column(String(32), nullable=True) + # Nullable scene/movie FK — bug może być na liście, na ekranie favorites itd. + # Mobile w Player ekranie używa tego samego param `sceneId` dla movies (legacy + # progress tracking hack), więc backend smart-routes po lookup'ie: payload + # `scene_id` próbujemy najpierw jako Scene, jeśli nie ma — jako Movie. + scene_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("scenes.id", ondelete="SET NULL"), + nullable=True, + ) + movie_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("movies.id", ondelete="SET NULL"), + nullable=True, + ) + message: Mapped[str] = mapped_column(Text, nullable=False) + # PNG/JPEG bytes z react-native-view-shot, base64. Limit 1MB w API (per-request). + screenshot_b64: Mapped[str | None] = mapped_column(Text, nullable=True) + # Po obejrzeniu/naprawieniu: True. Brak osobnej tabeli statusów — single-user app. + resolved: Mapped[bool] = mapped_column(default=False, nullable=False) diff --git a/app/models/external_record.py b/app/models/external_record.py new file mode 100644 index 0000000..89b10d1 --- /dev/null +++ b/app/models/external_record.py @@ -0,0 +1,42 @@ +import enum +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Enum, ForeignKey, LargeBinary, String, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base, UUIDPKMixin + + +class EntityKind(str, enum.Enum): + scene = "scene" + performer = "performer" + studio = "studio" + tag = "tag" + movie = "movie" + + +class ExternalRecord(UUIDPKMixin, Base): + """Surowy snapshot encji z konkretnego źródła. Idempotentny — raw_hash chroni przed reprocessingiem.""" + + __tablename__ = "external_records" + __table_args__ = ( + UniqueConstraint("source_id", "entity_kind", "external_id"), + ) + + source_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("sources.id", ondelete="CASCADE"), nullable=False, index=True + ) + entity_kind: Mapped[EntityKind] = mapped_column( + Enum(EntityKind, name="entity_kind"), nullable=False + ) + external_id: Mapped[str] = mapped_column(String, nullable=False) + raw: Mapped[dict] = mapped_column(JSONB, nullable=False) + raw_hash: Mapped[bytes] = mapped_column(LargeBinary(32), nullable=False) + fetched_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + last_seen_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) diff --git a/app/models/favorite_movie.py b/app/models/favorite_movie.py new file mode 100644 index 0000000..60dec85 --- /dev/null +++ b/app/models/favorite_movie.py @@ -0,0 +1,27 @@ +"""Favorite movies — single-user, in-app. Mirror FavoriteStudio struktury.""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class FavoriteMovie(Base): + __tablename__ = "favorite_movies" + + movie_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("movies.id", ondelete="CASCADE"), + primary_key=True, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + last_seen_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) diff --git a/app/models/favorite_performer.py b/app/models/favorite_performer.py new file mode 100644 index 0000000..f1f54ed --- /dev/null +++ b/app/models/favorite_performer.py @@ -0,0 +1,30 @@ +"""Favorite performers — single-user (na razie), do wewn. powiadomień o nowych scenach.""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class FavoritePerformer(Base): + __tablename__ = "favorite_performers" + + performer_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("performers.id", ondelete="CASCADE"), + primary_key=True, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + # Kiedy user "zobaczył" listę nowych scen — przed tą datą sceny są "stare". Przy + # mark-as-seen update'ujemy do now(). Domyślnie = created_at, więc świeży favorite + # ma 0 nowych scen do momentu pojawienia się czegoś świeżego. + last_seen_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) diff --git a/app/models/favorite_scene.py b/app/models/favorite_scene.py new file mode 100644 index 0000000..49e6cee --- /dev/null +++ b/app/models/favorite_scene.py @@ -0,0 +1,24 @@ +"""Favorite scenes — single-user.""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class FavoriteScene(Base): + __tablename__ = "favorite_scenes" + + scene_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("scenes.id", ondelete="CASCADE"), + primary_key=True, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) diff --git a/app/models/favorite_studio.py b/app/models/favorite_studio.py new file mode 100644 index 0000000..07135d2 --- /dev/null +++ b/app/models/favorite_studio.py @@ -0,0 +1,27 @@ +"""Favorite studios — single-user, in-app. Mirror FavoritePerformer struktury.""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class FavoriteStudio(Base): + __tablename__ = "favorite_studios" + + studio_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("studios.id", ondelete="CASCADE"), + primary_key=True, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + last_seen_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) diff --git a/app/models/ingest_run.py b/app/models/ingest_run.py new file mode 100644 index 0000000..014fcbc --- /dev/null +++ b/app/models/ingest_run.py @@ -0,0 +1,40 @@ +import enum +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Enum, ForeignKey, Integer, func +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base, UUIDPKMixin + + +class IngestStatus(str, enum.Enum): + running = "running" + success = "success" + partial = "partial" + failed = "failed" + + +class IngestRun(UUIDPKMixin, Base): + __tablename__ = "ingest_runs" + + source_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("sources.id", ondelete="CASCADE"), nullable=False, index=True + ) + started_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + status: Mapped[IngestStatus] = mapped_column( + Enum(IngestStatus, name="ingest_status"), + nullable=False, + default=IngestStatus.running, + server_default=IngestStatus.running.value, + ) + records_seen: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + records_new: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + records_updated: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, server_default="0" + ) + errors: Mapped[dict | None] = mapped_column(JSONB) diff --git a/app/models/merge_candidate.py b/app/models/merge_candidate.py new file mode 100644 index 0000000..93e3be1 --- /dev/null +++ b/app/models/merge_candidate.py @@ -0,0 +1,45 @@ +import enum +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Enum, Float, String, func +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base, UUIDPKMixin + + +class MergeKind(str, enum.Enum): + scene = "scene" + performer = "performer" + studio = "studio" + movie = "movie" + + +class MergeStatus(str, enum.Enum): + pending = "pending" + auto_merged = "auto_merged" + merged = "merged" + rejected = "rejected" + + +class MergeCandidate(UUIDPKMixin, Base): + __tablename__ = "merge_candidates" + + kind: Mapped[MergeKind] = mapped_column(Enum(MergeKind, name="merge_kind"), nullable=False) + left_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False, index=True) + right_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False, index=True) + score: Mapped[float] = mapped_column(Float, nullable=False) + reasons: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + status: Mapped[MergeStatus] = mapped_column( + Enum(MergeStatus, name="merge_status"), + nullable=False, + default=MergeStatus.pending, + server_default=MergeStatus.pending.value, + index=True, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + resolved_by: Mapped[str | None] = mapped_column(String(128)) diff --git a/app/models/movie.py b/app/models/movie.py new file mode 100644 index 0000000..3c4fc22 --- /dev/null +++ b/app/models/movie.py @@ -0,0 +1,116 @@ +"""Movies — full-length adult films (paradisehill primary, mirrory z innych tube'ów). + +Movies vs Scenes: +- Scena: 5-30min, single performance, jeden moment w czasie. Kanon: TPDB/StashDB. +- Movie: 60-180min, multi-chapter, full DVD/release. Kanon: paradisehill + mirrory. + +Performers/Studios/Tags reusable — ten sam ludzie/studia mogą występować w obu. +""" +from __future__ import annotations + +import uuid +from datetime import date, datetime + +from sqlalchemy import ( + Date, + DateTime, + Float, + ForeignKey, + Integer, + String, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base, TimestampMixin, UUIDPKMixin + + +class Movie(UUIDPKMixin, TimestampMixin, Base): + __tablename__ = "movies" + + title: Mapped[str] = mapped_column(String(512), nullable=False) + title_normalized: Mapped[str] = mapped_column(String(512), nullable=False, index=True) + slug: Mapped[str | None] = mapped_column(String(512), index=True) + release_year: Mapped[int | None] = mapped_column(Integer, index=True) + release_date: Mapped[date | None] = mapped_column(Date, index=True) + studio_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("studios.id", ondelete="SET NULL"), index=True + ) + director: Mapped[str | None] = mapped_column(String(256)) + country: Mapped[str | None] = mapped_column(String(64)) + duration_sec: Mapped[int | None] = mapped_column(Integer) + description: Mapped[str | None] = mapped_column(Text) + poster_url: Mapped[str | None] = mapped_column(String(2048)) + backdrop_url: Mapped[str | None] = mapped_column(String(2048)) + rating: Mapped[float | None] = mapped_column(Float) + + +class MovieExternalRef(Base): + __tablename__ = "movie_external_refs" + + source_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("sources.id", ondelete="CASCADE"), primary_key=True + ) + external_id: Mapped[str] = mapped_column(String(256), primary_key=True) + movie_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("movies.id", ondelete="CASCADE"), nullable=False, index=True + ) + confidence: Mapped[float] = mapped_column(Float, nullable=False, default=1.0, server_default="1.0") + url: Mapped[str | None] = mapped_column(String(1024)) + first_seen: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + last_seen: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + +class MoviePerformer(Base): + __tablename__ = "movie_performers" + + movie_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("movies.id", ondelete="CASCADE"), primary_key=True + ) + performer_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("performers.id", ondelete="CASCADE"), primary_key=True + ) + role: Mapped[str | None] = mapped_column(String(64)) + position: Mapped[int | None] = mapped_column(Integer) + as_alias: Mapped[str | None] = mapped_column(String(256)) + + +class MovieTag(Base): + __tablename__ = "movie_tags" + + movie_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("movies.id", ondelete="CASCADE"), primary_key=True + ) + tag_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True + ) + source_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("sources.id", ondelete="SET NULL") + ) + + +class MovieChapter(UUIDPKMixin, Base): + """Optional chapter markers dla filmów (paradisehill czasem dzieli na sceny).""" + + __tablename__ = "movie_chapters" + __table_args__ = ( + UniqueConstraint("movie_id", "chapter_index", name="uq_movie_chapters_movie_id_chapter_index"), + ) + + movie_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("movies.id", ondelete="CASCADE"), nullable=False, index=True + ) + chapter_index: Mapped[int] = mapped_column(Integer, nullable=False) + title: Mapped[str | None] = mapped_column(String(512)) + start_sec: Mapped[int | None] = mapped_column(Integer) + end_sec: Mapped[int | None] = mapped_column(Integer) + scene_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("scenes.id", ondelete="SET NULL") + ) diff --git a/app/models/movie_playback_source.py b/app/models/movie_playback_source.py new file mode 100644 index 0000000..43e7151 --- /dev/null +++ b/app/models/movie_playback_source.py @@ -0,0 +1,56 @@ +"""Linki do odtwarzania movies — analog do PlaybackSource ale z movie_id zamiast scene_id. + +Origin patterns (przykłady): + - 'paradisehill' — primary, full URL + - 'psyplay:streamporn' — psyplay-themed sites: streamporn/pandamovies/mangoporn + - 'wp_movies:speedporn' — generic wp themes: speedporn/eroticmv/... +""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import ( + DateTime, + ForeignKey, + Integer, + String, + UniqueConstraint, + func, +) +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base, TimestampMixin, UUIDPKMixin + + +class MoviePlaybackSource(UUIDPKMixin, TimestampMixin, Base): + __tablename__ = "movie_playback_sources" + __table_args__ = ( + UniqueConstraint("origin", "page_url", name="uq_movie_playback_sources_origin_page_url"), + ) + + movie_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("movies.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + origin: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + page_url: Mapped[str] = mapped_column(String(2048), nullable=False) + embed_url: Mapped[str | None] = mapped_column(String(2048)) + stream_url: Mapped[str | None] = mapped_column(String(2048)) + quality: Mapped[str | None] = mapped_column(String(16)) + duration_sec: Mapped[int | None] = mapped_column(Integer) + thumbnail_url: Mapped[str | None] = mapped_column(String(2048)) + animated_thumbnail_url: Mapped[str | None] = mapped_column(String(2048)) + + last_seen_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + dead_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, index=True + ) + dead_reason: Mapped[str | None] = mapped_column(String(512), nullable=True) diff --git a/app/models/performer.py b/app/models/performer.py new file mode 100644 index 0000000..ce16c7b --- /dev/null +++ b/app/models/performer.py @@ -0,0 +1,81 @@ +import enum +import uuid +from datetime import date, datetime + +from sqlalchemy import Date, DateTime, Enum, Float, ForeignKey, Integer, String, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base, TimestampMixin, UUIDPKMixin + + +class Gender(str, enum.Enum): + female = "female" + male = "male" + transgender_female = "transgender_female" + transgender_male = "transgender_male" + non_binary = "non_binary" + intersex = "intersex" + unknown = "unknown" + + +class Performer(UUIDPKMixin, TimestampMixin, Base): + __tablename__ = "performers" + + canonical_name: Mapped[str] = mapped_column(String(256), nullable=False) + name_normalized: Mapped[str] = mapped_column(String(256), nullable=False, index=True) + slug: Mapped[str] = mapped_column(String(256), nullable=False, unique=True) + gender: Mapped[Gender | None] = mapped_column(Enum(Gender, name="performer_gender")) + birth_date: Mapped[date | None] = mapped_column(Date) + country: Mapped[str | None] = mapped_column(String(64)) + # Continuous search worker: kiedy ostatni per-performer search across tubes. + # Queue: ORDER BY last_searched_at NULLS FIRST, search_run_count ASC. Po pełnym + # sweep cykliczne refresh najstarszych. + last_searched_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + search_run_count: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, server_default="0" + ) + + +class PerformerAlias(Base): + __tablename__ = "performer_aliases" + __table_args__ = (UniqueConstraint("performer_id", "alias_normalized"),) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid() + ) + performer_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("performers.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + alias: Mapped[str] = mapped_column(String(256), nullable=False) + alias_normalized: Mapped[str] = mapped_column(String(256), nullable=False, index=True) + source_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("sources.id", ondelete="SET NULL") + ) + + +class PerformerExternalRef(Base): + __tablename__ = "performer_external_refs" + + source_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("sources.id", ondelete="CASCADE"), primary_key=True + ) + external_id: Mapped[str] = mapped_column(String, primary_key=True) + performer_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("performers.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + confidence: Mapped[float] = mapped_column(Float, nullable=False, default=1.0, server_default="1.0") + first_seen: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + last_seen: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) diff --git a/app/models/play_progress.py b/app/models/play_progress.py new file mode 100644 index 0000000..ceb003a --- /dev/null +++ b/app/models/play_progress.py @@ -0,0 +1,33 @@ +"""Pozycja odtwarzania per scena (continue watching).""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class ScenePlayProgress(Base): + __tablename__ = "scene_play_progress" + + scene_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("scenes.id", ondelete="CASCADE"), + primary_key=True, + ) + position_sec: Mapped[int] = mapped_column( + Integer, nullable=False, server_default="0", default=0 + ) + # Mirror Scene.duration_sec gdy player zwróci ten dato — pozwala na poprawny + # progress_pct nawet gdy Scene.duration_sec jest None. + duration_sec: Mapped[int | None] = mapped_column(Integer) + finished: Mapped[bool] = mapped_column( + Boolean, nullable=False, server_default="false", default=False + ) + last_played_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) diff --git a/app/models/playback_source.py b/app/models/playback_source.py new file mode 100644 index 0000000..fce7a48 --- /dev/null +++ b/app/models/playback_source.py @@ -0,0 +1,68 @@ +"""Linki do odtwarzania scen z zewnętrznych tube/agregatorów. + +Jedna kanoniczna scena może mieć N source'ów (jedna scena pojawia się na 5 tube'ach). +Każdy source ma `page_url` (zawsze) i opcjonalnie `embed_url` / `stream_url` jeśli +scraper potrafi je odkryć. Dedup — po (origin, page_url) — żeby ten sam scrap +nie tworzył duplikatów. +""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import ( + DateTime, + ForeignKey, + Integer, + String, + UniqueConstraint, + func, +) +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base, TimestampMixin, UUIDPKMixin + + +class PlaybackSource(UUIDPKMixin, TimestampMixin, Base): + __tablename__ = "playback_sources" + __table_args__ = (UniqueConstraint("origin", "page_url"),) + + scene_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("scenes.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + origin: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + """Krótka nazwa źródła, np. 'xmoviesforyou', 'yourdailypornvideos'.""" + + page_url: Mapped[str] = mapped_column(String(2048), nullable=False) + """Strona oryginalna z odtwarzaczem (deep link do scene page).""" + + embed_url: Mapped[str | None] = mapped_column(String(2048)) + """URL playera w iframe (jeśli scraper go znalazł). Może być None.""" + + stream_url: Mapped[str | None] = mapped_column(String(2048)) + """Bezpośredni URL do strumienia (m3u8/mp4) jeśli scraper potrafił go wyciągnąć. + Tylko z tym polem mobile może odpalić native player.""" + + quality: Mapped[str | None] = mapped_column(String(16)) + """np. '720p', '1080p', '4k'. None = unknown.""" + + duration_sec: Mapped[int | None] = mapped_column(Integer) + thumbnail_url: Mapped[str | None] = mapped_column(String(2048)) + animated_thumbnail_url: Mapped[str | None] = mapped_column(String(2048)) + + last_seen_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + dead_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, index=True + ) + """Set gdy resolve endpoint dostanie HosterDead / 404 'Video is offline'/'deleted' z tube'a. + API filtruje takie sources z listy. None = aktywny.""" + + dead_reason: Mapped[str | None] = mapped_column(String(512), nullable=True) diff --git a/app/models/scene.py b/app/models/scene.py new file mode 100644 index 0000000..5fc5170 --- /dev/null +++ b/app/models/scene.py @@ -0,0 +1,109 @@ +import enum +import uuid +from datetime import date, datetime + +from sqlalchemy import ( + Date, + DateTime, + Enum, + Float, + ForeignKey, + Integer, + String, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base, TimestampMixin, UUIDPKMixin + + +class FingerprintKind(str, enum.Enum): + phash = "phash" + oshash = "oshash" + md5 = "md5" + + +class Scene(UUIDPKMixin, TimestampMixin, Base): + __tablename__ = "scenes" + + title: Mapped[str] = mapped_column(String, nullable=False) + title_normalized: Mapped[str] = mapped_column(String, nullable=False, index=True) + slug: Mapped[str | None] = mapped_column(String, index=True) + release_date: Mapped[date | None] = mapped_column(Date, index=True) + studio_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("studios.id", ondelete="SET NULL"), index=True + ) + duration_sec: Mapped[int | None] = mapped_column(Integer) + description: Mapped[str | None] = mapped_column(Text) + code: Mapped[str | None] = mapped_column(String(128), index=True) + director: Mapped[str | None] = mapped_column(String(256)) + + +class SceneExternalRef(Base): + __tablename__ = "scene_external_refs" + + source_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("sources.id", ondelete="CASCADE"), primary_key=True + ) + external_id: Mapped[str] = mapped_column(String, primary_key=True) + scene_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("scenes.id", ondelete="CASCADE"), nullable=False, index=True + ) + confidence: Mapped[float] = mapped_column(Float, nullable=False, default=1.0, server_default="1.0") + url: Mapped[str | None] = mapped_column(String(1024)) + first_seen: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + last_seen: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + +class SceneFingerprint(Base): + """Fingerprinty zaciągnięte ze źródeł (TPDB/StashDB) — nie liczone lokalnie.""" + + __tablename__ = "scene_fingerprints" + __table_args__ = (UniqueConstraint("scene_id", "kind", "value"),) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid() + ) + scene_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("scenes.id", ondelete="CASCADE"), nullable=False, index=True + ) + kind: Mapped[FingerprintKind] = mapped_column(Enum(FingerprintKind, name="fingerprint_kind")) + value: Mapped[str] = mapped_column(String(128), nullable=False, index=True) + source_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("sources.id", ondelete="SET NULL") + ) + + +class ScenePerformer(Base): + __tablename__ = "scene_performers" + + scene_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("scenes.id", ondelete="CASCADE"), primary_key=True + ) + performer_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("performers.id", ondelete="CASCADE"), primary_key=True + ) + role: Mapped[str | None] = mapped_column(String(64)) + position: Mapped[int | None] = mapped_column(Integer) + as_alias: Mapped[str | None] = mapped_column(String(256)) + + +class SceneTag(Base): + __tablename__ = "scene_tags" + + scene_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("scenes.id", ondelete="CASCADE"), primary_key=True + ) + tag_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True + ) + source_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("sources.id", ondelete="SET NULL") + ) diff --git a/app/models/source.py b/app/models/source.py new file mode 100644 index 0000000..bf651d7 --- /dev/null +++ b/app/models/source.py @@ -0,0 +1,26 @@ +import enum + +from sqlalchemy import Enum, Float, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base, TimestampMixin, UUIDPKMixin + + +class SourceKind(str, enum.Enum): + tpdb = "tpdb" + stashdb = "stashdb" + scraper = "scraper" + porn_app = "porn_app" + manual = "manual" + + +class Source(UUIDPKMixin, TimestampMixin, Base): + __tablename__ = "sources" + + kind: Mapped[SourceKind] = mapped_column( + Enum(SourceKind, name="source_kind"), nullable=False + ) + name: Mapped[str] = mapped_column(String(128), nullable=False, unique=True) + base_url: Mapped[str | None] = mapped_column(String(512)) + auth_secret_ref: Mapped[str | None] = mapped_column(String(128)) + weight: Mapped[float] = mapped_column(Float, nullable=False, default=1.0, server_default="1.0") diff --git a/app/models/studio.py b/app/models/studio.py new file mode 100644 index 0000000..88c4ae1 --- /dev/null +++ b/app/models/studio.py @@ -0,0 +1,57 @@ +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Float, ForeignKey, String, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base, TimestampMixin, UUIDPKMixin + + +class Studio(UUIDPKMixin, TimestampMixin, Base): + __tablename__ = "studios" + + name: Mapped[str] = mapped_column(String(256), nullable=False) + name_normalized: Mapped[str] = mapped_column(String(256), nullable=False, index=True) + slug: Mapped[str] = mapped_column(String(256), nullable=False, unique=True) + parent_studio_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("studios.id", ondelete="SET NULL"), nullable=True + ) + network: Mapped[str | None] = mapped_column(String(256)) + homepage_url: Mapped[str | None] = mapped_column(String(512)) + + +class StudioAlias(Base): + __tablename__ = "studio_aliases" + __table_args__ = (UniqueConstraint("studio_id", "alias_normalized"),) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid() + ) + studio_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("studios.id", ondelete="CASCADE"), nullable=False, index=True + ) + alias: Mapped[str] = mapped_column(String(256), nullable=False) + alias_normalized: Mapped[str] = mapped_column(String(256), nullable=False, index=True) + source_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("sources.id", ondelete="SET NULL"), nullable=True + ) + + +class StudioExternalRef(Base): + __tablename__ = "studio_external_refs" + + source_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("sources.id", ondelete="CASCADE"), primary_key=True + ) + external_id: Mapped[str] = mapped_column(String, primary_key=True) + studio_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("studios.id", ondelete="CASCADE"), nullable=False, index=True + ) + confidence: Mapped[float] = mapped_column(Float, nullable=False, default=1.0, server_default="1.0") + first_seen: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + last_seen: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) diff --git a/app/models/tag.py b/app/models/tag.py new file mode 100644 index 0000000..33882bc --- /dev/null +++ b/app/models/tag.py @@ -0,0 +1,18 @@ +import uuid + +from sqlalchemy import ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base, TimestampMixin, UUIDPKMixin + + +class Tag(UUIDPKMixin, TimestampMixin, Base): + __tablename__ = "tags" + + name: Mapped[str] = mapped_column(String(128), nullable=False) + slug: Mapped[str] = mapped_column(String(128), nullable=False, unique=True) + parent_tag_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("tags.id", ondelete="SET NULL") + ) + description: Mapped[str | None] = mapped_column(String(1024)) diff --git a/app/normalize/__init__.py b/app/normalize/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/normalize/movies.py b/app/normalize/movies.py new file mode 100644 index 0000000..7aec668 --- /dev/null +++ b/app/normalize/movies.py @@ -0,0 +1,66 @@ +"""RawMovie → NormalizedMovie. Reuse normalize_studio/performer/tag z scenes.py.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date + +from app.connectors.base import RawMovie, RawMovieChapter, RawPlaybackSource +from app.normalize.scenes import ( + NormalizedPerformer, + NormalizedStudio, + NormalizedTag, + normalize_performer, + normalize_studio, + normalize_tag, +) +from app.normalize.text import normalize, slugify + + +@dataclass +class NormalizedMovie: + external_id: str + title: str + title_normalized: str + slug: str + release_year: int | None + release_date: date | None + description: str | None + duration_sec: int | None + director: str | None + country: str | None + rating: float | None + poster_url: str | None + backdrop_url: str | None + url: str | None + + studio: NormalizedStudio | None = None + performers: list[NormalizedPerformer] = field(default_factory=list) + tags: list[NormalizedTag] = field(default_factory=list) + chapters: list[RawMovieChapter] = field(default_factory=list) + playback_sources: list[RawPlaybackSource] = field(default_factory=list) + cross_source_refs: dict[str, str] = field(default_factory=dict) + + +def normalize_movie(raw: RawMovie) -> NormalizedMovie: + return NormalizedMovie( + external_id=raw.external_id, + title=raw.title, + title_normalized=normalize(raw.title), + slug=slugify(raw.title), + release_year=raw.release_year, + release_date=raw.release_date, + description=raw.description, + duration_sec=raw.duration_sec, + director=raw.director, + country=raw.country, + rating=raw.rating, + poster_url=raw.poster_url, + backdrop_url=raw.backdrop_url, + url=raw.url, + studio=normalize_studio(raw.studio) if raw.studio else None, + performers=[normalize_performer(p) for p in raw.performers], + tags=[normalize_tag(t) for t in raw.tags], + chapters=list(raw.chapters), + playback_sources=list(raw.playback_sources), + cross_source_refs=dict(raw.cross_source_refs), + ) diff --git a/app/normalize/scenes.py b/app/normalize/scenes.py new file mode 100644 index 0000000..563a193 --- /dev/null +++ b/app/normalize/scenes.py @@ -0,0 +1,167 @@ +"""Mapowanie RawScene/RawPerformer/RawStudio/RawTag → znormalizowane DTO gotowe do upsertu. + +Normalizacja = wyliczenie pól indeksujących (`*_normalized`, `slug`). Surowe pola jak +`title`, `release_date` przekazujemy bez zmian — kanon wybiera resolver. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date + +from app.connectors.base import ( + RawPerformer, + RawPlaybackSource, + RawScene, + RawStudio, + RawTag, +) +from app.normalize.text import normalize, normalize_person, slugify + + +@dataclass +class NormalizedTag: + name: str + slug: str + external_id: str | None + + +@dataclass +class NormalizedStudio: + name: str + name_normalized: str + slug: str + external_id: str | None + parent_external_id: str | None + parent_name: str | None + network: str | None + homepage_url: str | None + + +@dataclass +class NormalizedPerformer: + canonical_name: str + name_normalized: str + slug: str + external_id: str | None + aliases: list[str] + aliases_normalized: list[str] + gender: str | None + birth_date: date | None + country: str | None + as_alias_in_scene: str | None + + +@dataclass +class NormalizedScene: + external_id: str + title: str + title_normalized: str + slug: str + release_date: date | None + description: str | None + duration_sec: int | None + code: str | None + director: str | None + url: str | None + + studio: NormalizedStudio | None = None + performers: list[NormalizedPerformer] = field(default_factory=list) + tags: list[NormalizedTag] = field(default_factory=list) + + fingerprints: list[tuple[str, str]] = field(default_factory=list) + """Lista (kind, value), np. [('phash', 'abc...'), ('oshash', '...')].""" + + playback_sources: list[RawPlaybackSource] = field(default_factory=list) + """Linki do odtwarzania (passthrough — resolver dorzuca do tabeli playback_sources).""" + + cross_source_refs: dict[str, str] = field(default_factory=dict) + """Mapowanie source_name → external_id deklarowane przez źródło z którego pochodzi + ta scena (np. StashDB ujawnia tpdb_id w `urls`).""" + + +def normalize_tag(raw: RawTag) -> NormalizedTag: + return NormalizedTag( + name=raw.name, + slug=raw.slug or slugify(raw.name), + external_id=raw.external_id, + ) + + +def normalize_studio(raw: RawStudio) -> NormalizedStudio: + return NormalizedStudio( + name=raw.name, + name_normalized=normalize(raw.name), + slug=raw.slug or slugify(raw.name), + external_id=raw.external_id, + parent_external_id=raw.parent_external_id, + parent_name=raw.parent_name, + network=raw.network, + homepage_url=raw.homepage_url, + ) + + +_GENDER_ALIASES = { + "female": "female", + "f": "female", + "male": "male", + "m": "male", + "transgender female": "transgender_female", + "trans female": "transgender_female", + "trans-female": "transgender_female", + "transgender_female": "transgender_female", + "transgender male": "transgender_male", + "trans male": "transgender_male", + "trans-male": "transgender_male", + "transgender_male": "transgender_male", + "non binary": "non_binary", + "non-binary": "non_binary", + "nonbinary": "non_binary", + "non_binary": "non_binary", + "intersex": "intersex", + "unknown": "unknown", +} + + +def _normalize_gender(value: str | None) -> str | None: + """TPDB/StashDB zwracają warianty z spacjami/myślnikami. Normalizujemy do enum + `performer_gender` w bazie. Wartość niezmapowana → None (NULL w DB).""" + if not value: + return None + return _GENDER_ALIASES.get(value.strip().lower()) + + +def normalize_performer(raw: RawPerformer) -> NormalizedPerformer: + aliases = list(dict.fromkeys(raw.aliases)) # de-dup zachowując kolejność + return NormalizedPerformer( + canonical_name=raw.name, + name_normalized=normalize_person(raw.name), + slug=slugify(raw.name), + external_id=raw.external_id, + aliases=aliases, + aliases_normalized=[normalize_person(a) for a in aliases], + gender=_normalize_gender(raw.gender), + birth_date=raw.birth_date, + country=raw.country, + as_alias_in_scene=raw.as_alias_in_scene, + ) + + +def normalize_scene(raw: RawScene) -> NormalizedScene: + return NormalizedScene( + external_id=raw.external_id, + title=raw.title, + title_normalized=normalize(raw.title), + slug=slugify(raw.title), + release_date=raw.release_date, + description=raw.description, + duration_sec=raw.duration_sec, + code=raw.code, + director=raw.director, + url=raw.url, + studio=normalize_studio(raw.studio) if raw.studio else None, + performers=[normalize_performer(p) for p in raw.performers], + tags=[normalize_tag(t) for t in raw.tags], + fingerprints=[(fp.kind, fp.value) for fp in raw.fingerprints], + playback_sources=list(raw.playback_sources), + cross_source_refs=dict(raw.cross_source_refs), + ) diff --git a/app/normalize/tag_categories.py b/app/normalize/tag_categories.py new file mode 100644 index 0000000..6d28766 --- /dev/null +++ b/app/normalize/tag_categories.py @@ -0,0 +1,150 @@ +"""Mapping canonical tag slug → kandydaci aliasów slug-ów per tube. + +Strategia per tube: iterujemy listę candidates dla danego canonical_slug po kolei, +pierwszy alias który zwróci >0 scen na danym tube'ie wygrywa. Reszta aliasów dla +tej kategorii skip. Dzięki temu obsługujemy slug-mismatch między tube'ami: + - większość tubeów: "anal" + - paradisehillcc: "anal-sex" (osobny inflection) + - niektóre: "ass-fuck", "anal-fuck" + +Format aliasów: zawsze z myślnikami. `categoryReplacer` per tube zamieni '-' na +docelowy separator ('_' / '+' / ''). Pierwszy alias w liście to zwykle najbardziej +mainstream slug, dalej coraz bardziej niszowe odmiany. +""" +from __future__ import annotations + + +CATEGORIES: dict[str, list[str]] = { + "anal": [ + "anal", "anal-sex", "anal-fuck", "ass-fuck", "anal-porn", "analsex", + "anal-pov", "ass-to-mouth", "atm", + ], + "anal-creampie": ["anal-creampie", "anal-cream-pie", "creampied-ass"], + "anal-fisting": ["anal-fisting", "anal-fist", "fisting-anal"], + "anal-gape": ["anal-gape", "gaping", "ass-gape", "gape"], + "double-penetration": [ + "double-penetration", "dp", "double-anal", "dap", "double-anal-penetration", + ], + "big-tits": [ + "big-tits", "big-tit", "big-boobs", "huge-tits", "busty", "big-titted", + "huge-boobs", "bigtits", "big-tits-amateur", "massive-tits", + ], + "natural-tits": [ + "natural-tits", "natural-boobs", "all-natural", "natural", "naturaltits", + ], + "small-tits": [ + "small-tits", "small-tit", "tiny-tits", "petite", "flat-chested", + "smalltits", "small-boobs", + ], + "fake-tits": [ + "fake-tits", "fake-boobs", "silicone", "fake", "implants", "silicone-tits", + ], + "big-ass": [ + "big-ass", "big-butt", "big-butts", "huge-ass", "phat-ass", "pawg", + "bigass", "big-booty", "huge-booty", "bubble-butt", "thick", "thicc", + ], + "blowjob": [ + "blowjob", "blow-job", "oral", "oral-sex", "blowjobs", "bj", + "cock-sucking", "deep-blowjob", "sloppy-blowjob", + ], + "deepthroat": ["deepthroat", "deep-throat", "deep-throat-fuck"], + "face-fuck": ["face-fuck", "facefuck", "throat-fuck", "rough-throat"], + "facial": ["facial", "facials", "cum-on-face", "facial-cumshot"], + "creampie": [ + "creampie", "cream-pie", "internal-cumshot", "creampies", "cum-inside", + ], + "cum-on-tits": [ + "cum-on-tits", "tits-cumshot", "cumshot-on-tits", "cum-on-boobs", + ], + "cumshot": ["cumshot", "cum-shot", "cumshots", "cum"], + "milf-30": [ + "milf", "milfs", "mom", "moms", "stepmom", "step-mom", "mature", + "cougar", "mature-women", "milf-anal", "mature-milf", "mommy", + ], + "lesbian": [ + "lesbian", "lesbians", "girl-girl", "girl-on-girl", "lez", "lezzie", + "all-girl", "lesbo", "lesbian-sex", + ], + "threesome": [ + "threesome", "3some", "ffm", "mfm", "fmf", "mff", "two-girls", + "ffm-threesome", "threesomes", + ], + "foursome": ["foursome", "4some", "two-couples", "four-some"], + "gangbang": [ + "gangbang", "gang-bang", "orgy", "orgies", "group-sex", "bukkake", + "group", "groupsex", + ], + "interracial": [ + "interracial", "bbc", "ir", "black-on-white", "interracial-anal", + ], + "rough": ["rough", "rough-sex", "extreme", "brutal", "punish", "punished"], + "bdsm": [ + "bdsm", "bondage", "femdom", "domination", "submission", "dominatrix", + "tied-up", "slave", "fetish", + ], + "masturbation": [ + "masturbation", "solo", "solo-girl", "masturbating", "masturbate", + "fingering", "jerk-off", "self-pleasure", + ], + "pov": ["pov", "point-of-view", "pov-sex", "first-person"], + "doggy-style": [ + "doggy-style", "doggystyle", "doggie-style", "doggy", "from-behind", + ], + "cowgirl": ["cowgirl", "riding", "riding-cock", "girl-on-top"], + "reverse-cowgirl": ["reverse-cowgirl", "rcg", "reverse-riding"], + "rimming": ["rimming", "ass-licking", "rim-job", "rimjob", "asslicking"], + "handjob": ["handjob", "hand-job", "handjobs", "hj"], + "footjob": ["footjob", "foot-job"], + "fisting": ["fisting", "fist", "fist-fucking", "fistfuck"], + "squirt": ["squirt", "squirting", "squirts", "female-ejaculation"], + "ebony": ["ebony", "black", "blacks-women", "black-women", "ebony-anal"], + "asian": [ + "asian", "asians", "japanese", "korean", "chinese", "thai", "japan", + "asian-anal", + ], + "latina": ["latina", "latin", "latinas", "latina-anal", "spanish"], + "teen-18": [ + "teen", "teens", "18", "barely-legal", "teenagers-18", "young", + "young-girl", "18-year-old", "young-teen", "youngteens", + ], + "amateur": [ + "amateur", "amateur-porn", "homemade", "home-made", "real-amateur", + "amateurs", + ], + "hardcore": ["hardcore", "hard-core", "hard-fuck", "rough-fuck"], + "step-fantasy": [ + "step-fantasy", "stepfamily", "stepmom", "stepsis", "step-sister", + "stepbro", "step-brother", "step-daughter", "step-son", "taboo", + "family", "family-taboo", + ], + "lingerie": ["lingerie", "stockings", "fishnets", "lingerie-sex", "garter"], + "piercing": ["piercing", "pierced", "pierced-pussy", "pierced-nipples"], + "feet": ["feet", "foot-fetish", "footjob", "barefoot", "feet-licking"], + "pussy-licking": [ + "pussy-licking", "pussy-eating", "eating-pussy", "muff-diving", + ], + "big-dick": [ + "big-dick", "big-cock", "huge-cock", "monster-cock", "huge-dick", + "bigcock", "bigdick", "big-cocks", + ], + "public": [ + "public", "public-sex", "outdoor", "outdoors", "outdoor-sex", + "in-public", "public-flashing", + ], + "anal-toys": ["anal-toys", "butt-plug", "buttplug", "anal-beads"], + "anal-fingering": ["anal-fingering", "finger-ass", "ass-fingering"], + "tribbing": ["tribbing", "trib", "scissoring", "scissor"], + # Brand-new canonical slugs — gdy pierwszy alias w aktywnym tube hit'uje, tag jest + # automatycznie utworzony przez `_ensure_tag` (nazwa = title-case slug). + "hairy": ["hairy", "hairy-pussy", "bush", "unshaven"], + "tattoo": ["tattoo", "tattoos", "tattooed", "inked"], + "european": ["european", "euro", "czech", "british", "german", "russian"], + "cuckold": ["cuckold", "cuck", "hotwife", "wife-watching"], + "swingers": ["swingers", "swinging", "wife-swap"], + "gloryhole": ["gloryhole", "glory-hole"], + "pissing": ["pissing", "piss", "watersports"], + "bbw": ["bbw", "chubby", "curvy", "plus-size"], +} + + +__all__ = ["CATEGORIES"] diff --git a/app/normalize/tag_inference.py b/app/normalize/tag_inference.py new file mode 100644 index 0000000..6b1da09 --- /dev/null +++ b/app/normalize/tag_inference.py @@ -0,0 +1,186 @@ +"""Inferencja tagów z tytułu sceny. + +Kontekst: porn-app `VideoInformation` zwraca tylko podstawowe pola (title, link, img, +duration, quality, pornstars) — bez tagów. ~7000 scen w DB z tube'ów ma 0-1 tagów, +przez co filtrowanie wg tagu (anal/big-tits/...) zwraca prawie wyłącznie sceny TPDB, +których z kolei prawie żadna nie ma żywego playback. + +Rozwiązanie: dla każdej sceny wyciągamy z tytułu wzmianki o popularnych kategoriach. +Słownik jest celowo ostrożny — tylko phrasy które rzadko fałszywie się dopasują (full +word boundaries), pokrywające ~30-40% kategorialnych tagów (anal/blowjob/lesbian/itp.). +Wizualne atrybuty (brown-hair/tattoos/lingerie) świadomie pominięte — z tytułu nie da +się ich odgadnąć. + +Slug-i są zgrane z istniejącymi w DB (np. "milf-30" zamiast "milf", bo TPDB taki +ustanowił). Inference dorzuca SceneTag do scen porn-app — gdy potem resolver merguje +porn-app scenę z TPDB sceną, tagi z TPDB i tagi z inference się sumują (set union), +więc inference nie nadpisuje, tylko uzupełnia. +""" +from __future__ import annotations + +import re + +# Map: canonical slug → lista phrasów (lowercase). Każdy phrase matchuje się word +# boundaries — więc "anal" w "fundamental" NIE łapie, ale "anal sex" czy "Anal." tak. +# Phrasy z dwóch słów dopasowują się z tolerancją na wieloznaki spacji/kresek/podkreśleń +# (`anal_creampie`, `anal-creampie`, `anal creampie` — wszystkie matchują wzorzec). +TAG_PHRASES: dict[str, list[str]] = { + "anal": [ + "anal", "anally", "anus", "asshole", "ass fuck", "butt fuck", "ass to mouth", + "atm", "buttplug", "anally fucked", + ], + "anal-creampie": ["anal creampie", "anal cream pie", "creampied ass", "ass creampie"], + "anal-fisting": ["anal fisting", "anal fist", "fisting anal"], + "anal-gape": ["anal gape", "gaping ass", "ass gape"], + "double-anal-penetration-dap": ["double anal", "dap", "dual anal", "two cocks in ass"], + "big-tits": [ + "big tits", "big tit", "big boobs", "big boob", "big breasts", + "huge tits", "huge tit", "huge boobs", "huge boob", "busty", + "big naturals", "massive tits", "bigtits", "bigtit", + ], + "natural-tits": ["natural tits", "natural tit", "natural boobs", "all natural"], + "small-tits": ["small tits", "small tit", "tiny tits", "flat chested", "petite"], + "fake-tits": ["fake tits", "fake boobs", "silicone tits", "implants"], + "big-ass": [ + "big ass", "big butt", "huge ass", "fat ass", "pawg", "phat ass", + "thicc", "thicc ass", "big booty", "jiggly ass", + ], + "blowjob": [ + "blowjob", "blow job", "blowjobs", "bj", "cock sucking", "sucking cock", + "sucking dick", "dick sucking", "throat fuck", + ], + "deepthroat": ["deepthroat", "deep throat", "deepthroats", "throat goat"], + "face-fuck": ["face fuck", "facefuck", "throat fuck"], + "facial": ["facial", "cum on face", "facial cumshot", "facials"], + "cumshot": ["cumshot", "cum shot", "cumshots", "cum load"], + "creampie": ["creampie", "cream pie", "creampies", "cum inside"], + "cum-on-tits": ["cum on tits", "cumshot on tits", "tit cum"], + "milf-30": [ + "milf", "milfs", "mom", "moms", "mommy", "stepmom", "step mom", + "stepmother", "step mother", "hot mom", "mature woman", "cougar", + "stepmommy", + ], + "lesbian": [ + "lesbian", "lesbians", "girl on girl", "girls only", "lez", + "all girl", "lesbo", + ], + "threesome": [ + "threesome", "3some", "three some", "mfm", "mff", "fmf", "ffm", + "two girls one guy", "two guys one girl", "ffm threesome", + ], + "foursome": ["foursome", "4some", "four some", "two couples"], + "gangbang": [ + "gangbang", "gang bang", "orgy", "group sex", "bukkake", + ], + "interracial": [ + "interracial", "bbc", "big black cock", "ir", "black on white", + ], + "rough": ["rough sex", "rough", "brutal", "punish", "punished", "extreme"], + "bdsm": [ + "bdsm", "bondage", "submission", "dominatrix", "femdom", "tied up", + "tied", "slave", "domination", + ], + "masturbation": [ + "masturbation", "masturbating", "masturbate", "solo", "fingering herself", + "jerk off", "jerking off", "jerking", + ], + "pov": ["pov", "point of view", "first person"], + "doggy-style": ["doggy", "doggystyle", "doggy style", "from behind", "doggie style"], + "cowgirl": ["cowgirl", "riding cock", "riding dick"], + "reverse-cowgirl": ["reverse cowgirl", "reverse-cowgirl", "rcg"], + "rimming": ["rimming", "rim job", "rimjob", "ass licking", "ass-licking"], + "handjob": ["handjob", "hand job", "hj", "tugging"], + "footjob": ["footjob", "foot job"], + "feet": ["foot fetish", "feet fetish", "barefoot"], + "pussy-licking": ["pussy licking", "pussy lick", "eating pussy", "pussy eating"], + "fisting": ["fisting", "fist fuck", "fistfuck", "fisted"], + "squirt": ["squirt", "squirting", "squirts", "female ejaculation"], + "big-dick": [ + "big dick", "big cock", "huge dick", "huge cock", "monster cock", + "monster dick", "bigcock", "bigdick", + ], + "lingerie": ["lingerie", "stockings", "fishnets", "garter"], + "public": ["in public", "public sex", "outdoor sex", "outdoors", "public flashing"], + "ebony": ["ebony", "black girl", "black girls"], + "asian": [ + "asian", "japanese", "korean", "chinese", "thai", "vietnamese", + "japanese girl", "asian girl", + ], + "latina": ["latina", "latin girl", "latinas"], + "teen-18": [ + "teen", "teens", "18yo", "18 year old", "18-year", "teenage", + "barely legal", "young 18", + ], + "amateur": ["amateur", "homemade", "home made", "real amateur"], + "hardcore": ["hardcore", "hard core"], + "step-fantasy": [ + "stepsister", "step sister", "stepsis", "stepbrother", "step brother", + "stepson", "step son", "stepdad", "step dad", "stepfather", "step father", + "stepmom", "step mom", "step daughter", "stepdaughter", + ], + "anal-toys": ["anal toy", "butt plug", "buttplug", "anal beads"], + "anal-fingering": ["anal fingering", "finger ass", "ass fingering"], + "tribbing": ["tribbing", "trib", "scissoring"], + "double-penetration": [ + "double penetration", "dp scene", "dped", + ], + "hairy": ["hairy pussy", "hairy bush", "unshaven", "natural bush"], + "tattoo": ["tattooed", "tattoos", "inked"], + "european": ["czech girl", "british girl", "german girl", "russian girl", "euro porn"], + "cuckold": ["cuckold", "hotwife", "wife watching", "cuck"], + "swingers": ["swingers", "swinging couple", "wife swap"], + "gloryhole": ["gloryhole", "glory hole"], + "pissing": ["pissing", "watersports"], + "bbw": ["bbw", "chubby", "plus size"], +} + + +def _build_pattern() -> tuple[re.Pattern[str], list[tuple[int, str]]]: + """Buduje 1 regex z named groups dla wszystkich phrasów. Match O(N) na tytułu. + + Phrasa multiword: spacje jako `[\\s_-]+` (tolerancja na separator). Single word: + word boundary `\\b...\\b`. Słownik: `pattern → slug` żeby zmapować named groups + z powrotem na slugi. + """ + parts: list[str] = [] + pattern_to_slug: list[tuple[int, str]] = [] + idx = 0 + for slug, phrases in TAG_PHRASES.items(): + for phrase in phrases: + words = phrase.split() + if len(words) == 1: + # \b<word>\b — działa też dla słów zaczynających/kończących się na non-alnum. + pat = rf"\b{re.escape(words[0])}\b" + else: + pat = r"\b" + r"[\s_-]+".join(re.escape(w) for w in words) + r"\b" + parts.append(f"(?P<g{idx}>{pat})") + pattern_to_slug.append((idx, slug)) + idx += 1 + combined = re.compile("|".join(parts), re.IGNORECASE) + return combined, pattern_to_slug + + +_PATTERN, _PATTERN_TO_SLUG = _build_pattern() +_GROUP_TO_SLUG = dict(_PATTERN_TO_SLUG) + + +def infer_tag_slugs(text: str | None) -> list[str]: + """Zwraca posortowaną listę unikalnych canonical tag slugów wykrytych w tytule. + + Kolejność: alfabetyczna (deterministyczne — łatwiej testować, brak wpływu na DB). + """ + if not text: + return [] + found: set[str] = set() + for m in _PATTERN.finditer(text): + for name, value in m.groupdict().items(): + if value is None: + continue + idx = int(name[1:]) # 'g42' → 42 + slug = _GROUP_TO_SLUG.get(idx) + if slug: + found.add(slug) + return sorted(found) + + +__all__ = ["infer_tag_slugs", "TAG_PHRASES"] diff --git a/app/normalize/text.py b/app/normalize/text.py new file mode 100644 index 0000000..09b1652 --- /dev/null +++ b/app/normalize/text.py @@ -0,0 +1,61 @@ +"""Normalizacja tekstu używana wszędzie w pipeline'ie matchingu. + +Cel: dwie formy tej samej nazwy (różne casing, akcenty, interpunkcja, whitespace, „the"/myślniki) +muszą produkować identyczny string po `normalize`. Trgram index na tych polach robi resztę. +""" +from __future__ import annotations + +import re +import unicodedata + +from slugify import slugify as _slugify + +_NON_ASCII_ALNUM_RE = re.compile(r"[^a-z0-9\s]+") +_WS_RE = re.compile(r"\s+") +_LEADING_ARTICLE_RE = re.compile(r"^(the|a|an)\s+", flags=re.IGNORECASE) + +# Litery których NFKD nie rozkłada na ASCII — mapujemy ręcznie. +_EXTRA_TRANSLIT = str.maketrans( + { + "Ł": "L", "ł": "l", + "Ø": "O", "ø": "o", + "Æ": "AE", "æ": "ae", + "Œ": "OE", "œ": "oe", + "Þ": "Th", "þ": "th", + "Ð": "D", "ð": "d", + "Đ": "D", "đ": "d", + "ß": "ss", + "Ħ": "H", "ħ": "h", + } +) + + +def strip_accents(value: str) -> str: + pre = value.translate(_EXTRA_TRANSLIT) + nfkd = unicodedata.normalize("NFKD", pre) + return "".join(c for c in nfkd if not unicodedata.combining(c)) + + +def normalize(value: str | None) -> str: + """Lower + strip accents + remove punctuation + collapse whitespace + drop leading article.""" + if not value: + return "" + out = strip_accents(value).lower() + out = _NON_ASCII_ALNUM_RE.sub(" ", out) + out = _WS_RE.sub(" ", out).strip() + out = _LEADING_ARTICLE_RE.sub("", out) + return out + + +def normalize_person(value: str | None) -> str: + """Normalizacja imion: jak `normalize`, plus zwija inicjały „M." → „m".""" + if not value: + return "" + out = normalize(value) + return _WS_RE.sub(" ", out).strip() + + +def slugify(value: str | None) -> str: + if not value: + return "" + return _slugify(value, lowercase=True, max_length=200) diff --git a/app/resolve/__init__.py b/app/resolve/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/resolve/movie_match.py b/app/resolve/movie_match.py new file mode 100644 index 0000000..d6ca731 --- /dev/null +++ b/app/resolve/movie_match.py @@ -0,0 +1,60 @@ +"""Wyszukiwanie kandydatów do dedup movies — blocking + fuzzy title prefilter. + +Strategia: nie chcemy O(N) score'ować wszystkich filmów dla każdego nowego. +Blocking: kandydat musi mieć title trigram similarity ≥0.4 (pg_trgm) z incoming title, +ALBO (same studio AND year w oknie ±1). Plus pierwsze X (np. 50) filmów z każdego +zbioru — wystarczająco szeroko żeby nie zgubić matchów, dostatecznie wąsko żeby +score'ować szybko. +""" +from __future__ import annotations + +import uuid +from datetime import date + +from sqlalchemy import or_, select +from sqlalchemy.orm import Session + +from app.models.movie import Movie + + +def find_movie_candidates( + session: Session, + *, + title_normalized: str, + studio_id: uuid.UUID | None, + release_year: int | None, + limit: int = 50, +) -> list[Movie]: + """Zwraca kandydatów do score'owania. + + Trigram threshold: pg_trgm `%` operator domyślnie ma similarity ≥0.3 — zniżamy + do 0.4 dla jakości. Plus secondary blocking po (studio_id, year ±1) jako safety + net dla SEO tytułów (np. mangoporn często ma "Watch Cece adult..." vs paradisehill + "Cece" — trigram złapie, ale gdyby nie, studio+year wystarczają). + """ + from sqlalchemy import literal + + q = session.execute( + select(Movie) + .where( + or_( + # pg_trgm `%` — wymagamy minimum bound 0.4 (set per-session pewnie low default). + # Bezpieczniej użyć similarity() funkcji bezpośrednio z literalnym threshold. + Movie.title_normalized.op("%")(literal(title_normalized)), + # Studio + year ±1 jako fallback blocking + _studio_year_block(studio_id, release_year), + ) + ) + .limit(limit) + ).scalars() + return list(q) + + +def _studio_year_block(studio_id: uuid.UUID | None, release_year: int | None): + from sqlalchemy import and_, false + if studio_id is None or release_year is None: + return false() + return and_( + Movie.studio_id == studio_id, + Movie.release_year.between(release_year - 1, release_year + 1), + ) diff --git a/app/resolve/movie_resolver.py b/app/resolve/movie_resolver.py new file mode 100644 index 0000000..661a068 --- /dev/null +++ b/app/resolve/movie_resolver.py @@ -0,0 +1,372 @@ +"""Resolver movies — MVP (M2): same-source ref + create canonical. + +Composite fuzzy dedup (mirrory) wjeżdża w M5 — `_score_movie_candidate` + triage do +`merge_candidates(kind=movie)`. Na tym etapie paradisehill jest jedynym źródłem, +więc same-source path 1 wystarcza. +""" +from __future__ import annotations + +import logging +import uuid +from dataclasses import dataclass + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.merge_candidate import MergeCandidate, MergeKind, MergeStatus +from app.models.movie import ( + Movie, + MovieChapter, + MovieExternalRef, + MoviePerformer, + MovieTag, +) +from app.models.movie_playback_source import MoviePlaybackSource +from app.models.performer import Performer +from app.normalize.movies import NormalizedMovie +from app.normalize.text import slugify +from app.resolve.movie_match import find_movie_candidates +from app.resolve.movie_score import ( + MovieScoreBreakdown, + score_movie_candidate, + triage_movie, +) +from app.resolve.performer_resolver import resolve_performer +from app.resolve.studio_resolver import resolve_studio +from app.resolve.tag_resolver import resolve_tag + +log = logging.getLogger(__name__) + + +@dataclass +class MovieResolveResult: + movie: Movie + was_created: bool + path: str # 'same_source' | 'composite_auto' | 'composite_review' | 'new' + score: float | None = None + candidate_id: uuid.UUID | None = None + + +def resolve_movie( + session: Session, + *, + norm: NormalizedMovie, + source_id: uuid.UUID, +) -> MovieResolveResult: + studio = resolve_studio(session, norm=norm.studio, source_id=source_id) if norm.studio else None + studio_id = studio.id if studio else None + + # Path 1: same-source external_ref + existing_ref = session.execute( + select(MovieExternalRef).where( + MovieExternalRef.source_id == source_id, + MovieExternalRef.external_id == norm.external_id, + ) + ).scalar_one_or_none() + if existing_ref is not None: + movie = session.get(Movie, existing_ref.movie_id) + if movie is not None: + _update_movie_fields(movie, norm, studio_id=studio_id) + _sync_attached_entities(session, movie=movie, norm=norm, source_id=source_id) + return MovieResolveResult(movie=movie, was_created=False, path="same_source") + + # Pre-resolve performerów (potrzebne dla cast Jaccard score) + resolved_performers: list[tuple[uuid.UUID, str | None]] = [] + for p_norm in norm.performers: + performer = resolve_performer(session, norm=p_norm, source_id=source_id) + resolved_performers.append((performer.id, p_norm.as_alias_in_scene)) + performer_ids = [pid for pid, _ in resolved_performers] + + # Path 2: composite fuzzy + candidates = find_movie_candidates( + session, + title_normalized=norm.title_normalized, + studio_id=studio_id, + release_year=norm.release_year, + ) + best_movie: Movie | None = None + best_breakdown: MovieScoreBreakdown | None = None + for cand in candidates: + breakdown = score_movie_candidate( + session, + candidate=cand, + norm_title_normalized=norm.title_normalized, + norm_release_year=norm.release_year, + norm_studio_id=studio_id, + norm_performer_ids=performer_ids, + ) + if best_breakdown is None or breakdown.composite > best_breakdown.composite: + best_movie = cand + best_breakdown = breakdown + + if best_movie is not None and best_breakdown is not None: + decision = triage_movie(best_breakdown.composite) + if decision == "auto": + _update_movie_fields(best_movie, norm, studio_id=studio_id) + _attach_external_ref(session, movie_id=best_movie.id, source_id=source_id, norm=norm) + _sync_performers(session, movie_id=best_movie.id, resolved=resolved_performers) + _sync_tags(session, movie_id=best_movie.id, norm=norm, source_id=source_id) + _sync_chapters(session, movie_id=best_movie.id, norm=norm) + _sync_playback_sources(session, movie_id=best_movie.id, norm=norm) + log.info( + "movie auto-merge: %s ← incoming '%s' (score=%.2f)", + best_movie.id, norm.title, best_breakdown.composite, + ) + return MovieResolveResult( + movie=best_movie, + was_created=False, + path="composite_auto", + score=best_breakdown.composite, + ) + if decision == "review": + new_movie = _create_canonical(session, norm=norm, studio_id=studio_id) + _attach_external_ref(session, movie_id=new_movie.id, source_id=source_id, norm=norm) + _sync_performers(session, movie_id=new_movie.id, resolved=resolved_performers) + _sync_tags(session, movie_id=new_movie.id, norm=norm, source_id=source_id) + _sync_chapters(session, movie_id=new_movie.id, norm=norm) + _sync_playback_sources(session, movie_id=new_movie.id, norm=norm) + session.add( + MergeCandidate( + kind=MergeKind.movie, + left_id=best_movie.id, + right_id=new_movie.id, + score=best_breakdown.composite, + reasons={"path": "composite", **best_breakdown.to_dict()}, + status=MergeStatus.pending, + ) + ) + return MovieResolveResult( + movie=new_movie, + was_created=True, + path="composite_review", + score=best_breakdown.composite, + candidate_id=best_movie.id, + ) + + # Brak match → nowa kanoniczna + movie = _create_canonical(session, norm=norm, studio_id=studio_id) + _attach_external_ref(session, movie_id=movie.id, source_id=source_id, norm=norm) + _sync_performers(session, movie_id=movie.id, resolved=resolved_performers) + _sync_tags(session, movie_id=movie.id, norm=norm, source_id=source_id) + _sync_chapters(session, movie_id=movie.id, norm=norm) + _sync_playback_sources(session, movie_id=movie.id, norm=norm) + return MovieResolveResult(movie=movie, was_created=True, path="new") + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + +def _create_canonical( + session: Session, *, norm: NormalizedMovie, studio_id: uuid.UUID | None +) -> Movie: + movie = Movie( + title=norm.title, + title_normalized=norm.title_normalized, + slug=norm.slug or slugify(norm.title), + release_year=norm.release_year, + release_date=norm.release_date, + studio_id=studio_id, + director=norm.director, + country=norm.country, + duration_sec=norm.duration_sec, + description=norm.description, + poster_url=norm.poster_url, + backdrop_url=norm.backdrop_url, + rating=norm.rating, + ) + session.add(movie) + session.flush() + return movie + + +def _update_movie_fields( + movie: Movie, norm: NormalizedMovie, *, studio_id: uuid.UUID | None +) -> None: + """Fill-in only — paradisehill jest primary, więc nie nadpisujemy ustawionych pól. + Gdy doszłaby konkurencja źródeł (M5), dodamy source-rank logic jak w scene_resolver.""" + if norm.title and not movie.title: + movie.title = norm.title + movie.title_normalized = norm.title_normalized + if norm.slug and not movie.slug: + movie.slug = norm.slug + if norm.release_year and not movie.release_year: + movie.release_year = norm.release_year + if norm.release_date and not movie.release_date: + movie.release_date = norm.release_date + if studio_id and not movie.studio_id: + movie.studio_id = studio_id + if norm.director and not movie.director: + movie.director = norm.director + if norm.country and not movie.country: + movie.country = norm.country + if norm.duration_sec and not movie.duration_sec: + movie.duration_sec = norm.duration_sec + if norm.description and not movie.description: + movie.description = norm.description + if norm.poster_url and not movie.poster_url: + movie.poster_url = norm.poster_url + if norm.backdrop_url and not movie.backdrop_url: + movie.backdrop_url = norm.backdrop_url + if norm.rating is not None and movie.rating is None: + movie.rating = norm.rating + + +def _attach_external_ref( + session: Session, *, movie_id: uuid.UUID, source_id: uuid.UUID, norm: NormalizedMovie +) -> None: + existing = session.execute( + select(MovieExternalRef).where( + MovieExternalRef.source_id == source_id, + MovieExternalRef.external_id == norm.external_id, + ) + ).scalar_one_or_none() + if existing is None: + session.add( + MovieExternalRef( + source_id=source_id, + external_id=norm.external_id, + movie_id=movie_id, + confidence=1.0, + url=norm.url, + ) + ) + elif norm.url and not existing.url: + existing.url = norm.url + + +def _sync_attached_entities( + session: Session, *, movie: Movie, norm: NormalizedMovie, source_id: uuid.UUID +) -> None: + resolved: list[tuple[uuid.UUID, str | None]] = [] + for p_norm in norm.performers: + performer = resolve_performer(session, norm=p_norm, source_id=source_id) + resolved.append((performer.id, p_norm.as_alias_in_scene)) + _sync_performers(session, movie_id=movie.id, resolved=resolved) + _sync_tags(session, movie_id=movie.id, norm=norm, source_id=source_id) + _sync_chapters(session, movie_id=movie.id, norm=norm) + _sync_playback_sources(session, movie_id=movie.id, norm=norm) + + +def _sync_performers( + session: Session, + *, + movie_id: uuid.UUID, + resolved: list[tuple[uuid.UUID, str | None]], +) -> None: + seen_ids: set[uuid.UUID] = set() + for position, (performer_id, as_alias) in enumerate(resolved): + if performer_id in seen_ids: + continue + seen_ids.add(performer_id) + existing = session.execute( + select(MoviePerformer).where( + MoviePerformer.movie_id == movie_id, + MoviePerformer.performer_id == performer_id, + ) + ).scalar_one_or_none() + if existing is None: + if session.get(Performer, performer_id) is None: + continue + session.add( + MoviePerformer( + movie_id=movie_id, + performer_id=performer_id, + position=position, + as_alias=as_alias, + ) + ) + elif as_alias and not existing.as_alias: + existing.as_alias = as_alias + + +def _sync_tags( + session: Session, + *, + movie_id: uuid.UUID, + norm: NormalizedMovie, + source_id: uuid.UUID, +) -> None: + # PostgreSQL INSERT ... ON CONFLICT DO NOTHING dla race-safe insert. + # Bez tego concurrent movie ingests rzucały IntegrityError pk_movie_tags + # (GOON-M, analogicznie do scene_tags GOON-H). + from sqlalchemy.dialects.postgresql import insert as pg_insert + seen_tag_ids: set[uuid.UUID] = set() + for t_norm in norm.tags: + tag = resolve_tag(session, norm=t_norm) + if tag is None or tag.id in seen_tag_ids: + continue + seen_tag_ids.add(tag.id) + stmt = ( + pg_insert(MovieTag.__table__) + .values(movie_id=movie_id, tag_id=tag.id, source_id=source_id) + .on_conflict_do_nothing(index_elements=["movie_id", "tag_id"]) + ) + session.execute(stmt) + + +def _sync_chapters( + session: Session, *, movie_id: uuid.UUID, norm: NormalizedMovie +) -> None: + """Idempotent: po `chapter_index`. Nowy chapter dodaje, istniejący update'uje pola + ale nie kasuje nieprzysłanych — caller upserchuje co ma.""" + for raw_ch in norm.chapters: + existing = session.execute( + select(MovieChapter).where( + MovieChapter.movie_id == movie_id, + MovieChapter.chapter_index == raw_ch.chapter_index, + ) + ).scalar_one_or_none() + if existing is None: + session.add( + MovieChapter( + movie_id=movie_id, + chapter_index=raw_ch.chapter_index, + title=raw_ch.title, + start_sec=raw_ch.start_sec, + end_sec=raw_ch.end_sec, + ) + ) + else: + if raw_ch.title and not existing.title: + existing.title = raw_ch.title + if raw_ch.start_sec is not None and existing.start_sec is None: + existing.start_sec = raw_ch.start_sec + if raw_ch.end_sec is not None and existing.end_sec is None: + existing.end_sec = raw_ch.end_sec + + +def _sync_playback_sources( + session: Session, *, movie_id: uuid.UUID, norm: NormalizedMovie +) -> None: + """Upsert po (origin, page_url) — unique constraint w tabeli.""" + for pb in norm.playback_sources: + existing = session.execute( + select(MoviePlaybackSource).where( + MoviePlaybackSource.origin == pb.origin, + MoviePlaybackSource.page_url == pb.page_url, + ) + ).scalar_one_or_none() + if existing is None: + session.add( + MoviePlaybackSource( + movie_id=movie_id, + origin=pb.origin, + page_url=pb.page_url, + embed_url=pb.embed_url, + stream_url=pb.stream_url, + quality=pb.quality, + duration_sec=pb.duration_sec, + thumbnail_url=pb.thumbnail_url, + animated_thumbnail_url=pb.animated_thumbnail_url, + ) + ) + else: + if pb.embed_url and not existing.embed_url: + existing.embed_url = pb.embed_url + if pb.stream_url and not existing.stream_url: + existing.stream_url = pb.stream_url + if pb.thumbnail_url and not existing.thumbnail_url: + existing.thumbnail_url = pb.thumbnail_url + if pb.animated_thumbnail_url and not existing.animated_thumbnail_url: + existing.animated_thumbnail_url = pb.animated_thumbnail_url diff --git a/app/resolve/movie_score.py b/app/resolve/movie_score.py new file mode 100644 index 0000000..21d4df4 --- /dev/null +++ b/app/resolve/movie_score.py @@ -0,0 +1,155 @@ +"""Composite scoring dla movies — title + year + studio + cast Jaccard. + +Movies vs Scenes scoring: +- Movies rzadko mają fingerprinty — tytuł jest najsilniejszym sygnałem (weight 0.5). +- Year (production year) ważny ale czasem nieznany (paradisehill często NULL); wagę + redystrybuujemy proporcjonalnie gdy brak. +- Studio: binary signal — match=1.0, mismatch=0.0; gdy któraś strona ma NULL, sygnał + pomijamy (None waga). +- Cast: Jaccard po canonical UUID-ach performerów. Bonus signal — niektóre source'y + (paradisehill często, dooplay zawsze) mają cast; tube'y bez cast → None. + +Threshold (lower niż scenes — title dla movies ma niższy false-positive rate): + ≥0.85 auto-merge + 0.65-0.85 review + <0.65 nowy kanon +""" +from __future__ import annotations + +import uuid +from collections.abc import Iterable +from dataclasses import dataclass + +from rapidfuzz import fuzz +from sqlalchemy.orm import Session + +from app.models.movie import Movie, MoviePerformer + + +@dataclass +class MovieScoreBreakdown: + title: float | None = None + year: float | None = None + studio_match: bool | None = None + cast: float | None = None + composite: float = 0.0 + reasons: dict | None = None + + def to_dict(self) -> dict: + return { + "title": self.title, + "year": self.year, + "studio_match": self.studio_match, + "cast": self.cast, + "composite": self.composite, + "reasons": self.reasons or {}, + } + + +_BASE_WEIGHTS = { + "title": 0.50, + "year": 0.20, + "studio": 0.20, + "cast": 0.10, +} + +# Movies-specific thresholds (lower niż scenes ze względu na unique titles) +AUTO_THRESHOLD = 0.85 +REVIEW_THRESHOLD = 0.65 + + +def score_movie_candidate( + session: Session, + *, + candidate: Movie, + norm_title_normalized: str, + norm_release_year: int | None, + norm_studio_id: uuid.UUID | None, + norm_performer_ids: Iterable[uuid.UUID], +) -> MovieScoreBreakdown: + """Liczy score dopasowania incoming → candidate. Wszystko w [0,1].""" + breakdown = MovieScoreBreakdown(reasons={}) + + # Title (always) + if candidate.title_normalized and norm_title_normalized: + breakdown.title = fuzz.token_set_ratio( + candidate.title_normalized, norm_title_normalized + ) / 100.0 + + # Year — exact lub ±1 = wysoka, ±2 = średnia + if candidate.release_year is not None and norm_release_year is not None: + delta = abs(candidate.release_year - norm_release_year) + if delta == 0: + breakdown.year = 1.0 + elif delta == 1: + breakdown.year = 0.7 + elif delta == 2: + breakdown.year = 0.4 + else: + breakdown.year = 0.0 + + # Studio match — binary + if candidate.studio_id and norm_studio_id: + breakdown.studio_match = candidate.studio_id == norm_studio_id + + # Cast Jaccard — gdy obie strony mają performerów + norm_perf_set = {str(pid) for pid in norm_performer_ids if pid is not None} + if norm_perf_set: + cand_perf_rows = session.execute( + MoviePerformer.__table__.select().where( + MoviePerformer.movie_id == candidate.id + ) + ).fetchall() + cand_perf_set = {str(r.performer_id) for r in cand_perf_rows} + if cand_perf_set: + inter = norm_perf_set & cand_perf_set + union = norm_perf_set | cand_perf_set + breakdown.cast = len(inter) / len(union) if union else 0.0 + + # Composite — redystrybuuj wagi po dostępnych sygnałach + available: dict[str, float] = {} + if breakdown.title is not None: + available["title"] = breakdown.title + if breakdown.year is not None: + available["year"] = breakdown.year + if breakdown.studio_match is not None: + available["studio"] = 1.0 if breakdown.studio_match else 0.0 + if breakdown.cast is not None: + available["cast"] = breakdown.cast + + if not available: + breakdown.composite = 0.0 + breakdown.reasons["no_signals"] = True + return breakdown + + weights = {k: _BASE_WEIGHTS[k] for k in available} + total_w = sum(weights.values()) + if total_w == 0.0: + breakdown.composite = 0.0 + return breakdown + norm_w = {k: w / total_w for k, w in weights.items()} + score = sum(available[k] * norm_w[k] for k in available) + + # Hard reject: studio mismatch + year mismatch (delta ≥3) → różne filmy z podobnym + # tytułem. "Tushy Anal Bliss 7" vs "Anal Bliss 7" z innego studia rok różny — to NIE + # jest mirror, to różne wydania. + if ( + breakdown.studio_match is False + and breakdown.year is not None + and breakdown.year < 0.4 + ): + breakdown.reasons["studio_year_mismatch_reject"] = True + score = 0.0 + + breakdown.composite = max(0.0, min(1.0, score)) + breakdown.reasons["weights"] = norm_w + breakdown.reasons["sub_scores"] = available + return breakdown + + +def triage_movie(score: float) -> str: + if score >= AUTO_THRESHOLD: + return "auto" + if score >= REVIEW_THRESHOLD: + return "review" + return "reject" diff --git a/app/resolve/performer_resolver.py b/app/resolve/performer_resolver.py new file mode 100644 index 0000000..252f900 --- /dev/null +++ b/app/resolve/performer_resolver.py @@ -0,0 +1,145 @@ +"""M2: prosty resolver performera. + +Ścieżki: + 1. exact external_ref dla source → update. + 2. name_normalized match w `performers` → reuse + dopnij external_ref. + 3. alias_normalized match w `performer_aliases` → reuse parent + external_ref. + 4. insert nowego. + +(Fuzzy / triage merge_candidates: M8.) +""" +from __future__ import annotations + +import logging +import uuid + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.performer import Performer, PerformerAlias, PerformerExternalRef +from app.normalize.scenes import NormalizedPerformer +from app.normalize.text import slugify + +log = logging.getLogger(__name__) + + +def resolve_performer( + session: Session, + *, + norm: NormalizedPerformer, + source_id: uuid.UUID, +) -> Performer: + if norm.external_id: + ref = session.execute( + select(PerformerExternalRef).where( + PerformerExternalRef.source_id == source_id, + PerformerExternalRef.external_id == norm.external_id, + ) + ).scalar_one_or_none() + if ref is not None: + performer = session.get(Performer, ref.performer_id) + assert performer is not None + _update_performer_fields(performer, norm) + _ensure_aliases(session, performer.id, norm, source_id) + return performer + + # name_normalized i alias_normalized nie mają unique constraint w schemacie — historycznie + # się zdarzają duplikaty (np. ten sam name_normalized dla różnych Performer rows po niefortunych + # ingestach). Bierzemy pierwszy stabilny match (po id) zamiast wybuchać. + performer = session.execute( + select(Performer) + .where(Performer.name_normalized == norm.name_normalized) + .order_by(Performer.id) + .limit(1) + ).scalars().first() + + if performer is None: + alias_match = session.execute( + select(PerformerAlias) + .where(PerformerAlias.alias_normalized == norm.name_normalized) + .order_by(PerformerAlias.performer_id) + .limit(1) + ).scalars().first() + if alias_match is not None: + performer = session.get(Performer, alias_match.performer_id) + + if performer is None: + performer = Performer( + canonical_name=norm.canonical_name, + name_normalized=norm.name_normalized, + slug=_unique_slug(session, norm.slug or slugify(norm.canonical_name) or "performer"), + gender=norm.gender, + birth_date=norm.birth_date, + country=norm.country, + ) + session.add(performer) + session.flush() + log.debug("performer create id=%s name=%s", performer.id, performer.canonical_name) + else: + _update_performer_fields(performer, norm) + + if norm.external_id: + existing_ref = session.execute( + select(PerformerExternalRef).where( + PerformerExternalRef.source_id == source_id, + PerformerExternalRef.external_id == norm.external_id, + ) + ).scalar_one_or_none() + if existing_ref is None: + session.add( + PerformerExternalRef( + source_id=source_id, + external_id=norm.external_id, + performer_id=performer.id, + confidence=1.0, + ) + ) + + _ensure_aliases(session, performer.id, norm, source_id) + return performer + + +def _update_performer_fields(performer: Performer, norm: NormalizedPerformer) -> None: + if norm.gender and not performer.gender: + performer.gender = norm.gender + if norm.birth_date and not performer.birth_date: + performer.birth_date = norm.birth_date + if norm.country and not performer.country: + performer.country = norm.country + + +def _ensure_aliases( + session: Session, + performer_id: uuid.UUID, + norm: NormalizedPerformer, + source_id: uuid.UUID, +) -> None: + seen: set[str] = set() + for alias, alias_norm in zip(norm.aliases, norm.aliases_normalized, strict=True): + if not alias_norm or alias_norm in seen or alias_norm == norm.name_normalized: + continue + seen.add(alias_norm) + existing = session.execute( + select(PerformerAlias.id).where( + PerformerAlias.performer_id == performer_id, + PerformerAlias.alias_normalized == alias_norm, + ) + ).first() + if existing is None: + session.add( + PerformerAlias( + performer_id=performer_id, + alias=alias, + alias_normalized=alias_norm, + source_id=source_id, + ) + ) + + +def _unique_slug(session: Session, base: str) -> str: + candidate = base + n = 1 + while session.execute(select(Performer.id).where(Performer.slug == candidate)).first(): + n += 1 + candidate = f"{base}-{n}" + return candidate diff --git a/app/resolve/scene_match.py b/app/resolve/scene_match.py new file mode 100644 index 0000000..d8f7b1e --- /dev/null +++ b/app/resolve/scene_match.py @@ -0,0 +1,167 @@ +"""Helpery do znajdowania kandydatów scen w bazie (paths 1-4 resolvera).""" +from __future__ import annotations + +import uuid +from datetime import date, timedelta + +from sqlalchemy import and_, or_, select +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.models.scene import Scene, SceneExternalRef, SceneFingerprint +from app.models.source import Source +from app.resolve.scoring import hamming_distance_hex + + +def find_by_external_ref( + session: Session, *, source_id: uuid.UUID, external_id: str +) -> Scene | None: + """Path 1: ten sam (source, external_id) widziany już wcześniej.""" + ref = session.execute( + select(SceneExternalRef).where( + SceneExternalRef.source_id == source_id, + SceneExternalRef.external_id == external_id, + ) + ).scalar_one_or_none() + if ref is None: + return None + return session.get(Scene, ref.scene_id) + + +def find_by_cross_source_refs( + session: Session, *, refs: dict[str, str] +) -> tuple[Scene, str] | None: + """Path 2: cross-source UUID. `refs` = {source_name: external_id}. + + Zwraca (Scene, source_name_via_which_matched). Pierwszy match wygrywa. + """ + if not refs: + return None + sources = ( + session.execute(select(Source).where(Source.name.in_(list(refs)))) + .scalars() + .all() + ) + by_name = {s.name: s for s in sources} + for source_name, external_id in refs.items(): + src = by_name.get(source_name) + if src is None: + continue + ref = session.execute( + select(SceneExternalRef).where( + SceneExternalRef.source_id == src.id, + SceneExternalRef.external_id == external_id, + ) + ).scalar_one_or_none() + if ref is not None: + scene = session.get(Scene, ref.scene_id) + if scene is not None: + return scene, source_name + return None + + +def find_by_fingerprint_exact( + session: Session, *, kind: str, value: str +) -> Scene | None: + """Path 3a: oshash / md5 — exact match.""" + row = session.execute( + select(SceneFingerprint.scene_id) + .where(SceneFingerprint.kind == kind, SceneFingerprint.value == value) + .limit(1) + ).scalar_one_or_none() + if row is None: + return None + return session.get(Scene, row) + + +def find_by_phash_within( + session: Session, + *, + phash: str, + max_hamming: int | None = None, +) -> tuple[Scene, int] | None: + """Path 3b: pHash w obrębie max_hamming (Hamming distance bitów hex). + + Implementacja: seq scan po wszystkich phashach. Akceptowalne dla self-hosted + rzędu 10⁵ scen; przy 10⁶+ można dodać locality-sensitive index (BK-tree, MinHash). + Zwraca (Scene, distance) dla najbliższego matcha ≤ max_hamming, albo None. + """ + if max_hamming is None: + max_hamming = get_settings().fingerprint_hamming_max + + rows = session.execute( + select(SceneFingerprint.scene_id, SceneFingerprint.value).where( + SceneFingerprint.kind == "phash" + ) + ).all() + + best: tuple[uuid.UUID, int] | None = None + target_len = len(phash) + for scene_id, value in rows: + if len(value) != target_len: + continue + try: + d = hamming_distance_hex(phash, value) + except ValueError: + continue + if d <= max_hamming and (best is None or d < best[1]): + best = (scene_id, d) + if d == 0: + break + if best is None: + return None + scene = session.get(Scene, best[0]) + if scene is None: + return None + return scene, best[1] + + +def find_blocking_candidates( + session: Session, + *, + studio_id: uuid.UUID | None, + release_date: date | None, + window_days: int | None = None, + title_normalized: str | None = None, + limit: int = 50, +) -> list[Scene]: + """Path 4 blocking: zawęża space scen do potencjalnych kandydatów. + + Reguły: + - jeśli mamy studio + date → studio_id == X AND date BETWEEN ±window_days + - jeśli mamy tylko date → date BETWEEN ±window_days + - jeśli mamy tylko studio → studio_id == X + - dodatkowo, jeśli `title_normalized` podany, OR-uj exact title match + (przydaje się gdy date/studio brakuje) + """ + if window_days is None: + window_days = get_settings().date_window_days + + conds = [] + if studio_id is not None and release_date is not None: + conds.append( + and_( + Scene.studio_id == studio_id, + Scene.release_date.is_not(None), + Scene.release_date >= release_date - timedelta(days=window_days), + Scene.release_date <= release_date + timedelta(days=window_days), + ) + ) + elif release_date is not None: + conds.append( + and_( + Scene.release_date >= release_date - timedelta(days=window_days), + Scene.release_date <= release_date + timedelta(days=window_days), + ) + ) + elif studio_id is not None: + conds.append(Scene.studio_id == studio_id) + + if title_normalized: + conds.append(Scene.title_normalized == title_normalized) + + if not conds: + return [] + + stmt = select(Scene).where(or_(*conds)).limit(limit) + return list(session.execute(stmt).scalars().all()) diff --git a/app/resolve/scene_merge.py b/app/resolve/scene_merge.py new file mode 100644 index 0000000..59bcc92 --- /dev/null +++ b/app/resolve/scene_merge.py @@ -0,0 +1,224 @@ +"""Scalanie dwóch scen kanonicznych w jedną (admin merge). + +`keep_id` przejmuje wszystko od `drop_id`: + - external_refs (ze zmianą scene_id na keep) + - scene_performers (z deduplikacją na (scene_id, performer_id)) + - scene_tags + - scene_fingerprints +Następnie `drop` Scene jest usuwana — CASCADE i tak by wyczyściło reszta, ale +relacje i tak przepinamy do `keep`, a nie kasujemy razem ze sceną. + +Pending merge_candidates referencjonujące `drop_id` (left lub right) są kasowane +żeby admin nie musiał ich ponownie rozstrzygać. +""" +from __future__ import annotations + +import logging +import uuid +from datetime import UTC, datetime + +from sqlalchemy import or_, select, update +from sqlalchemy.orm import Session + +from app.models.merge_candidate import MergeCandidate, MergeKind, MergeStatus +from app.models.scene import ( + Scene, + SceneExternalRef, + SceneFingerprint, + ScenePerformer, + SceneTag, +) + +log = logging.getLogger(__name__) + + +class MergeError(Exception): + pass + + +def merge_scenes( + session: Session, + *, + keep_id: uuid.UUID, + drop_id: uuid.UUID, + resolved_by: str | None = None, +) -> Scene: + if keep_id == drop_id: + raise MergeError("cannot merge scene into itself") + + keep = session.get(Scene, keep_id) + drop = session.get(Scene, drop_id) + if keep is None or drop is None: + raise MergeError("scene not found") + + _move_external_refs(session, keep_id=keep_id, drop_id=drop_id) + _move_performers(session, keep_id=keep_id, drop_id=drop_id) + _move_tags(session, keep_id=keep_id, drop_id=drop_id) + _move_fingerprints(session, keep_id=keep_id, drop_id=drop_id) + _coalesce_canonical_fields(keep, drop) + + session.delete(drop) + session.flush() + + _close_pending_candidates(session, scene_id=drop_id, resolved_by=resolved_by) + return keep + + +def resolve_candidate( + session: Session, + *, + candidate_id: uuid.UUID, + action: str, # "merge" | "reject" + keep_left: bool = True, + resolved_by: str | None = None, +) -> MergeCandidate: + """Rozstrzyga jeden MergeCandidate. Dla `merge` decyzja co zostaje: + `keep_left=True` (default) → `left_id` przejmuje `right_id`.""" + cand = session.get(MergeCandidate, candidate_id) + if cand is None: + raise MergeError("candidate not found") + if cand.status != MergeStatus.pending: + raise MergeError(f"candidate already resolved: status={cand.status.value}") + if cand.kind != MergeKind.scene: + raise MergeError(f"only scene merges are supported (got {cand.kind.value})") + + now = datetime.now(UTC) + if action == "reject": + cand.status = MergeStatus.rejected + cand.resolved_at = now + cand.resolved_by = resolved_by + return cand + + if action == "merge": + keep_id, drop_id = (cand.left_id, cand.right_id) if keep_left else (cand.right_id, cand.left_id) + merge_scenes(session, keep_id=keep_id, drop_id=drop_id, resolved_by=resolved_by) + cand.status = MergeStatus.merged + cand.resolved_at = now + cand.resolved_by = resolved_by + # Update reasons z final decyzją (zachowaj poprzednie scoring data) + reasons = dict(cand.reasons or {}) + reasons["resolution"] = {"keep_id": str(keep_id), "drop_id": str(drop_id)} + cand.reasons = reasons + return cand + + raise MergeError(f"unsupported action: {action}") + + +# ---- helpery -------------------------------------------------------------- + +def _move_external_refs(session: Session, *, keep_id: uuid.UUID, drop_id: uuid.UUID) -> None: + drop_refs = ( + session.execute(select(SceneExternalRef).where(SceneExternalRef.scene_id == drop_id)) + .scalars() + .all() + ) + for ref in drop_refs: + clash = session.execute( + select(SceneExternalRef).where( + SceneExternalRef.source_id == ref.source_id, + SceneExternalRef.external_id == ref.external_id, + SceneExternalRef.scene_id == keep_id, + ) + ).scalar_one_or_none() + if clash is not None: + # Już mamy ref pod keep — usuń konkurencyjny pod drop + session.delete(ref) + else: + ref.scene_id = keep_id + + +def _move_performers(session: Session, *, keep_id: uuid.UUID, drop_id: uuid.UUID) -> None: + drop_links = ( + session.execute(select(ScenePerformer).where(ScenePerformer.scene_id == drop_id)) + .scalars() + .all() + ) + for link in drop_links: + clash = session.execute( + select(ScenePerformer).where( + ScenePerformer.scene_id == keep_id, + ScenePerformer.performer_id == link.performer_id, + ) + ).scalar_one_or_none() + if clash is not None: + if link.as_alias and not clash.as_alias: + clash.as_alias = link.as_alias + session.delete(link) + else: + link.scene_id = keep_id + + +def _move_tags(session: Session, *, keep_id: uuid.UUID, drop_id: uuid.UUID) -> None: + drop_links = ( + session.execute(select(SceneTag).where(SceneTag.scene_id == drop_id)) + .scalars() + .all() + ) + for link in drop_links: + clash = session.execute( + select(SceneTag).where( + SceneTag.scene_id == keep_id, SceneTag.tag_id == link.tag_id + ) + ).scalar_one_or_none() + if clash is not None: + session.delete(link) + else: + link.scene_id = keep_id + + +def _move_fingerprints(session: Session, *, keep_id: uuid.UUID, drop_id: uuid.UUID) -> None: + drops = ( + session.execute(select(SceneFingerprint).where(SceneFingerprint.scene_id == drop_id)) + .scalars() + .all() + ) + for fp in drops: + clash = session.execute( + select(SceneFingerprint).where( + SceneFingerprint.scene_id == keep_id, + SceneFingerprint.kind == fp.kind, + SceneFingerprint.value == fp.value, + ) + ).scalar_one_or_none() + if clash is not None: + session.delete(fp) + else: + fp.scene_id = keep_id + + +def _coalesce_canonical_fields(keep: Scene, drop: Scene) -> None: + """Wypełnij braki w `keep` polami z `drop`. Nie nadpisuje istniejących wartości.""" + if not keep.description and drop.description: + keep.description = drop.description + if not keep.duration_sec and drop.duration_sec: + keep.duration_sec = drop.duration_sec + if not keep.code and drop.code: + keep.code = drop.code + if not keep.director and drop.director: + keep.director = drop.director + if not keep.release_date and drop.release_date: + keep.release_date = drop.release_date + if not keep.studio_id and drop.studio_id: + keep.studio_id = drop.studio_id + if drop.title and len(drop.title) > len(keep.title or ""): + keep.title = drop.title + keep.title_normalized = drop.title_normalized + + +def _close_pending_candidates( + session: Session, *, scene_id: uuid.UUID, resolved_by: str | None +) -> None: + """Pending candidates referencjonujące usuniętą scenę kasujemy (status=rejected), + bo right_id już nie istnieje. Auto_merged/merged zostawiamy jako audit.""" + session.execute( + update(MergeCandidate) + .where( + MergeCandidate.status == MergeStatus.pending, + or_(MergeCandidate.left_id == scene_id, MergeCandidate.right_id == scene_id), + ) + .values( + status=MergeStatus.rejected, + resolved_at=datetime.now(UTC), + resolved_by=resolved_by or "auto:scene_dropped", + ) + ) diff --git a/app/resolve/scene_resolver.py b/app/resolve/scene_resolver.py new file mode 100644 index 0000000..8e17c9a --- /dev/null +++ b/app/resolve/scene_resolver.py @@ -0,0 +1,611 @@ +"""Resolver sceny — pełny pipeline dedup (M3). + +Ścieżki, w kolejności first-match-wins: + + 1. **same-source external_ref** — to samo (source, external_id) widziane już wcześniej + → update kanonicznej. + 2. **cross-source UUID** — scena z source A deklaruje że jest tym samym UUID + w source B (np. StashDB → tpdb_id w scene.urls). Auto-merge: dołącz nowy + external_ref do istniejącej kanonicznej + log MergeCandidate(auto_merged). + 3. **fingerprint match** — oshash exact lub phash Hamming ≤ N. + Auto-merge. + 4. **composite fuzzy** — blocking (studio + date) → score → triage: + composite ≥ auto_merge_threshold → auto-merge + ≥ review_threshold → utwórz nową kanoniczną + zapisz + MergeCandidate(pending) + < review_threshold → utwórz nową, bez kandydata + +Funkcja zwraca SceneResolveResult opisujący jak doszło do dopasowania. +""" +from __future__ import annotations + +import logging +import uuid +from dataclasses import dataclass + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.merge_candidate import MergeCandidate, MergeKind, MergeStatus +from app.models.performer import Performer +from app.models.playback_source import PlaybackSource +from app.models.scene import ( + Scene, + SceneExternalRef, + SceneFingerprint, + ScenePerformer, + SceneTag, +) +from app.models.source import Source, SourceKind +from app.normalize.scenes import NormalizedScene +from app.normalize.text import slugify +from app.resolve.performer_resolver import resolve_performer +from app.resolve.scene_match import ( + find_blocking_candidates, + find_by_cross_source_refs, + find_by_external_ref, + find_by_fingerprint_exact, + find_by_phash_within, +) +from app.resolve.scene_score import score_candidate +from app.resolve.scoring import ScoreBreakdown, triage +from app.resolve.studio_resolver import resolve_studio +from app.resolve.tag_resolver import resolve_tag + +log = logging.getLogger(__name__) + + +@dataclass +class SceneResolveResult: + scene: Scene + was_created: bool + path: str # 'same_source' | 'cross_source' | 'fp_oshash' | 'fp_phash' | 'composite_auto' | 'composite_review' | 'new' + score: float | None = None + candidate_id: uuid.UUID | None = None # competitor in pending review (path=composite_review) + + +def resolve_scene( + session: Session, + *, + norm: NormalizedScene, + source_id: uuid.UUID, +) -> SceneResolveResult: + studio = resolve_studio(session, norm=norm.studio, source_id=source_id) if norm.studio else None + studio_id = studio.id if studio else None + + # Tube/agregator (np. pornapp) → studio nie jest informatywny, performers ważniejsze. + src = session.get(Source, source_id) + source_kind = src.kind if src else SourceKind.manual + aggregator_mode = src is not None and src.kind == SourceKind.scraper + + # Path 1: same-source external_ref + existing = find_by_external_ref(session, source_id=source_id, external_id=norm.external_id) + if existing is not None: + _update_scene_fields(existing, norm, studio_id=studio_id, source_kind=source_kind, session=session) + _sync_attached_entities(session, scene=existing, norm=norm, source_id=source_id) + return SceneResolveResult(scene=existing, was_created=False, path="same_source") + + # Path 2: cross-source UUID + cross = find_by_cross_source_refs(session, refs=norm.cross_source_refs) + if cross is not None: + scene_match, via_source_name = cross + _update_scene_fields(scene_match, norm, studio_id=studio_id, source_kind=source_kind, session=session) + _attach_external_ref(session, scene_id=scene_match.id, source_id=source_id, norm=norm) + _sync_attached_entities(session, scene=scene_match, norm=norm, source_id=source_id) + _log_auto_merge( + session, + scene_id=scene_match.id, + score=1.0, + reasons={"path": "cross_source", "via_source": via_source_name}, + ) + return SceneResolveResult(scene=scene_match, was_created=False, path="cross_source", score=1.0) + + # Path 3: fingerprints + for kind, value in norm.fingerprints: + if kind == "phash": + continue # phash leci osobno żeby użyć Hamming + match = find_by_fingerprint_exact(session, kind=kind, value=value) + if match is not None: + _update_scene_fields(match, norm, studio_id=studio_id, source_kind=source_kind, session=session) + _attach_external_ref(session, scene_id=match.id, source_id=source_id, norm=norm) + _sync_attached_entities(session, scene=match, norm=norm, source_id=source_id) + _log_auto_merge( + session, + scene_id=match.id, + score=1.0, + reasons={"path": "fp_exact", "kind": kind, "value": value}, + ) + return SceneResolveResult(scene=match, was_created=False, path=f"fp_{kind}", score=1.0) + + for kind, value in norm.fingerprints: + if kind != "phash": + continue + result = find_by_phash_within(session, phash=value) + if result is not None: + scene_match, distance = result + score = 1.0 - distance / 64.0 + # Duration sanity check: phash może collide gdy compilation zawiera chapter sceny + # (oba mają ten sam frame sample), ale duration będzie wyraźnie inny. + # Wymagamy proximity ≥0.5 (±30s) dla auto-merge; inaczej → review queue. + from app.resolve.scoring import duration_proximity + dur_prox = duration_proximity(scene_match.duration_sec, norm.duration_sec) + if dur_prox is not None and dur_prox < 0.5: + # phash match ale duration rozjeżdża się → tworzymy nową scenę + review. + new_scene = _create_canonical(session, norm=norm, studio_id=studio_id) + _attach_external_ref(session, scene_id=new_scene.id, source_id=source_id, norm=norm) + _sync_attached_entities(session, scene=new_scene, norm=norm, source_id=source_id) + session.add( + MergeCandidate( + kind=MergeKind.scene, + left_id=scene_match.id, + right_id=new_scene.id, + score=score, + reasons={ + "path": "fp_phash", + "hamming": distance, + "duration_mismatch": True, + "left_dur": scene_match.duration_sec, + "right_dur": norm.duration_sec, + }, + status=MergeStatus.pending, + ) + ) + return SceneResolveResult( + scene=new_scene, + was_created=True, + path="fp_phash_review", + score=score, + candidate_id=scene_match.id, + ) + _update_scene_fields(scene_match, norm, studio_id=studio_id, source_kind=source_kind, session=session) + _attach_external_ref(session, scene_id=scene_match.id, source_id=source_id, norm=norm) + _sync_attached_entities(session, scene=scene_match, norm=norm, source_id=source_id) + _log_auto_merge( + session, + scene_id=scene_match.id, + score=score, + reasons={"path": "fp_phash", "hamming": distance, "dur_prox": dur_prox}, + ) + return SceneResolveResult( + scene=scene_match, was_created=False, path="fp_phash", score=score + ) + + # Dedup norm.performers po external_id (lub slug jako fallback) — pornhub i + # niektóre tube connectorzy zwracają tę samą performerkę 2x (raz pod aliasem + # scenowym typu "Lilkarina2", raz canonical "Lil Karina") → bulk INSERT + # performer_external_refs hitował PK violation (Sentry GOON-C). Preferujemy + # wariant z aliasem (informacyjnie bogatszy). + _seen_perf: set[str | tuple[str, str]] = set() + _deduped_perf = [] + for p_norm in norm.performers: + key: str | tuple[str, str] = p_norm.external_id or ("slug", p_norm.slug or "") + if key in _seen_perf: + # Już mamy — jeśli poprzedni był bez aliasu a ten ma, podmień. + if p_norm.as_alias_in_scene: + for idx, existing in enumerate(_deduped_perf): + existing_key = existing.external_id or ("slug", existing.slug or "") + if existing_key == key and not existing.as_alias_in_scene: + _deduped_perf[idx] = p_norm + break + continue + _seen_perf.add(key) + _deduped_perf.append(p_norm) + norm.performers = _deduped_perf + + # Pre-resolve performerów do canonical UUID, żeby Path 4 mógł liczyć Jaccard. + resolved_performers: list[tuple[uuid.UUID, str | None]] = [] + for p_norm in norm.performers: + performer = resolve_performer(session, norm=p_norm, source_id=source_id) + resolved_performers.append((performer.id, p_norm.as_alias_in_scene)) + performer_ids = [pid for pid, _ in resolved_performers] + + # Path 4: composite fuzzy. + # W aggregator_mode studio z tube'a (np. "HQPorner") nie jest fizycznym studiem produkcyjnym, + # więc filtrowanie kandydatów po nim wyklucza wszystkie canonical sceny z TPDB/StashDB + # (które mają prawdziwe studia jak "Brazzers"). Wyłączamy studio blocking. + blocking_studio = None if aggregator_mode else studio_id + candidates = find_blocking_candidates( + session, + studio_id=blocking_studio, + release_date=norm.release_date, + title_normalized=norm.title_normalized, + ) + + # Plus: dla aggregator_mode dorzucamy jako kandydatów wszystkie canonical sceny + # które mają wspólny choć jeden performer z naszą sceną (mocny sygnał — performerzy + # to też nasz "blocking key" gdy studio i date są nieinformatywne). + if aggregator_mode and performer_ids: + from sqlalchemy import distinct + more = ( + session.execute( + select(Scene) + .join(ScenePerformer, ScenePerformer.scene_id == Scene.id) + .where(ScenePerformer.performer_id.in_(performer_ids)) + .group_by(Scene.id) + .limit(50) + ) + .scalars() + .all() + ) + seen = {c.id for c in candidates} + for c in more: + if c.id not in seen: + candidates.append(c) + + best_scene: Scene | None = None + best_breakdown: ScoreBreakdown | None = None + for cand in candidates: + breakdown = score_candidate( + session, + candidate=cand, + norm=norm, + resolved_performer_ids=performer_ids, + studio_id=studio_id, + aggregator_mode=aggregator_mode, + ) + if best_breakdown is None or breakdown.composite > best_breakdown.composite: + best_scene = cand + best_breakdown = breakdown + + if best_scene is not None and best_breakdown is not None: + decision = triage(best_breakdown.composite) + if decision == "auto": + _update_scene_fields(best_scene, norm, studio_id=studio_id, source_kind=source_kind, session=session) + _attach_external_ref(session, scene_id=best_scene.id, source_id=source_id, norm=norm) + _sync_performers(session, scene_id=best_scene.id, resolved=resolved_performers) + _sync_tags(session, scene_id=best_scene.id, norm=norm, source_id=source_id) + _sync_fingerprints( + session, scene_id=best_scene.id, norm=norm, source_id=source_id + ) + _sync_playback_sources(session, scene_id=best_scene.id, norm=norm) + _log_auto_merge( + session, + scene_id=best_scene.id, + score=best_breakdown.composite, + reasons={"path": "composite", **best_breakdown.to_dict()}, + ) + return SceneResolveResult( + scene=best_scene, + was_created=False, + path="composite_auto", + score=best_breakdown.composite, + ) + + if decision == "review": + new_scene = _create_canonical(session, norm=norm, studio_id=studio_id) + _attach_external_ref(session, scene_id=new_scene.id, source_id=source_id, norm=norm) + _sync_performers(session, scene_id=new_scene.id, resolved=resolved_performers) + _sync_tags(session, scene_id=new_scene.id, norm=norm, source_id=source_id) + _sync_fingerprints( + session, scene_id=new_scene.id, norm=norm, source_id=source_id + ) + _sync_playback_sources(session, scene_id=new_scene.id, norm=norm) + session.add( + MergeCandidate( + kind=MergeKind.scene, + left_id=best_scene.id, + right_id=new_scene.id, + score=best_breakdown.composite, + reasons={"path": "composite", **best_breakdown.to_dict()}, + status=MergeStatus.pending, + ) + ) + return SceneResolveResult( + scene=new_scene, + was_created=True, + path="composite_review", + score=best_breakdown.composite, + candidate_id=best_scene.id, + ) + + # Brak żadnego sensownego dopasowania → nowa kanoniczna + new_scene = _create_canonical(session, norm=norm, studio_id=studio_id) + _attach_external_ref(session, scene_id=new_scene.id, source_id=source_id, norm=norm) + _sync_performers(session, scene_id=new_scene.id, resolved=resolved_performers) + _sync_tags(session, scene_id=new_scene.id, norm=norm, source_id=source_id) + _sync_fingerprints(session, scene_id=new_scene.id, norm=norm, source_id=source_id) + _sync_playback_sources(session, scene_id=new_scene.id, norm=norm) + return SceneResolveResult(scene=new_scene, was_created=True, path="new") + + +# ---- helpery -------------------------------------------------------------- + +def _create_canonical( + session: Session, *, norm: NormalizedScene, studio_id: uuid.UUID | None +) -> Scene: + scene = Scene( + title=norm.title, + title_normalized=norm.title_normalized, + slug=norm.slug or slugify(norm.title), + release_date=norm.release_date, + studio_id=studio_id, + duration_sec=norm.duration_sec, + description=norm.description, + code=norm.code, + director=norm.director, + ) + session.add(scene) + session.flush() + return scene + + +def _update_scene_fields( + scene: Scene, + norm: NormalizedScene, + *, + studio_id: uuid.UUID | None, + source_kind: SourceKind, + session: Session | None = None, +) -> None: + """Aktualizuje pola kanoniczne sceny z incoming normalized scene. + + **Source rank**: + - TPDB/StashDB (canonical): mogą nadpisywać. Gdy istniejąca scena ma TYLKO + scraper external_refs (tube-rooted), canonical zawsze nadpisuje (czyści + SEO tytuły). Gdy scena już ma canonical ref, longer-wins dla title. + - Scraper/tube (pornapp): TYLKO fill-in braków, nigdy nie nadpisuje + już ustawionych pól. Tube'y mają długie SEO tytuły, które bez tego + ranga by zaśmiecały tytuły z TPDB/StashDB. + """ + is_canonical = source_kind in (SourceKind.tpdb, SourceKind.stashdb) + + # Title: canonical → overwrite (czyści tube-pollution); scraper tylko gdy pusto. + if not scene.title: + scene.title = norm.title + scene.title_normalized = norm.title_normalized + elif is_canonical: + scene_has_canonical = ( + session is not None + and _has_canonical_external_ref(session, scene_id=scene.id) + ) + if not scene_has_canonical or len(norm.title) > len(scene.title): + # Pierwszy canonical zastępuje tube SEO crap; canonical-vs-canonical longer-wins. + scene.title = norm.title + scene.title_normalized = norm.title_normalized + + if norm.slug and not scene.slug: + scene.slug = norm.slug + if norm.release_date and not scene.release_date: + scene.release_date = norm.release_date + if studio_id and not scene.studio_id: + scene.studio_id = studio_id + # Duration: canonical może doprecyzować (TPDB/StashDB lepiej to mierzą niż tube + # który czasem reportuje compilation length); scraper tylko gdy null. + if norm.duration_sec and (not scene.duration_sec or is_canonical): + scene.duration_sec = norm.duration_sec + if norm.description and not scene.description: + scene.description = norm.description + if norm.code and not scene.code: + scene.code = norm.code + if norm.director and not scene.director: + scene.director = norm.director + + +def _has_canonical_external_ref(session: Session, *, scene_id: uuid.UUID) -> bool: + """Czy scena ma już choć jeden external_ref ze źródła canonical (tpdb/stashdb)?""" + row = session.execute( + select(SceneExternalRef.source_id) + .join(Source, Source.id == SceneExternalRef.source_id) + .where( + SceneExternalRef.scene_id == scene_id, + Source.kind.in_([SourceKind.tpdb, SourceKind.stashdb]), + ) + .limit(1) + ).first() + return row is not None + + +def _attach_external_ref( + session: Session, + *, + scene_id: uuid.UUID, + source_id: uuid.UUID, + norm: NormalizedScene, +) -> None: + existing = session.execute( + select(SceneExternalRef).where( + SceneExternalRef.source_id == source_id, + SceneExternalRef.external_id == norm.external_id, + ) + ).scalar_one_or_none() + if existing is None: + session.add( + SceneExternalRef( + source_id=source_id, + external_id=norm.external_id, + scene_id=scene_id, + confidence=1.0, + url=norm.url, + ) + ) + else: + if norm.url and not existing.url: + existing.url = norm.url + + +def _sync_attached_entities( + session: Session, + *, + scene: Scene, + norm: NormalizedScene, + source_id: uuid.UUID, +) -> None: + """Zsynchronizuj performerów/tagi/fingerprinty dla istniejącej już kanonicznej sceny. + + Używane w pathach 1-3 (gdzie performerzy nie byli pre-resolved przez resolver). + """ + resolved: list[tuple[uuid.UUID, str | None]] = [] + for p_norm in norm.performers: + performer = resolve_performer(session, norm=p_norm, source_id=source_id) + resolved.append((performer.id, p_norm.as_alias_in_scene)) + _sync_performers(session, scene_id=scene.id, resolved=resolved) + _sync_tags(session, scene_id=scene.id, norm=norm, source_id=source_id) + _sync_fingerprints(session, scene_id=scene.id, norm=norm, source_id=source_id) + _sync_playback_sources(session, scene_id=scene.id, norm=norm) + + +def _sync_performers( + session: Session, + *, + scene_id: uuid.UUID, + resolved: list[tuple[uuid.UUID, str | None]], +) -> None: + # Deduplikuj — dwa różne aliasy tej samej osoby (np. "Aj Applegate" + "AJ Applegate") + # przejdą przez resolve_performer zwracając ten sam Performer.id. Bez tej dedup + # flush rzuci UNIQUE violation na pk_scene_performers (scene_id, performer_id). + seen_ids: set[uuid.UUID] = set() + deduped: list[tuple[uuid.UUID, str | None]] = [] + for pid, alias in resolved: + if pid in seen_ids: + continue + seen_ids.add(pid) + deduped.append((pid, alias)) + + for position, (performer_id, as_alias) in enumerate(deduped): + existing = session.execute( + select(ScenePerformer).where( + ScenePerformer.scene_id == scene_id, + ScenePerformer.performer_id == performer_id, + ) + ).scalar_one_or_none() + if existing is None: + if session.get(Performer, performer_id) is None: + continue + session.add( + ScenePerformer( + scene_id=scene_id, + performer_id=performer_id, + position=position, + as_alias=as_alias, + ) + ) + elif as_alias and not existing.as_alias: + existing.as_alias = as_alias + + +def _sync_tags( + session: Session, + *, + scene_id: uuid.UUID, + norm: NormalizedScene, + source_id: uuid.UUID, +) -> None: + for t_norm in norm.tags: + tag = resolve_tag(session, norm=t_norm) + if tag is None: + continue + existing = session.execute( + select(SceneTag).where( + SceneTag.scene_id == scene_id, + SceneTag.tag_id == tag.id, + ) + ).scalar_one_or_none() + if existing is None: + session.add(SceneTag(scene_id=scene_id, tag_id=tag.id, source_id=source_id)) + + +def _sync_fingerprints( + session: Session, + *, + scene_id: uuid.UUID, + norm: NormalizedScene, + source_id: uuid.UUID, +) -> None: + for kind, value in norm.fingerprints: + existing = session.execute( + select(SceneFingerprint.id).where( + SceneFingerprint.scene_id == scene_id, + SceneFingerprint.kind == kind, + SceneFingerprint.value == value, + ) + ).first() + if existing is None: + session.add( + SceneFingerprint( + scene_id=scene_id, kind=kind, value=value, source_id=source_id + ) + ) + + +def _sync_playback_sources( + session: Session, *, scene_id: uuid.UUID, norm: NormalizedScene +) -> None: + """Upsert per (origin, page_url). Bez modyfikacji existing — chyba że dorzucamy + brakujące pola (np. stream_url po późniejszym resolve).""" + from datetime import UTC, datetime + + for ps in norm.playback_sources: + if not ps.page_url: + continue + existing = session.execute( + select(PlaybackSource).where( + PlaybackSource.origin == ps.origin, + PlaybackSource.page_url == ps.page_url, + ) + ).scalar_one_or_none() + if existing is None: + session.add( + PlaybackSource( + scene_id=scene_id, + origin=ps.origin, + page_url=ps.page_url, + embed_url=ps.embed_url, + stream_url=ps.stream_url, + quality=ps.quality, + duration_sec=ps.duration_sec, + thumbnail_url=ps.thumbnail_url, + animated_thumbnail_url=ps.animated_thumbnail_url, + ) + ) + else: + # Refresh + uzupełnij braki (nigdy nie nadpisujemy istniejących wartości). + existing.last_seen_at = datetime.now(UTC) + if existing.scene_id != scene_id: + # Ten sam (origin, page_url) trafił do innej canonical sceny — to znaczy + # że dedup zmergował. Re-link do bieżącej. + existing.scene_id = scene_id + for attr in ("embed_url", "stream_url", "quality", "duration_sec", "thumbnail_url", "animated_thumbnail_url"): + if getattr(existing, attr) is None and getattr(ps, attr) is not None: + setattr(existing, attr, getattr(ps, attr)) + + +def _log_auto_merge( + session: Session, *, scene_id: uuid.UUID, score: float, reasons: dict +) -> None: + """Audit log auto-merga. left=right=scene_id (jednostronne — to nie diff dwóch + kanonicznych, tylko trace że ścieżka X przyniosła kolejny external_ref do sceny X). + """ + session.add( + MergeCandidate( + kind=MergeKind.scene, + left_id=scene_id, + right_id=scene_id, + score=score, + reasons=reasons, + status=MergeStatus.auto_merged, + ) + ) + + +def add_fingerprints( + session: Session, + *, + scene_id: uuid.UUID, + fingerprints: list[tuple[str, str]], + source_id: uuid.UUID, +) -> None: + """Compat-alias z M2 (używany przez ingest.py). Zachowuje kontrakt.""" + for kind, value in fingerprints: + existing = session.execute( + select(SceneFingerprint.id).where( + SceneFingerprint.scene_id == scene_id, + SceneFingerprint.kind == kind, + SceneFingerprint.value == value, + ) + ).first() + if existing is None: + session.add( + SceneFingerprint( + scene_id=scene_id, kind=kind, value=value, source_id=source_id + ) + ) diff --git a/app/resolve/scene_score.py b/app/resolve/scene_score.py new file mode 100644 index 0000000..d4893a4 --- /dev/null +++ b/app/resolve/scene_score.py @@ -0,0 +1,117 @@ +"""Scoring kandydat ↔ kandydat dla pipeline'u dedup.""" +from __future__ import annotations + +import uuid +from collections.abc import Iterable + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.scene import Scene, SceneFingerprint, ScenePerformer +from app.normalize.scenes import NormalizedScene +from app.resolve.scoring import ( + ScoreBreakdown, + composite_score, + date_proximity, + duration_proximity, + performer_set_similarity, + phash_similarity, + title_similarity, +) + + +def score_candidate( + session: Session, + *, + candidate: Scene, + norm: NormalizedScene, + resolved_performer_ids: Iterable[uuid.UUID], + studio_id: uuid.UUID | None, + aggregator_mode: bool = False, +) -> ScoreBreakdown: + """Liczy ScoreBreakdown dla pary (kandydat z DB, znormalizowana scena z importu). + + `aggregator_mode=True` dla scen pochodzących z tube/agregatora (np. pornapp): studio + nie jest informatywne (tube agreguje wiele studiów), performers stają się głównym + sygnałem — patrz `composite_score` szczegóły. + """ + + fp = _best_phash_similarity(session, candidate.id, norm.fingerprints) + title = title_similarity(candidate.title_normalized, norm.title_normalized) + cand_perfs = _candidate_performer_ids(session, candidate.id) + perf = performer_set_similarity(cand_perfs, list(resolved_performer_ids)) if (cand_perfs or list(resolved_performer_ids)) else None + date_score = date_proximity(candidate.release_date, norm.release_date) + duration_score = duration_proximity(candidate.duration_sec, norm.duration_sec) + + studio_match: bool | None + if studio_id is None or candidate.studio_id is None: + studio_match = None # nieinformatywne + else: + studio_match = candidate.studio_id == studio_id + + composite, reasons = composite_score( + fp=fp, + title=title, + performers=perf, + date_score=date_score if (candidate.release_date and norm.release_date) else None, + duration_score=duration_score, + studio_match=studio_match, + aggregator_mode=aggregator_mode, + ) + + breakdown = ScoreBreakdown( + fp=fp, + title=title, + performers=perf, + date=date_score, + duration=duration_score, + studio_match=studio_match, + composite=composite, + reasons=reasons, + ) + return breakdown + + +def _best_phash_similarity( + session: Session, + scene_id: uuid.UUID, + incoming_fingerprints: list[tuple[str, str]], +) -> float | None: + """Najlepsza similarity między phashami sceny w DB a incoming.""" + incoming = [v for kind, v in incoming_fingerprints if kind == "phash"] + if not incoming: + return None + existing = ( + session.execute( + select(SceneFingerprint.value).where( + SceneFingerprint.scene_id == scene_id, + SceneFingerprint.kind == "phash", + ) + ) + .scalars() + .all() + ) + if not existing: + return None + best = 0.0 + for left in incoming: + for right in existing: + if len(left) != len(right): + continue + try: + sim = phash_similarity(left, right) + except ValueError: + continue + if sim > best: + best = sim + return best + + +def _candidate_performer_ids(session: Session, scene_id: uuid.UUID) -> list[uuid.UUID]: + return list( + session.execute( + select(ScenePerformer.performer_id).where(ScenePerformer.scene_id == scene_id) + ) + .scalars() + .all() + ) diff --git a/app/resolve/scoring.py b/app/resolve/scoring.py new file mode 100644 index 0000000..c5ba020 --- /dev/null +++ b/app/resolve/scoring.py @@ -0,0 +1,274 @@ +"""Scoring funkcji do dopasowania kandydatów scen. + +Wszystkie sub-score'y wracają w [0, 1]. Composite score łączy je z wagami, +redystrybuując wagę gdy któryś sygnał jest niedostępny (np. brak fingerprintu). + +Wagi (gdy wszystko dostępne): + fp_phash: 0.40 + title: 0.25 + performers: 0.20 + date: 0.15 + +Twardy reject: studio_match=False → score 0.0 (chyba że ma silny fingerprint match +≥0.95, wtedy ufamy fingerprintowi i ignorujemy studio mismatch — bo zdarza się że +TPDB ma "Brazzers Exxtra" a StashDB "Brazzers" jako studio sceny). +""" +from __future__ import annotations + +import math +import uuid +from collections.abc import Iterable +from dataclasses import dataclass +from datetime import date + +from rapidfuzz import fuzz + +from app.config import get_settings + + +@dataclass +class ScoreBreakdown: + """Per-sub-score values + final composite + reasons (do zapisu w merge_candidates.reasons).""" + + fp: float | None = None + title: float | None = None + performers: float | None = None + date: float | None = None + duration: float | None = None + studio_match: bool | None = None + composite: float = 0.0 + reasons: dict = None # type: ignore[assignment] + + def to_dict(self) -> dict: + return { + "fp": self.fp, + "title": self.title, + "performers": self.performers, + "date": self.date, + "duration": self.duration, + "studio_match": self.studio_match, + "composite": self.composite, + "reasons": self.reasons or {}, + } + + +# ---- Sub-scorers ---------------------------------------------------------- + +def hamming_distance_hex(a: str, b: str) -> int: + """Hamming distance dwóch hex hashy o tej samej długości.""" + if len(a) != len(b): + raise ValueError(f"hash length mismatch: {len(a)} vs {len(b)}") + return bin(int(a, 16) ^ int(b, 16)).count("1") + + +def phash_similarity(a: str, b: str, *, bits: int = 64) -> float: + """Similarity = 1 - hamming/bits. Dla 64-bit phash i ≤5 różnic → ≥0.92.""" + d = hamming_distance_hex(a, b) + return max(0.0, 1.0 - d / bits) + + +def title_similarity(a: str, b: str) -> float: + """`a`, `b` powinny być już znormalizowane (`title_normalized`). + + Token-set ratio jest odporny na zmianę kolejności słów / dodatkowe tokeny. + """ + if not a or not b: + return 0.0 + return fuzz.token_set_ratio(a, b) / 100.0 + + +def performer_set_similarity( + left_ids: Iterable[uuid.UUID], + right_ids: Iterable[uuid.UUID], +) -> float: + """Jaccard na zbiorach kanonicznych UUID-ów performerów.""" + left = {str(i) for i in left_ids if i is not None} + right = {str(i) for i in right_ids if i is not None} + if not left and not right: + return 0.0 + intersection = left & right + union = left | right + if not union: + return 0.0 + return len(intersection) / len(union) + + +def date_proximity(left: date | None, right: date | None, *, window_days: int = 7) -> float: + """1.0 gdy ten sam dzień, liniowy spadek do 0 w oknie window_days, poza oknem 0.0.""" + if left is None or right is None: + return 0.0 + delta = abs((left - right).days) + if delta == 0: + return 1.0 + if delta > window_days: + return 0.0 + return 1.0 - delta / window_days + + +def duration_proximity( + left: int | None, right: int | None, *, window_sec: int = 60 +) -> float | None: + """1.0 gdy duration identyczny, liniowy spadek do 0 w oknie window_sec. + + Zwraca None gdy któraś wartość brak (sygnał nieinformatywny). Tube'y rzadko + podają dokładny duration; różnica ±60s zwykle oznacza tę samą scenę z innym + intro/outro. Poza oknem → 0.0 (różne sceny). + """ + if not left or not right: + return None + delta = abs(left - right) + if delta == 0: + return 1.0 + if delta > window_sec: + return 0.0 + return 1.0 - delta / window_sec + + +# ---- Composite ------------------------------------------------------------ + +# Bazowe wagi gdy wszystkie sygnały są dostępne. +_BASE_WEIGHTS = { + "fp": 0.40, + "title": 0.20, + "performers": 0.15, + "date": 0.15, + "duration": 0.10, +} + + +def composite_score( + *, + fp: float | None, + title: float | None, + performers: float | None, + date_score: float | None, + duration_score: float | None = None, + studio_match: bool | None, + aggregator_mode: bool = False, +) -> tuple[float, dict]: + """Łączy sub-score'y w jeden composite [0, 1] + zwraca raport reasons. + + studio_match=False → hard reject na 0.0, chyba że: + - fp ≥ 0.95 (silny fingerprint bije studio mismatch), albo + - aggregator_mode=True (np. tube'y typu HQPorner agregują z różnych studiów, + więc studio z naszej perspektywy nie jest informatywny — pomijamy hard reject + i zwiększamy wagę performers). + """ + reasons: dict = {} + + if studio_match is False: + if fp is not None and fp >= 0.95: + reasons["studio_mismatch_overridden_by_fp"] = True + elif aggregator_mode: + reasons["studio_ignored_aggregator"] = True + studio_match = None # nie informatywny + else: + reasons["studio_mismatch"] = True + return 0.0, reasons + + available = { + k: v + for k, v in { + "fp": fp, + "title": title, + "performers": performers, + "date": date_score, + "duration": duration_score, + }.items() + if v is not None + } + if not available: + return 0.0, {"no_signals": True} + + base_weights = dict(_BASE_WEIGHTS) + if aggregator_mode: + # tube'y nie mają date/fp, performer set + duration to najsilniejsze sygnały. + base_weights = { + "fp": 0.20, + "title": 0.15, + "performers": 0.35, + "date": 0.05, + "duration": 0.25, + } + reasons["aggregator_weights"] = base_weights + + weights = {k: base_weights[k] for k in available} + total_w = sum(weights.values()) + if total_w == 0.0: + return 0.0, reasons + norm_w = {k: w / total_w for k, w in weights.items()} + + score = sum(available[k] * norm_w[k] for k in available) + reasons["weights"] = norm_w + reasons["sub_scores"] = available + + # W aggregator mode wymagamy minimalnego performer overlap dla auto-merge — + # bez tego polegamy tylko na title fuzzy, co ma wysoki false-positive rate + # (różne sceny mogą mieć podobne nazwy). + if aggregator_mode and (performers is None or performers < 0.5): + score = min(score, 0.74) # cap poniżej review threshold + reasons["aggregator_low_performer_cap"] = True + + # Weak-signal cap: w aggregator mode gdy NIE MAMY duration ANI fingerprint ANI + # date (wszystkie najsilniejsze sygnały braki), polegamy WYŁĄCZNIE na title + + # performers. Same-performer dla prolific actresses (Tania Amazon, Mia Malkova, + # Aria Alexander) daje 1.0, a token-set ratio 0.75 z imienia/nazwiska w tytule + # jest powszechny → composite szybko hitje threshold (0.925) i auto-merguje + # 78 różnych scen pod jedną canonical (zgłoszone 2026-05-08). + # + # Reload 2026-05-09: title bypass przy ≥0.95 zostawiał furtkę dla "Simone Peach + # BBW rides..." vs "Peach Lollypop sexy BBW rides..." (token-set sweep + # podbija title >0.95 mimo że to różne osoby). Cap zawsze, niezależnie od + # title — auto-merge wymaga **co najmniej jednego strong signal** (fp, + # duration, date). Bez nich → review queue, nigdy auto-merge. + has_strong_signal = ( + (fp is not None and fp >= 0.5) + or (duration_score is not None and duration_score >= 0.5) + or (date_score is not None and date_score >= 0.5) + ) + if aggregator_mode and not has_strong_signal: + score = min(score, 0.85) + reasons["aggregator_weak_signal_cap"] = True + + # Strong-signal boost: w aggregator mode duration ±3s + performer overlap ≥0.5 + # + title >=0.40 ≈ pewny match (te same długości + ten sam performer + nie-totalnie- + # różne tytuły to bardzo rzadki false positive). Bumpujemy do auto-merge gdy + # tube SEO title różni się od studio canonical title ale zachowuje wspólny token. + # + # **Tightened 2026-05-12** (bug-report ef090842): poprzednio duration ±6s bez + # guardu na title → "Five Star Anal Fuck" (2105s) i "Match My Freak" (2110s) Lily + # Lou auto-merge'owało się w jedną scenę bo duration diff=5 (score 0.917 ≥0.90) + # + performer 1.0 wystarczało. Zmiany: + # - duration ≥ 0.95 (≤3s diff zamiast ≤6s) — Brazzers/Naughty America często + # mają sąsiednie sceny z tym samym actorem o pochodnej długości (intro/outro), + # ale ≤3s to praktycznie ten sam encoding + # - title ≥ 0.40 — zatrzymuje "totally different title" false matches; nadal + # toleruje "TheCanonicalTitle" vs "SiteSlug SEO Title TheCanonical Title FREE" + if ( + aggregator_mode + and duration_score is not None + and duration_score >= 0.95 + and performers is not None + and performers >= 0.5 + and title is not None + and title >= 0.40 + ): + if score < 0.92: + reasons["duration_perf_strong_match_bump"] = True + score = max(score, 0.92) + + return _clamp(score), reasons + + +def triage(score: float) -> str: + """Zwraca 'auto', 'review', 'reject' wg progów z config.""" + s = get_settings() + if score >= s.auto_merge_threshold: + return "auto" + if score >= s.review_threshold: + return "review" + return "reject" + + +def _clamp(v: float) -> float: + return max(0.0, min(1.0, v if not math.isnan(v) else 0.0)) diff --git a/app/resolve/studio_resolver.py b/app/resolve/studio_resolver.py new file mode 100644 index 0000000..90270a2 --- /dev/null +++ b/app/resolve/studio_resolver.py @@ -0,0 +1,118 @@ +"""M2: prosty resolver studia. + +Ścieżka 1: exact external ref → update. +Ścieżka 2: name_normalized exact match → reuse + dopnij external_ref. +Ścieżka 3: insert nowego. + +(Fuzzy alias matching dochodzi w M3.) +""" +from __future__ import annotations + +import logging +import uuid + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.studio import Studio, StudioAlias, StudioExternalRef +from app.normalize.scenes import NormalizedStudio +from app.normalize.text import slugify + +log = logging.getLogger(__name__) + + +def resolve_studio( + session: Session, + *, + norm: NormalizedStudio, + source_id: uuid.UUID, +) -> Studio: + if norm.external_id: + ref = session.execute( + select(StudioExternalRef).where( + StudioExternalRef.source_id == source_id, + StudioExternalRef.external_id == norm.external_id, + ) + ).scalar_one_or_none() + if ref is not None: + studio = session.get(Studio, ref.studio_id) + assert studio is not None + _update_studio_fields(studio, norm) + return studio + + studio = session.execute( + select(Studio).where(Studio.name_normalized == norm.name_normalized) + ).scalar_one_or_none() + if studio is None: + studio = Studio( + name=norm.name, + name_normalized=norm.name_normalized, + slug=_unique_slug(session, norm.slug or slugify(norm.name) or "studio"), + network=norm.network, + homepage_url=norm.homepage_url, + ) + session.add(studio) + session.flush() + log.debug("studio create id=%s name=%s", studio.id, studio.name) + else: + _update_studio_fields(studio, norm) + + if norm.external_id: + existing_ref = session.execute( + select(StudioExternalRef).where( + StudioExternalRef.source_id == source_id, + StudioExternalRef.external_id == norm.external_id, + ) + ).scalar_one_or_none() + if existing_ref is None: + session.add( + StudioExternalRef( + source_id=source_id, + external_id=norm.external_id, + studio_id=studio.id, + confidence=1.0, + ) + ) + + return studio + + +def _update_studio_fields(studio: Studio, norm: NormalizedStudio) -> None: + if norm.network and not studio.network: + studio.network = norm.network + if norm.homepage_url and not studio.homepage_url: + studio.homepage_url = norm.homepage_url + + +def _unique_slug(session: Session, base: str) -> str: + candidate = base + n = 1 + while session.execute(select(Studio.id).where(Studio.slug == candidate)).first(): + n += 1 + candidate = f"{base}-{n}" + return candidate + + +def add_alias_if_missing( + session: Session, + *, + studio_id: uuid.UUID, + alias: str, + alias_normalized: str, + source_id: uuid.UUID | None, +) -> None: + existing = session.execute( + select(StudioAlias).where( + StudioAlias.studio_id == studio_id, + StudioAlias.alias_normalized == alias_normalized, + ) + ).scalar_one_or_none() + if existing is None: + session.add( + StudioAlias( + studio_id=studio_id, + alias=alias, + alias_normalized=alias_normalized, + source_id=source_id, + ) + ) diff --git a/app/resolve/studio_title_parser.py b/app/resolve/studio_title_parser.py new file mode 100644 index 0000000..352ce76 --- /dev/null +++ b/app/resolve/studio_title_parser.py @@ -0,0 +1,141 @@ +"""Studio extraction z tube tytułów typu aggregator-WordPress. + +Format obserwowany w istniejących scenach (zaingestowanych przez porn-app legacy): + - `[StudioCamelCase] Performer1, Performer2 (Title)` — porndish, xmoviesforyou + - `[StudioCamelCase] Performer (Title / date)` — porndish, xmoviesforyou z datą + - `Studio – Performer1 – Title` — watchporn, hdporn92 (en/em-dash separator) + - `Studio – Performer1 & Performer2 – Title – S24:E3` — watchporn series + +Powód: te tube'y aggregator-WordPress mają `studio_name` ustawione na nazwę source'a +(`PornDish`, `Watch.Porn`) zamiast prawdziwego studio (`OpenFamily`, `TouchMyWife`). +Resolver path 4 composite scoring blokuje po `studio_id + release_date` — z błędnym +studio_id znajduje 0 kandydatów → fallback na performer-only blocking → bez strong +signal (duration/fp/date) score capuje 0.85 < 0.92 threshold → orphan. + +Po retro-fix studio_id na prawdziwe canonical studio, path 4 znajduje kandydatów +i performer + duration + title fuzzy może auto-merge. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass +from datetime import date + +# Bracket format: `[Studio] Performers (Title)` lub `[Studio] Performers (Title / Date)`. +# Studio musi być przynajmniej 2 chars, no whitespace. Tolerujemy hyphen w studio +# (np. `[Passion-HD]`, `[PervMom-Squirts]`). +_BRACKET_RE = re.compile( + r'^\[(?P<studio>[A-Za-z0-9][A-Za-z0-9 \-\.]{1,40})\]\s+(?P<rest>.+?)$' +) + +# Em/en/regular dash format: `Studio – Performers – Title [– Episode]`. Studio +# musi być max 35 chars + zaczyna alfanumerycznie. Pierwszy segment przed pierwszym +# dashem to studio. Dash separator może być różny: en-dash (–), em-dash (—), +# regular hyphen z spacjami `– ` / `- `. Wymagamy że studio jest co najmniej 3 znaki +# żeby uniknąć `S24:E3` style false-positive. +_DASH_RE = re.compile( + r'^(?P<studio>[A-Za-z][A-Za-z0-9 \-\.]{2,35}?)\s+[–—\-]+\s+(?P<rest>.+?)$' +) + +# Tytuły które są TYLKO slug-concat (lowercase, brak struktury) — nie parsujemy. +_LOWERCASE_RE = re.compile(r'^[a-z0-9 ]+$') + + +@dataclass +class ParsedTitle: + studio: str | None # raw studio name as extracted (e.g. "OpenFamily") + title_remainder: str # rest of title after studio extracted + format: str # 'bracket' | 'dash' | 'none' + release_date: date | None = None # parsed z `(... / MM.DD.YYYY)` lub `MM/DD/YYYY` + clean_title: str | None = None # tytuł sceny bez studio + bez date suffix + + +# Date patterns w `[Studio] X (Title / MM.DD.YYYY)` lub similar. +# Porndish & xmoviesforyou używają `MM.DD.YYYY` (american). Czasem widać też +# `MM/DD/YYYY`. Hyphen w roku nigdy. +_DATE_RE = re.compile( + r'(?P<m>0[1-9]|1[0-2])[\./](?P<d>0[1-9]|[12][0-9]|3[01])[\./](?P<y>20\d{2})' +) + + +def _parse_date_from_tail(remainder: str) -> tuple[date | None, str]: + """Wyciągnij datę z `(Title / MM.DD.YYYY)` lub `(Title / MM/DD/YYYY)`. + + Returns (parsed_date | None, remainder_without_date_suffix). + """ + m = _DATE_RE.search(remainder) + if not m: + return None, remainder + try: + d = date(int(m.group("y")), int(m.group("m")), int(m.group("d"))) + except (ValueError, TypeError): + return None, remainder + # Drop everything from `/` lub `(` lub ` MM.DD` boundary up to date + cut_pos = m.start() + # Walk back through separators ` / ` lub `(` before date + while cut_pos > 0 and remainder[cut_pos - 1] in " /([": + cut_pos -= 1 + cleaned = (remainder[:cut_pos] + remainder[m.end():]).strip(" ()/-,") + return d, cleaned + + +def parse_title(title: str) -> ParsedTitle: + """Wyparsuj studio z tytułu sceny. + + Returns ParsedTitle(studio=None) gdy nie wykryto formatu — wtedy caller powinien + pominąć fix dla tej sceny. + """ + if not title: + return ParsedTitle(studio=None, title_remainder=title or "", format="none") + title = title.strip() + + # Lower-only slug-concat — bez sensu próbować + if _LOWERCASE_RE.match(title): + return ParsedTitle(studio=None, title_remainder=title, format="none") + + m = _BRACKET_RE.match(title) + if m: + studio = m.group("studio").strip() + rest = m.group("rest").strip() + rel_date, cleaned_rest = _parse_date_from_tail(rest) + # Extract inner title z `Performers (Title)` — bierzemy tylko zawartość parens + # jeśli istnieją; inaczej całą reszte + inner = re.search(r'\(([^()]+)\)\s*$', cleaned_rest) + clean_title = inner.group(1).strip() if inner else cleaned_rest + return ParsedTitle( + studio=studio, + title_remainder=rest, + format="bracket", + release_date=rel_date, + clean_title=clean_title or None, + ) + + m = _DASH_RE.match(title) + if m: + studio = m.group("studio").strip() + # Filter: studio nie może być znanym non-studio prefixem (e.g. "NEW", "HD", "VR"). + # Te są częste w SEO tytułach i tworzyłyby false-positive studio_id. + if studio.lower() in _NON_STUDIO_PREFIXES: + return ParsedTitle(studio=None, title_remainder=title, format="none") + rest = m.group("rest").strip() + # Dash format: `Studio – Performers – Title – [Optional Episode]`. Tytuł sceny + # to OSTATNI segment (po ostatnim dashu); performerzy to pre-last segments. + parts = re.split(r'\s+[–—\-]+\s+', rest) + clean_title = parts[-1] if parts else rest + return ParsedTitle( + studio=studio, + title_remainder=rest, + format="dash", + clean_title=clean_title, + ) + + return ParsedTitle(studio=None, title_remainder=title, format="none") + + +# Słowa które pojawiają się jako pierwszy token tytułu ale NIE są studio names — +# typowo SEO booster prefix lub jakość/kategoria/etykieta. +_NON_STUDIO_PREFIXES = frozenset({ + "new", "hd", "4k", "vr", "free", "watch", "porn", "video", "full", + "anal", "best", "exclusive", "amateur", "homemade", "pov", "milf", "teen", + "bbw", "bdsm", "interracial", "lesbian", "threesome", "gangbang", +}) diff --git a/app/resolve/tag_resolver.py b/app/resolve/tag_resolver.py new file mode 100644 index 0000000..421aa43 --- /dev/null +++ b/app/resolve/tag_resolver.py @@ -0,0 +1,49 @@ +"""Resolver tagów. Tag identyfikuje slug (case-insensitive).""" +from __future__ import annotations + +import uuid + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.models.tag import Tag +from app.normalize.scenes import NormalizedTag +from app.normalize.text import slugify + + +def resolve_tag(session: Session, *, norm: NormalizedTag) -> Tag | None: + slug = norm.slug or slugify(norm.name) + # DB columns: name VARCHAR(128), slug VARCHAR(128). Scraper occasionally + # captures multi-tag composite URLs (e.g. /tags/face-sitting-fake-tits-...) + # which break the insert. Reject anything >120 chars instead of crashing + # the whole ingest batch. + if len(slug) > 120: + return None + tag = session.execute(select(Tag).where(Tag.slug == slug)).scalar_one_or_none() + if tag is not None: + return tag + + name = (norm.name or "").strip() or slug.replace("-", " ").title() + if len(name) > 120: + name = name[:120] + tag = Tag(name=name, slug=slug) + # SAVEPOINT — chroni outer transaction przed concurrent insert race: + # gdy worker scrapuje sceny + API endpoint enrich_tags_from_tube robią + # `resolve_tag('hardcore-sex')` jednocześnie, jeden INSERT się uda, + # drugi → UniqueViolation slug. Bez savepoint cała transakcja API + # rzucała 500 (Sentry GOON-H, 5x od 2026-05-12). Z savepoint rollback + # na savepoint + re-SELECT zwraca już-istniejący tag. + sp = session.begin_nested() + try: + session.add(tag) + session.flush() + sp.commit() + return tag + except IntegrityError: + sp.rollback() + return session.execute(select(Tag).where(Tag.slug == slug)).scalar_one_or_none() + + +def resolve_tag_by_id(session: Session, tag_id: uuid.UUID) -> Tag | None: + return session.get(Tag, tag_id) diff --git a/app/scheduler/__init__.py b/app/scheduler/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/scheduler/browse_latest.py b/app/scheduler/browse_latest.py new file mode 100644 index 0000000..cc7f938 --- /dev/null +++ b/app/scheduler/browse_latest.py @@ -0,0 +1,70 @@ +"""Browse-latest strategy — scrap newest scenes from rich-metadata tubes. + +Pattern (vs performer-driven): + - performer-driven: backfill scen dla znanych performerów (TPDB/StashDB → tube). + Wymaga już znanego performera w DB. + - browse-latest: forward-fill świeżymi scenami z tube'ów (~100 najnowszych / + tube / dzień). Łapie sceny których performer może być new dla nas — później + canonical ingest dorobi metadata. + +Każdy `BaseBrowseScraper.latest_scenes(max_pages=5)` yielduje RawScene z bogatą +metadata (studio + performers + duration + tags + description). Composite fuzzy +w resolverze ma więc dobre sygnały dla canonical match (vs orphan-only tubes +typu pornditt, gdzie był sam title + krótki opis). + +Schedulowane przez `jobs.py` raz dziennie (`sched_browse_latest_hours=24`). +""" +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass + +from app.connectors.direct_scrapers import ALL_BROWSE_SCRAPERS +from app.models.source import SourceKind +from app.scheduler.performer_driven import _ingest_iter_into_run + +log = logging.getLogger(__name__) + + +@dataclass +class BrowseCounters: + scrapers_run: int = 0 + total_seen: int = 0 + total_new: int = 0 + total_updated: int = 0 + total_errors: int = 0 + + +def run_browse_latest(*, max_pages: int = 5) -> BrowseCounters: + """Iteruj wszystkie zarejestrowane browse scrapers, scrap latest N pages każdy.""" + counters = BrowseCounters() + for scraper_cls in ALL_BROWSE_SCRAPERS: + scraper = scraper_cls() + t0 = time.time() + log.info("browse-latest: %s starting (max_pages=%d)", scraper.sitetag, max_pages) + try: + c = _ingest_iter_into_run( + source_kind=SourceKind.scraper, + source_name="pornapp", + run_label=f"browse-latest:{scraper.sitetag}", + iterator_factory=lambda s=scraper, mp=max_pages: s.latest_scenes( + max_pages=mp + ), + ) + counters.scrapers_run += 1 + counters.total_seen += c.get("seen", 0) + counters.total_new += c.get("new", 0) + counters.total_updated += c.get("updated", 0) + counters.total_errors += c.get("errors", 0) + elapsed = time.time() - t0 + log.info( + "browse-latest: %s done in %.1fs counters=%s", + scraper.sitetag, elapsed, c, + ) + except Exception as e: + counters.total_errors += 1 + log.exception("browse-latest: %s failed: %s", scraper.sitetag, e) + + log.info("browse-latest done: %s", counters) + return counters diff --git a/app/scheduler/bulk_dedup.py b/app/scheduler/bulk_dedup.py new file mode 100644 index 0000000..9c7743a --- /dev/null +++ b/app/scheduler/bulk_dedup.py @@ -0,0 +1,472 @@ +"""Bulk dedup pass dla istniejących scen kanonicznych. + +Sceny ingestowane przed dodaniem `duration_proximity` + `duration_perf_strong_match_bump` +(2026-05-03) nigdy nie zobaczyły nowego scoringu — zostały obok siebie jako odrębne +canonical entries. Ten moduł re-scoruje pary scen ze SHARED phashem / performerem +i tworzy auto_merge / pending kandydaty zgodnie z aktualnym scoringiem. + +Strategie: + phash — pary scen mające ten sam phash (Hamming ≤5). Mocny sygnał. + performers — dla każdego performera, pair-wise scenes; trafia bumpa duration±6s + perf≥0.5. + +`dry_run=True` tylko loguje co by zrobił, nic nie zapisuje. +""" +from __future__ import annotations + +import itertools +import logging +import uuid +from collections.abc import Iterable +from dataclasses import dataclass, field + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.db import session_scope +from app.models.merge_candidate import MergeCandidate, MergeKind, MergeStatus +from app.models.scene import Scene, SceneFingerprint, ScenePerformer +from app.resolve.scene_merge import merge_scenes +from app.resolve.scoring import ( + ScoreBreakdown, + composite_score, + date_proximity, + duration_proximity, + hamming_distance_hex, + performer_set_similarity, + phash_similarity, + title_similarity, + triage, +) + +log = logging.getLogger(__name__) + + +@dataclass +class BulkCounters: + pairs_scored: int = 0 + auto_merged: int = 0 + pending_added: int = 0 + rejected_low: int = 0 + skipped_already_pending: int = 0 + skipped_self: int = 0 + skipped_not_cross_source: int = 0 + + +# ---- pair scoring ----------------------------------------------------- + +def _scene_phashes(session: Session, scene_id: uuid.UUID) -> list[str]: + return list( + session.execute( + select(SceneFingerprint.value).where( + SceneFingerprint.scene_id == scene_id, + SceneFingerprint.kind == "phash", + ) + ) + .scalars() + .all() + ) + + +def _scene_performers(session: Session, scene_id: uuid.UUID) -> list[uuid.UUID]: + return list( + session.execute( + select(ScenePerformer.performer_id).where(ScenePerformer.scene_id == scene_id) + ) + .scalars() + .all() + ) + + +def _best_phash(left_phashes: list[str], right_phashes: list[str]) -> float | None: + """Najlepsza similarity między dwoma listami phashy (równa długość).""" + if not left_phashes or not right_phashes: + return None + best = 0.0 + for a in left_phashes: + for b in right_phashes: + if len(a) != len(b): + continue + try: + sim = phash_similarity(a, b) + except ValueError: + continue + if sim > best: + best = sim + return best if best > 0 else None + + +def score_scene_pair(session: Session, a: Scene, b: Scene) -> ScoreBreakdown: + """Odpowiednik `score_candidate` ale bez wrappowania w NormalizedScene.""" + a_phashes = _scene_phashes(session, a.id) + b_phashes = _scene_phashes(session, b.id) + fp = _best_phash(a_phashes, b_phashes) + + title = title_similarity(a.title_normalized, b.title_normalized) + + a_perfs = _scene_performers(session, a.id) + b_perfs = _scene_performers(session, b.id) + perf = ( + performer_set_similarity(a_perfs, b_perfs) + if (a_perfs or b_perfs) + else None + ) + + date_score = date_proximity(a.release_date, b.release_date) if (a.release_date and b.release_date) else None + duration_score = duration_proximity(a.duration_sec, b.duration_sec) + + studio_match: bool | None + if a.studio_id is None or b.studio_id is None: + studio_match = None + else: + studio_match = a.studio_id == b.studio_id + + # Bulk dedup nie jest aggregator — porównujemy dwie kanoniczne sceny, studio + # to prawdziwe studio. Aggregator mode tylko w resolverze przy ingest z tube'a. + composite, reasons = composite_score( + fp=fp, + title=title, + performers=perf, + date_score=date_score, + duration_score=duration_score, + studio_match=studio_match, + aggregator_mode=False, + ) + + return ScoreBreakdown( + fp=fp, + title=title, + performers=perf, + date=date_score, + duration=duration_score, + studio_match=studio_match, + composite=composite, + reasons=reasons, + ) + + +# ---- candidate selection --------------------------------------------- + +def _pairs_sharing_phash(session: Session, max_hamming: int) -> Iterable[tuple[uuid.UUID, uuid.UUID]]: + """Yield pary scene_id mające phashe w odległości Hamminga ≤ max_hamming. + + Nie próbujemy pgvector / BK-tree — dla 215 phashy O(N²) = 46k iteracji jest OK. + """ + rows = session.execute( + select(SceneFingerprint.scene_id, SceneFingerprint.value).where( + SceneFingerprint.kind == "phash" + ) + ).all() + seen_pairs: set[tuple[uuid.UUID, uuid.UUID]] = set() + for (sid_a, hash_a), (sid_b, hash_b) in itertools.combinations(rows, 2): + if sid_a == sid_b: + continue + if len(hash_a) != len(hash_b): + continue + try: + d = hamming_distance_hex(hash_a, hash_b) + except ValueError: + continue + if d > max_hamming: + continue + pair = (sid_a, sid_b) if sid_a < sid_b else (sid_b, sid_a) + if pair in seen_pairs: + continue + seen_pairs.add(pair) + yield pair + + +def _pairs_sharing_performer( + session: Session, + *, + cross_source_only: bool = False, +) -> Iterable[tuple[uuid.UUID, uuid.UUID]]: + """Yield pary scen z wspólnym performerem. Dedup pair (a<b). + + `cross_source_only=True` — tylko pary (tpdb-only ↔ stashdb-only). To eliminuje + ~99% par w bazie z dużą liczbą scen mainstream performerów (popularny performer + ma N scen → N²/2 par; 1959 performerów × średnio 50 scen² = 4M+ par bez filtru, + z filtrem zostają tylko cross-source kandydaci ~10-100x mniej). Bez tego flag + bulk_dedup OOM-uje na 41k+ scenes. + """ + # Per performer, weź wszystkie scene_id i wygeneruj pary. + perf_to_scenes: dict[uuid.UUID, list[uuid.UUID]] = {} + for performer_id, scene_id in session.execute( + select(ScenePerformer.performer_id, ScenePerformer.scene_id) + ): + perf_to_scenes.setdefault(performer_id, []).append(scene_id) + + if cross_source_only: + # Pre-build scene → canonical kinds map + from app.models.scene import SceneExternalRef + from app.models.source import Source, SourceKind + scene_kinds: dict[uuid.UUID, set[str]] = {} + for sid, kind in session.execute( + select(SceneExternalRef.scene_id, Source.kind) + .join(Source, Source.id == SceneExternalRef.source_id) + .where(Source.kind.in_([SourceKind.tpdb, SourceKind.stashdb])) + ): + scene_kinds.setdefault(sid, set()).add(kind.value) + + # Pre-build scene → (studio_id, release_date, duration_sec) — żeby pre-filtrować + # pary do tych z realną szansą bycia duplikatami zanim odpalimy scoring (drogie). + # Bez tego 9M par × ~110/s = 22h na 0% hit rate. + scene_meta: dict[uuid.UUID, tuple] = {} + for sid, studio_id, rel_date, dur in session.execute( + select(Scene.id, Scene.studio_id, Scene.release_date, Scene.duration_sec) + ): + scene_meta[sid] = (studio_id, rel_date, dur) + + def _candidate(a_id: uuid.UUID, b_id: uuid.UUID) -> bool: + a = scene_meta.get(a_id) + b = scene_meta.get(b_id) + if not a or not b: + return False + a_studio, a_date, a_dur = a + b_studio, b_date, b_dur = b + # studio match (oba znają studio i to samo) — bardzo silny sygnał + if a_studio is not None and a_studio == b_studio: + return True + # date ±7d (oba mają daty) + if a_date and b_date and abs((a_date - b_date).days) <= 7: + return True + # duration ±30s (oba znają długość; 30s zostawia margines na intro/outro + # różniący się między TPDB a StashDB metadata) + if a_dur and b_dur and abs(a_dur - b_dur) <= 30: + return True + return False + + seen_pairs: set[tuple[uuid.UUID, uuid.UUID]] = set() + for scene_ids in perf_to_scenes.values(): + tpdb_only = [sid for sid in scene_ids if scene_kinds.get(sid) == {"tpdb"}] + stash_only = [sid for sid in scene_ids if scene_kinds.get(sid) == {"stashdb"}] + for a in tpdb_only: + for b in stash_only: + pair = (a, b) if a < b else (b, a) + if pair in seen_pairs: + continue + if not _candidate(a, b): + continue + seen_pairs.add(pair) + yield pair + return + + seen_pairs = set() + for scene_ids in perf_to_scenes.values(): + if len(scene_ids) < 2: + continue + for a, b in itertools.combinations(scene_ids, 2): + pair = (a, b) if a < b else (b, a) + if pair in seen_pairs: + continue + seen_pairs.add(pair) + yield pair + + +# ---- main entry -------------------------------------------------------- + +def run_bulk_dedup( + *, + strategy: str = "all", # 'phash' | 'performers' | 'all' + dry_run: bool = False, + max_hamming: int | None = None, + auto_merge_threshold: float | None = None, + review_threshold: float | None = None, + cross_source_only: bool = False, +) -> BulkCounters: + settings = get_settings() + auto_t = auto_merge_threshold or settings.auto_merge_threshold + review_t = review_threshold or settings.review_threshold + max_h = max_hamming or settings.fingerprint_hamming_max + + counters = BulkCounters() + + # Etap 1: zbierz wszystkie kandydujące pary (BEZ commita do DB). + log.info("bulk_dedup start strategy=%s dry_run=%s", strategy, dry_run) + + with session_scope() as session: + pairs: list[tuple[uuid.UUID, uuid.UUID]] = [] + if strategy in ("phash", "all"): + phash_pairs = list(_pairs_sharing_phash(session, max_hamming=max_h)) + log.info("bulk_dedup: %d phash-shared pairs", len(phash_pairs)) + pairs.extend(phash_pairs) + if strategy in ("performers", "all"): + perf_pairs = list( + _pairs_sharing_performer(session, cross_source_only=cross_source_only) + ) + log.info( + "bulk_dedup: %d performer-shared pairs (cross_source_only=%s)", + len(perf_pairs), cross_source_only, + ) + pairs.extend(perf_pairs) + + # Dedup + unique_pairs = list({(a, b) if a < b else (b, a) for a, b in pairs}) + log.info("bulk_dedup: %d unique pairs total", len(unique_pairs)) + + # Etap 2: per pair — score + decision. Każda decyzja w osobnej transakcji, + # żeby gdyby coś padło, częściowy postęp był persistowany. + for sid_a, sid_b in unique_pairs: + try: + _process_pair( + sid_a, sid_b, + auto_t=auto_t, review_t=review_t, + dry_run=dry_run, counters=counters, + cross_source_only=cross_source_only, + ) + except Exception as e: + log.exception("bulk_dedup pair %s↔%s failed: %s", sid_a, sid_b, e) + + log.info("bulk_dedup done: %s", counters) + return counters + + +def _scene_source_kinds(session: Session, scene_id: uuid.UUID) -> set[str]: + """Zwraca set('tpdb', 'stashdb', 'scraper'...) — kinds external_refów sceny.""" + from app.models.scene import SceneExternalRef + from app.models.source import Source + rows = session.execute( + select(Source.kind) + .join(SceneExternalRef, SceneExternalRef.source_id == Source.id) + .where(SceneExternalRef.scene_id == scene_id) + .distinct() + ).all() + return {kind.value for (kind,) in rows} + + +def _is_cross_source_pair(session: Session, sid_a: uuid.UUID, sid_b: uuid.UUID) -> bool: + """True gdy para to (tpdb-only ↔ stashdb-only) — czyli kandydaci do mergu który + odsłoniłby cross-source overlap. Pomija pary gdzie któryś już ma BOTH refs + (już zmergowane wcześniej) lub te same single-source (rzadko duplikaty).""" + a_kinds = _scene_source_kinds(session, sid_a) + b_kinds = _scene_source_kinds(session, sid_b) + canonical_a = a_kinds & {"tpdb", "stashdb"} + canonical_b = b_kinds & {"tpdb", "stashdb"} + # Obie strony muszą mieć dokładnie 1 canonical źródło, i muszą się różnić. + if len(canonical_a) != 1 or len(canonical_b) != 1: + return False + return canonical_a != canonical_b + + +def _process_pair( + sid_a: uuid.UUID, + sid_b: uuid.UUID, + *, + auto_t: float, + review_t: float, + dry_run: bool, + counters: BulkCounters, + cross_source_only: bool = False, +) -> None: + with session_scope() as session: + a = session.get(Scene, sid_a) + b = session.get(Scene, sid_b) + if a is None or b is None: + counters.skipped_self += 1 + return + if a.id == b.id: + counters.skipped_self += 1 + return + + if cross_source_only and not _is_cross_source_pair(session, sid_a, sid_b): + counters.skipped_not_cross_source += 1 + return + + # Skip jeśli już istnieje pending/auto_merged/rejected dla tej pary. + existing = session.execute( + select(MergeCandidate).where( + MergeCandidate.kind == MergeKind.scene, + ( + ((MergeCandidate.left_id == a.id) & (MergeCandidate.right_id == b.id)) + | ((MergeCandidate.left_id == b.id) & (MergeCandidate.right_id == a.id)) + ), + MergeCandidate.status.in_( + [MergeStatus.pending, MergeStatus.merged, MergeStatus.rejected] + ), + ).limit(1) + ).scalar_one_or_none() + if existing is not None: + counters.skipped_already_pending += 1 + return + + breakdown = score_scene_pair(session, a, b) + counters.pairs_scored += 1 + if counters.pairs_scored % 500 == 0: + log.info( + "bulk_dedup progress: %d pairs scored (auto=%d pending=%d rej=%d)", + counters.pairs_scored, + counters.auto_merged, + counters.pending_added, + counters.rejected_low, + ) + + decision = "auto" if breakdown.composite >= auto_t else ( + "review" if breakdown.composite >= review_t else "reject" + ) + + if decision == "auto": + counters.auto_merged += 1 + if dry_run: + log.info( + "[dry] AUTO %s '%s' (dur=%s) ↔ '%s' (dur=%s) score=%.3f", + a.id, a.title[:40], a.duration_sec, b.title[:40], b.duration_sec, + breakdown.composite, + ) + return + # Pick keep = scene z większą liczbą external_refs, tie-break: starszy (lower created_at). + keep, drop = _pick_keep_drop(session, a, b) + log.info( + "AUTO merge keep=%s drop=%s score=%.3f", + keep.id, drop.id, breakdown.composite, + ) + session.add( + MergeCandidate( + kind=MergeKind.scene, + left_id=keep.id, + right_id=keep.id, # audit: post-merge scene_id + score=breakdown.composite, + reasons={"path": "bulk_dedup", **breakdown.to_dict()}, + status=MergeStatus.auto_merged, + ) + ) + merge_scenes( + session, keep_id=keep.id, drop_id=drop.id, resolved_by="bulk_dedup" + ) + elif decision == "review": + counters.pending_added += 1 + if dry_run: + log.info( + "[dry] PENDING '%s' ↔ '%s' score=%.3f", + a.title[:40], b.title[:40], breakdown.composite, + ) + return + session.add( + MergeCandidate( + kind=MergeKind.scene, + left_id=a.id, + right_id=b.id, + score=breakdown.composite, + reasons={"path": "bulk_dedup", **breakdown.to_dict()}, + status=MergeStatus.pending, + ) + ) + else: + counters.rejected_low += 1 + + +def _pick_keep_drop(session: Session, a: Scene, b: Scene) -> tuple[Scene, Scene]: + """Keep = scena z większą liczbą external_refs. Tie → starsza.""" + from app.models.scene import SceneExternalRef + + a_count = session.execute( + select(func.count()).select_from(SceneExternalRef).where(SceneExternalRef.scene_id == a.id) + ).scalar_one() + b_count = session.execute( + select(func.count()).select_from(SceneExternalRef).where(SceneExternalRef.scene_id == b.id) + ).scalar_one() + if a_count > b_count: + return a, b + if b_count > a_count: + return b, a + # tie → starsza scena (lower created_at) + return (a, b) if a.created_at <= b.created_at else (b, a) diff --git a/app/scheduler/jobs.py b/app/scheduler/jobs.py new file mode 100644 index 0000000..007b514 --- /dev/null +++ b/app/scheduler/jobs.py @@ -0,0 +1,227 @@ +"""APScheduler job definitions dla worker'a (M5). + +Domyślny harmonogram: + - tpdb — co 6h, delta od ostatniego successful run + - stashdb — co 6h, delta + - performer-driven — co 12h, top-N performerów z bazy (auto-discovers nowe sceny przez + ALL_DIRECT_SCRAPERS — 25 tube'ów per-tube HTTP scraping) + - performer-continuous — tick co N sekund, 1 performer per tick (ORDER BY last_searched_at) + +Konfigurację (interwały, włącz/wyłącz) można nadpisać przez env (`GOON_SCHED_*`), +patrz `app/scheduler/config.py`. + +Uwaga: APScheduler in-process (BlockingScheduler) — wystarczy dla self-hosted single +worker. Dla multi-worker trzebaby Redis/SQLAlchemy job store + distributed lock. +""" +from __future__ import annotations + +import logging +from typing import Any + +from apscheduler.schedulers.blocking import BlockingScheduler +from apscheduler.triggers.interval import IntervalTrigger + +from app.connectors import get_movie_connectors +from app.connectors.stashdb import StashDBConnector +from app.connectors.tpdb import TPDBConnector +from app.ingest import ingest_from_connector, ingest_movies_from_connector +from app.scheduler.browse_latest import run_browse_latest +from app.scheduler.performer_driven import run_continuous_one_at_a_time, run_performer_driven + +log = logging.getLogger(__name__) + + +def _job_tpdb() -> None: + log.info("[scheduler] tpdb delta starting") + try: + ingest_from_connector(TPDBConnector(), use_delta=True) + except Exception: + log.exception("[scheduler] tpdb job failed") + + +def _job_stashdb() -> None: + log.info("[scheduler] stashdb delta starting") + try: + ingest_from_connector(StashDBConnector(), use_delta=True) + except Exception: + log.exception("[scheduler] stashdb job failed") + + +def _job_performer_driven(top_n: int) -> None: + log.info("[scheduler] performer-driven top-%d starting", top_n) + try: + run_performer_driven( + top_n=top_n, + per_performer_limit=50, + ) + except Exception: + log.exception("[scheduler] performer-driven job failed") + + +def _job_browse_latest(max_pages: int) -> None: + """Browse-latest — scrap newest scenes z rich-metadata tubes (shyfap + ...). + Komplementarny do performer-driven: forward-fill (new scenes) vs backward (known performers). + """ + log.info("[scheduler] browse-latest starting (max_pages=%d)", max_pages) + try: + run_browse_latest(max_pages=max_pages) + except Exception: + log.exception("[scheduler] browse-latest job failed") + + +def _job_movie_ingest() -> None: + """Movies ingest — paradisehill (primary) + dooplay mirrory. + + Paradisehill jako primary daje canonical movie record (title + year + studio). + Mirrory dooplay (mangoporn/streamporn/pandamovies) doklejają playback sources + z native-friendly origins (mangoporn:luluvid, :voe, etc.) — `extract_stream_from_hoster` + rozwiązuje je do bezpośredniego stream URL → mobile gra natywnie zamiast WebView. + + Matching mirror→primary movie idzie przez `resolve_movie` (title+year+studio + similarity). Każdy connector osobny IngestRun + delta od ostatniego success. + + Kolejność: paradisehill FIRST (żeby mirrory miały do czego się przykleić), + potem mirrory. Pojedynczy failed connector NIE zatrzymuje pozostałych — + każdy w osobnym try/except. + """ + for name, cls in get_movie_connectors(): + log.info("[scheduler] movie ingest %s starting", name) + try: + ingest_movies_from_connector(cls(), use_delta=True) + except Exception: + log.exception("[scheduler] movie ingest %s failed", name) + + +def _job_performer_continuous(refresh_after_days: int) -> None: + """Continuous worker — 1 performer per tick, ORDER BY last_searched_at NULLS FIRST. + + Per tick: full search across ~25 tubeów (per_performer_limit=None). Tick zajmuje + ~50-80s. Interval ustawiony na 15s + max_instances=1 + coalesce=True znaczy że + real rate to max(15s, tick_duration) — efektywnie ~1 perf/50-80s. + """ + try: + run_continuous_one_at_a_time( + refresh_after_days=refresh_after_days, + per_performer_limit=None, # full coverage all tubes + ) + except Exception: + log.exception("[scheduler] performer-continuous failed") + + +def build_scheduler(cfg: dict[str, Any]) -> BlockingScheduler: + """Buduje scheduler na podstawie cfg dictu. + + cfg keys: + tpdb_hours: int | None (None = wyłączony) + stashdb_hours: int | None + performer_driven_hours: int | None + performer_driven_top_n: int + performer_continuous_seconds: int | None + performer_continuous_refresh_days: int + """ + sched = BlockingScheduler(timezone="UTC") + + if cfg.get("tpdb_hours"): + sched.add_job( + _job_tpdb, + IntervalTrigger(hours=cfg["tpdb_hours"]), + id="tpdb", + replace_existing=True, + max_instances=1, + coalesce=True, + ) + log.info("scheduler: tpdb every %dh", cfg["tpdb_hours"]) + + if cfg.get("stashdb_hours"): + sched.add_job( + _job_stashdb, + IntervalTrigger(hours=cfg["stashdb_hours"]), + id="stashdb", + replace_existing=True, + max_instances=1, + coalesce=True, + ) + log.info("scheduler: stashdb every %dh", cfg["stashdb_hours"]) + + if cfg.get("performer_driven_hours"): + top_n = cfg.get("performer_driven_top_n") or 20 + sched.add_job( + lambda: _job_performer_driven(top_n), + IntervalTrigger(hours=cfg["performer_driven_hours"]), + id="performer_driven", + replace_existing=True, + max_instances=1, + coalesce=True, + ) + log.info( + "scheduler: performer-driven every %dh (top_n=%d)", + cfg["performer_driven_hours"], + top_n, + ) + + if cfg.get("browse_latest_hours"): + max_pages = cfg.get("browse_latest_max_pages") or 5 + sched.add_job( + lambda: _job_browse_latest(max_pages), + IntervalTrigger(hours=cfg["browse_latest_hours"]), + id="browse_latest", + replace_existing=True, + max_instances=1, + coalesce=True, + ) + log.info( + "scheduler: browse-latest every %dh (max_pages=%d)", + cfg["browse_latest_hours"], max_pages, + ) + + if cfg.get("movie_ingest_hours"): + sched.add_job( + _job_movie_ingest, + IntervalTrigger(hours=cfg["movie_ingest_hours"]), + id="movie_ingest", + replace_existing=True, + max_instances=1, + coalesce=True, + ) + log.info("scheduler: movie-ingest every %dh", cfg["movie_ingest_hours"]) + + if cfg.get("performer_continuous_seconds"): + refresh_days = cfg.get("performer_continuous_refresh_days") or 30 + seconds = cfg["performer_continuous_seconds"] + sched.add_job( + lambda: _job_performer_continuous(refresh_days), + IntervalTrigger(seconds=seconds), + id="performer_continuous", + replace_existing=True, + max_instances=1, + coalesce=True, + ) + log.info( + "scheduler: performer-continuous every %ds (refresh_after=%dd)", + seconds, refresh_days, + ) + + return sched + + +DEFAULT_CONFIG: dict[str, Any] = { + "tpdb_hours": 6, + "stashdb_hours": 6, + "performer_driven_hours": 12, + "performer_driven_top_n": 20, + # Browse-latest — newest scenes z rich-metadata tubes (shyfap, ...). Raz dziennie + # × ~100 scen/tube/run = drobny budżet, łapie świeże sceny których performera jeszcze + # nie znamy (newcomerki → canonical ingest dorobi potem). + "browse_latest_hours": 24, + "browse_latest_max_pages": 5, + # Movies — paradisehill + dooplay mirrory. Raz dziennie wystarczy (sites rosną + # wolniej niż tube'y). Najwazniejsze: mirrory dorzucają native-friendly playback + # sources do paradisehill movies → mobile gra natywnie zamiast WebView. + "movie_ingest_hours": 24, + # Continuous worker: tick co 15s, ale max_instances=1 + coalesce sprawia że + # efektywny rate = max(15s, tick_duration). Tick z full coverage (25 tubes) ~50-80s, + # więc realnie ~1 perf/60s. Przy 14.7k performerów = ~10 dni full sweep + refresh + # każdego co 30 dni. + "performer_continuous_seconds": 15, + "performer_continuous_refresh_days": 30, +} diff --git a/app/scheduler/performer_driven.py b/app/scheduler/performer_driven.py new file mode 100644 index 0000000..f56549f --- /dev/null +++ b/app/scheduler/performer_driven.py @@ -0,0 +1,612 @@ +"""Performer-driven ingest strategy. + +Rationale (memory: kompletność > świeżość): + Random newest-first ingest z TPDB/StashDB rzadko pokrywa się ze świeżym pull'em + z hqporner — bo każde źródło widzi inny wycinek katalogu w danym momencie. Dla + popularnych performerów jednak prawdopodobieństwo overlapu jest dużo wyższe, + a zasięg po jednym performerze jest skończony i da się przeszukać. + +Cykl per performer: + 1. Lookup performer_external_refs(tpdb, stashdb) — kanoniczne UUID-y. + 2. Dla każdego źródła z UUID: pobierz wszystkie sceny tego performera (paginated). + 3. Dla każdego direct tube scrapera: search tube po `canonical_name` (np. "Lola Noir"). + 4. Sceny lecą przez normalny `_process_scene` → resolver merguje cross-source. + +Wybór listy performerów (kolejność prób): + a) `--performers "name1,name2"` — explicit. + b) `--performer-ids <uuid1>,<uuid2>` — explicit po naszym kanonicznym UUID. + c) Top-N z bazy po `scene_count desc` (`--top-n=N`, default 20). +""" +from __future__ import annotations + +import logging +import uuid +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.connectors.stashdb import StashDBConnector +from app.connectors.tpdb import TPDBConnector +from app.db import session_scope +from app.ingest import ( + _hash_raw, # type: ignore[attr-defined] + _process_scene, # type: ignore[attr-defined] + get_or_create_source, +) +from app.models.ingest_run import IngestRun, IngestStatus +from app.models.performer import Performer, PerformerExternalRef +from app.models.scene import ScenePerformer +from app.models.source import Source, SourceKind + +log = logging.getLogger(__name__) + + +@dataclass +class PerformerTarget: + performer_id: uuid.UUID + canonical_name: str + tpdb_id: str | None = None + stashdb_id: str | None = None + + +@dataclass +class StrategyCounters: + performers_processed: int = 0 + performers_skipped_no_refs: int = 0 + per_source: dict[str, dict[str, int]] = field(default_factory=dict) + """source_name → {seen, new, updated, skipped, errors} agregowane po wszystkich performerach.""" + + def merge(self, source_name: str, c: dict[str, int]) -> None: + bucket = self.per_source.setdefault( + source_name, {"seen": 0, "new": 0, "updated": 0, "skipped": 0, "errors": 0} + ) + for k in bucket: + bucket[k] += c.get(k, 0) + + +# ---- Public API ------------------------------------------------------ + +def run_performer_driven( + *, + performer_names: list[str] | None = None, + performer_ids: list[uuid.UUID] | None = None, + top_n: int = 20, + sitetags: list[str] | None = None, + per_performer_limit: int | None = None, + canonical_per_performer_limit: int | None = None, + skip_tubes: bool = False, + skip_canonical: bool = False, +) -> StrategyCounters: + """Główne entry point dla worker CLI.""" + counters = StrategyCounters() + + # Gdy user explicit żąda performerów po nazwach, automatycznie uzupełniamy brakujące + # canonical refs (TPDB/StashDB UUID) — historycznie wielu performerów miało tylko + # stashdb ref, więc TPDB ingest dla nich nigdy nie odpalał. Dla top-N path nie + # auto-fillujemy bo to dodatkowe API calls bez explicit user intent. + fill_missing_refs = bool(performer_names) + + targets = _resolve_targets( + performer_names=performer_names, + performer_ids=performer_ids, + top_n=top_n, + fill_missing_refs=fill_missing_refs, + ) + if not targets: + log.warning("performer-driven: no targets resolved (empty top-N or unknown names)") + return counters + + log.info( + "performer-driven: %d performers -> tpdb=%s stashdb=%s tubes=%s", + len(targets), + sum(1 for t in targets if t.tpdb_id), + sum(1 for t in targets if t.stashdb_id), + not skip_tubes, + ) + + # `skip_canonical=True` używane przez continuous worker — pomija TPDB/StashDB + # ingest (są wolne, ~3min/perf). Continuous skupia się na search-based backfill; + # canonical refresh top performerów dzieje się przez scheduled `_job_performer_driven` + # co 12h i przez on-demand `/refresh` API. + tpdb = None if skip_canonical else _try_build(TPDBConnector) + stashdb = None if skip_canonical else _try_build(StashDBConnector) + + for target in targets: + log.info("=== performer: %s (id=%s) ===", target.canonical_name, target.performer_id) + if not skip_canonical and not (target.tpdb_id or target.stashdb_id): + log.warning( + "skip %s: brak tpdb/stashdb external_ref, search-only by name nie pomoże w mergu", + target.canonical_name, + ) + counters.performers_skipped_no_refs += 1 + continue + + # 1. TPDB — limit canonical_per_performer_limit (popular performerzy mają tysiące + # scen, fetch wszystkich za każdym refresh = wolne ticki). Default 200 = top 2 + # strony, wystarczy do catch-up'u świeżych scen. + canonical_limit = canonical_per_performer_limit if canonical_per_performer_limit is not None else per_performer_limit + if tpdb and target.tpdb_id: + c = _ingest_iter_into_run( + source_kind=SourceKind.tpdb, + source_name="tpdb", + run_label=f"performer-driven:tpdb:{target.canonical_name}", + iterator_factory=lambda t=target: tpdb.fetch_scenes_for_performer( + t.tpdb_id, limit=canonical_limit + ), + ) + counters.merge("tpdb", c) + + # 2. StashDB + if stashdb and target.stashdb_id: + c = _ingest_iter_into_run( + source_kind=SourceKind.stashdb, + source_name="stashdb", + run_label=f"performer-driven:stashdb:{target.canonical_name}", + iterator_factory=lambda t=target: stashdb.fetch_scenes_for_performer( + t.stashdb_id, limit=canonical_limit + ), + ) + counters.merge("stashdb", c) + + # 3. Direct scrapery — per-tube HTTP scraping search. Każdy scraper to + # niezależny budżet rate-limit (osobny serwer tube). Wszystkie scrapery + # feedują do `Source(name='pornapp')` (legacy nazwa kept for DB compat) z + # external_id `f"{sitetag}:{url}"` — resolver mergeuje idempotentnie cross-source. + if not skip_tubes: + from app.connectors.direct_scrapers import ALL_DIRECT_SCRAPERS + + sitetag_filter = set(sitetags or []) or None + scrapers = [ + s for s in ALL_DIRECT_SCRAPERS + if sitetag_filter is None or s.sitetag in sitetag_filter + ] + + for scraper_cls in scrapers: + scraper = scraper_cls() + c = _ingest_iter_into_run( + source_kind=SourceKind.scraper, + source_name="pornapp", + run_label=f"performer-driven:direct:{scraper.sitetag}:{target.canonical_name}", + iterator_factory=lambda s=scraper, t=target: s.search( + t.canonical_name, page=1, limit=50 + ), + ) + counters.merge("pornapp", c) + + counters.performers_processed += 1 + + log.info("performer-driven done: %s", counters) + return counters + + +# ---- Internal -------------------------------------------------------- + +def _try_build(cls, **kwargs): # type: ignore[no-untyped-def] + try: + kw = {k: v for k, v in kwargs.items() if v is not None} + return cls(**kw) + except Exception as e: + log.warning("connector %s skipped: %s", cls.__name__, e) + return None + + +def _resolve_targets( + *, + performer_names: list[str] | None, + performer_ids: list[uuid.UUID] | None, + top_n: int, + fill_missing_refs: bool = False, +) -> list[PerformerTarget]: + """Mapuje wejście (UUID-y / nazwy / top-N) na PerformerTarget z TPDB/StashDB ID. + + Dla explicit nazw których nie znajdujemy w naszej bazie — robimy live lookup do + TPDB i StashDB (queryPerformers) żeby wyciągnąć kanoniczne UUID-y. Bez tego user + musiałby najpierw uruchomić newest-first ingest dla każdej performerki. + + Gdy `fill_missing_refs=True`: także dla performerów istniejących w bazie próbuje + live-lookup brakujących TPDB/StashDB refów. Use case: user pyta o explicit nazwy + i chce pełny catch-up, nie tylko z tych źródeł które aktualnie mamy zmapowane. + """ + targets: list[PerformerTarget] = [] + found_in_db_names: set[str] = set() + + with session_scope() as session: + if performer_ids: + performers = session.execute( + select(Performer).where(Performer.id.in_(performer_ids)) + ).scalars().all() + elif performer_names: + normalized = [n.strip().lower() for n in performer_names if n.strip()] + if not normalized: + return [] + performers = session.execute( + select(Performer).where(Performer.name_normalized.in_(normalized)) + ).scalars().all() + else: + performers = _top_n_by_scene_count(session, top_n) + + # Cache external_refs by performer + source kind + tpdb_src = session.execute( + select(Source).where(Source.kind == SourceKind.tpdb) + ).scalar_one_or_none() + stashdb_src = session.execute( + select(Source).where(Source.kind == SourceKind.stashdb) + ).scalar_one_or_none() + + for p in performers: + tpdb_id: str | None = None + stashdb_id: str | None = None + if tpdb_src: + tpdb_id = session.execute( + select(PerformerExternalRef.external_id).where( + PerformerExternalRef.performer_id == p.id, + PerformerExternalRef.source_id == tpdb_src.id, + ).limit(1) + ).scalar_one_or_none() + if stashdb_src: + stashdb_id = session.execute( + select(PerformerExternalRef.external_id).where( + PerformerExternalRef.performer_id == p.id, + PerformerExternalRef.source_id == stashdb_src.id, + ).limit(1) + ).scalar_one_or_none() + + if fill_missing_refs: + if tpdb_src and tpdb_id is None: + tpdb_id = _ensure_canonical_ref( + session, + performer_id=p.id, + name=p.canonical_name, + kind=SourceKind.tpdb, + source_id=tpdb_src.id, + ) + if stashdb_src and stashdb_id is None: + stashdb_id = _ensure_canonical_ref( + session, + performer_id=p.id, + name=p.canonical_name, + kind=SourceKind.stashdb, + source_id=stashdb_src.id, + ) + + targets.append( + PerformerTarget( + performer_id=p.id, + canonical_name=p.canonical_name, + tpdb_id=tpdb_id, + stashdb_id=stashdb_id, + ) + ) + found_in_db_names.add(p.name_normalized.strip().lower()) + + # Live lookup dla nazw nieznalezionych w bazie. + if performer_names: + missing = [ + n.strip() + for n in performer_names + if n.strip() and n.strip().lower() not in found_in_db_names + ] + for name in missing: + target = _lookup_via_api(name) + if target is not None: + targets.append(target) + + return targets + + +def _ensure_canonical_ref( + session: Session, + *, + performer_id: uuid.UUID, + name: str, + kind: SourceKind, + source_id: uuid.UUID, +) -> str | None: + """Live lookup TPDB/StashDB UUID by nazwa, INSERT PerformerExternalRef. + + Returns external_id (świeżo dodany albo już zlinkowany) lub None gdy: + - lookup zwrócił None (nie ma takiego performera w danym źródle) + - external_id znaleziony, ale jest już zmapowany do innego performera lokalnego + (conflict — wymaga ręcznej decyzji o merge) + """ + try: + if kind == SourceKind.tpdb: + ext_id = TPDBConnector().find_performer_id_by_name(name) + elif kind == SourceKind.stashdb: + ext_id = StashDBConnector().find_performer_id_by_name(name) + else: + return None + except Exception as e: + log.warning("%s lookup failed for %s: %s", kind.value, name, e) + return None + if not ext_id: + return None + existing = session.execute( + select(PerformerExternalRef).where( + PerformerExternalRef.source_id == source_id, + PerformerExternalRef.external_id == ext_id, + ) + ).scalar_one_or_none() + if existing: + if existing.performer_id != performer_id: + log.warning( + "%s ref conflict: ext_id=%s already maps to performer %s (asked for %s)", + kind.value, ext_id, existing.performer_id, performer_id, + ) + return None + return ext_id + session.add( + PerformerExternalRef( + source_id=source_id, + external_id=ext_id, + performer_id=performer_id, + confidence=0.85, + ) + ) + log.info("auto-filled %s ref for %s: %s", kind.value, name, ext_id) + return ext_id + + +def _lookup_via_api(name: str) -> PerformerTarget | None: + """Live lookup nazwy → TPDB UUID + StashDB UUID. Tworzy lokalny placeholder + Performer + external_refs żeby kolejne runy nie wymagały re-lookupu. + + Jeśli żadne ze źródeł nie zwróciło ID → None (nie ma sensu nic wciągać). + """ + tpdb_id: str | None = None + stashdb_id: str | None = None + try: + tpdb = TPDBConnector() + tpdb_id = tpdb.find_performer_id_by_name(name) + except Exception as e: + log.warning("tpdb lookup failed for %s: %s", name, e) + try: + stashdb = StashDBConnector() + stashdb_id = stashdb.find_performer_id_by_name(name) + except Exception as e: + log.warning("stashdb lookup failed for %s: %s", name, e) + + if not (tpdb_id or stashdb_id): + log.warning("performer '%s' not found in any external source — skipping", name) + return None + + log.info( + "live lookup: %s → tpdb=%s stashdb=%s", name, tpdb_id, stashdb_id + ) + + # Stwórz placeholder Performera w naszej bazie + external_refs, żeby resolver + # mógł zmappować scena→performer poprawnie i kolejne runy znalazły go w DB. + from app.normalize.text import normalize_person, slugify + + with session_scope() as session: + normalized = normalize_person(name) + existing = session.execute( + select(Performer).where(Performer.name_normalized == normalized) + ).scalar_one_or_none() + if existing is not None: + performer_id = existing.id + else: + performer = Performer( + canonical_name=name, + name_normalized=normalized, + slug=slugify(name), + ) + session.add(performer) + session.flush() + performer_id = performer.id + + if tpdb_id: + tpdb_src = session.execute( + select(Source).where(Source.kind == SourceKind.tpdb) + ).scalar_one_or_none() + if tpdb_src is not None: + exists = session.execute( + select(PerformerExternalRef).where( + PerformerExternalRef.source_id == tpdb_src.id, + PerformerExternalRef.external_id == tpdb_id, + ) + ).scalar_one_or_none() + if exists is None: + session.add( + PerformerExternalRef( + source_id=tpdb_src.id, + external_id=tpdb_id, + performer_id=performer_id, + confidence=0.9, + ) + ) + if stashdb_id: + stashdb_src = session.execute( + select(Source).where(Source.kind == SourceKind.stashdb) + ).scalar_one_or_none() + if stashdb_src is not None: + exists = session.execute( + select(PerformerExternalRef).where( + PerformerExternalRef.source_id == stashdb_src.id, + PerformerExternalRef.external_id == stashdb_id, + ) + ).scalar_one_or_none() + if exists is None: + session.add( + PerformerExternalRef( + source_id=stashdb_src.id, + external_id=stashdb_id, + performer_id=performer_id, + confidence=0.9, + ) + ) + + return PerformerTarget( + performer_id=performer_id, + canonical_name=name, + tpdb_id=tpdb_id, + stashdb_id=stashdb_id, + ) + + +def _top_n_by_scene_count(session: Session, n: int) -> list[Performer]: + rows = session.execute( + select(Performer, func.count(ScenePerformer.scene_id).label("c")) + .outerjoin(ScenePerformer, ScenePerformer.performer_id == Performer.id) + .group_by(Performer.id) + .order_by(func.count(ScenePerformer.scene_id).desc()) + .limit(n) + ).all() + return [p for p, _ in rows] + + +def _claim_next_for_search( + session: Session, *, refresh_after: timedelta, min_scene_count: int = 1 +) -> Performer | None: + """Wybiera 1 performera z queue + UPDATE last_searched_at = now() w jednej + transakcji (skip locked → safe pod konkurencyjnym workerze). + + Queue: + 1. Performerzy NIGDY niesearchowani (last_searched_at IS NULL) + 2. Performerzy searchowani > `refresh_after` temu + 3. Filtruj scene_count >= min_scene_count (eliminuje noise/false performerów) + 4. Order: NULLS FIRST, potem najstarsze last_searched_at + """ + cutoff = datetime.now(UTC) - refresh_after + # Subquery scene_count + sc_sub = ( + select( + ScenePerformer.performer_id.label("pid"), + func.count(ScenePerformer.scene_id).label("scene_count"), + ) + .group_by(ScenePerformer.performer_id) + .subquery() + ) + + # NOTE: nie używamy FOR UPDATE bo PostgreSQL nie pozwala na to z GROUP BY + # subquery (scene_count agg). APScheduler max_instances=1 gwarantuje że tylko + # jeden tick runa się na raz, więc race nie jest realny. + row = session.execute( + select(Performer) + .join(sc_sub, sc_sub.c.pid == Performer.id, isouter=False) + .where(sc_sub.c.scene_count >= min_scene_count) + .where( + (Performer.last_searched_at.is_(None)) + | (Performer.last_searched_at < cutoff) + ) + .order_by( + Performer.last_searched_at.asc().nullsfirst(), + sc_sub.c.scene_count.desc(), + ) + .limit(1) + ).scalar_one_or_none() + + if row is None: + return None + + row.last_searched_at = datetime.now(UTC) + row.search_run_count = (row.search_run_count or 0) + 1 + session.flush() + return row + + +def run_continuous_one_at_a_time( + *, + refresh_after_days: int = 30, + min_scene_count: int = 1, + per_performer_limit: int | None = None, + canonical_per_performer_limit: int | None = 100, +) -> StrategyCounters | None: + """Pobiera 1 performera z priority queue, runs full performer-driven search, + update last_searched_at. Zwraca counters (lub None gdy queue pusta). + + Wywoływane przez APScheduler interval job (np. co 60s). + """ + refresh_after = timedelta(days=refresh_after_days) + with session_scope() as session: + target_perf = _claim_next_for_search( + session, + refresh_after=refresh_after, + min_scene_count=min_scene_count, + ) + if target_perf is None: + log.info("performer-continuous: queue empty (all searched within %dd)", refresh_after_days) + return None + # Wyciągamy ID przed zamknięciem sesji bo chcemy dalej operować poza nią + performer_id = target_perf.id + canonical_name = target_perf.canonical_name + run_count = target_perf.search_run_count + session.commit() + + log.info( + "performer-continuous: claimed %s (id=%s, run #%d)", + canonical_name, performer_id, run_count, + ) + + counters = run_performer_driven( + performer_ids=[performer_id], + top_n=0, # ignorowane gdy performer_ids podane + per_performer_limit=per_performer_limit, + canonical_per_performer_limit=canonical_per_performer_limit, + skip_canonical=True, # tylko direct scrapery (backward fill) + ) + return counters + + +def _ingest_iter_into_run( + *, + source_kind: SourceKind, + source_name: str, + run_label: str, + iterator_factory, # type: ignore[no-untyped-def] +) -> dict[str, int]: + """Wariant ingest_from_connector dla iteratorów ad-hoc (per-performer pull). + + Otwiera IngestRun, iteruje, _process_scene per scene, finalizuje run. Counters + podobne do `ingest_from_connector` ale per-call. + """ + counters = {"seen": 0, "new": 0, "updated": 0, "skipped": 0, "errors": 0} + + with session_scope() as session: + source = get_or_create_source(session, kind=source_kind, name=source_name) + run = IngestRun(source_id=source.id, status=IngestStatus.running) + session.add(run) + session.flush() + run_id = run.id + source_id = source.id + + log.info("ingest start label=%s run_id=%s", run_label, run_id) + + failed = False + try: + for raw in iterator_factory(): + counters["seen"] += 1 + try: + _process_scene(source_id=source_id, raw_scene=raw, counters=counters) + except Exception as exc: # pragma: no cover - obronnie + counters["errors"] += 1 + log.exception("performer-driven scene failed external_id=%s: %s", raw.external_id, exc) + if counters["errors"] > 50: + raise + except Exception as exc: + failed = True + log.exception("performer-driven run failed (%s): %s", run_label, exc) + + with session_scope() as session: + run = session.get(IngestRun, run_id) + assert run is not None + run.finished_at = datetime.now(UTC) + if failed: + run.status = IngestStatus.failed + elif counters["errors"] > 0: + run.status = IngestStatus.partial + else: + run.status = IngestStatus.success + run.records_seen = counters["seen"] + run.records_new = counters["new"] + run.records_updated = counters["updated"] + + log.info("ingest done label=%s counters=%s", run_label, counters) + return counters + + +# Suppress unused-import lint +_ = _hash_raw +_ = timedelta diff --git a/app/scheduler/worker.py b/app/scheduler/worker.py new file mode 100644 index 0000000..08936af --- /dev/null +++ b/app/scheduler/worker.py @@ -0,0 +1,293 @@ +"""Worker process. + +Tryby: + --once --source=tpdb|stashdb [--limit=N] [--no-delta] + Klasyczny ingest z jednego connectora. + + --once --strategy=performer-driven [--top-n=N] [--performers="Lola,Mia"] + Strategia performer-driven: dla top-N performerów (lub explicit listy) + pobierz wszystkie sceny z TPDB/StashDB + szukaj w direct tube scrapach. + Patrz `app/scheduler/performer_driven.py`. + +Pełen scheduler (APScheduler + cron) — patrz `jobs.py`. +""" +from __future__ import annotations + +import argparse +import logging +import signal +import sys +import time +import uuid + +from app.config import get_settings +from app.connectors.base import BaseConnector +from app.connectors.stashdb import StashDBConnector +from app.connectors.tpdb import TPDBConnector +from app.ingest import ingest_from_connector +from app.scheduler.performer_driven import run_performer_driven + +log = logging.getLogger(__name__) + + +CONNECTORS: dict[str, type[BaseConnector]] = { + "tpdb": TPDBConnector, + "stashdb": StashDBConnector, +} + +STRATEGIES = ("performer-driven", "bulk-dedup", "movies") + + +# Movie connectors zarejestrowane w app/connectors/__init__.py — wspólne miejsce +# dla scheduler-job i ręcznego --strategy=movies. +def _movie_connectors() -> dict[str, type]: + from app.connectors import get_movie_connectors + return dict(get_movie_connectors()) + + +def _build_connector(name: str) -> BaseConnector: + cls = CONNECTORS.get(name) + if cls is None: + raise SystemExit(f"unknown source: {name}. available={list(CONNECTORS)}") + return cls() + + +def run_once_source(*, source: str, limit: int | None, use_delta: bool) -> int: + connector = _build_connector(source) + counters = ingest_from_connector(connector, limit=limit, use_delta=use_delta) + log.info("run_once finished counters=%s", counters) + return 0 if counters["errors"] == 0 else 1 + + +def run_once_strategy( + *, + strategy: str, + top_n: int, + performers: list[str] | None, + performer_ids: list[uuid.UUID] | None, + sitetags: list[str] | None, + per_performer_limit: int | None, + skip_tubes: bool = False, + dedup_strategy: str = "all", + cross_source_only: bool = False, + dry_run: bool = False, +) -> int: + if strategy == "performer-driven": + counters = run_performer_driven( + performer_names=performers, + performer_ids=performer_ids, + top_n=top_n, + sitetags=sitetags, + per_performer_limit=per_performer_limit, + skip_tubes=skip_tubes, + ) + log.info( + "performer-driven done: processed=%d skipped_no_refs=%d per_source=%s", + counters.performers_processed, + counters.performers_skipped_no_refs, + counters.per_source, + ) + total_errors = sum(s.get("errors", 0) for s in counters.per_source.values()) + return 0 if total_errors == 0 else 1 + + if strategy == "movies": + from app.ingest import ingest_movies_from_connector + registry = _movie_connectors() + # `performers` field reusowane: lista `paradisehill,streamporn,...` lub "all". + # Default: wszystkie. CLI: --performers=paradisehill albo --performers=streamporn,mangoporn + which = performers or ["all"] + targets: list[str] = list(registry.keys()) if "all" in which else which + any_error = False + for tgt in targets: + cls = registry.get(tgt) + if cls is None: + log.error("unknown movie source: %s (available: %s)", tgt, list(registry)) + any_error = True + continue + log.info("movie ingest %s starting (delta=%s, limit=%s)", tgt, True, per_performer_limit) + try: + counters = ingest_movies_from_connector( + cls(), + use_delta=True, + limit=per_performer_limit, + ) + log.info("movie ingest %s done: %s", tgt, counters) + if counters.get("errors", 0) > 0: + any_error = True + except Exception: + log.exception("movie ingest %s failed", tgt) + any_error = True + return 1 if any_error else 0 + + if strategy == "bulk-dedup": + from app.scheduler.bulk_dedup import run_bulk_dedup + bc = run_bulk_dedup( + strategy=dedup_strategy, + dry_run=dry_run, + cross_source_only=cross_source_only, + ) + log.info("bulk-dedup done: %s", bc) + return 0 + + raise SystemExit(f"unknown strategy: {strategy}. available={STRATEGIES}") + + +def run_forever() -> int: + """APScheduler scheduled mode — odpala joby cron-like wg config (env-driven). + + Joby: + tpdb / stashdb — co N godzin, delta od ostatniego successful run + performer-driven — co N godzin, top-N performerów z bazy + performer-continuous — tick co N sekund, 1 performer per tick (tube backfill) + """ + from app.scheduler.jobs import build_scheduler # opóźniony import (apscheduler) + + settings = get_settings() + cfg = { + "tpdb_hours": settings.sched_tpdb_hours or None, + "stashdb_hours": settings.sched_stashdb_hours or None, + "performer_driven_hours": settings.sched_performer_driven_hours or None, + "performer_driven_top_n": settings.sched_performer_driven_top_n, + "performer_continuous_seconds": getattr( + settings, "sched_performer_continuous_seconds", 60 + ) or None, + "performer_continuous_refresh_days": getattr( + settings, "sched_performer_continuous_refresh_days", 30 + ), + "movie_ingest_hours": getattr(settings, "sched_movie_ingest_hours", 24) or None, + # Browse-latest scheduler — freshporno/porn00/pornxp browse newest scenes raz + # dziennie (~100 scen/tube/run). Bug: brak tego klucza w worker config przez + # ~tydzień powodował że browse-mode nigdy nie odpalał (15k freshporno z 2026-05-13 + # to bulk import jednorazowy). Bug-report 93d3c485 (2026-05-19) "brak freshporno". + "browse_latest_hours": getattr(settings, "sched_browse_latest_hours", 24) or None, + "browse_latest_max_pages": getattr(settings, "sched_browse_latest_max_pages", 5), + } + sched = build_scheduler(cfg) + log.info("worker scheduled mode starting (jobs=%d)", len(sched.get_jobs())) + try: + sched.start() + except (KeyboardInterrupt, SystemExit): + log.info("worker received shutdown") + return 0 + + +def _split_csv(s: str | None) -> list[str]: + if not s: + return [] + return [x.strip() for x in s.split(",") if x.strip()] + + +def main(argv: list[str] | None = None) -> int: + settings = get_settings() + logging.basicConfig( + level=settings.log_level, + format="%(asctime)s %(levelname)s %(name)s %(message)s", + ) + + # Sentry: pusty DSN → no-op. Worker errory (failed ingest, dead connector, + # connection loss do TPDB/StashDB) trafiają do Sentry z release tagiem + # `goon-worker@...` — odróżnia ich od API errors w UI. + if settings.sentry_dsn: + import sentry_sdk + from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration + + sentry_sdk.init( + dsn=settings.sentry_dsn, + environment=settings.sentry_environment, + traces_sample_rate=settings.sentry_traces_sample_rate, + integrations=[SqlalchemyIntegration()], + release="goon-worker@0.1.0", + ) + + parser = argparse.ArgumentParser(prog="goon-worker") + parser.add_argument("--once", action="store_true", help="Uruchom jeden cykl ingest i zakończ") + parser.add_argument("--source", choices=sorted(CONNECTORS), help="Nazwa źródła (np. tpdb)") + parser.add_argument( + "--strategy", + choices=STRATEGIES, + help="Strategia ingest (alternatywa do --source)", + ) + parser.add_argument("--limit", type=int, default=None, help="Limit scen do pobrania") + parser.add_argument( + "--no-delta", + action="store_true", + help="Pomija filtr od ostatniego successful run (full re-pull)", + ) + # Strategy-specific options + parser.add_argument("--top-n", type=int, default=20, help="Liczba top performerów (performer-driven)") + parser.add_argument( + "--performers", + type=str, + default=None, + help="Lista nazwisk performerów (CSV) — overrides top-N", + ) + parser.add_argument( + "--performer-ids", + type=str, + default=None, + help="Lista naszych kanonicznych UUID-ów (CSV) — overrides top-N i --performers", + ) + parser.add_argument( + "--sitetags", + type=str, + default=None, + help="CSV sitetagów do tube search (default: wszystkie z ALL_DIRECT_SCRAPERS)", + ) + parser.add_argument( + "--skip-tubes", + action="store_true", + help="Pomija tube discovery (canonical-only catch-up: tylko TPDB+StashDB)", + ) + parser.add_argument( + "--per-performer-limit", + type=int, + default=None, + help="Limit scen z jednego connectora per performer (debug)", + ) + parser.add_argument( + "--dedup-strategy", + choices=("phash", "performers", "all"), + default="all", + help="bulk-dedup zakres: phash | performers | all", + ) + parser.add_argument( + "--cross-source-only", + action="store_true", + help="bulk-dedup: tylko pary (tpdb-only ↔ stashdb-only) — najwyższa wartość," + " pomija pary już zmergowane lub same-source", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Tylko loguj, nic nie zapisuj (dla bulk-dedup preview)", + ) + args = parser.parse_args(argv) + + if args.once: + if args.strategy: + performer_ids = None + if args.performer_ids: + try: + performer_ids = [uuid.UUID(s) for s in _split_csv(args.performer_ids)] + except ValueError as e: + parser.error(f"invalid --performer-ids: {e}") + return run_once_strategy( + strategy=args.strategy, + top_n=args.top_n, + performers=_split_csv(args.performers) or None, + performer_ids=performer_ids, + sitetags=_split_csv(args.sitetags) or None, + per_performer_limit=args.per_performer_limit, + skip_tubes=args.skip_tubes, + dedup_strategy=args.dedup_strategy, + cross_source_only=args.cross_source_only, + dry_run=args.dry_run, + ) + if not args.source: + parser.error("--once wymaga --source albo --strategy") + return run_once_source(source=args.source, limit=args.limit, use_delta=not args.no_delta) + return run_forever() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..87c28a7 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,66 @@ +<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width,initial-scale=1"> + <title>{% block title %}goon{% endblock %} + + + + +
+

goon

+
+
+
+ {% block content %}{% endblock %} +
+ + diff --git a/app/templates/candidate_detail.html b/app/templates/candidate_detail.html new file mode 100644 index 0000000..0141029 --- /dev/null +++ b/app/templates/candidate_detail.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} +{% block title %}Candidate · goon{% endblock %} + +{% block content %} +
+ ← back + {{ cand.kind }} · {{ cand.status }} + {{ '%.2f' % cand.score }} +
+ + {% if cand.status == "pending" %} +
+
+ + + +
+
+ {% else %} +
Resolved.
+ {% endif %} + +
+
+

LEFT

+ {% if cand.left %} + {{ scene_block(cand.left) }} + {% else %} +
scene missing (id={{ cand.left_id }})
+ {% endif %} +
+
+

RIGHT

+ {% if cand.right %} + {{ scene_block(cand.right) }} + {% else %} +
scene missing (id={{ cand.right_id }})
+ {% endif %} +
+
+ +
+

Reasons

+
{{ cand.reasons | tojson(indent=2) }}
+
+ + {% macro scene_block(s) %} +
+
{{ s.title }}
+
+ {{ s.release_date or '?' }} + {% if s.studio %} · {{ s.studio.name }}{% endif %} + {% if s.duration_sec %} · {{ '%dm%02ds' % (s.duration_sec // 60, s.duration_sec % 60) }}{% endif %} +
+ {% if s.code %}
code: {{ s.code }}
{% endif %} +
+ {% for p in s.performers %} + + {{ p.canonical_name }}{% if p.as_alias %} as {{ p.as_alias }}{% endif %} + + {% endfor %} +
+
+ {% for t in s.tags %}{{ t.name }}{% endfor %} +
+
+ sources: + {% for r in s.external_refs %} + {{ r.source }}{% if r.external_id %} · {{ r.external_id[:8] }}{% endif %} + {% endfor %} +
+ {% if s.description %} +
{{ s.description[:280] }}{% if s.description|length > 280 %}…{% endif %}
+ {% endif %} +
+ {% endmacro %} +{% endblock %} diff --git a/app/templates/candidates_list.html b/app/templates/candidates_list.html new file mode 100644 index 0000000..60d162e --- /dev/null +++ b/app/templates/candidates_list.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} +{% block title %}{{ status_label }} merge candidates · goon{% endblock %} + +{% block content %} +
+ {{ status_label }} + · {{ total }} total +
+ + {% if items %} + {% for it in items %} +
+
+ +
+ {{ '%.2f' % it.score }} +
+
+
+ {% endfor %} + + {% if total > per_page %} +
+ {% if page > 1 %} + ← prev + {% endif %} + page {{ page }} of {{ ((total - 1) // per_page) + 1 }} + {% if page * per_page < total %} + next → + {% endif %} +
+ {% endif %} + {% else %} +
No candidates with status = {{ status }}.
+ {% endif %} +{% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..25376f7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,62 @@ +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:-goon} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-goon} + POSTGRES_DB: ${POSTGRES_DB:-goon} + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - pgdata:/var/lib/postgresql/data + - ./alembic/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 3s + retries: 12 + start_period: 10s + restart: unless-stopped + + api: + build: + context: . + dockerfile: Dockerfile + # Auto-apply pending migrations on startup, then exec uvicorn. Single API + # replica only — multi-replica deployments must run migrations out-of-band. + command: + - bash + - -c + - | + alembic upgrade head && \ + exec uvicorn app.main:app --host 0.0.0.0 --port 8000 + env_file: .env + environment: + DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-goon}:${POSTGRES_PASSWORD:-goon}@db:5432/${POSTGRES_DB:-goon} + BACKEND_PUBLIC_URL: ${BACKEND_PUBLIC_URL:-} + ports: + - "${API_PORT:-8000}:8000" + depends_on: + db: + condition: service_healthy + restart: unless-stopped + + worker: + build: + context: . + dockerfile: Dockerfile + command: ["python", "-m", "app.scheduler.worker"] + env_file: .env + environment: + DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-goon}:${POSTGRES_PASSWORD:-goon}@db:5432/${POSTGRES_DB:-goon} + depends_on: + db: + condition: service_healthy + api: + # Worker waits for `api` so migrations are applied before any ingest job + # tries to write. `api` itself blocks on `db` healthcheck. + condition: service_started + restart: unless-stopped + +volumes: + pgdata: diff --git a/landing/index.html b/landing/index.html new file mode 100644 index 0000000..c99c5f2 --- /dev/null +++ b/landing/index.html @@ -0,0 +1,304 @@ + + + + + + + + goon · self-hosted adult content aggregator + + + + + + + + + + +
+
+
+

18+ Adults Only

+

+ This site discusses software for indexing adult content. You must be of legal + age in your jurisdiction to enter. +

+
+ + + Take me out + +
+
+
+ + +
+
+
+
+ goon +
+ +

+ Self-hosted
+ scene catalog
+ for grown-ups. +

+ +

+ Goon indexes scene metadata from TPDB & StashDB, deduplicates across + 30+ public tubes, and serves a fast mobile client. Zero ads. Zero tracking. + Your data stays on your VPS. +

+ + + +

+ Android only · self-hosted backend required · 18+ +

+
+
+ + +
+
+

What it does

+

+ Goon is not a tube. It does not host, transcode, or proxy content. + It is a metadata aggregator + mobile UI for finding scenes that are + already publicly available. +

+ +
+
+
+

Multi-source ingest

+

+ TPDB & StashDB metadata + 30+ public tubes. Cross-source + deduplication via perceptual-hash thumbnails + title-Levenshtein matching. +

+
+ +
+
+

Per-performer backfill

+

+ A continuous worker walks performers by staleness and back-fills tube scenes + for the longest-stale first. Completeness over recency. +

+
+ +
+
+

Smart stream resolution

+

+ yt-dlp for mainstream tubes + P.A.C.K.E.R. unpacker for JWPlayer hosters + + WebView fallback for IP-bound CDNs. Streams direct from source, no transcoding. +

+
+ +
+
+

Mobile-first UI

+

+ React Native + Expo. Scene grid, performer pages, watch history, + favorites, hold-to-preview thumbnails. Built for thumb scrolling. +

+
+ +
+
+

Privacy by default

+

+ App lock (PIN + biometrics), FLAG_SECURE screenshot block, age gate. + No analytics. No telemetry unless YOU configure Sentry with your own DSN. +

+
+ +
+
+

100% self-hosted

+

+ One docker compose up -d + and you own the API, the DB, the worker. No SaaS dependencies. + Your search history is yours. +

+
+
+
+
+ + +
+
+

In the app

+

Screen-shots from a real install — censored where needed.

+ + +
+
+ Scenes grid +
+
+ Scene detail +
+
+ Performer page +
+
+ Favorites +
+
+
+
+ + +
+
+

Quick start

+

5 commands. Backend runs in 30 seconds on any Docker host.

+ +
+
$ git clone https://github.com/REPLACE_PERSONA/goon.git
+
$ cd goon && cp .env.example .env
+
$ # edit .env: set TPDB_API_TOKEN, STASHDB_API_KEY, API_KEYS
+
$ docker compose up -d
+
$ curl localhost:8000/health
+
{"status":"ok"}
+
+ +

+ Then download the APK above, point it at your backend, paste an API key. + Full docs in the + README. +

+
+
+ + + + + +
+
+
+
+
+ goon +
+

Self-hosted adult content metadata aggregator.

+

MIT license. No warranty. 18+ jurisdictions only.

+
+
+ GitHub + Releases + Docs +
+
+

+ Goon does not host, transcode, store, or distribute any media. It scrapes + publicly-available metadata and links out to the source. Operators are + responsible for complying with local law. See README » Disclaimer. +

+
+ + + + diff --git a/mobile/.gitignore b/mobile/.gitignore new file mode 100644 index 0000000..6ccde07 --- /dev/null +++ b/mobile/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.expo/ +.expo-shared/ +dist/ +web-build/ +*.log +.env* diff --git a/mobile/App.tsx b/mobile/App.tsx new file mode 100644 index 0000000..a894294 --- /dev/null +++ b/mobile/App.tsx @@ -0,0 +1,327 @@ +// MUST be the first import for react-navigation 7 + reanimated/gesture-handler +// in release builds. Otherwise Hermes-bundled APK boots to a white screen. +import 'react-native-gesture-handler'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import * as Sentry from '@sentry/react-native'; +import Constants from 'expo-constants'; +import { registerRootComponent } from 'expo'; +import * as ScreenCapture from 'expo-screen-capture'; +import { StatusBar } from 'expo-status-bar'; +import * as Updates from 'expo-updates'; +import { + apkInstallerAvailable, + canRequestInstall, + installApk, + openInstallPermissionSettings, +} from './src/native/apkInstaller'; +import React, { useEffect, useRef, useState } from 'react'; +import { ActivityIndicator, Alert, AppState, AppStateStatus, Linking, View } from 'react-native'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { GoonClient } from './src/api'; +import { ClientProvider } from './src/ClientContext'; +import { ErrorBoundary } from './src/ErrorBoundary'; +import { isAccepted as isAgeGateAccepted } from './src/lib/agegate'; +import { APP_VERSION } from './src/lib/appVersion'; +import { getSettings as getLockSettings } from './src/lib/applock'; +import { AppNavigator } from './src/navigation'; +import { AgeGateScreen } from './src/screens/AgeGateScreen'; +import { AppLockScreen } from './src/screens/AppLockScreen'; +import { LoginScreen } from './src/screens/LoginScreen'; +import { clearCredentials, loadCredentials } from './src/storage'; +import { theme } from './src/theme'; + +// Sentry: init przed registerRootComponent. Pusty DSN → SDK no-op (devel build bez +// crash reportingu). DSN czytamy z `EXPO_PUBLIC_SENTRY_DSN` (Expo SDK 49+ auto-injectuje +// env var do bundla przy buildzie), z fallback na app.json `extra.sentryDsn` (legacy). +// Lokalnie: ustaw w `mobile/.env` (gitignored). CI: GitHub Secret `SENTRY_DSN` → +// workflow exportuje jako `EXPO_PUBLIC_SENTRY_DSN` przed gradle build. +const SENTRY_DSN = + process.env.EXPO_PUBLIC_SENTRY_DSN || + (Constants.expoConfig?.extra?.sentryDsn as string | undefined); +const SENTRY_ENV = + process.env.EXPO_PUBLIC_SENTRY_ENVIRONMENT || + (Constants.expoConfig?.extra?.sentryEnvironment as string | undefined) || + 'production'; +if (SENTRY_DSN) { + Sentry.init({ + dsn: SENTRY_DSN, + environment: SENTRY_ENV, + // 0.1 = 10% sesji ma full performance trace. Free tier ma 10k transactions/mies, + // przy 1 użytkowniku to wieczność. Errory są zawsze 100%. + tracesSampleRate: 0.1, + // Lokalna wersja appki — dystyngwuje builds w Sentry release filter. expo-constants + // czyta `version` z `app.json` (a nie z package.json), więc tutaj `expoConfig.version`. + release: `goon-mobile@${Constants.expoConfig?.version ?? '0.0.0'}`, + // Boot diagnostic: jeden message przy starcie z tagiem `source:boot` pozwala + // potwierdzić że SDK rzeczywiście wysyła. Jeśli w Sentry nie ma go po starcie + // appki → init nie startuje albo zaprzeszł blockera (network/DNS/uplink). + // sendDefaultPii=false (default) — IP / user-agent / cookies nie idą do Sentry. + // attachScreenshot=false — scena thumbnails / video frames nie wyciekną w error reports. + }); + // Capture jednorazowo przy bundle load (przed `App` render). Sentry RN buforuje + // events do flush przy next tick — wystartuje równo z bridge ready. + Sentry.captureMessage('mobile boot OK', { + level: 'info', + tags: { source: 'boot' }, + }); +} + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 30_000 }, + }, +}); + +export default function App() { + const [hydrated, setHydrated] = useState(false); + const [ageAccepted, setAgeAccepted] = useState(false); + const [client, setClient] = useState(null); + const [bootError, setBootError] = useState(null); + const [locked, setLocked] = useState(false); + const [lockReady, setLockReady] = useState(false); + const backgroundedAt = useRef(null); + + useEffect(() => { + (async () => { + try { + const accepted = await isAgeGateAccepted(); + setAgeAccepted(accepted); + const creds = await loadCredentials(); + if (creds) setClient(new GoonClient(creds.baseUrl, creds.apiKey)); + const lockSettings = await getLockSettings(); + if (lockSettings.enabled && lockSettings.hasPin) { + setLocked(true); + } + } catch (e) { + // SecureStore may fail on devices without lockscreen / keychain quirks — + // surface the error instead of silently white-screening. + setBootError(e instanceof Error ? e.message : String(e)); + } finally { + setHydrated(true); + setLockReady(true); + } + })(); + }, []); + + // FLAG_SECURE — blocks screenshots and hides app preview from app switcher. + useEffect(() => { + ScreenCapture.preventScreenCaptureAsync('goon-applock').catch(() => {}); + return () => { + ScreenCapture.allowScreenCaptureAsync('goon-applock').catch(() => {}); + }; + }, []); + + // Update flow — DWUSTOPNIOWY: + // B) Expo Updates (silent JS bundle): jeśli backend ma nowszy bundle + // manifest dla naszego runtimeVersion, fetch + reload bez ANY dialogu. + // Większość naszych zmian (api.ts, screens, components) to JS — silent. + // A) APK install (PackageInstaller native): jeśli backend `/version` ma + // wersję ZNACZNIE wyższą niż embedded (native bump z nowym Kotlin/manifest), + // pokaż dialog → native installer (1-click "Install update?"). + // Fallback: gdy expo-updates SDK niedostępne (dev build, web), używamy starego + // flow z Linking.openURL. + const updateChecked = useRef(false); + useEffect(() => { + if (!client || updateChecked.current) return; + updateChecked.current = true; + (async () => { + // Etap B: silent JS update + if (Updates.isEnabled) { + try { + const upd = await Updates.checkForUpdateAsync(); + if (upd.isAvailable) { + await Updates.fetchUpdateAsync(); + // Cichy restart — user zobaczy splash na 200ms i jest w nowej wersji. + await Updates.reloadAsync(); + return; // reloadAsync nie wraca + } + } catch { + // OTA fail (brak sieci / 204 / TLS pin mismatch) — leciemy do A. + } + } + // Etap A: APK update prompt (native bump albo Expo Updates disabled) + try { + const out = await client.getServerVersion(); + const bundled = APP_VERSION; + if (out.version && out.version !== bundled && out.apk_url) { + // In-app installer dostępny → "Install" zamiast "Download" + if (apkInstallerAvailable) { + const pendingApkUrl = out.apk_url!; + const runInstall = async () => { + try { + await installApk(pendingApkUrl); + // PackageInstaller pokazuje natywny dialog — JS już nie ma co robić. + } catch (e) { + Alert.alert('Install failed', e instanceof Error ? e.message : String(e)); + } + }; + const tryInstall = async () => { + const granted = await canRequestInstall(); + if (granted) { + await runInstall(); + return; + } + // Brak permission → wystaw Settings + zarejestruj AppState listener + // który po powrocie do appki (granted=true) automatycznie odpali + // installApk. Bez tego user musi zamknąć i otworzyć dialog Update + // jeszcze raz po toggling permission — zgłaszane w bug-report 97adff93. + const sub = AppState.addEventListener('change', async (next) => { + if (next !== 'active') return; + const now = await canRequestInstall(); + if (now) { + sub.remove(); + await runInstall(); + } + }); + // Safety timeout: zdejmij listener po 5 minutach jeśli user nie + // grantnął (uniknie wycieku w przypadku Settings-canceled). + setTimeout(() => sub.remove(), 5 * 60 * 1000); + Alert.alert( + 'Install permission', + 'Allow installing updates from Goon? Toggle "Allow from this source", then tap back — install starts automatically.', + [ + { text: 'Cancel', style: 'cancel', onPress: () => sub.remove() }, + { text: 'Open Settings', onPress: () => openInstallPermissionSettings() }, + ], + ); + }; + Alert.alert( + 'Update available', + `Server: ${out.version}\nApp: ${bundled}\n\nInstall update now?`, + [ + { text: 'Later', style: 'cancel' }, + { text: 'Install', onPress: tryInstall }, + ], + ); + } else { + // Fallback: Chrome download + Alert.alert( + 'Update available', + `Server: ${out.version}\nApp: ${bundled}\n\nTap "Download" to install the latest APK.`, + [ + { text: 'Later', style: 'cancel' }, + { + text: 'Download', + onPress: () => Linking.openURL(out.apk_url!).catch(() => {}), + }, + ], + ); + } + } + } catch { + // best-effort + } + })(); + }, [client]); + + // AppState lock-on-background — re-lock if user backgrounded longer than timeout. + useEffect(() => { + const sub = AppState.addEventListener('change', async (next: AppStateStatus) => { + if (next === 'background' || next === 'inactive') { + backgroundedAt.current = Date.now(); + return; + } + if (next === 'active') { + const at = backgroundedAt.current; + backgroundedAt.current = null; + if (at === null) return; + const settings = await getLockSettings(); + if (!(settings.enabled && settings.hasPin)) return; + const elapsed = Math.floor((Date.now() - at) / 1000); + if (elapsed >= settings.timeoutSeconds) { + setLocked(true); + } + } + }); + return () => sub.remove(); + }, []); + + if (!hydrated || !lockReady) { + return ( + + + + ); + } + + // Age gate takes precedence over everything else — must be accepted at the + // current ToS version before any UI (lock, login, browse) is reachable. + if (!ageAccepted) { + return ( + + + + setAgeAccepted(true)} /> + + + ); + } + + // App lock takes precedence over everything (even login). Refresh settings every + // unlock so a freshly-disabled lock doesn't bounce back next background. + if (locked) { + return ( + + + + setLocked(false)} + onLogout={async () => { + await clearCredentials(); + setClient(null); + queryClient.clear(); + setLocked(false); + }} + /> + + + ); + } + + return ( + + + + + + {bootError ? ( + + + + + + ) : null} + {client ? ( + + { + await clearCredentials(); + setClient(null); + queryClient.clear(); + }} + /> + + ) : ( + setClient(new GoonClient(baseUrl, apiKey))} + /> + )} + + + + + ); +} + +// `package.json:main` points to this file directly, so we must register the +// root component ourselves (the standard `expo/AppEntry` boilerplate is bypassed). +// Without this line release builds load the bundle but throw +// `Invariant Violation: "main" has not been registered` and white-screen. +// +// Sentry.wrap() owija App w error boundary + adds touch event tracking — standardowy +// pattern z docs Sentry RN. No-op gdy DSN pusty. +registerRootComponent(SENTRY_DSN ? Sentry.wrap(App) : App); diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 0000000..c0085be --- /dev/null +++ b/mobile/README.md @@ -0,0 +1,71 @@ +# goon mobile (Expo / React Native) + +Mobile client do self-hosted goon backendu. iOS/Android jeden codebase. + +## Setup + +```bash +cd mobile +npm install +npx expo start +``` + +Następnie: +- **Expo Go** (iOS/Android): zeskanuj QR z terminala +- **Android emulator**: `npm run android` +- **iOS simulator**: `npm run ios` (tylko macOS) +- **Web preview**: `npm run web` + +## Konfiguracja + +Po pierwszym uruchomieniu zobaczysz ekran logowania: + +- **Backend URL** — adres twojego goon backendu, np.: + - `http://192.168.1.10:8000` (LAN) + - `https://goon.tvojadomena.dev` (przez Cloudflare Tunnel/Caddy) + - `http://100.x.x.x:8000` (przez Tailscale) +- **API Key** — klucz wygenerowany dla backendu, ustawiony w `.env`: + ``` + API_KEYS= + ``` + Generowanie: + ```bash + python -c "import secrets; print(secrets.token_urlsafe(32))" + ``` + +Klucz jest trzymany w `expo-secure-store` (Keychain na iOS, Keystore na Androidzie). + +## Ekrany + +- **Login** — backend URL + API key, weryfikacja przez `/healthz` + `/scenes` +- **Scenes** — lista scen z search; pull-to-refresh +- **Scene detail** — performerzy, tagi, źródła, opis +- **Merge queue** — pending merge candidates, sortowane po score (desc) +- **Merge detail** — side-by-side dwóch scen + reasons + akcje: + - **Merge → keep LEFT** (default — left to wcześniejsza/kanoniczna) + - **Merge → keep RIGHT** (gdy nowsza wersja ma lepsze metadane) + - **Reject (keep both)** — to nie jest duplikat, zostawiamy oddzielnie + +## Stack + +- Expo SDK 52 + React Native 0.76 (new architecture) +- TypeScript strict +- React Navigation 7 (native stack) +- TanStack Query 5 (cache, optimistic invalidation) +- expo-secure-store (credentials) + +## Build .apk (sideload na Androida) + +```bash +npx eas build --profile preview --platform android +``` + +Wymaga konta Expo. Bez niego można użyć `expo prebuild` + `gradlew assembleRelease`. + +## Dlaczego nie PWA + +Wybrany RN+Expo zamiast PWA bo: +- swipe gestures pasują do triage merge queue +- secure-store jest natywny (Keychain/Keystore) zamiast localStorage +- pull-to-refresh i FlatList virtualization out-of-the-box +- Jeden .apk można rozdać testerom bez App Store diff --git a/mobile/android/.gitignore b/mobile/android/.gitignore new file mode 100644 index 0000000..8a6be07 --- /dev/null +++ b/mobile/android/.gitignore @@ -0,0 +1,16 @@ +# OSX +# +.DS_Store + +# Android/IntelliJ +# +build/ +.idea +.gradle +local.properties +*.iml +*.hprof +.cxx/ + +# Bundle artifacts +*.jsbundle diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle new file mode 100644 index 0000000..5274938 --- /dev/null +++ b/mobile/android/app/build.gradle @@ -0,0 +1,193 @@ +apply plugin: "com.android.application" +apply plugin: "org.jetbrains.kotlin.android" +apply plugin: "com.facebook.react" + +def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath() + +/** + * This is the configuration block to customize your React Native Android app. + * By default you don't need to apply any configuration, just uncomment the lines you need. + */ +react { + entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim()) + reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile() + hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc" + codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile() + + // Use Expo CLI to bundle the app, this ensures the Metro config + // works correctly with Expo projects. + cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim()) + bundleCommand = "export:embed" + + /* Folders */ + // The root of your project, i.e. where "package.json" lives. Default is '../..' + // root = file("../../") + // The folder where the react-native NPM package is. Default is ../../node_modules/react-native + // reactNativeDir = file("../../node_modules/react-native") + // The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen + // codegenDir = file("../../node_modules/@react-native/codegen") + + /* Variants */ + // The list of variants to that are debuggable. For those we're going to + // skip the bundling of the JS bundle and the assets. By default is just 'debug'. + // If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants. + // debuggableVariants = ["liteDebug", "prodDebug"] + + /* Bundling */ + // A list containing the node command and its flags. Default is just 'node'. + // nodeExecutableAndArgs = ["node"] + + // + // The path to the CLI configuration file. Default is empty. + // bundleConfig = file(../rn-cli.config.js) + // + // The name of the generated asset file containing your JS bundle + // bundleAssetName = "MyApplication.android.bundle" + // + // The entry file for bundle generation. Default is 'index.android.js' or 'index.js' + // entryFile = file("../js/MyApplication.android.js") + // + // A list of extra flags to pass to the 'bundle' commands. + // See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle + // extraPackagerArgs = [] + + /* Hermes Commands */ + // The hermes compiler command to run. By default it is 'hermesc' + // hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc" + // + // The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map" + // hermesFlags = ["-O", "-output-source-map"] + + /* Autolinking */ + autolinkLibrariesWithApp() +} + +/** + * Set this to true to Run Proguard on Release builds to minify the Java bytecode. + */ +def enableProguardInReleaseBuilds = (findProperty('android.enableProguardInReleaseBuilds') ?: false).toBoolean() + +/** + * The preferred build flavor of JavaScriptCore (JSC) + * + * For example, to use the international variant, you can use: + * `def jscFlavor = 'org.webkit:android-jsc-intl:+'` + * + * The international variant includes ICU i18n library and necessary data + * allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that + * give correct results when using with locales other than en-US. Note that + * this variant is about 6MiB larger per architecture than default. + */ +def jscFlavor = 'org.webkit:android-jsc:+' + +apply from: new File(["node", "--print", "require('path').dirname(require.resolve('@sentry/react-native/package.json'))"].execute().text.trim(), "sentry.gradle") + +android { + ndkVersion rootProject.ext.ndkVersion + + buildToolsVersion rootProject.ext.buildToolsVersion + compileSdk rootProject.ext.compileSdkVersion + + namespace 'com.goon.mobile' + defaultConfig { + applicationId 'com.goon.mobile' + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 6 + versionName "0.1.6" + } + signingConfigs { + debug { + storeFile file('debug.keystore') + storePassword 'android' + keyAlias 'androiddebugkey' + keyPassword 'android' + } + // Release signing config reads from ~/.gradle/gradle.properties + // (outside the repo). If those properties are absent, fall back to + // the debug keystore so unsigned-builds still produce an APK; the + // anti-tamper signature check on the server will reject them. + release { + if (project.hasProperty('GOON_RELEASE_STORE_FILE')) { + storeFile file(GOON_RELEASE_STORE_FILE) + storePassword GOON_RELEASE_STORE_PASSWORD + keyAlias GOON_RELEASE_KEY_ALIAS + keyPassword GOON_RELEASE_KEY_PASSWORD + } else { + storeFile file('debug.keystore') + storePassword 'android' + keyAlias 'androiddebugkey' + keyPassword 'android' + } + } + } + buildTypes { + debug { + signingConfig signingConfigs.debug + } + release { + signingConfig signingConfigs.release + shrinkResources (findProperty('android.enableShrinkResourcesInReleaseBuilds')?.toBoolean() ?: false) + minifyEnabled enableProguardInReleaseBuilds + proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" + crunchPngs (findProperty('android.enablePngCrunchInReleaseBuilds')?.toBoolean() ?: true) + } + } + packagingOptions { + jniLibs { + useLegacyPackaging (findProperty('expo.useLegacyPackaging')?.toBoolean() ?: false) + } + } + androidResources { + ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~' + } +} + +// Apply static values from `gradle.properties` to the `android.packagingOptions` +// Accepts values in comma delimited lists, example: +// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini +["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop -> + // Split option: 'foo,bar' -> ['foo', 'bar'] + def options = (findProperty("android.packagingOptions.$prop") ?: "").split(","); + // Trim all elements in place. + for (i in 0.. 0) { + println "android.packagingOptions.$prop += $options ($options.length)" + // Ex: android.packagingOptions.pickFirsts += '**/SCCS/**' + options.each { + android.packagingOptions[prop] += it + } + } +} + +dependencies { + // The version of react-native is set by the React Native Gradle Plugin + implementation("com.facebook.react:react-android") + + def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true"; + def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true"; + def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true"; + + if (isGifEnabled) { + // For animated gif support + implementation("com.facebook.fresco:animated-gif:${reactAndroidLibs.versions.fresco.get()}") + } + + if (isWebpEnabled) { + // For webp support + implementation("com.facebook.fresco:webpsupport:${reactAndroidLibs.versions.fresco.get()}") + if (isWebpAnimatedEnabled) { + // Animated webp support + implementation("com.facebook.fresco:animated-webp:${reactAndroidLibs.versions.fresco.get()}") + } + } + + if (hermesEnabled.toBoolean()) { + implementation("com.facebook.react:hermes-android") + } else { + implementation jscFlavor + } +} diff --git a/mobile/android/app/debug.keystore b/mobile/android/app/debug.keystore new file mode 100644 index 0000000000000000000000000000000000000000..364e105ed39fbfd62001429a68140672b06ec0de GIT binary patch literal 2257 zcmchYXEfYt8;7T1^dLH$VOTZ%2NOdOH5j5LYLtZ0q7x-V8_6gU5)#7dkq{HTmsfNq zB3ZqcAxeY^G10@?efK?Q&)M(qInVv!xjx+IKEL}p*K@LYvIzo#AZG>st5|P)KF1_Z;y){W{<7K{nl!CPuE z_^(!C(Ol0n8 zK13*rzAtW>(wULKPRYLd7G18F8#1P`V*9`(Poj26eOXYyBVZPno~Cvvhx7vPjAuZo zF?VD!zB~QG(!zbw#qsxT8%BSpqMZ4f70ZPn-3y$L8{EVbbN9$H`B&Z1quk9tgp5FM zuxp3pJ0b8u|3+#5bkJ4SRnCF2l7#DyLYXYY8*?OuAwK4E6J{0N=O3QNVzQ$L#FKkR zi-c@&!nDvezOV$i$Lr}iF$XEcwnybQ6WZrMKuw8gCL^U#D;q3t&HpTbqyD%vG=TeDlzCT~MXUPC|Leb-Uk+ z=vnMd(|>ld?Fh>V8poP;q;;nc@en$|rnP0ytzD&fFkCeUE^kG9Kx4wUh!!rpjwKDP zyw_e|a^x_w3E zP}}@$g>*LLJ4i0`Gx)qltL}@;mDv}D*xR^oeWcWdPkW@Uu)B^X&4W1$p6}ze!zudJ zyiLg@uggoMIArBr*27EZV7djDg@W1MaL+rcZ-lrANJQ%%>u8)ZMWU@R2qtnmG(acP z0d_^!t>}5W zpT`*2NR+0+SpTHb+6Js4b;%LJB;B_-ChhnU5py}iJtku*hm5F0!iql8Hrpcy1aYbT z1*dKC5ua6pMX@@iONI?Hpr%h;&YaXp9n!ND7-=a%BD7v&g zOO41M6EbE24mJ#S$Ui0-brR5ML%@|ndz^)YLMMV1atna{Fw<;TF@>d&F|!Z>8eg>>hkFrV)W+uv=`^F9^e zzzM2*oOjT9%gLoub%(R57p-`TXFe#oh1_{&N-YN z<}artH|m=d8TQuKSWE)Z%puU|g|^^NFwC#N=@dPhasyYjoy(fdEVfKR@cXKHZV-`06HsP`|Ftx;8(YD$fFXumLWbGnu$GMqRncXYY9mwz9$ap zQtfZB^_BeNYITh^hA7+(XNFox5WMeG_LtJ%*Q}$8VKDI_p8^pqX)}NMb`0e|wgF7D zuQACY_Ua<1ri{;Jwt@_1sW9zzdgnyh_O#8y+C;LcZq6=4e^cs6KvmK@$vVpKFGbQ= z$)Eux5C|Fx;Gtmv9^#Y-g@7Rt7*eLp5n!gJmn7&B_L$G?NCN`AP>cXQEz}%F%K;vUs{+l4Q{}eWW;ATe2 zqvXzxoIDy(u;F2q1JH7Sf;{jy_j})F+cKlIOmNfjBGHoG^CN zM|Ho&&X|L-36f}Q-obEACz`sI%2f&k>z5c$2TyTSj~vmO)BW~+N^kt`Jt@R|s!){H ze1_eCrlNaPkJQhL$WG&iRvF*YG=gXd1IyYQ9ew|iYn7r~g!wOnw;@n42>enAxBv*A zEmV*N#sxdicyNM=A4|yaOC5MByts}s_Hpfj|y<6G=o=!3S@eIFKDdpR7|FY>L&Wat&oW&cm&X~ z5Bt>Fcq(fgnvlvLSYg&o6>&fY`ODg4`V^lWWD=%oJ#Kbad2u~! zLECFS*??>|vDsNR&pH=Ze0Eo`sC_G`OjoEKVHY|wmwlX&(XBE<@sx3Hd^gtd-fNwUHsylg06p`U2y_={u}Bc + + + + + diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4f31a0a --- /dev/null +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mobile/android/app/src/main/java/com/goon/mobile/AntiTamperModule.kt b/mobile/android/app/src/main/java/com/goon/mobile/AntiTamperModule.kt new file mode 100644 index 0000000..21598c3 --- /dev/null +++ b/mobile/android/app/src/main/java/com/goon/mobile/AntiTamperModule.kt @@ -0,0 +1,64 @@ +package com.goon.mobile + +import android.content.pm.PackageManager +import android.content.pm.Signature +import android.os.Build +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import java.security.MessageDigest + +/** + * Native bridge that returns SHA-256 (hex) of the APK's signing certificate. + * + * Mobile sends this hash in the X-App-Signature header on every request. + * Backend compares against ALLOWED_APP_SIG_HASH. Re-packaging the APK with + * a different keystore (e.g. someone unzipping, modifying JS, re-signing + * with a debug keystore) yields a different hash and is rejected with 403. + * + * Caveat: a sufficiently motivated attacker on a rooted device can hook + * PackageManager via Frida/Xposed and return the original hash. This is + * a speed bump, not a wall. + */ +class AntiTamperModule(reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { + + override fun getName(): String = "AntiTamper" + + @ReactMethod + fun getSignatureHash(promise: Promise) { + try { + val ctx = reactApplicationContext + val pkgName = ctx.packageName + val pm = ctx.packageManager + + val signatures: Array? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val info = pm.getPackageInfo(pkgName, PackageManager.GET_SIGNING_CERTIFICATES) + val si = info.signingInfo + if (si == null) { + null + } else if (si.hasMultipleSigners()) { + si.apkContentsSigners + } else { + si.signingCertificateHistory + } + } else { + @Suppress("DEPRECATION") + pm.getPackageInfo(pkgName, PackageManager.GET_SIGNATURES).signatures + } + + if (signatures == null || signatures.isEmpty()) { + promise.reject("ERR_NO_SIG", "no signatures on package $pkgName") + return + } + + val md = MessageDigest.getInstance("SHA-256") + val hash = md.digest(signatures[0].toByteArray()) + val hex = hash.joinToString("") { "%02x".format(it) } + promise.resolve(hex) + } catch (e: Exception) { + promise.reject("ERR_HASH", e.message ?: "unknown", e) + } + } +} diff --git a/mobile/android/app/src/main/java/com/goon/mobile/AntiTamperPackage.kt b/mobile/android/app/src/main/java/com/goon/mobile/AntiTamperPackage.kt new file mode 100644 index 0000000..f5b2bac --- /dev/null +++ b/mobile/android/app/src/main/java/com/goon/mobile/AntiTamperPackage.kt @@ -0,0 +1,14 @@ +package com.goon.mobile + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class AntiTamperPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List = + listOf(AntiTamperModule(reactContext)) + + override fun createViewManagers(reactContext: ReactApplicationContext): List> = + emptyList() +} diff --git a/mobile/android/app/src/main/java/com/goon/mobile/ApkInstallerModule.kt b/mobile/android/app/src/main/java/com/goon/mobile/ApkInstallerModule.kt new file mode 100644 index 0000000..07882eb --- /dev/null +++ b/mobile/android/app/src/main/java/com/goon/mobile/ApkInstallerModule.kt @@ -0,0 +1,146 @@ +package com.goon.mobile + +import android.app.PendingIntent +import android.content.Intent +import android.content.pm.PackageInstaller +import android.net.Uri +import android.os.Build +import android.provider.Settings +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import java.io.File +import java.io.FileOutputStream +import java.net.HttpURLConnection +import java.net.URL + +/** + * In-app APK update flow przez PackageInstaller API: + * + * 1. `canRequestInstall()` — sprawdza Android 8+ permission `REQUEST_INSTALL_PACKAGES`. + * 2. `openInstallPermissionSettings()` — wystawia Settings → "Install unknown apps" + * dla naszego pkgu, gdy permission nie nadane (jednorazowy step). + * 3. `installApk(url)` — pobiera APK do `cacheDir`, otwiera PackageInstaller.Session, + * wpisuje bytes, commit z PendingIntent. Android pokazuje TYLKO dialog + * "Update X to a newer version? [Cancel] [Install]" — bez "scan" warning. + * + * URL musi być HTTPS — Network Security Config (cert SPKI pin dla configured + * backend domain) jest stosowany do HttpURLConnection automatycznie, więc cert + * self-signed jest weryfikowany pinem zanim cokolwiek się pobierze. MITM blocked. + */ +class ApkInstallerModule(reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { + + override fun getName(): String = "ApkInstaller" + + @ReactMethod + fun canRequestInstall(promise: Promise) { + val ctx = reactApplicationContext + try { + val ok = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ctx.packageManager.canRequestPackageInstalls() + } else { + true // pre-O: nie ma scoped permission, REQUEST_INSTALL_PACKAGES wystarczy + } + promise.resolve(ok) + } catch (e: Exception) { + promise.reject("ERR_CHECK", e.message ?: "fail", e) + } + } + + @ReactMethod + fun openInstallPermissionSettings(promise: Promise) { + try { + val ctx = reactApplicationContext + val intent = Intent( + Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:${ctx.packageName}"), + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ctx.startActivity(intent) + promise.resolve(null) + } catch (e: Exception) { + promise.reject("ERR_OPEN_SETTINGS", e.message ?: "fail", e) + } + } + + @ReactMethod + fun installApk(url: String, promise: Promise) { + Thread { + val ctx = reactApplicationContext + val cache = File(ctx.cacheDir, "update.apk") + try { + if (cache.exists()) cache.delete() + + val conn = URL(url).openConnection() as HttpURLConnection + conn.connectTimeout = 30_000 + conn.readTimeout = 120_000 + conn.requestMethod = "GET" + conn.connect() + if (conn.responseCode !in 200..299) { + promise.reject( + "ERR_DOWNLOAD", + "HTTP ${conn.responseCode} fetching APK", + ) + return@Thread + } + + conn.inputStream.use { input -> + FileOutputStream(cache).use { output -> + input.copyTo(output) + } + } + + if (cache.length() == 0L) { + promise.reject("ERR_DOWNLOAD", "downloaded APK is empty") + return@Thread + } + + installFromFile(cache, promise) + } catch (e: Exception) { + promise.reject("ERR_DOWNLOAD", e.message ?: "download fail", e) + } + }.start() + } + + private fun installFromFile(apk: File, promise: Promise) { + try { + val ctx = reactApplicationContext + val installer = ctx.packageManager.packageInstaller + val params = PackageInstaller.SessionParams( + PackageInstaller.SessionParams.MODE_FULL_INSTALL + ) + params.setAppPackageName(ctx.packageName) + val sessionId = installer.createSession(params) + val session = installer.openSession(sessionId) + + session.use { s -> + apk.inputStream().use { input -> + s.openWrite("apk", 0, apk.length()).use { output -> + input.copyTo(output) + s.fsync(output) + } + } + + // PendingIntent musi być MUTABLE bo PackageInstaller dorzuca extras + // (PackageInstaller.EXTRA_STATUS) przed delivery do nas. + val intent = Intent(ctx, MainActivity::class.java).apply { + action = "com.goon.mobile.APK_INSTALL_RESULT" + } + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + val pendingIntent = PendingIntent.getActivity(ctx, sessionId, intent, flags) + s.commit(pendingIntent.intentSender) + } + + // commit() jest async — Android pokaże dialog "Install update?" w tej samej + // chwili. Resolwujemy od razu — JS nie czeka na user choice (Android UI). + promise.resolve(null) + } catch (e: Exception) { + promise.reject("ERR_INSTALL", e.message ?: "install fail", e) + } + } +} diff --git a/mobile/android/app/src/main/java/com/goon/mobile/ApkInstallerPackage.kt b/mobile/android/app/src/main/java/com/goon/mobile/ApkInstallerPackage.kt new file mode 100644 index 0000000..9db306c --- /dev/null +++ b/mobile/android/app/src/main/java/com/goon/mobile/ApkInstallerPackage.kt @@ -0,0 +1,14 @@ +package com.goon.mobile + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class ApkInstallerPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List = + listOf(ApkInstallerModule(reactContext)) + + override fun createViewManagers(reactContext: ReactApplicationContext): List> = + emptyList() +} diff --git a/mobile/android/app/src/main/java/com/goon/mobile/MainActivity.kt b/mobile/android/app/src/main/java/com/goon/mobile/MainActivity.kt new file mode 100644 index 0000000..2d6f388 --- /dev/null +++ b/mobile/android/app/src/main/java/com/goon/mobile/MainActivity.kt @@ -0,0 +1,61 @@ +package com.goon.mobile + +import android.os.Build +import android.os.Bundle + +import com.facebook.react.ReactActivity +import com.facebook.react.ReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled +import com.facebook.react.defaults.DefaultReactActivityDelegate + +import expo.modules.ReactActivityDelegateWrapper + +class MainActivity : ReactActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + // Set the theme to AppTheme BEFORE onCreate to support + // coloring the background, status bar, and navigation bar. + // This is required for expo-splash-screen. + setTheme(R.style.AppTheme); + super.onCreate(null) + } + + /** + * Returns the name of the main component registered from JavaScript. This is used to schedule + * rendering of the component. + */ + override fun getMainComponentName(): String = "main" + + /** + * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate] + * which allows you to enable New Architecture with a single boolean flags [fabricEnabled] + */ + override fun createReactActivityDelegate(): ReactActivityDelegate { + return ReactActivityDelegateWrapper( + this, + BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, + object : DefaultReactActivityDelegate( + this, + mainComponentName, + fabricEnabled + ){}) + } + + /** + * Align the back button behavior with Android S + * where moving root activities to background instead of finishing activities. + * @see onBackPressed + */ + override fun invokeDefaultOnBackPressed() { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { + if (!moveTaskToBack(false)) { + // For non-root activities, use the default implementation to finish them. + super.invokeDefaultOnBackPressed() + } + return + } + + // Use the default back button implementation on Android S + // because it's doing more than [Activity.moveTaskToBack] in fact. + super.invokeDefaultOnBackPressed() + } +} diff --git a/mobile/android/app/src/main/java/com/goon/mobile/MainApplication.kt b/mobile/android/app/src/main/java/com/goon/mobile/MainApplication.kt new file mode 100644 index 0000000..697dc0e --- /dev/null +++ b/mobile/android/app/src/main/java/com/goon/mobile/MainApplication.kt @@ -0,0 +1,57 @@ +package com.goon.mobile + +import android.app.Application +import android.content.res.Configuration + +import com.facebook.react.PackageList +import com.facebook.react.ReactApplication +import com.facebook.react.ReactNativeHost +import com.facebook.react.ReactPackage +import com.facebook.react.ReactHost +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load +import com.facebook.react.defaults.DefaultReactNativeHost +import com.facebook.react.soloader.OpenSourceMergedSoMapping +import com.facebook.soloader.SoLoader + +import expo.modules.ApplicationLifecycleDispatcher +import expo.modules.ReactNativeHostWrapper + +class MainApplication : Application(), ReactApplication { + + override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper( + this, + object : DefaultReactNativeHost(this) { + override fun getPackages(): List { + val packages = PackageList(this).packages + packages.add(AntiTamperPackage()) + packages.add(ApkInstallerPackage()) + return packages + } + + override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry" + + override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG + + override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED + override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED + } + ) + + override val reactHost: ReactHost + get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost) + + override fun onCreate() { + super.onCreate() + SoLoader.init(this, OpenSourceMergedSoMapping) + if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { + // If you opted-in for the New Architecture, we load the native entry point for this app. + load() + } + ApplicationLifecycleDispatcher.onApplicationCreate(this) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig) + } +} diff --git a/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml b/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..883b2a0 --- /dev/null +++ b/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/mobile/android/app/src/main/res/drawable/rn_edit_text_material.xml b/mobile/android/app/src/main/res/drawable/rn_edit_text_material.xml new file mode 100644 index 0000000..5c25e72 --- /dev/null +++ b/mobile/android/app/src/main/res/drawable/rn_edit_text_material.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..3941bea --- /dev/null +++ b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..3941bea --- /dev/null +++ b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..edfbf2293682dc2f3ec42461a79dfb45121b34af GIT binary patch literal 2613 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 literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..4511f7a9d4f7b90abe598fbcb3d9a3ba1fd16c46 GIT binary patch literal 3450 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 V1Pf5MOoC@B2-Cxd)<9)v{2y52_^bc` literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..56c3e001f76f90dbf39dd01f8790b949c90f1499 GIT binary patch literal 1556 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 literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..1fd1d2c465b234fca5bbf0ba777743930c8ac1b6 GIT binary patch literal 2110 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 MlUAl>_zTzmFBHGgiU0rr literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..0ec84d8731d0833973feced8eaad498183409745 GIT binary patch literal 4969 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 K&b*q+i2pAVhO1Zr literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..7d7901a08e48203760a89468d33edc2dde093449 GIT binary patch literal 6402 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 EKQG^BH~;_u literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000000000000000000000000000000000000..90bbeee2fa3af50151be9f2f9abe1e2717db1376 GIT binary patch literal 33146 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 z z3WdmbbaVrUKZ`7U+07>PK2sDlfQG%b)y@J%nI>Fm8qr&~dcu z`RQNQkJxpzWx8sf)Fj`r*;;l;&)?XGs-K#29wL44SL$%P=lJv2o6RSUx&C@Z+_F8d zNP4$ETm${2Az8{0{#>Wm>30izV^lSk9&!jhAjF^da74f$POkCF#gG8v;Wu@dvcVhZ z8b85BUFkiCkM6mtyz|b9Y}XB&ZrW(L>T2ndHK4Q<794_+WRDV&cou>I9m}kQ^5GBl z?J$VD2xL4$_cKx9cHFr#SxtNYt6+Oy34usteTq+8b($oc@IDx`toX*Jm|Mp^VNy6+ zQDcPyX8@#KSy>&nKzR3jrsnB*qu2jHj5O@vCJ;&JC<=b4y#aDumk+ z*1^eJ`YTA*c24D*R$9BVl*&;U#c~EKSx1qvQ^Ssq_;-Ch9LiE0dBnJ<=!Ut2+(0f# zcZBMft<$}oe8}Z7TuDiqc<$&Uk^VNd$j#w}#ph=_^FF@Gd2ld6HdSxnG2G$|s0>n` z+dv;$X{PUnfCgFXx7V91&9TN%zA*iiQ?wvO-523YntllZAj)6M>(QfPEq0-w?9uS@CfcMm9O2LDqhk zc889n@df$drH_Sjz{1Rch4r^wdJz`Tf2dD!LYBDI$q}4>kUm2s`Z^1L9%4!2%0dH zZ}${aF0iK|BN~jh6Nc~`43g%ForV6l<4DkqxGNoZlr=9E-mB<&yz=O$!}*-X%RhH- zf919{sQ))nFFv&{KGi+N=zKl~G0K1NuE0 ztyp0)lHkIIQ*LIKUHqfC+Ibt0Rr?Yt{LI_9d|A#tSrbe?3GE|`IJe&A1_TGpcN#0} zBU=jS0x5Yb={Dog2@?AFSLg=lNjEG5%3@#`3`L|elo67LgQif39X(+F07~+2MWr_c z1*^kYdq#*Y>@>lRm5gBbSpE^3zw{jc+_2mu_^4&(SKlY+ah50eP2_M%#oA?Sg^4Tl z@BU1e*~|7nqK|F^IP8esE=n8@3PXhH`e@-{2qb!JpJBQL+=YNd<6%CfXi5H69U?tW zyVgcV9f#0hNccR%7a?I#%XkDHJHo&*vg~|hC3l^8 z!Eks2J8JgxRAZ8LYerK~d-2Q5AtqJcK^{Tp1yy}+9!|i>s}Kr^AS;xyx(SwMBWdy5 zs}ST$9`KA36<}H@c(MzL zD5Z^H9*4k2GFcruPwZJbIJ;`fJ~IwEGWTi0|-pz`4|iX;S9(mK`=Nv6ygKgm!G~GnA*%1%ReHQr zI`gIk@?U6r78JWnQbQ6BKW|gy_?ornUI^9>?kg=x=&<)ovJ=N)q%Qdreev%6u@3cN zzGpzh#fgV`1{oFX~j-eBfrA^{r{}GyPqnl;p>|07JGE}IQ0+Pkq z!F{xRlwc;ntiQnFFBSQ~=MdU3I0jC~u~AB-6c!$+2170Z9B2Ji?#kB4(kKVtSvHiC zT33Oy{Ep#2U#7ZDV&aR>%HwZ|V;-3N<+kkU7x0^E`q|Jn1R%hQ_JVTy?Wt#s30i zY{z)6Lm+5^Jq#B#NU33by-okx-av+dO)mx>?P{NXQx=znb@3xR19jL|WaGehPbSrw z2R5KeoK0*g*?-UeGyCuM%f6T9)1*cWe*cQl2GkFNp4;RBw{A4Z z|08r1s{2C6!XKTl;9H^}<^B3fbG7r4M4j8XO2b`=idINkiXD!AN0*#l4whrv)b^mn zB=@>Hms8n*=sOj)zF4s#gR(#swB-yu%;i12o}mbl^kpkbj@99fw4lhl3>FJ<*A=a2 zm<}vf{C4nR**&$cwZ7#g;@wcNA4;tVD6cq7$#&H*r_vmnKlKkk`ZLms%(k4QrFs5I zjeURcY*VOFBY>fOOsGx$ZvF6}n#4of!l?>Y2xE0Lz?c+k!?i%EEK86q2c8F^-?@Q= zkx7KG>`Fi zsM#?%#sz-4M6IkAw%QKHpb(DuC6Yn$qnpLL2`r<+8EnBFCTIV}a}}_kJ5%z&h8uXY z)d#Qd>e>!AnJ&JVGFo_RUK286F-dbzD_9;AUjw5LZMm*C`Zer9T@c4IXreBN*DNjV zR}x=#8_fh|fFD3tUtMW-$0?TbQC`k`U4$D3cpHclIE>iH#Zki-&I3C5BkE14zv2CJ z$>8Pum!;)*XCEcstWizcW1>Yz_o4i+VyQ*Wjv5REUczopFz>q80Wd!ldqWt8i)AEWF@L^t->eJi8t7boXzg z74LU`L!y6&XPRPOhKo8y%jyLyt*cLyV&<$@o4!oNj-Slc&mhBinqWjW96`|H@NCSOFxw=f6PMe4*0{S{BSZhfceZ|i_pwu_@`4O@ zU&zf3s(aYoZvf_&#eN&wzEE9f`sZAWA~j80#6 z?pd;CPHxb8Ovc1)^VE`*Xehk(OVaAA9hX0AtR7&pWKGN% zx)TRLGe-5b0d|fOyn_SV0vSP^)1dD(mgpSA-x$cnW%wHICfC`0zUOzU%Qr7K=u{ox zEHCJk!TlHe`UmoY8!yjKtq7mRd^O)T*_;|IG5>C}`AX*(^IxrXO%p?YK{*q(!n!(J zZjeC3O~0kmUui}Hn+8gFZyQ#ITU85obJY+i4lwx;JFj(Q5}(Bwz=#)R+X1HhPW2o1 zYQodbUYMF0UR^bx-5xy^CHfFMZCkM9VZL~^U?q8bj8E+1yE;2|=gm$yBzHrS%0j52 zBorc@?w-D5FKo61d^F!Om%&n`o3^o@bmPE>cY=vfrDt<{PXYDjfU=?6J-&0_{PCGUb9-d!)0i3E=9Tw@w zE|{+l%`cn8E}!&X{*V?s_EYr5J96@|R%~(`M*c7fMussgjTo^Q;|>esj%ux0608~q zIw=MV-@)zF6VDPcEVX0n@6PDlx7*`XUGBqf->&X`4rd*rm+H+{gIBeJ3xbyhFH2pz zck54g^}+dYQDjs9ZLFB{D|4+V`=IZrgO;%++J!t;LntWs}xF!;8fQ(W~1< zJ=U!Y8DInFs_4g5%6>B712=-;bCvB6o!AL(V+h8wwYC1xFqi>Z49f#xtiB|m>zlxY z&*tXhJTx!7H@pwVCw1@ZH(hOWnU8t4J!YzTcuuR%bk)A)@#P=A1ov$p5>j~q%~lEyab;+ExY)ut?AFkQS zvYWK4v%5kz%smUnmh?j4t#ti)wOEnKsvvgBV`xUo{MT}VgXpmGRfmMYb?Qf*!@B-Q^C>-GbCd>%@o(Q~! zn)4XCLskA*bUT<5p*ez3lMRP3Oh!CX9lIy^FfRoI6$%jH@z-{sBb+JaXeH=0L$UbPlI zemgJv2F5>uOP>+32Y;UVCL0dSDUJEa4w@d@5(0{4yc~j-wDaL^vSw*#cDHYB(3(T+ zA6^QIo%WoM>EHe+r@l2OI0THN8v&Cier{hJ26Ru2*LngEuj~@5L6{;CQm0T3AK*lW zZala+1Wd_Dy!U>KGVI)kBtt|#%cfuHB90M*xO+<(!XE#=Ma{8STe zK69}3%b>&Mms66L&x;1E3!49A#ZLct@k}&wVM!}y_3le^&)`XQrYWwTc!|_<%t%88 z34=4pgboDlhRTiZvp?JV3kHUk=;2^m*zm&LsNt0ahi;ecag1STw!GK!^cT_7g28t$ zUW^ZkjVLU*37orh?=SHT`cM3V3Q7)jnxcJ3!I&ZoF1w)JtKTJZ_LCxxLKrUKJOu_1 zV=3DQL4Z;qZJlOJSrrz^&ki)@;&0kCa2lLUv{X?SuTkmfz z{-fd0a&?$t_PblOqW%(K;Yna;Xeg|D(o;;jUsENBuI(jU-mQQc=n%ox5iy2yG{!Mi zGTmqX5d_jk#zWU9!K-0uP!eAqgbth{vf~UJ(HCwW$85AA?)FGp1Vh z2S?2VI-|N2rv_#h`sveo1H0$V7w@+&x3{i537qg8Yzhx<3I|6@fX!41Z=KEQ5+nd~ zr*I5-A5^CuH;%%I=X7Y+W7okjL6F@f;PTS2(hym)kp@Nui4KQZ)~4IB$-u!n8FmW? zatESjYX+i_Lx1!g&K+A>_q*lSfcd4xXRF;Ulf&MF{gb>XclYl@jg=fAsoqIpb6o&X z?RWj@O+{AVav2OJozP*y%DMDmeDAg@uhQw1=~_E90>bdHgp6$h`^F>!l7<~C!~nMf zQ)>j@l3J%f#!mJw6)cm$rh8LkcxY&7aMLlcP*6@7C2IO!B_)tcy;v?i6_*s?Xd0)j zWpNHp17jQuM#ecvxsl8eSTzzPIsQ!?4gpKTt8HPEZ{%w#W`xXT|DO3`wr~=hS11)N zjvsapZVaCqAL9KEo2ZKthlZ-UuCVehYUt7LUH^D6Hz|#QYd)?U;^WXA0{6g%$OMKv z88(t0B8wRy6K?pj5ongj*v?#>V%5-_mJm^n*{_k8qJ;wQ?~jLg)x6)}d?W9-_=DGs zpBnyss88q?<;-#O6blHHPvLmo|7bT2nQPqO9(*fAkIo9wV}Wfz7a$Y3u?0cSvLP}s za^&;w*5#iCD+R0HWtM*KsGRg{{x&}J`z{bTpdWbF+3=_%!mu+r6V9MfHiI%{Dqy;3 zH0nRnxA`M~r67?ur6Ui$g`n|L(Bo@@jqKyY{@62{=v-y})Tx}Q4Cdw2n zjg3<|@{Nt*b6^EQGbai-!8rIjb0#PBC`U*Q)Nl8g+k|aLM@TreWTMj|K8%Mph0NWR ztwyTJO7c7Lz?wQFku-@X0*~iIIk#4<3zoL8{<=SJJ~JZ7s|tM^`u-gdO%<E_;2kKl-4$;f!V*n%4F!x~9C{T%f zFTri*S-Tud=AZldu>JW9mXYtR_#gl8xr3MCZ+-3?>1P0RbW%!cg2q@G-O#$RX3dn~RU=x8p&BPmZ zT4K|PV>tKVqw>E`n=f4`Sh^a!_;bhVo0pZLZv}aw_s=y7Ur@!wSBk~GM!fqwYEbyZ z8!)FMjOP{ivG~3q|6g{^llqnjL zm12PcH~Lf`Mt%fIObW)>p@lBPk72B!s1$j6N?Z!zF<6Nnv;0tg^?j1L=ws~a?rASai@t^tXrdu_Mn!1J4RpzO}Xp z6QY605~@7*gBTlmUkJ>>^ZQ+~!omh0czSyBIHA1B@izv%KEH#Ma;oc^Boi!9tPC+@ zCqdCFmCOinMq`~X?$ll<@Bi4saVBoGJmBE@xxI7U32Cc8%>N9et>m7bik(#l5^N0T z=2cC422Qn{b87TT-rrBn? z|NKX=?K58xsDWs?hHubNb|eEsQN}BvP}2$zgob9$PE|vfyJU96{Kyq8S;zdj8oQJ< zA1Yb}QRNhuw`Lf?W*X}Q#SRKmpr9EjvZ@Lcg5n@wCG=~0z)b?hYsHV{D@|4RV8K_KRdaT?DvXdYP3(VV&feG12oTVHV` zK1Bxc={Q1q42%(~8d3pDHK9w$ZfPg(RAWo3@7fBJ)mBzQ%rH`{TzGwbJx88tbO`c3 zwy#`hUDSKI%mtT3zL@<|T#5{NE0!zx1__6|#_+@t&#iyW8^gH5Pvf9`TwB{ch}+fI zNA>2CcA3Sy>aKGl$`w8O8yHjwSX+i3Vm%3CKnv5AH#+TdzPQH>DNnIX-J5;ByjWNV zq;vA%k(UQoI4{8&*N%ok5>-_@<6QW zem_qhPh7VG*7d3F)^XwhIe=V?H_bXA8rVZD(9(zKarg+L#%+x50!g4t$qBpY_8lj@ zxHmVBHg_fB)t6hVyf3T&ik4?in=iO~^|emtRtY|x1Lt1>`&DB310^>FP5@>(oT%@U z)S%{X0l=<7ZsdfX3G@u$a=BC<(_hK-Hsl2Obz0U4;v|shcfMd73GMnG^}3A-W;qpG zS#zo1jmm=#_g&Q3@|GeFh?CVjV#YtdTJ0s8&;YrG{ z4Bw7q+}UMbp7%^B-KczxHeD&kz$9 zlmqIg-bqltHiteG0|ZS#u>DVHQx@VM@jQhAIlMk8&`$7dW_n6KNS4VE%aw`g8Cd2( zdIm6S*OBqPWK9(UIAwywb~1b+iln*?xn#pq&k|L~UGIlXnZfZ*LZ zXD{^DO&EgLpAB;+YP$BfPDqJn!yhfpuNDSJf0drlCbtr?_nvH5j@jO#flF2e!gfCsAh!2{GieoYmC4Yxp# z&~o0bicL-+pGe$diJc=KWifU=A5>R&*SlAX(J+ANvJh}Z{uqY9?loLPT7Vjo{?`hh zI@JXve<}~aI$|9FxB|Sa>*_vx|Aikw$h!R|J#R&f4m!lV3kH37%!m2rsfpH)^DRGX zf|pu?)MB@_b`Jjf^U_@OA)|HS*NXSDNkQzl&1MVYK7Drf^HX7CKh+bE+gKUYE(m*g zjMo*$6X*A%;?{y1-qhE4tg6p%!l#_)$))7f2|ty)al-6?}%IVTM#EP7~+^mtnl76^*sl{_O7i01D~!tLr^g7gRSO#w)^d zbAd_y2l#b^9~fT91w1|szcf2LIp4Arw0*(-R^;qb%zTrYsA_w}vy;P%2Xiiss$RBy zyY&5PLFDS^x2?ZGqFO25F(m3px)nQJo@V-Wy?0MQAh)rS5>;!^U-J82B@eK%rXMa) zQDs*cCETNbQdnouU(uy@@hFGO8=rLr;ak4Y)Izl`~nxU#!-b|ArFb@amif`w9f;eCf{vj%UEU?oqX3#UZJ(?Cxs zD5;|YgX~KHsuYHmb7Z~;G@Z?9Y^)4B>f!Od)~%_M;^6_xoH_eumj~jg!2G|MbrCa5 z_rO5;7%i+1+K!FU%IL;zVT)T{yHfx;`~QJWx%&M8*R-4z!&?B|1a_JmB*s(4+}G{w z2ct~^C}r3y{*Sa9$JhXFy7VJi+$h&+?Qn?h|CkfrEJ~gjoNBFa{&_I%rOBc;_L!{qb_;C2f6T3t+E3*eM7{Fk9{dU{q5?<-6yQ9A9DRfT zegJ|wudd6ZjO$!+f~S@;1C1bLg8I+6(*kvgXs_0=__wTk^-^opE4i=v{#Jtf$*8voee0 znv7gH=rnOSMt)rPzO;kqJ3j{Zf3DHeN;|l#S1s@CJpU8BdmXNu#4-5Fc6-_q$y4?K8p+A&M$*Iofi^gtfy9DCikp>wwl z0w*3D*aqeFiGynZS75*sf{s}1%_(J+zn+@FQe0)%If2f@X-vTwG{%7d1Pr{pHy6}P zFh&wF9#3@PyXIo0P{teUc7pRj{yI+_5A0lTvyX+hC?0j4@DKX?h2QC}h8Mo&9^hgq z&hutB4%7R$i&j0%SOG@XM6fHP2ZzCsunX`c3z+=IG=_<@mjWVXmI-F273QO?rsEs?`*U7gt%HFoC$D9`tAJNChyxdZWbgbqstNZDb^=i_G!U&EqvNS+Ib+8Hy;*Hn3c?1f?AqdzW4-gC5 zGSFdP6}oWCcqzwux>g9Hsifd}C&&m;1)`cbueF@}U%>*#Ngl6sJd5$2xNXZ&E+r1lebkSq4R=)U;vEEsj$dGU=H9m&ntEIq~YMbhFJ3;^kw zvV9vATWB#UFM?o-@^)*p6aZom3W~(QMQE?kgaU{L77^&d^XHTZ zO}acHw1+C{5u78}wEmGImCy`9JhYO-rge0JfaorUXut_A2}N~Vi4mcM1+ybH|;RIE1U=~G4ypCM|dJ#b_87dD?0q~-$hpoVmQpp2dp|Ha0 z`%PUQ=xH8Nf-y(9HVU{=Rs0=iZ3M`$==cu3u`xQQx@o<;x3suLL?behcloY25gVWh z0=slc=+=MM-F;x`Ot3qlayr1h&hDE1fm4R#*(XkQwdn&q8jxzt0z&Ob_N85Yg<~lq zXdl2~V1|U=2*PVqOa7x2?OCRFe4KVXKpYgTSQW5#6H+t`B*z}9M%M?F3Xm#~H@gIi z#XLh41n^Kj)EWGYLYwZU1et#n9l7}%4jCt_fPvnL;%Xo}1ORn2|6pX!!J+902&9Is z@CiqM|2|y@g4qzhm27)T3P%b^CSL=P)Oo($->29O`z+szF-Y3qc5)2(C104ag&a6~ zEoUr=QDFnTcv^R)tFw!!<_xt!fZzWsm-0dKu?JIYIu49o@sEaX(zg*r-gYyk%s4(6 zDhI%Au%HoYdYfWeIJb#FNkKEUpjMe{<~fE=dGz-hjpqTH^}nvWt&Mc{h20n$(RSs! z9o^9ff@9GQwvus9U|6{FTRY7IWX)5xC+bx4ah4>*j4L<~LI)Sl7?7l4!7&#QL0w2j z8jR}v=N#>L6(UgB2gzv=aZz9sj|&kLG2P0QcpvDIr4gJVozX%whMT8s5!eM&kR2lE z#m}@omU6VxRvx?4K{nQzWtxuCIrW+A-Gi0Wm6;^wi4!>Ij{t&kE1_r;u^dZPGQ}VK02hZUiHI_J%$)se-3SQ|Q=T3nBY$K=$_ITs zu{?h*7GY)5t;JSYeMXR3I2rBom~@k-`zBPuXbsEf)4Pc80c=6^dvvEdkR7qj9VhOwnq$ zBV?RVGYYrWUg7J7?u=9hK3#^qGjUASMX>Y0twCp0dx_DGexz~ zz^+qf0*w`yB&t3b@%Y^L1Mw0^HcrKMeeI5hDzggxbe@^_x_mkusvMprS3)hZqOti;uyVSRDL zwFL(%4)H3k%N6VQ=?ZkY0X!RCg^cQ?_^~1Zyh@@4f44v5$_co5yCSr@*vXo6eJiDS zoT92KbWj&gX+F`og>6e`+G3T|>px_-F-bUQ1@V%GI54v*nS>*Z9x~{_5U~XC>ld9-1IY%m&=5E6~WXn55 z`R-+1d|l|YcidmVi5$NdqgxZ>zQte$g}IK{g)BEZ*QQTAF^0c`6*KLN*h0h-<k6)K8Pzf{HgIU+Do^4%JsUAoS(!y7H z3-Q_p%pqj(=phE_nlc6{ziSk8Xtaqpp5Sa9*pnypPj%)tl?G&0IB~bCj(@##LTm8(=a%nTrIe|b zv1=eID67dVmcvYI)U9mN7R{#rI!vKRs79u$vk*rY8JfFcNssJ8U zk`dMn*!eb+DTBEK*W&S=qB`ej&AAouVDI>0pi4@HYif4Dpr=i7rt@B)yw`xIf%G!4 zO6q|C*XD{+x$oQ6h1+$XG)1JOEAPS@B&#dQKa_@Jh}Xb6+<|L|{aB=Rn+9@5(;%Ih zhCS3KGqWaKHU z$=ltx4>Xf|WJ-EmQX5@UBenYuJRg(+((Q4wKG@?_JboWI$SUQ;iiY8%t1m}kGbpSV z?w0e+_$i{PMq3lY`VljH1|<)q>Rdf;)yyfLD;j!mC^aftSXMk&aXqrE_>jPTy!^?c zd&*o+*R+9Gy9=|lf%u4I%A@^E%oFpq*;{tzV?a-JnJ6F?ES|GIo9A4?d-u-u_;7=nbM-kR2@88?P&pAw z+uIb(bq!{`_l{fla2D^}8ZW%Qm>ntb;ZFU!I`@{`CSnFByU$oWN(oLGc55v(<0M5^ z3_BNu9{DZ;Z%AV?ceJOa1eXVMg3CqL(Iro)t;ODzu@8vLcNYSC=D*$(7NCW#ZdY3A la)DeP*y;Iy{4f_jz1>jrp-=+GBTIlUvXwotif}ys{{ULJ@FD;J literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..94bf6ae51914ed897267796d8b8f8e538208e91b GIT binary patch literal 8340 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 literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..8752e2308219214aeae930a8d0641c411ca2f377 GIT binary patch literal 9250 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 literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000000000000000000000000000000000000..553eb5140bcfd28e078d73a72b278099e43d0469 GIT binary patch literal 50583 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 literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..55f67230e4df79e35508fc1ec1bb1142cbfb29e1 GIT binary patch literal 11949 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⩔o&}pOcLz!U^kt&CES% zK|TeciPED=U|v-0Tqe;PK!}k8TBM*$a!jfB#)E|$)74UOg?H%Pmr~9-yx^ac-Z8Kl z^wzLiMg#)E{u@eFq~+*;{jjwDK&5Ees^$1>3&WQo)o{C{v7X(I>;Mj1tR{LJr_ZxP zaU~YG{;CQPdK&+o-c8v06M5{yj%L_6p!QkYf51a~r6b#23#9(Pc4W731lnYhwsbOx zs1m(aCeG&_O|)(2_T05+{+0Vi{H5!Oy0g75_MzmHOc-#%qHKC1K5UC$7=GDBRd9w6 z&?totvn4jb3H%6xZgpC5|G=-HJFkvz+&WmdVz)oA_xXJ?bMS;a`;(3{e+8{}wUnwH zLeg>VNyJG5FJn%RuVa(Do>W1*oE;tf}L5NOQD;?fDw`Jq(6(f-=T+{D^B z`^UeggRd(8T?>Ana=^_CAVEo~kugW=!+QN%p~M=Yxt%RtOD@*HoH0BZ0*}NYQHf;G zXai#rqnrM_WO;21C$|osS^4$v9iHvZ`jf5gv-Z#H&4Z!H>}skJD2sL|EQao($4z@5 z%ZMUGS@iR`>$ef!vUA|Uf7L6 z8rilk)~8M<0}BsU-UJURU1}?477e#8zg!L-6;7e;`?}IoEG|l2R`7fj(7mXUrz%&7 z9B;tmo`zjEG8Vz!^!?ecr@Zwd_=nrsle^agKB>&j>pr(1wqJ$Z3EnSyuoimKy)$s# zMB1F3LMwxtZ{Y*;!@7-GJ5`(n0zJx>PQ$l;5h$+&`$A{kmuO7rxQEGy@oI^LB*QKg zn7-w$fKfrO=e!&xlHut5CTdUn0fBYQUB9?Ntr$Oi({m4#{X-46L-NBo8 zGYQ`Y0uxSHF6AE5%b1q%@8TPZnNbbA8p+zC*)o37w7p*+CQu~4fk~*E^NPZYR{954 zYrbTJ_;^kucxa&7C_D~oj;7`>=$?WyMwdz|)ejwbe@#B{3F>-s#Gn`FHal>C>4Ho% zKZtfcXQl)Mx(AEzai-nLS-j~Zeo%VOVdJf4UBJmZ<@>XYr4GCIqIi;{aJ-g6H|Ze8 zK?GNM=HsvMFN|JgAdcsZw4xIa2zzrOc3;|%54$*LrBYoMuN#DcY(EpaaQ@9;94>|^orp8d> z$V$X_U`PvQ)@U3OPFbkR163>01bHeD>OK4N`wr!gH-g78E17`;BmlP!g;<>D4DUl8 zV04)2ea>x8X2*X=S^!v}`9lj=EEq}zei@3uBqIOgQGi&45O)k#h(|~WYuV(r?IPze zv(1sZ@oQhyDJhW%>cLPOHFFJ(o*U$QwfMQ-JGN2$bL^-2SpH%bBtD5dO`cb&PL;Nw zNCA*vE)1o`V{;u9k5|R7Fc}&Or!&W{3SRU8fyz++xL5ODtvFL9S{20`XwxjwacABM zWDqXsS@FVPhQ*qoUIwF3i(~f(wYbCuW7b|pNK$%Op++pIErF;y+(_ZU$&a>yTMkSQ zJx-Wz*bjyFXyJ?q;KWD%D;5-mYZgd$DtD9cz4z=z4SiA#fmAZ#iP^|l0aFwYq?n~6TS73d zMr9FXSoRDfuAokH!-|iz@}^QFUbY>|HqUJY#fS_Z97}(Yvn=eAzbtkd1D}@RPK$Dm zY5@6wq8!e!7?kLkX{G@p5M?FBgCN!t)>ZgGoV)uUE6D%oW zgTqM1ELtf7X||#kntf(>B|bu;-zYUG_(2q1X2K7bMO3~o1Z)hltsjQ?Y#j2|-=}mE z=J9Zy>%EoBtE54ZeN_k)+X~EuO5TtE+x~kg;{?lg<#m|aCAkCUZ@!yD)fGf)Cq{tL zVZ7TUn<`U$>0_W#{)dn{PXMcbYd;epoRCk1A1l zQ{pXf=s72prA?C1wuxmUk_Dg}U{L|P9K?-=KO0+tY{%IPANEqE-y!zU8*`tM&ojG4T)CGw?ZRp)q3J-Bc8 z+RJ-4{blEn7Ky`xnq^U-7(`;0vf$wHcX4Cyr`t&s-NI5o_S5SV$eTAu3z%!9gbX7T zHp*LP&8~DQgu#=UD}Ey(WKZHthvG^S1=UFYI8kjab8`-cT_C5@ls z-rD+ffXZ0?K}d~>W4L^nD3S%qC{~px7rR|{y|ZM=I-`Hc?;!w4A9pjp9p5i?*t0OM zI6Y$E`fMaa9!J?NyFWMw{rb*H4cXvc7(>T)<=O`d|>G?6O~V4Me?_7yia$st(QN>cpm%jUoD^VeueC9FP%#bALAR;t*R^Jihgp|$_3Ho z?TV)V8u%=l|5)2q2cu>k+vwjBa6Htu^JpP~!>)fh-P;RiW!hjW^`e?}?6Y-ea?W3g z^#&nZt1c%-;Y<&)f9>bKE+(9OXSmdz?!BBEk+lN5V9I2=6t%5oOFt9M&=SReX^)i# zeO_SRa{Cg)n~z5~e1kPLH6pzmxSS#zEA=YeWQ=21#Jq35AAHSru3sr124fzSTb z!wt!9vuUxlVKXZ?(-mU3iSGDVryu4*q;k~WbSQtuqAtB?>bH}kliBs5)|rV3M3GfD z`}D|2l6SsW*yGCR%1p~Eck9%3&VM*?k8Cy@sw@wD&J}cU@@8Zv&oAI*qaHgKslPX| zP$AUdvvcWS%&Ewsd!Gu_&(Ey+miU=I;IYV_my2u3jAZw4d?TN! z+lv^J594?xUD5Q<*TTSv8DGJm?rf1!GgM`kZ&8$p5UeN_(F9Th^Mz@d#51rjKZW??bJkJp`?CcnMwAeYpqhPwyn zM@p1HP!}0O)4W=a@S>0qxoeN3o(vW5-B9cVn+uAsv0l7;%L=-hU3k~%q|(^NErTCh$16E3u}k;Q8&AkyRMI@*eqI|nt&pG~!e}cHCS4ZQJ6d=5V3Do)NBHyI zTk*`VheL2pG` z_df@9{=9$oS>u9W-e$ArN!gI{ZxzMEj{%B7zn*rpS0^=218$XiagL@hF2B%D`wz3F zQ^P8SWXuvWgjdf#c@|=|b?1~5^!Rx>m1pS`(l5Ch`$Yd&Y-%K$UvpB9l+vZ1K_ojA*`mfJE+pek=n}Gv=DK49}KoXrCrIn6l z%rsBN*I}Zy7{pY>pAK2QZ2bLT^0{Ig_*0ow*${|)e!%*4a%MP9{aZFL^`(XeXZcCf zu<0{`lKvgFDG8U}{JGzwr@R=waARwK>$(5o6rYm+uYSM7sjG^9hc1oX2)7a8kB%=) zKbp}g6rHKAY%Hy8kTJx8iPj~}G+R<9r@z$oRe6Q)eSTusBR1;$lO0zT!xT#R4bRvX zYI*S%!q>E5!lndE0^Xzbo4f7m<05@!TP%Ta1<#v{Jzs44xsu5~t5D3X63la|^NUKS zSZ9|??KEt`U^&?O3RkW1D9`00-)fPYez)&`VDVx&j(#dKnD&_Zo+S#4a}!ZBdD>8+ z7VE5|IbEt9>0Ry&R^sO_|(uQ-`O_%1l3uH-|MvfjJ$Te`$^G7kTRP ztR}#n>AEu;|CZ={CRn|V;h0oZLFpML(?VT>Y1*LU)5^-~#_3UKmxk%eO6SPNY3HXJ zm6an&&d$WjQH2uc!fIn9oSjvU*?o;15pSMo`9Ef@BP?FM>-+Mp>?+GY5kITa@lFGs z_%!4SG1H(7uX{P$2se-_cQxmRlBwI%=m`r`CYcche*SU!pAD5#Kdo!Y2?MnZvrE=C zBmuqOU-MQ4)GfS8;rch5frt<$UHAh?e?k6z5jInRRjPVvaFRB;ob}xR4 z>yPw{>OsRLHdw7XK{RA?_L}#|@4lZ|N}KmMdqhsVM#aXC&9BTiKP`LeQ0~Ci$*;v5 zO|j|U`sw_sKAOs5>b}j_k~{g~VN;pDtUwII7gB1-|Mc66-KPjR6`cDYl78W~HvyG_ zhT9&))T|>g$>uz5#L3TmBpdHeU6YH;_{2}Z+{+B(f!p2%ioot=CTrQgJwzR+DK!8E z7O_n^_f+mwh+llpkt<-qh{Vy_pfyLVbMfjyjz$A4-1C)Z^ zn%11Y`UihQJXcRe8i8RoMG-Ky46T%Rz0*XWS3PU)Q;t8_#EO1a2cz()ZNQJzFK`}q z{gF>|3~5^ogdW^u3nG(!rF}{Ph$&9$O5&pYhwM&Q&a=9zyRKP$vI{B;R9sWfl|zD~ z_!iqqrj_?#Zi{)cX+*XARCWGu&uTxKwlFQ1q+gAW4c$CW>!! z?S=n@?sVPFR43VKaf-2(Tz2+=%Ut?yxBh&T{0q`(Nb9-1`I4rQP`^F0W5%FQ*{4&( zqa1z`b$072*W;^gVQt|mHDn~wMH5qe#e*1vxn(a&h_83)FJ#;s4eR~aT}rX5&XYo@#H>Z~Tfm#r}c(>;GD!ZqdQGwc1u?d|PAyD{Z$ zzJ0$#I;v+44D-6ad4asZOYByh>r&}qkl(bPiGDfzdN&i;V|wX&d~i(LYJ15)nO3@U zjryd)GV?8430q_PW>n|D_wVcbQYpLT^#k56!}@a@3z3{t-h#(8K_6u>B-djT%=*0h zTesLVrM-FCt=0W17Xu&@*W~A;bW3)D^?~^HFqO!S zw>AMwhzHbr3(60md-GA`m(qd5rF!;QC48eJuiIzdzLjjK=TQ^HWnZ9b7MU*Lj_TRO u#Lv66&Lgu7jVUlMycK;h*7dhW&pK+Nj \ No newline at end of file diff --git a/mobile/android/app/src/main/res/values/colors.xml b/mobile/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..599f126 --- /dev/null +++ b/mobile/android/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + #0E1018 + #0E1018 + #023c69 + #0E1018 + \ No newline at end of file diff --git a/mobile/android/app/src/main/res/values/strings.xml b/mobile/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..482c998 --- /dev/null +++ b/mobile/android/app/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + goon + contain + false + \ No newline at end of file diff --git a/mobile/android/app/src/main/res/values/styles.xml b/mobile/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..e2683a9 --- /dev/null +++ b/mobile/android/app/src/main/res/values/styles.xml @@ -0,0 +1,17 @@ + + + + + \ No newline at end of file diff --git a/mobile/android/app/src/main/res/xml/network_security_config.xml b/mobile/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..f78d080 --- /dev/null +++ b/mobile/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,38 @@ + + + + + + localhost + 10.0.2.2 + + + + + + + + diff --git a/mobile/android/build.gradle b/mobile/android/build.gradle new file mode 100644 index 0000000..abbcb8e --- /dev/null +++ b/mobile/android/build.gradle @@ -0,0 +1,41 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + ext { + buildToolsVersion = findProperty('android.buildToolsVersion') ?: '35.0.0' + minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '24') + compileSdkVersion = Integer.parseInt(findProperty('android.compileSdkVersion') ?: '35') + targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '34') + kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25' + + ndkVersion = "26.1.10909125" + } + repositories { + google() + mavenCentral() + } + dependencies { + classpath('com.android.tools.build:gradle') + classpath('com.facebook.react:react-native-gradle-plugin') + classpath('org.jetbrains.kotlin:kotlin-gradle-plugin') + } +} + +apply plugin: "com.facebook.react.rootproject" + +allprojects { + repositories { + maven { + // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm + url(new File(['node', '--print', "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), '../android')) + } + maven { + // Android JSC is installed from npm + url(new File(['node', '--print', "require.resolve('jsc-android/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim(), '../dist')) + } + + google() + mavenCentral() + maven { url 'https://www.jitpack.io' } + } +} diff --git a/mobile/android/gradle.properties b/mobile/android/gradle.properties new file mode 100644 index 0000000..08d7cf3 --- /dev/null +++ b/mobile/android/gradle.properties @@ -0,0 +1,58 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m +org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true + +# Enable AAPT2 PNG crunching +android.enablePngCrunchInReleaseBuilds=true + +# Use this property to specify which architecture you want to build. +# You can also override it from the CLI using +# ./gradlew -PreactNativeArchitectures=x86_64 +reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 + +# Use this property to enable support to the new architecture. +# This will allow you to use TurboModules and the Fabric render in +# your application. You should enable this flag either if you want +# to write custom TurboModules/Fabric components OR use libraries that +# are providing them. +newArchEnabled=false + +# Use this property to enable or disable the Hermes JS engine. +# If set to false, you will be using JSC instead. +hermesEnabled=true + +# Enable GIF support in React Native images (~200 B increase) +expo.gif.enabled=true +# Enable webp support in React Native images (~85 KB increase) +expo.webp.enabled=true +# Enable animated webp support (~3.4 MB increase) +# Disabled by default because iOS doesn't support animated webp +expo.webp.animated=false + +# Enable network inspector +EX_DEV_CLIENT_NETWORK_INSPECTOR=true + +# Use legacy packaging to compress native libraries in the resulting APK. +expo.useLegacyPackaging=false + +android.extraMavenRepos=[] \ No newline at end of file diff --git a/mobile/android/gradle/wrapper/gradle-wrapper.jar b/mobile/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189 GIT binary patch literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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 GZ~q4|mN%#X literal 0 HcmV?d00001 diff --git a/mobile/android/gradle/wrapper/gradle-wrapper.properties b/mobile/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..79eb9d0 --- /dev/null +++ b/mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/mobile/android/gradlew b/mobile/android/gradlew new file mode 100644 index 0000000..f5feea6 --- /dev/null +++ b/mobile/android/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/mobile/android/gradlew.bat b/mobile/android/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/mobile/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/mobile/android/sentry.properties b/mobile/android/sentry.properties new file mode 100644 index 0000000..0efddb2 --- /dev/null +++ b/mobile/android/sentry.properties @@ -0,0 +1,4 @@ +defaults.url=https://sentry.io/ +# no org found, falling back to SENTRY_ORG environment variable +# no project found, falling back to SENTRY_PROJECT environment variable +# Using SENTRY_AUTH_TOKEN environment variable \ No newline at end of file diff --git a/mobile/android/settings.gradle b/mobile/android/settings.gradle new file mode 100644 index 0000000..9bb2c76 --- /dev/null +++ b/mobile/android/settings.gradle @@ -0,0 +1,38 @@ +pluginManagement { + includeBuild(new File(["node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().toString()) +} +plugins { id("com.facebook.react.settings") } + +extensions.configure(com.facebook.react.ReactSettingsExtension) { ex -> + if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') { + ex.autolinkLibrariesFromCommand() + } else { + def command = [ + 'node', + '--no-warnings', + '--eval', + 'require(require.resolve(\'expo-modules-autolinking\', { paths: [require.resolve(\'expo/package.json\')] }))(process.argv.slice(1))', + 'react-native-config', + '--json', + '--platform', + 'android' + ].toList() + ex.autolinkLibrariesFromCommand(command) + } +} + +rootProject.name = 'goon' + +dependencyResolutionManagement { + versionCatalogs { + reactAndroidLibs { + from(files(new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), "../gradle/libs.versions.toml"))) + } + } +} + +apply from: new File(["node", "--print", "require.resolve('expo/package.json')"].execute(null, rootDir).text.trim(), "../scripts/autolinking.gradle"); +useExpoModules() + +include ':app' +includeBuild(new File(["node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile()) diff --git a/mobile/app.json b/mobile/app.json new file mode 100644 index 0000000..f9ec1be --- /dev/null +++ b/mobile/app.json @@ -0,0 +1,54 @@ +{ + "expo": { + "name": "goon", + "slug": "goon", + "version": "0.1.8", + "orientation": "portrait", + "userInterfaceStyle": "automatic", + "newArchEnabled": false, + "runtimeVersion": "1.0", + "updates": { + "enabled": false, + "url": "https://invalid.example.invalid/expo-updates/manifest", + "checkAutomatically": "ON_LOAD", + "fallbackToCacheTimeout": 0 + }, + "icon": "./assets/icon.png", + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.goon.mobile" + }, + "android": { + "package": "com.goon.mobile", + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#0E1018" + } + }, + "splash": { + "image": "./assets/splash.png", + "backgroundColor": "#0E1018", + "resizeMode": "contain" + }, + "web": { + "favicon": "./assets/favicon.png" + }, + "plugins": [ + "expo-asset", + [ + "expo-build-properties", + { + "android": { + "usesCleartextTraffic": false + } + } + ], + "expo-video", + "@sentry/react-native/expo" + ], + "extra": { + "sentryDsn": "", + "sentryEnvironment": "production" + } + } +} \ No newline at end of file diff --git a/mobile/assets/adaptive-icon.png b/mobile/assets/adaptive-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..123637440600e75dd9d03c6b4e8ec7d55b5cda24 GIT binary patch literal 151728 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