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.
229 lines
8.7 KiB
Python
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"
|