diff --git a/app/enrich/tpdb_movies.py b/app/enrich/tpdb_movies.py index a7d6454..8859c20 100644 --- a/app/enrich/tpdb_movies.py +++ b/app/enrich/tpdb_movies.py @@ -34,6 +34,8 @@ 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 @@ -56,33 +58,113 @@ def _cand_year(raw: dict) -> int | None: return None -def _best_match(connector: TPDBConnector, movie: Movie, *, min_title: float) -> dict | 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. - `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).""" + 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_score = 0.0 - for raw in connector.search_movies(query, per_page=10): + 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 - score = fuzz.token_sort_ratio(my_norm, normalize(cand_title)) / 100.0 - if score <= best_score: + 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) - best_score = score - best = raw - return best if best is not None and best_score >= min_title else None + 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: @@ -123,7 +205,7 @@ def enrich_movie( if already is not None: return "skip" - raw = _best_match(connector, movie, min_title=min_title) + raw = _best_match(session, connector, movie, min_title=min_title) if raw is None: return "no_match" ext_id = str(raw["id"])