diff --git a/app/api/playback.py b/app/api/playback.py index 3f6307c..45ad295 100644 --- a/app/api/playback.py +++ b/app/api/playback.py @@ -224,6 +224,19 @@ def resolve_movie_playback( if stream: type_hint = "m3u8" if ".m3u8" in stream.lower() else "mp4" raw_meta: dict = {"origin": pb.origin, "host": target} + # seekplayer-engine (#hash family: easyvidplayer/player4me/seekplayer/ + # embedseek/upns — ~322k sources) zwraca master.m3u8 na raw-IP CDN + # (185.237.x/203.188.x/45.156.x `/v4///pp//master.m3u8`). + # Zweryfikowane cross-IP (curl_cffi chrome + Bright Data, 2026-06-06): + # manifest + variant + fMP4 segment WSZYSTKIE 200 z innego IP, a cert jest + # VALID (verify=True OK — IP-SAN, nie self-signed jak głosił stary docstring). + # Token jest TIME-bound (`` unix ts), NIE IP-bound. → mobile ExoPlayer + # gra direct z CDN, zero VPS bandwidth (był to największy movie proxy-sink). + # Proxy (`stream_url`) zostaje jako fallback (stream_proxy IP-host gałąź + # robi verify=False). Device-verified na emulatorze przed deployem. + from app.extractors.hosters import seekplayer_engine + if seekplayer_engine.matches(target): + raw_meta["mobile_direct_ok"] = True links.append( StreamLink( stream_url=stream, diff --git a/app/api/scenes.py b/app/api/scenes.py index b7f7f7a..0b73403 100644 --- a/app/api/scenes.py +++ b/app/api/scenes.py @@ -67,6 +67,40 @@ def _default_scene_count(session: Session) -> int: return total +# Blacklisty (performer/studio/tag) są zwykle PUSTE (self-hosted, single-user). Mimo to +# 3 NOT EXISTS klauzule doklejały się do KAŻDEJ filtrowanej listy scen i były ewaluowane +# per-row — przy filtrze typu duży-tag/has_playback planer chodzi po ~176k scen, więc te +# puste-zawsze klauzule kosztowały ~3.4s (mega-tag „anal": 6.7s→3.3s po pominięciu). +# Cache'ujemy emptiness (TTL 5 min); gdy ktoś doda blacklist-wpis, w ciągu 5 min klauzule +# wracają. Patrz reference_scenes_list_perf / task #22. +_BLACKLIST_EMPTY_CACHE: dict = {"ts": 0.0, "val": False, "checked": False} +_BLACKLIST_EMPTY_TTL = 300.0 + + +def _blacklists_empty(session: Session) -> bool: + """True gdy WSZYSTKIE 3 blacklisty puste → można pominąć NOT EXISTS klauzule.""" + import time as _time + from app.models.blacklist import ( + BlacklistedPerformer, + BlacklistedStudio, + BlacklistedTag, + ) + now = _time.monotonic() + if _BLACKLIST_EMPTY_CACHE["checked"] and (now - _BLACKLIST_EMPTY_CACHE["ts"]) < _BLACKLIST_EMPTY_TTL: + return _BLACKLIST_EMPTY_CACHE["val"] + has_any = session.execute( + select( + exists(select(1).select_from(BlacklistedPerformer)) + | exists(select(1).select_from(BlacklistedStudio)) + | exists(select(1).select_from(BlacklistedTag)) + ) + ).scalar_one() + _BLACKLIST_EMPTY_CACHE["ts"] = now + _BLACKLIST_EMPTY_CACHE["val"] = not has_any + _BLACKLIST_EMPTY_CACHE["checked"] = True + return not has_any + + def _split_csv(raw: str | None) -> list[str]: if not raw: return [] @@ -211,30 +245,33 @@ def list_scenes( # 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) + # Pomijamy gdy wszystkie 3 blacklisty puste (typowy stan single-user) — te NOT EXISTS + # ewaluują się per-row na ~176k scen przy mega-tagu i kosztowały ~3.4s za nic. + if not _blacklists_empty(session): + from app.models.blacklist import ( + BlacklistedPerformer, + BlacklistedStudio, + BlacklistedTag, ) - ) - 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) + 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( diff --git a/mobile/src/screens/MovieDetailScreen.tsx b/mobile/src/screens/MovieDetailScreen.tsx index 8b5ed04..a11b719 100644 --- a/mobile/src/screens/MovieDetailScreen.tsx +++ b/mobile/src/screens/MovieDetailScreen.tsx @@ -205,14 +205,21 @@ function WatchChip({ ...parts.map((p) => ({ text: ((p.raw as any).part_label as string) ?? p.quality ?? 'Part', onPress: () => { + // Preferuj direct CDN URL (0 VPS bandwidth) → fallback proxy. paradisehill + // parts to mp4 z portable CDN (v1.paradisehill.cc, time-bound) → direct gra + // natywnie, proxy tylko gdyby direct padł. Mirror scen (SceneDetailScreen). + const pDirect = p.direct_url; + const pIsDirect = !!pDirect && pDirect !== p.stream_url; navigation.navigate('Player', { - url: p.stream_url || p.embed_url || pb.page_url, + url: pDirect || p.stream_url || p.embed_url || pb.page_url, sceneId: movieId, playbackId: pb.id, entityKind: 'movie', durationSec: pb.duration_sec ?? null, title: `${title} — ${(p.raw as any).part_label ?? p.quality}`, - mode: p.stream_url ? 'video' : 'webview', + mode: (p.direct_url || p.stream_url) ? 'video' : 'webview', + headers: pIsDirect && p.headers ? p.headers : undefined, + fallbackProxyUrl: pIsDirect ? p.stream_url || undefined : undefined, fallbackEmbedUrl: p.embed_url || pb.embed_url || pb.page_url, }); }, @@ -223,7 +230,14 @@ function WatchChip({ return; } - const target = res.best?.stream_url || res.best?.embed_url || pb.page_url; + // Preferuj direct CDN URL (0 VPS bandwidth) → fallback proxy gdy direct fails. + // seekplayer-engine (#hash family, ~322k źródeł) zwraca master.m3u8 na raw-IP CDN + // z VALID ZeroSSL IP-SAN cert + time-bound token — zweryfikowane na emulatorze + // (ExoPlayer gra direct, PLAYING, zero VPS proxy). Wcześniej movie path szedł + // ZAWSZE przez proxy (używał stream_url jako primary). Mirror SceneDetailScreen. + const bestDirect = res.best?.direct_url; + const isDirect = !!bestDirect && bestDirect !== res.best?.stream_url; + const target = bestDirect || res.best?.stream_url || res.best?.embed_url || pb.page_url; const fallbackEmbed = res.best?.embed_url || pb.embed_url || pb.page_url; navigation.navigate('Player', { url: target, @@ -236,7 +250,9 @@ function WatchChip({ entityKind: 'movie', durationSec: pb.duration_sec ?? null, title, - mode: res.best?.stream_url ? 'video' : 'webview', + mode: (res.best?.direct_url || res.best?.stream_url) ? 'video' : 'webview', + headers: isDirect && res.best?.headers ? res.best.headers : undefined, + fallbackProxyUrl: isDirect ? res.best?.stream_url || undefined : undefined, fallbackEmbedUrl: fallbackEmbed, }); },