From 79e7694b24fc9acde26d03bbfe4402f64c4e6080 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 12:05:53 +0200 Subject: [PATCH 1/5] feat(feed): /api/facets endpoint for contextual tag counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a facets endpoint that returns per-tag channel counts for the current filter context (scope, channel, date, content type, search, watch state, and the other category's tags). Each category is counted with its own selections ignored — standard drill-down faceting — by a new exclude_tag_category param threaded into _filtered_query, so selecting one topic doesn't zero out the other topics. Count is distinct channels with a matching video, keeping the channel-count chip semantics. Reuses the feed's filter query so both stay in lockstep. --- backend/app/routes/feed.py | 41 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index fc0b8b3..08f2ecc 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -67,6 +67,7 @@ def _filtered_query( include_live: bool, show: str, scope: str = "my", + exclude_tag_category: str | None = None, ) -> tuple[Select, object]: """Build the feed query (joins + all WHERE filters), shared by /feed and /feed/count. Returns the column-bearing select plus the watch-status expression for sorting. @@ -153,6 +154,11 @@ def _filtered_query( by_category: dict[str, list[int]] = {} for tag_id, category in cat_rows: by_category.setdefault(category, []).append(tag_id) + # Facet counting drops the category being counted so its own chips don't zero each + # other out (standard drill-down faceting: a category's count ignores its own + # 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) for category, ids in by_category.items(): if category == "topic" and tag_mode == "and" and len(ids) > 1: @@ -294,6 +300,41 @@ def get_feed_count( return {"count": total or 0} +@router.get("/facets") +def get_facets( + params: dict = Depends(_feed_params), + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Per-tag channel counts for the *current* filter context, so the sidebar can show + live counts and drop chips that no longer match anything. Each category is counted with + every other filter applied (scope, channel, date, content type, search, watch state, + and the other category's tags) but its own selections ignored — standard drill-down + faceting, so selecting one topic doesn't zero out the rest of the topics. + + The count is the number of distinct channels that have at least one video in the current + view, matching the existing channel-count chip semantics. JSON object keys are strings + (tag ids).""" + visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id) + counts: dict[int, int] = {} + for category in ("language", "topic"): + base, _status = _filtered_query( + db, user, **{**params, "exclude_tag_category": category} + ) + channels = base.with_only_columns(Video.channel_id).distinct().subquery() + rows = db.execute( + select(ChannelTag.tag_id, func.count(func.distinct(ChannelTag.channel_id))) + .select_from(channels) + .join(ChannelTag, ChannelTag.channel_id == channels.c.channel_id) + .join(Tag, and_(Tag.id == ChannelTag.tag_id, Tag.category == category)) + .where(visible) + .group_by(ChannelTag.tag_id) + ).all() + for tag_id, count in rows: + counts[tag_id] = count + return {"counts": counts} + + @router.post("/videos/{video_id}/state") def set_video_state( video_id: str, From 79f53ccf59206dae2e483e28a4e95b14941a4058 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 12:06:02 +0200 Subject: [PATCH 2/5] feat(filters): dynamic faceted chips driven by /api/facets Topic and language chips now show live channel counts for the current filter context instead of the static global count, and chips that match nothing are hidden (selected chips stay so they can be cleared). Selecting a channel (or any filter) drops the now-irrelevant chips and updates the rest. Extract a shared filterParams() so the feed and facets queries see identical filters; the facets query is keyed on filters so it refetches as they change. Trilingual empty-state string when a category has no matching tags. --- frontend/src/components/Sidebar.tsx | 62 ++++++++++++++++++----- frontend/src/i18n/locales/de/sidebar.json | 1 + frontend/src/i18n/locales/en/sidebar.json | 1 + frontend/src/i18n/locales/hu/sidebar.json | 1 + frontend/src/lib/api.ts | 15 ++++-- 5 files changed, 63 insertions(+), 17 deletions(-) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 3ab6c97..1e5a452 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -74,10 +74,12 @@ const DEFAULT_SIDEBAR_FILTERS: Omit = { function TagChip({ tag, + count, active, onClick, }: { tag: Tag; + count: number; active: boolean; onClick: () => void; }) { @@ -85,7 +87,7 @@ function TagChip({ return ( ); @@ -119,6 +121,26 @@ export default function Sidebar({ const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const tags = tagsQuery.data ?? []; + // Live per-tag channel counts for the current filter context (scope, channel, date, + // search, watch state, other category's tags). Lets us show contextual counts and hide + // chips that no longer match anything. Keyed on filters so it refetches as they change. + const facetsQuery = useQuery({ + queryKey: ["facets", filters], + queryFn: () => api.facets(filters), + staleTime: 30_000, + }); + const facetsReady = !!facetsQuery.data; + const facetCounts = facetsQuery.data?.counts ?? {}; + // Before facets load, fall back to the static global count so chips don't flash empty. + const chipCount = (tag: Tag): number => + facetsReady ? facetCounts[String(tag.id)] ?? 0 : tag.channel_count; + // Visible chips: hide zero-count ones once facets are in, but always keep selected ones + // so an active filter can still be cleared. + const visibleChips = (list: Tag[]): Tag[] => + facetsReady + ? list.filter((tg) => chipCount(tg) > 0 || filters.tags.includes(tg.id)) + : list; + // After a page refresh the channel name isn't in the URL (only the id is), so // filters.channelName is undefined and the chip would fall back to "This channel". // Resolve the title from the (cached) channel list keyed by id. Only fetch when a @@ -362,20 +384,27 @@ export default function Sidebar({ /> ); - case "language": + case "language": { + const chips = visibleChips(languages); return (
- {languages.map((t) => ( + {chips.map((tg) => ( toggleTag(t.id)} + key={tg.id} + tag={tg} + count={chipCount(tg)} + active={filters.tags.includes(tg.id)} + onClick={() => toggleTag(tg.id)} /> ))} + {facetsReady && chips.length === 0 && ( + {t("sidebar.noMatchingTags")} + )}
); - case "topic": + } + case "topic": { + const chips = visibleChips(topics); return ( <>
@@ -390,17 +419,22 @@ export default function Sidebar({
- {topics.map((t) => ( + {chips.map((tg) => ( toggleTag(t.id)} + key={tg.id} + tag={tg} + count={chipCount(tg)} + active={filters.tags.includes(tg.id)} + onClick={() => toggleTag(tg.id)} /> ))} + {facetsReady && chips.length === 0 && ( + {t("sidebar.noMatchingTags")} + )}
); + } } } diff --git a/frontend/src/i18n/locales/de/sidebar.json b/frontend/src/i18n/locales/de/sidebar.json index 221aa2f..11ccab4 100644 --- a/frontend/src/i18n/locales/de/sidebar.json +++ b/frontend/src/i18n/locales/de/sidebar.json @@ -24,6 +24,7 @@ "to": "Bis", "clearDates": "Daten löschen", "reshuffle": "Neu mischen", + "noMatchingTags": "Keine passenden Tags", "widget": { "show": "Anzeigen", "sort": "Sortierung", diff --git a/frontend/src/i18n/locales/en/sidebar.json b/frontend/src/i18n/locales/en/sidebar.json index 152f7b9..23b21be 100644 --- a/frontend/src/i18n/locales/en/sidebar.json +++ b/frontend/src/i18n/locales/en/sidebar.json @@ -24,6 +24,7 @@ "to": "To", "clearDates": "clear dates", "reshuffle": "Reshuffle", + "noMatchingTags": "No matching tags here", "widget": { "show": "Show", "sort": "Sort", diff --git a/frontend/src/i18n/locales/hu/sidebar.json b/frontend/src/i18n/locales/hu/sidebar.json index 5c5cc0e..0c4cbab 100644 --- a/frontend/src/i18n/locales/hu/sidebar.json +++ b/frontend/src/i18n/locales/hu/sidebar.json @@ -24,6 +24,7 @@ "to": "Eddig", "clearDates": "dátumok törlése", "reshuffle": "Újrakeverés", + "noMatchingTags": "Nincs ide illő címke", "widget": { "show": "Megjelenítés", "sort": "Rendezés", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e813069..52b0a97 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -152,13 +152,13 @@ async function req(url: string, opts: RequestInit = {}): Promise { return r.status === 204 ? null : r.json(); } -function feedQuery(f: FeedFilters, offset: number, limit: number): string { +// The filter half of the query (everything except paging/sort), shared by the feed and +// the facet-count endpoint so both see exactly the same filter context. +function filterParams(f: FeedFilters): URLSearchParams { const p = new URLSearchParams(); f.tags.forEach((t) => p.append("tags", String(t))); p.set("tag_mode", f.tagMode); if (f.q) p.set("q", f.q); - p.set("sort", f.sort); - if (f.sort === "shuffle" && f.seed) p.set("seed", String(f.seed)); p.set("scope", f.scope); p.set("show_normal", String(f.includeNormal)); p.set("include_shorts", String(f.includeShorts)); @@ -170,6 +170,13 @@ function feedQuery(f: FeedFilters, offset: number, limit: number): string { if (f.dateTo) p.set("published_before", f.dateTo); if (f.minDuration != null) p.set("min_duration", String(f.minDuration)); if (f.maxDuration != null) p.set("max_duration", String(f.maxDuration)); + return p; +} + +function feedQuery(f: FeedFilters, offset: number, limit: number): string { + const p = filterParams(f); + p.set("sort", f.sort); + if (f.sort === "shuffle" && f.seed) p.set("seed", String(f.seed)); p.set("offset", String(offset)); p.set("limit", String(limit)); return p.toString(); @@ -252,6 +259,8 @@ export const api = { req(`/api/feed?${feedQuery(f, offset, limit)}`), feedCount: (f: FeedFilters): Promise<{ count: number }> => req(`/api/feed/count?${feedQuery(f, 0, 0)}`), + facets: (f: FeedFilters): Promise<{ counts: Record }> => + req(`/api/facets?${filterParams(f).toString()}`), setState: (id: string, status: string) => req(`/api/videos/${id}/state`, { method: "POST", body: JSON.stringify({ status }) }), saveProgress: (id: string, positionSeconds: number, durationSeconds: number) => From 5656875a07908c502a454ce714156d1264de6604 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 12:10:09 +0200 Subject: [PATCH 3/5] feat(filters): sort facet chips by count, then name Order topic/language chips by their (contextual) count descending, name as the tiebreaker, so the most-populated tags sit at the top and the smallest counts fall to the bottom as you scan down. --- frontend/src/components/Sidebar.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 1e5a452..48a67b5 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -135,11 +135,16 @@ export default function Sidebar({ const chipCount = (tag: Tag): number => facetsReady ? facetCounts[String(tag.id)] ?? 0 : tag.channel_count; // Visible chips: hide zero-count ones once facets are in, but always keep selected ones - // so an active filter can still be cleared. - const visibleChips = (list: Tag[]): Tag[] => - facetsReady + // so an active filter can still be cleared. Sorted by count (largest first), then name — + // so scanning top→bottom the smallest counts end up at the very bottom. + const visibleChips = (list: Tag[]): Tag[] => { + const shown = facetsReady ? list.filter((tg) => chipCount(tg) > 0 || filters.tags.includes(tg.id)) : list; + return [...shown].sort( + (a, b) => chipCount(b) - chipCount(a) || a.name.localeCompare(b.name) + ); + }; // After a page refresh the channel name isn't in the URL (only the id is), so // filters.channelName is undefined and the chip would fall back to "This channel". From 64911e3c4d35cbcfa909dd76e0dfbc82bb62be08 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 12:20:08 +0200 Subject: [PATCH 4/5] fix(feed): conjunctive facet counts when topic match mode is AND MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In AND ("All") topic mode the facet endpoint still excluded the topic selections when counting topic chips, so every topic kept its full count and none dropped out as you narrowed — e.g. picking Comedy left Cooking visible even though no channel has both. Count topics conjunctively in AND mode (keep the selected topics applied) so each remaining chip reflects channels that ALSO have all already-selected topics; non-co-occurring tags fall to zero and hide. OR mode stays disjunctive. Verified: Comedy selected narrows topic chips 21 -> 6. --- backend/app/routes/feed.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 08f2ecc..b743b92 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -318,8 +318,15 @@ def get_facets( visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id) counts: dict[int, int] = {} for category in ("language", "topic"): + # Disjunctive (OR) facets drop the category's own selections so its chips keep + # 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" base, _status = _filtered_query( - db, user, **{**params, "exclude_tag_category": category} + db, + user, + **{**params, "exclude_tag_category": None if conjunctive else category}, ) channels = base.with_only_columns(Video.channel_id).distinct().subquery() rows = db.execute( From aa4b069567ba7b8770a2ddb6bc40a2d7276c6a82 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 12:20:17 +0200 Subject: [PATCH 5/5] feat(filters): make the topic Any/All match toggle prominent The AND/OR ("Any"/"All") control for topic chips was a single faint corner link that was easy to miss. Replace it with a labelled segmented control ("Match: [Any][All]") so the AND option is discoverable. New trilingual 'match' label. --- frontend/src/components/Sidebar.tsx | 35 +++++++++++++++++------ frontend/src/i18n/locales/de/sidebar.json | 1 + frontend/src/i18n/locales/en/sidebar.json | 1 + frontend/src/i18n/locales/hu/sidebar.json | 1 + 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 48a67b5..260134b 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -412,16 +412,33 @@ export default function Sidebar({ const chips = visibleChips(topics); return ( <> -
- + {(["or", "and"] as const).map((m) => ( + + ))} +
{chips.map((tg) => ( diff --git a/frontend/src/i18n/locales/de/sidebar.json b/frontend/src/i18n/locales/de/sidebar.json index 11ccab4..6fa35b8 100644 --- a/frontend/src/i18n/locales/de/sidebar.json +++ b/frontend/src/i18n/locales/de/sidebar.json @@ -19,6 +19,7 @@ "any": "Beliebig", "all": "Alle", "tagModeTooltip": "Beliebige vs. alle ausgewählten Tags treffen", + "match": "Treffer", "custom": "Benutzerdefiniert", "from": "Von", "to": "Bis", diff --git a/frontend/src/i18n/locales/en/sidebar.json b/frontend/src/i18n/locales/en/sidebar.json index 23b21be..5d8a473 100644 --- a/frontend/src/i18n/locales/en/sidebar.json +++ b/frontend/src/i18n/locales/en/sidebar.json @@ -19,6 +19,7 @@ "any": "Any", "all": "All", "tagModeTooltip": "Match any vs all selected tags", + "match": "Match", "custom": "Custom", "from": "From", "to": "To", diff --git a/frontend/src/i18n/locales/hu/sidebar.json b/frontend/src/i18n/locales/hu/sidebar.json index 0c4cbab..72780a2 100644 --- a/frontend/src/i18n/locales/hu/sidebar.json +++ b/frontend/src/i18n/locales/hu/sidebar.json @@ -19,6 +19,7 @@ "any": "Bármelyik", "all": "Összes", "tagModeTooltip": "Bármelyik vagy az összes kijelölt címke illeszkedjen", + "match": "Egyezés", "custom": "Egyéni", "from": "Ettől", "to": "Eddig",