Enrich existing movies (from paradisehill/dooplay, which mostly lack cast) with metadata from TPDB's /movies API: cast, categories (tags), studio, director + a canonical TPDB UUID for dedup. Chosen over IAFD after a source-comparison research pass — IAFD has strong cast/studio but ZERO categories, while TPDB /movies has ~11 tags/movie, cast, studio, director, a canonical UUID (+ sparse phash), is already an integrated API (no scraping/anti-bot), and covers ~75-85% of our western-DVD-feature catalog. Enrichment only ever augments EXISTING movies and never creates new ones (TPDB has no playback, so a standalone TPDB movie would be unplayable). Writes to movie_performers / movie_tags / movie.studio_id, which the movies API + mobile detail already render, so no schema/API/UI change is needed. - connectors/tpdb.py: search_movies() + fetch_movie() + _parse_movie() reusing the existing _parse_studio/_parse_performer/_parse_tag. - enrich/tpdb_movies.py: match our movie to a TPDB /movies result by token_sort_ratio on normalized titles (sort, not set, to reject the short-title-subset trap "Fantasies" -> "Tara's Fetish Fantasies") with a +/-2yr guard; then attach cast/tags/studio/director. Incoming performers deduped by external_id to avoid the performer_external_refs PK clash. - resolve/movie_merge.py: merge_movies() mirror of scene_merge; two of our movies mapping to the same TPDB UUID are the same film -> merge. - scheduler: _job_tpdb_movie_enrich every 6h, batch 200, prioritizing playable movies missing cast/studio. Verified on a 150-movie batch: 119 enriched, 4 deduped, 26 no-match, 0 errors; matched titles/studios spot-checked correct (Big Butts Drive Me Nuts 4 -> 33 tags, Seinfeld #2 -> 10 cast/17 tags, German BB Video titles -> categories+studio). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
418 lines
15 KiB
Python
418 lines
15 KiB
Python
"""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,
|
|
RawMovie,
|
|
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 search_movies(self, query: str, *, per_page: int = 10) -> list[dict[str, Any]]:
|
|
"""GET /movies?q=<query> → surowe payloady filmów (list endpoint jest już
|
|
hydrated: tags + performers + site embedded, bez detail-fetcha).
|
|
|
|
Używane do enrichmentu movies: wyszukujemy kandydatów po tytule, scorujemy
|
|
i mapujemy najlepszy przez `_parse_movie`. Pusty list na błąd/brak."""
|
|
if not query.strip():
|
|
return []
|
|
with self._client() as client:
|
|
try:
|
|
payload = self._get(client, "/movies", {"q": query, "per_page": per_page})
|
|
except httpx.HTTPStatusError as e:
|
|
log.warning("tpdb /movies q=%s failed: %s", query, e)
|
|
return []
|
|
return payload.get("data") or []
|
|
|
|
def fetch_movie(self, movie_uuid: str) -> dict[str, Any] | None:
|
|
"""GET /movies/<uuid> → pojedynczy film (detail). None gdy 404/błąd.
|
|
|
|
NB: list endpoint jest już hydrated, więc detail rzadko potrzebny — trzymamy
|
|
dla spójności / gdyby TPDB kiedyś przeniósł część pól tylko do detalu."""
|
|
with self._client() as client:
|
|
try:
|
|
payload = self._get(client, f"/movies/{movie_uuid}", {})
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
return None
|
|
log.warning("tpdb /movies/%s failed: %s", movie_uuid, e)
|
|
return None
|
|
return payload.get("data")
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
def _parse_movie(raw: dict[str, Any]) -> RawMovie | None:
|
|
"""TPDB movie payload → RawMovie. Reużywa _parse_studio/_parse_performer/_parse_tag
|
|
(movies mają identyczny kształt site/performers/tags co sceny)."""
|
|
external_id = raw.get("id")
|
|
title = raw.get("title")
|
|
if not external_id or not title:
|
|
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)
|
|
|
|
directors = raw.get("directors") or []
|
|
director = ", ".join(d.get("name") for d in directors if isinstance(d, dict) and d.get("name")) or None
|
|
|
|
d = _parse_date(raw.get("date"))
|
|
# duration bywa 1s-placeholderem na movies — poniżej 60s traktujemy jak brak.
|
|
dur = raw.get("duration")
|
|
duration_sec = int(dur) if dur and int(dur) > 60 else None
|
|
|
|
def _img(field: str) -> str | None:
|
|
v = raw.get(field)
|
|
if isinstance(v, str) and v:
|
|
return v
|
|
if isinstance(v, dict):
|
|
return v.get("large") or v.get("full") or v.get("medium") or None
|
|
return None
|
|
|
|
poster = _img("poster") or _img("posters")
|
|
backdrop = _img("background") or _img("backdrop")
|
|
|
|
return RawMovie(
|
|
external_id=str(external_id),
|
|
title=title,
|
|
description=raw.get("description"),
|
|
release_year=d.year if d else None,
|
|
release_date=d,
|
|
duration_sec=duration_sec,
|
|
director=director,
|
|
rating=float(raw["rating"]) if raw.get("rating") not in (None, "") else None,
|
|
poster_url=poster,
|
|
backdrop_url=backdrop,
|
|
url=raw.get("url"),
|
|
studio=_parse_studio(raw.get("site")),
|
|
performers=performers,
|
|
tags=tags,
|
|
raw=raw,
|
|
)
|