From fb05c5eef7aef26b78f9d166e79767b6ad8d83c4 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 8 Jun 2026 08:41:27 -0400 Subject: [PATCH] fix(cleanup): don't flag a character's fandom (or a chaptered series) as unused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit find_unused_tags only excluded tags with image_tag or series_page references, so it flagged every fandom as 'unused' — fandoms are NEVER applied to images (a character carries its fandom via tag.fandom_id), and the FK is ondelete=SET NULL, so deleting one silently strips the fandom off all its characters (operator-flagged 2026-06-08: artist-OC fandoms showing as unused). Exclude tags referenced as a character's fandom_id, and (same class of gap) tags referenced by a series_chapter (an all-placeholder series has chapters but no pages yet). A genuinely orphaned fandom with no characters is still swept. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/services/cleanup_service.py | 26 ++++++++++++++++++++----- tests/test_cleanup_service.py | 25 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/backend/app/services/cleanup_service.py b/backend/app/services/cleanup_service.py index 05c55c8..4ed3b11 100644 --- a/backend/app/services/cleanup_service.py +++ b/backend/app/services/cleanup_service.py @@ -21,6 +21,7 @@ from sqlalchemy import func, or_, select, update from sqlalchemy.orm import Session from ..models import Artist, ImageRecord, LibraryAuditRun, Tag +from ..models.series_chapter import SeriesChapter from ..models.series_page import SeriesPage from ..models.tag import image_tag @@ -141,21 +142,36 @@ def count_tag_associations(session: Session, *, tag_id: int) -> int: def find_unused_tags( session: Session, *, limit: int | None = None, ) -> list[Tag]: - """Tags with no image_tag rows AND no series_page rows. + """Tags genuinely referenced by nothing — safe to sweep. - Sorted by name. Used by both dry-run preview and the live prune. - A tag is "unused" iff it has zero rows in image_tag AND zero rows - in series_page (so we don't accidentally prune a series tag that - happens to have no images yet). + Sorted by name. Used by both dry-run preview and the live prune. A tag is + "unused" iff it has zero references across ALL the ways a tag can be in use: + - image_tag (applied to an image) + - series_page (a series tag with ordered pages) + - series_chapter (a series tag with chapters but no pages yet, e.g. + all-placeholder) + - tag.fandom_id (a fandom referenced by a character) + + The fandom check is essential: fandom tags are NEVER applied to images — a + character carries its fandom via fandom_id — so without it every assigned + fandom looked "unused", and the FK is ondelete=SET NULL, so deleting one + would silently strip the fandom off all its characters (operator-flagged + 2026-06-08). """ used_via_image_tag = select(image_tag.c.tag_id).distinct() used_via_series = select(SeriesPage.series_tag_id).where( SeriesPage.series_tag_id.is_not(None) ).distinct() + used_via_chapter = select(SeriesChapter.series_tag_id).distinct() + used_via_fandom = select(Tag.fandom_id).where( + Tag.fandom_id.is_not(None) + ).distinct() stmt = ( select(Tag) .where(Tag.id.not_in(used_via_image_tag)) .where(Tag.id.not_in(used_via_series)) + .where(Tag.id.not_in(used_via_chapter)) + .where(Tag.id.not_in(used_via_fandom)) .order_by(Tag.name) ) if limit is not None: diff --git a/tests/test_cleanup_service.py b/tests/test_cleanup_service.py index ba6242f..25285b1 100644 --- a/tests/test_cleanup_service.py +++ b/tests/test_cleanup_service.py @@ -155,6 +155,31 @@ def test_find_unused_tags_returns_only_unreferenced(db_sync, tmp_path): assert names.index("aaa-unused") < names.index("zzz-unused") +def test_find_unused_tags_excludes_fandom_referenced_by_character(db_sync): + # A fandom is never on an image — a character carries it via fandom_id — so + # an assigned fandom must NOT be flagged unused (deleting it SET-NULLs the + # fandom off its characters). Operator-flagged 2026-06-08. + fandom = _make_tag(db_sync, name="Creux", kind=TagKind.fandom) + char = _make_tag(db_sync, name="OcChar", kind=TagKind.character) + char.fandom_id = fandom.id + orphan_fandom = _make_tag(db_sync, name="NobodysFandom", kind=TagKind.fandom) + db_sync.commit() + + names = [t.name for t in cleanup_service.find_unused_tags(db_sync)] + assert "Creux" not in names # referenced by a character → kept + assert "NobodysFandom" in names # genuinely orphaned → still swept + + +def test_find_unused_tags_excludes_series_with_only_chapters(db_sync): + # An all-placeholder series has chapters but no pages yet — not unused. + series = _make_tag(db_sync, name="EmptySeries", kind=TagKind.series) + db_sync.add(SeriesChapter(series_tag_id=series.id, chapter_number=1)) + db_sync.commit() + + names = [t.name for t in cleanup_service.find_unused_tags(db_sync)] + assert "EmptySeries" not in names + + # --- unlink_image_files ---------------------------------------------