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
+24 -4
View File
@@ -100,6 +100,11 @@ def serialize(row) -> dict:
}
def _tag_visible(user: User):
"""A ChannelTag row this user may see: a shared system tag (user_id NULL) or their own."""
return or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
def tags_by_category(db: Session, tag_ids: list[int]) -> dict[str, list[int]]:
"""Group tag ids by their category (language/topic/other). The single source for the category split
shared by the feed's tag WHERE (filtered_query) and the facet/count summary (feed_summary), so the
@@ -118,6 +123,7 @@ def filtered_query(
*,
tags: list[int],
tag_modes: dict[str, str],
untagged: bool = False,
channel_ids: list[str],
q: str | None,
min_duration: int | None,
@@ -294,7 +300,7 @@ def filtered_query(
# selections but still honours the other categories' filters).
if exclude_tag_category:
by_category.pop(exclude_tag_category, None)
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
visible = _tag_visible(user)
for category, ids in by_category.items():
if tag_modes.get(category, "or") == "and" and len(ids) > 1:
sub = (
@@ -307,6 +313,18 @@ def filtered_query(
sub = select(ChannelTag.channel_id).where(ChannelTag.tag_id.in_(ids), visible)
query = query.where(Video.channel_id.in_(sub))
# "No tag" filter (item B): keep only channels the USER has not personally tagged in the channel
# manager — i.e. channels with no ChannelTag row owned by this user (user_id == me, source "user").
# System/auto tags (user_id NULL) don't count: this is "channels I haven't tagged yet". It's a plain
# channel-level narrowing (like channel_ids/date), NOT a tag-category selection, so feed_summary
# leaves it applied in the base pass and needs no mirror in passes().
if untagged:
query = query.where(
Video.channel_id.notin_(
select(ChannelTag.channel_id).where(ChannelTag.user_id == user.id)
)
)
# Content type: Normal / Shorts / Live·Upcoming as a union of enabled types.
# Explicit Watched/Saved/Hidden views show every type so nothing goes missing.
explicit_view = show in ("watched", "hidden")
@@ -355,8 +373,9 @@ def feed_summary(db: Session, user: User, **params) -> dict:
within, a category in "and" mode requires ALL its selected tags)."""
tag_modes = params.get("tag_modes", {})
# ONE pass: per-channel video counts for the base filter, tags stripped so every candidate channel
# is present to facet over.
# ONE pass: per-channel video counts for the base filter, the positive tag filter stripped so every
# candidate channel is present to facet over. The "No tag" (untagged) filter is a plain channel-level
# narrowing, so it STAYS applied here (not stripped) and the facets naturally drill down within it.
base, _rank = filtered_query(db, user, **{**params, "tags": []})
ch_col = base.with_only_columns(Video.channel_id).subquery()
channel_counts: dict[str, int] = dict(
@@ -386,7 +405,8 @@ def feed_summary(db: Session, user: User, **params) -> dict:
def passes(ch_tags: set[int], exclude_cat: str | None) -> bool:
# The Python mirror of filtered_query's SQL tag block (AND across categories, OR within,
# topic-AND requires all selected) — keep in lockstep; see the ⚠️ note there.
# topic-AND requires all selected) — keep in lockstep; see the ⚠️ note there. (The "No tag"
# filter is applied in the base query above, so it needs no mirror here.)
for cat, sel in selected_by_cat.items():
if cat == exclude_cat:
continue