@@ -30,6 +37,7 @@ export function SubscriptionsHeader({ active, count, onVideosIntent, onChannelsI
;
+ menu: React.RefObject;
+ toggle: () => void;
+ close: (restoreFocus?: boolean) => void;
+ onTriggerKeyDown: (event: KeyboardEvent) => void;
+ onMenuKeyDown: (event: KeyboardEvent) => void;
+} {
+ const id = useId();
+ const [open, setOpen] = useState(false);
+ const trigger = useRef(null);
+ const menu = useRef(null);
+ const startAtEnd = useRef(false);
+
+ function close(restoreFocus = false): void {
+ setOpen(false);
+ if (restoreFocus) trigger.current?.focus();
+ }
+
+ useEffect(() => {
+ if (!open) return;
+ if (disabled) {
+ setOpen(false);
+ return;
+ }
+ const items = menu.current?.querySelectorAll('[role="menuitem"]');
+ items?.[startAtEnd.current ? items.length - 1 : 0]?.focus();
+ function dismiss(event: Event): void {
+ const target = event.target;
+ if (
+ target instanceof Node &&
+ !menu.current?.contains(target) &&
+ !trigger.current?.contains(target)
+ ) {
+ setOpen(false);
+ }
+ }
+ document.addEventListener("pointerdown", dismiss);
+ document.addEventListener("focusin", dismiss);
+ return () => {
+ document.removeEventListener("pointerdown", dismiss);
+ document.removeEventListener("focusin", dismiss);
+ };
+ }, [open, disabled]);
+
+ return {
+ id,
+ open,
+ trigger,
+ menu,
+ close,
+ toggle: () => {
+ startAtEnd.current = false;
+ setOpen((value) => !value);
+ },
+ onTriggerKeyDown: (event) => {
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
+ event.preventDefault();
+ startAtEnd.current = event.key === "ArrowUp";
+ setOpen(true);
+ },
+ onMenuKeyDown: (event) => {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ event.stopPropagation();
+ close(true);
+ } else if (event.key === "Tab") {
+ close(true);
+ } else if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
+ event.preventDefault();
+ const items = Array.from(
+ menu.current?.querySelectorAll('[role="menuitem"]') ?? [],
+ );
+ const current =
+ document.activeElement instanceof HTMLButtonElement
+ ? items.indexOf(document.activeElement)
+ : -1;
+ const next =
+ event.key === "Home"
+ ? 0
+ : event.key === "End"
+ ? items.length - 1
+ : (current + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
+ items[next]?.focus();
+ }
+ },
+ };
+}
diff --git a/apps/web/src/hooks/use-group-channel-page.ts b/apps/web/src/hooks/use-group-channel-page.ts
new file mode 100644
index 00000000..fa1756a8
--- /dev/null
+++ b/apps/web/src/hooks/use-group-channel-page.ts
@@ -0,0 +1,92 @@
+import { keepPreviousData, type UseQueryResult, useQuery } from "@tanstack/react-query";
+import { useEffect, useLayoutEffect, useState } from "react";
+import { groupMembershipPageOptions } from "../lib/group-membership-queries";
+import { type GroupPage, groupPage } from "../lib/group-pagination";
+import type { GroupedSubscription, MembershipPage } from "../types/subscription-groups";
+import { useAuth } from "./use-auth";
+import { useDebouncedValue } from "./use-debounced-value";
+
+export function useGroupChannelPage(
+ filter: string,
+ excluded: boolean,
+ search: string,
+ selected: GroupedSubscription[] | null,
+): {
+ query: UseQueryResult;
+ channels: GroupedSubscription[];
+ pagination: GroupPage & {
+ total: number;
+ viewport: (element: HTMLDivElement | null) => void;
+ onPage: (page: number) => void;
+ };
+} {
+ const { me, authReady, isAuthed } = useAuth();
+ const [element, setElement] = useState(null);
+ const [size, setSize] = useState(10);
+ const debounced = useDebouncedValue(search.trim(), 250);
+ const key = JSON.stringify([filter, excluded, debounced, selected !== null]);
+ const [position, setPosition] = useState({ key, start: 0 });
+ const page = position.key === key ? Math.floor(position.start / size) : 0;
+ const query = useQuery({
+ ...groupMembershipPageOptions(me?.id, {
+ page: selected ? 0 : page,
+ limit: size,
+ filter,
+ excluded,
+ search: debounced,
+ }),
+ enabled: authReady && isAuthed && selected === null,
+ placeholderData: keepPreviousData,
+ });
+ const total = selected?.length ?? query.data?.total ?? 0;
+ const pagination = groupPage(total, size, page);
+ useEffect(() => {
+ if (
+ (selected !== null || (query.isSuccess && !query.isPlaceholderData)) &&
+ page !== pagination.page
+ )
+ setPosition({ key, start: pagination.start });
+ }, [
+ key,
+ page,
+ pagination.page,
+ pagination.start,
+ selected,
+ query.isSuccess,
+ query.isPlaceholderData,
+ ]);
+ useLayoutEffect(() => {
+ if (!element) return;
+ const desktop = window.matchMedia("(min-width: 1024px) and (min-height: 600px)");
+ function measure(): void {
+ const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize);
+ setSize(
+ desktop.matches
+ ? Math.max(1, Math.min(100, Math.floor(((element?.clientHeight ?? 0) / rem - 4.5) / 3.5)))
+ : 10,
+ );
+ }
+ measure();
+ const observer = new ResizeObserver(measure);
+ observer.observe(element);
+ desktop.addEventListener("change", measure);
+ return () => {
+ observer.disconnect();
+ desktop.removeEventListener("change", measure);
+ };
+ }, [element]);
+ return {
+ query,
+ channels: selected
+ ? [...selected]
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .slice(pagination.start, pagination.end)
+ : (query.data?.items ?? []),
+ pagination: {
+ ...pagination,
+ total,
+ viewport: setElement,
+ onPage: (next) => setPosition({ key, start: next * size }),
+ },
+ };
+}
diff --git a/apps/web/src/hooks/use-group-combobox.ts b/apps/web/src/hooks/use-group-combobox.ts
new file mode 100644
index 00000000..ab9c7584
--- /dev/null
+++ b/apps/web/src/hooks/use-group-combobox.ts
@@ -0,0 +1,120 @@
+import { type KeyboardEvent, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
+import type { SubscriptionGroup } from "../types/subscription-groups";
+
+export function useGroupCombobox(
+ groups: SubscriptionGroup[],
+ selected: ReadonlySet,
+ onToggle: (id: string) => void,
+): {
+ input: React.RefObject;
+ root: React.RefObject;
+ list: React.RefObject;
+ listId: string;
+ query: string;
+ open: boolean;
+ active: number;
+ placement: { above: boolean; height: number };
+ matches: SubscriptionGroup[];
+ chosen: SubscriptionGroup[];
+ setOpen: (open: boolean) => void;
+ search: (query: string) => void;
+ choose: (id: string) => void;
+ onKeyDown: (event: KeyboardEvent) => void;
+} {
+ const input = useRef(null);
+ const root = useRef(null);
+ const list = useRef(null);
+ const listId = useId();
+ const [query, setQuery] = useState("");
+ const [open, setOpen] = useState(false);
+ const [highlighted, setHighlighted] = useState(0);
+ const [placement, setPlacement] = useState({ above: false, height: 224 });
+ const chosen = groups.filter((group) => selected.has(group.id));
+ const matches = groups.filter(
+ (group) =>
+ !selected.has(group.id) &&
+ group.name.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()),
+ );
+ const active = Math.min(highlighted, matches.length - 1);
+ useLayoutEffect(() => {
+ if (chosen.length > 0 && document.activeElement === input.current) {
+ input.current?.scrollIntoView({ block: "nearest" });
+ }
+ }, [chosen.length]);
+ useLayoutEffect(() => {
+ if (!open) return;
+ function position(): void {
+ const bounds = root.current?.getBoundingClientRect();
+ if (!bounds) return;
+ const below = window.innerHeight - bounds.bottom - 12;
+ const above = bounds.top - 68;
+ const flip = below < Math.min(224, Math.max(40, matches.length * 36)) && above > below;
+ setPlacement({ above: flip, height: Math.max(40, Math.min(224, flip ? above : below)) });
+ }
+ position();
+ window.addEventListener("resize", position);
+ window.addEventListener("scroll", position, true);
+ return () => {
+ window.removeEventListener("resize", position);
+ window.removeEventListener("scroll", position, true);
+ };
+ }, [open, matches.length]);
+ useEffect(() => {
+ if (open) list.current?.children[active]?.scrollIntoView({ block: "nearest" });
+ }, [active, open]);
+ function search(value: string): void {
+ setQuery(value);
+ setHighlighted(0);
+ setOpen(true);
+ }
+ function choose(id: string): void {
+ onToggle(id);
+ setQuery("");
+ setHighlighted(0);
+ input.current?.focus();
+ }
+ function onKeyDown(event: KeyboardEvent): void {
+ if (event.nativeEvent.isComposing) return;
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
+ event.preventDefault();
+ setOpen(true);
+ setHighlighted(
+ !open
+ ? event.key === "ArrowDown"
+ ? 0
+ : Math.max(0, matches.length - 1)
+ : Math.max(
+ 0,
+ Math.min(matches.length - 1, active + (event.key === "ArrowDown" ? 1 : -1)),
+ ),
+ );
+ } else if (event.key === "Enter") {
+ event.preventDefault();
+ if (open && matches[active]) choose(matches[active].id);
+ else setOpen(true);
+ } else if (event.key === "Escape" && open) {
+ event.preventDefault();
+ event.stopPropagation();
+ setOpen(false);
+ } else if (event.key === "Backspace" && !query && chosen.length) {
+ event.preventDefault();
+ onToggle(chosen[chosen.length - 1].id);
+ }
+ }
+ return {
+ input,
+ root,
+ list,
+ listId,
+ query,
+ open,
+ active,
+ placement,
+ matches,
+ chosen,
+ setOpen,
+ search,
+ choose,
+ onKeyDown,
+ };
+}
diff --git a/apps/web/src/hooks/use-group-manager.ts b/apps/web/src/hooks/use-group-manager.ts
new file mode 100644
index 00000000..f2ab2c51
--- /dev/null
+++ b/apps/web/src/hooks/use-group-manager.ts
@@ -0,0 +1,171 @@
+import { useState } from "react";
+import { deleteSubscriptionGroup, updateGroupMemberships } from "../lib/api-subscription-groups";
+import { clearMembershipChanges } from "../lib/subscription-group-selection";
+import { m } from "../paraglide/messages.js";
+import type { GroupedSubscription, SubscriptionGroup } from "../types/subscription-groups";
+import { useGroupChannelPage } from "./use-group-channel-page";
+import { useGroupSelection } from "./use-group-selection";
+import { useGroupActions } from "./use-subscription-groups";
+
+type State = {
+ actions: ReturnType;
+ page: ReturnType;
+ selectionQuery: ReturnType["query"];
+ activeFilter: string;
+ activeGroup: SubscriptionGroup | undefined;
+ excluded: boolean;
+ query: string;
+ chosen: GroupedSubscription[];
+ validSelected: Set;
+ visible: GroupedSubscription[];
+ hiddenCount: number;
+ filterName: string;
+ disabled: boolean;
+ editing: string | null;
+ drafts: ReadonlyMap>;
+ onlySelected: boolean;
+ confirmationProps: { title: string; description: string; confirmLabel: string } | null;
+ setExcluded: (value: boolean) => void;
+ setQuery: (value: string) => void;
+ selectResults: () => void;
+ setOnlySelected: (value: boolean) => void;
+ setDraft: (url: string, ids: Set) => void;
+ setConfirmation: (value: SubscriptionGroup | "clear" | null) => void;
+ changeFilter: (value: string) => void;
+ toggle: (url: string) => void;
+ clearSelection: () => void;
+ bulk: (groupId: string, action: "add" | "remove") => Promise;
+ confirm: () => Promise;
+};
+
+export function useGroupManager(groups: SubscriptionGroup[], groupsReady: boolean): State {
+ const [filter, setFilter] = useState("all");
+ const [excluded, setExcluded] = useState(false);
+ const [query, setQuery] = useState("");
+ const selection = useGroupSelection();
+ const [onlySelected, setOnlySelected] = useState(false);
+ const [confirmation, setConfirmation] = useState(null);
+ const { chosen, selected: validSelected, drafts, setDraft } = selection;
+ const activeGroup = groups.find((group) => group.id === filter);
+ const activeFilter = activeGroup || filter === "ungrouped" ? filter : "all";
+ const page = useGroupChannelPage(activeFilter, excluded, query, onlySelected ? chosen : null);
+ const selectedChannels = new Map(chosen.map((channel) => [channel.channelUrl, channel]));
+ const visible = page.channels.map(
+ (channel) => selectedChannels.get(channel.channelUrl) ?? channel,
+ );
+ const canEdit =
+ groupsReady &&
+ selection.query.isSuccess &&
+ !selection.query.isFetching &&
+ (onlySelected || (page.query.isSuccess && !page.query.isPlaceholderData));
+ const actions = useGroupActions(canEdit);
+ const hiddenCount =
+ chosen.length - visible.filter((channel) => validSelected.has(channel.channelUrl)).length;
+ const filterName =
+ activeGroup?.name ?? (activeFilter === "ungrouped" ? m.sg_ungrouped() : m.sg_all_channels());
+ const editing = chosen.length === 1 ? chosen[0].channelUrl : null;
+ const disabled = actions.busy || !canEdit;
+
+ function toggle(url: string): void {
+ actions.clearError();
+ const channel = visible.find((item) => item.channelUrl === url);
+ if (channel) selection.toggle(channel);
+ }
+ function changeFilter(value: string): void {
+ actions.clearError();
+ setFilter(value);
+ setExcluded(false);
+ setOnlySelected(false);
+ }
+ function clearSelection(): void {
+ actions.clearError();
+ selection.clear();
+ setOnlySelected(false);
+ }
+ async function bulk(groupId: string, action: "add" | "remove"): Promise {
+ const channelUrls = chosen
+ .filter((channel) => channel.groupIds.includes(groupId) === (action === "remove"))
+ .map((channel) => channel.channelUrl);
+ if (channelUrls.length === 0) return;
+ const name = groups.find((group) => group.id === groupId)?.name ?? "";
+ const message =
+ action === "add"
+ ? channelUrls.length === 1
+ ? m.sg_added_one({ group: name })
+ : m.sg_added({ count: channelUrls.length, group: name })
+ : channelUrls.length === 1
+ ? m.sg_removed_one({ group: name })
+ : m.sg_removed({ count: channelUrls.length, group: name });
+ if (
+ await actions.run(() => updateGroupMemberships([{ groupId, channelUrls, action }]), message)
+ )
+ clearSelection();
+ }
+ async function confirm(): Promise {
+ const pending = confirmation;
+ setConfirmation(null);
+ if (pending === "clear") {
+ const changes = clearMembershipChanges(chosen);
+ if (changes.length === 0) return;
+ if (await actions.run(() => updateGroupMemberships(changes), m.sg_memberships_cleared()))
+ clearSelection();
+ } else if (pending) {
+ if (
+ (await actions.run(
+ () => deleteSubscriptionGroup(pending.id),
+ m.sg_group_deleted({ group: pending.name }),
+ )) &&
+ activeFilter === pending.id
+ )
+ changeFilter("all");
+ }
+ }
+
+ const confirmationProps = confirmation
+ ? {
+ title:
+ confirmation === "clear"
+ ? m.sg_remove_all()
+ : m.sg_delete_named({ group: confirmation.name }),
+ description:
+ confirmation === "clear"
+ ? chosen.length === 1
+ ? m.sg_clear_one_confirmation()
+ : m.sg_clear_confirmation({ count: chosen.length })
+ : confirmation.channelCount === 1
+ ? m.sg_delete_one_confirmation()
+ : m.sg_delete_confirmation({ count: confirmation.channelCount }),
+ confirmLabel: confirmation === "clear" ? m.sg_remove_all() : m.sg_delete_group(),
+ }
+ : null;
+ return {
+ actions,
+ page,
+ selectionQuery: selection.query,
+ activeFilter,
+ activeGroup,
+ excluded,
+ query,
+ chosen,
+ validSelected,
+ visible,
+ hiddenCount,
+ filterName,
+ disabled,
+ editing,
+ drafts,
+ onlySelected,
+ confirmationProps,
+ setExcluded,
+ setQuery,
+ selectResults: () => selection.select(visible),
+ setOnlySelected,
+ setDraft,
+ setConfirmation,
+ changeFilter,
+ toggle,
+ clearSelection,
+ bulk,
+ confirm,
+ };
+}
diff --git a/apps/web/src/hooks/use-group-pagination.ts b/apps/web/src/hooks/use-group-pagination.ts
new file mode 100644
index 00000000..cc8b4404
--- /dev/null
+++ b/apps/web/src/hooks/use-group-pagination.ts
@@ -0,0 +1,62 @@
+import { useLayoutEffect, useRef, useState } from "react";
+import { fitGroupPage, type GroupPage, groupPage } from "../lib/group-pagination";
+
+type Options = {
+ total: number;
+ rowRem: number;
+ fallbackSize: number;
+ reservedRem?: number;
+ anchor?: number;
+};
+
+export function useGroupPagination({
+ total,
+ rowRem,
+ fallbackSize,
+ reservedRem = 0,
+ anchor = -1,
+}: Options): GroupPage & {
+ viewport: React.RefObject;
+ onPage: (page: number) => void;
+} {
+ const viewport = useRef(null);
+ const [state, setState] = useState(() => groupPage(total, fallbackSize, 0));
+ const current = groupPage(total, state.size, state.page);
+ useLayoutEffect(() => {
+ const element = viewport.current;
+ if (!element) return;
+ const desktop = window.matchMedia("(min-width: 1024px) and (min-height: 600px)");
+ function measure(): void {
+ const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize);
+ setState((previous) => {
+ const next = fitGroupPage(
+ previous,
+ total,
+ desktop.matches ? (element?.clientHeight ?? 0) : fallbackSize * rowRem * rem,
+ rowRem * rem,
+ desktop.matches ? reservedRem * rem : 0,
+ anchor,
+ );
+ return next.size === previous.size &&
+ next.page === previous.page &&
+ next.end === previous.end &&
+ next.pages === previous.pages
+ ? previous
+ : next;
+ });
+ }
+ measure();
+ const observer = new ResizeObserver(measure);
+ observer.observe(element);
+ desktop.addEventListener("change", measure);
+ return () => {
+ observer.disconnect();
+ desktop.removeEventListener("change", measure);
+ };
+ }, [total, rowRem, fallbackSize, reservedRem, anchor]);
+ return {
+ ...current,
+ viewport,
+ onPage: (page) => setState(groupPage(total, current.size, page)),
+ };
+}
diff --git a/apps/web/src/hooks/use-group-selection.ts b/apps/web/src/hooks/use-group-selection.ts
new file mode 100644
index 00000000..0a9e8983
--- /dev/null
+++ b/apps/web/src/hooks/use-group-selection.ts
@@ -0,0 +1,59 @@
+import { type UseQueryResult, useQuery } from "@tanstack/react-query";
+import { useState } from "react";
+import { selectedMembershipOptions } from "../lib/group-membership-queries";
+import type { GroupedSubscription } from "../types/subscription-groups";
+import { useAuth } from "./use-auth";
+
+type Selection = {
+ channels: Map;
+ drafts: ReadonlyMap>;
+};
+
+export function useGroupSelection(): {
+ chosen: GroupedSubscription[];
+ selected: Set;
+ drafts: Selection["drafts"];
+ query: UseQueryResult;
+ select: (channels: GroupedSubscription[]) => void;
+ clear: () => void;
+ toggle: (channel: GroupedSubscription) => void;
+ setDraft: (url: string, ids: Set) => void;
+} {
+ const { me, authReady, isAuthed } = useAuth();
+ const [state, setState] = useState({ channels: new Map(), drafts: new Map() });
+ const query = useQuery({
+ ...selectedMembershipOptions(me?.id, [...state.channels.keys()]),
+ enabled: authReady && isAuthed && state.channels.size > 0,
+ initialData: () => [...state.channels.values()],
+ initialDataUpdatedAt: 0,
+ });
+ const chosen = query.data ?? [...state.channels.values()];
+ return {
+ chosen,
+ selected: new Set(chosen.map((channel) => channel.channelUrl)),
+ drafts: state.drafts,
+ query,
+ select: (channels) =>
+ setState((current) => ({
+ ...current,
+ channels: new Map([...chosen, ...channels].map((channel) => [channel.channelUrl, channel])),
+ })),
+ clear: () => setState({ channels: new Map(), drafts: new Map() }),
+ toggle: (channel) =>
+ setState((current) => {
+ const channels = new Map(chosen.map((item) => [item.channelUrl, item]));
+ if (channels.has(channel.channelUrl)) channels.delete(channel.channelUrl);
+ else channels.set(channel.channelUrl, channel);
+ return {
+ channels,
+ drafts: new Map([...current.drafts].filter(([url]) => channels.has(url))),
+ };
+ }),
+ setDraft: (url, ids) =>
+ setState((current) =>
+ current.channels.has(url)
+ ? { ...current, drafts: new Map(current.drafts).set(url, ids) }
+ : current,
+ ),
+ };
+}
diff --git a/apps/web/src/hooks/use-subscription-feed.ts b/apps/web/src/hooks/use-subscription-feed.ts
index a4c07f8f..593130ad 100644
--- a/apps/web/src/hooks/use-subscription-feed.ts
+++ b/apps/web/src/hooks/use-subscription-feed.ts
@@ -1,26 +1,29 @@
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo } from "react";
import { ApiError } from "../lib/api";
-import { fetchSubscriptionFeed } from "../lib/api-user";
import { mapVideoItem } from "../lib/mappers";
import { proxyImage } from "../lib/proxy";
+import { subscriptionFeedQueryOptions } from "../lib/subscription-queries";
import type { VideoStream } from "../types/stream";
import { useAuth } from "./use-auth";
import { useSubscriptions } from "./use-subscriptions";
-export const SUBSCRIPTION_FEED_KEY = ["subscription-feed"];
-
type Result = {
streams: VideoStream[];
isLoading: boolean;
+ isLoadingError: boolean;
+ isFetchNextPageError: boolean;
isFetchingNextPage: boolean;
hasNextPage: boolean;
fetchNextPage: () => void;
+ refetch: () => void;
+ error: Error | null;
};
-export function useSubscriptionFeed(): Result {
+export function useSubscriptionFeed(filter = "all"): Result {
const { authReady, isAuthed } = useAuth();
- const { query: subsQuery } = useSubscriptions();
+ const { query: subsQuery } = useSubscriptions(filter);
+ const empty = subsQuery.isSuccess && subsQuery.data.length === 0;
const queryClient = useQueryClient();
const avatarMap = useMemo(
() => new Map((subsQuery.data ?? []).map((s) => [s.channelUrl, proxyImage(s.avatarUrl)])),
@@ -28,12 +31,8 @@ export function useSubscriptionFeed(): Result {
);
const query = useInfiniteQuery({
- queryKey: SUBSCRIPTION_FEED_KEY,
- queryFn: ({ pageParam, signal }) => fetchSubscriptionFeed(pageParam as string | null, signal),
- initialPageParam: null as string | null,
- getNextPageParam: (last) => last.nextpage ?? undefined,
- staleTime: 5 * 60 * 1000,
- enabled: authReady && isAuthed,
+ ...subscriptionFeedQueryOptions(filter),
+ enabled: authReady && isAuthed && subsQuery.isSuccess && !empty,
});
useEffect(() => {
@@ -43,9 +42,12 @@ export function useSubscriptionFeed(): Result {
query.error.code ?? "",
)
) {
- void queryClient.resetQueries({ queryKey: SUBSCRIPTION_FEED_KEY, exact: true });
+ void queryClient.resetQueries({
+ queryKey: subscriptionFeedQueryOptions(filter).queryKey,
+ exact: true,
+ });
}
- }, [query.error, queryClient]);
+ }, [query.error, queryClient, filter]);
const streams = useMemo(
() =>
@@ -63,10 +65,14 @@ export function useSubscriptionFeed(): Result {
);
return {
- streams,
- isLoading: query.isLoading,
+ streams: empty ? [] : streams,
+ isLoading: !empty && query.isLoading,
+ isLoadingError: !empty && query.isLoadingError,
+ isFetchNextPageError: !empty && query.isFetchNextPageError,
isFetchingNextPage: query.isFetchingNextPage,
- hasNextPage: query.hasNextPage,
+ hasNextPage: !empty && query.hasNextPage,
fetchNextPage: query.fetchNextPage,
+ refetch: query.refetch,
+ error: empty ? null : query.error,
};
}
diff --git a/apps/web/src/hooks/use-subscription-groups.ts b/apps/web/src/hooks/use-subscription-groups.ts
new file mode 100644
index 00000000..538f7376
--- /dev/null
+++ b/apps/web/src/hooks/use-subscription-groups.ts
@@ -0,0 +1,78 @@
+import { type UseQueryResult, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useRef, useState } from "react";
+import { ApiError } from "../lib/api";
+import { fetchSubscriptionGroups, MembershipUpdateError } from "../lib/api-subscription-groups";
+import {
+ invalidateSubscriptionQueries,
+ SUBSCRIPTION_GROUPS_KEY,
+} from "../lib/subscription-queries";
+import { m } from "../paraglide/messages.js";
+import type { SubscriptionGroup } from "../types/subscription-groups";
+import { useAuth } from "./use-auth";
+
+export function useSubscriptionGroups(): UseQueryResult {
+ const { authReady, isAuthed, me } = useAuth();
+ return useQuery({
+ queryKey: [...SUBSCRIPTION_GROUPS_KEY, me?.id],
+ queryFn: ({ signal }) => fetchSubscriptionGroups(signal),
+ enabled: authReady && isAuthed,
+ staleTime: 60_000,
+ });
+}
+
+type GroupActions = {
+ busy: boolean;
+ error: string | null;
+ notice: string | null;
+ clearError: () => void;
+ run: (
+ action: () => Promise,
+ success: string,
+ change?: "groups" | "memberships",
+ ) => Promise;
+};
+
+export function useGroupActions(enabled: boolean): GroupActions {
+ const client = useQueryClient();
+ const lock = useRef(false);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [notice, setNotice] = useState(null);
+
+ async function run(
+ action: () => Promise,
+ success: string,
+ change: "groups" | "memberships" = "memberships",
+ ): Promise {
+ if (!enabled || lock.current) return false;
+ lock.current = true;
+ setBusy(true);
+ setError(null);
+ setNotice(null);
+ let succeeded = false;
+ try {
+ await action();
+ succeeded = true;
+ setNotice(success);
+ } catch (cause) {
+ setError(
+ cause instanceof MembershipUpdateError
+ ? cause.failedUrls.length === 1
+ ? m.sg_partial_one_failure()
+ : m.sg_partial_failure({ count: cause.failedUrls.length })
+ : cause instanceof ApiError && cause.code === "subscription_group_name_conflict"
+ ? m.sg_duplicate_name()
+ : cause instanceof ApiError && cause.code === "subscription_group_invalid_name"
+ ? m.sg_invalid_name()
+ : m.sg_save_error(),
+ );
+ } finally {
+ await invalidateSubscriptionQueries(client, change);
+ lock.current = false;
+ setBusy(false);
+ }
+ return succeeded;
+ }
+
+ return { busy, error, notice, clearError: () => setError(null), run };
+}
diff --git a/apps/web/src/hooks/use-subscriptions.ts b/apps/web/src/hooks/use-subscriptions.ts
index 08a9b5c7..0f193014 100644
--- a/apps/web/src/hooks/use-subscriptions.ts
+++ b/apps/web/src/hooks/use-subscriptions.ts
@@ -1,11 +1,13 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { fetchSubscriptions, subscribe, unsubscribe } from "../lib/api-user";
+import { subscribe, unsubscribe } from "../lib/api-user";
import { normalizeChannelUrl } from "../lib/channel-url";
+import {
+ invalidateSubscriptionQueries,
+ subscriptionsQueryOptions,
+} from "../lib/subscription-queries";
import type { SubscriptionItem } from "../types/user";
import { useAuth } from "./use-auth";
-export const SUBSCRIPTIONS_KEY = ["subscriptions"];
-
function hasSubscription(data: SubscriptionItem[] | undefined, channelUrl: string): boolean {
const target = normalizeChannelUrl(channelUrl);
return (data ?? []).some((item) => normalizeChannelUrl(item.channelUrl) === target);
@@ -23,16 +25,14 @@ function dedupeSubscriptions(data: SubscriptionItem[]): SubscriptionItem[] {
return output;
}
-export function useSubscriptions() {
+export function useSubscriptions(filter = "all") {
const qc = useQueryClient();
const { authReady, isAuthed } = useAuth();
const query = useQuery({
- queryKey: SUBSCRIPTIONS_KEY,
- queryFn: fetchSubscriptions,
+ ...subscriptionsQueryOptions(filter),
enabled: authReady && isAuthed,
select: dedupeSubscriptions,
- staleTime: 5 * 60 * 1000,
});
const add = useMutation({
@@ -44,12 +44,12 @@ export function useSubscriptions() {
channelUrl: normalizeChannelUrl(item.channelUrl),
});
},
- onSuccess: () => qc.invalidateQueries({ queryKey: SUBSCRIPTIONS_KEY }),
+ onSuccess: () => invalidateSubscriptionQueries(qc),
});
const remove = useMutation({
mutationFn: (channelUrl: string) => (isAuthed ? unsubscribe(channelUrl) : Promise.resolve()),
- onSuccess: () => qc.invalidateQueries({ queryKey: SUBSCRIPTIONS_KEY }),
+ onSuccess: () => invalidateSubscriptionQueries(qc),
});
function isSubscribed(channelUrl: string): boolean {
diff --git a/apps/web/src/lib/api-subscription-groups.ts b/apps/web/src/lib/api-subscription-groups.ts
new file mode 100644
index 00000000..b39a5cb1
--- /dev/null
+++ b/apps/web/src/lib/api-subscription-groups.ts
@@ -0,0 +1,72 @@
+import type { MembershipChange, SubscriptionGroup } from "../types/subscription-groups";
+import { apiErrorFromResponse } from "./api";
+import { authed, authedJson } from "./authed";
+import { API_BASE } from "./env";
+import { membershipBatches } from "./membership-batches";
+
+const GROUPS_URL = `${API_BASE}/subscriptions/groups`;
+const MAX_CONCURRENT_MEMBERSHIP_REQUESTS = 3;
+
+export function fetchSubscriptionGroups(signal?: AbortSignal): Promise {
+ return authedJson(GROUPS_URL, { signal });
+}
+
+async function groupRequest(path: string, method: string, body?: unknown): Promise {
+ const response = await authed(`${GROUPS_URL}${path}`, {
+ method,
+ ...(body === undefined
+ ? {}
+ : { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }),
+ });
+ if (!response.ok) {
+ throw apiErrorFromResponse(response, await response.json().catch(() => null));
+ }
+ return response;
+}
+
+export async function createSubscriptionGroup(name: string): Promise {
+ const response = await groupRequest("", "POST", { name: name.trim() });
+ return response.json();
+}
+
+export async function renameSubscriptionGroup(id: string, name: string): Promise {
+ await groupRequest(`/${encodeURIComponent(id)}`, "PUT", { name: name.trim() });
+}
+
+export async function deleteSubscriptionGroup(id: string): Promise {
+ await groupRequest(`/${encodeURIComponent(id)}`, "DELETE");
+}
+
+export class MembershipUpdateError extends Error {
+ readonly failedUrls: string[];
+ constructor(failedUrls: string[]) {
+ super("Some subscription group changes could not be saved");
+ this.failedUrls = failedUrls;
+ }
+}
+
+export async function updateGroupMemberships(changes: MembershipChange[]): Promise {
+ const failed = new Set();
+ for (const change of changes) {
+ const { batches, invalid } = membershipBatches(change.channelUrls);
+ for (const url of invalid) failed.add(url);
+ for (let offset = 0; offset < batches.length; offset += MAX_CONCURRENT_MEMBERSHIP_REQUESTS) {
+ await Promise.all(
+ batches
+ .slice(offset, offset + MAX_CONCURRENT_MEMBERSHIP_REQUESTS)
+ .map(async (channelUrls) => {
+ try {
+ await groupRequest(
+ `/${encodeURIComponent(change.groupId)}/channels`,
+ change.action === "add" ? "PUT" : "DELETE",
+ { channelUrls },
+ );
+ } catch {
+ for (const url of channelUrls) failed.add(url);
+ }
+ }),
+ );
+ }
+ }
+ if (failed.size > 0) throw new MembershipUpdateError([...failed]);
+}
diff --git a/apps/web/src/lib/api-user.ts b/apps/web/src/lib/api-user.ts
index afb3cc97..cf62c7c2 100644
--- a/apps/web/src/lib/api-user.ts
+++ b/apps/web/src/lib/api-user.ts
@@ -1,9 +1,10 @@
import type { SubscriptionFeedPage } from "../types/api";
import type { HistoryItem, SearchHistoryItem, SettingsItem, SubscriptionItem } from "../types/user";
-import { ApiError } from "./api";
+import { ApiError, apiErrorFromResponse } from "./api";
import { authed, authedJson } from "./authed";
import { channelUrlVariants, normalizeChannelUrl } from "./channel-url";
import { API_BASE as BASE } from "./env";
+import { subscriptionFilterParams } from "./subscription-group-selection";
import { normalizeApiPayload } from "./text-normalize";
type HistoryParams = {
@@ -59,8 +60,12 @@ export async function clearHistory(): Promise {
if (!res.ok) throw new ApiError("Failed to clear history", res.status);
}
-export function fetchSubscriptions(): Promise {
- return authedJson(`${BASE}/subscriptions`);
+export function fetchSubscriptions(
+ filter = "all",
+ signal?: AbortSignal,
+): Promise {
+ const search = subscriptionFilterParams(filter).toString();
+ return authedJson(`${BASE}/subscriptions${search ? `?${search}` : ""}`, { signal });
}
export async function subscribe(item: Omit): Promise {
@@ -136,8 +141,10 @@ export async function clearSearchHistory(): Promise {
export async function fetchSubscriptionFeed(
cursor: string | null = null,
signal?: AbortSignal,
+ filter = "all",
): Promise {
- const search = new URLSearchParams({ limit: "30" });
+ const search = subscriptionFilterParams(filter);
+ search.set("limit", "30");
if (cursor !== null) search.set("cursor", cursor);
const url = `${BASE}/subscriptions/feed?${search.toString()}`;
while (true) {
@@ -148,12 +155,7 @@ export async function fetchSubscriptionFeed(
continue;
}
if (!res.ok) {
- const error = body as { code?: string; error?: string };
- throw new ApiError(
- error.error ?? "Subscription feed request failed",
- res.status,
- error.code ?? null,
- );
+ throw apiErrorFromResponse(res, body);
}
return body as SubscriptionFeedPage;
}
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index 4781374d..64b72564 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -13,11 +13,18 @@ import { normalizeApiPayload } from "./text-normalize";
export class ApiError extends Error {
status: number;
code: string | null;
- constructor(message: string, status: number, code: string | null = null) {
+ requestId: string | null;
+ constructor(
+ message: string,
+ status: number,
+ code: string | null = null,
+ requestId: string | null = null,
+ ) {
super(message);
this.name = "ApiError";
this.status = status;
this.code = code;
+ this.requestId = requestId;
}
}
@@ -63,6 +70,19 @@ function toErrorCode(body: unknown): string | null {
return typeof candidate.code === "string" && candidate.code.length > 0 ? candidate.code : null;
}
+export function apiErrorFromResponse(response: Response, body: unknown): ApiError {
+ const bodyRequestId =
+ body && typeof body === "object" && "requestId" in body && typeof body.requestId === "string"
+ ? body.requestId
+ : null;
+ return new ApiError(
+ toErrorMessage(response.status, response.statusText, body),
+ response.status,
+ toErrorCode(body),
+ extractRequestId(response.headers) ?? bodyRequestId,
+ );
+}
+
export async function request(url: string, init?: RequestInit): Promise {
const method = init?.method ?? "GET";
let res: Response;
@@ -102,7 +122,7 @@ export async function request(url: string, init?: RequestInit): Promise {
requestId,
message: sanitizeDebugText(errorMessage),
});
- throw new ApiError(errorMessage, res.status, errorCode);
+ throw apiErrorFromResponse(res, body);
}
return body as T;
}
diff --git a/apps/web/src/lib/auth-routes.ts b/apps/web/src/lib/auth-routes.ts
index cc9a7111..5fc6fda6 100644
--- a/apps/web/src/lib/auth-routes.ts
+++ b/apps/web/src/lib/auth-routes.ts
@@ -7,6 +7,7 @@ export type RedirectTarget =
| "/profile"
| "/settings"
| "/subscriptions"
+ | "/subscriptions/groups"
| "/youtube-session"
| `/youtube-session?returnTo=${string}`
| `/shorts?v=${string}`;
@@ -61,6 +62,7 @@ export function sanitizeRedirect(value: string | undefined): RedirectTarget {
if (value === "/profile") return "/profile";
if (value === "/settings") return "/settings";
if (value === "/subscriptions") return "/subscriptions";
+ if (value === "/subscriptions/groups") return "/subscriptions/groups";
if (value === "/youtube-session") return "/youtube-session";
if (value === "/playlists" || value.startsWith("/playlists/")) return "/playlists";
return "/";
diff --git a/apps/web/src/lib/authed.ts b/apps/web/src/lib/authed.ts
index b63146bf..f9d1993d 100644
--- a/apps/web/src/lib/authed.ts
+++ b/apps/web/src/lib/authed.ts
@@ -1,5 +1,5 @@
import { useAuthStore } from "../stores/auth-store";
-import { ApiError } from "./api";
+import { ApiError, apiErrorFromResponse } from "./api";
import { recordApiError } from "./api-error-log";
import { isRefreshSessionRejected, refreshSession } from "./auth-session";
import { extractRequestId, recordClientEvent } from "./client-debug-log";
@@ -125,7 +125,6 @@ export async function authed(
export async function authedJson(url: string, init?: RequestInit): Promise {
const res = await authed(url, init);
- const body = normalizeApiPayload(await res.json());
- if (!res.ok) throw new ApiError((body as { error: string }).error, res.status);
- return body as T;
+ if (!res.ok) throw apiErrorFromResponse(res, await res.json().catch(() => null));
+ return normalizeApiPayload(await res.json()) as T;
}
diff --git a/apps/web/src/lib/group-membership-queries.ts b/apps/web/src/lib/group-membership-queries.ts
new file mode 100644
index 00000000..026b3276
--- /dev/null
+++ b/apps/web/src/lib/group-membership-queries.ts
@@ -0,0 +1,59 @@
+import { queryOptions } from "@tanstack/react-query";
+import type {
+ GroupedSubscription,
+ MembershipPage,
+ MembershipPageRequest,
+} from "../types/subscription-groups";
+import { authedJson } from "./authed";
+import { API_BASE } from "./env";
+import { membershipBatches } from "./membership-batches";
+import { subscriptionFilterParams } from "./subscription-group-selection";
+import { SUBSCRIPTION_GROUP_MEMBERSHIPS_KEY } from "./subscription-queries";
+
+export function groupMembershipPageOptions(
+ profile: string | undefined,
+ request: MembershipPageRequest,
+): ReturnType> {
+ return queryOptions({
+ queryKey: [...SUBSCRIPTION_GROUP_MEMBERSHIPS_KEY, profile, "page", request],
+ queryFn: ({ signal }) => {
+ const params = subscriptionFilterParams(request.filter);
+ params.set("page", String(request.page));
+ params.set("limit", String(request.limit));
+ if (request.search) params.set("search", request.search);
+ if (request.excluded && request.filter !== "all" && request.filter !== "ungrouped")
+ params.set("excluded", "true");
+ return authedJson(`${API_BASE}/subscriptions/group-memberships/page?${params}`, { signal });
+ },
+ staleTime: 60_000,
+ });
+}
+
+export function selectedMembershipOptions(
+ profile: string | undefined,
+ urls: string[],
+): ReturnType> {
+ return queryOptions({
+ queryKey: [...SUBSCRIPTION_GROUP_MEMBERSHIPS_KEY, profile, "selected", [...urls].sort()],
+ queryFn: async ({ signal }) => {
+ const { batches, invalid } = membershipBatches(urls);
+ if (invalid.length) throw new Error("Invalid selected channel URL");
+ const result: GroupedSubscription[] = [];
+ for (const channelUrls of batches) {
+ result.push(
+ ...(await authedJson(
+ `${API_BASE}/subscriptions/group-memberships/lookup`,
+ {
+ method: "POST",
+ signal,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ channelUrls }),
+ },
+ )),
+ );
+ }
+ return result;
+ },
+ staleTime: 60_000,
+ });
+}
diff --git a/apps/web/src/lib/group-pagination.ts b/apps/web/src/lib/group-pagination.ts
new file mode 100644
index 00000000..9f67ef80
--- /dev/null
+++ b/apps/web/src/lib/group-pagination.ts
@@ -0,0 +1,28 @@
+export type GroupPage = { page: number; size: number; pages: number; start: number; end: number };
+
+export function groupPage(total: number, size: number, page: number): GroupPage {
+ const capacity = Math.max(1, Math.floor(size));
+ const pages = Math.max(1, Math.ceil(total / capacity));
+ const current = Math.max(0, Math.min(page, pages - 1));
+ return {
+ page: current,
+ size: capacity,
+ pages,
+ start: current * capacity,
+ end: Math.min(total, (current + 1) * capacity),
+ };
+}
+
+export function fitGroupPage(
+ previous: GroupPage,
+ total: number,
+ height: number,
+ rowHeight: number,
+ reserved: number,
+ anchor: number,
+): GroupPage {
+ const size = Math.max(1, Math.floor((height - reserved) / rowHeight));
+ const visibleAnchor = anchor >= previous.start && anchor < previous.end;
+ const first = visibleAnchor ? anchor : previous.start;
+ return groupPage(total, size, size === previous.size ? previous.page : Math.floor(first / size));
+}
diff --git a/apps/web/src/lib/membership-batches.ts b/apps/web/src/lib/membership-batches.ts
new file mode 100644
index 00000000..8d49e2ae
--- /dev/null
+++ b/apps/web/src/lib/membership-batches.ts
@@ -0,0 +1,28 @@
+const MAX_CHANNELS = 500;
+const MAX_BODY_BYTES = 1024 * 1024;
+const MAX_URL_LENGTH = 2048;
+const ENCODER = new TextEncoder();
+const EMPTY_BODY_BYTES = ENCODER.encode(JSON.stringify({ channelUrls: [] })).byteLength;
+
+export function membershipBatches(urls: string[]): { batches: string[][]; invalid: string[] } {
+ const batches: string[][] = [];
+ const invalid: string[] = [];
+ let batch: string[] = [];
+ let bytes = EMPTY_BODY_BYTES;
+ for (const url of new Set(urls)) {
+ if (!url.trim() || url.length > MAX_URL_LENGTH) {
+ invalid.push(url);
+ continue;
+ }
+ const size = ENCODER.encode(JSON.stringify(url)).byteLength;
+ if (batch.length === MAX_CHANNELS || bytes + size + (batch.length ? 1 : 0) > MAX_BODY_BYTES) {
+ batches.push(batch);
+ batch = [];
+ bytes = EMPTY_BODY_BYTES;
+ }
+ bytes += size + (batch.length ? 1 : 0);
+ batch.push(url);
+ }
+ if (batch.length) batches.push(batch);
+ return { batches, invalid };
+}
diff --git a/apps/web/src/lib/profile-query-cache.ts b/apps/web/src/lib/profile-query-cache.ts
index d37e9e2a..51ee7b60 100644
--- a/apps/web/src/lib/profile-query-cache.ts
+++ b/apps/web/src/lib/profile-query-cache.ts
@@ -30,6 +30,8 @@ const PROFILE_QUERIES = new Set([
"shorts-recommendations",
"shorts-subscriptions-fallback",
"subscription-feed",
+ "subscription-groups",
+ "subscription-group-memberships",
"subscriptions",
"watch-later",
"watch-recommendations",
diff --git a/apps/web/src/lib/subscription-group-selection.ts b/apps/web/src/lib/subscription-group-selection.ts
new file mode 100644
index 00000000..246141c9
--- /dev/null
+++ b/apps/web/src/lib/subscription-group-selection.ts
@@ -0,0 +1,46 @@
+import type { GroupedSubscription, MembershipChange } from "../types/subscription-groups";
+
+export function channelMembershipChanges(
+ channel: GroupedSubscription,
+ desired: ReadonlySet,
+): MembershipChange[] {
+ return [
+ ...[...desired]
+ .filter((id) => !channel.groupIds.includes(id))
+ .map((groupId) => ({
+ groupId,
+ channelUrls: [channel.channelUrl],
+ action: "add" as const,
+ })),
+ ...channel.groupIds
+ .filter((id) => !desired.has(id))
+ .map((groupId) => ({
+ groupId,
+ channelUrls: [channel.channelUrl],
+ action: "remove" as const,
+ })),
+ ];
+}
+
+export function clearMembershipChanges(channels: GroupedSubscription[]): MembershipChange[] {
+ const urlsByGroup = new Map();
+ for (const channel of channels) {
+ for (const groupId of channel.groupIds) {
+ const urls = urlsByGroup.get(groupId) ?? [];
+ urls.push(channel.channelUrl);
+ urlsByGroup.set(groupId, urls);
+ }
+ }
+ return [...urlsByGroup].map(([groupId, channelUrls]) => ({
+ groupId,
+ channelUrls,
+ action: "remove",
+ }));
+}
+
+export function subscriptionFilterParams(filter = "all"): URLSearchParams {
+ const params = new URLSearchParams();
+ if (filter === "ungrouped") params.set("ungrouped", "true");
+ else if (filter !== "all") params.set("groupId", filter);
+ return params;
+}
diff --git a/apps/web/src/lib/subscription-queries.ts b/apps/web/src/lib/subscription-queries.ts
new file mode 100644
index 00000000..584e26b9
--- /dev/null
+++ b/apps/web/src/lib/subscription-queries.ts
@@ -0,0 +1,73 @@
+import {
+ type InfiniteData,
+ infiniteQueryOptions,
+ type QueryClient,
+ queryOptions,
+} from "@tanstack/react-query";
+import type { SubscriptionFeedPage } from "../types/api";
+import type { SubscriptionItem } from "../types/user";
+import { fetchSubscriptionFeed, fetchSubscriptions } from "./api-user";
+
+const SUBSCRIPTIONS_KEY = ["subscriptions"];
+const SUBSCRIPTION_FEED_KEY = ["subscription-feed"];
+export const SUBSCRIPTION_GROUPS_KEY = ["subscription-groups"];
+export const SUBSCRIPTION_GROUP_MEMBERSHIPS_KEY = ["subscription-group-memberships"];
+const SUBSCRIPTION_STALE_MS = 5 * 60 * 1000;
+
+export function subscriptionsQueryOptions(
+ filter = "all",
+): ReturnType> {
+ return queryOptions({
+ queryKey: filter === "all" ? SUBSCRIPTIONS_KEY : [...SUBSCRIPTIONS_KEY, filter],
+ queryFn: ({ signal }) => fetchSubscriptions(filter, signal),
+ staleTime: SUBSCRIPTION_STALE_MS,
+ });
+}
+
+export function subscriptionFeedQueryOptions(
+ filter = "all",
+): ReturnType<
+ typeof infiniteQueryOptions<
+ SubscriptionFeedPage,
+ Error,
+ InfiniteData,
+ string[],
+ string | null
+ >
+> {
+ return infiniteQueryOptions({
+ queryKey: filter === "all" ? SUBSCRIPTION_FEED_KEY : [...SUBSCRIPTION_FEED_KEY, filter],
+ queryFn: ({ pageParam, signal }) => fetchSubscriptionFeed(pageParam, signal, filter),
+ initialPageParam: null as string | null,
+ getNextPageParam: (last) => last.nextpage ?? undefined,
+ staleTime: SUBSCRIPTION_STALE_MS,
+ });
+}
+
+export async function invalidateSubscriptionQueries(
+ client: QueryClient,
+ change: "subscriptions" | "groups" | "memberships" = "subscriptions",
+): Promise {
+ const keys =
+ change === "groups"
+ ? [SUBSCRIPTION_GROUPS_KEY]
+ : [
+ SUBSCRIPTIONS_KEY,
+ SUBSCRIPTION_FEED_KEY,
+ SUBSCRIPTION_GROUPS_KEY,
+ SUBSCRIPTION_GROUP_MEMBERSHIPS_KEY,
+ ];
+ await Promise.all(
+ keys.map((queryKey) => {
+ const deferred =
+ change === "memberships" &&
+ (queryKey === SUBSCRIPTIONS_KEY || queryKey === SUBSCRIPTION_FEED_KEY);
+ // Membership edits leave global views unchanged; filtered views refresh when opened.
+ return client.invalidateQueries({
+ queryKey,
+ refetchType: deferred ? "none" : "active",
+ predicate: (query) => !deferred || query.queryKey.length > 1,
+ });
+ }),
+ );
+}
diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts
index f0df2748..7304c128 100644
--- a/apps/web/src/routeTree.gen.ts
+++ b/apps/web/src/routeTree.gen.ts
@@ -41,6 +41,7 @@ import { Route as ImportYoutubeRouteImport } from './routes/import/youtube'
import { Route as PlaylistsIdRouteImport } from './routes/playlists_.$id'
import { Route as ShortsVideoIdRouteImport } from './routes/shorts_.$videoId'
import { Route as SubscriptionsChannelsRouteImport } from './routes/subscriptions_.channels'
+import { Route as SubscriptionsGroupsRouteImport } from './routes/subscriptions_.groups'
import { Route as AuthOidcCallbackRouteImport } from './routes/auth.oidc.callback'
const IndexRoute = IndexRouteImport.update({
@@ -204,6 +205,11 @@ const SubscriptionsChannelsRoute = SubscriptionsChannelsRouteImport.update({
path: '/subscriptions/channels',
getParentRoute: () => rootRouteImport,
} as any)
+const SubscriptionsGroupsRoute = SubscriptionsGroupsRouteImport.update({
+ id: '/subscriptions_/groups',
+ path: '/subscriptions/groups',
+ getParentRoute: () => rootRouteImport,
+} as any)
const AuthOidcCallbackRoute = AuthOidcCallbackRouteImport.update({
id: '/auth/oidc/callback',
path: '/auth/oidc/callback',
@@ -242,6 +248,7 @@ export interface FileRoutesByFullPath {
'/playlists/$id': typeof PlaylistsIdRoute
'/shorts/$videoId': typeof ShortsVideoIdRoute
'/subscriptions/channels': typeof SubscriptionsChannelsRoute
+ '/subscriptions/groups': typeof SubscriptionsGroupsRoute
'/import/': typeof ImportIndexRoute
'/auth/oidc/callback': typeof AuthOidcCallbackRoute
}
@@ -276,6 +283,7 @@ export interface FileRoutesByTo {
'/playlists/$id': typeof PlaylistsIdRoute
'/shorts/$videoId': typeof ShortsVideoIdRoute
'/subscriptions/channels': typeof SubscriptionsChannelsRoute
+ '/subscriptions/groups': typeof SubscriptionsGroupsRoute
'/import': typeof ImportIndexRoute
'/auth/oidc/callback': typeof AuthOidcCallbackRoute
}
@@ -312,6 +320,7 @@ export interface FileRoutesById {
'/playlists_/$id': typeof PlaylistsIdRoute
'/shorts_/$videoId': typeof ShortsVideoIdRoute
'/subscriptions_/channels': typeof SubscriptionsChannelsRoute
+ '/subscriptions_/groups': typeof SubscriptionsGroupsRoute
'/import/': typeof ImportIndexRoute
'/auth/oidc/callback': typeof AuthOidcCallbackRoute
}
@@ -349,6 +358,7 @@ export interface FileRouteTypes {
| '/playlists/$id'
| '/shorts/$videoId'
| '/subscriptions/channels'
+ | '/subscriptions/groups'
| '/import/'
| '/auth/oidc/callback'
fileRoutesByTo: FileRoutesByTo
@@ -383,6 +393,7 @@ export interface FileRouteTypes {
| '/playlists/$id'
| '/shorts/$videoId'
| '/subscriptions/channels'
+ | '/subscriptions/groups'
| '/import'
| '/auth/oidc/callback'
id:
@@ -418,6 +429,7 @@ export interface FileRouteTypes {
| '/playlists_/$id'
| '/shorts_/$videoId'
| '/subscriptions_/channels'
+ | '/subscriptions_/groups'
| '/import/'
| '/auth/oidc/callback'
fileRoutesById: FileRoutesById
@@ -452,6 +464,7 @@ export interface RootRouteChildren {
PlaylistsIdRoute: typeof PlaylistsIdRoute
ShortsVideoIdRoute: typeof ShortsVideoIdRoute
SubscriptionsChannelsRoute: typeof SubscriptionsChannelsRoute
+ SubscriptionsGroupsRoute: typeof SubscriptionsGroupsRoute
AuthOidcCallbackRoute: typeof AuthOidcCallbackRoute
}
@@ -681,6 +694,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SubscriptionsChannelsRouteImport
parentRoute: typeof rootRouteImport
}
+ '/subscriptions_/groups': {
+ id: '/subscriptions_/groups'
+ path: '/subscriptions/groups'
+ fullPath: '/subscriptions/groups'
+ preLoaderRoute: typeof SubscriptionsGroupsRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/auth/oidc/callback': {
id: '/auth/oidc/callback'
path: '/auth/oidc/callback'
@@ -736,6 +756,7 @@ const rootRouteChildren: RootRouteChildren = {
PlaylistsIdRoute: PlaylistsIdRoute,
ShortsVideoIdRoute: ShortsVideoIdRoute,
SubscriptionsChannelsRoute: SubscriptionsChannelsRoute,
+ SubscriptionsGroupsRoute: SubscriptionsGroupsRoute,
AuthOidcCallbackRoute: AuthOidcCallbackRoute,
}
export const routeTree = rootRouteImport
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index ae5f1342..c5bd75cf 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -202,7 +202,10 @@ function RootLayoutContent() {
{watchCinemaPage ? !isMobile && : }
-
+
diff --git a/apps/web/src/routes/subscriptions.tsx b/apps/web/src/routes/subscriptions.tsx
index f8b92f04..7cb26cd8 100644
--- a/apps/web/src/routes/subscriptions.tsx
+++ b/apps/web/src/routes/subscriptions.tsx
@@ -2,49 +2,49 @@ import { useQueryClient } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useMemo, useRef } from "react";
import { ScrollSentinel } from "../components/scroll-sentinel";
+import { SubscriptionGroupFilter } from "../components/subscription-group-filter";
import { SubscriptionsHeader } from "../components/subscriptions-header";
import { VideoGrid } from "../components/video-grid";
import { VideoGridSkeleton } from "../components/video-grid-skeleton";
import { useBlockedFilter } from "../hooks/use-blocked-filter";
import { streamQueryOptions } from "../hooks/use-stream";
-import { SUBSCRIPTION_FEED_KEY, useSubscriptionFeed } from "../hooks/use-subscription-feed";
-import { SUBSCRIPTIONS_KEY, useSubscriptions } from "../hooks/use-subscriptions";
+import { useSubscriptionFeed } from "../hooks/use-subscription-feed";
+import { useSubscriptions } from "../hooks/use-subscriptions";
import { ApiError } from "../lib/api";
-import { fetchSubscriptionFeed, fetchSubscriptions } from "../lib/api-user";
+import {
+ subscriptionFeedQueryOptions,
+ subscriptionsQueryOptions,
+} from "../lib/subscription-queries";
import { m } from "../paraglide/messages.js";
-const SUBSCRIPTION_STALE_MS = 5 * 60 * 1000;
-
-function nextSubscriptionPage(last: Awaited>) {
- return last.nextpage ?? undefined;
-}
-
function SubscriptionsPage() {
const queryClient = useQueryClient();
const prefetchedIdsRef = useRef(new Set());
- const { query } = useSubscriptions();
+ const { group = "all" } = Route.useSearch();
+ const navigate = Route.useNavigate();
+ const { query } = useSubscriptions(group);
const subscriptions = query.data ?? [];
- const { streams, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
- useSubscriptionFeed();
+ const {
+ streams,
+ isLoading,
+ isLoadingError,
+ isFetchNextPageError,
+ isFetchingNextPage,
+ hasNextPage,
+ fetchNextPage,
+ refetch,
+ error: feedError,
+ } = useSubscriptionFeed(group);
const { filter } = useBlockedFilter();
const visible = useMemo(() => filter(streams), [filter, streams]);
function prefetchChannels() {
- void queryClient.prefetchQuery({
- queryKey: SUBSCRIPTIONS_KEY,
- queryFn: fetchSubscriptions,
- staleTime: SUBSCRIPTION_STALE_MS,
- });
+ void queryClient.prefetchQuery(subscriptionsQueryOptions(group));
}
function prefetchVideos() {
- void queryClient.prefetchInfiniteQuery({
- queryKey: SUBSCRIPTION_FEED_KEY,
- queryFn: ({ pageParam, signal }) => fetchSubscriptionFeed(pageParam as string | null, signal),
- initialPageParam: null as string | null,
- getNextPageParam: nextSubscriptionPage,
- staleTime: SUBSCRIPTION_STALE_MS,
- });
+ if (!query.data?.length) return;
+ void queryClient.prefetchInfiniteQuery(subscriptionFeedQueryOptions(group));
}
useEffect(() => {
@@ -60,31 +60,67 @@ function SubscriptionsPage() {
}
}, [streams, queryClient]);
- if (query.isSuccess && subscriptions.length === 0) {
- return (
-
-
{m.ui_no_subscriptions_yet_2()}
-
- );
- }
-
return (
+
void navigate({ search: { group: value }, replace })}
+ />
{query.isLoading || isLoading ? (
+ ) : query.isLoadingError || isLoadingError ? (
+
+
{m.subscriptions_feed_load_error()}
+
{
+ void query.refetch();
+ refetch();
+ }}
+ className="min-h-9 border border-border-strong px-3 text-fg hover:bg-surface disabled:opacity-40"
+ >
+ {m.ui_retry()}
+
+
) : (
<>
+ {visible.length === 0 && (
+
+ {group === "all" && subscriptions.length === 0
+ ? m.ui_no_subscriptions_yet_2()
+ : m.sg_empty_feed()}
+
+ )}
{isFetchingNextPage && }
+ {isFetchNextPageError && (
+
+
{m.subscriptions_feed_next_page_error()}
+
+ {m.ui_retry()}
+
+
+ )}
>
)}
@@ -93,5 +129,8 @@ function SubscriptionsPage() {
}
export const Route = createFileRoute("/subscriptions")({
+ validateSearch: (search: Record): { group?: string } => ({
+ group: typeof search.group === "string" && search.group ? search.group : "all",
+ }),
component: SubscriptionsPage,
});
diff --git a/apps/web/src/routes/subscriptions_.channels.tsx b/apps/web/src/routes/subscriptions_.channels.tsx
index 49c5238a..2d68464e 100644
--- a/apps/web/src/routes/subscriptions_.channels.tsx
+++ b/apps/web/src/routes/subscriptions_.channels.tsx
@@ -1,52 +1,34 @@
import { useQueryClient } from "@tanstack/react-query";
-import { createFileRoute } from "@tanstack/react-router";
+import { createFileRoute, Link } from "@tanstack/react-router";
import { SubscriptionChannelList } from "../components/subscription-channel-list";
+import { SubscriptionGroupFilter } from "../components/subscription-group-filter";
import { SubscriptionsHeader } from "../components/subscriptions-header";
import { VideoGridSkeleton } from "../components/video-grid-skeleton";
import { useBlockedFilter } from "../hooks/use-blocked-filter";
-import { SUBSCRIPTION_FEED_KEY } from "../hooks/use-subscription-feed";
-import { SUBSCRIPTIONS_KEY, useSubscriptions } from "../hooks/use-subscriptions";
-import { fetchSubscriptionFeed, fetchSubscriptions } from "../lib/api-user";
+import { useSubscriptions } from "../hooks/use-subscriptions";
+import {
+ subscriptionFeedQueryOptions,
+ subscriptionsQueryOptions,
+} from "../lib/subscription-queries";
import { m } from "../paraglide/messages.js";
-const SUBSCRIPTION_STALE_MS = 5 * 60 * 1000;
-
-function nextSubscriptionPage(last: Awaited>) {
- return last.nextpage ?? undefined;
-}
-
function SubscriptionChannelsPage() {
const queryClient = useQueryClient();
- const { query } = useSubscriptions();
+ const { group = "all" } = Route.useSearch();
+ const navigate = Route.useNavigate();
+ const { query } = useSubscriptions(group);
const { isChannelIdentityBlocked } = useBlockedFilter();
const subscriptions = (query.data ?? []).filter(
(item) => !isChannelIdentityBlocked({ url: item.channelUrl, name: item.name }),
);
function prefetchChannels() {
- void queryClient.prefetchQuery({
- queryKey: SUBSCRIPTIONS_KEY,
- queryFn: fetchSubscriptions,
- staleTime: SUBSCRIPTION_STALE_MS,
- });
+ void queryClient.prefetchQuery(subscriptionsQueryOptions(group));
}
function prefetchVideos() {
- void queryClient.prefetchInfiniteQuery({
- queryKey: SUBSCRIPTION_FEED_KEY,
- queryFn: ({ pageParam, signal }) => fetchSubscriptionFeed(pageParam as string | null, signal),
- initialPageParam: null as string | null,
- getNextPageParam: nextSubscriptionPage,
- staleTime: SUBSCRIPTION_STALE_MS,
- });
- }
-
- if (query.isSuccess && subscriptions.length === 0) {
- return (
-
-
{m.ui_no_subscriptions_yet_2()}
-
- );
+ if (!query.data?.length) return;
+ void queryClient.prefetchInfiniteQuery(subscriptionFeedQueryOptions(group));
}
return (
@@ -54,11 +36,41 @@ function SubscriptionChannelsPage() {
+
+ void navigate({ search: { group: value }, replace })}
+ />
+
+ {m.sg_manage_groups()}
+
+
{query.isLoading ? (
+ ) : query.isError ? (
+
+
{m.sg_channels_load_error()}
+
void query.refetch()}
+ className="min-h-9 border border-border-strong px-3 text-fg hover:bg-surface disabled:opacity-40"
+ >
+ {m.ui_retry()}
+
+
+ ) : subscriptions.length === 0 ? (
+
+ {group === "all" ? m.ui_no_subscriptions_yet_2() : m.sg_no_channel_match()}
+
) : (
)}
@@ -67,5 +79,8 @@ function SubscriptionChannelsPage() {
}
export const Route = createFileRoute("/subscriptions_/channels")({
+ validateSearch: (search: Record): { group?: string } => ({
+ group: typeof search.group === "string" && search.group ? search.group : "all",
+ }),
component: SubscriptionChannelsPage,
});
diff --git a/apps/web/src/routes/subscriptions_.groups.tsx b/apps/web/src/routes/subscriptions_.groups.tsx
new file mode 100644
index 00000000..102ea529
--- /dev/null
+++ b/apps/web/src/routes/subscriptions_.groups.tsx
@@ -0,0 +1,12 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { GroupManager } from "../components/subscription-groups/group-manager";
+import { useAuth } from "../hooks/use-auth";
+
+function SubscriptionGroupsPage(): React.JSX.Element {
+ const { me } = useAuth();
+ return ;
+}
+
+export const Route = createFileRoute("/subscriptions_/groups")({
+ component: SubscriptionGroupsPage,
+});
diff --git a/apps/web/src/styles/subscription-groups.css b/apps/web/src/styles/subscription-groups.css
new file mode 100644
index 00000000..6ee1d3a1
--- /dev/null
+++ b/apps/web/src/styles/subscription-groups.css
@@ -0,0 +1,79 @@
+@reference "../index.css";
+
+@layer components {
+ .sg-button {
+ @apply inline-flex min-h-8 items-center justify-center gap-1.5 border border-border px-2 py-1 text-xs font-medium text-fg-muted transition-colors hover:border-border-strong hover:bg-surface-strong hover:text-fg disabled:cursor-not-allowed disabled:opacity-40;
+ }
+
+ .sg-membership-toggle {
+ @apply bg-app;
+ }
+
+ .sg-membership-toggle:enabled {
+ @apply cursor-pointer border-fg-muted bg-surface-strong text-fg hover:border-fg hover:bg-surface-soft;
+ }
+
+ .sg-membership-toggle:enabled[aria-pressed="true"] {
+ @apply border-fg bg-fg text-app hover:bg-fg-strong;
+ }
+
+ .sg-chip {
+ @apply border border-border-strong px-1.5 py-0.5 text-xs text-fg-muted;
+ }
+
+ .sg-menu-item {
+ @apply px-3 py-2 text-left text-xs text-fg-muted hover:bg-surface-strong hover:text-fg disabled:opacity-40;
+ }
+}
+
+.sg-manager :is(button, input, select, a):focus-visible {
+ outline: 2px solid var(--color-accent);
+ outline-offset: 3px;
+}
+
+.sg-manager :is(input, select) {
+ caret-color: var(--color-accent);
+}
+
+.sg-combobox:focus-within {
+ outline: 2px solid var(--color-accent);
+ outline-offset: 2px;
+}
+
+.sg-manager .sg-combobox input:focus-visible {
+ outline: none;
+}
+
+.sg-manager ::selection {
+ background: var(--color-accent);
+ color: var(--color-app);
+}
+
+.sg-manager {
+ scrollbar-color: var(--color-border-strong) var(--color-surface);
+ scroll-margin-top: 4rem;
+}
+
+@media (min-width: 1024px) and (min-height: 600px) {
+ .sg-page {
+ display: flex;
+ height: 100dvh;
+ flex-direction: column;
+ padding-bottom: 0.5rem;
+ }
+
+ .sg-page > .sg-manager {
+ min-height: 0;
+ flex: 1;
+ }
+
+ .sg-page .sg-workspace {
+ grid-template-rows: minmax(0, 1fr);
+ }
+
+ .sg-page > footer {
+ flex-shrink: 0;
+ margin-top: 0.5rem;
+ padding-block: 0.5rem;
+ }
+}
diff --git a/apps/web/src/types/subscription-groups.ts b/apps/web/src/types/subscription-groups.ts
new file mode 100644
index 00000000..3d211efd
--- /dev/null
+++ b/apps/web/src/types/subscription-groups.ts
@@ -0,0 +1,31 @@
+import type { SubscriptionItem } from "./user";
+
+export type SubscriptionGroup = {
+ id: string;
+ name: string;
+ channelCount: number;
+ createdAt: number;
+ updatedAt: number;
+};
+
+export type GroupedSubscription = SubscriptionItem & { groupIds: string[] };
+export type MembershipPage = {
+ items: GroupedSubscription[];
+ total: number;
+ totalSubscriptions: number;
+ ungroupedCount: number;
+ page: number;
+ limit: number;
+};
+export type MembershipPageRequest = {
+ page: number;
+ limit: number;
+ filter: string;
+ search: string;
+ excluded: boolean;
+};
+export type MembershipChange = {
+ groupId: string;
+ channelUrls: string[];
+ action: "add" | "remove";
+};
diff --git a/apps/web/tests/api-subscription-groups.test.ts b/apps/web/tests/api-subscription-groups.test.ts
new file mode 100644
index 00000000..2ec942fc
--- /dev/null
+++ b/apps/web/tests/api-subscription-groups.test.ts
@@ -0,0 +1,91 @@
+import { afterEach, beforeEach, expect, test } from "bun:test";
+import { ApiError } from "../src/lib/api";
+import {
+ createSubscriptionGroup,
+ deleteSubscriptionGroup,
+ MembershipUpdateError,
+ renameSubscriptionGroup,
+ updateGroupMemberships,
+} from "../src/lib/api-subscription-groups";
+import { useAuthStore } from "../src/stores/auth-store";
+
+const originalFetch = globalThis.fetch;
+type Call = { url: string; method: string; body: Record };
+let calls: Call[] = [];
+let failGroup = "";
+beforeEach(() => {
+ calls = [];
+ failGroup = "";
+ useAuthStore.getState().setToken("subscription-groups-test");
+ globalThis.fetch = async (input, init) => {
+ const url = String(input);
+ calls.push({
+ url,
+ method: init?.method ?? "GET",
+ body: typeof init?.body === "string" ? JSON.parse(init.body) : {},
+ });
+ if (failGroup && url.includes(failGroup))
+ return Response.json(
+ { error: "Conflict", code: "subscription_group_name_conflict" },
+ { status: 409 },
+ );
+ if (!init?.method) return Response.json([]);
+ if (init.method === "POST") return Response.json({ id: "new", name: "Tech" }, { status: 201 });
+ return new Response(null, { status: 204 });
+ };
+});
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+ useAuthStore.getState().setSignedOut();
+});
+
+test("batch writes deduplicate and split at the 500-channel API limit", async () => {
+ const urls = Array.from({ length: 1001 }, (_, index) => `https://youtube.com/channel/${index}`);
+ await updateGroupMemberships([
+ { groupId: "tech", action: "add", channelUrls: [...urls, urls[0]] },
+ ]);
+ expect(calls.map((call) => (call.body.channelUrls as string[]).length)).toEqual([500, 500, 1]);
+ expect(calls.every((call) => call.method === "PUT" && call.url.endsWith("/tech/channels"))).toBe(
+ true,
+ );
+});
+
+test("failed groups report affected channels while other groups are attempted", async () => {
+ failGroup = "/failed/";
+ try {
+ await updateGroupMemberships([
+ { groupId: "failed", action: "remove", channelUrls: ["one", "two"] },
+ { groupId: "ok", action: "remove", channelUrls: ["two"] },
+ ]);
+ throw new Error("Expected a partial failure");
+ } catch (error) {
+ expect(error).toBeInstanceOf(MembershipUpdateError);
+ if (error instanceof MembershipUpdateError) expect(error.failedUrls).toEqual(["one", "two"]);
+ }
+ expect(calls.length).toBe(2);
+ expect(calls.every((call) => call.method === "DELETE" && call.url.includes("/groups/"))).toBe(
+ true,
+ );
+});
+
+test("empty membership changes do not make requests", async () => {
+ await updateGroupMemberships([{ groupId: "tech", action: "add", channelUrls: [] }]);
+ expect(calls).toEqual([]);
+});
+
+test("group CRUD handles bodyless success and preserves backend error codes", async () => {
+ await createSubscriptionGroup(" Tech ");
+ await renameSubscriptionGroup("new", " Science ");
+ await deleteSubscriptionGroup("new");
+ expect(calls.map((call) => call.method)).toEqual(["POST", "PUT", "DELETE"]);
+ expect(calls[0].body).toEqual({ name: "Tech" });
+ expect(calls[1].body).toEqual({ name: "Science" });
+ failGroup = "/groups";
+ try {
+ await createSubscriptionGroup("Tech");
+ throw new Error("Expected a duplicate-name failure");
+ } catch (error) {
+ expect(error).toBeInstanceOf(ApiError);
+ if (error instanceof ApiError) expect(error.code).toBe("subscription_group_name_conflict");
+ }
+});
diff --git a/apps/web/tests/group-manager-memberships.test.tsx b/apps/web/tests/group-manager-memberships.test.tsx
new file mode 100644
index 00000000..a73e4c38
--- /dev/null
+++ b/apps/web/tests/group-manager-memberships.test.tsx
@@ -0,0 +1,126 @@
+import { afterEach, expect, test } from "bun:test";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { renderToStaticMarkup } from "react-dom/server";
+import { ChannelGroupEditor } from "../src/components/subscription-groups/channel-group-editor";
+import { useGroupManager } from "../src/hooks/use-group-manager";
+import { updateGroupMemberships } from "../src/lib/api-subscription-groups";
+import {
+ groupMembershipPageOptions,
+ selectedMembershipOptions,
+} from "../src/lib/group-membership-queries";
+import { channelMembershipChanges } from "../src/lib/subscription-group-selection";
+import { useAuthStore } from "../src/stores/auth-store";
+import type { GroupedSubscription } from "../src/types/subscription-groups";
+
+const originalFetch = globalThis.fetch;
+const clients: QueryClient[] = [];
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+ useAuthStore.getState().setSignedOut();
+ for (const client of clients.splice(0)) client.clear();
+});
+const groups = ["tech", "science", "travel"].map((id) => ({
+ id,
+ name: id,
+ channelCount: 1,
+ createdAt: 0,
+ updatedAt: 0,
+}));
+const channel: GroupedSubscription = {
+ channelUrl: "https://example.com/channel/selected",
+ name: "Selected channel",
+ avatarUrl: "",
+ subscribedAt: 0,
+ groupIds: ["tech", "travel"],
+};
+const other = { ...channel, channelUrl: "https://example.com/channel/other", groupIds: [] };
+
+function readManager(current: string[], draft?: string[]) {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ clients.push(client);
+ const options = groupMembershipPageOptions(undefined, {
+ page: 0,
+ limit: 10,
+ filter: "all",
+ excluded: false,
+ search: "",
+ });
+ client.setQueryData(options.queryKey, {
+ items: [channel, other],
+ total: 2,
+ totalSubscriptions: 2,
+ ungroupedCount: 1,
+ page: 0,
+ limit: 10,
+ });
+ client.setQueryData(selectedMembershipOptions(undefined, [channel.channelUrl]).queryKey, [
+ { ...channel, groupIds: current },
+ ]);
+ let state: ReturnType | undefined;
+ function ReadManager(): React.JSX.Element | null {
+ const manager = useGroupManager(groups, true);
+ state = manager;
+ if (!manager.validSelected.has(channel.channelUrl)) {
+ manager.toggle(channel.channelUrl);
+ return null;
+ }
+ if (draft && !manager.drafts.has(channel.channelUrl)) {
+ manager.setDraft(channel.channelUrl, new Set(draft));
+ return null;
+ }
+ const visible = manager.visible[0];
+ return (
+ {}}
+ busy={false}
+ disabled={false}
+ onSave={() => {}}
+ onCancel={() => {}}
+ />
+ );
+ }
+ const markup = renderToStaticMarkup(
+
+
+ ,
+ );
+ if (!state) throw new Error("Manager did not render");
+ return { state, markup, client, options };
+}
+
+test("inline editing shows refreshed selected memberships without changing page order or cache", () => {
+ const { state, markup, client, options } = readManager(["travel"]);
+ expect(state.visible.map((item) => item.channelUrl)).toEqual([
+ channel.channelUrl,
+ other.channelUrl,
+ ]);
+ expect(state.visible[0].groupIds).toEqual(["travel"]);
+ expect(state.visible[1]).toEqual(other);
+ expect(markup).toContain('aria-label="Remove travel"');
+ expect(markup).not.toContain('aria-label="Remove tech"');
+ expect(client.getQueryData(options.queryKey)?.items[0].groupIds).toEqual(["tech", "travel"]);
+});
+
+test("a retained draft is saved against refreshed memberships, including externally removed groups", async () => {
+ const desired = ["tech", "science", "travel"];
+ const { state, markup } = readManager(["travel"], desired);
+ const draft = state.drafts.get(channel.channelUrl);
+ if (!draft) throw new Error("Draft was lost");
+ expect([...draft]).toEqual(desired);
+ for (const id of desired) expect(markup).toContain(`aria-label="Remove ${id}"`);
+ const stored = new Set(["travel"]);
+ useAuthStore.getState().setToken("membership-regression");
+ globalThis.fetch = async (input, init) => {
+ const group = new URL(String(input), "https://fixture.test").pathname.split("/").at(-2);
+ if (!group) throw new Error("Missing group ID");
+ expect(JSON.parse(String(init?.body)).channelUrls).toEqual([channel.channelUrl]);
+ if (init?.method === "PUT") stored.add(group);
+ else stored.delete(group);
+ return new Response(null, { status: 204 });
+ };
+ await updateGroupMemberships(channelMembershipChanges(state.visible[0], draft));
+ expect([...stored].sort()).toEqual([...desired].sort());
+});
diff --git a/apps/web/tests/group-membership-queries.test.ts b/apps/web/tests/group-membership-queries.test.ts
new file mode 100644
index 00000000..c082a309
--- /dev/null
+++ b/apps/web/tests/group-membership-queries.test.ts
@@ -0,0 +1,132 @@
+import { afterEach, beforeEach, expect, test } from "bun:test";
+import { QueryClient, QueryObserver } from "@tanstack/react-query";
+import {
+ groupMembershipPageOptions,
+ selectedMembershipOptions,
+} from "../src/lib/group-membership-queries";
+import { invalidateSubscriptionQueries } from "../src/lib/subscription-queries";
+import { useAuthStore } from "../src/stores/auth-store";
+
+const originalFetch = globalThis.fetch;
+const clients: QueryClient[] = [];
+function client(): QueryClient {
+ const value = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ clients.push(value);
+ return value;
+}
+beforeEach(() => useAuthStore.getState().setToken("page-test"));
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+ useAuthStore.getState().setSignedOut();
+ for (const value of clients.splice(0)) value.clear();
+});
+const request = { page: 1, limit: 7, filter: "tech", excluded: true, search: "A & B" };
+
+test("pages and filters have independent caches and send bounded server parameters", async () => {
+ const cache = client();
+ const calls: URL[] = [];
+ globalThis.fetch = async (input) => {
+ const url = new URL(String(input), "https://fixture.test");
+ calls.push(url);
+ return Response.json({
+ items: [],
+ total: 1001,
+ totalSubscriptions: 1200,
+ ungroupedCount: 20,
+ page: Number(url.searchParams.get("page")),
+ limit: Number(url.searchParams.get("limit")),
+ });
+ };
+ const requests = [
+ request,
+ { ...request, page: 2 },
+ { ...request, filter: "ungrouped", excluded: false },
+ { ...request, filter: "all", excluded: false },
+ ];
+ for (const value of requests)
+ await cache.fetchQuery(groupMembershipPageOptions("profile", value));
+ await cache.fetchQuery(groupMembershipPageOptions("profile", request));
+ expect(calls).toHaveLength(4);
+ expect(calls.every((url) => url.pathname === "/api/subscriptions/group-memberships/page")).toBe(
+ true,
+ );
+ expect(Object.fromEntries(calls[0].searchParams)).toEqual({
+ page: "1",
+ limit: "7",
+ groupId: "tech",
+ excluded: "true",
+ search: "A & B",
+ });
+ expect(calls[2].searchParams.get("ungrouped")).toBe("true");
+ expect(calls[3].searchParams.has("groupId")).toBe(false);
+});
+
+test("selection lookup splits large selections, deduplicates URLs and stays account scoped", async () => {
+ const cache = client();
+ const urls = Array.from({ length: 1201 }, (_, i) => `https://example.com/channel/${i}`);
+ const sizes: number[] = [];
+ globalThis.fetch = async (input, init) => {
+ expect(String(input)).toBe("/api/subscriptions/group-memberships/lookup");
+ expect(init?.method).toBe("POST");
+ const body = JSON.parse(String(init?.body));
+ sizes.push(body.channelUrls.length);
+ return Response.json(
+ body.channelUrls.map((channelUrl: string) => ({
+ channelUrl,
+ name: channelUrl,
+ avatarUrl: "",
+ subscribedAt: 0,
+ groupIds: [],
+ })),
+ );
+ };
+ expect(
+ await cache.fetchQuery(selectedMembershipOptions("first", [...urls, urls[0]])),
+ ).toHaveLength(1201);
+ expect(sizes).toEqual([500, 500, 201]);
+ expect(cache.getQueryData(selectedMembershipOptions("second", urls).queryKey)).toBeUndefined();
+});
+
+test("leaving a page or selection aborts the underlying read", () => {
+ for (const options of [
+ groupMembershipPageOptions("profile", request),
+ selectedMembershipOptions("profile", ["channel"]),
+ ]) {
+ const cache = client();
+ let signal: AbortSignal | null | undefined;
+ globalThis.fetch = async (_input, init) => {
+ signal = init?.signal;
+ return new Promise((_resolve, reject) =>
+ signal?.addEventListener("abort", () => reject(signal?.reason), { once: true }),
+ );
+ };
+ const observer = new QueryObserver(cache, options);
+ const stop = observer.subscribe(() => {});
+ expect(signal?.aborted).toBe(false);
+ stop();
+ expect(signal?.aborted).toBe(true);
+ }
+});
+
+test("membership changes refresh off-page selections and remove unsubscribed channels", async () => {
+ const cache = client();
+ let present = true;
+ let groupIds: string[] = [];
+ globalThis.fetch = async () =>
+ Response.json(
+ present
+ ? [{ channelUrl: "off-page", name: "Off page", avatarUrl: "", subscribedAt: 0, groupIds }]
+ : [],
+ );
+ const options = selectedMembershipOptions("profile", ["off-page"]);
+ await cache.fetchQuery(options);
+ const observer = new QueryObserver(cache, options);
+ const stop = observer.subscribe(() => {});
+ groupIds = ["tech", "science"];
+ await invalidateSubscriptionQueries(cache, "memberships");
+ expect(cache.getQueryData(options.queryKey)?.[0].groupIds).toEqual(groupIds);
+ present = false;
+ await invalidateSubscriptionQueries(cache, "subscriptions");
+ expect(cache.getQueryData(options.queryKey)).toEqual([]);
+ stop();
+});
diff --git a/apps/web/tests/group-pagination.test.ts b/apps/web/tests/group-pagination.test.ts
new file mode 100644
index 00000000..bd21158a
--- /dev/null
+++ b/apps/web/tests/group-pagination.test.ts
@@ -0,0 +1,59 @@
+import { expect, test } from "bun:test";
+import { fitGroupPage, groupPage } from "../src/lib/group-pagination";
+
+test("replacement pages cover 150 channels exactly once, including the partial last page", () => {
+ const channels = Array.from({ length: 150 }, (_, index) => index);
+ for (const size of [1, 6, 8, 11, 17]) {
+ const seen: number[] = [];
+ const first = groupPage(channels.length, size, 0);
+ for (let index = 0; index < first.pages; index++) {
+ const page = groupPage(channels.length, size, index);
+ seen.push(...channels.slice(page.start, page.end));
+ }
+ expect(seen).toEqual(channels);
+ }
+});
+
+test("viewport capacity budgets the full expanded editor without losing the last selected row", () => {
+ const before = groupPage(150, 8, 1);
+ const editing = fitGroupPage(before, 150, 448, 56, 72, 15);
+ expect(editing.size).toBe(6);
+ expect(editing.start).toBeLessThanOrEqual(15);
+ expect(editing.end).toBeGreaterThan(15);
+ expect(editing.size * 56 + 72).toBeLessThanOrEqual(448);
+ const closed = fitGroupPage(editing, 150, 448, 56, 0, 15);
+ expect(closed.start).toBeLessThanOrEqual(15);
+ expect(closed.end).toBeGreaterThan(15);
+});
+
+test("resizing preserves the first visible item when selection is on another page", () => {
+ const before = groupPage(150, 8, 5);
+ const resized = fitGroupPage(before, 150, 340, 56, 72, 2);
+ expect(resized.start).toBeLessThanOrEqual(before.start);
+ expect(resized.end).toBeGreaterThan(before.start);
+ expect(resized.size).toBe(4);
+});
+
+test("navigating while one channel is selected does not force its page back into view", () => {
+ const next = groupPage(150, 6, 3);
+ expect(fitGroupPage(next, 150, 448, 56, 72, 15)).toEqual(next);
+});
+
+test("selecting a second channel keeps the clicked row visible when the editor closes", () => {
+ const editing = groupPage(150, 5, 1);
+ const multiple = fitGroupPage(editing, 150, 372, 56, 0, 6);
+ expect(multiple.start).toBeLessThanOrEqual(6);
+ expect(multiple.end).toBeGreaterThan(6);
+});
+
+test("deleting the last group on a page clamps to an available page", () => {
+ const before = groupPage(19, 6, 3);
+ const after = fitGroupPage(before, 18, 216, 36, 0, -1);
+ expect(after).toEqual({ page: 2, size: 6, pages: 3, start: 12, end: 18 });
+ expect(groupPage(0, 6, 3)).toEqual({ page: 0, size: 6, pages: 1, start: 0, end: 0 });
+});
+
+test("very short viewports and notices retain at least one usable row", () => {
+ expect(fitGroupPage(groupPage(18, 6, 0), 18, 30, 56, 72, -1).size).toBe(1);
+ expect(groupPage(18, 0, -1).size).toBe(1);
+});
diff --git a/apps/web/tests/group-refresh-errors.test.tsx b/apps/web/tests/group-refresh-errors.test.tsx
new file mode 100644
index 00000000..47e8796c
--- /dev/null
+++ b/apps/web/tests/group-refresh-errors.test.tsx
@@ -0,0 +1,163 @@
+import { afterEach, expect, test } from "bun:test";
+import { QueryClient, QueryClientProvider, QueryObserver } from "@tanstack/react-query";
+import { renderToStaticMarkup } from "react-dom/server";
+import { GroupManagerData } from "../src/components/subscription-groups/group-manager-data";
+import { useGroupActions } from "../src/hooks/use-subscription-groups";
+import { MembershipUpdateError } from "../src/lib/api-subscription-groups";
+import { m } from "../src/paraglide/messages.js";
+import type { GroupedSubscription, SubscriptionGroup } from "../src/types/subscription-groups";
+
+const cleanups: Array<() => void> = [];
+afterEach(() => {
+ for (const cleanup of cleanups.splice(0)) cleanup();
+});
+
+function readActions(client: QueryClient, enabled: boolean): ReturnType {
+ let actions: ReturnType | undefined;
+ function ReadActions(): null {
+ actions = useGroupActions(enabled);
+ return null;
+ }
+ renderToStaticMarkup(
+
+
+ ,
+ );
+ if (!actions) throw new Error("Actions did not render");
+ return actions;
+}
+
+function setup() {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const failedReads = new Set();
+ const state: { memberships: string[]; writes: number } = { memberships: [], writes: 0 };
+ const group: SubscriptionGroup = {
+ id: "tech",
+ name: "Tech",
+ channelCount: 0,
+ createdAt: 0,
+ updatedAt: 0,
+ };
+ const channel: GroupedSubscription = {
+ channelUrl: "https://www.youtube.com/channel/example",
+ name: "Example",
+ avatarUrl: "",
+ subscribedAt: 0,
+ groupIds: [],
+ };
+ const groupKey = ["subscription-groups", "profile"];
+ const channelKey = ["subscription-group-memberships", "profile"];
+ client.setQueryData(groupKey, [group]);
+ client.setQueryData(channelKey, [channel]);
+ const groups = new QueryObserver(client, {
+ queryKey: groupKey,
+ staleTime: Infinity,
+ queryFn: async () => {
+ if (failedReads.has("groups")) throw new Error("Group refresh unavailable");
+ return [{ ...group, channelCount: state.memberships.length ? 1 : 0 }];
+ },
+ });
+ const channels = new QueryObserver(client, {
+ queryKey: channelKey,
+ staleTime: Infinity,
+ queryFn: async () => {
+ if (failedReads.has("memberships")) throw new Error("Membership refresh unavailable");
+ return [{ ...channel, groupIds: [...state.memberships] }];
+ },
+ });
+ const unsubscribers = [groups.subscribe(() => {}), channels.subscribe(() => {})];
+ cleanups.push(() => {
+ for (const unsubscribe of unsubscribers) unsubscribe();
+ client.clear();
+ });
+ return {
+ client,
+ groups,
+ channels,
+ state,
+ failedReads,
+ write: async () => {
+ state.writes++;
+ state.memberships = ["tech"];
+ },
+ retry: () => Promise.all([groups.refetch(), channels.refetch()]),
+ actions: () =>
+ readActions(
+ client,
+ groups.getCurrentResult().isSuccess && channels.getCurrentResult().isSuccess,
+ ),
+ render: () =>
+ renderToStaticMarkup(
+
+ Edit memberships
+ ,
+ ),
+ };
+}
+
+test("saved memberships with a failed refresh pause editing; recovery only repeats reads", async () => {
+ const fixture = setup();
+ fixture.failedReads.add("memberships");
+ expect(await fixture.actions().run(fixture.write, "Saved")).toBe(true);
+ expect(fixture.state.memberships).toEqual(["tech"]);
+ expect(fixture.channels.getCurrentResult().data?.[0].groupIds).toEqual([]);
+ expect(fixture.render()).toContain(m.sg_refresh_error());
+ expect(fixture.render()).toContain(' {
+ const fixture = setup();
+ fixture.failedReads.add("groups");
+ expect(await fixture.actions().run(fixture.write, "Saved")).toBe(true);
+ expect(fixture.channels.getCurrentResult().data?.[0].groupIds).toEqual(["tech"]);
+ expect(fixture.render()).toContain(' {
+ const fixture = setup();
+ fixture.failedReads.add("memberships");
+ expect(
+ await fixture.actions().run(async () => {
+ await fixture.write();
+ throw new MembershipUpdateError(["https://www.youtube.com/channel/example"]);
+ }, "Saved"),
+ ).toBe(false);
+ expect(fixture.render()).toContain(' {
+ const fixture = setup();
+ fixture.failedReads.add("memberships");
+ await fixture.channels.refetch();
+ expect(fixture.render()).toContain("Edit memberships");
+ expect(fixture.render()).toContain(m.sg_refresh_error());
+ expect(fixture.render()).toContain(' {
+ const fixture = setup();
+ fixture.failedReads.add("groups");
+ await fixture.client.resetQueries({ queryKey: ["subscription-groups"] });
+ expect(fixture.render()).toContain(m.sg_load_error());
+ expect(fixture.render()).toContain(m.sg_retry());
+ expect(fixture.render()).not.toContain("Edit memberships");
+});
diff --git a/apps/web/tests/helpers/subscription-feed.tsx b/apps/web/tests/helpers/subscription-feed.tsx
new file mode 100644
index 00000000..4144e8ef
--- /dev/null
+++ b/apps/web/tests/helpers/subscription-feed.tsx
@@ -0,0 +1,46 @@
+import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { renderToStaticMarkup } from "react-dom/server";
+import { useSubscriptionFeed } from "../../src/hooks/use-subscription-feed";
+import type { VideoItem } from "../../src/types/api";
+
+export function readFeed(
+ client: QueryClient,
+ filter = "all",
+): ReturnType {
+ let state: ReturnType | undefined;
+ function ReadFeed(): null {
+ state = useSubscriptionFeed(filter);
+ return null;
+ }
+ renderToStaticMarkup(
+
+
+ ,
+ );
+ if (!state) throw new Error("Feed hook did not render");
+ return state;
+}
+
+export function video(id: string): VideoItem {
+ return {
+ id,
+ url: `https://www.youtube.com/watch?v=${id}`,
+ title: id,
+ thumbnailUrl: "",
+ uploaderName: "Test channel",
+ uploaderUrl: "",
+ uploaderAvatarUrl: "",
+ uploaderVerified: false,
+ duration: 100,
+ viewCount: 1,
+ uploadDate: "",
+ uploaded: 0,
+ streamType: "VIDEO_STREAM",
+ isLive: false,
+ isPostLive: false,
+ isLiveContent: false,
+ requiresMembership: false,
+ isShortFormContent: false,
+ shortDescription: null,
+ };
+}
diff --git a/apps/web/tests/membership-batches.test.ts b/apps/web/tests/membership-batches.test.ts
new file mode 100644
index 00000000..b8a88bec
--- /dev/null
+++ b/apps/web/tests/membership-batches.test.ts
@@ -0,0 +1,85 @@
+import { afterEach, expect, test } from "bun:test";
+import { MembershipUpdateError, updateGroupMemberships } from "../src/lib/api-subscription-groups";
+import { membershipBatches } from "../src/lib/membership-batches";
+import { useAuthStore } from "../src/stores/auth-store";
+
+const bodyBytes = (channelUrls: string[]): number =>
+ new TextEncoder().encode(JSON.stringify({ channelUrls })).byteLength;
+const MAX_BYTES = 1024 * 1024;
+const originalFetch = globalThis.fetch;
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+ useAuthStore.getState().setSignedOut();
+});
+
+test("a body exactly 1 MiB fits, and adding another URL starts a new batch", () => {
+ const urls = Array.from({ length: 499 }, (_, i) => `https://example.org/${i}/`.padEnd(2048, "a"));
+ let remaining = MAX_BYTES - bodyBytes(urls);
+ for (let i = 0; remaining > 0; i++) {
+ const added = Math.min(remaining, 4000);
+ const count = Math.ceil(added / 2);
+ urls[i] =
+ urls[i].slice(0, -count) + "界".repeat(Math.floor(added / 2)) + (added % 2 ? "é" : "");
+ remaining -= added;
+ }
+ expect(bodyBytes(urls)).toBe(MAX_BYTES);
+ const { batches, invalid } = membershipBatches([...urls, "https://example.org/extra"]);
+ expect(invalid).toEqual([]);
+ expect(batches).toEqual([urls, ["https://example.org/extra"]]);
+});
+
+test("UTF-8 and JSON escapes count toward the byte limit without losing channels", () => {
+ const urls = Array.from(
+ { length: 600 },
+ (_, i) => `https://example.org/${i}/${'界\\"'.repeat(670)}`,
+ );
+ const { batches, invalid } = membershipBatches([...urls, urls[0]]);
+ expect(invalid).toEqual([]);
+ expect(batches.flat()).toEqual(urls);
+ expect(batches.every((batch) => batch.length <= 500 && bodyBytes(batch) <= MAX_BYTES)).toBe(true);
+ expect(batches.length).toBeGreaterThan(2);
+});
+
+test("overlong URLs are reported without preventing valid membership writes", async () => {
+ useAuthStore.getState().setToken("membership-test");
+ const calls: string[][] = [];
+ globalThis.fetch = async (_input, init) => {
+ calls.push(JSON.parse(String(init?.body)).channelUrls);
+ return new Response(null, { status: 204 });
+ };
+ const valid = "https://example.org/".padEnd(2048, "a");
+ const invalid = `${valid}a`;
+ try {
+ await updateGroupMemberships([
+ { groupId: "tech", action: "add", channelUrls: [invalid, valid] },
+ ]);
+ throw new Error("Expected invalid URL");
+ } catch (error) {
+ expect(error).toBeInstanceOf(MembershipUpdateError);
+ if (error instanceof MembershipUpdateError) expect(error.failedUrls).toEqual([invalid]);
+ }
+ expect(calls).toEqual([[valid]]);
+});
+
+test("a large edit has at most three requests in flight and preserves operation order", async () => {
+ useAuthStore.getState().setToken("membership-test");
+ let active = 0;
+ let peak = 0;
+ const methods: string[] = [];
+ globalThis.fetch = async (_input, init) => {
+ methods.push(init?.method ?? "GET");
+ active++;
+ peak = Math.max(peak, active);
+ await Bun.sleep(1);
+ active--;
+ return new Response(null, { status: 204 });
+ };
+ const channelUrls = Array.from({ length: 5001 }, (_, i) => `https://example.org/${i}`);
+ await updateGroupMemberships([
+ { groupId: "tech", action: "add", channelUrls },
+ { groupId: "tech", action: "remove", channelUrls: [channelUrls[0]] },
+ ]);
+ expect(peak).toBe(3);
+ expect(active).toBe(0);
+ expect(methods).toEqual([...Array(11).fill("PUT"), "DELETE"]);
+});
diff --git a/apps/web/tests/profile-query-cache.test.ts b/apps/web/tests/profile-query-cache.test.ts
index d468af04..5225ca51 100644
--- a/apps/web/tests/profile-query-cache.test.ts
+++ b/apps/web/tests/profile-query-cache.test.ts
@@ -15,12 +15,18 @@ test("refreshes mounted subscriptions without replacing their observer or media
const media = { url: "playing-video" };
client.setQueryData(["stream", "playing-video"], media);
client.setQueryData(["history"], ["original-video"]);
+ client.setQueryData(["subscription-groups", "original-profile"], ["old-group"]);
+ client.setQueryData(["subscription-group-memberships", "original-profile"], ["old-membership"]);
client.setQueryData(["search-panel-videos", 0], ["original-recommendation"]);
profile = "Test";
const refresh = resetProfileQueries(client);
expect(observer.getCurrentResult().data).toBeUndefined();
expect(client.getQueryData(["history"])).toBeUndefined();
+ expect(client.getQueryData(["subscription-groups", "original-profile"])).toBeUndefined();
+ expect(
+ client.getQueryData(["subscription-group-memberships", "original-profile"]),
+ ).toBeUndefined();
expect(client.getQueryData(["search-panel-videos", 0])).toBeUndefined();
expect(client.getQueryData(["stream", "playing-video"])).toBe(media);
await refresh;
diff --git a/apps/web/tests/subscription-feed-errors.test.tsx b/apps/web/tests/subscription-feed-errors.test.tsx
new file mode 100644
index 00000000..70a452e9
--- /dev/null
+++ b/apps/web/tests/subscription-feed-errors.test.tsx
@@ -0,0 +1,110 @@
+import { afterEach, expect, test } from "bun:test";
+import { InfiniteQueryObserver, QueryClient } from "@tanstack/react-query";
+import {
+ subscriptionFeedQueryOptions,
+ subscriptionsQueryOptions,
+} from "../src/lib/subscription-queries";
+import { useAuthStore } from "../src/stores/auth-store";
+import { readFeed, video } from "./helpers/subscription-feed";
+
+const originalFetch = globalThis.fetch;
+const clients: QueryClient[] = [];
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+ useAuthStore.getState().setSignedOut();
+ for (const client of clients.splice(0)) client.clear();
+});
+
+function setup(filter = "all") {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ clients.push(client);
+ client.setQueryData(subscriptionsQueryOptions(filter).queryKey, [
+ {
+ channelUrl: "https://example.org/channel",
+ name: "Test channel",
+ avatarUrl: "",
+ subscribedAt: 0,
+ },
+ ]);
+ useAuthStore.getState().setToken("feed-error-test");
+ const observer = new InfiniteQueryObserver(client, subscriptionFeedQueryOptions(filter));
+ return { client, observer };
+}
+
+test("a failed next page preserves videos and retries the same cursor", async () => {
+ const { client, observer } = setup();
+ let failNext = true;
+ const cursors: Array = [];
+ globalThis.fetch = async (input) => {
+ const cursor = new URL(String(input), "https://fixture.invalid").searchParams.get("cursor");
+ cursors.push(cursor);
+ if (cursor && failNext) return Response.json({ error: "Unavailable" }, { status: 503 });
+ return Response.json({
+ videos: [video(cursor ? "second" : "first")],
+ nextpage: cursor ? null : "page-2",
+ });
+ };
+ await observer.refetch();
+ await observer.fetchNextPage();
+ const failed = readFeed(client);
+ expect(failed.streams.map((item) => item.title)).toEqual(["first"]);
+ expect(failed.isLoadingError).toBe(false);
+ expect(failed.isFetchNextPageError).toBe(true);
+ expect(failed.hasNextPage).toBe(true);
+
+ failNext = false;
+ await failed.fetchNextPage();
+ const recovered = readFeed(client);
+ expect(recovered.streams.map((item) => item.title)).toEqual(["first", "second"]);
+ expect(recovered.isFetchNextPageError).toBe(false);
+ expect(recovered.hasNextPage).toBe(false);
+ expect(cursors).toEqual([null, "page-2", "page-2"]);
+});
+
+test("an initial-load failure exposes recovery without a pagination error", async () => {
+ const { client, observer } = setup();
+ globalThis.fetch = async () => Response.json({ error: "Unavailable" }, { status: 503 });
+ await observer.refetch();
+ const failed = readFeed(client);
+ expect(failed.isLoadingError).toBe(true);
+ expect(failed.isFetchNextPageError).toBe(false);
+ expect(failed.streams).toEqual([]);
+ globalThis.fetch = async () => Response.json({ videos: [video("first")], nextpage: null });
+ await failed.refetch();
+ expect(readFeed(client).isLoadingError).toBe(false);
+});
+
+test("a failed background refresh retains cached feed content", async () => {
+ const { client, observer } = setup();
+ globalThis.fetch = async () => Response.json({ videos: [video("first")], nextpage: null });
+ await observer.refetch();
+ globalThis.fetch = async () => Response.json({ error: "Unavailable" }, { status: 503 });
+ await observer.refetch();
+ const failed = readFeed(client);
+ expect(failed.isLoadingError).toBe(false);
+ expect(failed.isFetchNextPageError).toBe(false);
+ expect(failed.streams.map((item) => item.title)).toEqual(["first"]);
+});
+
+test("a prefetched filtered feed is reused and keeps its filter when fetching another page", async () => {
+ const filter = "tech & science/#";
+ const { client } = setup(filter);
+ const calls: URL[] = [];
+ globalThis.fetch = async (input) => {
+ const url = new URL(String(input), "https://fixture.invalid");
+ calls.push(url);
+ const cursor = url.searchParams.get("cursor");
+ return Response.json({
+ videos: [video(cursor ? "second" : "first")],
+ nextpage: cursor ? null : "page-2",
+ });
+ };
+ await client.prefetchInfiniteQuery(subscriptionFeedQueryOptions(filter));
+ const feed = readFeed(client, filter);
+ expect(feed.streams.map((item) => item.title)).toEqual(["first"]);
+ expect(calls).toHaveLength(1);
+ await feed.fetchNextPage();
+ expect(readFeed(client, filter).streams.map((item) => item.title)).toEqual(["first", "second"]);
+ expect(calls.map((url) => url.searchParams.get("cursor"))).toEqual([null, "page-2"]);
+ expect(calls.every((url) => url.searchParams.get("groupId") === filter)).toBe(true);
+});
diff --git a/apps/web/tests/subscription-feed-filters.test.tsx b/apps/web/tests/subscription-feed-filters.test.tsx
new file mode 100644
index 00000000..22ae37cb
--- /dev/null
+++ b/apps/web/tests/subscription-feed-filters.test.tsx
@@ -0,0 +1,95 @@
+import { afterEach, expect, test } from "bun:test";
+import { QueryClient } from "@tanstack/react-query";
+import { proxyImage } from "../src/lib/proxy";
+import {
+ subscriptionFeedQueryOptions,
+ subscriptionsQueryOptions,
+} from "../src/lib/subscription-queries";
+import { useAuthStore } from "../src/stores/auth-store";
+import { readFeed, video } from "./helpers/subscription-feed";
+
+const originalFetch = globalThis.fetch;
+const clients: QueryClient[] = [];
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+ useAuthStore.getState().setSignedOut();
+ for (const client of clients.splice(0)) client.clear();
+});
+
+function setup(): QueryClient {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ clients.push(client);
+ useAuthStore.getState().setToken("feed-filter-test");
+ return client;
+}
+
+test("all, named and ungrouped feeds keep separate pages, cursors and avatar sources", async () => {
+ const client = setup();
+ const calls: Array<{ filter: string; cursor: string | null }> = [];
+ const channelUrl = "https://www.youtube.com/channel/shared";
+ globalThis.fetch = async (input) => {
+ const url = new URL(String(input), "https://fixture.invalid");
+ const filter =
+ url.searchParams.get("groupId") ??
+ (url.searchParams.get("ungrouped") === "true" ? "ungrouped" : "all");
+ const cursor = url.searchParams.get("cursor");
+ calls.push({ filter, cursor });
+ return Response.json({
+ videos: [{ ...video(`${filter}-${cursor ? 2 : 1}`), uploaderUrl: channelUrl }],
+ nextpage: cursor ? null : `${filter}-cursor`,
+ });
+ };
+ const filters = ["all", "tech", "ungrouped"];
+ for (const filter of filters) {
+ client.setQueryData(subscriptionsQueryOptions(filter).queryKey, [
+ {
+ channelUrl,
+ name: "Channel",
+ avatarUrl: `https://example.org/${filter}.jpg`,
+ subscribedAt: 0,
+ },
+ ]);
+ await client.prefetchInfiniteQuery(subscriptionFeedQueryOptions(filter));
+ }
+ for (const filter of filters) {
+ const feed = readFeed(client, filter);
+ expect(feed.streams.map((item) => item.title)).toEqual([`${filter}-1`]);
+ expect(feed.streams[0].channelAvatar).toBe(proxyImage(`https://example.org/${filter}.jpg`));
+ }
+ await readFeed(client, "tech").fetchNextPage();
+ expect(readFeed(client, "all").streams).toHaveLength(1);
+ expect(readFeed(client, "ungrouped").streams).toHaveLength(1);
+ await readFeed(client, "ungrouped").fetchNextPage();
+ await readFeed(client, "all").fetchNextPage();
+ for (const filter of filters) {
+ expect(calls.filter((call) => call.filter === filter).map((call) => call.cursor)).toEqual([
+ null,
+ `${filter}-cursor`,
+ ]);
+ expect(readFeed(client, filter).streams.map((item) => item.title)).toEqual([
+ `${filter}-1`,
+ `${filter}-2`,
+ ]);
+ }
+});
+
+test("a named feed reuses filtered subscriptions without creating an unfiltered query", () => {
+ const client = setup();
+ client.setQueryData(subscriptionsQueryOptions("tech").queryKey, []);
+ expect(readFeed(client, "tech").isLoading).toBe(false);
+ expect(client.getQueryState(subscriptionsQueryOptions().queryKey)).toBeUndefined();
+});
+
+test("empty subscriptions suppress cached feed cards and loading or pagination states", () => {
+ const client = setup();
+ client.setQueryData(subscriptionsQueryOptions().queryKey, []);
+ client.setQueryData(subscriptionFeedQueryOptions().queryKey, {
+ pages: [{ videos: [video("old")], nextpage: "old-cursor" }],
+ pageParams: [null],
+ });
+ const feed = readFeed(client);
+ expect(feed.streams).toEqual([]);
+ expect(feed.isLoading).toBe(false);
+ expect(feed.isLoadingError).toBe(false);
+ expect(feed.hasNextPage).toBe(false);
+});
diff --git a/apps/web/tests/subscription-group-selection.test.ts b/apps/web/tests/subscription-group-selection.test.ts
new file mode 100644
index 00000000..e37a4471
--- /dev/null
+++ b/apps/web/tests/subscription-group-selection.test.ts
@@ -0,0 +1,50 @@
+import { expect, test } from "bun:test";
+import {
+ channelMembershipChanges,
+ clearMembershipChanges,
+ subscriptionFilterParams,
+} from "../src/lib/subscription-group-selection";
+import type { GroupedSubscription } from "../src/types/subscription-groups";
+
+const channels: GroupedSubscription[] = [
+ {
+ channelUrl: "https://youtube.com/channel/one",
+ name: "One",
+ avatarUrl: "",
+ subscribedAt: 0,
+ groupIds: ["tech", "music"],
+ },
+ {
+ channelUrl: "https://youtube.com/channel/two",
+ name: "Two",
+ avatarUrl: "",
+ subscribedAt: 0,
+ groupIds: ["music"],
+ },
+ {
+ channelUrl: "https://youtube.com/channel/three",
+ name: "Three",
+ avatarUrl: "",
+ subscribedAt: 0,
+ groupIds: [],
+ },
+];
+test("inline save sends only the membership difference", () => {
+ expect(channelMembershipChanges(channels[0], new Set(["music", "science"]))).toEqual([
+ { groupId: "science", channelUrls: [channels[0].channelUrl], action: "add" },
+ { groupId: "tech", channelUrls: [channels[0].channelUrl], action: "remove" },
+ ]);
+ expect(channelMembershipChanges(channels[0], new Set(channels[0].groupIds))).toEqual([]);
+});
+
+test("remove all groups targets only selected channel memberships", () => {
+ expect(clearMembershipChanges([channels[1], channels[2]])).toEqual([
+ { groupId: "music", channelUrls: [channels[1].channelUrl], action: "remove" },
+ ]);
+});
+
+test("feed and subscription filters match the server contract", () => {
+ expect(subscriptionFilterParams("all").toString()).toBe("");
+ expect(subscriptionFilterParams("ungrouped").toString()).toBe("ungrouped=true");
+ expect(subscriptionFilterParams("tech").toString()).toBe("groupId=tech");
+});
diff --git a/apps/web/tests/subscription-queries.test.ts b/apps/web/tests/subscription-queries.test.ts
new file mode 100644
index 00000000..b1033b4b
--- /dev/null
+++ b/apps/web/tests/subscription-queries.test.ts
@@ -0,0 +1,63 @@
+import { afterEach, expect, test } from "bun:test";
+import { QueryClient } from "@tanstack/react-query";
+import { fetchSubscriptions } from "../src/lib/api-user";
+import {
+ invalidateSubscriptionQueries,
+ subscriptionsQueryOptions,
+} from "../src/lib/subscription-queries";
+import { useAuthStore } from "../src/stores/auth-store";
+
+const originalFetch = globalThis.fetch;
+const clients: QueryClient[] = [];
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+ useAuthStore.getState().setSignedOut();
+ for (const client of clients.splice(0)) client.clear();
+});
+
+test("channel prefetches stay distinct by filter and are reused by subsequent reads", async () => {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ clients.push(client);
+ useAuthStore.getState().setToken("subscription-query-test");
+ const calls: string[] = [];
+ globalThis.fetch = async (input) => {
+ const url = new URL(String(input), "https://fixture.invalid");
+ calls.push(url.pathname + url.search);
+ const name =
+ url.searchParams.get("groupId") ?? (url.searchParams.has("ungrouped") ? "ungrouped" : "all");
+ return Response.json([{ channelUrl: name, name, avatarUrl: "", subscribedAt: 0 }]);
+ };
+ for (const filter of ["all", "ungrouped", "tech & science/#"]) {
+ const options = subscriptionsQueryOptions(filter);
+ await client.prefetchQuery(options);
+ expect((await client.fetchQuery(options))[0].name).toBe(filter);
+ }
+ expect(calls).toEqual([
+ "/api/subscriptions",
+ "/api/subscriptions?ungrouped=true",
+ "/api/subscriptions?groupId=tech+%26+science%2F%23",
+ ]);
+ expect((await fetchSubscriptions())[0].name).toBe("all");
+ expect(calls.at(-1)).toBe("/api/subscriptions");
+});
+
+test("subscription changes invalidate every filter and profile variant without clearing data", async () => {
+ const client = new QueryClient();
+ clients.push(client);
+ const affectedKeys = [
+ ["subscriptions"],
+ ["subscriptions", "tech"],
+ ["subscriptions", "ungrouped"],
+ ["subscription-feed"],
+ ["subscription-feed", "tech"],
+ ["subscription-groups", "profile"],
+ ["subscription-group-memberships", "profile"],
+ ];
+ for (const key of [...affectedKeys, ["playlists"]]) client.setQueryData(key, ["cached"]);
+ await invalidateSubscriptionQueries(client);
+ for (const key of affectedKeys) {
+ expect(client.getQueryState(key)?.isInvalidated).toBe(true);
+ expect(client.getQueryData(key)).toEqual(["cached"]);
+ }
+ expect(client.getQueryState(["playlists"])?.isInvalidated).toBe(false);
+});
diff --git a/apps/web/tests/subscription-refresh-scope.test.ts b/apps/web/tests/subscription-refresh-scope.test.ts
new file mode 100644
index 00000000..de82a62d
--- /dev/null
+++ b/apps/web/tests/subscription-refresh-scope.test.ts
@@ -0,0 +1,63 @@
+import { afterEach, expect, test } from "bun:test";
+import { QueryClient, QueryObserver } from "@tanstack/react-query";
+import { invalidateSubscriptionQueries } from "../src/lib/subscription-queries";
+
+const cleanups: Array<() => void> = [];
+afterEach(() => {
+ for (const cleanup of cleanups.splice(0)) cleanup();
+});
+
+function setup() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false, staleTime: Infinity } },
+ });
+ const reads: string[] = [];
+ const keys = [
+ ["subscription-groups", "profile"],
+ ["subscription-group-memberships", "profile"],
+ ["subscriptions"],
+ ["subscriptions", "tech"],
+ ["subscriptions", "ungrouped"],
+ ["subscription-feed"],
+ ["subscription-feed", "tech"],
+ ["subscription-feed", "ungrouped"],
+ ];
+ for (const queryKey of keys) {
+ client.setQueryData(queryKey, ["cached"]);
+ const observer = new QueryObserver(client, {
+ queryKey,
+ queryFn: async () => {
+ reads.push(queryKey.join("/"));
+ return ["fresh"];
+ },
+ });
+ cleanups.push(observer.subscribe(() => {}));
+ }
+ cleanups.push(() => client.clear());
+ return { client, reads };
+}
+
+test("membership saves refresh manager reads and defer filtered views without touching global views", async () => {
+ const { client, reads } = setup();
+ await invalidateSubscriptionQueries(client, "memberships");
+ expect(reads.sort()).toEqual([
+ "subscription-group-memberships/profile",
+ "subscription-groups/profile",
+ ]);
+ for (const family of ["subscriptions", "subscription-feed"]) {
+ expect(client.getQueryState([family])?.isInvalidated).toBe(false);
+ for (const filter of ["tech", "ungrouped"]) {
+ expect(client.getQueryState([family, filter])?.isInvalidated).toBe(true);
+ expect(client.getQueryData([family, filter])).toEqual(["cached"]);
+ }
+ }
+});
+
+test("creating or renaming a group refreshes only group definitions", async () => {
+ const { client, reads } = setup();
+ await invalidateSubscriptionQueries(client, "groups");
+ expect(reads).toEqual(["subscription-groups/profile"]);
+ expect(client.getQueryState(["subscription-group-memberships", "profile"])?.isInvalidated).toBe(
+ false,
+ );
+});
diff --git a/apps/web/tests/subscription-requests.test.ts b/apps/web/tests/subscription-requests.test.ts
new file mode 100644
index 00000000..dc4a678e
--- /dev/null
+++ b/apps/web/tests/subscription-requests.test.ts
@@ -0,0 +1,84 @@
+import { afterEach, beforeEach, expect, test } from "bun:test";
+import { QueryClient, QueryObserver } from "@tanstack/react-query";
+import { ApiError } from "../src/lib/api";
+import { fetchSubscriptionGroups } from "../src/lib/api-subscription-groups";
+import { fetchSubscriptions } from "../src/lib/api-user";
+import { subscriptionsQueryOptions } from "../src/lib/subscription-queries";
+import { useAuthStore } from "../src/stores/auth-store";
+
+const originalFetch = globalThis.fetch;
+beforeEach(() => useAuthStore.getState().setToken("subscription-request-test"));
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+ useAuthStore.getState().setSignedOut();
+});
+
+test.each(["all", "ungrouped", "deleted-group"])(
+ "%s reads preserve structured errors and request IDs",
+ async (filter) => {
+ globalThis.fetch = async () =>
+ Response.json(
+ {
+ error: "Subscription group not found",
+ code: "subscription_group_not_found",
+ requestId: "body-id",
+ },
+ { status: 404, headers: { "x-request-id": "header-id" } },
+ );
+ try {
+ await fetchSubscriptions(filter);
+ throw new Error("Expected missing group");
+ } catch (error) {
+ expect(error).toBeInstanceOf(ApiError);
+ if (!(error instanceof ApiError)) throw error;
+ expect(error.code).toBe("subscription_group_not_found");
+ expect(error.status).toBe(404);
+ expect(error.requestId).toBe("header-id");
+ }
+ },
+);
+
+test("request IDs fall back to the body; non-JSON failures keep their status", async () => {
+ globalThis.fetch = async () =>
+ Response.json({ error: "Unavailable", requestId: "body-id" }, { status: 503 });
+ await expect(fetchSubscriptions()).rejects.toMatchObject({ status: 503, requestId: "body-id" });
+ globalThis.fetch = async () =>
+ new Response("Unavailable", { status: 502, statusText: "Bad Gateway" });
+ await expect(fetchSubscriptions()).rejects.toMatchObject({ status: 502, message: "Bad Gateway" });
+});
+
+test("every subscription list read forwards cancellation to fetch", async () => {
+ for (const read of [
+ fetchSubscriptionGroups,
+ (signal: AbortSignal) => fetchSubscriptions("tech", signal),
+ ]) {
+ const controller = new AbortController();
+ const reason = new DOMException("Navigation", "AbortError");
+ globalThis.fetch = async (_input, init) => {
+ expect(init?.signal).toBe(controller.signal);
+ return new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true });
+ });
+ };
+ const pending = read(controller.signal);
+ controller.abort(reason);
+ await expect(pending).rejects.toBe(reason);
+ }
+});
+
+test("unsubscribing from a query aborts its pending subscription request", async () => {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ let signal: AbortSignal | null | undefined;
+ globalThis.fetch = async (_input, init) => {
+ signal = init?.signal;
+ return new Promise((_resolve, reject) => {
+ signal?.addEventListener("abort", () => reject(signal?.reason), { once: true });
+ });
+ };
+ const observer = new QueryObserver(client, subscriptionsQueryOptions("tech"));
+ const unsubscribe = observer.subscribe(() => {});
+ expect(signal?.aborted).toBe(false);
+ unsubscribe();
+ expect(signal?.aborted).toBe(true);
+ client.clear();
+});
diff --git a/package.json b/package.json
index 4e314410..2b1f364f 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"localize": "bun run --cwd apps/web localize",
"localization:report": "node scripts/localization-report.mjs",
"dev": "bun run --cwd apps/web dev",
+ "dev:groups-fixture": "bun scripts/fixtures/subscription-groups.ts",
"build": "bun run --cwd apps/web build",
"check": "bun run localize && node scripts/check-localization.mjs && biome check .",
"format": "biome format --write .",
diff --git a/scripts/fixtures/subscription-groups-data.ts b/scripts/fixtures/subscription-groups-data.ts
new file mode 100644
index 00000000..68df83d8
--- /dev/null
+++ b/scripts/fixtures/subscription-groups-data.ts
@@ -0,0 +1,165 @@
+import type { VideoItem } from "../../apps/web/src/types/api";
+import type {
+ GroupedSubscription,
+ SubscriptionGroup,
+} from "../../apps/web/src/types/subscription-groups";
+
+const GROUP_NAMES = [
+ "Tech",
+ "Video essays",
+ "Music",
+ "Science",
+ "Cooking",
+ "Gaming",
+ "News",
+ "Design",
+ "DIY & making",
+ "Travel",
+ "History",
+ "Photography",
+ "Podcasts",
+ "Fitness",
+ "Languages",
+ "Space",
+ "Weekend watchlist — documentaries and deep dives",
+ "To explore",
+];
+const ORIGINAL_NAMES = [
+ "Lemnos Life",
+ "Mental Outlaw",
+ "Jack Rhysider",
+ "Network Chuck",
+ "Fireship",
+ "Veritasium",
+ "Smarter Every Day",
+ "Behoops",
+ "Red Shirts",
+ "Annie Bramley",
+ "KEXP",
+ "NPR Music",
+ "Dorian Me",
+ "Arcade Sound",
+ "Cooking Comically",
+ "Technology Connections",
+ "The B1M",
+ "Noclip",
+ "Asianometry",
+ "PBS Space Time",
+ "DW News",
+];
+const SUBJECTS = [
+ "Analog",
+ "Architecture",
+ "Astronomy",
+ "Baking",
+ "Cinema",
+ "Circuit",
+ "Design",
+ "Ecology",
+ "History",
+ "Indie Games",
+ "Jazz",
+ "Language",
+ "Ocean",
+ "Photography",
+ "Robotics",
+];
+const FORMATS = [
+ "Lab",
+ "Notebook",
+ "Journal",
+ "Workshop",
+ "Studio",
+ "Archive",
+ "Field Notes",
+ "Weekly",
+ "Collective",
+];
+const SPECIAL_NAMES = [
+ "A",
+ "Café des idées",
+ "京都の小さな工房",
+ "서울 디자인 스튜디오",
+ "حكايات العلوم",
+ "Atlas Workshop — repairing, rebuilding and understanding everyday machines one project at a time",
+];
+const EPOCH = Date.UTC(2026, 8, 1);
+
+export function makeGroups(): SubscriptionGroup[] {
+ return GROUP_NAMES.map((name, index) => ({
+ id: `10000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`,
+ name,
+ channelCount: 0,
+ createdAt: EPOCH,
+ updatedAt: EPOCH,
+ }));
+}
+
+export function makeChannels(groups: SubscriptionGroup[]): GroupedSubscription[] {
+ return Array.from({ length: 150 }, (_, index) => {
+ const generated = index - ORIGINAL_NAMES.length;
+ const name =
+ ORIGINAL_NAMES[index] ??
+ SPECIAL_NAMES[generated] ??
+ `${SUBJECTS[generated % SUBJECTS.length]} ${FORMATS[Math.floor(generated / SUBJECTS.length)]}`;
+ const memberships = new Set();
+ if (index % 7 !== 0) {
+ memberships.add(index % 16);
+ if (index % 2 === 0) memberships.add(0);
+ if (index % 3 !== 0) memberships.add((index + 5) % 16);
+ if (index % 4 === 0) memberships.add((index + 9) % 16);
+ if (index % 11 === 0) memberships.add(16);
+ }
+ if (index === 26) {
+ memberships.clear();
+ for (let group = 0; group < 10; group++) memberships.add(group);
+ }
+ return {
+ channelUrl: `https://www.youtube.com/channel/UCfixture${String(index).padStart(15, "0")}`,
+ name,
+ avatarUrl: index % 13 === 0 ? "" : `/api/__qa/avatar/${index}.svg`,
+ subscribedAt: EPOCH - index * 86_400_000,
+ groupIds: [...memberships].map((group) => groups[group].id),
+ };
+ });
+}
+
+export function makeVideos(channels: GroupedSubscription[]): VideoItem[] {
+ return Array.from({ length: 300 }, (_, index) => {
+ const channel = channels[index % channels.length];
+ const id = `mock${String(index).padStart(7, "0")}`;
+ return {
+ id,
+ url: `https://www.youtube.com/watch?v=${id}`,
+ title: `${channel.name}: ${index < channels.length ? "A closer look" : "Behind the scenes"}`,
+ thumbnailUrl: `/api/__qa/thumbnail/${index}.svg`,
+ uploaderName: channel.name,
+ uploaderUrl: channel.channelUrl,
+ uploaderAvatarUrl: channel.avatarUrl,
+ uploaderVerified: index % 8 === 0,
+ duration: 240 + ((index * 137) % 5400),
+ viewCount: 850 + index * 12437,
+ uploaded: EPOCH - index * 3_600_000,
+ uploadDate: "2026-09-01",
+ streamType: "VIDEO_STREAM",
+ isLive: false,
+ isPostLive: false,
+ isLiveContent: false,
+ requiresMembership: false,
+ isShortFormContent: false,
+ shortDescription: "Illustrative local fixture video; playback is not available.",
+ };
+ });
+}
+
+export function fixtureImage(kind: string, index: number): Response {
+ const hue = (index * 47) % 360;
+ const thumbnail = kind === "thumbnail";
+ const width = thumbnail ? 640 : 80;
+ const height = thumbnail ? 360 : 80;
+ const label = thumbnail ? `Preview ${String(index + 1).padStart(3, "0")}` : String(index + 1);
+ return new Response(
+ `${label} `,
+ { headers: { "Content-Type": "image/svg+xml" } },
+ );
+}
diff --git a/scripts/fixtures/subscription-groups-state.ts b/scripts/fixtures/subscription-groups-state.ts
new file mode 100644
index 00000000..bae93093
--- /dev/null
+++ b/scripts/fixtures/subscription-groups-state.ts
@@ -0,0 +1,112 @@
+import { makeChannels, makeGroups, makeVideos } from "./subscription-groups-data";
+
+type FailureRule = { path: string; method: string; count: number; query?: string };
+export type Fixture = {
+ groups: ReturnType;
+ channels: ReturnType;
+ videos: ReturnType;
+ writes: Array<{ path: string; method: string; body: unknown }>;
+ failure: FailureRule | null;
+};
+
+export function createFixture(): Fixture {
+ const groups = makeGroups();
+ const channels = makeChannels(groups);
+ return {
+ groups,
+ channels,
+ videos: makeVideos(channels),
+ writes: [],
+ failure: null,
+ };
+}
+
+export function groupCounts(state: Fixture): Fixture["groups"] {
+ return state.groups.map((group) => ({
+ ...group,
+ channelCount: state.channels.filter((channel) => channel.groupIds.includes(group.id)).length,
+ }));
+}
+
+export function filteredChannels(state: Fixture, url: URL): Fixture["channels"] {
+ const groupId = url.searchParams.get("groupId");
+ return state.channels.filter((channel) =>
+ groupId
+ ? channel.groupIds.includes(groupId)
+ : url.searchParams.get("ungrouped") === "true"
+ ? channel.groupIds.length === 0
+ : true,
+ );
+}
+
+function error(message: string, status: number, code?: string): Response {
+ return Response.json({ error: message, code }, { status });
+}
+
+export async function writeGroup(
+ state: Fixture,
+ request: Request,
+ path: string,
+): Promise {
+ const id = path.split("/")[3];
+ const group = state.groups.find((item) => item.id === id);
+ if (id && !group) return error("Group not found", 404, "subscription_group_not_found");
+ const body: unknown =
+ request.method === "DELETE" && !path.endsWith("/channels") ? {} : await request.json();
+ state.writes.push({ path, method: request.method, body });
+ if (!body || typeof body !== "object") return error("Invalid request body", 400);
+ if (path.endsWith("/channels")) {
+ if (!("channelUrls" in body) || !Array.isArray(body.channelUrls))
+ return error("Invalid channels", 400);
+ const urls: unknown[] = body.channelUrls;
+ if (!urls.length || urls.length > 500 || urls.some((url) => typeof url !== "string")) {
+ return error("Specify 1 to 500 channel URLs", 400);
+ }
+ if (request.method !== "PUT" && request.method !== "DELETE")
+ return error("Invalid method", 405);
+ if (urls.some((url) => !state.channels.some((channel) => channel.channelUrl === url))) {
+ return error("Subscription not found", 404, "subscription_not_found");
+ }
+ state.channels = state.channels.map((channel) =>
+ !urls.includes(channel.channelUrl)
+ ? channel
+ : {
+ ...channel,
+ groupIds:
+ request.method === "PUT"
+ ? [...new Set([...channel.groupIds, id])]
+ : channel.groupIds.filter((groupId) => groupId !== id),
+ },
+ );
+ } else if (request.method === "POST" || request.method === "PUT") {
+ const name = "name" in body && typeof body.name === "string" ? body.name.trim() : "";
+ if (!name || name.length > 100)
+ return error("Invalid name", 400, "subscription_group_invalid_name");
+ if (
+ state.groups.some((item) => item.id !== id && item.name.toLowerCase() === name.toLowerCase())
+ ) {
+ return error("Duplicate name", 409, "subscription_group_name_conflict");
+ }
+ if (group) {
+ group.name = name;
+ group.updatedAt = Date.now();
+ } else {
+ const created = {
+ id: crypto.randomUUID(),
+ name,
+ channelCount: 0,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+ state.groups.push(created);
+ return Response.json(created, { status: 201 });
+ }
+ } else if (request.method === "DELETE" && group) {
+ state.groups = state.groups.filter((item) => item.id !== id);
+ state.channels = state.channels.map((channel) => ({
+ ...channel,
+ groupIds: channel.groupIds.filter((groupId) => groupId !== id),
+ }));
+ } else return error("Invalid method", 405);
+ return new Response(null, { status: 204 });
+}
diff --git a/scripts/fixtures/subscription-groups.ts b/scripts/fixtures/subscription-groups.ts
new file mode 100644
index 00000000..63d8b027
--- /dev/null
+++ b/scripts/fixtures/subscription-groups.ts
@@ -0,0 +1,165 @@
+import { fixtureImage } from "./subscription-groups-data";
+import {
+ createFixture,
+ filteredChannels,
+ groupCounts,
+ writeGroup,
+} from "./subscription-groups-state";
+import { fixtureMembershipLookup, fixtureMembershipPage } from "./subscription-membership-pages";
+
+let state = createFixture();
+const me = {
+ id: "fixture-user",
+ role: "user",
+ publicUsername: "Local fixture · 150 channels",
+ bio: null,
+ avatarUrl: null,
+ avatarType: null,
+ avatarCode: null,
+};
+const empty = (): Response => new Response(null, { status: 204 });
+
+Bun.serve({
+ hostname: "127.0.0.1",
+ port: 9876,
+ async fetch(request): Promise {
+ const url = new URL(request.url);
+ const path = url.pathname;
+ const method = request.method;
+ if (path === "/__qa/state") {
+ return Response.json({
+ ...state,
+ groups: groupCounts(state),
+ summary: {
+ channels: state.channels.length,
+ groups: state.groups.length,
+ videos: state.videos.length,
+ ungrouped: state.channels.filter((channel) => channel.groupIds.length === 0).length,
+ multipleGroups: state.channels.filter((channel) => channel.groupIds.length > 1).length,
+ },
+ });
+ }
+ if (path === "/__qa/reset" && method === "POST") {
+ state = createFixture();
+ return empty();
+ }
+ if (path === "/__qa/fail" && method === "POST") {
+ const rule: unknown = await request.json();
+ if (
+ !rule ||
+ typeof rule !== "object" ||
+ !("path" in rule) ||
+ typeof rule.path !== "string" ||
+ !("method" in rule) ||
+ typeof rule.method !== "string" ||
+ !("count" in rule) ||
+ !Number.isInteger(rule.count) ||
+ Number(rule.count) < 0
+ ) {
+ return Response.json(
+ { error: "Expected path, method and nonnegative count" },
+ { status: 400 },
+ );
+ }
+ state.failure = {
+ path: rule.path,
+ method: rule.method,
+ count: Number(rule.count),
+ query: "query" in rule && typeof rule.query === "string" ? rule.query : undefined,
+ };
+ return empty();
+ }
+ const failure = state.failure;
+ if (
+ failure &&
+ failure.count > 0 &&
+ method === failure.method &&
+ path.includes(failure.path) &&
+ (!failure.query || url.search.includes(failure.query))
+ ) {
+ failure.count--;
+ return Response.json({ error: "Simulated fixture failure" }, { status: 503 });
+ }
+ const image = path.match(/^\/__qa\/(avatar|thumbnail)\/(\d+)\.svg$/);
+ if (image) return fixtureImage(image[1], Number(image[2]));
+ if (path === "/instance")
+ return Response.json({
+ guestAllowed: true,
+ youtubeRemoteLoginEnabled: false,
+ parentalControlsEnabled: false,
+ });
+ if (path === "/auth/register/status")
+ return Response.json({ allowRegistration: false, bootstrapAvailable: false });
+ if (path === "/auth/oidc/status")
+ return Response.json({
+ enabled: false,
+ providerName: null,
+ localLoginEnabled: true,
+ autoRedirect: false,
+ });
+ if (path === "/auth/login" || path === "/auth/refresh")
+ return Response.json({ accessToken: "local-fixture-only" });
+ if (path === "/auth/me") return Response.json(me);
+ if (path === "/profiles")
+ return Response.json({
+ profiles: [
+ { ...me, name: me.publicUsername, isActive: true, isDefault: true, lastUsedAt: 0 },
+ ],
+ activeProfileId: me.id,
+ defaultProfileId: me.id,
+ });
+ if (path === "/subscriptions/group-memberships/page") return fixtureMembershipPage(state, url);
+ if (path === "/subscriptions/group-memberships/lookup" && method === "POST")
+ return fixtureMembershipLookup(state, request);
+ if (path === "/subscriptions/group-memberships") return Response.json(state.channels);
+ if (path === "/subscriptions/groups" && method === "GET")
+ return Response.json(groupCounts(state));
+ if (path.startsWith("/subscriptions/groups") && method !== "GET")
+ return writeGroup(state, request, path);
+ const groupId = url.searchParams.get("groupId");
+ if (
+ path.startsWith("/subscriptions") &&
+ groupId &&
+ !state.groups.some((group) => group.id === groupId)
+ ) {
+ return Response.json(
+ { error: "Group not found", code: "subscription_group_not_found" },
+ { status: 404 },
+ );
+ }
+ if (path === "/subscriptions") return Response.json(filteredChannels(state, url));
+ if (path === "/subscriptions/feed") {
+ const channels = new Set(filteredChannels(state, url).map((channel) => channel.channelUrl));
+ const videos = state.videos.filter((video) => channels.has(video.uploaderUrl));
+ const offset = Math.max(0, Number(url.searchParams.get("cursor")) || 0);
+ const limit = Math.max(1, Math.min(30, Number(url.searchParams.get("limit")) || 30));
+ return Response.json({
+ videos: videos.slice(offset, offset + limit),
+ nextpage: offset + limit < videos.length ? String(offset + limit) : null,
+ generation: 1,
+ generatedAt: Date.now(),
+ refreshing: false,
+ });
+ }
+ if (path === "/settings")
+ return Response.json({
+ captionStyles: {},
+ sponsorBlockCategoryActions: {},
+ defaultService: 0,
+ defaultLandingPage: "/",
+ volume: 1,
+ hideSubscriptionLiveStreams: false,
+ deArrowEnabled: false,
+ });
+ if (path.startsWith("/streams"))
+ return Response.json({ error: "Fixture videos cannot be played" }, { status: 404 });
+ if (path.includes("notifications"))
+ return Response.json({ items: [], unreadCount: 0, nextCursor: null });
+ if (path === "/progress/batch") return Response.json([]);
+ if (method === "POST" || method === "PUT") return empty();
+ return Response.json([]);
+ },
+});
+console.log(
+ "Local subscription fixture: http://127.0.0.1:9876 (150 channels, 18 groups, 300 videos)",
+);
diff --git a/scripts/fixtures/subscription-membership-pages.ts b/scripts/fixtures/subscription-membership-pages.ts
new file mode 100644
index 00000000..fa14ebd7
--- /dev/null
+++ b/scripts/fixtures/subscription-membership-pages.ts
@@ -0,0 +1,50 @@
+import type { Fixture } from "./subscription-groups-state";
+
+export function fixtureMembershipPage(state: Fixture, url: URL): Response {
+ const group = url.searchParams.get("groupId");
+ if (group && !state.groups.some((item) => item.id === group))
+ return Response.json(
+ { error: "Group not found", code: "subscription_group_not_found" },
+ { status: 404 },
+ );
+ const page = Math.max(0, Number(url.searchParams.get("page")) || 0);
+ const limit = Math.max(1, Math.min(100, Number(url.searchParams.get("limit")) || 20));
+ const search = (url.searchParams.get("search") ?? "").trim().toLowerCase();
+ const excluded = url.searchParams.get("excluded") === "true";
+ const ungrouped = url.searchParams.get("ungrouped") === "true";
+ const channels = state.channels
+ .filter(
+ (channel) =>
+ (group
+ ? channel.groupIds.includes(group) !== excluded
+ : !ungrouped || channel.groupIds.length === 0) &&
+ (!search || `${channel.name} ${channel.channelUrl}`.toLowerCase().includes(search)),
+ )
+ .sort(
+ (a, b) =>
+ a.name.toLowerCase().localeCompare(b.name.toLowerCase()) ||
+ a.channelUrl.localeCompare(b.channelUrl),
+ );
+ return Response.json({
+ items: channels.slice(page * limit, (page + 1) * limit),
+ total: channels.length,
+ totalSubscriptions: state.channels.length,
+ ungroupedCount: state.channels.filter((channel) => !channel.groupIds.length).length,
+ page,
+ limit,
+ });
+}
+
+export async function fixtureMembershipLookup(state: Fixture, request: Request): Promise {
+ const body: unknown = await request.json();
+ if (
+ !body ||
+ typeof body !== "object" ||
+ !("channelUrls" in body) ||
+ !Array.isArray(body.channelUrls) ||
+ body.channelUrls.length > 500
+ )
+ return Response.json({ error: "Invalid channels" }, { status: 400 });
+ const selected = new Set(body.channelUrls);
+ return Response.json(state.channels.filter((channel) => selected.has(channel.channelUrl)));
+}