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.
325 lines
13 KiB
Python
325 lines
13 KiB
Python
"""Composite scoring dla movies, title + year + studio + cast Jaccard.
|
|
|
|
Movies vs Scenes scoring:
|
|
- Movies rzadko mają fingerprinty, tytuł jest najsilniejszym sygnałem (weight 0.5).
|
|
- Year (production year) ważny ale czasem nieznany (paradisehill często NULL); wagę
|
|
redystrybuujemy proporcjonalnie gdy brak.
|
|
- Studio: binary signal, match=1.0, mismatch=0.0; gdy któraś strona ma NULL, sygnał
|
|
pomijamy (None waga).
|
|
- Cast: Jaccard po canonical UUID-ach performerów. Bonus signal, niektóre source'y
|
|
(paradisehill często, dooplay zawsze) mają cast; tube'y bez cast → None.
|
|
|
|
Threshold (lower niż scenes, title dla movies ma niższy false-positive rate):
|
|
≥0.85 auto-merge
|
|
0.65-0.85 review
|
|
<0.65 nowy kanon
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from collections.abc import Iterable
|
|
from dataclasses import dataclass
|
|
|
|
from rapidfuzz import fuzz
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models.movie import Movie, MoviePerformer
|
|
|
|
|
|
@dataclass
|
|
class MovieScoreBreakdown:
|
|
title: float | None = None
|
|
year: float | None = None
|
|
studio_match: bool | None = None
|
|
cast: float | None = None
|
|
composite: float = 0.0
|
|
reasons: dict | None = None
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"title": self.title,
|
|
"year": self.year,
|
|
"studio_match": self.studio_match,
|
|
"cast": self.cast,
|
|
"composite": self.composite,
|
|
"reasons": self.reasons or {},
|
|
}
|
|
|
|
|
|
_BASE_WEIGHTS = {
|
|
"title": 0.50,
|
|
"year": 0.20,
|
|
"studio": 0.20,
|
|
"cast": 0.10,
|
|
}
|
|
|
|
# Movies-specific thresholds (lower niż scenes ze względu na unique titles)
|
|
AUTO_THRESHOLD = 0.85
|
|
REVIEW_THRESHOLD = 0.65
|
|
|
|
|
|
def _series_number(title_normalized: str) -> int | None:
|
|
"""Ostatnia samodzielna liczba w tytule, z pominieciem lat (1900-2099).
|
|
|
|
"her way to use tinder 61" -> 61, "the doctor is in 2" -> 2, "the doctor" -> None,
|
|
"cece 2019" -> None (rok to nie numer odcinka).
|
|
"""
|
|
liczby = [
|
|
int(t)
|
|
for t in title_normalized.split()
|
|
if t.isdigit() and not 1900 <= int(t) <= 2099
|
|
]
|
|
return liczby[-1] if liczby else None
|
|
|
|
|
|
# Tyle najwyzej dostaje tytul, gdy numer serii ma tylko jedna strona. Ponizej progu
|
|
# auto-merge, zeby sam tytul nie decydowal, ale dosc, zeby ze zgodnym studiem i rokiem
|
|
# trafic do recznego przegladu zamiast od razu odpadac.
|
|
_ONE_SIDED_NUMBER_CAP = 0.6
|
|
|
|
# Slowa, ktore lustra dooplay dokladaja do tytulu w SEO ("Watch Cece Adult Movie Online
|
|
# Free"). Nie niosa tresci, wiec przy tescie podzbioru je pomijamy.
|
|
_SEO_WYPELNIACZE = frozenset({
|
|
"watch", "adult", "xxx", "movie", "movies", "film", "online", "free", "full",
|
|
"porn", "hd", "stream", "streaming", "the", "a", "an", "and", "of",
|
|
})
|
|
|
|
|
|
def _title_signal(cand_title: str, inc_title: str) -> tuple[float, bool, dict]:
|
|
"""(ocena tytulu 0..1, twarde odrzucenie, powody).
|
|
|
|
`token_set_ratio` mierzy czesc wspolna, wiec tytul bedacy podzbiorem drugiego dostaje
|
|
pelne 100: "the doctor" vs "the doctor is in 2", "fantasies" vs "dark fantasies 3".
|
|
Numerowane serie wychodza na 90-96%, tez ponad progiem auto-merge. Gdy zrodlo nie ma
|
|
roku ani studia (lustra dooplay czesto nie maja), o wszystkim decyduje sam tytul i
|
|
cala seria zapada sie w jeden wpis. Zgloszenie 32100b94 (2026-08-30): pod "The Doctor"
|
|
siedzialo 9 roznych filmow z pandamovies, w tym granny porn; w bazie 1878 filmow ma
|
|
co najmniej dwie rozne strony z pandamovies, a jedna to norma.
|
|
|
|
Zasady, obie oparte na numerze serii (ostatnia liczba w tytule, bez lat):
|
|
* oba tytuly maja numer i sie rozni -> twarde odrzucenie (to inny odcinek),
|
|
* numer ma tylko jedna strona -> tytul najwyzej 0.6 (inne wydanie, nie lustro).
|
|
Sam podzbior bez numerow zostaje bez kary celowo: "cece" vs "watch cece adult" to
|
|
prawdziwe lustro z przedrostkiem SEO i to wlasnie dla niego jest token_set.
|
|
"""
|
|
ocena = fuzz.token_set_ratio(cand_title, inc_title) / 100.0
|
|
powody: dict = {}
|
|
n_c, n_i = _series_number(cand_title), _series_number(inc_title)
|
|
if n_c is not None and n_i is not None and n_c != n_i:
|
|
powody["series_number_mismatch_reject"] = [n_c, n_i]
|
|
return ocena, True, powody
|
|
if (n_c is None) != (n_i is None):
|
|
powody["series_number_one_sided_cap"] = n_c if n_c is not None else n_i
|
|
return min(ocena, _ONE_SIDED_NUMBER_CAP), False, powody
|
|
# Podzbior z PRAWDZIWYMI dodatkowymi slowami to inny film, nie lustro. Po
|
|
# normalizacji "The Doctor" zostaje samym "doctor", a jedno slowo jest podzbiorem
|
|
# kazdego tytulu z tym slowem: "granny s doctor games", "we need a doctor", "down on
|
|
# 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.
|
|
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
|
|
|
|
|
|
# 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 tok in _OGONKI_APOSTROFU and out:
|
|
out[-1] = out[-1] + tok
|
|
else:
|
|
out.append(_SKROTY.get(tok, tok))
|
|
return [t for t in out if t not in _SEO_WYPELNIACZE]
|
|
|
|
|
|
def score_movie_candidate(
|
|
session: Session,
|
|
*,
|
|
candidate: Movie,
|
|
norm_title_normalized: str,
|
|
norm_release_year: int | None,
|
|
norm_studio_id: uuid.UUID | None,
|
|
norm_performer_ids: Iterable[uuid.UUID],
|
|
) -> MovieScoreBreakdown:
|
|
"""Liczy score dopasowania incoming → candidate. Wszystko w [0,1]."""
|
|
breakdown = MovieScoreBreakdown(reasons={})
|
|
|
|
# Title (always)
|
|
series_reject = False
|
|
if candidate.title_normalized and norm_title_normalized:
|
|
breakdown.title, series_reject, powody = _title_signal(
|
|
candidate.title_normalized, norm_title_normalized
|
|
)
|
|
breakdown.reasons.update(powody)
|
|
|
|
# Year, exact lub ±1 = wysoka, ±2 = średnia
|
|
if candidate.release_year is not None and norm_release_year is not None:
|
|
delta = abs(candidate.release_year - norm_release_year)
|
|
if delta == 0:
|
|
breakdown.year = 1.0
|
|
elif delta == 1:
|
|
breakdown.year = 0.7
|
|
elif delta == 2:
|
|
breakdown.year = 0.4
|
|
else:
|
|
breakdown.year = 0.0
|
|
|
|
# Studio match, binary
|
|
if candidate.studio_id and norm_studio_id:
|
|
breakdown.studio_match = candidate.studio_id == norm_studio_id
|
|
|
|
# Cast Jaccard, gdy obie strony mają performerów
|
|
norm_perf_set = {str(pid) for pid in norm_performer_ids if pid is not None}
|
|
if norm_perf_set:
|
|
cand_perf_rows = session.execute(
|
|
MoviePerformer.__table__.select().where(
|
|
MoviePerformer.movie_id == candidate.id
|
|
)
|
|
).fetchall()
|
|
cand_perf_set = {str(r.performer_id) for r in cand_perf_rows}
|
|
if cand_perf_set:
|
|
inter = norm_perf_set & cand_perf_set
|
|
union = norm_perf_set | cand_perf_set
|
|
breakdown.cast = len(inter) / len(union) if union else 0.0
|
|
|
|
# Composite, redystrybuuj wagi po dostępnych sygnałach
|
|
available: dict[str, float] = {}
|
|
if breakdown.title is not None:
|
|
available["title"] = breakdown.title
|
|
if breakdown.year is not None:
|
|
available["year"] = breakdown.year
|
|
if breakdown.studio_match is not None:
|
|
available["studio"] = 1.0 if breakdown.studio_match else 0.0
|
|
if breakdown.cast is not None:
|
|
available["cast"] = breakdown.cast
|
|
|
|
if not available:
|
|
breakdown.composite = 0.0
|
|
breakdown.reasons["no_signals"] = True
|
|
return breakdown
|
|
|
|
weights = {k: _BASE_WEIGHTS[k] for k in available}
|
|
total_w = sum(weights.values())
|
|
if total_w == 0.0:
|
|
breakdown.composite = 0.0
|
|
return breakdown
|
|
norm_w = {k: w / total_w for k, w in weights.items()}
|
|
score = sum(available[k] * norm_w[k] for k in available)
|
|
|
|
# Hard reject: studio mismatch + year mismatch (delta ≥3) → różne filmy z podobnym
|
|
# tytułem. "Tushy Anal Bliss 7" vs "Anal Bliss 7" z innego studia rok różny, to NIE
|
|
# jest mirror, to różne wydania.
|
|
if (
|
|
breakdown.studio_match is False
|
|
and breakdown.year is not None
|
|
and breakdown.year < 0.4
|
|
):
|
|
breakdown.reasons["studio_year_mismatch_reject"] = True
|
|
score = 0.0
|
|
|
|
# Hard reject: rozne numery serii. Bez tego "Her way to use Tinder 61" wchlaniala
|
|
# "... Tinder 60" (token_set 96%), az pod jednym tytulem siedzialy 102 strony.
|
|
if series_reject:
|
|
score = 0.0
|
|
|
|
breakdown.composite = max(0.0, min(1.0, score))
|
|
breakdown.reasons["weights"] = norm_w
|
|
breakdown.reasons["sub_scores"] = available
|
|
return breakdown
|
|
|
|
|
|
def triage_movie(score: float) -> str:
|
|
if score >= AUTO_THRESHOLD:
|
|
return "auto"
|
|
if score >= REVIEW_THRESHOLD:
|
|
return "review"
|
|
return "reject"
|