From 4ee0dab68f0636a7f07bbaa6ce647661fe292db1 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 25 Aug 2026 01:44:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(feed,channels):=20"No=20tag"=20filter=20fo?= =?UTF-8?q?r=20untagged=20channels=20(E8=C2=B7S1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/app/feedquery.py | 28 +++++++-- backend/app/routes/feed.py | 3 + backend/tests/test_feed_views.py | 34 +++++++++++ frontend/src/components/Channels.tsx | 18 +++++- .../src/components/FeedFiltersProvider.tsx | 9 ++- .../src/components/SavedViewsProvider.tsx | 2 + frontend/src/components/Sidebar.tsx | 60 +++++++++++++------ .../components/channelColumns.dom.test.tsx | 24 +++++++- frontend/src/components/channelColumns.tsx | 12 +++- frontend/src/i18n/locales/en/channels.json | 3 +- frontend/src/i18n/locales/en/feed.json | 1 + frontend/src/i18n/locales/en/sidebar.json | 1 + frontend/src/i18n/locales/hu/channels.json | 3 +- frontend/src/i18n/locales/hu/feed.json | 1 + frontend/src/i18n/locales/hu/sidebar.json | 1 + frontend/src/lib/api.ts | 8 ++- frontend/src/lib/api/feed.ts | 4 ++ frontend/src/lib/api/tagModes.test.ts | 14 ++++- frontend/src/lib/api/tagModes.ts | 10 ++++ frontend/src/lib/urlState.dom.test.ts | 8 +++ frontend/src/lib/urlState.ts | 3 + frontend/src/lib/useActiveFilters.ts | 10 ++++ 22 files changed, 223 insertions(+), 34 deletions(-) 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 && (