From 79e7694b24fc9acde26d03bbfe4402f64c4e6080 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 12:05:53 +0200 Subject: [PATCH] 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,