From 4fc307c42673b190cd1cc35b64e9b20a568dc594 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 3 Oct 2026 19:37:24 +0200 Subject: [PATCH] fix(autotag): the orphan cleanup keeps a system tag a saved view filters on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feed views store tag ids. A system tag no channel carried for a while was deleted and, once a channel earned it again, recreated under a new id — the view's filter then pointed at nothing, the feed dropped the unknown id, and the view showed the whole feed under its old name. The cleanup now skips tags a saved feed view references (test seen red without it). --- backend/app/sync/autotag.py | 14 +++++++++-- backend/tests/test_autotag_orphans.py | 36 +++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_autotag_orphans.py diff --git a/backend/app/sync/autotag.py b/backend/app/sync/autotag.py index 39ce4ac9..5ce0ba07 100644 --- a/backend/app/sync/autotag.py +++ b/backend/app/sync/autotag.py @@ -12,7 +12,7 @@ from sqlalchemy import and_, exists, func, select from sqlalchemy.orm import Session from app import logs, progress, sysconfig -from app.models import Channel, ChannelTag, Tag, Video +from app.models import Channel, ChannelTag, SavedView, Tag, Video log = logs.get_logger(__name__) @@ -289,12 +289,22 @@ def run_autotag_all(db: Session, only_missing: bool = False) -> dict: def _cleanup_orphan_system_tags(db: Session) -> int: - """Delete system tags that no longer link to any channel (e.g. after a mapping change).""" + """Delete system tags that no longer link to any channel (e.g. after a mapping change) — but not + one a saved feed view filters on. Views hold tag IDS, and a deleted tag comes back under a new id + when a channel earns it again, so the view's filter would point at nothing; the feed drops an + unknown id, and the view showed the whole feed under its old name (E32 S2).""" + in_views = { + t + for (filters,) in db.execute(select(SavedView.filters).where(SavedView.source == "feed")) + for t in ((filters or {}).get("tags") or []) + if isinstance(t, int) + } orphans = ( db.execute( select(Tag).where( Tag.user_id.is_(None), ~exists().where(ChannelTag.tag_id == Tag.id), + Tag.id.notin_(in_views), ) ) .scalars() diff --git a/backend/tests/test_autotag_orphans.py b/backend/tests/test_autotag_orphans.py new file mode 100644 index 00000000..ef2b2759 --- /dev/null +++ b/backend/tests/test_autotag_orphans.py @@ -0,0 +1,36 @@ +"""sync/autotag's orphan cleanup (E32 S2: the module was 32% covered). + +A system tag no channel carries any more is deleted — unless a saved feed view filters on it. The +views hold tag ids; a deleted tag returns under a NEW id when a channel earns it again, and the feed +drops an unknown id, so the view silently showed the whole feed under its old name. +""" + +from sqlalchemy import select + +from app.models import Channel, ChannelTag, SavedView, Tag +from app.sync import autotag + + +def test_an_orphan_system_tag_goes_but_one_a_saved_view_filters_on_stays(db, make_user): + user = make_user() + used = Tag(user_id=None, name="Gaming", category="topic") + orphan = Tag(user_id=None, name="Cooking", category="topic") + in_view = Tag(user_id=None, name="Music", category="topic") + own = Tag(user_id=user.id, name="Mine", category="other") + ch = Channel(id="UC_tags", title="T") + db.add_all([used, orphan, in_view, own, ch]) + db.flush() + db.add(ChannelTag(channel_id=ch.id, tag_id=used.id, user_id=None, source="auto")) + db.add( + SavedView( + user_id=user.id, source="feed", name="Music", filters={"tags": [in_view.id], "q": ""} + ) + ) + # A Plex view's filters are another vocabulary; they must not break the scan. + db.add(SavedView(user_id=user.id, source="plex", name="P", filters={"genres": ["Drama"]})) + db.commit() + + assert autotag._cleanup_orphan_system_tags(db) == 1 + db.expire_all() + names = set(db.execute(select(Tag.name)).scalars()) + assert names == {"Gaming", "Music", "Mine"} # only the unreferenced system orphan went