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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user