- {VARIANTS.map((variant) => (
+ {variants.map((variant) => (
so bottom badges rise clear.
+ */
+export function RequestDownloadBar({ percent }: { percent: number }) {
+ return (
+
+ );
+}
diff --git a/web/src/components/watchlist/WatchlistTabs.tsx b/web/src/components/watchlist/WatchlistTabs.tsx
new file mode 100644
index 0000000000..fe7a94886b
--- /dev/null
+++ b/web/src/components/watchlist/WatchlistTabs.tsx
@@ -0,0 +1,105 @@
+import type { ReactNode } from "react";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import {
+ parseWatchlistTab,
+ WATCHLIST_NOT_IN_LIBRARY_TAB,
+ type WatchlistTab,
+} from "@/lib/watchlistTitles";
+
+function tabId(idBase: string, value: WatchlistTab): string {
+ return `${idBase}-tab-${value}`;
+}
+
+function panelId(idBase: string, value: WatchlistTab): string {
+ return `${idBase}-panel-${value}`;
+}
+
+/**
+ * The watchlist page's tabs, in the Requests page's line style: the library
+ * grid, and the titles the library doesn't have yet with their count. An
+ * amber dot flags titles that need the viewer's attention.
+ */
+export default function WatchlistTabs({
+ idBase,
+ value,
+ onValueChange,
+ count,
+ attention,
+}: {
+ /** Shared with the page's WatchlistTabPanel, which holds the selected tab's content. */
+ idBase: string;
+ value: WatchlistTab;
+ onValueChange: (value: WatchlistTab) => void;
+ /** Titles on the second tab; omitted while they load. */
+ count?: number;
+ attention: boolean;
+}) {
+ return (
+
onValueChange(parseWatchlistTab(next))}>
+
+
+ In your library
+
+
+ Not in library
+ Not in your library yet
+ {count !== undefined && count > 0 ? (
+
+ {count}
+
+ ) : null}
+ {attention ? (
+ <>
+
+ , some need attention
+ >
+ ) : null}
+
+
+
+ );
+}
+
+/**
+ * The content of the selected watchlist tab, labelled by its tab. The page
+ * lays the content out apart from the tabs, so the tabs point here by ID
+ * instead of through Radix's TabsContent. Without tabs (`tabs` false) the
+ * children render as they are.
+ */
+export function WatchlistTabPanel({
+ idBase,
+ value,
+ tabs = true,
+ children,
+}: {
+ idBase: string;
+ value: WatchlistTab;
+ tabs?: boolean;
+ children: ReactNode;
+}) {
+ if (!tabs) return <>{children}>;
+ return (
+
+ {children}
+
+ );
+}
diff --git a/web/src/components/watchlist/WatchlistTitleCard.test.tsx b/web/src/components/watchlist/WatchlistTitleCard.test.tsx
new file mode 100644
index 0000000000..f66145e14b
--- /dev/null
+++ b/web/src/components/watchlist/WatchlistTitleCard.test.tsx
@@ -0,0 +1,174 @@
+import type { ReactNode } from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router";
+import type { WatchlistTitle } from "@/api/v2/watchlistTitles";
+import { UICustomizationContext } from "@/contexts/uiCustomizationContext";
+import {
+ buildDefaultPrefs,
+ PRESET_IDS,
+ type CardOverlayPrefs,
+ type OverlayId,
+ type PresetId,
+} from "@/lib/overlays";
+import type { CardCaption } from "@/lib/uiCustomization";
+import { watchlistTitleStatus } from "@/lib/watchlistTitles";
+import WatchlistTitleCard from "./WatchlistTitleCard";
+
+const NOW = new Date(2026, 8, 30, 12);
+
+const downloading: WatchlistTitle = {
+ media_type: "movie",
+ tmdb_id: 949,
+ title: "Heat",
+ year: 1995,
+ added_at: "2026-09-01T00:00:00Z",
+ status: "active",
+ request: {
+ requestable: false,
+ status: "downloading",
+ download: { phase: "downloading", percent: 43, downloads: 1, updated_at: NOW.toISOString() },
+ },
+};
+
+const lost: WatchlistTitle = {
+ ...downloading,
+ tmdb_id: 950,
+ title: "Lost Film",
+ status: "removed",
+ request: { requestable: false },
+};
+
+const notRequested: WatchlistTitle = {
+ ...downloading,
+ tmdb_id: 951,
+ title: "Open Title",
+ request: { requestable: true },
+};
+
+function prefsWith(preset: PresetId, only?: OverlayId): CardOverlayPrefs {
+ const prefs = buildDefaultPrefs();
+ prefs.preset = preset;
+ if (only) {
+ for (const key of Object.keys(prefs.items) as OverlayId[]) {
+ prefs.items[key] = { ...prefs.items[key], enabled: key === only };
+ }
+ }
+ return prefs;
+}
+
+function renderCard(
+ title: WatchlistTitle,
+ overlayPrefs: CardOverlayPrefs | null,
+ options: { caption?: CardCaption; onRequest?: () => void; onRemove?: () => void } = {},
+): HTMLElement {
+ const wrap = (children: ReactNode) => (
+
+ {children}
+
+ );
+ return render(
+ wrap(
+
,
+ ),
+ ).container;
+}
+
+describe("WatchlistTitleCard", () => {
+ beforeEach(() => vi.stubGlobal("CSS", { supports: () => true }));
+ afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ });
+
+ it("opens the Discover title page and never offers Play", () => {
+ renderCard(downloading, buildDefaultPrefs());
+ for (const link of screen.getAllByRole("link", { name: /Heat/ })) {
+ expect(link).toHaveAttribute("href", "/title/movie/949");
+ }
+ expect(screen.queryByRole("button", { name: /play/i })).toBeNull();
+ });
+
+ const captionCases: Array<[string, CardOverlayPrefs | null]> = [
+ ["overlays off", null],
+ ["the status badge off", prefsWith("classic", "content_rating")],
+ ...PRESET_IDS.map((id): [string, CardOverlayPrefs] => [`the ${id} preset`, prefsWith(id)]),
+ ];
+
+ it.each(captionCases)("keeps the full status in the caption with %s", (_, prefs) => {
+ for (const caption of ["title_metadata", "title", "artwork"] as const) {
+ renderCard(downloading, prefs, { caption });
+ expect(screen.getByTestId("watchlist-title-caption").textContent).toMatch(
+ /^Downloading · 43%/,
+ );
+ cleanup();
+ }
+ });
+
+ it("draws the download bar only while overlays and the status badge are on", () => {
+ const prefs = buildDefaultPrefs();
+ prefs.items.request_status = { ...prefs.items.request_status, position: "bottom-left" };
+ const withBar = renderCard(downloading, prefs);
+ expect(withBar.querySelector("[data-request-download-bar]")).not.toBeNull();
+ // The bottom badge row rises clear of the bar.
+ expect(withBar.querySelector('[data-overlay-edge="bottom"]')).toHaveClass("mb-2");
+ cleanup();
+
+ expect(renderCard(downloading, null).querySelector("[data-request-download-bar]")).toBeNull();
+ cleanup();
+
+ const badgeOff = buildDefaultPrefs();
+ badgeOff.items.request_status = { ...badgeOff.items.request_status, enabled: false };
+ expect(
+ renderCard(downloading, badgeOff).querySelector("[data-request-download-bar]"),
+ ).toBeNull();
+ });
+
+ it("marks a title that needs attention in amber with a way to find it again", () => {
+ renderCard(lost, buildDefaultPrefs());
+ const caption = screen.getByTestId("watchlist-title-caption");
+ expect(caption).toHaveClass("text-amber-300");
+ expect(caption.textContent).toBe("No longer listed on TMDB · Find it");
+ expect(screen.getByRole("link", { name: "Find it" }).getAttribute("href")).toContain(
+ "q=Lost+Film",
+ );
+ });
+
+ it("offers Request on hover only for a title that can be requested", () => {
+ const onRequest = vi.fn();
+ renderCard(notRequested, null, { onRequest });
+ fireEvent.click(screen.getByRole("button", { name: /Request Open Title/ }));
+ expect(onRequest).toHaveBeenCalledTimes(1);
+ cleanup();
+
+ renderCard(downloading, null, { onRequest });
+ expect(screen.queryByRole("button", { name: /Request Heat/ })).toBeNull();
+ });
+
+ it("removes the title from the watchlist from the corner action", () => {
+ const onRemove = vi.fn();
+ renderCard(downloading, null, { onRemove });
+ const button = screen.getByRole("button", { name: "Remove Heat from your watchlist" });
+ expect(button).toHaveAttribute("aria-pressed", "true");
+ fireEvent.click(button);
+ expect(onRemove).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/web/src/components/watchlist/WatchlistTitleCard.tsx b/web/src/components/watchlist/WatchlistTitleCard.tsx
new file mode 100644
index 0000000000..01cf6967e3
--- /dev/null
+++ b/web/src/components/watchlist/WatchlistTitleCard.tsx
@@ -0,0 +1,138 @@
+import type { WatchlistTitle } from "@/api/v2/watchlistTitles";
+import MediaCardArtwork, {
+ MEDIA_CARD_CAPTION_CLASS,
+ MEDIA_CARD_META_CLASS,
+ MEDIA_CARD_TITLE_CLASS,
+} from "@/components/MediaCardArtwork";
+import CardOverlays from "@/components/overlays/CardOverlays";
+import { RequestDownloadBar } from "@/components/overlays/RequestDownloadBar";
+import { RequestAction, WatchlistAction } from "@/components/RequestPosterCard";
+import ViewTransitionLink from "@/components/ViewTransitionLink";
+import { useUICustomization } from "@/hooks/useUICustomization";
+import {
+ formatMediaType,
+ requestDetailHref,
+ requestSearchHref,
+ tmdbImageURL,
+} from "@/lib/mediaRequests";
+import {
+ overlayDataFromWatchlistTitle,
+ requestDownloadBarPercent,
+ type CardOverlayPrefs,
+} from "@/lib/overlays";
+import { cn } from "@/lib/utils";
+import type { WatchlistTitleStatus } from "@/lib/watchlistTitles";
+
+/**
+ * A watchlist title the library doesn't have yet. The poster carries the
+ * viewer's card overlays, with the request status as one of them; the caption
+ * always says the full status in words, so the card reads the same with
+ * overlays off. It opens the Discover title page and never offers Play.
+ */
+export default function WatchlistTitleCard({
+ title,
+ status,
+ overlayPrefs,
+ onRequest,
+ isRequesting = false,
+ onRemove,
+ isRemoving = false,
+}: {
+ title: WatchlistTitle;
+ status: WatchlistTitleStatus;
+ /** The viewer's overlay preferences; null while loading or with overlays off. */
+ overlayPrefs: CardOverlayPrefs | null;
+ /** Requests the title from the hover action; shown only on a requestable card. */
+ onRequest?: () => void;
+ isRequesting?: boolean;
+ /** Removes the title from the watchlist from the hover corner action. */
+ onRemove?: () => void;
+ isRemoving?: boolean;
+}) {
+ const { cardPresentation } = useUICustomization();
+ const showCaption = cardPresentation.caption !== "artwork";
+ const href = requestDetailHref(title.media_type, title.tmdb_id);
+ const meta = [formatMediaType(title.media_type), title.year].filter(Boolean).join(" · ");
+ const label = `${title.title} (${meta})`;
+ const overlayData = overlayDataFromWatchlistTitle(title, status);
+ const downloadPercent = overlayPrefs
+ ? requestDownloadBarPercent(overlayData, overlayPrefs)
+ : null;
+
+ return (
+
+
+
+
+ {downloadPercent !== null ? : null}
+ {overlayPrefs ? (
+
+ ) : null}
+
+
+ {status.requestable && onRequest ? (
+
+ ) : null}
+ {onRemove ? (
+
+
+
+ ) : null}
+
+
+ {showCaption ? (
+ <>
+
+ {title.title}
+
+
+ {meta}
+
+ >
+ ) : null}
+ {/* The status stays under every caption setting: with overlays off,
+ it is the only place the card says where the title stands. */}
+
+ {status.caption}
+ {status.attention ? (
+ <>
+ {" · "}
+
+ Find it
+
+ >
+ ) : null}
+
+
+
+ );
+}
diff --git a/web/src/components/watchlist/WatchlistTitlesTab.test.tsx b/web/src/components/watchlist/WatchlistTitlesTab.test.tsx
new file mode 100644
index 0000000000..12df3dbf69
--- /dev/null
+++ b/web/src/components/watchlist/WatchlistTitlesTab.test.tsx
@@ -0,0 +1,97 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router";
+import type { WatchlistTitle } from "@/api/v2/watchlistTitles";
+import WatchlistTitlesTab from "./WatchlistTitlesTab";
+
+const mocks = vi.hoisted(() => ({ toggle: vi.fn(), submit: vi.fn() }));
+
+vi.mock("@/hooks/useOverlayPrefs", () => ({
+ useOverlayPrefs: () => ({ prefs: null, isOverlaySupported: true }),
+}));
+vi.mock("@/hooks/useSubmitMediaRequest", () => ({
+ useSubmitMediaRequest: () => ({ submit: mocks.submit, isSubmitting: () => false }),
+}));
+vi.mock("@/hooks/useWatchlistTitleToggle", () => ({
+ useWatchlistTitleToggle: () => ({ enabled: true, toggle: mocks.toggle, isPending: () => false }),
+}));
+
+function title(tmdbID: number, overrides: Partial
= {}): WatchlistTitle {
+ return {
+ media_type: "movie",
+ tmdb_id: tmdbID,
+ title: `Title ${tmdbID}`,
+ added_at: "2026-09-01T00:00:00Z",
+ status: "active",
+ request: { requestable: true },
+ ...overrides,
+ };
+}
+
+function renderTab(props: Partial[0]> = {}) {
+ render(
+
+
+ ,
+ );
+}
+
+describe("WatchlistTitlesTab", () => {
+ afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ });
+
+ it("points an empty watchlist to Discover", () => {
+ renderTab();
+ expect(screen.getByText("Nothing waiting for the library")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Browse Discover" })).toHaveAttribute(
+ "href",
+ "/requests",
+ );
+ });
+
+ it("explains the tab, links to Discover, and lists titles soonest first", () => {
+ renderTab({
+ titles: [
+ title(1, { status: "removed" }),
+ title(2, { request: { requestable: false, status: "pending" } }),
+ ],
+ watchlistRequests: false,
+ });
+ expect(screen.getByText(/can't be played\. When one arrives/)).toBeInTheDocument();
+ expect(screen.queryByText(/They've been requested for you/)).toBeNull();
+ expect(screen.getByRole("link", { name: "Find more in Discover" })).toHaveAttribute(
+ "href",
+ "/requests",
+ );
+ expect(
+ screen.getAllByTestId("watchlist-title-caption").map((node) => node.textContent),
+ ).toEqual(["Awaiting approval", "No longer listed on TMDB · Find it"]);
+ });
+
+ it("requests and removes titles from their cards", () => {
+ const heat = title(3);
+ renderTab({ titles: [heat] });
+ fireEvent.click(screen.getByRole("button", { name: /Request Title 3/ }));
+ expect(mocks.submit).toHaveBeenCalledWith(
+ expect.objectContaining({ media_type: "movie", tmdb_id: 3, availability: "missing" }),
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Remove Title 3 from your watchlist" }));
+ expect(mocks.toggle).toHaveBeenCalledWith({ ...heat, in_watchlist: true });
+ });
+
+ it("offers a retry when the titles fail to load", () => {
+ const onRetry = vi.fn();
+ renderTab({ titles: undefined, isError: true, onRetry });
+ fireEvent.click(screen.getByRole("button", { name: "Retry" }));
+ expect(onRetry).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/web/src/components/watchlist/WatchlistTitlesTab.tsx b/web/src/components/watchlist/WatchlistTitlesTab.tsx
new file mode 100644
index 0000000000..48e2e53600
--- /dev/null
+++ b/web/src/components/watchlist/WatchlistTitlesTab.tsx
@@ -0,0 +1,131 @@
+import { useMemo } from "react";
+import { Bookmark, Compass, RefreshCw } from "lucide-react";
+import type { WatchlistTitle } from "@/api/v2/watchlistTitles";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+import ViewTransitionLink from "@/components/ViewTransitionLink";
+import WatchlistTitleCard from "@/components/watchlist/WatchlistTitleCard";
+import { useOverlayPrefs } from "@/hooks/useOverlayPrefs";
+import { useSubmitMediaRequest } from "@/hooks/useSubmitMediaRequest";
+import { useWatchlistTitleToggle } from "@/hooks/useWatchlistTitleToggle";
+import { cardGridClasses } from "@/lib/uiCustomization";
+import {
+ sortWatchlistTitlesSoonestFirst,
+ watchlistTitlesHint,
+ watchlistTitleStatus,
+} from "@/lib/watchlistTitles";
+
+const DISCOVER_HREF = "/requests";
+
+/**
+ * The watchlist's "Not in your library yet" tab: titles saved from Discover
+ * that the library doesn't have, soonest first, each opening its Discover
+ * title page.
+ */
+export default function WatchlistTitlesTab({
+ titles,
+ isLoading,
+ isError,
+ onRetry,
+ watchlistRequests,
+}: {
+ titles: WatchlistTitle[] | undefined;
+ isLoading: boolean;
+ isError: boolean;
+ onRetry: () => void;
+ /** Adding a title requests it for this viewer (FeatureStatus.watchlist_requests). */
+ watchlistRequests: boolean;
+}) {
+ const { prefs: overlayPrefs } = useOverlayPrefs();
+ const { submit, isSubmitting } = useSubmitMediaRequest();
+ const watchlist = useWatchlistTitleToggle();
+ const entries = useMemo(() => {
+ const now = new Date();
+ return sortWatchlistTitlesSoonestFirst(titles ?? [], now).map((title) => ({
+ title,
+ status: watchlistTitleStatus(title, now),
+ }));
+ }, [titles]);
+
+ if (isError) {
+ return (
+
+
Could not load these titles.
+
+
+ Retry
+
+
+ );
+ }
+
+ if (isLoading) {
+ return (
+
+ {Array.from({ length: 6 }).map((_, index) => (
+
+ ))}
+
+ );
+ }
+
+ if (entries.length === 0) {
+ return (
+
+
+
Nothing waiting for the library
+
+ Add movies and series from Discover to your watchlist. The ones the library doesn’t
+ have yet wait here until they arrive.
+
+
+
+
+ Browse Discover
+
+
+
+ );
+ }
+
+ return (
+
+
+ {watchlistTitlesHint(watchlistRequests)}
+
+ Find more in Discover
+
+
+
+ {entries.map(({ title, status }) => (
+
+ submit({
+ media_type: title.media_type,
+ tmdb_id: title.tmdb_id,
+ title: title.title,
+ year: title.year,
+ poster_path: title.poster_path,
+ availability: "missing",
+ request: title.request,
+ })
+ }
+ isRequesting={isSubmitting(title)}
+ onRemove={() => watchlist.toggle({ ...title, in_watchlist: true })}
+ isRemoving={watchlist.isPending(title)}
+ />
+ ))}
+
+
+ );
+}
diff --git a/web/src/hooks/queries/keys.ts b/web/src/hooks/queries/keys.ts
index ee61339c71..a58c0463a6 100644
--- a/web/src/hooks/queries/keys.ts
+++ b/web/src/hooks/queries/keys.ts
@@ -103,6 +103,8 @@ export const watchlistKeys = {
all: ["watchlist"] as const,
list: () => ["watchlist", "list"] as const,
check: (itemId: string) => ["watchlist", "check", itemId] as const,
+ /** Watchlist entries for titles the library doesn't have yet. */
+ titles: () => ["watchlist", "titles"] as const,
};
export const historyKeys = {
diff --git a/web/src/hooks/queries/useRequests.ts b/web/src/hooks/queries/useRequests.ts
index cd600b19c2..5fa2fb781b 100644
--- a/web/src/hooks/queries/useRequests.ts
+++ b/web/src/hooks/queries/useRequests.ts
@@ -37,7 +37,7 @@ import type {
RequestMediaType,
} from "@/api/types";
import { tmdbPageCount } from "@/lib/mediaRequests";
-import { adminKeys, requestKeys } from "./keys";
+import { adminKeys, requestKeys, watchlistKeys } from "./keys";
export const REQUESTS_STALE_TIME = 30_000;
const DISCOVER_BRAND_STALE_TIME = 24 * 60 * 60 * 1000;
@@ -78,6 +78,8 @@ export function invalidateRequestSurfaces(queryClient: ReturnType ({
+ ...(await importOriginal()),
+ scheduleMediaSurfaceInvalidation: vi.fn(),
+}));
+
+describe("watchlistAddToast", () => {
+ const input = { title: "Heat", request: { requestable: true } };
+
+ it("says a title the library has was only added", () => {
+ expect(
+ watchlistAddToast({ item_id: "abc", request: { requestable: false } }, input, true),
+ ).toEqual({ title: "Added to your watchlist" });
+ });
+
+ it("says the add also requested the title", () => {
+ expect(
+ watchlistAddToast(
+ { request: { requestable: false, status: "pending", requested_by_viewer: true } },
+ input,
+ true,
+ ),
+ ).toEqual({
+ title: "Added to your watchlist and requested",
+ description:
+ "We'll let you know when Heat is available. It moves into your watchlist on its own.",
+ });
+ });
+
+ it("says the add followed someone else's request", () => {
+ expect(
+ watchlistAddToast(
+ { request: { requestable: false, status: "pending", following: true } },
+ input,
+ true,
+ ),
+ ).toEqual({
+ title: "Added to your watchlist",
+ description: "We'll let you know when Heat is available.",
+ });
+ });
+
+ it("stays quiet about a request the viewer already had", () => {
+ const requested = { requestable: false, status: "pending" as const, requested_by_viewer: true };
+ expect(
+ watchlistAddToast({ request: requested }, { title: "Heat", request: requested }, true),
+ ).toEqual({ title: "Added to your watchlist" });
+ });
+
+ it("gives the reason a request was refused", () => {
+ expect(
+ watchlistAddToast({ request: { requestable: false, reason: "quota_exceeded" } }, input, true),
+ ).toEqual({
+ title: "Added to your watchlist",
+ description: "It wasn't requested: request limit reached.",
+ });
+ });
+
+ it("says watchlist requests are off", () => {
+ expect(watchlistAddToast({ request: { requestable: true } }, input, false)).toEqual({
+ title: "Added to your watchlist",
+ description: "It wasn't requested, because watchlist requests are off.",
+ });
+ expect(watchlistAddToast({ request: { requestable: true } }, input, undefined)).toEqual({
+ title: "Added to your watchlist",
+ description: "It wasn't requested.",
+ });
+ });
+});
+
+describe("patchCachedInWatchlist", () => {
+ const heat = { media_type: "movie", tmdb_id: 949, title: "Heat", request: { requestable: true } };
+ const other = {
+ media_type: "series",
+ tmdb_id: 949,
+ title: "Other",
+ request: { requestable: true },
+ };
+
+ it("sets in_watchlist on the matching title at any depth", () => {
+ const data = { sections: [{ items: [heat, other] }], detail: { ...heat, in_watchlist: false } };
+ const patched = patchCachedInWatchlist(data, "movie", 949, true) as typeof data;
+ expect(patched.sections[0]!.items[0]).toEqual({ ...heat, in_watchlist: true });
+ expect(patched.detail.in_watchlist).toBe(true);
+ // A series with the same TMDB number is a different title.
+ expect(patched.sections[0]!.items[1]).toBe(other);
+ });
+
+ it("keeps the references of anything it does not change", () => {
+ const data = { pages: [{ items: [other] }] };
+ expect(patchCachedInWatchlist(data, "movie", 949, true)).toBe(data);
+ const already = { items: [{ ...heat, in_watchlist: true }] };
+ expect(patchCachedInWatchlist(already, "movie", 949, true)).toBe(already);
+ });
+
+ it("ignores objects without a request state", () => {
+ const person = { media_type: "movie", tmdb_id: 949, name: "Not a title" };
+ expect(patchCachedInWatchlist(person, "movie", 949, true)).toBe(person);
+ });
+});
+
+describe("refreshAfterTitlesRead", () => {
+ const title = (tmdbID: number) => ({ media_type: "movie", tmdb_id: tmdbID }) as WatchlistTitle;
+ let queryClient: QueryClient;
+ let invalidated: unknown[][];
+
+ beforeEach(() => {
+ vi.mocked(scheduleMediaSurfaceInvalidation).mockClear();
+ queryClient = new QueryClient();
+ invalidated = [];
+ const invalidate = queryClient.invalidateQueries.bind(queryClient);
+ vi.spyOn(queryClient, "invalidateQueries").mockImplementation((filters, options) => {
+ for (const query of queryClient.getQueryCache().findAll(filters)) {
+ invalidated.push([...query.queryKey]);
+ }
+ return invalidate(filters, options);
+ });
+ queryClient.setQueryData(["catalog", "list", { source: "watchlist" }], { items: [] });
+ queryClient.setQueryData(["catalog", "list", { source: "favorites" }], { items: [] });
+ queryClient.setQueryData(["watchlist", "titles"], []);
+ });
+
+ it("refreshes the library watchlist grid after every read, not other lists or itself", () => {
+ refreshAfterTitlesRead(queryClient, undefined, [title(1)]);
+ expect(invalidated).toEqual([["catalog", "list", { source: "watchlist" }]]);
+ expect(scheduleMediaSurfaceInvalidation).not.toHaveBeenCalled();
+ });
+
+ it("refreshes the library surfaces when a title the tab showed is gone", () => {
+ refreshAfterTitlesRead(queryClient, [title(1), title(2)], [title(2)]);
+ expect(scheduleMediaSurfaceInvalidation).toHaveBeenCalledTimes(1);
+ });
+
+ it("settles when the same titles come back", () => {
+ refreshAfterTitlesRead(queryClient, [title(2)], [title(2), title(3)]);
+ expect(scheduleMediaSurfaceInvalidation).not.toHaveBeenCalled();
+ });
+});
+
+describe("watchlistTitlesRefetchInterval", () => {
+ const title = (download?: object) =>
+ ({
+ media_type: "movie",
+ tmdb_id: 1,
+ request: { requestable: false, download },
+ }) as WatchlistTitle;
+
+ it("polls while a title downloads", () => {
+ expect(watchlistTitlesRefetchInterval([title(), title({ phase: "downloading" })])).toBe(30_000);
+ });
+
+ it("stays quiet otherwise", () => {
+ expect(watchlistTitlesRefetchInterval([title()])).toBe(false);
+ expect(watchlistTitlesRefetchInterval(undefined)).toBe(false);
+ });
+});
diff --git a/web/src/hooks/queries/watchlistTitles.ts b/web/src/hooks/queries/watchlistTitles.ts
new file mode 100644
index 0000000000..ce6866d777
--- /dev/null
+++ b/web/src/hooks/queries/watchlistTitles.ts
@@ -0,0 +1,245 @@
+import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import type { RequestMediaType, RequestState } from "@/api/types";
+import {
+ addWatchlistTitleV2,
+ deleteWatchlistTitleV2,
+ listWatchlistTitlesV2,
+ type WatchlistTitle,
+ type WatchlistTitleEntry,
+} from "@/api/v2/watchlistTitles";
+import { formatRequestReason } from "@/lib/mediaRequests";
+import { catalogKeys, requestKeys, watchlistKeys } from "./keys";
+import { scheduleMediaSurfaceInvalidation } from "./mediaSurfaceRefresh";
+import {
+ invalidateRequestSurfaces,
+ REQUEST_DOWNLOAD_REFETCH_INTERVAL,
+ REQUESTS_STALE_TIME,
+} from "./useRequests";
+
+/** Watchlist entries for titles the library doesn't have yet. */
+export function useWatchlistTitles(options: { enabled?: boolean } = {}) {
+ const queryClient = useQueryClient();
+ return useQuery({
+ queryKey: watchlistKeys.titles(),
+ queryFn: async ({ signal }) => {
+ const before = queryClient.getQueryData(watchlistKeys.titles());
+ const titles = await listWatchlistTitlesV2(signal);
+ refreshAfterTitlesRead(queryClient, before, titles);
+ return titles;
+ },
+ enabled: options.enabled ?? true,
+ staleTime: REQUESTS_STALE_TIME,
+ refetchInterval: (query) => watchlistTitlesRefetchInterval(query.state.data),
+ });
+}
+
+/**
+ * While a title downloads, the list refreshes on the interval the other
+ * request surfaces use, so its progress doesn't freeze.
+ */
+export function watchlistTitlesRefetchInterval(
+ titles: readonly WatchlistTitle[] | undefined,
+): number | false {
+ return titles?.some((t) => t.request.download) ? REQUEST_DOWNLOAD_REFETCH_INTERVAL : false;
+}
+
+/**
+ * Reading the titles moves the ones the library now has onto the library
+ * watchlist, server side, so the library tab's grid is refreshed after every
+ * read. When a title this tab showed is gone, the library surfaces that list
+ * the watchlist (the home row, item states) refresh too. That refresh reads
+ * the titles once more, which finds nothing else gone, so it settles.
+ */
+export function refreshAfterTitlesRead(
+ queryClient: QueryClient,
+ before: readonly WatchlistTitle[] | undefined,
+ after: readonly WatchlistTitle[],
+): void {
+ queryClient.invalidateQueries({ predicate: (query) => isWatchlistCatalogQuery(query.queryKey) });
+ if (!before) return;
+ const still = new Set(after.map((t) => `${t.media_type}-${t.tmdb_id}`));
+ if (before.some((t) => !still.has(`${t.media_type}-${t.tmdb_id}`))) {
+ scheduleMediaSurfaceInvalidation(queryClient);
+ }
+}
+
+export interface ToggleWatchlistTitleInput {
+ mediaType: RequestMediaType;
+ tmdbID: number;
+ /** Names the title in the toast. */
+ title: string;
+ /** Whether the title is on the watchlist now; the mutation flips it. */
+ inWatchlist: boolean;
+ /** The title's request state before the add, to tell what the add did. */
+ request?: RequestState;
+}
+
+/** The toast after an add: whether the title was also requested, and why not. */
+export function watchlistAddToast(
+ entry: Pick,
+ input: Pick,
+ watchlistRequests: boolean | undefined,
+): { title: string; description?: string } {
+ const before = input.request;
+ const after = entry.request;
+ const available = `We'll let you know when ${input.title} is available.`;
+ if (entry.item_id) {
+ return { title: "Added to your watchlist" };
+ }
+ if (after.requested_by_viewer && !before?.requested_by_viewer) {
+ return {
+ title: "Added to your watchlist and requested",
+ description: `${available} It moves into your watchlist on its own.`,
+ };
+ }
+ if (after.following && !before?.following) {
+ return { title: "Added to your watchlist", description: available };
+ }
+ if (after.status) {
+ // It already had a request the viewer made or follows.
+ return { title: "Added to your watchlist" };
+ }
+ if (after.reason) {
+ return {
+ title: "Added to your watchlist",
+ description: `It wasn't requested: ${formatRequestReason(after.reason).toLowerCase()}.`,
+ };
+ }
+ if (watchlistRequests === false) {
+ return {
+ title: "Added to your watchlist",
+ description: "It wasn't requested, because watchlist requests are off.",
+ };
+ }
+ return { title: "Added to your watchlist", description: "It wasn't requested." };
+}
+
+/**
+ * Sets in_watchlist on every cached TMDB title matching the key: the title
+ * page, and the Discover, browse and search results that list it. The request
+ * caches hold those titles at several depths (pages, sections, the title
+ * page's recommendations), so this walks plain objects and arrays, and keeps
+ * the reference of anything it does not change.
+ */
+export function patchCachedInWatchlist(
+ data: unknown,
+ mediaType: RequestMediaType,
+ tmdbID: number,
+ inWatchlist: boolean,
+): unknown {
+ if (Array.isArray(data)) {
+ let changed = false;
+ const next = data.map((entry) => {
+ const patched = patchCachedInWatchlist(entry, mediaType, tmdbID, inWatchlist);
+ if (patched !== entry) changed = true;
+ return patched;
+ });
+ return changed ? next : data;
+ }
+ if (
+ typeof data !== "object" ||
+ data === null ||
+ Object.getPrototypeOf(data) !== Object.prototype
+ ) {
+ return data;
+ }
+ const record = data as Record;
+ let next: Record | null = null;
+ for (const [key, value] of Object.entries(record)) {
+ if (typeof value !== "object" || value === null) continue;
+ const patched = patchCachedInWatchlist(value, mediaType, tmdbID, inWatchlist);
+ if (patched !== value) {
+ next ??= { ...record };
+ next[key] = patched;
+ }
+ }
+ const isTitle =
+ record.media_type === mediaType &&
+ record.tmdb_id === tmdbID &&
+ typeof record.request === "object" &&
+ record.request !== null;
+ if (isTitle && record.in_watchlist !== inWatchlist) {
+ next ??= { ...record };
+ next.in_watchlist = inWatchlist;
+ }
+ return next ?? data;
+}
+
+function setCachedInWatchlist(
+ queryClient: QueryClient,
+ mediaType: RequestMediaType,
+ tmdbID: number,
+ inWatchlist: boolean,
+) {
+ queryClient.setQueriesData({ queryKey: requestKeys.all }, (data: unknown) =>
+ patchCachedInWatchlist(data, mediaType, tmdbID, inWatchlist),
+ );
+}
+
+function isWatchlistCatalogQuery(queryKey: readonly unknown[]): boolean {
+ const [root, kind, params] = queryKey;
+ return (
+ root === catalogKeys.all[0] &&
+ (kind === "list" || kind === "filters") &&
+ typeof params === "object" &&
+ params !== null &&
+ (params as { source?: unknown }).source === "watchlist"
+ );
+}
+
+/**
+ * Adds a title to the watchlist by its TMDB ID, or removes it. The server may
+ * also request the title on add, or cancel its watchlist request on remove,
+ * so both refresh the request surfaces as well as the watchlist.
+ */
+export function useToggleWatchlistTitle() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ retry: false,
+ mutationFn: async (input: ToggleWatchlistTitleInput): Promise => {
+ if (input.inWatchlist) {
+ await deleteWatchlistTitleV2(input.mediaType, input.tmdbID);
+ return null;
+ }
+ return addWatchlistTitleV2(input.mediaType, input.tmdbID);
+ },
+ onMutate: async ({ mediaType, tmdbID, inWatchlist }) => {
+ await queryClient.cancelQueries({ queryKey: requestKeys.detail(mediaType, tmdbID) });
+ setCachedInWatchlist(queryClient, mediaType, tmdbID, !inWatchlist);
+ },
+ onError: (err, { mediaType, tmdbID, inWatchlist }) => {
+ setCachedInWatchlist(queryClient, mediaType, tmdbID, inWatchlist);
+ toast.error(err instanceof Error ? err.message : "Failed to update your watchlist");
+ },
+ onSuccess: (entry, input) => {
+ if (!entry) {
+ toast.success("Removed from your watchlist");
+ return;
+ }
+ const status = queryClient.getQueryData<{ watchlist_requests?: boolean }>(
+ requestKeys.status(),
+ );
+ const message = watchlistAddToast(entry, input, status?.watchlist_requests);
+ toast.success(
+ message.title,
+ message.description ? { description: message.description } : undefined,
+ );
+ },
+ onSettled: (entry, _err, input) => {
+ queryClient.invalidateQueries({ queryKey: watchlistKeys.all });
+ queryClient.invalidateQueries({
+ predicate: (query) => isWatchlistCatalogQuery(query.queryKey),
+ });
+ invalidateRequestSurfaces(queryClient);
+ // An add the library has lands on its item, and a remove drops the
+ // item's entry too, so the library surfaces (the home watchlist row,
+ // the item's user state) refresh as for a library watchlist toggle.
+ if (entry?.item_id) {
+ scheduleMediaSurfaceInvalidation(queryClient, { itemId: entry.item_id });
+ } else if (input.inWatchlist) {
+ scheduleMediaSurfaceInvalidation(queryClient);
+ }
+ },
+ });
+}
diff --git a/web/src/hooks/useOverlayPrefs.test.tsx b/web/src/hooks/useOverlayPrefs.test.tsx
index 7407dcad2a..5401c0e559 100644
--- a/web/src/hooks/useOverlayPrefs.test.tsx
+++ b/web/src/hooks/useOverlayPrefs.test.tsx
@@ -290,24 +290,38 @@ describe("useOverlayPrefs", () => {
// ui.card_overlays validation is all-or-nothing, so one overlay id the
// server's schema predates would fail every badge save on that server. With
// the revision unknown, an id the server already stored is still accepted.
- it.each([
- { name: "a revision-12 server", revision: 12, storedAdvisory: false, kept: false },
- { name: "a revision-13 server", revision: 13, storedAdvisory: false, kept: true },
- { name: "an unknown revision", revision: undefined, storedAdvisory: false, kept: false },
- {
- name: "an unknown revision with a stored advisory badge",
- revision: undefined,
- storedAdvisory: true,
- kept: true,
- },
- ])("writes advisory_age only where it is accepted: $name", async (c) => {
+ it.each(
+ (
+ [
+ ["advisory_age", 13],
+ ["request_status", 15],
+ ] as const
+ ).flatMap(([id, since]) => [
+ {
+ id,
+ name: `a revision-${since - 1} server`,
+ revision: since - 1,
+ stored: false,
+ kept: false,
+ },
+ { id, name: `a revision-${since} server`, revision: since, stored: false, kept: true },
+ { id, name: "an unknown revision", revision: undefined, stored: false, kept: false },
+ {
+ id,
+ name: "an unknown revision with it stored",
+ revision: undefined,
+ stored: true,
+ kept: true,
+ },
+ ]),
+ )("writes $id only where it is accepted: $name", async (c) => {
mocks.profileId = "profile-1";
- mocks.effective = c.storedAdvisory
+ mocks.effective = c.stored
? (effectiveOverlayValue({
version: 2,
preset: "classic",
order: [],
- items: { advisory_age: { enabled: true, position: "bottom-right" } },
+ items: { [c.id]: { enabled: true, position: "bottom-right" } },
}).data as Record)
: {};
mocks.v2.mockImplementation(async (operation: string) => {
@@ -328,17 +342,17 @@ describe("useOverlayPrefs", () => {
"pending",
);
});
- expect(result.current.isOverlaySupported("advisory_age")).toBe(c.kept);
+ expect(result.current.isOverlaySupported(c.id)).toBe(c.kept);
const next = buildDefaultPrefs();
next.preset = "pill";
- next.order = ["advisory_age", "year"];
- next.items.advisory_age = { enabled: true, position: "bottom-right" };
+ next.order = [c.id, "year"];
+ next.items[c.id] = { enabled: true, position: "bottom-right" };
act(() => result.current.setPrefs(next));
const written = mocks.setValue.mock.calls[0]![0].value as CardOverlayPrefs;
- expect("advisory_age" in written.items).toBe(c.kept);
- expect(written.order).toEqual(c.kept ? ["advisory_age", "year"] : ["year"]);
+ expect(c.id in written.items).toBe(c.kept);
+ expect(written.order).toEqual(c.kept ? [c.id, "year"] : ["year"]);
expect(written.items.year).toEqual(next.items.year);
});
diff --git a/web/src/hooks/useSubmitMediaRequest.ts b/web/src/hooks/useSubmitMediaRequest.ts
index d53c4454a7..89128f01dc 100644
--- a/web/src/hooks/useSubmitMediaRequest.ts
+++ b/web/src/hooks/useSubmitMediaRequest.ts
@@ -37,7 +37,7 @@ export function useSubmitMediaRequest() {
);
const isSubmitting = useCallback(
- (item: RequestMediaResult) => pendingKeys.has(resultKey(item)),
+ (item: Pick) => pendingKeys.has(resultKey(item)),
[pendingKeys],
);
diff --git a/web/src/hooks/useWatchlistTitleToggle.ts b/web/src/hooks/useWatchlistTitleToggle.ts
new file mode 100644
index 0000000000..1e8080bdd6
--- /dev/null
+++ b/web/src/hooks/useWatchlistTitleToggle.ts
@@ -0,0 +1,62 @@
+import { useCallback, useState } from "react";
+import type { RequestMediaResult } from "@/api/types";
+import { useRequestFeatureStatus } from "@/hooks/queries/useRequests";
+import { useToggleWatchlistTitle } from "@/hooks/queries/watchlistTitles";
+import { watchlistTitlesAvailable } from "@/lib/watchlistTitles";
+
+type WatchlistToggleTarget = Pick<
+ RequestMediaResult,
+ "media_type" | "tmdb_id" | "title" | "in_watchlist" | "request"
+>;
+
+function titleKey(item: Pick): string {
+ return `${item.media_type}-${item.tmdb_id}`;
+}
+
+/**
+ * Adds TMDB titles to the watchlist from a list of cards, or removes them,
+ * and tracks which cards have a change in flight (see useSubmitMediaRequest
+ * for why each card waits on its own call). `enabled` is false until the
+ * server reports it keeps watchlist entries for titles outside the library.
+ */
+export function useWatchlistTitleToggle() {
+ const featureStatus = useRequestFeatureStatus();
+ const { mutateAsync } = useToggleWatchlistTitle();
+ const [pendingKeys, setPendingKeys] = useState>(() => new Set());
+
+ const toggle = useCallback(
+ (item: WatchlistToggleTarget) => {
+ const key = titleKey(item);
+ setPendingKeys((prev) => new Set(prev).add(key));
+ mutateAsync({
+ mediaType: item.media_type,
+ tmdbID: item.tmdb_id,
+ title: item.title,
+ inWatchlist: Boolean(item.in_watchlist),
+ request: item.request,
+ })
+ // The mutation's own onError already reported the failure.
+ .catch(() => {})
+ .finally(() => {
+ setPendingKeys((prev) => {
+ if (!prev.has(key)) return prev;
+ const next = new Set(prev);
+ next.delete(key);
+ return next;
+ });
+ });
+ },
+ [mutateAsync],
+ );
+
+ const isPending = useCallback(
+ (item: Pick) => pendingKeys.has(titleKey(item)),
+ [pendingKeys],
+ );
+
+ return {
+ enabled: watchlistTitlesAvailable(featureStatus.data),
+ toggle,
+ isPending,
+ };
+}
diff --git a/web/src/lib/documentTitle.ts b/web/src/lib/documentTitle.ts
index 02258a0265..c3af8f7200 100644
--- a/web/src/lib/documentTitle.ts
+++ b/web/src/lib/documentTitle.ts
@@ -32,6 +32,7 @@ const SETTINGS_TITLES: Record = {
playback: "Playback Settings",
profiles: "Profile Settings",
libraries: "Library Settings",
+ requests: "Request Settings",
"history-import": "History Import Settings",
"plex-webhooks": "Webhook Sync Settings",
"webhook-sync": "Webhook Sync Settings",
diff --git a/web/src/lib/overlays/extractors.ts b/web/src/lib/overlays/extractors.ts
index 0b42155317..1b89b744a0 100644
--- a/web/src/lib/overlays/extractors.ts
+++ b/web/src/lib/overlays/extractors.ts
@@ -1,4 +1,6 @@
import type { BrowseItem, EpisodeListItem, OverlaySummary, SectionItem } from "@/api/types";
+import type { WatchlistTitle } from "@/api/v2/watchlistTitles";
+import type { WatchlistTitleStatus } from "@/lib/watchlistTitles";
import type { OverlayData } from "./types";
// BrowseItem and SectionItem share the fields the overlay system consumes;
@@ -74,3 +76,25 @@ export function overlayDataFromSectionItem(item: SectionItem): OverlayData {
export function overlayDataFromEpisodeListItem(item: EpisodeListItem): OverlayData {
return extract(item);
}
+
+/**
+ * A watchlist title the library doesn't have. TMDB supplies the rating, age
+ * rating and year; file-based badges (resolution, HDR, audio) have no value
+ * and don't render. The request_status badge takes the status the card
+ * derived (watchlistTitleStatus), which keeps the request labels out of the
+ * overlay library.
+ */
+export function overlayDataFromWatchlistTitle(
+ title: Pick,
+ status: Pick,
+): OverlayData {
+ return {
+ rating_tmdb: title.vote_average ?? null,
+ content_rating: title.content_rating || undefined,
+ year: title.year || null,
+ request_status: status.badge,
+ request_status_icon: status.badgeIcon,
+ request_status_attention: status.attention,
+ request_download_percent: status.downloadPercent ?? null,
+ };
+}
diff --git a/web/src/lib/overlays/icons.tsx b/web/src/lib/overlays/icons.tsx
index 66782b852c..7df4bdc832 100644
--- a/web/src/lib/overlays/icons.tsx
+++ b/web/src/lib/overlays/icons.tsx
@@ -4,8 +4,10 @@ import {
Building2,
Calendar,
Clock,
+ Download,
Film,
Globe,
+ Hourglass,
Languages,
LayoutTemplate,
Monitor,
@@ -13,6 +15,7 @@ import {
Shield,
Star,
Subtitles,
+ TriangleAlert,
Tv,
Users,
Volume2,
@@ -40,6 +43,9 @@ const LUCIDE_ICONS: Partial> = {
calendar: Calendar,
globe: Globe,
users: Users,
+ download: Download,
+ hourglass: Hourglass,
+ alert: TriangleAlert,
};
// Inline brand marks. Each is a tiny SVG component that fills currentColor so
diff --git a/web/src/lib/overlays/index.ts b/web/src/lib/overlays/index.ts
index 8227cdee32..c3edaeb8f3 100644
--- a/web/src/lib/overlays/index.ts
+++ b/web/src/lib/overlays/index.ts
@@ -27,6 +27,7 @@ export {
isOverlaySuppressed,
isOverlaySupportedBy,
overlayPrefsForServer,
+ requestDownloadBarPercent,
storedOverlayIds,
} from "./schema";
export type { OverlayServerSupport } from "./schema";
@@ -37,5 +38,14 @@ export {
overlayDataFromBrowseItem,
overlayDataFromEpisodeListItem,
overlayDataFromSectionItem,
+ overlayDataFromWatchlistTitle,
} from "./extractors";
-export { SAMPLE_MOVIE_DATA, SAMPLE_SHOW_DATA } from "./sample-data";
+export {
+ OVERLAY_PREVIEW_SAMPLES,
+ OVERLAY_PREVIEW_VARIANTS,
+ SAMPLE_MOVIE_DATA,
+ SAMPLE_REQUEST_DATA,
+ SAMPLE_SHOW_DATA,
+} from "./sample-data";
+export type { OverlayPreviewVariant } from "./sample-data";
+export { ATTENTION_ACCENT } from "./registry/ribbons";
diff --git a/web/src/lib/overlays/registry/ribbons.ts b/web/src/lib/overlays/registry/ribbons.ts
index edff46f6f4..8052dd1f4c 100644
--- a/web/src/lib/overlays/registry/ribbons.ts
+++ b/web/src/lib/overlays/registry/ribbons.ts
@@ -4,6 +4,9 @@ import type { OverlayDef } from "../types";
// ribbons. Only entries backed by a real API field belong in this registry;
// registry membership makes a control visible in Settings.
+// Amber, for a title TMDB no longer lists as saved.
+export const ATTENTION_ACCENT = "#f59e0b";
+
function formatShowStatus(value: string | undefined): string | null {
if (!value) return null;
switch (value.toLowerCase()) {
@@ -39,4 +42,18 @@ export const RIBBON_OVERLAYS: readonly OverlayDef[] = [
availabilityNote: "Populated by metadata plugins (TMDB/TVDB updates pending)",
getValue: (d) => formatShowStatus(d.show_status),
},
+ {
+ id: "request_status",
+ category: "ribbons",
+ label: "Request Status",
+ description:
+ "Where a watchlist title the library doesn't have yet stands: downloading, release date, awaiting approval",
+ defaultPosition: "top-left",
+ defaultEnabled: true,
+ iconCapable: true,
+ introducedInManifest: 15,
+ getValue: (d) => d.request_status || null,
+ getIcon: (d) => d.request_status_icon ?? null,
+ getAccent: (d) => (d.request_status_attention ? ATTENTION_ACCENT : null),
+ },
];
diff --git a/web/src/lib/overlays/requestStatus.test.ts b/web/src/lib/overlays/requestStatus.test.ts
new file mode 100644
index 0000000000..7cd8e5e840
--- /dev/null
+++ b/web/src/lib/overlays/requestStatus.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildDefaultPrefs,
+ overlayDataFromWatchlistTitle,
+ requestDownloadBarPercent,
+ SAMPLE_REQUEST_DATA,
+} from "@/lib/overlays";
+
+describe("overlayDataFromWatchlistTitle", () => {
+ it("carries the status badge, icon, attention and download progress", () => {
+ expect(
+ overlayDataFromWatchlistTitle(
+ { vote_average: 7.9, content_rating: "R", year: 1995 },
+ { badge: "Downloading 43%", badgeIcon: "download", attention: false, downloadPercent: 43 },
+ ),
+ ).toEqual({
+ rating_tmdb: 7.9,
+ content_rating: "R",
+ year: 1995,
+ request_status: "Downloading 43%",
+ request_status_icon: "download",
+ request_status_attention: false,
+ request_download_percent: 43,
+ });
+ });
+});
+
+describe("requestDownloadBarPercent", () => {
+ it("draws the bar only while the request status badge is on", () => {
+ const prefs = buildDefaultPrefs();
+ expect(prefs.items.request_status.enabled).toBe(true);
+ expect(requestDownloadBarPercent(SAMPLE_REQUEST_DATA, prefs)).toBe(43);
+
+ prefs.items.request_status = { ...prefs.items.request_status, enabled: false };
+ expect(requestDownloadBarPercent(SAMPLE_REQUEST_DATA, prefs)).toBeNull();
+ });
+
+ it("draws no bar with overlays off, without a status, or without a known size", () => {
+ const prefs = buildDefaultPrefs();
+ expect(requestDownloadBarPercent(SAMPLE_REQUEST_DATA, null)).toBeNull();
+ expect(
+ requestDownloadBarPercent({ ...SAMPLE_REQUEST_DATA, request_status: undefined }, prefs),
+ ).toBeNull();
+ expect(
+ requestDownloadBarPercent({ ...SAMPLE_REQUEST_DATA, request_download_percent: null }, prefs),
+ ).toBeNull();
+ });
+
+ it("clamps the percent to the bar", () => {
+ expect(
+ requestDownloadBarPercent(
+ { ...SAMPLE_REQUEST_DATA, request_download_percent: 140 },
+ buildDefaultPrefs(),
+ ),
+ ).toBe(100);
+ });
+});
diff --git a/web/src/lib/overlays/sample-data.ts b/web/src/lib/overlays/sample-data.ts
index 2a7baef3d9..eec089ebf2 100644
--- a/web/src/lib/overlays/sample-data.ts
+++ b/web/src/lib/overlays/sample-data.ts
@@ -3,7 +3,8 @@ import type { OverlayData } from "./types";
// Sample OverlayData used by the settings preview cards so users can see how
// the configured overlays will actually look. Two variants — movie and show —
// because some overlays (show_status, edition) only render for one or the
-// other in real data.
+// other in real data. A third, a requested title the library doesn't have
+// yet, shows the request_status badge and its download bar.
export const SAMPLE_MOVIE_DATA: OverlayData = {
resolution: "2160p",
@@ -46,3 +47,29 @@ export const SAMPLE_SHOW_DATA: OverlayData = {
rt_certified_fresh: false,
imdb_top_250: null,
};
+
+// A watchlist title outside the library: TMDB data only, no file badges.
+export const SAMPLE_REQUEST_DATA: OverlayData = {
+ rating_tmdb: 8.1,
+ content_rating: "PG-13",
+ year: 2026,
+ request_status: "Downloading 43%",
+ request_status_icon: "download",
+ request_status_attention: false,
+ request_download_percent: 43,
+};
+
+/** The samples the settings preview offers. */
+export type OverlayPreviewVariant = "movie" | "show" | "requested";
+
+export const OVERLAY_PREVIEW_VARIANTS: readonly OverlayPreviewVariant[] = [
+ "movie",
+ "show",
+ "requested",
+];
+
+export const OVERLAY_PREVIEW_SAMPLES: Readonly> = {
+ movie: SAMPLE_MOVIE_DATA,
+ show: SAMPLE_SHOW_DATA,
+ requested: SAMPLE_REQUEST_DATA,
+};
diff --git a/web/src/lib/overlays/schema.ts b/web/src/lib/overlays/schema.ts
index 6bd121663d..45c7bab5df 100644
--- a/web/src/lib/overlays/schema.ts
+++ b/web/src/lib/overlays/schema.ts
@@ -3,6 +3,7 @@ import { OVERLAY_MAP, OVERLAY_REGISTRY } from "./registry";
import { OVERLAY_POSITIONS } from "./types";
import type {
CardOverlayPrefs,
+ OverlayData,
OverlayId,
OverlayItemConfig,
OverlayPosition,
@@ -203,3 +204,16 @@ export function orderedOverlaysForPosition(prefs: CardOverlayPrefs, position: Ov
const orderIndex = new Map(prefs.order.map((id, i) => [id, i]));
return [...enabled].sort((a, b) => (orderIndex.get(a.id) ?? 999) - (orderIndex.get(b.id) ?? 999));
}
+
+// The download bar a card draws while a watchlist title downloads, as a
+// percentage, or null for no bar. The bar belongs to the request_status
+// badge: it shows only while overlays are on (prefs is null when they are
+// off) and the badge is enabled.
+export function requestDownloadBarPercent(
+ data: OverlayData,
+ prefs: CardOverlayPrefs | null | undefined,
+): number | null {
+ if (!prefs?.items.request_status?.enabled || !data.request_status) return null;
+ const percent = data.request_download_percent;
+ return percent == null ? null : Math.min(100, Math.max(0, percent));
+}
diff --git a/web/src/lib/overlays/types.ts b/web/src/lib/overlays/types.ts
index 8e917dc53f..8795933336 100644
--- a/web/src/lib/overlays/types.ts
+++ b/web/src/lib/overlays/types.ts
@@ -38,6 +38,7 @@ export type OverlayId =
| "network"
// ribbons (status / awards)
| "show_status"
+ | "request_status"
| "imdb_top_250"
| "rt_certified_fresh";
@@ -72,6 +73,12 @@ export interface OverlayData {
show_status?: string;
imdb_top_250?: number | null;
rt_certified_fresh?: boolean | null;
+ // request status: only titles outside the library carry these, so library
+ // cards never show the badge.
+ request_status?: string; // badge label: "Downloading 43%", "Out Dec 18"
+ request_status_icon?: OverlayIconId | null;
+ request_status_attention?: boolean; // TMDB lost the title; paints amber
+ request_download_percent?: number | null; // drives the card's download bar
}
// Per-overlay user configuration. accentColor and showIcon are optional
@@ -144,6 +151,9 @@ export interface OverlayDef {
introducedInManifest?: number;
getValue: (data: OverlayData) => string | null;
getIcon?: (data: OverlayData) => OverlayIconId | null; // dynamic icon by data
+ // Accent by data, used when the viewer has not picked an accent for the
+ // badge; null falls back to defaultAccent.
+ getAccent?: (data: OverlayData) => string | null;
}
// Typed icon identifiers — every icon used anywhere must be in this union.
@@ -167,6 +177,9 @@ export type OverlayIconId =
| "calendar"
| "globe"
| "users"
+ | "download"
+ | "hourglass"
+ | "alert"
// brand marks (inline SVG)
| "hdr10"
| "hdr"
diff --git a/web/src/lib/overlays/ui-constants.ts b/web/src/lib/overlays/ui-constants.ts
index 6ee0afc37d..4014d46a2d 100644
--- a/web/src/lib/overlays/ui-constants.ts
+++ b/web/src/lib/overlays/ui-constants.ts
@@ -35,7 +35,8 @@ export const CATEGORY_META: Record = {
ribbons: {
category: "ribbons",
title: "Status & Awards",
- description: "Series lifecycle and award badges. Some require upcoming data sources.",
+ description:
+ "Series lifecycle, request status and award badges. Some require upcoming data sources.",
},
};
diff --git a/web/src/lib/settingsConformance.json b/web/src/lib/settingsConformance.json
index f63d46fa00..5b58f8e6d2 100644
--- a/web/src/lib/settingsConformance.json
+++ b/web/src/lib/settingsConformance.json
@@ -1,6 +1,6 @@
{
"fixture_version": 1,
- "manifest_revision": 14,
+ "manifest_revision": 15,
"description": "Cross-platform conformance cases for settings resolution. Every case runs against the shipped manifest in this directory: definitions are referenced by key, never restated, so an expectation can only be satisfied by resolving the real contract. Each platform's resolver (Go in internal/settingsresolve, TypeScript in web/src/lib/settingsResolve.ts, Kotlin and Swift in the client repos) runs every case through a hand-written runner; a runner must fail on any fixture field it does not know, because schema drift in the fixture itself is drift. A case's constraint_bindings attach a constraint to a copy of a real definition so constraint semantics stay testable even while no shipped definition carries that constraint kind. In expected entries, constrained:true requires stored_value and constraint_kind to be present, and stored_value may be null to mean the authored value was JSON null.",
"cases": [
{
diff --git a/web/src/lib/settingsContract.ts b/web/src/lib/settingsContract.ts
index 0148b959cb..8d514ced40 100644
--- a/web/src/lib/settingsContract.ts
+++ b/web/src/lib/settingsContract.ts
@@ -9,7 +9,7 @@
*/
export const SETTINGS_API_VERSION = 1;
-export const SETTINGS_REVISION = 14;
+export const SETTINGS_REVISION = 15;
export interface SettingSuggestedOption {
value: string;
@@ -264,6 +264,8 @@ export const SETTING_KEYS = {
PLAYER_VIDEO_SKIP_BACK_SECONDS: "player.video_skip_back_seconds",
/** Video fast-forward interval */
PLAYER_VIDEO_SKIP_FORWARD_SECONDS: "player.video_skip_forward_seconds",
+ /** Request titles I add to my watchlist */
+ REQUESTS_WATCHLIST_AUTO_REQUEST: "requests.watchlist_auto_request",
/** Search scope */
SEARCH_MEDIA_SCOPE: "search.media_scope",
/** Match device caption settings */
@@ -1142,6 +1144,22 @@ export const SETTING_DEFINITIONS: Record = {
{ value: 90, label: "90 seconds", introducedIn: 9 },
],
},
+ "requests.watchlist_auto_request": {
+ key: "requests.watchlist_auto_request",
+ type: "boolean",
+ nullable: false,
+ persistence: "remote",
+ introducedIn: 15,
+ scopes: ["profile"],
+ scopeIntroducedIn: [15],
+ resolutionOrder: ["profile", "default"],
+ defaultValue: true,
+ label: "Request titles I add to my watchlist",
+ description:
+ "When you add a title that is not in the library to your watchlist, also request it.",
+ category: "requests",
+ control: "switch",
+ },
"search.media_scope": {
key: "search.media_scope",
type: "enum",
diff --git a/web/src/lib/watchlistTitles.test.ts b/web/src/lib/watchlistTitles.test.ts
new file mode 100644
index 0000000000..cd6a3eb2ed
--- /dev/null
+++ b/web/src/lib/watchlistTitles.test.ts
@@ -0,0 +1,229 @@
+import { describe, expect, it } from "vitest";
+import type { WatchlistTitle } from "@/api/v2/watchlistTitles";
+import {
+ WATCHLIST_NOT_IN_LIBRARY_TAB,
+ WATCHLIST_TITLE_RANK,
+ parseWatchlistTab,
+ showWatchlistAutoRequestControl,
+ sortWatchlistTitlesSoonestFirst,
+ watchlistTitleNeedsAttention,
+ watchlistTitlesAvailable,
+ watchlistTitlesHint,
+ watchlistTitleStatus,
+} from "./watchlistTitles";
+
+const NOW = new Date(2026, 8, 30, 12);
+
+function title(overrides: Partial = {}): WatchlistTitle {
+ return {
+ media_type: "movie",
+ tmdb_id: 1,
+ title: "Heat",
+ added_at: "2026-09-01T00:00:00Z",
+ status: "active",
+ request: { requestable: true },
+ ...overrides,
+ };
+}
+
+describe("watchlistTitleStatus", () => {
+ it("shows download progress on the badge and the full status in the caption", () => {
+ const status = watchlistTitleStatus(
+ title({
+ request: {
+ requestable: false,
+ status: "downloading",
+ download: {
+ phase: "downloading",
+ percent: 43,
+ downloads: 1,
+ updated_at: NOW.toISOString(),
+ },
+ },
+ }),
+ NOW,
+ );
+ expect(status.badge).toBe("Downloading 43%");
+ expect(status.badgeIcon).toBe("download");
+ expect(status.caption).toMatch(/^Downloading · 43%/);
+ expect(status.downloadPercent).toBe(43);
+ expect(status.rank).toBe(WATCHLIST_TITLE_RANK.downloading);
+ expect(status.attention).toBe(false);
+ });
+
+ it("leads with the release date while a request awaits approval", () => {
+ const status = watchlistTitleStatus(
+ title({ release_date: "2026-12-18", request: { requestable: false, status: "pending" } }),
+ NOW,
+ );
+ expect(status.badge).toMatch(/^Out /);
+ expect(status.badgeIcon).toBe("calendar");
+ expect(status.caption).toBe(`${status.badge} · awaiting approval`);
+ expect(status.rank).toBe(WATCHLIST_TITLE_RANK.dated);
+ });
+
+ it("reads an undated pending request as awaiting approval", () => {
+ const status = watchlistTitleStatus(
+ title({ release_date: "2001-01-01", request: { requestable: false, status: "pending" } }),
+ NOW,
+ );
+ expect(status.badge).toBe("Awaiting approval");
+ expect(status.caption).toBe("Awaiting approval");
+ expect(status.rank).toBe(WATCHLIST_TITLE_RANK.awaitingApproval);
+ });
+
+ it("says an approved request is waiting for a download", () => {
+ const status = watchlistTitleStatus(
+ title({ request: { requestable: false, status: "approved" } }),
+ NOW,
+ );
+ expect(status.badge).toBe("Approved");
+ expect(status.badgeIcon).toBe("hourglass");
+ expect(status.caption).toBe("Approved · waiting for a download");
+ });
+
+ it("offers a request only when the title is requestable", () => {
+ expect(watchlistTitleStatus(title(), NOW)).toMatchObject({
+ badge: "Not requested",
+ caption: "Not requested",
+ requestable: true,
+ rank: WATCHLIST_TITLE_RANK.notRequested,
+ });
+ expect(
+ watchlistTitleStatus(
+ title({ request: { requestable: false, reason: "quota_exceeded" } }),
+ NOW,
+ ),
+ ).toMatchObject({ caption: "Not requested · request limit reached", requestable: false });
+ });
+
+ it("flags titles TMDB lost as needing attention", () => {
+ expect(watchlistTitleStatus(title({ status: "needs_review" }), NOW)).toMatchObject({
+ badge: "Needs attention",
+ badgeIcon: "alert",
+ caption: "TMDB lists it twice",
+ attention: true,
+ requestable: false,
+ });
+ expect(watchlistTitleStatus(title({ status: "removed" }), NOW)).toMatchObject({
+ badge: "Not on TMDB",
+ caption: "No longer listed on TMDB",
+ attention: true,
+ });
+ expect(watchlistTitleNeedsAttention({ status: "removed" })).toBe(true);
+ expect(watchlistTitleNeedsAttention({ status: "some_future_value" })).toBe(false);
+ });
+});
+
+describe("sortWatchlistTitlesSoonestFirst", () => {
+ it("orders downloading, dated, awaiting approval, not requested, then attention", () => {
+ const download = {
+ phase: "downloading",
+ percent: 10,
+ downloads: 1,
+ updated_at: NOW.toISOString(),
+ };
+ const titles = [
+ title({ tmdb_id: 5, status: "removed" }),
+ title({ tmdb_id: 4 }),
+ title({ tmdb_id: 3, request: { requestable: false, status: "pending" } }),
+ title({
+ tmdb_id: 22,
+ release_date: "2027-03-01",
+ request: { requestable: false, status: "approved" },
+ }),
+ title({
+ tmdb_id: 21,
+ release_date: "2026-11-01",
+ request: { requestable: false, status: "approved" },
+ }),
+ title({ tmdb_id: 1, request: { requestable: false, status: "downloading", download } }),
+ ];
+ expect(sortWatchlistTitlesSoonestFirst(titles, NOW).map((t) => t.tmdb_id)).toEqual([
+ 1, 21, 22, 3, 4, 5,
+ ]);
+ });
+
+ it("puts the most recently added first within a group of the same date", () => {
+ const titles = [
+ title({ tmdb_id: 1, added_at: "2026-09-01T00:00:00Z" }),
+ title({ tmdb_id: 2, added_at: "2026-09-20T00:00:00Z" }),
+ ];
+ expect(sortWatchlistTitlesSoonestFirst(titles, NOW).map((t) => t.tmdb_id)).toEqual([2, 1]);
+ });
+});
+
+describe("parseWatchlistTab", () => {
+ it("reads only the not-in-library value as the titles tab", () => {
+ expect(parseWatchlistTab(WATCHLIST_NOT_IN_LIBRARY_TAB)).toBe("not-in-library");
+ expect(parseWatchlistTab(null)).toBe("library");
+ expect(parseWatchlistTab("anything")).toBe("library");
+ });
+});
+
+describe("watchlistTitlesHint", () => {
+ it("drops the requested sentence when watchlist requests are off", () => {
+ expect(watchlistTitlesHint(true)).toBe(
+ "These titles aren't in the library yet, so they can't be played. They've been requested for you. When one arrives, it moves to In your library and you get a notification.",
+ );
+ expect(watchlistTitlesHint(false)).toBe(
+ "These titles aren't in the library yet, so they can't be played. When one arrives, it moves to In your library.",
+ );
+ });
+});
+
+describe("watchlistTitlesAvailable", () => {
+ it("needs both requests and watchlist titles on", () => {
+ expect(
+ watchlistTitlesAvailable({ requests_enabled: true, watchlist_titles_supported: true }),
+ ).toBe(true);
+ expect(
+ watchlistTitlesAvailable({ requests_enabled: false, watchlist_titles_supported: true }),
+ ).toBe(false);
+ expect(
+ watchlistTitlesAvailable({ requests_enabled: true, watchlist_titles_supported: false }),
+ ).toBe(false);
+ expect(watchlistTitlesAvailable(undefined)).toBe(false);
+ });
+});
+
+describe("showWatchlistAutoRequestControl", () => {
+ const status = { requests_enabled: true, allowed: true, watchlist_titles_supported: true };
+
+ it("shows the switch while watchlist adds request", () => {
+ expect(showWatchlistAutoRequestControl({ ...status, watchlist_requests: true }, true)).toBe(
+ true,
+ );
+ });
+
+ it("keeps the switch for a profile that opted out, so it can opt back in", () => {
+ expect(showWatchlistAutoRequestControl({ ...status, watchlist_requests: false }, false)).toBe(
+ true,
+ );
+ });
+
+ it("hides the switch when the server setting is off", () => {
+ expect(showWatchlistAutoRequestControl({ ...status, watchlist_requests: false }, true)).toBe(
+ false,
+ );
+ });
+
+ it("hides the switch when the viewer cannot request or the server is older", () => {
+ expect(
+ showWatchlistAutoRequestControl(
+ { ...status, allowed: false, watchlist_requests: true },
+ true,
+ ),
+ ).toBe(false);
+ expect(showWatchlistAutoRequestControl({ allowed: true, watchlist_requests: true }, true)).toBe(
+ false,
+ );
+ expect(showWatchlistAutoRequestControl(undefined, false)).toBe(false);
+ expect(
+ showWatchlistAutoRequestControl(
+ { ...status, requests_enabled: false, watchlist_requests: false },
+ false,
+ ),
+ ).toBe(false);
+ });
+});
diff --git a/web/src/lib/watchlistTitles.ts b/web/src/lib/watchlistTitles.ts
new file mode 100644
index 0000000000..967eebdf8a
--- /dev/null
+++ b/web/src/lib/watchlistTitles.ts
@@ -0,0 +1,261 @@
+import type { WatchlistTitle } from "@/api/v2/watchlistTitles";
+import { preferredDateLocale } from "@/lib/datetime";
+import {
+ formatRequestDisplayState,
+ formatRequestReason,
+ requestDisplayState,
+} from "@/lib/mediaRequests";
+import { formatRequestDownload, requestDownloadPercent } from "@/lib/requestDownload";
+import type { OverlayIconId } from "@/lib/overlays";
+
+/**
+ * How a watchlist title outside the library reads on its card: a short badge
+ * for the poster overlay, and a caption that always carries the full status in
+ * words, so the card stays clear with overlays turned off.
+ */
+export interface WatchlistTitleStatus {
+ /** The request_status badge: "Downloading 43%", "Out Dec 18", "Awaiting approval". */
+ badge: string;
+ badgeIcon: OverlayIconId | null;
+ /** The caption before any "Find it" link: "Out Dec 18 · awaiting approval". */
+ caption: string;
+ /** TMDB no longer has the title as saved; the viewer has to find it again. */
+ attention: boolean;
+ /** Download progress for the card's bar, while a download reports a size. */
+ downloadPercent?: number;
+ /** The title has no active request, and the viewer may request it now. */
+ requestable: boolean;
+ /** Sort group for "Soonest first"; lower sorts first. */
+ rank: WatchlistTitleRank;
+}
+
+/**
+ * Soonest first: downloading, then by release date, then awaiting approval,
+ * then not requested, then needs attention.
+ */
+export const WATCHLIST_TITLE_RANK = {
+ downloading: 0,
+ dated: 1,
+ awaitingApproval: 2,
+ notRequested: 3,
+ attention: 4,
+} as const;
+export type WatchlistTitleRank = (typeof WATCHLIST_TITLE_RANK)[keyof typeof WATCHLIST_TITLE_RANK];
+
+/** A YYYY-MM-DD calendar date as local midnight, or null when unparseable. */
+function calendarDate(value: string | undefined): Date | null {
+ const match = value ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) : null;
+ if (!match) return null;
+ const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
+ return Number.isNaN(date.getTime()) ? null : date;
+}
+
+function startOfDay(now: Date): Date {
+ return new Date(now.getFullYear(), now.getMonth(), now.getDate());
+}
+
+/** "Dec 18", with the year when it isn't this year. */
+function formatReleaseDay(date: Date, now: Date): string {
+ return date.toLocaleDateString(preferredDateLocale(), {
+ month: "short",
+ day: "numeric",
+ ...(date.getFullYear() !== now.getFullYear() ? { year: "numeric" } : {}),
+ });
+}
+
+/** Whether TMDB lost the title as saved: needs_review or removed. */
+export function watchlistTitleNeedsAttention(title: Pick): boolean {
+ return title.status === "needs_review" || title.status === "removed";
+}
+
+export function watchlistTitleStatus(
+ title: WatchlistTitle,
+ now: Date = new Date(),
+): WatchlistTitleStatus {
+ const request = title.request;
+ if (title.status === "needs_review") {
+ return {
+ badge: "Needs attention",
+ badgeIcon: "alert",
+ caption: "TMDB lists it twice",
+ attention: true,
+ requestable: false,
+ rank: WATCHLIST_TITLE_RANK.attention,
+ };
+ }
+ if (title.status === "removed") {
+ return {
+ badge: "Not on TMDB",
+ badgeIcon: "alert",
+ caption: "No longer listed on TMDB",
+ attention: true,
+ requestable: false,
+ rank: WATCHLIST_TITLE_RANK.attention,
+ };
+ }
+
+ const state = requestDisplayState(request.status, undefined, request.state);
+ const release = calendarDate(title.release_date);
+ const upcoming = release !== null && release.getTime() > startOfDay(now).getTime();
+ const out = upcoming && release ? `Out ${formatReleaseDay(release, now)}` : null;
+
+ if (request.download) {
+ const percent = requestDownloadPercent(request.download);
+ const downloading = request.download.phase === "downloading";
+ return {
+ badge:
+ downloading && percent !== undefined
+ ? `Downloading ${percent}%`
+ : formatRequestDownload(request.download, { now }).split(" · ")[0]!,
+ badgeIcon: "download",
+ caption: formatRequestDownload(request.download, { now }),
+ attention: false,
+ downloadPercent: percent,
+ requestable: false,
+ rank: WATCHLIST_TITLE_RANK.downloading,
+ };
+ }
+
+ switch (state) {
+ case undefined:
+ case "declined":
+ case "cancelled":
+ case "failed": {
+ const refused = !request.requestable && request.reason ? request.reason : undefined;
+ const detail = refused ? formatRequestReason(refused).toLowerCase() : null;
+ return {
+ badge: "Not requested",
+ badgeIcon: null,
+ caption: ["Not requested", detail ?? (out ? out.toLowerCase() : null)]
+ .filter(Boolean)
+ .join(" · "),
+ attention: false,
+ requestable: request.requestable,
+ rank: WATCHLIST_TITLE_RANK.notRequested,
+ };
+ }
+ case "processing":
+ return {
+ badge: "Processing",
+ badgeIcon: "download",
+ caption: "Processing",
+ attention: false,
+ requestable: false,
+ rank: WATCHLIST_TITLE_RANK.downloading,
+ };
+ case "pending":
+ return {
+ badge: out ?? "Awaiting approval",
+ badgeIcon: out ? "calendar" : "clock",
+ caption: out ? `${out} · awaiting approval` : "Awaiting approval",
+ attention: false,
+ requestable: false,
+ rank: out ? WATCHLIST_TITLE_RANK.dated : WATCHLIST_TITLE_RANK.awaitingApproval,
+ };
+ case "approved":
+ return {
+ badge: out ?? "Approved",
+ badgeIcon: out ? "calendar" : "hourglass",
+ caption: out ? `${out} · approved` : "Approved · waiting for a download",
+ attention: false,
+ requestable: false,
+ rank: WATCHLIST_TITLE_RANK.dated,
+ };
+ default: {
+ const label = formatRequestDisplayState(state);
+ return {
+ badge: label,
+ badgeIcon: null,
+ caption: label,
+ attention: false,
+ requestable: false,
+ rank: WATCHLIST_TITLE_RANK.dated,
+ };
+ }
+ }
+}
+
+function releaseTime(title: WatchlistTitle): number {
+ return calendarDate(title.release_date)?.getTime() ?? Number.POSITIVE_INFINITY;
+}
+
+function addedTime(title: WatchlistTitle): number {
+ const t = Date.parse(title.added_at);
+ return Number.isFinite(t) ? t : 0;
+}
+
+/**
+ * "Soonest first": downloading, then by release date, then awaiting approval,
+ * then not requested, then needs attention. Within a group, the earlier
+ * release date first (unknown dates last), then the most recently added.
+ */
+export function sortWatchlistTitlesSoonestFirst(
+ titles: readonly WatchlistTitle[],
+ now: Date = new Date(),
+): WatchlistTitle[] {
+ const ranked = titles.map((title) => ({ title, rank: watchlistTitleStatus(title, now).rank }));
+ ranked.sort((a, b) => {
+ if (a.rank !== b.rank) return a.rank - b.rank;
+ const releaseA = releaseTime(a.title);
+ const releaseB = releaseTime(b.title);
+ if (releaseA !== releaseB) return releaseA < releaseB ? -1 : 1;
+ return addedTime(b.title) - addedTime(a.title);
+ });
+ return ranked.map((entry) => entry.title);
+}
+
+/** The `?tab=` value of the watchlist's "Not in your library yet" tab. */
+export const WATCHLIST_NOT_IN_LIBRARY_TAB = "not-in-library";
+
+export type WatchlistTab = "library" | typeof WATCHLIST_NOT_IN_LIBRARY_TAB;
+
+/** The watchlist tab a `?tab=` value names; anything else is the library tab. */
+export function parseWatchlistTab(value: string | null): WatchlistTab {
+ return value === WATCHLIST_NOT_IN_LIBRARY_TAB ? WATCHLIST_NOT_IN_LIBRARY_TAB : "library";
+}
+
+/**
+ * The hint at the top of the titles tab. The request and notification clauses
+ * hold only while adds request: the notification comes from the request.
+ */
+export function watchlistTitlesHint(watchlistRequests: boolean): string {
+ return [
+ "These titles aren't in the library yet, so they can't be played.",
+ watchlistRequests
+ ? "They've been requested for you. When one arrives, it moves to In your library and you get a notification."
+ : "When one arrives, it moves to In your library.",
+ ].join(" ");
+}
+
+/**
+ * Whether the watchlist can hold titles the library doesn't have. The titles
+ * belong to the requests surface: with requests off their operations answer
+ * 409 capability_disabled, so both flags must hold.
+ */
+export function watchlistTitlesAvailable(
+ status: { requests_enabled?: boolean; watchlist_titles_supported?: boolean } | undefined,
+): boolean {
+ return status?.requests_enabled === true && status.watchlist_titles_supported === true;
+}
+
+/**
+ * Whether the profile's watchlist auto-request switch applies. The request
+ * status reports the effective answer (server setting, this profile's opt-in,
+ * and whether the viewer may request), not the server setting on its own. A
+ * profile that opted out still sees the switch so it can opt back in; with its
+ * opt-in on and the effective answer off, the server setting is off.
+ */
+export function showWatchlistAutoRequestControl(
+ status:
+ | {
+ requests_enabled?: boolean;
+ allowed?: boolean;
+ watchlist_titles_supported?: boolean;
+ watchlist_requests?: boolean;
+ }
+ | undefined,
+ profileValue: boolean,
+): boolean {
+ if (!watchlistTitlesAvailable(status) || !status?.allowed) return false;
+ return status.watchlist_requests === true || profileValue === false;
+}
diff --git a/web/src/pages/Catalog.collectionUnavailable.test.tsx b/web/src/pages/Catalog.collectionUnavailable.test.tsx
index b18b48f0c8..2bbf8c0f02 100644
--- a/web/src/pages/Catalog.collectionUnavailable.test.tsx
+++ b/web/src/pages/Catalog.collectionUnavailable.test.tsx
@@ -26,6 +26,10 @@ vi.mock("@/hooks/useCanRequest", () => ({
}));
vi.mock("@/hooks/queries/useRequests", () => ({
useRequestSearch: () => ({ data: undefined, isLoading: false }),
+ useRequestFeatureStatus: () => ({ data: undefined }),
+}));
+vi.mock("@/hooks/queries/watchlistTitles", () => ({
+ useWatchlistTitles: () => ({ data: undefined, isLoading: false, isError: false }),
}));
vi.mock("@/components/ItemGrid", () => ({ default: () =>
}));
vi.mock("@/components/catalog/CatalogFiltersPanel", () => ({ default: () => null }));
diff --git a/web/src/pages/Catalog.test.tsx b/web/src/pages/Catalog.test.tsx
index aa82ee7a0d..a4f30f7185 100644
--- a/web/src/pages/Catalog.test.tsx
+++ b/web/src/pages/Catalog.test.tsx
@@ -56,6 +56,11 @@ vi.mock("@/hooks/useCanRequest", () => ({
vi.mock("@/hooks/queries/useRequests", () => ({
useRequestSearch: (...args: unknown[]) => mockUseRequestSearch(...args),
+ useRequestFeatureStatus: () => ({ data: undefined }),
+}));
+
+vi.mock("@/hooks/queries/watchlistTitles", () => ({
+ useWatchlistTitles: () => ({ data: undefined, isLoading: false, isError: false }),
}));
vi.mock("@/components/RequestToAddSection", () => ({
diff --git a/web/src/pages/Catalog.tsx b/web/src/pages/Catalog.tsx
index 82cdf7332e..ffa2450814 100644
--- a/web/src/pages/Catalog.tsx
+++ b/web/src/pages/Catalog.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router";
import { CheckSquare, RefreshCw, Search, Trash2, X } from "lucide-react";
@@ -19,7 +19,17 @@ import { useSetCollectionSortPreference } from "@/hooks/queries/collections";
import { querySortToSelectValue } from "@/lib/collectionSortConfig";
import { useSearchMediaScope, type SearchMediaScope } from "@/hooks/useSearchMediaScope";
import { useRemoveHistory } from "@/hooks/queries/history";
-import { useRequestSearch } from "@/hooks/queries/useRequests";
+import { useRequestFeatureStatus, useRequestSearch } from "@/hooks/queries/useRequests";
+import { useWatchlistTitles } from "@/hooks/queries/watchlistTitles";
+import WatchlistTabs, { WatchlistTabPanel } from "@/components/watchlist/WatchlistTabs";
+import WatchlistTitlesTab from "@/components/watchlist/WatchlistTitlesTab";
+import {
+ parseWatchlistTab,
+ WATCHLIST_NOT_IN_LIBRARY_TAB,
+ watchlistTitleNeedsAttention,
+ watchlistTitlesAvailable,
+ type WatchlistTab,
+} from "@/lib/watchlistTitles";
import { useCanRequest } from "@/hooks/useCanRequest";
import { useDebounce } from "@/hooks/useDebounce";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
@@ -176,7 +186,37 @@ function CatalogResults({
[searchParams, setPreferredScope, setSearchParams],
);
- const showExactResultCount = state.source !== "section" && !isQuerySource;
+ // The watchlist splits into the library grid and the titles the library
+ // doesn't have yet, once the server keeps watchlist entries for those.
+ const isWatchlistSource = state.source === "watchlist";
+ const requestFeatureStatus = useRequestFeatureStatus({ enabled: isWatchlistSource });
+ const showWatchlistTabs =
+ isWatchlistSource && watchlistTitlesAvailable(requestFeatureStatus.data);
+ const watchlistTitles = useWatchlistTitles({ enabled: showWatchlistTabs });
+ const watchlistTabsId = useId();
+ const watchlistTab: WatchlistTab = showWatchlistTabs
+ ? parseWatchlistTab(searchParams.get("tab"))
+ : "library";
+ const showingWatchlistTitles = watchlistTab === WATCHLIST_NOT_IN_LIBRARY_TAB;
+ const watchlistNeedsAttention = useMemo(
+ () => (watchlistTitles.data ?? []).some(watchlistTitleNeedsAttention),
+ [watchlistTitles.data],
+ );
+ const setWatchlistTab = useCallback(
+ (tab: WatchlistTab) => {
+ const next = new URLSearchParams(searchParams);
+ if (tab === WATCHLIST_NOT_IN_LIBRARY_TAB) {
+ next.set("tab", tab);
+ } else {
+ next.delete("tab");
+ }
+ setSearchParams(next);
+ },
+ [searchParams, setSearchParams],
+ );
+
+ const showExactResultCount =
+ state.source !== "section" && !isQuerySource && !showingWatchlistTitles;
const catalogQuery = useCatalogWindow(effectiveState, {
limit,
visibleRange,
@@ -375,160 +415,191 @@ function CatalogResults({
- {state.source === "query" ? (
-