diff --git a/app/resolve/tag_resolver.py b/app/resolve/tag_resolver.py
index 7ad123a..db402b1 100644
--- a/app/resolve/tag_resolver.py
+++ b/app/resolve/tag_resolver.py
@@ -12,6 +12,22 @@ from app.normalize.scenes import NormalizedTag
from app.normalize.text import slugify
+def _canonical_dup2_slug(session: Session, slug: str) -> str:
+ """Kanonizuje numbered-duplicate slug `2` → ``.
+
+ TPDB taxonomy emituje zduplikowane tagi z suffixem `2` (name "Bubble Butt2").
+ `slugify` daje `bubble-butt2` (bez separatora przed cyfrą) → bez tego osobny tag
+ obok `bubble-butt` z tysiącami scen (dup-tag bug 2026-06-06; backfill
+ scripts/merge_dup2_tags.py). Gdy slug kończy się literą+"2" i baza istnieje jako
+ tag, używamy bazy. Warunek `[-2].isalpha()` wyklucza legit sufiksy gdzie cyfra jest
+ znacząca (milf-30, teen18 — nie kończą się "2"; chroni też przed "...22")."""
+ if len(slug) > 1 and slug[-1] == "2" and slug[-2].isalpha():
+ base = slug[:-1]
+ if session.execute(select(Tag.id).where(Tag.slug == base)).first():
+ return base
+ return slug
+
+
def resolve_tag(session: Session, *, norm: NormalizedTag) -> Tag | None:
slug = norm.slug or slugify(norm.name)
# DB columns: name VARCHAR(128), slug VARCHAR(128). Scraper occasionally
@@ -20,6 +36,7 @@ def resolve_tag(session: Session, *, norm: NormalizedTag) -> Tag | None:
# the whole ingest batch.
if len(slug) > 120:
return None
+ slug = _canonical_dup2_slug(session, slug)
tag = session.execute(select(Tag).where(Tag.slug == slug)).scalar_one_or_none()
if tag is not None:
return tag
diff --git a/scripts/merge_dup2_tags.py b/scripts/merge_dup2_tags.py
new file mode 100644
index 0000000..aacbb8c
--- /dev/null
+++ b/scripts/merge_dup2_tags.py
@@ -0,0 +1,109 @@
+"""Bulk-merge numbered-duplicate tagów: `2` → ``.
+
+Kontekst (2026-06-06): TPDB taxonomy emituje zduplikowane tagi z suffixem `2`
+(np. name "Bubble Butt2"). `slugify` daje `bubble-butt2` (bez separatora przed
+cyfrą), więc `resolve_tag` tworzy OSOBNY tag obok `bubble-butt`. Tubowe sceny
+dziedziczą dup-tag przez scene-merge → 75 par, ~10k scene_tags na złym tagu.
+
+Ten skrypt scala każdy `2` (gdy `` istnieje jako osobny tag) do bazy:
+scene_tags + movie_tags + blacklisted_tags przepisane (z deduplikacją na PK),
+dup-tag skasowany. Na koniec refresh zdenormalizowanych scene_count.
+
+Prewencja regeneracji żyje w `app/resolve/tag_resolver.py` (_canonical_dup2_slug).
+
+Użycie:
+ python scripts/merge_dup2_tags.py [--dry-run]
+"""
+from __future__ import annotations
+
+import argparse
+import logging
+
+from sqlalchemy import text
+
+from app.db import session_scope
+
+log = logging.getLogger("merge_dup2_tags")
+
+# Para = tag o slugu kończącym się literą+"2", którego baza (slug bez ostatniego
+# znaku) istnieje jako inny tag. `[a-z]2$` wyklucza wieloznakowe sufiksy (teen18,
+# milf-30, vr11111111) — tam ostatni znak nie jest "2" albo przedostatni to cyfra.
+_DUP_MAP_SQL = """
+SELECT d.id AS drop_id, d.slug AS drop_slug, d.scene_count AS drop_cnt,
+ b.id AS keep_id, b.slug AS keep_slug, b.scene_count AS keep_cnt
+FROM tags d
+JOIN tags b ON b.slug = left(d.slug, length(d.slug) - 1)
+WHERE d.slug ~ '[a-z]2$'
+ORDER BY d.scene_count DESC
+"""
+
+
+def main() -> None:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--dry-run", action="store_true")
+ args = ap.parse_args()
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
+
+ with session_scope() as s:
+ pairs = list(s.execute(text(_DUP_MAP_SQL)))
+ log.info("found %d dup pairs", len(pairs))
+ for p in pairs:
+ log.info(" %-32s (%5d) -> %-30s (%5d)", p.drop_slug, p.drop_cnt, p.keep_slug, p.keep_cnt)
+
+ if not pairs:
+ return
+
+ # Temp tabela mapująca drop→keep — jeden set-based przebieg na wszystkie pary.
+ s.execute(text("CREATE TEMP TABLE _dup_map ON COMMIT DROP AS " + _DUP_MAP_SQL))
+
+ if args.dry_run:
+ n = s.execute(text("SELECT count(*) FROM scene_tags st JOIN _dup_map m ON st.tag_id=m.drop_id")).scalar()
+ nm = s.execute(text("SELECT count(*) FROM movie_tags mt JOIN _dup_map m ON mt.tag_id=m.drop_id")).scalar()
+ log.info("DRY-RUN: would touch %d scene_tags + %d movie_tags across %d pairs", n, nm, len(pairs))
+ s.rollback()
+ return
+
+ # 1) scene_tags: przepisz drop→keep tam gdzie scena NIE ma już keep (PK collision);
+ # resztę (sceny mające oba tagi) usunie CASCADE przy DELETE FROM tags.
+ r1 = s.execute(text("""
+ UPDATE scene_tags st SET tag_id = m.keep_id
+ FROM _dup_map m
+ WHERE st.tag_id = m.drop_id
+ AND NOT EXISTS (SELECT 1 FROM scene_tags k
+ WHERE k.scene_id = st.scene_id AND k.tag_id = m.keep_id)
+ """))
+ log.info("scene_tags migrated: %d", r1.rowcount)
+
+ # 2) movie_tags: analogicznie
+ r2 = s.execute(text("""
+ UPDATE movie_tags mt SET tag_id = m.keep_id
+ FROM _dup_map m
+ WHERE mt.tag_id = m.drop_id
+ AND NOT EXISTS (SELECT 1 FROM movie_tags k
+ WHERE k.movie_id = mt.movie_id AND k.tag_id = m.keep_id)
+ """))
+ log.info("movie_tags migrated: %d", r2.rowcount)
+
+ # 3) blacklisted_tags: przenieś blacklist z dup na bazę (gdyby ktoś zbanował dup-tag),
+ # żeby DELETE+CASCADE nie zgubił bana. ON CONFLICT pomija gdy baza już zbanowana.
+ r3 = s.execute(text("""
+ INSERT INTO blacklisted_tags (tag_id)
+ SELECT m.keep_id FROM blacklisted_tags bt JOIN _dup_map m ON bt.tag_id = m.drop_id
+ ON CONFLICT DO NOTHING
+ """))
+ if r3.rowcount:
+ log.info("blacklist refs moved: %d", r3.rowcount)
+
+ # 4) Skasuj dup-tagi. CASCADE sprząta pozostałe (kolizyjne) scene_tags/movie_tags/blacklist.
+ rd = s.execute(text("DELETE FROM tags WHERE id IN (SELECT drop_id FROM _dup_map)"))
+ log.info("dup tags deleted: %d", rd.rowcount)
+ s.commit()
+
+ # 5) Refresh zdenormalizowanych scene_count (hot-path /tags czyta gotową kolumnę).
+ from app.scheduler.taxonomy_counts import refresh_taxonomy_counts
+ changed = refresh_taxonomy_counts()
+ log.info("taxonomy counts refreshed: %s", changed)
+
+
+if __name__ == "__main__":
+ main()