goon/scripts/movie_unmerge.py
goon-foss c4845e9f61 fix(unmerge): szukaj istniejacego filmu przed utworzeniem, sprzataj duble, dziedzicz created_at
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.
2026-09-02 15:29:21 +02:00

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())