goon/app/resolve/movie_score.py
goon-foss ec38403e40 feat(movies): rozdzielanie zlepionych filmow + sufit dla tytulu-podzbioru w scorerze
Skrypt `scripts/movie_unmerge.py` rozdziela filmy, ktore stary scorer zlepil z kilku
tytulow (8204 filmy z >=2 refami z jednego zrodla, 31 299 refow). Musi pobierac strony
na nowo, bo rekordy zewnetrzne filmow trzymaja tylko {slug, html_len} / {hex_id,
html_len}, a ingest jest przyrostowy, wiec skasowane rekordy same sie nie odbuduja.

Dla kazdego filmu: refy pobierane przez konektory zrodel (paradisehill, dooplay x4,
freeomovie; reszta profil ze sluga), klastry union-find po wspolnym adresie embedu
(bez prefiksu zrodla, ten sam plik siedzi pod mangoporn/pandamovies/streamporn) albo
po nowej ocenie zlozonej >= 0.85. Klaster z najstarszym refem zostaje filmem
(zachowuje id, ulubione, postep), kazdy inny dostaje nowy film z refami, hosterami,
obsada, tagami i rozdzialami. Kotwica zostaje z hosterami potwierdzonymi przez swieze
strony; reszta z rodzin luster leci, bo pandamovies zdazyl zrotowac hostery (swieza
strona ma 0 z 4 kluczy z bazy), wiec to i tak zwietrzale linki.

Proba na "The Doctor" ujawnila druga luke scorera: po normalizacji zostaje samo
"doctor", a jedno slowo jest podzbiorem kazdego tytulu z tym slowem, wiec "granny s
doctor games" i "we need a doctor" dostawaly 100 i szly do kotwicy. Teraz podzbior z
prawdziwymi dodatkowymi slowami ma sufit 0.6, a przedrostki SEO luster ("watch cece
adult movie online free") sa zdejmowane przed testem, zeby prawdziwe lustra nadal sie
laczyly. Po poprawce "The Doctor" rozpada sie na 11 klastrow, kotwica to sam
paradisehill, a "Doctor Adventures 14" laczy swoje cztery lustra.
2026-09-02 12:54:42 +02:00

229 lines
8.7 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.
ta = set(cand_title.split()) - _SEO_WYPELNIACZE
ti = set(inc_title.split()) - _SEO_WYPELNIACZE
if ta and ti and ta != ti and (ta < ti or ti < ta):
powody["subset_title_cap"] = sorted((ta ^ ti))[:4]
return min(ocena, _ONE_SIDED_NUMBER_CAP), False, powody
return ocena, False, powody
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"