diff --git a/backend/app/feedquery.py b/backend/app/feedquery.py index 2703583e..e3bb5254 100644 --- a/backend/app/feedquery.py +++ b/backend/app/feedquery.py @@ -117,7 +117,7 @@ def filtered_query( user: User, *, tags: list[int], - tag_mode: str, + tag_modes: dict[str, str], channel_ids: list[str], q: str | None, min_duration: int | None, @@ -293,9 +293,10 @@ def filtered_query( query = query.where(or_(func.unaccent(Video.title).ilike(like), channel_match)) if tags: - # AND across tag categories (e.g. language AND topic narrows), OR within a category; the any/all - # toggle controls multiple topic tags. ⚠️ feed_summary.passes() reimplements this exact rule in - # Python for the sidebar count/facets — keep the two in lockstep (equivalence-tested by + # AND across tag categories (e.g. language AND topic narrows), OR within a category — unless that + # category's mode is "and" (its per-category any/all toggle), which requires a channel to carry + # ALL its selected tags. ⚠️ feed_summary.passes() reimplements this exact rule in Python for the + # sidebar count/facets — keep the two in lockstep (equivalence-tested by # test_feed_summary_matches_count_plus_facets). by_category = tags_by_category(db, tags) # Facet counting drops the category being counted so its own chips don't zero each @@ -305,7 +306,7 @@ def filtered_query( by_category.pop(exclude_tag_category, None) visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id) for category, ids in by_category.items(): - if category == "topic" and tag_mode == "and" and len(ids) > 1: + if tag_modes.get(category, "or") == "and" and len(ids) > 1: sub = ( select(ChannelTag.channel_id) .where(ChannelTag.tag_id.in_(ids), visible) @@ -363,8 +364,8 @@ def feed_summary(db: Session, user: User, **params) -> dict: (the tag's own category dropped, unless it's a conjunctive topic-AND — mirroring the old /facets exclude_tag_category logic exactly). Tag matching is channel-based, identical to filtered_query's tag block (AND across categories, OR - within, topic-AND requires ALL selected topics).""" - tag_mode = params.get("tag_mode", "or") + 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. @@ -401,8 +402,8 @@ def feed_summary(db: Session, user: User, **params) -> dict: for cat, sel in selected_by_cat.items(): if cat == exclude_cat: continue - if cat == "topic" and tag_mode == "and" and len(sel) > 1: - if not sel <= ch_tags: # conjunctive: channel must carry ALL selected topics + if tag_modes.get(cat, "or") == "and" and len(sel) > 1: + if not sel <= ch_tags: # conjunctive: channel must carry ALL this category's selected tags return False elif not (sel & ch_tags): # disjunctive: channel must carry ANY selected tag return False @@ -416,7 +417,7 @@ def feed_summary(db: Session, user: User, **params) -> dict: for category in ("language", "topic", "other"): # Drop this category's own selections so its chips keep independent counts; a conjunctive # topic-AND keeps them (each remaining chip narrows to channels that ALSO have the selected). - exclude = None if (category == "topic" and tag_mode == "and") else category + exclude = None if tag_modes.get(category, "or") == "and" else category for cid, ch_tags in channel_tags.items(): if not passes(ch_tags, exclude): continue diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index c3ec31d4..75676c18 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -48,7 +48,11 @@ FINISH_MARGIN_SECONDS = 10 # Shared query parameters for /feed and /feed/count. def _feed_params( tags: list[int] = Query(default=[]), - tag_mode: str = "or", + # Per-category tag match: each of language / topic / other independently ORs (any) or ANDs (all) + # its own selected tags. Defaults to "or". (Superseded the single global `tag_mode`.) + tag_mode_language: str = "or", + tag_mode_topic: str = "or", + tag_mode_other: str = "or", channel_ids: list[str] = Query(default=[]), q: str | None = None, min_duration: int | None = None, @@ -65,7 +69,11 @@ def _feed_params( ) -> dict: return { "tags": tags, - "tag_mode": tag_mode, + "tag_modes": { + "language": tag_mode_language, + "topic": tag_mode_topic, + "other": tag_mode_other, + }, "channel_ids": channel_ids, "q": q, "min_duration": min_duration, @@ -162,7 +170,7 @@ def get_facets( # independent counts (you can OR more of them in). Conjunctive (AND, topics only) # keeps them applied, so each remaining chip narrows to channels that ALSO have all # already-selected topics — and tags that can't co-occur drop to zero (hidden). - conjunctive = category == "topic" and params.get("tag_mode") == "and" + conjunctive = params.get("tag_modes", {}).get(category) == "and" base, _rank = filtered_query( db, user, diff --git a/backend/tests/test_feed_views.py b/backend/tests/test_feed_views.py index 1a3eefd4..a8d04736 100644 --- a/backend/tests/test_feed_views.py +++ b/backend/tests/test_feed_views.py @@ -127,8 +127,8 @@ _SUMMARY_CASES = [ "show=all&tags={L1}", "show=all&tags={T1}", "show=all&tags={L1}&tags={T1}", - "show=all&tags={T1}&tags={T2}&tag_mode=or", - "show=all&tags={T1}&tags={T2}&tag_mode=and", + "show=all&tags={T1}&tags={T2}&tag_mode_topic=or", + "show=all&tags={T1}&tags={T2}&tag_mode_topic=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 "show=all&q=clip", diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index b3516082..b23e49af 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -27,7 +27,7 @@ import { useFeedFilters } from "./FeedFiltersProvider"; import { useConfirm } from "./ConfirmProvider"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; -import { api, type FeedFilters, type Me, type ChannelDetail } from "../lib/api"; +import { api, DEFAULT_TAG_MODES, type FeedFilters, type Me, type ChannelDetail } from "../lib/api"; import type { FeedView } from "../lib/feedView"; import { channelYouTubeUrl, formatViews } from "../lib/format"; import { ChannelTagsControl } from "./ChannelTags"; @@ -231,7 +231,7 @@ export default function ChannelPage({ // the explored, per-user-private videos show — the backend gates them to this explorer). const [filters, setFilters] = useState(() => ({ tags: [], - tagMode: "or", + tagModes: DEFAULT_TAG_MODES, q: "", sort: "newest", scope: "all", diff --git a/frontend/src/components/Combobox.tsx b/frontend/src/components/Combobox.tsx index 963a64a1..4325b3ac 100644 --- a/frontend/src/components/Combobox.tsx +++ b/frontend/src/components/Combobox.tsx @@ -55,6 +55,11 @@ export interface ComboboxProps { /** Chip/label text for a selected value whose option is not in the current (e.g. server-narrowed) * list — the collection picker passes the stored title here so a reloaded filter still names itself. */ selectedLabels?: Record; + /** Optional "any/all" match segment in the popover footer (like SortControl's direction segment): + * "or" = a match on ANY selected value, "and" = must match ALL. Provide BOTH to show it; it appears + * only once 2+ values are selected (below that, any and all are the same). */ + matchMode?: "or" | "and"; + onMatchModeChange?: (mode: "or" | "and") => void; } export default function Combobox({ @@ -73,6 +78,8 @@ export default function Combobox({ maxVisible = 50, renderOption, selectedLabels, + matchMode, + onMatchModeChange, }: ComboboxProps) { const { t } = useTranslation(); const [open, setOpen] = useState(false); @@ -282,6 +289,32 @@ export default function Combobox({ )} + {/* Any/all match segment (like SortControl's direction footer) — only meaningful, so only shown, + once 2+ values are picked. mousedown-preventDefault keeps the input focused so the list stays + open in the combobox's focus model. */} + {matchMode != null && onMatchModeChange && selected.length >= 2 && ( +
+ {t("sidebar.match")} +
+ {(["or", "and"] as const).map((m) => ( + + ))} +
+
+ )} ); diff --git a/frontend/src/components/FeedFiltersProvider.tsx b/frontend/src/components/FeedFiltersProvider.tsx index 3156533a..e59cb362 100644 --- a/frontend/src/components/FeedFiltersProvider.tsx +++ b/frontend/src/components/FeedFiltersProvider.tsx @@ -9,7 +9,7 @@ import { } from "react"; import { qk } from "../lib/queryKeys"; import { useQuery } from "@tanstack/react-query"; -import { api, getActiveAccount, type FeedFilters } from "../lib/api"; +import { api, getActiveAccount, DEFAULT_TAG_MODES, type FeedFilters } from "../lib/api"; import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState"; import { useUrlMirror } from "../lib/useUrlMirror"; import { accountKey, LS, readJSON, readMerged, writeJSON } from "../lib/storage"; @@ -32,7 +32,7 @@ import { useNavigation } from "./NavigationProvider"; const DEFAULT_FILTERS: FeedFilters = { tags: [], - tagMode: "or", + tagModes: DEFAULT_TAG_MODES, channelIds: [], q: "", sort: "newest", diff --git a/frontend/src/components/SavedViewsWidget.tsx b/frontend/src/components/SavedViewsWidget.tsx index ac61b64f..5ce32f65 100644 --- a/frontend/src/components/SavedViewsWidget.tsx +++ b/frontend/src/components/SavedViewsWidget.tsx @@ -18,7 +18,13 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { api, getActiveAccount, type FeedFilters, type SavedView } from "../lib/api"; +import { + api, + getActiveAccount, + coerceTagModes, + type FeedFilters, + type SavedView, +} from "../lib/api"; import { filtersToParams } from "../lib/urlState"; import { notifyActionError } from "../lib/actionErrors"; import { accountKey, LS, writeJSON } from "../lib/storage"; @@ -37,6 +43,8 @@ const asFilters = (raw: FeedFilters): FeedFilters => ({ ...raw, tags: raw.tags ?? [], channelIds: raw.channelIds ?? [], + // Migrate the legacy global `tagMode` (older blobs) into the per-category shape. + tagModes: coerceTagModes(raw), }); function syncDefaultMirror(views: SavedView[]): void { diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 1b549101..24b46963 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { Pencil, SlidersHorizontal } from "lucide-react"; -import { api, type FeedFilters, type Tag } from "../lib/api"; +import { api, type FeedFilters, type Tag, type TagCategory } from "../lib/api"; import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; import { useDebounced } from "../lib/useDebounced"; import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters"; @@ -14,7 +14,6 @@ import FilterRail from "./FilterRail"; import { type PanelItem } from "./PanelGroups"; import { type MediaSourceKind } from "../lib/mediaSource"; import ChannelPicker from "./ChannelPicker"; -import SegmentedToggle from "./SegmentedToggle"; import FilterChipGroup from "./FilterChipGroup"; import Combobox, { type ComboboxOption } from "./Combobox"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; @@ -97,6 +96,9 @@ export default function Sidebar({ tags: [...filters.tags.filter((id) => !inList.has(id)), ...next.map(Number)], }); } + // Each tag category (language/topic/other) has its OWN any/all match, set from its combobox footer. + const setTagMode = (category: TagCategory, mode: "or" | "and") => + setFilters({ ...filters, tagModes: { ...filters.tagModes, [category]: mode } }); // The badge counts exactly what the chip row above the feed shows — same list, so they can't // disagree about what's applied. @@ -200,31 +202,22 @@ export default function Sidebar({ selected={selectedIn(languages)} onChange={(next) => setTagSelection(languages, next)} emptyText={t("sidebar.noMatchingTags")} + matchMode={filters.tagModes.language} + onMatchModeChange={(m) => setTagMode("language", m)} /> ); } case "topic": { return ( -
- setFilters({ ...filters, tagMode })} - /> - setTagSelection(topics, next)} - emptyText={t("sidebar.noMatchingTags")} - /> -
+ setTagSelection(topics, next)} + emptyText={t("sidebar.noMatchingTags")} + matchMode={filters.tagModes.topic} + onMatchModeChange={(m) => setTagMode("topic", m)} + /> ); } case "tags": { @@ -236,6 +229,8 @@ export default function Sidebar({ selected={selectedIn(userTags)} onChange={(next) => setTagSelection(userTags, next)} emptyText={t("sidebar.noMatchingTags")} + matchMode={filters.tagModes.other} + onMatchModeChange={(m) => setTagMode("other", m)} />