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>
273 lines
10 KiB
Python
273 lines
10 KiB
Python
"""TPDB movie enrichment + dedup.
|
||
|
||
TPDB `/movies` jest naszym kanonicznym źródłem metadanych filmów: obsada, kategorie
|
||
(tagi), studio, reżyser, rok — plus stabilny UUID do dedupu. paradisehill (primary
|
||
movie source) prawie nie ma obsady, więc TPDB wypełnia największą lukę.
|
||
|
||
Zasada: TPDB TYLKO wzbogaca ISTNIEJĄCE filmy (z paradisehill/dooplay) i dedupuje,
|
||
NIGDY nie tworzy nowych — TPDB nie ma playbacku, więc nowy film byłby niegrywalny.
|
||
|
||
Flow per film:
|
||
1. skip, jeśli film ma już TPDB movie ref (movie_external_refs jest tylko-movie,
|
||
więc dowolny ref z tpdb source = już wzbogacony),
|
||
2. search TPDB /movies?q=<tytuł>, wybierz najlepszego kandydata (token-set na tytule
|
||
+ guard roku ±2, próg `min_title`),
|
||
3. DEDUP: jeśli ten TPDB UUID jest już przypięty do INNEGO naszego filmu → to ten
|
||
sam film (mirror) → merge_movies (keep = starszy created_at),
|
||
4. wzbogać ocalały film: obsada (resolve_performer → MoviePerformer), tagi
|
||
(resolve_tag → MovieTag source=tpdb), studio (fill studio_id), reżyser/rok/
|
||
poster/rating (fill-only), przypnij MovieExternalRef(tpdb, uuid).
|
||
|
||
Match jest zachowawczy (próg 0.90 + guard roku), bo TPDB search zwraca dużo (np.
|
||
"Pirates" → gay Knightbreeders przed feature) — bez scoringu wzięlibyśmy zły film.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import uuid
|
||
from datetime import UTC, datetime
|
||
|
||
from rapidfuzz import fuzz
|
||
from sqlalchemy import func, select
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.connectors.tpdb import TPDBConnector, _parse_movie
|
||
from app.models.movie import Movie, MovieExternalRef, MoviePerformer
|
||
from app.models.movie_playback_source import MoviePlaybackSource
|
||
from app.normalize.movies import normalize_movie
|
||
from app.normalize.text import normalize
|
||
from app.resolve.movie_merge import merge_movies
|
||
from app.resolve.movie_resolver import _sync_performers, _sync_tags
|
||
from app.resolve.performer_resolver import resolve_performer
|
||
from app.resolve.studio_resolver import resolve_studio
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
_DASH = str.maketrans({"–": "-", "—": "-", "‑": "-"})
|
||
|
||
|
||
def _cand_year(raw: dict) -> int | None:
|
||
d = raw.get("date")
|
||
if d and len(str(d)) >= 4:
|
||
try:
|
||
return int(str(d)[:4])
|
||
except ValueError:
|
||
return None
|
||
return None
|
||
|
||
|
||
def _best_match(connector: TPDBConnector, movie: Movie, *, min_title: float) -> dict | None:
|
||
"""Najlepszy TPDB movie payload dla naszego filmu, albo None.
|
||
|
||
`token_sort_ratio` (a NIE token_set) na znormalizowanych tytułach: odporny na
|
||
inwersję ("Title, The" ↔ "The Title") i kolejność, ALE penalizuje różnicę długości,
|
||
więc krótki generyczny tytuł nie łapie dłuższego nadzbioru (bug: "Fantasies" →
|
||
"Tara's Fetish Fantasies", bo token_set nagradza podzbiór). Precyzja > recall:
|
||
lepiej pominąć niż wzbogacić zły film. Guard roku ±2 (inny rok = inna edycja)."""
|
||
query = (movie.title or "").translate(_DASH).strip()[:60]
|
||
if not query:
|
||
return None
|
||
my_norm = normalize(movie.title)
|
||
best: dict | None = None
|
||
best_score = 0.0
|
||
for raw in connector.search_movies(query, per_page=10):
|
||
cand_title = raw.get("title")
|
||
if not cand_title:
|
||
continue
|
||
score = fuzz.token_sort_ratio(my_norm, normalize(cand_title)) / 100.0
|
||
if score <= best_score:
|
||
continue
|
||
cy = _cand_year(raw)
|
||
if movie.release_year and cy and abs(movie.release_year - cy) > 2:
|
||
continue # guard: inny rok → prawdopodobnie inny film (Taxi 2 ≠ Taxi Violeur 2)
|
||
best_score = score
|
||
best = raw
|
||
return best if best is not None and best_score >= min_title else None
|
||
|
||
|
||
def _fill_scalar_fields(movie: Movie, norm) -> None:
|
||
"""Fill-only — nie nadpisujemy pól ustawionych przez primary (paradisehill/mirror)."""
|
||
if norm.director and not movie.director:
|
||
movie.director = norm.director
|
||
if norm.release_year and not movie.release_year:
|
||
movie.release_year = norm.release_year
|
||
if norm.release_date and not movie.release_date:
|
||
movie.release_date = norm.release_date
|
||
if norm.duration_sec and not movie.duration_sec:
|
||
movie.duration_sec = norm.duration_sec
|
||
if norm.description and not movie.description:
|
||
movie.description = norm.description
|
||
if norm.poster_url and not movie.poster_url:
|
||
movie.poster_url = norm.poster_url
|
||
if norm.backdrop_url and not movie.backdrop_url:
|
||
movie.backdrop_url = norm.backdrop_url
|
||
if norm.rating is not None and movie.rating is None:
|
||
movie.rating = norm.rating
|
||
|
||
|
||
def enrich_movie(
|
||
session: Session,
|
||
movie: Movie,
|
||
*,
|
||
connector: TPDBConnector,
|
||
source_id: uuid.UUID,
|
||
min_title: float = 0.90,
|
||
) -> str:
|
||
"""Zwraca: 'skip' | 'no_match' | 'enriched' | 'merged'."""
|
||
already = session.execute(
|
||
select(MovieExternalRef.external_id).where(
|
||
MovieExternalRef.source_id == source_id,
|
||
MovieExternalRef.movie_id == movie.id,
|
||
)
|
||
).first()
|
||
if already is not None:
|
||
return "skip"
|
||
|
||
raw = _best_match(connector, movie, min_title=min_title)
|
||
if raw is None:
|
||
return "no_match"
|
||
ext_id = str(raw["id"])
|
||
|
||
outcome = "enriched"
|
||
# DEDUP: ten TPDB UUID już przypięty do innego naszego filmu → mirror tego samego.
|
||
other = session.execute(
|
||
select(MovieExternalRef).where(
|
||
MovieExternalRef.source_id == source_id,
|
||
MovieExternalRef.external_id == ext_id,
|
||
)
|
||
).scalar_one_or_none()
|
||
if other is not None and other.movie_id != movie.id:
|
||
m2 = session.get(Movie, other.movie_id)
|
||
if m2 is not None:
|
||
now = datetime.now(UTC)
|
||
keep, drop = (
|
||
(movie, m2)
|
||
if (movie.created_at or now) <= (m2.created_at or now)
|
||
else (m2, movie)
|
||
)
|
||
movie = merge_movies(
|
||
session, keep_id=keep.id, drop_id=drop.id, resolved_by="tpdb:dedup"
|
||
)
|
||
outcome = "merged"
|
||
|
||
rm = _parse_movie(raw)
|
||
if rm is None:
|
||
return "no_match"
|
||
norm = normalize_movie(rm)
|
||
|
||
if norm.studio is not None:
|
||
studio = resolve_studio(session, norm=norm.studio, source_id=source_id)
|
||
if studio is not None and not movie.studio_id:
|
||
movie.studio_id = studio.id
|
||
|
||
# Dedup wchodzących performerów po external_id (TPDB potrafi wylistować tego samego
|
||
# kanonicznego performera 2×: pod aliasem i kanonicznie) — bez tego resolve_performer
|
||
# próbuje wstawić performer_external_refs 2× → UniqueViolation (jak w scene_resolver).
|
||
resolved: list[tuple[uuid.UUID, str | None]] = []
|
||
seen_perf_keys: set[str] = set()
|
||
for p_norm in norm.performers:
|
||
key = p_norm.external_id or normalize(p_norm.name)
|
||
if key in seen_perf_keys:
|
||
continue
|
||
seen_perf_keys.add(key)
|
||
performer = resolve_performer(session, norm=p_norm, source_id=source_id)
|
||
resolved.append((performer.id, p_norm.as_alias_in_scene))
|
||
_sync_performers(session, movie_id=movie.id, resolved=resolved)
|
||
_sync_tags(session, movie_id=movie.id, norm=norm, source_id=source_id)
|
||
_fill_scalar_fields(movie, norm)
|
||
|
||
ref = session.execute(
|
||
select(MovieExternalRef).where(
|
||
MovieExternalRef.source_id == source_id,
|
||
MovieExternalRef.external_id == ext_id,
|
||
)
|
||
).scalar_one_or_none()
|
||
if ref is None:
|
||
session.add(
|
||
MovieExternalRef(
|
||
source_id=source_id,
|
||
external_id=ext_id,
|
||
movie_id=movie.id,
|
||
confidence=1.0,
|
||
url=rm.url,
|
||
)
|
||
)
|
||
elif ref.movie_id != movie.id:
|
||
ref.movie_id = movie.id
|
||
|
||
return outcome
|
||
|
||
|
||
def _candidate_movies(session: Session, *, source_id: uuid.UUID, limit: int) -> list[Movie]:
|
||
"""Filmy jeszcze nie wzbogacone TPDB, priorytet: mają żywy playback (są grywalne)
|
||
i brak obsady LUB brak studia (największa luka). Reszta później."""
|
||
has_tpdb = (
|
||
select(MovieExternalRef.movie_id)
|
||
.where(MovieExternalRef.source_id == source_id)
|
||
.scalar_subquery()
|
||
)
|
||
has_playback = (
|
||
select(MoviePlaybackSource.movie_id)
|
||
.where(MoviePlaybackSource.dead_at.is_(None))
|
||
.scalar_subquery()
|
||
)
|
||
perf_count = (
|
||
select(func.count())
|
||
.select_from(MoviePerformer)
|
||
.where(MoviePerformer.movie_id == Movie.id)
|
||
.correlate(Movie)
|
||
.scalar_subquery()
|
||
)
|
||
stmt = (
|
||
select(Movie)
|
||
.where(
|
||
Movie.id.not_in(has_tpdb),
|
||
Movie.id.in_(has_playback),
|
||
)
|
||
.where((perf_count == 0) | (Movie.studio_id.is_(None)))
|
||
.order_by(Movie.created_at.desc())
|
||
.limit(limit)
|
||
)
|
||
return list(session.execute(stmt).scalars().all())
|
||
|
||
|
||
def run_tpdb_movie_enrich(
|
||
session_factory,
|
||
*,
|
||
limit: int = 200,
|
||
min_title: float = 0.90,
|
||
) -> dict[str, int]:
|
||
"""Batch: wzbogać do `limit` filmów. Commit per-film (jeden błąd nie cofa reszty).
|
||
`session_factory` = kontekstowy scope (app.db.session_scope)."""
|
||
from app.ingest import get_or_create_source
|
||
from app.models.source import SourceKind
|
||
|
||
connector = TPDBConnector()
|
||
counters = {"seen": 0, "enriched": 0, "merged": 0, "no_match": 0, "skip": 0, "errors": 0}
|
||
|
||
with session_factory() as session:
|
||
src = get_or_create_source(session, kind=SourceKind.tpdb, name="tpdb")
|
||
source_id = src.id
|
||
session.commit()
|
||
movies = _candidate_movies(session, source_id=source_id, limit=limit)
|
||
movie_ids = [m.id for m in movies]
|
||
|
||
log.info("tpdb-movie-enrich: %d candidate movies", len(movie_ids))
|
||
for mid in movie_ids:
|
||
counters["seen"] += 1
|
||
try:
|
||
with session_factory() as session:
|
||
movie = session.get(Movie, mid)
|
||
if movie is None:
|
||
continue
|
||
outcome = enrich_movie(
|
||
session, movie, connector=connector, source_id=source_id, min_title=min_title
|
||
)
|
||
session.commit()
|
||
counters[outcome] = counters.get(outcome, 0) + 1
|
||
except Exception as e: # pragma: no cover - defensywnie, jeden film nie wywala batcha
|
||
counters["errors"] += 1
|
||
log.warning("tpdb-movie-enrich failed for %s: %s", mid, e)
|
||
|
||
log.info("tpdb-movie-enrich done: %s", counters)
|
||
return counters
|