diff --git a/backend/app/feedquery.py b/backend/app/feedquery.py
index 84cfe50f..6e6d8714 100644
--- a/backend/app/feedquery.py
+++ b/backend/app/feedquery.py
@@ -106,15 +106,26 @@ def _tag_visible(user: User):
return or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
+# The pseudo-category of a selected tag id no Tag row answers (a saved view or the feed state outlives
+# a deleted tag). It filters like a category whose tags no channel carries — to nothing, in the SQL and
+# in feed_summary's mirror alike — instead of the id being dropped, which showed the WHOLE feed under
+# the view's old name (E32 S2). No real category is called this (language/topic/other).
+MISSING_TAG_CATEGORY = "missing"
+
+
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
- two can't disagree on which selected tag belongs to which category."""
+ two can't disagree on which selected tag belongs to which category. An unknown id lands in
+ MISSING_TAG_CATEGORY."""
if not tag_ids:
return {} # skip the pointless `WHERE id IN ()` round-trip on the no-tag-filter hot path
out: dict[str, list[int]] = {}
for tid, cat in db.execute(select(Tag.id, Tag.category).where(Tag.id.in_(tag_ids))).all():
out.setdefault(cat, []).append(tid)
+ known = {tid for ids in out.values() for tid in ids}
+ if missing := [tid for tid in tag_ids if tid not in known]:
+ out[MISSING_TAG_CATEGORY] = missing
return out
diff --git a/backend/tests/test_feed_views.py b/backend/tests/test_feed_views.py
index af190e8c..9b647160 100644
--- a/backend/tests/test_feed_views.py
+++ b/backend/tests/test_feed_views.py
@@ -168,9 +168,30 @@ _SUMMARY_CASES = [
"show=all&untagged=true&tags={L1}",
"show=all&q=clip",
"show=unwatched",
+ # A deleted tag's id (a saved view or the feed state still holds it) matches nothing.
+ "show=all&tags=987654",
+ "show=all&tags={L1}&tags=987654",
]
+def test_a_deleted_tags_id_filters_to_nothing_not_to_everything(db, as_user, make_user):
+ """A saved view or the feed's filter state keeps a tag id after the tag is deleted. The feed used
+ to drop an unknown id, so that view silently showed the WHOLE feed under its old name (E32 S2).
+ A filter on a tag that no longer exists matches nothing; the chip says the tag is gone."""
+ user, tag_ids = _seed_tagged(db, make_user)
+ client = as_user(user)
+ everything = client.get("/api/feed/count?show=all")
+ assert everything.status_code == 200
+ assert everything.json()["count"] == 8
+ for qs in ("show=all&tags=987654", f"show=all&tags={tag_ids['L1']}&tags=987654"):
+ r = client.get(f"/api/feed/count?{qs}")
+ assert r.status_code == 200
+ assert r.json()["count"] == 0, qs
+ r = client.get(f"/api/feed?{qs}")
+ assert r.status_code == 200
+ assert r.json()["items"] == [], qs
+
+
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
diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx
index 9048789f..e520b459 100644
--- a/frontend/src/components/Channels.tsx
+++ b/frontend/src/components/Channels.tsx
@@ -16,7 +16,7 @@ import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
import { Sparkles } from "lucide-react";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage";
-import { buildChannelColumns, applySyncFilter } from "./channelColumns";
+import { buildChannelColumns, applySyncFilter, tagFilterChips } from "./channelColumns";
import { notify } from "../lib/notifications";
import { Badge } from "./ui/Badge";
import { Button } from "./ui/Button";
@@ -39,7 +39,7 @@ import { focusNavItem } from "../lib/listNav";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
import ColumnSortControl from "./ui/ColumnSortControl";
import FilterChipGroup from "./ui/FilterChipGroup";
-import TagFilterChips, { type FilterChip } from "./TagFilterChips";
+import TagFilterChips from "./TagFilterChips";
import ActionMenu, { type ActionMenuItem } from "./ui/ActionMenu";
import ChannelStatsPopover from "./ChannelStatsPopover";
import Pager from "./ui/Pager";
@@ -345,22 +345,17 @@ export default function Channels() {
const noTagOn = tagFilter.includes(NO_TAG_SENTINEL);
const toggleNoTagFilter = () =>
setFilters({ ...filters, tags: noTagOn ? [] : [NO_TAG_SENTINEL] });
- // The tag-filter chips (user tags + the "No tag" sentinel), fed to TagFilterChips which folds any
- // overflow into a "+N" pill so the row stays one line and the trailing "…" actions can't wrap.
- const tagFilterChips: FilterChip[] = [
- ...userTags.map((tg) => ({
- key: `t${tg.id}`,
- label: tg.name,
- active: tagFilter.includes(String(tg.id)),
- onToggle: () => toggleTagFilter(tg.id),
- })),
- {
- key: NO_TAG_SENTINEL,
- label: t("channels.tags.noTag"),
- active: noTagOn,
- onToggle: toggleNoTagFilter,
- },
- ];
+ // The tag-filter chips, fed to TagFilterChips which folds any overflow into a "+N" pill so the row
+ // stays one line and the trailing "…" actions can't wrap.
+ const tagChips = tagFilterChips({
+ userTags,
+ allTags,
+ picked: tagFilter,
+ noTagOn,
+ toggle: toggleTagFilter,
+ toggleNoTag: toggleNoTagFilter,
+ t,
+ });
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
// The TOKEN is the intent (it advances even when the same channel is re-focused); the name is the
// payload, so it is read through an effect event. Naming it would re-seed the box on any render that
@@ -735,7 +730,7 @@ export default function Channels() {
- {!fold.folded && }
+ {!fold.folded && }
{!fold.folded && tagFilter.length > 0 && (