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