diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index fc0b8b3..b743b92 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,48 @@ 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"): + # 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": None if conjunctive else 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, diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 3ab6c97..260134b 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,31 @@ 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. 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". // Resolve the title from the (cached) channel list keyed by id. Only fetch when a @@ -362,45 +389,74 @@ 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 ( <> -
- + {(["or", "and"] as const).map((m) => ( + + ))} +
- {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..6fa35b8 100644 --- a/frontend/src/i18n/locales/de/sidebar.json +++ b/frontend/src/i18n/locales/de/sidebar.json @@ -19,11 +19,13 @@ "any": "Beliebig", "all": "Alle", "tagModeTooltip": "Beliebige vs. alle ausgewählten Tags treffen", + "match": "Treffer", "custom": "Benutzerdefiniert", "from": "Von", "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..5d8a473 100644 --- a/frontend/src/i18n/locales/en/sidebar.json +++ b/frontend/src/i18n/locales/en/sidebar.json @@ -19,11 +19,13 @@ "any": "Any", "all": "All", "tagModeTooltip": "Match any vs all selected tags", + "match": "Match", "custom": "Custom", "from": "From", "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..72780a2 100644 --- a/frontend/src/i18n/locales/hu/sidebar.json +++ b/frontend/src/i18n/locales/hu/sidebar.json @@ -19,11 +19,13 @@ "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", "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) =>