goon/app/enrich/tpdb_movies.py
goon-foss 4baf6ea896 fix(movie-enrich): studio/performer disambiguation for generic TPDB titles
Generic movie titles ("Monster Tits", "Pirates") map to many different TPDB
films with identical titles (different studios/casts, all title-score 1.0). The
old matcher searched per_page=10 and ranked by title only, so for a generic title
the correct film was often not even in the top 10, and among same-title
candidates it picked arbitrarily. Result: a ~95% no_match rate and silent
misattribution (e.g. "Monster Tits" by Venom Digital Media would get Galaxy
Productions' TPDB entry).

_best_match now:
  - searches per_page=40 (the right film for a generic title is often past top 10),
  - ranks title-gate survivors by a composite of title + studio similarity +
    performer overlap (our studio/cast from the primary source disambiguate which
    same-title film it is),
  - guards against misattribution: if we have a studio/cast signal and there is
    more than one near-identical-title candidate but the winner shares neither
    studio nor cast, return no_match instead of attaching a wrong same-title film.

Verified on prod data: a no_match-with-studio sample now matches 18/18 with the
correct studio (fixing Galaxy to Venom, Exquisite to Rodney Moore, and a no_match
to Cherry Boxxx), and an already-enriched sample keeps 16/18 identical picks with
the 2 differences being the same studio (benign TPDB duplicate). No wrong-studio
regressions observed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 11:31:38 +02:00

355 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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.models.performer import Performer
from app.models.studio import Studio
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 _movie_studio_name(session: Session, movie: Movie) -> str | None:
if not movie.studio_id:
return None
st = session.get(Studio, movie.studio_id)
return st.name if st is not None else None
def _movie_performer_names(session: Session, movie: Movie) -> set[str]:
"""Znormalizowane nazwy performerów już przypiętych do filmu (do rozróżniania
kandydatów TPDB o tym samym tytule)."""
rows = session.execute(
select(Performer.name_normalized)
.join(MoviePerformer, MoviePerformer.performer_id == Performer.id)
.where(MoviePerformer.movie_id == movie.id)
).all()
return {r[0] for r in rows if r[0]}
def _cand_studio_name(raw: dict) -> str:
site = raw.get("site") or {}
name = site.get("name") if isinstance(site, dict) else site
return normalize(name or "")
def _cand_performer_names(raw: dict) -> set[str]:
return {
normalize(p.get("name"))
for p in (raw.get("performers") or [])
if isinstance(p, dict) and p.get("name")
}
def _best_match(
session: Session, connector: TPDBConnector, movie: Movie, *, min_title: float
) -> dict | None:
"""Najlepszy TPDB movie payload dla naszego filmu, albo None.
Tytuł to gate (`token_sort_ratio` >= min_title): odporny na inwersję i kolejność,
penalizuje różnicę długości (krótki generyczny tytuł nie łapie dłuższego nadzbioru).
ALE generyczne tytuły ("Monster Tits", "Pirates") mają w TPDB WIELE różnych filmów
o tym samym tytule (różne studia/obsady) i identycznym score 1.0. Sam tytuł ich NIE
rozróżni, a `per_page=10` często w ogóle nie zwracał właściwego (poprawny film bywa
poza top-10). Dlatego:
- `per_page=40` (właściwy film generycznego tytułu bywa dalej w wynikach),
- wśród kandydatów przechodzących title-gate wybieramy po KOMPOZYCIE:
tytuł + zbieżność studia + pokrycie obsady (nasze studio/obsada z primary
źródła jak paradisehill rozróżniają który to film),
- GUARD anty-misattribution: jeśli mamy sygnał (studio na naszym filmie) i jest
>1 kandydat o ~identycznym tytule, a zwycięzca NIE dzieli studia, to no_match
(lepiej nie wzbogacić niż przypiąć zły film o tej samej nazwie).
Precyzja > recall. 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)
my_studio = _movie_studio_name(session, movie)
my_studio_norm = normalize(my_studio) if my_studio else ""
my_perfs = _movie_performer_names(session, movie)
best: dict | None = None
best_key: tuple[float, float, float, float] | None = None
best_studio_sim = 0.0
best_perf_overlap = 0.0
n_exact_title = 0
for raw in connector.search_movies(query, per_page=40):
cand_title = raw.get("title")
if not cand_title:
continue
tscore = fuzz.token_sort_ratio(my_norm, normalize(cand_title)) / 100.0
if tscore < min_title:
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)
if tscore >= 0.97:
n_exact_title += 1
cand_studio = _cand_studio_name(raw)
studio_sim = (
fuzz.token_set_ratio(my_studio_norm, cand_studio) / 100.0
if my_studio_norm and cand_studio
else 0.0
)
cand_perfs = _cand_performer_names(raw)
perf_overlap = (len(my_perfs & cand_perfs) / len(my_perfs)) if my_perfs else 0.0
# Kompozyt: tytuł jako baza, studio i obsada jako rozróżniacze (waga 0.6 każdy).
composite = tscore + 0.6 * studio_sim + 0.6 * perf_overlap
key = (composite, tscore, studio_sim, perf_overlap)
if best_key is None or key > best_key:
best_key = key
best = raw
best_studio_sim = studio_sim
best_perf_overlap = perf_overlap
if best is None:
return None
# Anty-misattribution: mamy czym rozróżnić (studio lub obsada na naszym filmie), jest
# >1 kandydat o ~identycznym tytule, a zwycięzca nie dzieli NIC (ani studia, ani
# obsady) → to prawie na pewno inny film o tej samej nazwie. Nie wzbogacaj.
my_has_signal = bool(my_studio_norm) or bool(my_perfs)
if (
my_has_signal
and n_exact_title >= 2
and best_studio_sim < 0.5
and best_perf_overlap < 0.5
):
return None
return best
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(session, 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