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 && (