diff --git a/backend/app/feedquery.py b/backend/app/feedquery.py
index 87aa2be9..c432917c 100644
--- a/backend/app/feedquery.py
+++ b/backend/app/feedquery.py
@@ -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
diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py
index cedf2b21..e694f15c 100644
--- a/backend/app/routes/feed.py
+++ b/backend/app/routes/feed.py
@@ -53,6 +53,8 @@ def _feed_params(
tag_mode_language: str = "or",
tag_mode_topic: str = "or",
tag_mode_other: str = "or",
+ # "No tag" filter: keep only channels the user has not personally tagged (no user-owned ChannelTag).
+ untagged: bool = False,
channel_ids: list[str] = Query(default=[]),
q: str | None = None,
min_duration: int | None = None,
@@ -74,6 +76,7 @@ def _feed_params(
"topic": tag_mode_topic,
"other": tag_mode_other,
},
+ "untagged": untagged,
"channel_ids": channel_ids,
"q": q,
"min_duration": min_duration,
diff --git a/backend/tests/test_feed_views.py b/backend/tests/test_feed_views.py
index 90c197b5..deaa3cd6 100644
--- a/backend/tests/test_feed_views.py
+++ b/backend/tests/test_feed_views.py
@@ -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,
diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx
index 7abe86c0..46388acf 100644
--- a/frontend/src/components/Channels.tsx
+++ b/frontend/src/components/Channels.tsx
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } from "lucide-react";
import ExcludedChannelList from "./ExcludedChannelList";
-import { api, type ManagedChannel } from "../lib/api";
+import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage";
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
@@ -217,11 +217,18 @@ export default function Channels() {
// funnel share ONE state and stay in sync (bidirectional). Toggling a chip rewrites `filters.tags`;
// filterRows / DataTable then applies it. Channel-name search stays separate (the shared top box).
const tagFilter = Array.isArray(filters.tags) ? filters.tags : [];
+ // Picking a real tag drops the "No tag" sentinel (item B) — they're mutually exclusive, as in the feed.
const toggleTagFilter = (tagId: number) => {
const id = String(tagId);
- const next = tagFilter.includes(id) ? tagFilter.filter((x) => x !== id) : [...tagFilter, id];
+ const base = tagFilter.filter((x) => x !== NO_TAG_SENTINEL);
+ const next = base.includes(id) ? base.filter((x) => x !== id) : [...base, id];
setFilters({ ...filters, tags: next });
};
+ // The "No tag" chip: filter the table to channels you haven't personally tagged yet. Exclusive with
+ // real tag chips (clicking it drops them); the Tags column's test reads the sentinel (channelColumns).
+ const noTagOn = tagFilter.includes(NO_TAG_SENTINEL);
+ const toggleNoTagFilter = () =>
+ setFilters({ ...filters, tags: noTagOn ? [] : [NO_TAG_SENTINEL] });
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
useEffect(() => {
if (focusChannelName) setSearch(focusChannelName);
@@ -451,6 +458,13 @@ export default function Channels() {
{tg.name}
))}
+
{tagFilter.length > 0 && (