fix(movies): poprawki po code review scorera, merge_movies i rozdzielania

Scorer tytulu (`movie_score._title_signal`), na zywym ingescie:
* sklejanie ogonkow apostrofu obejmowalo KAZDA pojedyncza litere, wiec "watch a whore s
  tale" dawalo "watcha", a przedimek "a" nigdy nie wpadal do wypelniaczy; lustra z A/I
  na poczatku tytulu przestawaly sie laczyc. Teraz tylko s/t/d.
* rowne zbiory zlozone z samych liczb ("adult movie 2019" vs "porn film 2019" -> {2019})
  dawaly 1.0; teraz sufit.
* zaostrzenie "rozne slowa = sufit" zabijalo warianty zapisu: Vol./Volume, Pt./Part,
  Gang-Bang/Gangbang, literowki, liczbe mnoga. Skroty rozwijane, zlepki porownywane po
  zdjeciu spacji, slowa wlasne obu stron parowane jeden do jednego: liczba mnoga = 0.95,
  literowka tylko przy >= 0.92 i slowach od 4 liter. Prog 0.85 na slowie przepuszczal
  "sexy"/"sex", "intense"/"interns" i "infedele"/"fedele" (niewierna vs wierna), a
  porownanie calych posortowanych ciagow "girls only 4" vs "girls on girls 4".
Sprawdzone na 28 parach, w tym wszystkich przykladach z review.

`merge_movies`: ulubione i postep odtwarzania przenoszone na `keep` przed skasowaniem
`drop`. Oba maja FK ondelete=CASCADE, wiec kazde scalenie po cichu wymazywalo userowi
dane. Dotyczy tez dedup po TPDB.

Skrypt rozdzielania:
* `_istniejacy_film` dostaje pelna liste performer_id (jak resolver), nie tylko przekroj,
  ktory zawyzal Jaccarda (3 z 5 wspolnych dawalo 1.0); kandydaci z trigramow PLUS
  dokladne trafienie w title_normalized, bo LIMIT 50 gubil "control 2" wsrod sasiadow;
  wstepny test tytulu oszczedza zapytan o obsade; flush przed blokowaniem.
* klastry rozdzielone w tym samym filmie nie moga sie do siebie dolaczyc (klastrowanie
  juz orzeklo, ze to rozne filmy).
* przed przepisaniem kotwicy sprawdzenie, czy zbior refow nie zmienil sie w trakcie
  pobierania (wyscig miedzy shardami).
* dry-run pokazuje tez dolaczanie do istniejacych; liczniki liczone przy tworzeniu.
* sprzatanie dubli po `updated_at` (created_at jest cofane i nie nadaje sie na znacznik
  przebiegu), keep = wiecej refow, potem starsze pierwsze zobaczenie; try/except per
  film; --after-id; naiwny timestamp = UTC.
This commit is contained in:
goon-foss 2026-09-02 22:37:32 +02:00
parent c4845e9f61
commit 59b7b5a651
3 changed files with 264 additions and 99 deletions

View file

@ -14,7 +14,7 @@ import logging
import uuid
from datetime import UTC, datetime
from sqlalchemy import or_, select, update
from sqlalchemy import or_, select, text, update
from sqlalchemy.orm import Session
from app.models.merge_candidate import MergeCandidate, MergeKind, MergeStatus
@ -53,6 +53,7 @@ def merge_movies(
_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)
_move_user_data(session, keep_id=keep_id, drop_id=drop_id)
_coalesce_canonical_fields(keep, drop)
session.delete(drop)
@ -142,6 +143,24 @@ def _move_playback_sources(session: Session, *, keep_id: uuid.UUID, drop_id: uui
)
def _move_user_data(session: Session, *, keep_id: uuid.UUID, drop_id: uuid.UUID) -> None:
"""Ulubione i postep odtwarzania z `drop` na `keep`.
Oba maja FK `ondelete=CASCADE` na movies.id, wiec bez tego kroku skasowanie `drop`
po cichu wymazywalo userowi ulubione i miejsce, w ktorym skonczyl ogladac (code review
2026-09-02, sprzatanie dubli po rozdzielaniu filmow). Gdy urzadzenie ma juz wpis na
`keep`, ten na `drop` zostaje i idzie z kaskada: nie ma czego laczyc.
"""
for tabela in ("favorite_movies", "movie_play_progress"):
session.execute(
text(
f"UPDATE {tabela} SET movie_id = :keep WHERE movie_id = :drop "
f" AND device_id NOT IN (SELECT device_id FROM {tabela} WHERE movie_id = :keep)"
),
{"keep": str(keep_id), "drop": str(drop_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:

View file

@ -117,36 +117,108 @@ def _title_signal(cand_title: str, inc_title: str) -> tuple[float, bool, dict]:
# the doctor" dostawaly 100 i lecialy do kotwicy (rozdzielanie 2026-09-02). Wyjatek to
# przedrostki SEO luster ("watch cece adult" = "cece"), stad najpierw zdejmujemy
# slownik wypelniaczy i dopiero porownujemy zbiory slow.
ta = _slowa(cand_title)
ti = _slowa(inc_title)
if ta and ti and ta == ti:
# Te same slowa (po zdjeciu wypelniaczy i sklejeniu apostrofow): prawdziwe lustro,
# niezaleznie od kolejnosci i przedrostkow SEO.
return 1.0, False, powody
if ta and ti and (ta < ti or ti < ta):
powody["subset_title_cap"] = sorted((ta ^ ti))[:4]
return min(ocena, _ONE_SIDED_NUMBER_CAP), False, powody
if ta and ti:
# Kazda strona ma slowo, ktorego druga nie ma: dla filmow to prawie zawsze inny
# tytul, a nie literowka. `token_set_ratio` dawal tu 85-94 ("hardcore fucking sluts
# 2" vs "foot fucking sluts 2", "santa list gangbang" vs "gangbang d") i przy
# zgodnym studiu i roku wychodzil auto-merge (sprzatanie dubli 2026-09-02).
powody["distinct_words_cap"] = sorted(ta ^ ti)[:4]
return min(ocena, _ONE_SIDED_NUMBER_CAP), False, powody
la, li = _slowa(cand_title), _slowa(inc_title)
ta, ti = frozenset(la), frozenset(li)
if not ta or not ti:
# Po zdjeciu wypelniaczy jedna strona jest pusta: nie ma czego porownywac
# zbiorami, zostaje surowy token_set.
return ocena, False, powody
if not any(t.isalpha() for t in ta | ti):
# Zostaly same liczby ("adult movie 2019" vs "porn film 2019" -> {2019} == {2019}).
# Rok to nie tytul, wiec rownosc takich zbiorow niczego nie dowodzi.
powody["no_words_cap"] = sorted(ta | ti)[:4]
return min(ocena, _ONE_SIDED_NUMBER_CAP), False, powody
if ta == ti or "".join(la) == "".join(li):
# Te same slowa (po zdjeciu wypelniaczy, sklejeniu apostrofow i rozwinieciu
# skrotow) albo ten sam ciag po zdjeciu spacji ("gang bang" vs "gangbang"):
# prawdziwe lustro, niezaleznie od kolejnosci i przedrostkow SEO.
return 1.0, False, powody
# Literowka albo liczba mnoga: slowa wlasne kazdej strony musza sie sparowac jeden do
# jednego z podobienstwem >= 0.85 na poziomie SLOWA. Porownanie calych posortowanych
# ciagow bylo za luzne dla krotkich tytulow: "girls only 4" vs "girls on girls 4"
# dawalo 0.91, bo zbior zwija powtorzone "girls", a "only"/"on" to dwie litery.
prawie = _parowanie_slow(ta - ti, ti - ta)
if prawie is not None:
powody["fuzzy_words"] = round(prawie, 3)
return prawie, False, powody
# Podzbior z prawdziwymi dodatkowymi slowami ("doctor" vs "grannys doctor games") albo
# slowa wlasne po obu stronach: dla filmow to inny tytul, nie lustro. `token_set_ratio`
# dawal tu 85-100 i przy zgodnym studiu i roku wychodzil auto-merge.
klucz = "subset_title_cap" if (ta < ti or ti < ta) else "distinct_words_cap"
powody[klucz] = sorted(ta ^ ti)[:4]
return min(ocena, _ONE_SIDED_NUMBER_CAP), False, powody
def _slowa(title_normalized: str) -> frozenset[str]:
"""Zbior slow tytulu do porownania: bez wypelniaczy SEO, z doklejonymi ogonkami po
apostrofie ("she s" -> "shes", "gangbang d" -> "gangbangd"), bo `normalize()` tnie
apostrof na spacje i "She's In Control 3" rozjezdzalo sie z "Shes In Control 3"."""
# Literowka: dwa slowa od 4 liter o podobienstwie >= 0.92 ("fuking"/"fucking" 0.92).
# Prog 0.85 byl za nisko: "sexy"/"sex" 0.86, "intense"/"interns" 0.86,
# "infedele"/"fedele" 0.86 (niewierna vs wierna!) laczyly rozne filmy.
_FUZZY_WORD_MIN = 0.92
_FUZZY_WORD_MIN_LEN = 4
def _to_samo_slowo(a: str, b: str) -> float | None:
"""Ocena pary slow uznanych za to samo slowo, albo None.
Liczba pojedyncza/mnoga ("slut"/"sluts", "date"/"dates") to 0.95: w tytulach luster
to najczestsza roznica. Reszta tylko przez bardzo bliska literowke.
"""
if a == b:
return 1.0
if len(a) < len(b):
a, b = b, a
if a in (b + "s", b + "es") and len(b) >= 3:
return 0.95
if len(a) >= _FUZZY_WORD_MIN_LEN and len(b) >= _FUZZY_WORD_MIN_LEN:
r = fuzz.ratio(a, b) / 100.0
if r >= _FUZZY_WORD_MIN:
return r
return None
def _parowanie_slow(tylko_a: frozenset[str], tylko_b: frozenset[str]) -> float | None:
"""Najslabsze z parowan slow wlasnych obu stron, albo None, gdy sie nie paruja.
Kazde slowo wlasne jednej strony musi miec odpowiednik po drugiej (ta sama liczba
slow, kazda para wg `_to_samo_slowo`). Wynik to najslabsza para, wiec tytul dostaje
ocene dokladnie tak dobra, jak najgorsza literowka.
"""
if not tylko_a or not tylko_b or len(tylko_a) != len(tylko_b):
return None
wolne = set(tylko_b)
najslabsza = 1.0
for a in sorted(tylko_a):
pary = [(b, r) for b in wolne if (r := _to_samo_slowo(a, b)) is not None]
if not pary:
return None
b, r = max(pary, key=lambda x: x[1])
wolne.discard(b)
najslabsza = min(najslabsza, r)
return najslabsza
# Ogonki, ktore `normalize()` odcina od slowa przy apostrofie: "she's" -> "she s",
# "gangbang'd" -> "gangbang d". Tylko te sklejamy z powrotem; "a" i "i" to osobne slowa.
_OGONKI_APOSTROFU = frozenset({"s", "t", "d"})
# Skroty spotykane w tytulach filmow, rozwijane przed porownaniem zbiorow slow.
_SKROTY = {"vol": "volume", "pt": "part", "ep": "episode", "chap": "chapter", "no": "number"}
def _slowa(title_normalized: str) -> list[str]:
"""Slowa tytulu do porownania, w kolejnosci z tytulu.
Bez wypelniaczy SEO, z doklejonymi ogonkami apostrofu ("she s" -> "shes") i z
rozwinietymi skrotami ("vol" -> "volume"). Pierwsza wersja sklejala KAZDA pojedyncza
litere z poprzednim slowem, wiec "watch a whore s tale" dawalo "watcha", a przedimek
"a" nigdy nie trafial do wypelniaczy: lustra z "A"/"I" na poczatku tytulu przestawaly
sie laczyc (code review 2026-09-02).
"""
out: list[str] = []
for tok in title_normalized.split():
if len(tok) == 1 and tok.isalpha() and out:
if tok in _OGONKI_APOSTROFU and out:
out[-1] = out[-1] + tok
else:
out.append(tok)
return frozenset(out) - _SEO_WYPELNIACZE
out.append(_SKROTY.get(tok, tok))
return [t for t in out if t not in _SEO_WYPELNIACZE]
def score_movie_candidate(

View file

@ -48,7 +48,7 @@ import sys
import time
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from datetime import UTC, datetime
from urllib.parse import urlparse
from sqlalchemy import text
@ -254,44 +254,66 @@ def _opis(klaster: list[Profil]) -> str:
def _istniejacy_film(
s, *, norm: NormalizedMovie, studio_id, performer_names: set[str], wyklucz: set,
tylko_starsze_niz: datetime | None = None,
s, *, title_normalized: str, release_year: int | None, studio_id, performer_ids: list, wyklucz: set,
) -> Movie | None:
"""Film, do ktorego resolver dolaczylby ten tytul automatycznie (>= 0.85), albo None.
Ta sama para `find_movie_candidates` + `score_movie_candidate`, ktorej uzywa ingest,
wiec decyzja jest identyczna z ta, ktora zapadlaby przy normalnym wejsciu sceny.
Obsada porownywana po nazwach, bo w tym miejscu nie mamy jeszcze performer_id.
z PELNA lista performer_id (jak w `resolve_movie`), a nie tylko z tymi, ktore pasuja
do kandydata: pierwsza wersja podawala sam przekroj, przez co Jaccard byl gornym
oszacowaniem (3 z 5 wspolnych dawalo 1.0 zamiast 0.6) i skrypt laczyl chetniej niz
ingest. Kandydaci z trigramow PLUS dokladne trafienie w `title_normalized`, bo
`find_movie_candidates` bierze 50 losowych sasiadow i przy popularnych tytulach
("control 2") potrafi pominac ten wlasciwy.
"""
from app.resolve.movie_match import find_movie_candidates
from app.resolve.movie_score import AUTO_THRESHOLD, score_movie_candidate
from app.resolve.movie_score import AUTO_THRESHOLD, _title_signal, score_movie_candidate
kand = find_movie_candidates(
s, title_normalized=norm.title_normalized, studio_id=studio_id, release_year=norm.release_year
)
s.flush() # blokowanie po studiu i roku czyta kolumny, ktore mogly zmienic sie bez flush
kand = {c.id: c for c in find_movie_candidates(
s, title_normalized=title_normalized, studio_id=studio_id, release_year=release_year
)}
for c in s.query(Movie).filter(Movie.title_normalized == title_normalized).all():
kand.setdefault(c.id, c)
najlepszy, najlepsza = None, 0.0
for c in kand:
for c in kand.values():
if c.id in wyklucz:
continue
if tylko_starsze_niz is not None and c.created_at and c.created_at >= tylko_starsze_niz:
# Tytul jest czysto pythonowy i przy sufitach 0.6 nie da sie dojsc do 0.85, wiec
# obsady z bazy nie ma po co czytac dla takich kandydatow.
ocena, odrzut, _ = _title_signal(c.title_normalized or "", title_normalized)
if odrzut or ocena < 0.7:
continue
pids = [
pid for (pid,) in s.execute(
text(
"SELECT mp.performer_id FROM movie_performers mp JOIN performers p ON p.id = mp.performer_id "
" WHERE mp.movie_id = :m AND lower(p.canonical_name) = ANY(:n)"
),
{"m": str(c.id), "n": list(performer_names) or [""]},
).fetchall()
] if performer_names else []
# Do Jaccarda podajemy tylko to, co sie zgadza po nazwie, wiec dolny szacunek.
br = score_movie_candidate(
s, candidate=c, norm_title_normalized=norm.title_normalized,
norm_release_year=norm.release_year, norm_studio_id=studio_id, norm_performer_ids=pids,
s, candidate=c, norm_title_normalized=title_normalized,
norm_release_year=release_year, norm_studio_id=studio_id, norm_performer_ids=performer_ids,
)
if br.composite > najlepsza:
najlepszy, najlepsza = c, br.composite
return najlepszy if najlepszy is not None and najlepsza >= AUTO_THRESHOLD else None
if najlepsza >= 1.0:
break
return najlepszy if najlepsza >= AUTO_THRESHOLD else None
def _performer_ids_po_nazwach(s, nazwy: set[str]) -> list:
"""Do PODGLADU (dry-run): id performerow po nazwie kanonicznej, bez zapisu."""
if not nazwy:
return []
return [
pid for (pid,) in s.execute(
text("SELECT id FROM performers WHERE lower(canonical_name) = ANY(:n)"), {"n": list(nazwy)}
).fetchall()
]
def _refy_bez_zmian(s, movie_id: uuid.UUID, profile: list[Profil]) -> bool:
"""Czy zbior refow filmu jest ten sam, co przy wczytaniu (inny shard mogl w miedzyczasie
dolaczyc tu klaster). Przepisanie kotwicy na starych profilach skasowaloby jego
hostery i obsade jako "niepotwierdzone", wiec taki film pomijamy do nastepnego przebiegu."""
teraz = {(sid, e) for (sid, e) in s.execute(
text("SELECT source_id, external_id FROM movie_external_refs WHERE movie_id = :m"), {"m": str(movie_id)}
).fetchall()}
return teraz == {p.klucz for p in profile}
def _zaladuj_refy(s, movie_id: uuid.UUID) -> list[Profil]:
@ -346,17 +368,41 @@ def _przetworz(movie_id: uuid.UUID, *, dry_run: bool, liczniki: dict) -> None:
log.info("OK %-40s refy=%d, jeden klaster, bez zmian", tytul_m[:40], len(profile))
return
liczniki["rozdzielone"] += 1
liczniki["nowe_filmy"] += len(inne)
log.info("SPLIT %-40s refy=%d -> %d klastrow", tytul_m[:40], len(profile), len(klastry))
log.info(" kotwica: %s", _opis(kotwica))
for k in inne:
log.info(" nowy: %s", _opis(k))
if dry_run:
# Podglad ma pokazac TO SAMO, co zrobi przebieg: takze dolaczanie do istniejacych.
# Bez tego pierwsza wersja dry-run wypisywala "nowy" dla kazdego klastra i nie
# dalo sie z gory zobaczyc 798 dubli, ktore potem powstaly.
with session_scope() as s:
for k in inne:
wz = _wzorzec(k)
istn = None
if wz is not None and wz.norm is not None:
istn = _istniejacy_film(
s, title_normalized=wz.norm.title_normalized, release_year=wz.norm.release_year,
studio_id=None, performer_ids=_performer_ids_po_nazwach(s, set().union(*(p.cast for p in k))),
wyklucz={movie_id},
)
if istn is not None:
liczniki["dolaczone_do_istniejacych"] += 1
log.info(" dolaczy do %r: %s", istn.title[:36], _opis(k))
else:
liczniki["nowe_filmy"] += 1
log.info(" nowy: %s", _opis(k))
return
with session_scope() as s:
m = s.get(Movie, movie_id)
assert m is not None
if not _refy_bez_zmian(s, movie_id, profile):
log.warning(" %s: refy zmienily sie w trakcie pobierania (inny shard?), pomijam do nastepnego przebiegu", tytul_m[:40])
liczniki["pominiete_wyscig"] = liczniki.get("pominiete_wyscig", 0) + 1
return
# Filmy utworzone w TYM podziale: kolejne klastry nie moga sie do nich dolaczac,
# bo klastrowanie juz orzeklo, ze to rozne filmy; inaczej rozdzielone przed chwila
# sklejalo sie z powrotem przez ocene z bazy (dopelnione pola, obsada po id).
utworzone_tu: set = set()
# --- nowe filmy dla klastrow poza kotwica --------------------------------
for k in inne:
wz = _wzorzec(k)
@ -366,9 +412,16 @@ def _przetworz(movie_id: uuid.UUID, *, dry_run: bool, liczniki: dict) -> None:
# pierwszy przebieg (2026-09-02) utworzyl 798 filmow o tytule, ktory juz
# byl w bazie, i 408 dubli miedzy soba: "Control 2" powstawal na nowo,
# chociaz "Control 2" z innego zrodla juz istnial (zgloszenie a5cdda7f).
# Obsada rozwiazana tak jak w resolverze, pelna lista id.
pids: list = []
for p in k:
if p.norm is not None:
for pn in p.norm.performers:
pids.append(resolve_performer(s, norm=pn, source_id=p.source_id).id)
istn = _istniejacy_film(
s, norm=wz.norm, studio_id=studio.id if studio else None,
performer_names=set().union(*(p.cast for p in k)), wyklucz={movie_id},
s, title_normalized=wz.norm.title_normalized, release_year=wz.norm.release_year,
studio_id=studio.id if studio else None, performer_ids=list(dict.fromkeys(pids)),
wyklucz={movie_id} | utworzone_tu,
)
if istn is not None:
nowy = istn
@ -376,6 +429,8 @@ def _przetworz(movie_id: uuid.UUID, *, dry_run: bool, liczniki: dict) -> None:
log.info(" -> istniejacy film %s %r", nowy.id, nowy.title)
else:
nowy = _create_canonical(s, norm=wz.norm, studio_id=studio.id if studio else None)
utworzone_tu.add(nowy.id)
liczniki["nowe_filmy"] += 1
else:
# Zaden ref klastra nie dal sie pobrac: film z samego tytulu ze sluga,
# zeby refy nie zostaly na zlym filmie. Reszta doladuje sie przy ingescie.
@ -383,6 +438,8 @@ def _przetworz(movie_id: uuid.UUID, *, dry_run: bool, liczniki: dict) -> None:
nowy = Movie(title=t, title_normalized=normalize(t), slug=slugify(t))
s.add(nowy)
s.flush()
utworzone_tu.add(nowy.id)
liczniki["nowe_filmy"] += 1
klucze = set().union(*(p.keys for p in k))
# Rozdzielony film to STARA tresc, nie nowy nabytek: created_at = pierwsze
# zobaczenie jego refow, nie "teraz". Inaczej kilka tysiecy filmow z dzisiejsza
@ -446,62 +503,76 @@ def _przetworz(movie_id: uuid.UUID, *, dry_run: bool, liczniki: dict) -> None:
log.info(" kotwica zostaje jako %r (hostery skasowane: %d)", m.title, r.rowcount)
def _sprzataj_duble(utworzone_po: datetime, *, dry_run: bool) -> dict:
"""Scala filmy utworzone przez ten skrypt z filmem, ktory juz istnial (albo z
wczesniejszym z tego samego przebiegu), gdy resolver dalby auto-merge.
def _waga_filmu(s, movie_id: uuid.UUID) -> tuple:
"""Ktory z dwoch dubli zostaje: wiecej refow, potem starsze pierwsze zobaczenie, potem
starsze created_at. NIE sama data utworzenia, bo rozdzielone filmy dostaja created_at
z historii refow i "starszy" przestal znaczyc "istniejacy wczesniej"."""
refy, fs = s.execute(
text("SELECT count(*), min(first_seen) FROM movie_external_refs WHERE movie_id = :m"), {"m": str(movie_id)}
).fetchone()
m = s.get(Movie, movie_id)
return (-(refy or 0), fs or datetime.max.replace(tzinfo=UTC), m.created_at or datetime.max.replace(tzinfo=UTC))
Pierwszy przebieg 2026-09-02 tworzyl nowe filmy bez szukania istniejacych. Ten tryb
naprawia skutki: dla kazdego filmu z `created_at > utworzone_po` (od najstarszego)
szuka kandydata STARSZEGO od niego i przy >= 0.85 robi `merge_movies(keep=starszy,
drop=nowy)`, czyli te sama operacje, ktorej uzywa dedup po TPDB.
def _sprzataj_duble(zmienione_po: datetime, *, dry_run: bool, after_id: str | None) -> dict:
"""Scala duble wsrod filmow ruszonych po `zmienione_po` (po `updated_at`, bo
`created_at` rozdzielonych filmow jest cofane do historii refow i nie nadaje sie na
znacznik "z tego przebiegu").
Dla kazdego takiego filmu szuka partnera, do ktorego resolver dolaczylby go
automatycznie (>= 0.85), i scala przez `merge_movies`. Zostaje film o wiekszej wadze
(`_waga_filmu`), a `merge_movies` przenosi tez ulubione i postep, wiec kierunek nie
kosztuje danych usera. Blad przy jednym filmie nie przerywa przebiegu; `--after-id`
wznawia.
"""
from app.resolve.movie_merge import merge_movies
lic = {"sprawdzone": 0, "scalone": 0}
lic = {"sprawdzone": 0, "scalone": 0, "bledy": 0}
sql = "SELECT id FROM movies WHERE updated_at > :t"
params: dict = {"t": zmienione_po}
if after_id:
sql += " AND id > :aid"
params["aid"] = after_id
with session_scope() as s:
ids = [
r[0] for r in s.execute(
text("SELECT id FROM movies WHERE created_at > :t ORDER BY created_at"), {"t": utworzone_po}
).fetchall()
]
log.info("duble: filmow utworzonych po %s: %d", utworzone_po.isoformat(), len(ids))
ids = [r[0] for r in s.execute(text(sql + " ORDER BY id"), params).fetchall()]
log.info("duble: filmow zmienionych po %s: %d", zmienione_po.isoformat(), len(ids))
for i, mid in enumerate(ids, 1):
try:
with session_scope() as s:
m = s.get(Movie, mid)
if m is None:
continue
lic["sprawdzone"] += 1
nazwy = {
n.lower() for (n,) in s.execute(
text("SELECT p.canonical_name FROM movie_performers mp JOIN performers p ON p.id=mp.performer_id WHERE mp.movie_id=:m"),
{"m": str(mid)},
).fetchall() if n
}
norm = NormalizedMovie(
external_id="", title=m.title, title_normalized=m.title_normalized, slug=m.slug or "",
release_year=m.release_year, release_date=m.release_date, description=None, duration_sec=None,
director=None, country=None, rating=None, poster_url=None, backdrop_url=None, url=None,
)
pids = [pid for (pid,) in s.execute(
text("SELECT performer_id FROM movie_performers WHERE movie_id = :m"), {"m": str(mid)}
).fetchall()]
istn = _istniejacy_film(
s, norm=norm, studio_id=m.studio_id, performer_names=nazwy, wyklucz={mid},
tylko_starsze_niz=m.created_at,
s, title_normalized=m.title_normalized, release_year=m.release_year,
studio_id=m.studio_id, performer_ids=pids, wyklucz={mid},
)
if istn is None:
continue
keep, drop = (istn, m) if _waga_filmu(s, istn.id) <= _waga_filmu(s, m.id) else (m, istn)
lic["scalone"] += 1
log.info("DUBEL %r (%s) -> %r (%s)%s", m.title[:40], str(mid)[:8], istn.title[:40], str(istn.id)[:8], " [dry]" if dry_run else "")
log.info("DUBEL %r (%s) -> %r (%s)%s", drop.title[:40], str(drop.id)[:8], keep.title[:40], str(keep.id)[:8], " [dry]" if dry_run else "")
if not dry_run:
merge_movies(s, keep_id=istn.id, drop_id=mid, resolved_by="movie_unmerge:dedupe")
if i % 200 == 0:
log.info("[duble %d/%d] %s", i, len(ids), lic)
merge_movies(s, keep_id=keep.id, drop_id=drop.id, resolved_by="movie_unmerge:dedupe")
except Exception:
lic["bledy"] += 1
log.exception("duble: film %s, pomijam", mid)
if i % 200 == 0 or i == len(ids):
log.info("[duble %d/%d] %s (ostatni id %s)", i, len(ids), lic, mid)
for h in logging.getLogger().handlers:
h.flush()
log.info("KONIEC duble: %s", lic)
return lic
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--dedupe-created-after", default=None,
help="ISO timestamp: zamiast rozdzielania scal duble utworzone po tej chwili")
ap.add_argument("--dedupe-updated-after", default=None,
help="ISO timestamp (bez strefy = UTC): zamiast rozdzielania scal duble wsrod "
"filmow zmienionych po tej chwili (po updated_at)")
ap.add_argument("--movie-id", default=None)
ap.add_argument("--limit", type=int, default=0)
ap.add_argument("--after-id", default=None)
@ -513,8 +584,11 @@ def main(argv: list[str] | None = None) -> int:
for noisy in ("httpx", "httpcore", "urllib3", "curl_cffi"):
logging.getLogger(noisy).setLevel(logging.WARNING)
if a.dedupe_created_after:
_sprzataj_duble(datetime.fromisoformat(a.dedupe_created_after), dry_run=a.dry_run)
if a.dedupe_updated_after:
t = datetime.fromisoformat(a.dedupe_updated_after)
if t.tzinfo is None:
t = t.replace(tzinfo=UTC) # inaczej Postgres wzialby strefe sesji i okno by sie przesunelo
_sprzataj_duble(t, dry_run=a.dry_run, after_id=a.after_id)
return 0
if a.movie_id: