goon/scripts/auto_merge_freshporno_to_canonical.py
goon-foss a4486980a4 style: myslniki em/en z komentarzy i docstringow (calosc repo)
1745 wystapien w 291 plikach (backend, scripts, mobile). Dominowal jeden ksztalt,
' — ' laczace zdania, wiec poszedl przecinek; myslnik otwierajacy mysl zdjety;
zakresy 0–5 i A–Z na dywiz.

Podzial komentarz/kod robil skaner stanow, nie regex, zeby nie ruszyc myslnika
stojacego w stringu albo w wyrazeniu regularnym. Potwierdzone maszynowo: po wycieciu
komentarzy z wersji HEAD i z dysku obie sa identyczne we wszystkich 291 plikach,
czyli poza komentarzami nie zmienilo sie nic. 233 pliki .py parsuja sie bez bledu,
tsc ma te same 11 bledow w tych samych 3 plikach co przed zmiana.

Dwie rzeczy wymagaly poprawki po automacie: wyrownane listy definicji, gdzie myslnik
byl separatorem kolumny (37 miejsc, kolumna przywrocona), i trzy przeniesienia mysli
do nowej linii, gdzie zostawal wiszacy przecinek na poczatku (poprawione recznie).

Bundla nie republikuje: Hermes i tak nie niesie komentarzy, wiec dla uzytkownika
to zero zmiany.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:51:57 +02:00

215 lines
9 KiB
Python

"""Auto-merge freshporno orphan scenes do TPDB/StashDB canonical.
Wcześniejszy bulk_dedup `all` / `performers` OOM-ował na O(N²) collection
wszystkich par. Tutaj inny pattern: O(N), dla każdej freshporno orphan-with-date,
query candidate canonical scen przez indexes (performer overlap + release_date
window), score, decyzja.
Wykonanie po backfillu release_date dla 10390 freshporno scen, teraz mamy
sygnał daty który wcześniej był null i blokował composite score ≥0.92.
Decyzje:
- score ≥ auto_t (0.92): przenieś playback_source z tube → canonical,
skopiuj brakujące tagi, usuń tube scenę.
- review_t ≤ score < auto_t: insert merge_candidate (pending).
- score < 0.75: skip.
Idempotent: orphan scene bez kandydatów lub już zmerged → no-op.
"""
from __future__ import annotations
import logging
from datetime import timedelta
from sqlalchemy import and_, exists, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from app.config import get_settings
from app.db import session_scope
from app.models.merge_candidate import MergeCandidate, MergeKind, MergeStatus
from app.models.playback_source import PlaybackSource
from app.models.scene import Scene, ScenePerformer, SceneExternalRef, SceneTag
from app.models.source import Source
from app.scheduler.bulk_dedup import score_scene_pair
log = logging.getLogger(__name__)
DATE_WINDOW_DAYS = 7
def main() -> int:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
settings = get_settings()
auto_t = settings.auto_merge_threshold
review_t = settings.review_threshold
log.info("auto_t=%.2f review_t=%.2f", auto_t, review_t)
with session_scope() as session:
canon_src_ids = list(session.execute(
select(Source.id).where(Source.name.in_(["tpdb", "stashdb"]))
).scalars().all())
# Freshporno orphans z release_date (nasi kandydaci na merge w canonical).
orphan_ids = list(session.execute(
select(Scene.id)
.join(PlaybackSource, PlaybackSource.scene_id == Scene.id)
.where(PlaybackSource.origin == "tube:freshpornoorg")
.where(Scene.release_date.is_not(None))
.where(~exists().where(and_(
SceneExternalRef.scene_id == Scene.id,
SceneExternalRef.source_id.in_(canon_src_ids),
)))
.distinct()
).scalars().all())
log.info("freshporno orphan candidates (with date): %d", len(orphan_ids))
merged = 0
pending_added = 0
no_candidates = 0
no_match = 0
errors = 0
for scene_id in orphan_ids:
try:
with session_scope() as session:
tube = session.get(Scene, scene_id)
if tube is None:
continue
if tube.release_date is None:
continue
# Performery tube scene
perfs = list(session.execute(
select(ScenePerformer.performer_id).where(
ScenePerformer.scene_id == tube.id
)
).scalars().all())
if not perfs:
no_candidates += 1
continue
# Query canonical candidates: scenes które mają ≥1 wspólnego performera
# AND release_date w oknie ±N dni AND mają canonical external_ref (TPDB/StashDB).
date_low = tube.release_date - timedelta(days=DATE_WINDOW_DAYS)
date_high = tube.release_date + timedelta(days=DATE_WINDOW_DAYS)
cand_ids = list(session.execute(
select(Scene.id).distinct()
.join(ScenePerformer, ScenePerformer.scene_id == Scene.id)
.where(ScenePerformer.performer_id.in_(perfs))
.where(Scene.release_date.is_not(None))
.where(Scene.release_date.between(date_low, date_high))
.where(Scene.id != tube.id)
.where(exists().where(and_(
SceneExternalRef.scene_id == Scene.id,
SceneExternalRef.source_id.in_(canon_src_ids),
)))
).scalars().all())
if not cand_ids:
no_candidates += 1
continue
# Score wszystkich kandydatów, weź best
best_cand = None
best_score = 0.0
best_breakdown = None
for cand_id in cand_ids:
cand = session.get(Scene, cand_id)
if cand is None:
continue
b = score_scene_pair(session, tube, cand)
if b.composite > best_score:
best_score = b.composite
best_cand = cand
best_breakdown = b
if best_cand is None or best_score < review_t:
no_match += 1
continue
if best_score >= auto_t:
# Auto-merge: przenieś playback do canonical, skopiuj tagi, usuń tube scenę.
session.execute(
PlaybackSource.__table__.update()
.where(PlaybackSource.scene_id == tube.id)
.values(scene_id=best_cand.id)
)
# Merge tagi (unique constraint na pair scene_id+tag_id, ignore conflict)
tube_tag_ids = list(session.execute(
select(SceneTag.tag_id).where(SceneTag.scene_id == tube.id)
).scalars().all())
for tag_id in tube_tag_ids:
session.execute(
pg_insert(SceneTag.__table__)
.values(scene_id=best_cand.id, tag_id=tag_id)
.on_conflict_do_nothing()
)
# Move external_refs (freshporno)
session.execute(
SceneExternalRef.__table__.update()
.where(SceneExternalRef.scene_id == tube.id)
.values(scene_id=best_cand.id)
)
# Drop remaining attached rows + scene
session.execute(
SceneTag.__table__.delete().where(SceneTag.scene_id == tube.id)
)
session.execute(
ScenePerformer.__table__.delete().where(ScenePerformer.scene_id == tube.id)
)
session.delete(tube)
merged += 1
# Log audit
session.add(MergeCandidate(
kind=MergeKind.scene,
left_id=best_cand.id,
right_id=best_cand.id, # self-ref dla audit (drop scene już nie istnieje)
score=best_score,
reasons={"path": "freshporno_backfill_auto", **(best_breakdown.reasons if best_breakdown else {})},
status=MergeStatus.auto_merged,
))
else:
# Pending review (0.75-0.92)
a_id, b_id = (tube.id, best_cand.id) if tube.id < best_cand.id else (best_cand.id, tube.id)
existing = session.execute(
select(MergeCandidate).where(
MergeCandidate.kind == MergeKind.scene,
MergeCandidate.left_id == a_id,
MergeCandidate.right_id == b_id,
).limit(1)
).scalar_one_or_none()
if existing is None:
session.add(MergeCandidate(
kind=MergeKind.scene,
left_id=a_id,
right_id=b_id,
score=best_score,
reasons={"path": "freshporno_backfill_review", **(best_breakdown.reasons if best_breakdown else {})},
status=MergeStatus.pending,
))
pending_added += 1
except Exception as e:
errors += 1
if errors <= 5:
log.warning("scene=%s failed: %s", scene_id, e)
total_done = merged + pending_added + no_candidates + no_match + errors
if total_done % 200 == 0:
log.info(
"progress %d/%d: merged=%d pending=%d no_cand=%d no_match=%d errors=%d",
total_done, len(orphan_ids), merged, pending_added,
no_candidates, no_match, errors,
)
log.info(
"DONE: orphans=%d merged=%d pending_added=%d no_candidates=%d no_match=%d errors=%d",
len(orphan_ids), merged, pending_added, no_candidates, no_match, errors,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())