Trzy skutki pierwszego przebiegu rozdzielania (2026-09-02, 5846 nowych filmow), oba
zgloszone z apki tego samego dnia:
1. Duble (a5cdda7f). Klaster poza kotwica dostawal ZAWSZE nowy film, choc "Control 2"
z innego zrodla juz istnial: 798 nowych o tytule sprzed przebiegu, 408 dubli miedzy
soba. Teraz najpierw `find_movie_candidates` + `score_movie_candidate` (ten sam
resolver co ingest) i przy >= 0.85 refy ida do istniejacego. Tryb
`--dedupe-created-after <ts>` scala juz utworzone przez `merge_movies` (691 scalonych).
2. Scorer przepuszczal jeszcze pary z roznymi slowami po obu stronach: "hardcore
fucking sluts 2" vs "foot fucking sluts 2", "santa list gangbang" vs "gangbang d"
(token_set 85-94, ze zgodnym studiem i rokiem auto-merge). Lustra nie zmieniaja
tytulow, wiec: rowne zbiory slow po zdjeciu wypelniaczy SEO = 1.0, podzbior albo
rozne slowa po obu stronach = sufit 0.6. Ogonki po apostrofie sklejane ("she s" ->
"shes"), bo normalize() tnie apostrof na spacje. Lista dubli spadla z 941 do 691 i
sa to same identyczne tytuly.
3. Falszywe "+N nowych" (1db95176). Nowy film dostawal created_at = teraz, a badge w
ulubionych od lipca sumuje sceny i FILMY: 2196 filmow z obsada z dzisiejsza data
zapalilo "+N" u kazdego z performerem w ulubionych, przy zerze nowych scen na liscie.
Teraz created_at = pierwsze zobaczenie refow klastra; 5231 juz utworzonych
poprawione w bazie tym samym kryterium.
561 lines
24 KiB
Python
561 lines
24 KiB
Python
"""Rozdziela filmy zlepione z kilku roznych tytulow (skutek starego scorera).
|
|
|
|
Tlo: `movie_score` do 2026-09-02 mierzyl tytul przez `token_set_ratio`, ktore daje pelne
|
|
100 dla tytulu bedacego podzbiorem drugiego i 90-96 dla numerowanych serii. Efekt:
|
|
"The Doctor" = 9 filmow z pandamovies pod jednym wpisem (zgloszenie 32100b94, "w
|
|
pierwszym linku granny porn"), "Her way to use Tinder 61" = 102 strony. W bazie 8204
|
|
filmy maja >= 2 refy z tego samego zrodla, lacznie 31 299 refow.
|
|
|
|
Dlaczego to musi pobierac strony na nowo: rekordy zewnetrzne filmow trzymaja tylko
|
|
`{slug, html_len}` (dooplay) albo `{hex_id, html_len}` (paradisehill), bez tytulu, roku
|
|
ani listy hosterow, a ingest filmow jest przyrostowy (paradisehill `since`, dooplay 3
|
|
strony), wiec skasowane rekordy nie odbuduja sie same. Jedyne zrodlo prawdy o tym, ktory
|
|
hoster nalezy do ktorej strony, to sama strona.
|
|
|
|
Algorytm dla kazdego podejrzanego filmu M:
|
|
1. Kazdy ref pobieramy na nowo przez konektor zrodla -> RawMovie (tytul, rok, studio,
|
|
obsada, hostery). Zrodla bez konektora (tpdb, tube-scraper, yourdailypornmovies) i
|
|
nieudane pobrania dostaja profil z samego sluga adresu (tylko tytul).
|
|
2. Refy laczymy w klastry (union-find): ten sam plik u hostera (wspolny adres embedu,
|
|
BEZ prefiksu zrodla, bo ten sam plik siedzi pod mangoporn, pandamovies i streamporn)
|
|
ALBO nowa ocena zlozona >= 0.85 z regulami numeru serii z `movie_score`.
|
|
3. Klaster z najstarszym refem zostaje filmem M (zachowuje id, ulubione i postep).
|
|
Kazdy inny klaster dostaje nowy film; jego refy, hostery (po adresie embedu),
|
|
obsada, tagi i rozdzialy przechodza tam.
|
|
4. M zostaje z hosterami potwierdzonymi przez swieze strony kotwicy. Reszta wierszy z
|
|
rodzin luster (mangoporn/pandamovies/streamporn/paradisehill/freeomovie) leci, bo
|
|
albo nalezy do odczepionego filmu, albo jest zwietrzala (pandamovies zrotowal
|
|
hostery: swieza strona "Granny's Doctor Games" ma 0 z 4 kluczy w bazie). Wiersze z
|
|
innych origin (kanoniczne strony studiow) zostaja. Obsada, tagi i rozdzialy M sa
|
|
budowane od nowa z profili kotwicy, o ile choc jeden udalo sie pobrac.
|
|
|
|
Bezpieczniki: `--dry-run` tylko liczy i wypisuje klastry; `--movie-id` na jeden film;
|
|
`--after-id` do wznowienia (przebieg idzie ORDER BY id). Odpalaj z pliku, w osobnym
|
|
kontenerze (`docker compose run -d --rm ... python3 -u /srv/scripts/movie_unmerge.py`),
|
|
nie przez stdin (patrz browse_catchup.py) i nie w kontenerze workera.
|
|
|
|
Uzycie:
|
|
python3 -u /srv/scripts/movie_unmerge.py --movie-id 6283c6cb-... --dry-run
|
|
python3 -u /srv/scripts/movie_unmerge.py --limit 50 --dry-run
|
|
python3 -u /srv/scripts/movie_unmerge.py
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import logging
|
|
import re
|
|
import sys
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from urllib.parse import urlparse
|
|
|
|
from sqlalchemy import text
|
|
|
|
from app.connectors.base import RawMovie
|
|
from app.connectors.dooplay import (
|
|
MangopornConnector,
|
|
PandamoviesConnector,
|
|
StreampornConnector,
|
|
StreampornVipConnector,
|
|
)
|
|
from app.connectors.freeomovie import FreeoMovieConnector
|
|
from app.connectors.paradisehill import ParadisehillConnector
|
|
from app.db import session_scope
|
|
from app.models.movie import Movie, MovieChapter, MoviePerformer, MovieTag
|
|
from app.normalize.movies import NormalizedMovie, normalize_movie
|
|
from app.normalize.text import normalize, slugify
|
|
from app.resolve.movie_resolver import (
|
|
_create_canonical,
|
|
_sync_chapters,
|
|
_sync_performers,
|
|
_sync_playback_sources,
|
|
_sync_tags,
|
|
_update_movie_fields,
|
|
)
|
|
from app.resolve.movie_score import _title_signal
|
|
from app.resolve.performer_resolver import resolve_performer
|
|
from app.resolve.studio_resolver import resolve_studio
|
|
|
|
log = logging.getLogger("movie-unmerge")
|
|
|
|
_SPACING_SEC = 0.4
|
|
_AUTO = 0.85
|
|
_WAGI = {"title": 0.50, "year": 0.20, "studio": 0.20, "cast": 0.10}
|
|
|
|
# Rodziny origin, ktorych wiersze na filmie-kotwicy wolno skasowac, gdy zadna swieza
|
|
# strona ich nie potwierdza. Inne origin (np. kanoniczne strony studiow) zostaja.
|
|
_RODZINY_LUSTER = ("mangoporn", "pandamovies", "streamporn", "streampornvip", "paradisehill", "freeomovie")
|
|
|
|
_SUFIKSY = (
|
|
"-adult-movie-online-free", "-movie-online-free", "-porn-movie", "-online-free",
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class Profil:
|
|
source_name: str
|
|
source_id: uuid.UUID
|
|
external_id: str
|
|
url: str | None
|
|
first_seen: datetime
|
|
raw: RawMovie | None = None
|
|
norm: NormalizedMovie | None = None
|
|
title_norm: str = ""
|
|
year: int | None = None
|
|
studio_norm: str | None = None
|
|
cast: set[str] = field(default_factory=set)
|
|
keys: set[str] = field(default_factory=set) # adresy embedow / stron hosterow
|
|
fetched: bool = False
|
|
|
|
@property
|
|
def klucz(self) -> tuple[uuid.UUID, str]:
|
|
return (self.source_id, self.external_id)
|
|
|
|
|
|
def _tytul_ze_sluga(url: str | None, external_id: str) -> str:
|
|
seg = ""
|
|
if url:
|
|
seg = urlparse(url).path.rstrip("/").split("/")[-1]
|
|
if not seg or re.fullmatch(r"[0-9a-f]{8,}", seg):
|
|
seg = external_id.split(":")[-1]
|
|
seg = seg.lower()
|
|
for pre in ("watch-xxx-", "watch-"):
|
|
if seg.startswith(pre):
|
|
seg = seg[len(pre):]
|
|
for suf in _SUFIKSY:
|
|
if seg.endswith(suf):
|
|
seg = seg[: -len(suf)]
|
|
# `.../2768634/supermodel-sluts-9-po` -> bez numerycznego id na poczatku
|
|
seg = re.sub(r"^\d{5,}-", "", seg)
|
|
return normalize(seg.replace("-", " ").replace("_", " "))
|
|
|
|
|
|
_KONEKTORY: dict = {}
|
|
|
|
|
|
def _konektor(name: str):
|
|
if name not in _KONEKTORY:
|
|
klasy = {
|
|
"paradisehill": ParadisehillConnector,
|
|
"mangoporn": MangopornConnector,
|
|
"pandamovies": PandamoviesConnector,
|
|
"streamporn": StreampornConnector,
|
|
"streampornvip": StreampornVipConnector,
|
|
"freeomovie": FreeoMovieConnector,
|
|
}
|
|
_KONEKTORY[name] = klasy[name]() if name in klasy else None
|
|
return _KONEKTORY[name]
|
|
|
|
|
|
def _pobierz(p: Profil) -> RawMovie | None:
|
|
conn = _konektor(p.source_name)
|
|
if conn is None:
|
|
return None
|
|
time.sleep(_SPACING_SEC)
|
|
if p.source_name == "paradisehill":
|
|
return conn._fetch_detail(p.external_id)
|
|
if p.source_name == "freeomovie":
|
|
if not p.url:
|
|
return None
|
|
return conn._parse_detail(p.url, _tytul_ze_sluga(p.url, p.external_id).title())
|
|
url = p.url or f"{conn.base_url.rstrip('/')}/movies/{p.external_id}/"
|
|
return conn._fetch_detail(url)
|
|
|
|
|
|
def _profil(p: Profil) -> Profil:
|
|
try:
|
|
raw = _pobierz(p)
|
|
except Exception as e: # 404, timeout, CF: profil z samego sluga
|
|
log.info(" %s %s: pobranie padlo (%s), profil ze sluga", p.source_name, p.external_id, type(e).__name__)
|
|
raw = None
|
|
if raw is not None:
|
|
p.raw = raw
|
|
p.norm = normalize_movie(raw)
|
|
p.title_norm = p.norm.title_normalized
|
|
p.year = p.norm.release_year
|
|
p.studio_norm = p.norm.studio.name_normalized if p.norm.studio else None
|
|
p.cast = {q.canonical_name.lower() for q in p.norm.performers if q.canonical_name}
|
|
p.keys = {s.page_url for s in raw.playback_sources if s.page_url}
|
|
p.fetched = True
|
|
else:
|
|
p.title_norm = _tytul_ze_sluga(p.url, p.external_id)
|
|
if p.url:
|
|
p.keys.add(p.url) # wiersz "landing" ma page_url == adres strony
|
|
return p
|
|
|
|
|
|
def _ten_sam_film(a: Profil, b: Profil) -> bool:
|
|
"""Odbicie `score_movie_candidate` w pamieci, z tymi samymi wagami i odrzuceniami."""
|
|
if a.keys & b.keys & _bez_landingow(a, b):
|
|
return True
|
|
if not a.title_norm or not b.title_norm:
|
|
return False
|
|
ocena, odrzut, _ = _title_signal(a.title_norm, b.title_norm)
|
|
if odrzut:
|
|
return False
|
|
dost = {"title": ocena}
|
|
rok = None
|
|
if a.year and b.year:
|
|
d = abs(a.year - b.year)
|
|
rok = 1.0 if d == 0 else 0.7 if d == 1 else 0.4 if d == 2 else 0.0
|
|
dost["year"] = rok
|
|
studio = None
|
|
if a.studio_norm and b.studio_norm:
|
|
studio = a.studio_norm == b.studio_norm
|
|
dost["studio"] = 1.0 if studio else 0.0
|
|
if a.cast and b.cast:
|
|
dost["cast"] = len(a.cast & b.cast) / len(a.cast | b.cast)
|
|
if studio is False and rok is not None and rok < 0.4:
|
|
return False
|
|
suma_w = sum(_WAGI[k] for k in dost)
|
|
wynik = sum(v * _WAGI[k] / suma_w for k, v in dost.items())
|
|
return wynik >= _AUTO
|
|
|
|
|
|
def _bez_landingow(a: Profil, b: Profil) -> set[str]:
|
|
"""Wspolne klucze licza sie tylko dla hosterow, nie dla adresow stron (te sa unikalne)."""
|
|
return (a.keys | b.keys) - {a.url, b.url}
|
|
|
|
|
|
def _klastry(profile: list[Profil]) -> list[list[Profil]]:
|
|
rodzic = list(range(len(profile)))
|
|
|
|
def find(i):
|
|
while rodzic[i] != i:
|
|
rodzic[i] = rodzic[rodzic[i]]
|
|
i = rodzic[i]
|
|
return i
|
|
|
|
for i in range(len(profile)):
|
|
for j in range(i + 1, len(profile)):
|
|
if find(i) != find(j) and _ten_sam_film(profile[i], profile[j]):
|
|
rodzic[find(j)] = find(i)
|
|
grupy: dict[int, list[Profil]] = {}
|
|
for i, p in enumerate(profile):
|
|
grupy.setdefault(find(i), []).append(p)
|
|
return list(grupy.values())
|
|
|
|
|
|
def _wzorzec(klaster: list[Profil]) -> Profil | None:
|
|
"""Profil, z ktorego bierzemy pola kanoniczne: paradisehill, potem pierwszy pobrany."""
|
|
for p in klaster:
|
|
if p.fetched and p.source_name == "paradisehill":
|
|
return p
|
|
for p in klaster:
|
|
if p.fetched:
|
|
return p
|
|
return None
|
|
|
|
|
|
def _opis(klaster: list[Profil]) -> str:
|
|
return "; ".join(f"{p.source_name}:{p.title_norm[:34]}" for p in klaster[:6]) + (" ..." if len(klaster) > 6 else "")
|
|
|
|
|
|
def _istniejacy_film(
|
|
s, *, norm: NormalizedMovie, studio_id, performer_names: set[str], wyklucz: set,
|
|
tylko_starsze_niz: datetime | None = None,
|
|
) -> 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.
|
|
"""
|
|
from app.resolve.movie_match import find_movie_candidates
|
|
from app.resolve.movie_score import AUTO_THRESHOLD, score_movie_candidate
|
|
|
|
kand = find_movie_candidates(
|
|
s, title_normalized=norm.title_normalized, studio_id=studio_id, release_year=norm.release_year
|
|
)
|
|
najlepszy, najlepsza = None, 0.0
|
|
for c in kand:
|
|
if c.id in wyklucz:
|
|
continue
|
|
if tylko_starsze_niz is not None and c.created_at and c.created_at >= tylko_starsze_niz:
|
|
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,
|
|
)
|
|
if br.composite > najlepsza:
|
|
najlepszy, najlepsza = c, br.composite
|
|
return najlepszy if najlepszy is not None and najlepsza >= AUTO_THRESHOLD else None
|
|
|
|
|
|
def _zaladuj_refy(s, movie_id: uuid.UUID) -> list[Profil]:
|
|
rows = s.execute(
|
|
text(
|
|
"""
|
|
SELECT src.name, r.source_id, r.external_id, r.url, r.first_seen
|
|
FROM movie_external_refs r JOIN sources src ON src.id = r.source_id
|
|
WHERE r.movie_id = :m ORDER BY r.first_seen
|
|
"""
|
|
),
|
|
{"m": str(movie_id)},
|
|
).fetchall()
|
|
return [Profil(source_name=n, source_id=sid, external_id=e, url=u, first_seen=fs) for n, sid, e, u, fs in rows]
|
|
|
|
|
|
def _przypnij_zawartosc(s, movie_id: uuid.UUID, p: Profil) -> None:
|
|
"""Obsada, tagi, rozdzialy i hostery z profilu na wskazany film (jak w resolverze)."""
|
|
assert p.norm is not None
|
|
resolved = []
|
|
for pn in p.norm.performers:
|
|
perf = resolve_performer(s, norm=pn, source_id=p.source_id)
|
|
resolved.append((perf.id, pn.as_alias_in_scene))
|
|
_sync_performers(s, movie_id=movie_id, resolved=resolved)
|
|
_sync_tags(s, movie_id=movie_id, norm=p.norm, source_id=p.source_id)
|
|
_sync_chapters(s, movie_id=movie_id, norm=p.norm)
|
|
_sync_playback_sources(s, movie_id=movie_id, norm=p.norm)
|
|
# Klaster to zwykle kilka luster tego samego filmu z ta sama obsada. Helpery
|
|
# resolvera sprawdzaja "czy juz jest" SELECT-em, ktory bez flush nie widzi wierszy
|
|
# dodanych przez poprzedni profil, wiec ten sam performer wpadal dwa razy i padal
|
|
# klucz glowny movie_performers (pierwszy prawdziwy przebieg na "The Doctor").
|
|
s.flush()
|
|
|
|
|
|
def _przetworz(movie_id: uuid.UUID, *, dry_run: bool, liczniki: dict) -> None:
|
|
with session_scope() as s:
|
|
m = s.get(Movie, movie_id)
|
|
if m is None:
|
|
return
|
|
tytul_m = m.title
|
|
profile = _zaladuj_refy(s, movie_id)
|
|
if len(profile) < 2:
|
|
return
|
|
profile = [_profil(p) for p in profile]
|
|
klastry = _klastry(profile)
|
|
klastry.sort(key=lambda k: min(p.first_seen for p in k))
|
|
kotwica, inne = klastry[0], klastry[1:]
|
|
liczniki["filmy"] += 1
|
|
liczniki["pobrane"] += sum(1 for p in profile if p.fetched)
|
|
liczniki["niepobrane"] += sum(1 for p in profile if not p.fetched)
|
|
if not inne:
|
|
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:
|
|
return
|
|
|
|
with session_scope() as s:
|
|
m = s.get(Movie, movie_id)
|
|
assert m is not None
|
|
# --- nowe filmy dla klastrow poza kotwica --------------------------------
|
|
for k in inne:
|
|
wz = _wzorzec(k)
|
|
if wz is not None and wz.norm is not None:
|
|
studio = resolve_studio(s, norm=wz.norm.studio, source_id=wz.source_id) if wz.norm.studio else None
|
|
# NAJPIERW istniejacy film, tym samym resolverem co ingest. Bez tego
|
|
# 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).
|
|
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},
|
|
)
|
|
if istn is not None:
|
|
nowy = istn
|
|
liczniki["dolaczone_do_istniejacych"] += 1
|
|
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)
|
|
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.
|
|
t = (k[0].title_norm or "untitled").title()
|
|
nowy = Movie(title=t, title_normalized=normalize(t), slug=slugify(t))
|
|
s.add(nowy)
|
|
s.flush()
|
|
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
|
|
# data wpadlo w badge "+N nowych" u kazdego, kto ma performera w ulubionych,
|
|
# a na liscie scen nie bylo nic nowego (zgloszenie 1db95176, 2026-09-02).
|
|
najstarszy = min(p.first_seen for p in k)
|
|
if nowy.created_at is None or nowy.created_at > najstarszy:
|
|
nowy.created_at = najstarszy
|
|
for p in k:
|
|
s.execute(
|
|
text("UPDATE movie_external_refs SET movie_id=:n WHERE source_id=:sid AND external_id=:e"),
|
|
{"n": str(nowy.id), "sid": str(p.source_id), "e": p.external_id},
|
|
)
|
|
if klucze:
|
|
r = s.execute(
|
|
text("UPDATE movie_playback_sources SET movie_id=:n WHERE movie_id=:m AND page_url = ANY(:k)"),
|
|
{"n": str(nowy.id), "m": str(movie_id), "k": list(klucze)},
|
|
)
|
|
liczniki["hostery_przeniesione"] += r.rowcount
|
|
for p in k:
|
|
if p.norm is not None:
|
|
_przypnij_zawartosc(s, nowy.id, p)
|
|
# Dopelnianie pol z kazdego lustra (tylko braki): wzorcem bywa
|
|
# paradisehill bez roku, a mangoporn ma rok i studio.
|
|
st = resolve_studio(s, norm=p.norm.studio, source_id=p.source_id) if p.norm.studio else None
|
|
_update_movie_fields(nowy, p.norm, studio_id=st.id if st else None)
|
|
liczniki["refy_przeniesione"] += len(k)
|
|
log.info(" -> nowy film %s %r", nowy.id, nowy.title)
|
|
|
|
# --- kotwica: zostaje to, co potwierdzaja swieze strony ------------------
|
|
klucze_k = set().union(*(p.keys for p in kotwica))
|
|
wzory = [p for p in kotwica if p.norm is not None]
|
|
r = s.execute(
|
|
text(
|
|
"""
|
|
DELETE FROM movie_playback_sources
|
|
WHERE movie_id = :m AND NOT (page_url = ANY(:k))
|
|
AND split_part(origin, ':', 1) = ANY(:rodz)
|
|
"""
|
|
),
|
|
{"m": str(movie_id), "k": list(klucze_k) or [""], "rodz": list(_RODZINY_LUSTER)},
|
|
)
|
|
liczniki["hostery_skasowane"] += r.rowcount
|
|
if wzory:
|
|
for tabela in (MoviePerformer, MovieTag, MovieChapter):
|
|
s.query(tabela).filter(tabela.movie_id == movie_id).delete(synchronize_session=False)
|
|
wz = _wzorzec(kotwica)
|
|
assert wz is not None and wz.norm is not None
|
|
studio = resolve_studio(s, norm=wz.norm.studio, source_id=wz.source_id) if wz.norm.studio else None
|
|
m.title = wz.norm.title
|
|
m.title_normalized = wz.norm.title_normalized
|
|
m.slug = wz.norm.slug or slugify(wz.norm.title)
|
|
m.release_year = wz.norm.release_year
|
|
if studio is not None:
|
|
m.studio_id = studio.id
|
|
_update_movie_fields(m, wz.norm, studio_id=studio.id if studio else None)
|
|
for p in wzory:
|
|
_przypnij_zawartosc(s, movie_id, p)
|
|
st = resolve_studio(s, norm=p.norm.studio, source_id=p.source_id) if p.norm.studio else None
|
|
_update_movie_fields(m, p.norm, studio_id=st.id if st else 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.
|
|
|
|
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.
|
|
"""
|
|
from app.resolve.movie_merge import merge_movies
|
|
|
|
lic = {"sprawdzone": 0, "scalone": 0}
|
|
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))
|
|
for i, mid in enumerate(ids, 1):
|
|
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,
|
|
)
|
|
istn = _istniejacy_film(
|
|
s, norm=norm, studio_id=m.studio_id, performer_names=nazwy, wyklucz={mid},
|
|
tylko_starsze_niz=m.created_at,
|
|
)
|
|
if istn is None:
|
|
continue
|
|
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 "")
|
|
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)
|
|
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("--movie-id", default=None)
|
|
ap.add_argument("--limit", type=int, default=0)
|
|
ap.add_argument("--after-id", default=None)
|
|
ap.add_argument("--shard", default=None, help="k/n: tylko filmy z hashtext(id) %% n == k (rownolegle kontenery)")
|
|
ap.add_argument("--dry-run", action="store_true")
|
|
a = ap.parse_args(argv)
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", stream=sys.stdout)
|
|
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)
|
|
return 0
|
|
|
|
if a.movie_id:
|
|
ids = [uuid.UUID(a.movie_id)]
|
|
else:
|
|
sql = """
|
|
SELECT DISTINCT movie_id FROM movie_external_refs
|
|
WHERE movie_id IN (SELECT movie_id FROM movie_external_refs
|
|
GROUP BY movie_id, source_id HAVING count(*) >= 2)
|
|
"""
|
|
params: dict = {}
|
|
if a.after_id:
|
|
sql += " AND movie_id > :aid"
|
|
params["aid"] = a.after_id
|
|
if a.shard:
|
|
k, n = (int(x) for x in a.shard.split("/"))
|
|
sql += " AND abs(hashtext(movie_id::text)) % :n = :k"
|
|
params.update({"k": k, "n": n})
|
|
sql += " ORDER BY movie_id"
|
|
if a.limit:
|
|
sql += f" LIMIT {int(a.limit)}"
|
|
with session_scope() as s:
|
|
ids = [r[0] for r in s.execute(text(sql), params).fetchall()]
|
|
log.info("filmow do sprawdzenia: %d%s", len(ids), " (DRY RUN)" if a.dry_run else "")
|
|
|
|
liczniki = {k: 0 for k in ("filmy", "rozdzielone", "nowe_filmy", "dolaczone_do_istniejacych", "refy_przeniesione", "hostery_przeniesione", "hostery_skasowane", "pobrane", "niepobrane")}
|
|
t0 = time.time()
|
|
for i, mid in enumerate(ids, 1):
|
|
try:
|
|
_przetworz(mid, dry_run=a.dry_run, liczniki=liczniki)
|
|
except Exception:
|
|
log.exception("film %s: blad, pomijam", mid)
|
|
liczniki.setdefault("bledy", 0)
|
|
liczniki["bledy"] += 1
|
|
if i % 25 == 0 or i == len(ids):
|
|
log.info("[%d/%d] %s (%.0f min, ostatni id %s)", i, len(ids), liczniki, (time.time() - t0) / 60, mid)
|
|
for h in logging.getLogger().handlers:
|
|
h.flush()
|
|
log.info("KONIEC: %s | %.0f min", liczniki, (time.time() - t0) / 60)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|