feat(filters): filter the feed by several channels at once (OR)

Pick any number of channels; they OR together and every other filter applies
across the lot — "the last week's unwatched from these three".

`channelId`/`channelName` are replaced outright by `channelIds: string[]`, no
legacy reader (standing rule: no back-compat unless asked — a saved view written
against the old field simply loses its channel filter). The wire and the share
URL move with it: repeated `channel_ids` params, `?channel=a,b`. Backend ORs via
`Video.channel_id.in_()`; the count endpoint shares the same params object.

Two things the migration exposed:
- SavedViewsWidget fed DB blobs straight into serializers that now index an
  array — the FeedFilters type says nothing about what an older build wrote, so
  the blob is coerced where it enters. It white-screened the app before this.
- Chips carried channelName to label themselves. Without it they resolve names
  from the channel cache, so they now fall back to the id, not a shared
  "This channel" that would render N identical chips while the cache loads.
This commit is contained in:
2026-07-16 00:54:22 +02:00
parent 82e351bc62
commit 0d10e0f0a5
11 changed files with 88 additions and 66 deletions
+7 -6
View File
@@ -116,7 +116,7 @@ def _filtered_query(
*,
tags: list[int],
tag_mode: str,
channel_id: str | None,
channel_ids: list[str],
q: str | None,
min_duration: int | None,
max_duration: int | None,
@@ -186,7 +186,7 @@ def _filtered_query(
# opened an un-subscribed channel's page) is visible only to users who explored or
# subscribe to its channel, so one user's curiosity never leaks into everyone else's
# catalog. Applied in BOTH scopes; the channel page reaches its ephemeral videos by
# asking for scope=all + channel_id (the channel is in this accessible set).
# asking for scope=all + channel_ids (the channel is in this accessible set).
accessible_explore = (
select(Subscription.channel_id).where(Subscription.user_id == user.id)
).union(
@@ -227,8 +227,9 @@ def _filtered_query(
else: # "organic" (default): subscriptions only
query = query.where(subscribed)
if channel_id:
query = query.where(Video.channel_id == channel_id)
if channel_ids:
# OR across the picked channels; every other filter still applies on top.
query = query.where(Video.channel_id.in_(channel_ids))
if min_duration is not None:
query = query.where(Video.duration_seconds >= min_duration)
if max_duration is not None:
@@ -354,7 +355,7 @@ def _to_tsquery_str(q: str) -> str | None:
def _feed_params(
tags: list[int] = Query(default=[]),
tag_mode: str = "or",
channel_id: str | None = None,
channel_ids: list[str] = Query(default=[]),
q: str | None = None,
min_duration: int | None = None,
max_duration: int | None = None,
@@ -371,7 +372,7 @@ def _feed_params(
return {
"tags": tags,
"tag_mode": tag_mode,
"channel_id": channel_id,
"channel_ids": channel_ids,
"q": q,
"min_duration": min_duration,
"max_duration": max_duration,