fix(cleanup): don't flag a character's fandom (or a chaptered series) as unused
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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 ---------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user