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