W 30 dni 490 scen zostalo przez "mark as broken" bez zadnego zrodla, glownie przez problemy sieci uzytkownika (porndoe 403, galaxyporn, perverzija). Ostatnie zrodlo znika tylko, gdy serwer widzi 404/410 albo HosterDead. Skrypt przywrocil 366 zrodel z pozytywnym potwierdzeniem, ze strona zyje. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
786 lines
34 KiB
Python
786 lines
34 KiB
Python
"""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 import func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.api.schemas import PlaybackSourceOut
|
|
from app.api.device import get_device_id
|
|
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,
|
|
)
|
|
|
|
# Hostery znane z przekierowywania całej domeny na następcę (rodzina DoodStream
|
|
# zmienia szyld co kilka miesięcy). TYLKO dla nich rozwiązujemy przekierowanie przed
|
|
# oddaniem embedu, bo to kosztuje jeden fetch; zwykłe embedy idą bez dotykania sieci.
|
|
_REDIRECTING_EMBED_RE = re.compile(
|
|
r"//(?:[a-z0-9-]+\.)?(?:myvidplay|doply|doodstream|dood|d0+d|dooood|do0od|do7go"
|
|
r"|ds2play)\.[a-z]{2,6}/",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def _resolve_embed_redirect(url: str) -> str:
|
|
"""Zwraca końcowy adres po przekierowaniach albo oryginał, gdy nic się nie zmienia.
|
|
|
|
Wynik trafia do `embed_url` w bazie, więc każdy adres jest rozwiązywany najwyżej
|
|
raz; po utrwaleniu regex przestaje pasować i ta funkcja robi się darmowa.
|
|
"""
|
|
if not _REDIRECTING_EMBED_RE.search(url):
|
|
return url
|
|
from app.extractors import browser_get
|
|
|
|
try:
|
|
res = browser_get(url, timeout=15.0, follow_redirects=True)
|
|
final = str(getattr(res, "url", "") or "")
|
|
# Utrwalamy tylko realny skok na inny adres http(s); błąd hostera (5xx itp.)
|
|
# zostawia oryginał, żeby nie zabetonować chwilowej awarii jako "nowego" adresu.
|
|
if final.startswith("http") and final != url and res.status_code < 400:
|
|
return final
|
|
except Exception as e:
|
|
log.info("embed redirect resolve padl dla %s: %s", url, e)
|
|
return url
|
|
|
|
|
|
# IP-BOUND CDN signature, token bind do requester IP, cross-IP fetch = 403.
|
|
# Wymaga mobile WebView fallback (mobile extract z phone session, nie VPS).
|
|
# Shared KVS infrastructure across multiple hosters (luluvid movies, mypornerleak,
|
|
# latestpornvideo), wszystkie używają tego samego CDN pool.
|
|
_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":
|
|
# Paradisehill: pobierz page, parsuj `var videoList = [...]` żeby dostać N parts.
|
|
# Każdy part to direct mp4 z paradisehill CDN (v1.paradisehill.cc), serwowane
|
|
# bez auth, 200 OK z plain User-Agent + Referer.
|
|
# Bug-reports `c5693926`/`418270e4`/`3c999b27` 2026-05-21 ("ładuje tylko 1 z N").
|
|
# Poprzednio: tylko WebView fallback → mobile gra 1. part w playerze paradisehilla,
|
|
# nie ma sposobu przejść do następnego.
|
|
try:
|
|
from app.connectors.paradisehill import fetch_and_extract_parts
|
|
parts = fetch_and_extract_parts(pb.page_url)
|
|
except Exception as e:
|
|
log.warning("paradisehill parts extract failed for %s: %s", pb.page_url, e)
|
|
parts = []
|
|
if parts:
|
|
for url, label in parts:
|
|
# NIE proxifikujemy tutaj, outer `_proxify_link` poniżej (linia 247) opakuje
|
|
# wszystkie linki. Double-wrap → token wewnątrz tokena (broken proxy URL).
|
|
links.append(
|
|
StreamLink(
|
|
stream_url=url,
|
|
embed_url=None,
|
|
quality=label,
|
|
type="mp4",
|
|
raw={"origin": pb.origin, "part_label": label},
|
|
)
|
|
)
|
|
else:
|
|
# Fallback: brak videoList (np. login-only movie), WebView na całość.
|
|
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
|
|
# Mixdrop mxcontent CDN wymaga curl_cffi JA3 → wymusza VPS proxy.
|
|
# Pre-2026-05-25 skipowaliśmy ten path "Bandwidth + anonimowość > UX",
|
|
# ale bug-report b1b5e1a2 zgłosił że Mixdrop WebView fallback = czarny
|
|
# ekran (recaptcha/adblock-detect blokują player init w in-app WebView).
|
|
# Movie ma zwykle 10+ alt hosterów, ale jeśli WebView fallback nie
|
|
# działa, user widzi tylko czarny ekran zamiast jakiejkolwiek alternatywy.
|
|
# Backend ma pełen pipeline: mixdrop.py extract → raw={proxy_impersonate:
|
|
# True, refetch_url} → stream_proxy._curl_cffi_stream z Chrome JA3 +
|
|
# auto-refetch on token expire. Włączamy go z powrotem.
|
|
# Bandwidth cost: ~485 MB/movie play; przy ~3 plays/day = 1.5 GB/day
|
|
# (acceptable na 8GB/m Hetzner plan z 20 TB transfer).
|
|
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/<tok>/<exp>/pp/<hash>/master.m3u8`).
|
|
# Zweryfikowane cross-IP (curl_cffi chrome + Bright Data, 2026-06-06):
|
|
# manifest + variant + fMP4 segment WSZYSTKIE 200 z innego IP, a cert jest
|
|
# VALID (verify=True OK, IP-SAN, nie self-signed jak głosił stary docstring).
|
|
# Token jest TIME-bound (`<exp>` unix ts), NIE IP-bound. → mobile ExoPlayer
|
|
# gra direct z CDN, zero VPS bandwidth (był to największy movie proxy-sink).
|
|
# Proxy (`stream_url`) zostaje jako fallback (stream_proxy IP-host gałąź
|
|
# 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,
|
|
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:
|
|
# Hoster potrafi zostać atrapą przekierowującą na następcę (myvidplay i
|
|
# doply odpowiadają 30x na playmogo). WebView w apce blokuje top-frame
|
|
# skok na inną domenę (ochrona przed popunderami), więc surowy adres
|
|
# kończy się czarnym ekranem: zgłoszenia 3b75cb54/11d7b17b/d3af0813,
|
|
# wcześniej identyczny mechanizm w galaxyporn (vikingfile → vik1ngfile).
|
|
# Rozwiązujemy przekierowanie TUTAJ i utrwalamy wynik, żeby telefon
|
|
# dostał adres, na którym wolno mu zostać.
|
|
final_embed = _resolve_embed_redirect(pb.embed_url)
|
|
if final_embed != pb.embed_url:
|
|
log.info(
|
|
"movie playback %s: embed %s przekierowuje na %s, utrwalam",
|
|
pb.id, pb.embed_url, final_embed,
|
|
)
|
|
pb.embed_url = final_embed[:2048]
|
|
session.commit()
|
|
links.append(
|
|
StreamLink(
|
|
stream_url=None,
|
|
embed_url=pb.embed_url,
|
|
quality=pb.quality,
|
|
type="hoster",
|
|
raw={"origin": pb.origin},
|
|
)
|
|
)
|
|
elif not links:
|
|
# Sam adres strony serwisu (parser nie znalazl hosterow, np. mangoporn po zmianie
|
|
# wygladu 10.2026). Ekstraktor nie ma tu czego szukac, telefon gra w WebView.
|
|
links.append(
|
|
StreamLink(
|
|
stream_url=None,
|
|
embed_url=pb.page_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]
|
|
# Dla paradisehill multipart: `_pick_best` wybiera "Part N" z najwyższą cyfrą (parsuje
|
|
# quality jako int), ale user chce zacząć od Part 1. Override: zawsze links[0].
|
|
if pb.origin == "paradisehill" and len(links) > 1 and any(
|
|
(link.raw or {}).get("part_label") for link in links
|
|
):
|
|
best = links[0]
|
|
else:
|
|
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)
|
|
others_alive = session.scalar(
|
|
select(func.count())
|
|
.select_from(PlaybackSource)
|
|
.where(
|
|
PlaybackSource.scene_id == scene_id,
|
|
PlaybackSource.id != pb.id,
|
|
PlaybackSource.dead_at.is_(None),
|
|
)
|
|
)
|
|
if not others_alive and not _scene_source_confirmed_dead(pb):
|
|
log.info(
|
|
"user mark-dead na ostatnim zrodle sceny %s odrzucone, serwer go nie potwierdzil (%s)",
|
|
pb.id, tag,
|
|
)
|
|
return
|
|
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)
|
|
|
|
|
|
def _scene_source_confirmed_dead(pb: PlaybackSource) -> bool:
|
|
"""Czy serwer sam widzi, ze zrodlo sceny nie zyje. Uzywane, gdy przytrzymanie w apce
|
|
zabiloby OSTATNIE zywe zrodlo sceny (scena znika wtedy z list).
|
|
|
|
W 30 dni do 2026-10-15 z 617 zrodel oznaczonych w apce 490 zostawilo scene bez zadnego
|
|
zrodla, glownie porndoe (403 z sieci uzytkownika), galaxyporn, perverzija, javflix
|
|
(zgloszenie cbb87c8e: strona zyje, ekstraktor daje voe i doodstream). Martwe tylko,
|
|
gdy strona daje 404/410 albo ekstraktor mowi wprost, ze film usuniety; kazda
|
|
niepewnosc zostawia zrodlo.
|
|
"""
|
|
origin = pb.origin or ""
|
|
if not origin.startswith(("tube:", "pornapp:")):
|
|
return False
|
|
try:
|
|
try_extract(origin.split(":", 1)[1], pb.page_url)
|
|
except HosterDead:
|
|
return True
|
|
except TubePageError as e:
|
|
return e.status_code in (404, 410)
|
|
except Exception:
|
|
return False
|
|
return False
|
|
|
|
|
|
def _movie_source_confirmed_dead(pb: Any) -> bool:
|
|
"""Czy serwer sam widzi, ze link filmu nie zyje. Uzywane, gdy przytrzymanie w apce
|
|
zabiloby OSTATNI link filmu.
|
|
|
|
Zgloszenie 284c9522 (2026-10-14): jedyny link "Italian She Male 44" oznaczyl telefon,
|
|
ktory nie dochodzil do wezla CDN playmogo, a plik gral (206 z innego IP). Film zostal bez
|
|
zadnego linku i apka chowala cala sekcje. Martwy jest tylko przy 404/410 strony embedu albo
|
|
gdy ekstraktor mowi wprost, ze film usuniety. Rodzina DoodStream ma blok "Not Found"
|
|
zawsze w HTML (ukryty), wiec tam zyje, dopoki strona ma pass_md5. Kazda niepewnosc
|
|
(blad sieci, captcha, pusty ekstraktor) zostawia link.
|
|
"""
|
|
import httpx
|
|
|
|
target = pb.embed_url or pb.page_url
|
|
try:
|
|
r = httpx.get(
|
|
target,
|
|
headers={"User-Agent": "Mozilla/5.0", "Referer": pb.page_url or target},
|
|
timeout=15.0,
|
|
follow_redirects=True,
|
|
)
|
|
except Exception as e:
|
|
log.info("kontrola linku filmu %s padla: %s", pb.id, e)
|
|
return False
|
|
if r.status_code in (404, 410):
|
|
return True
|
|
if r.status_code >= 400 or "pass_md5" in r.text:
|
|
return False
|
|
try:
|
|
extract_stream_from_hoster(target, referer=pb.page_url or target)
|
|
except HosterDead:
|
|
return True
|
|
except Exception:
|
|
return False
|
|
return False
|
|
|
|
|
|
@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)
|
|
others_alive = session.scalar(
|
|
select(func.count())
|
|
.select_from(MoviePlaybackSource)
|
|
.where(
|
|
MoviePlaybackSource.movie_id == movie_id,
|
|
MoviePlaybackSource.id != pb.id,
|
|
MoviePlaybackSource.dead_at.is_(None),
|
|
)
|
|
)
|
|
if not others_alive and not _movie_source_confirmed_dead(pb):
|
|
log.info(
|
|
"user mark-dead na ostatnim zrodle filmu %s odrzucone, serwer go nie potwierdzil (%s)",
|
|
pb.id, tag,
|
|
)
|
|
return
|
|
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)
|
|
|
|
|
|
def _record_resolve_failure(
|
|
session: Session, pb: PlaybackSource, kind: str, detail: str, device_id: str
|
|
) -> None:
|
|
"""Blad rozwiazania po stronie serwera do playback_events.
|
|
|
|
Telefon po 503/502 z resolve pokazuje alert i nie dochodzi do odtwarzacza, wiec nic nie
|
|
zglaszal. Hentaimama padala tak od zmiany strony i miala zero wierszy w telemetrii (ani
|
|
sukcesow, ani bledow), wiec zaden alarm jej nie widzial (2026-10-15).
|
|
"""
|
|
from app.models.playback_event import PlaybackEvent
|
|
|
|
try:
|
|
session.add(
|
|
PlaybackEvent(
|
|
origin=pb.origin[:64],
|
|
scene_id=pb.scene_id,
|
|
status="error",
|
|
error_kind=kind,
|
|
error_detail=detail[:512],
|
|
via="backend",
|
|
device_id=device_id,
|
|
)
|
|
)
|
|
session.commit()
|
|
except Exception as e:
|
|
session.rollback()
|
|
log.info("zapis bledu resolve do playback_events padl: %s", e)
|
|
|
|
|
|
@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)],
|
|
device_id: Annotated[str, Depends(get_device_id)],
|
|
) -> 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)
|
|
_record_resolve_failure(
|
|
session, pb, "resolve_http", f"tube fetch HTTP {e.status_code}", device_id
|
|
)
|
|
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,
|
|
)
|
|
_record_resolve_failure(session, pb, "resolve_none", "extractor returned nothing", device_id)
|
|
# 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")
|
|
# manifest_producer: hoster oddaje TREŚĆ manifestu (POST), nie URL, `/proxy/hls`
|
|
# zawoła producenta zamiast GET-a. Patrz extractors/hosters/loadvid.py.
|
|
manifest_producer = (link.raw or {}).get("manifest_producer")
|
|
token = make_token(
|
|
raw_url, referer, impersonate=use_impersonate,
|
|
refresh=refetch_url, refresh_hoster=refetch_hoster,
|
|
producer=manifest_producer,
|
|
)
|
|
# Decyzja na BASIE link.type (zaufanie do extractora), z fallback path-hint.
|
|
# Pornhat: raw URL `.../get_file/.../<id>.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"}
|
|
is_hls = type_lower in {"m3u8", "hls"}
|
|
# ExoPlayer (expo-video 2.0.6, brak contentType) zgaduje typ z URL: ścieżka kończąca
|
|
# się `.m3u8` → HLS (gra direct, 0 VPS), inaczej → progressive Mp4Extractor → fail
|
|
# → fallback na pełny proxy → CAŁE wideo przez VPS (pornhat `...mp4,?...`: 466 hitów
|
|
# /48h, audit 2026-06-11). Dla takich time-bound HLS dajemy manifest-passthrough pod
|
|
# `play.m3u8`: segmenty zostają absolutne na CDN → telefon ciągnie je direct, przez
|
|
# VPS leci tylko ~1KB manifestu. Manifesty z poprawnym `.m3u8` zostają w pełni direct.
|
|
raw_path = raw_url.split("?", 1)[0].lower()
|
|
hls_needs_passthrough = is_hls and mobile_direct_ok and not raw_path.endswith(".m3u8")
|
|
if use_impersonate or force_proxy or (is_manifest_type and not mobile_direct_ok):
|
|
direct_for_mobile = proxied
|
|
elif hls_needs_passthrough:
|
|
direct_for_mobile = f"/proxy/hls/{token}/play.m3u8"
|
|
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
|