goon/app/connectors/tpdb.py
goon-foss a4486980a4 style: myslniki em/en z komentarzy i docstringow (calosc repo)
1745 wystapien w 291 plikach (backend, scripts, mobile). Dominowal jeden ksztalt,
' — ' laczace zdania, wiec poszedl przecinek; myslnik otwierajacy mysl zdjety;
zakresy 0–5 i A–Z na dywiz.

Podzial komentarz/kod robil skaner stanow, nie regex, zeby nie ruszyc myslnika
stojacego w stringu albo w wyrazeniu regularnym. Potwierdzone maszynowo: po wycieciu
komentarzy z wersji HEAD i z dysku obie sa identyczne we wszystkich 291 plikach,
czyli poza komentarzami nie zmienilo sie nic. 233 pliki .py parsuja sie bez bledu,
tsc ma te same 11 bledow w tych samych 3 plikach co przed zmiana.

Dwie rzeczy wymagaly poprawki po automacie: wyrownane listy definicji, gdzie myslnik
byl separatorem kolumny (37 miejsc, kolumna przywrocona), i trzy przeniesienia mysli
do nowej linii, gdzie zostawal wiszacy przecinek na poczatku (poprawione recznie).

Bundla nie republikuje: Hermes i tak nie niesie komentarzy, wiec dla uzytkownika
to zero zmiany.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:51:57 +02:00

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,
)