goon/app/resolve/movie_merge.py
goon-foss b9898ba592 feat(movies): TPDB movie enrichment + dedup
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>
2026-07-02 11:36:36 +02:00

190 lines
6.8 KiB
Python

"""Scalanie dwóch kanonicznych movies w jeden (dedup).
`keep_id` przejmuje od `drop_id`: external_refs, performers, tags, chapters,
playback_sources — z deduplikacją na kluczach złączeń. Potem `drop` Movie jest
usuwany. Mirror `scene_merge.merge_scenes` dla movies.
Używane przez TPDB enrichment: gdy dwa nasze filmy (mirrory paradisehill/dooplay)
mapują się na TEN SAM TPDB movie UUID, to ten sam film → merge. `MergeKind.movie`
już istnieje w merge_candidates.
"""
from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime
from sqlalchemy import or_, select, update
from sqlalchemy.orm import Session
from app.models.merge_candidate import MergeCandidate, MergeKind, MergeStatus
from app.models.movie import (
Movie,
MovieChapter,
MovieExternalRef,
MoviePerformer,
MovieTag,
)
from app.models.movie_playback_source import MoviePlaybackSource
log = logging.getLogger(__name__)
class MovieMergeError(Exception):
pass
def merge_movies(
session: Session,
*,
keep_id: uuid.UUID,
drop_id: uuid.UUID,
resolved_by: str | None = None,
) -> Movie:
if keep_id == drop_id:
raise MovieMergeError("cannot merge movie into itself")
keep = session.get(Movie, keep_id)
drop = session.get(Movie, drop_id)
if keep is None or drop is None:
raise MovieMergeError("movie not found")
_move_external_refs(session, keep_id=keep_id, drop_id=drop_id)
_move_performers(session, keep_id=keep_id, drop_id=drop_id)
_move_tags(session, keep_id=keep_id, drop_id=drop_id)
_move_chapters(session, keep_id=keep_id, drop_id=drop_id)
_move_playback_sources(session, keep_id=keep_id, drop_id=drop_id)
_coalesce_canonical_fields(keep, drop)
session.delete(drop)
session.flush()
_close_pending_candidates(session, movie_id=drop_id, resolved_by=resolved_by)
log.info("merged movie %s%s", keep_id, drop_id)
return keep
# ---- helpery --------------------------------------------------------------
def _move_external_refs(session: Session, *, keep_id: uuid.UUID, drop_id: uuid.UUID) -> None:
for ref in session.execute(
select(MovieExternalRef).where(MovieExternalRef.movie_id == drop_id)
).scalars().all():
clash = session.execute(
select(MovieExternalRef).where(
MovieExternalRef.source_id == ref.source_id,
MovieExternalRef.external_id == ref.external_id,
MovieExternalRef.movie_id == keep_id,
)
).scalar_one_or_none()
if clash is not None:
session.delete(ref)
else:
ref.movie_id = keep_id
def _move_performers(session: Session, *, keep_id: uuid.UUID, drop_id: uuid.UUID) -> None:
for link in session.execute(
select(MoviePerformer).where(MoviePerformer.movie_id == drop_id)
).scalars().all():
clash = session.execute(
select(MoviePerformer).where(
MoviePerformer.movie_id == keep_id,
MoviePerformer.performer_id == link.performer_id,
)
).scalar_one_or_none()
if clash is not None:
if link.as_alias and not clash.as_alias:
clash.as_alias = link.as_alias
session.delete(link)
else:
link.movie_id = keep_id
def _move_tags(session: Session, *, keep_id: uuid.UUID, drop_id: uuid.UUID) -> None:
for link in session.execute(
select(MovieTag).where(MovieTag.movie_id == drop_id)
).scalars().all():
clash = session.execute(
select(MovieTag).where(
MovieTag.movie_id == keep_id, MovieTag.tag_id == link.tag_id
)
).scalar_one_or_none()
if clash is not None:
session.delete(link)
else:
link.movie_id = keep_id
def _move_chapters(session: Session, *, keep_id: uuid.UUID, drop_id: uuid.UUID) -> None:
"""Chaptery są lokalne per-movie (UQ movie_id, chapter_index). Kolizja indeksu z
keep → keep swoje (kasujemy drop's); brak kolizji → przepięcie."""
for ch in session.execute(
select(MovieChapter).where(MovieChapter.movie_id == drop_id)
).scalars().all():
clash = session.execute(
select(MovieChapter).where(
MovieChapter.movie_id == keep_id,
MovieChapter.chapter_index == ch.chapter_index,
)
).scalar_one_or_none()
if clash is not None:
session.delete(ch)
else:
ch.movie_id = keep_id
def _move_playback_sources(session: Session, *, keep_id: uuid.UUID, drop_id: uuid.UUID) -> None:
"""Unique (origin, page_url) jest GLOBALNY → drop i keep nie mogą współdzielić
tego samego źródła, więc samo przepięcie movie_id nie grozi kolizją."""
session.execute(
update(MoviePlaybackSource)
.where(MoviePlaybackSource.movie_id == drop_id)
.values(movie_id=keep_id)
)
def _coalesce_canonical_fields(keep: Movie, drop: Movie) -> None:
"""Wypełnij braki w `keep` polami z `drop` (bez nadpisywania ustawionych)."""
if not keep.description and drop.description:
keep.description = drop.description
if not keep.duration_sec and drop.duration_sec:
keep.duration_sec = drop.duration_sec
if not keep.director and drop.director:
keep.director = drop.director
if not keep.country and drop.country:
keep.country = drop.country
if not keep.release_date and drop.release_date:
keep.release_date = drop.release_date
if not keep.release_year and drop.release_year:
keep.release_year = drop.release_year
if not keep.studio_id and drop.studio_id:
keep.studio_id = drop.studio_id
if not keep.poster_url and drop.poster_url:
keep.poster_url = drop.poster_url
if not keep.backdrop_url and drop.backdrop_url:
keep.backdrop_url = drop.backdrop_url
if keep.rating is None and drop.rating is not None:
keep.rating = drop.rating
if drop.title and len(drop.title) > len(keep.title or ""):
keep.title = drop.title
keep.title_normalized = drop.title_normalized
# created_at = najwcześniejsze "first seen" (NEW badge correctness, jak w scenach).
if drop.created_at and keep.created_at and drop.created_at < keep.created_at:
keep.created_at = drop.created_at
def _close_pending_candidates(
session: Session, *, movie_id: uuid.UUID, resolved_by: str | None
) -> None:
session.execute(
update(MergeCandidate)
.where(
MergeCandidate.kind == MergeKind.movie,
MergeCandidate.status == MergeStatus.pending,
or_(MergeCandidate.left_id == movie_id, MergeCandidate.right_id == movie_id),
)
.values(
status=MergeStatus.rejected,
resolved_at=datetime.now(UTC),
resolved_by=resolved_by or "auto:movie_dropped",
)
)