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
+3 -3
View File
@@ -184,8 +184,8 @@ export interface FeedFilters {
// Library (scope "all") only — provenance filter: "organic" (default) hides search-
// discovered videos, "all" mixes them in, "search" shows only them. Ignored for "my".
librarySource?: "organic" | "all" | "search" | "plex";
channelId?: string;
channelName?: string;
/** OR-ed together: a video from ANY of these passes, and every other filter still applies. */
channelIds: string[];
maxAgeDays?: number;
minDuration?: number;
maxDuration?: number;
@@ -432,7 +432,7 @@ function filterParams(f: FeedFilters): URLSearchParams {
p.set("include_shorts", String(f.includeShorts));
p.set("include_live", String(f.includeLive));
p.set("show", f.show);
if (f.channelId) p.set("channel_id", f.channelId);
f.channelIds.forEach((id) => p.append("channel_ids", id));
if (f.maxAgeDays) p.set("max_age_days", String(f.maxAgeDays));
if (f.dateFrom) p.set("published_after", f.dateFrom);
if (f.dateTo) p.set("published_before", f.dateTo);
+2 -2
View File
@@ -37,7 +37,7 @@ export function filtersToParams(f: FeedFilters): URLSearchParams {
if (f.includeLive) p.set("live", "1");
if (f.tags.length) p.set("tags", f.tags.join(","));
if (f.tagMode === "and") p.set("tagmode", "and");
if (f.channelId) p.set("channel", f.channelId);
if (f.channelIds.length) p.set("channel", f.channelIds.join(","));
if (f.maxAgeDays) p.set("maxage", String(f.maxAgeDays));
if (f.dateFrom) p.set("from", f.dateFrom);
if (f.dateTo) p.set("to", f.dateTo);
@@ -72,7 +72,7 @@ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): Fee
.map((s) => Number(s))
.filter((n) => Number.isInteger(n));
if (params.has("tagmode")) f.tagMode = params.get("tagmode") === "and" ? "and" : "or";
f.channelId = params.get("channel") || undefined;
f.channelIds = (params.get("channel") ?? "").split(",").filter(Boolean);
f.maxAgeDays = num(params.get("maxage"));
f.dateFrom = params.get("from") || undefined;
f.dateTo = params.get("to") || undefined;
+11 -9
View File
@@ -14,6 +14,7 @@ export interface ActiveFilter {
export const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
tags: [],
tagMode: "or",
channelIds: [],
sort: "newest",
includeNormal: true,
includeShorts: false,
@@ -48,7 +49,7 @@ export function useActiveFilters(
const channelsQuery = useQuery({
queryKey: ["channels"],
queryFn: api.channels,
enabled: !!filters.channelId && !filters.channelName,
enabled: filters.channelIds.length > 0,
staleTime: 5 * 60_000,
});
@@ -95,15 +96,16 @@ export function useActiveFilters(
});
}
if (filters.channelId) {
const name =
filters.channelName ??
channelsQuery.data?.find((c) => c.id === filters.channelId)?.title ??
t("sidebar.thisChannel");
// One chip per channel — they OR together, so dropping one shouldn't drop the rest (same shape
// as tags).
for (const id of filters.channelIds) {
out.push({
key: "channel",
label: name,
remove: () => setFilters({ ...filters, channelId: undefined, channelName: undefined }),
key: `channel:${id}`,
// Fall back to the id, not a generic label: several chips reading "This channel" while
// the name cache loads would be indistinguishable, and each ✕ drops a different one.
label: channelsQuery.data?.find((c) => c.id === id)?.title ?? id,
remove: () =>
setFilters({ ...filters, channelIds: filters.channelIds.filter((x) => x !== id) }),
});
}