feat(feed,channels): "No tag" filter for untagged channels (E8·S1)

Filter to channels you haven't personally tagged in the channel manager
(no user-owned ChannelTag; auto language/topic tags don't count):

- Feed: a "No tag" sentinel in the "Your tags" sidebar combobox (exclusive
  with real tag picks) + a removable "Untagged" chip; new `untagged` bool
  feed param / `notag` URL key, persisted + coerced on every filter path.
- Channel manager: a virtual "No tag" chip in the tag-chip row filters the
  table to untagged channels (client-side, shared NO_TAG_SENTINEL).

Backend `untagged` is a plain channel-level narrowing, so feed_summary keeps
it applied in the base pass — the count/facet lockstep holds with no mirror.

Note: item E (per-tag counts) and item D (Any/All toggle) were already
shipped; S1 delivers only item B.
This commit is contained in:
2026-08-25 01:44:34 +02:00
parent 5d7be687d3
commit 4ee0dab68f
22 changed files with 223 additions and 34 deletions
+34
View File
@@ -162,11 +162,45 @@ _SUMMARY_CASES = [
"show=all&tags={O1}&tags={O2}&tag_mode_other=and",
"show=all&tags={U1}", # user-owned tag (visibility == user.id branch)
"show=all&tags={U1}&tags={L1}", # user tag AND a system tag, cross-category
# "No tag" filter (item B): channels with no USER tag. It stays applied in the base pass, so the
# facet/count lockstep must still hold with it on (alone, and combined with a positive tag filter).
"show=all&untagged=true",
"show=all&untagged=true&tags={L1}",
"show=all&q=clip",
"show=unwatched",
]
def test_untagged_filter_keeps_only_channels_the_user_has_not_tagged(db, as_user, make_user):
"""The "No tag" filter (item B): `untagged=true` keeps only channels with NO tag the USER personally
attached (no user-owned ChannelTag). System/auto tags (user_id NULL) don't count, and another user's
tag on the same channel doesn't either."""
user = make_user(email="untagged@example.com")
other = make_user(email="someone-else@example.com")
mine = Tag(user_id=user.id, name="Fav", category="other")
sys_topic = Tag(user_id=None, name="Tech", category="topic")
theirs = Tag(user_id=other.id, name="TheirTag", category="other")
db.add_all([mine, sys_topic, theirs])
chans = {c: Channel(id=c, title=c, subscriber_count=10) for c in ("chMine", "chSys", "chNone")}
db.add_all(chans.values())
db.flush()
# chMine: the user's own tag → tagged. chSys: only a system tag + another user's tag → still
# "untagged" for THIS user. chNone: nothing at all → untagged.
db.add(ChannelTag(channel_id="chMine", tag_id=mine.id, user_id=user.id, source="user"))
db.add(ChannelTag(channel_id="chSys", tag_id=sys_topic.id, user_id=None, source="auto"))
db.add(ChannelTag(channel_id="chSys", tag_id=theirs.id, user_id=other.id, source="user"))
for cid in chans:
db.add(Subscription(user_id=user.id, channel_id=cid, priority=0, hidden=False))
db.add(_video(f"{cid}v", cid, f"{cid} clip"))
db.commit()
client = as_user(user)
# Off: every subscribed channel shows.
assert _feed_ids(client, "show=all") == {"chMinev", "chSysv", "chNonev"}
# On: only channels the user has NOT personally tagged (a system tag / another user's tag don't count).
assert _feed_ids(client, "show=all&untagged=true") == {"chSysv", "chNonev"}
@pytest.mark.parametrize("case", _SUMMARY_CASES)
def test_feed_summary_matches_count_plus_facets(db, as_user, make_user, case):
# /feed/summary must return byte-identical numbers to the old /feed/count + /facets it replaces,