"""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