diff --git a/apps/backend/package.json b/apps/backend/package.json
index 4c3c614..f73f907 100644
--- a/apps/backend/package.json
+++ b/apps/backend/package.json
@@ -28,6 +28,7 @@
"@hono/zod-validator": "^0.4.3",
"@stremlist/shared": "workspace:*",
"@supabase/supabase-js": "^2.95.3",
+ "@vercel/functions": "^3.9.5",
"@vercel/related-projects": "^1.0.0",
"hono": "^4.11.9",
"linkedom": "^0.18.9",
diff --git a/apps/backend/src/__tests__/watchlist-crud.test.ts b/apps/backend/src/__tests__/watchlist-crud.test.ts
index ea6a435..391602f 100644
--- a/apps/backend/src/__tests__/watchlist-crud.test.ts
+++ b/apps/backend/src/__tests__/watchlist-crud.test.ts
@@ -4,6 +4,16 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import app from "../index.js";
+const backgroundMocks = vi.hoisted(() => ({
+ scheduleBackgroundTask: vi.fn(),
+}));
+const prewarmMocks = vi.hoisted(() => ({
+ prewarmWatchlists: vi.fn(),
+}));
+
+vi.mock("../lib/background", () => backgroundMocks);
+vi.mock("../services/watchlist-prewarm", () => prewarmMocks);
+
vi.mock("../lib/supabase", async () => {
return await import("./helpers/mock-supabase.js");
});
@@ -91,6 +101,9 @@ describe("Watchlist CRUD via API", () => {
beforeEach(() => {
db.reset();
seedUser(OWNER);
+ backgroundMocks.scheduleBackgroundTask.mockReset();
+ prewarmMocks.prewarmWatchlists.mockReset();
+ prewarmMocks.prewarmWatchlists.mockResolvedValue(undefined);
});
// ---- GET /:userId/config ----
@@ -178,6 +191,17 @@ describe("Watchlist CRUD via API", () => {
expect(data.watchlists[0].imdbUserId).toBe(OWNER);
expect(data.watchlists[1].imdbUserId).toBe(OTHER_IMDB);
+
+ expect(backgroundMocks.scheduleBackgroundTask).toHaveBeenCalledOnce();
+ const task = backgroundMocks.scheduleBackgroundTask.mock.calls[0][0] as
+ | (() => Promise)
+ | undefined;
+ expect(task).toBeTypeOf("function");
+ await task?.();
+ expect(prewarmMocks.prewarmWatchlists).toHaveBeenCalledWith(
+ OWNER,
+ data.watchlists,
+ );
});
it("preserves IDs when updating sort order", async () => {
@@ -269,6 +293,7 @@ describe("Watchlist CRUD via API", () => {
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain("unique");
+ expect(backgroundMocks.scheduleBackgroundTask).not.toHaveBeenCalled();
});
it("rejects empty watchlist array", async () => {
diff --git a/apps/backend/src/lib/__tests__/background.test.ts b/apps/backend/src/lib/__tests__/background.test.ts
new file mode 100644
index 0000000..94108c7
--- /dev/null
+++ b/apps/backend/src/lib/__tests__/background.test.ts
@@ -0,0 +1,98 @@
+import { Hono } from "hono";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const vercelMocks = vi.hoisted(() => ({
+ waitUntil: vi.fn(),
+}));
+
+vi.mock("@vercel/functions", () => vercelMocks);
+
+import { scheduleBackgroundTask } from "../background";
+
+describe("scheduleBackgroundTask", () => {
+ const originalVercel = process.env.VERCEL;
+
+ beforeEach(() => {
+ vercelMocks.waitUntil.mockReset();
+ delete process.env.VERCEL;
+ });
+
+ afterEach(() => {
+ if (originalVercel === undefined) {
+ delete process.env.VERCEL;
+ } else {
+ process.env.VERCEL = originalVercel;
+ }
+ });
+
+ it("registers the task with Vercel so it can finish after the response", async () => {
+ process.env.VERCEL = "1";
+ const task = vi.fn().mockResolvedValue(undefined);
+
+ scheduleBackgroundTask(task);
+
+ expect(vercelMocks.waitUntil).toHaveBeenCalledOnce();
+ const promise = vercelMocks.waitUntil.mock.calls[0][0] as Promise;
+ await promise;
+ expect(task).toHaveBeenCalledOnce();
+ });
+
+ it("runs the task locally without registering it with Vercel", async () => {
+ const task = vi.fn().mockResolvedValue(undefined);
+
+ scheduleBackgroundTask(task);
+ await vi.waitFor(() => {
+ expect(task).toHaveBeenCalledOnce();
+ });
+
+ expect(vercelMocks.waitUntil).not.toHaveBeenCalled();
+ });
+
+ it("does not delay the HTTP response while the task is pending", async () => {
+ let finishTask: (() => void) | undefined;
+ const pendingTask = new Promise((resolve) => {
+ finishTask = resolve;
+ });
+ const task = vi.fn(() => pendingTask);
+ const app = new Hono().get("/probe", (c) => {
+ scheduleBackgroundTask(task);
+ return c.json({ ok: true });
+ });
+
+ const response = await app.request("/probe");
+
+ expect(response.status).toBe(200);
+ await expect(response.json()).resolves.toEqual({ ok: true });
+ expect(task).toHaveBeenCalledOnce();
+ expect(vercelMocks.waitUntil).not.toHaveBeenCalled();
+
+ finishTask?.();
+ await pendingTask;
+ });
+
+ it.each([
+ [
+ "synchronous",
+ () => {
+ throw new Error("sync failure");
+ },
+ ],
+ ["asynchronous", () => Promise.reject(new Error("async failure"))],
+ ])(
+ "logs a %s task failure without an unhandled rejection",
+ async (_, task) => {
+ const error = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => undefined);
+
+ scheduleBackgroundTask(task);
+
+ await vi.waitFor(() => {
+ expect(error).toHaveBeenCalledWith(
+ "Background task failed:",
+ expect.any(Error),
+ );
+ });
+ },
+ );
+});
diff --git a/apps/backend/src/lib/background.ts b/apps/backend/src/lib/background.ts
new file mode 100644
index 0000000..4502e86
--- /dev/null
+++ b/apps/backend/src/lib/background.ts
@@ -0,0 +1,15 @@
+import { waitUntil } from "@vercel/functions";
+
+type BackgroundTask = () => Promise;
+
+export function scheduleBackgroundTask(task: BackgroundTask): void {
+ const promise = Promise.resolve()
+ .then(task)
+ .catch((error: unknown) => {
+ console.error("Background task failed:", error);
+ });
+
+ if (process.env.VERCEL) {
+ waitUntil(promise);
+ }
+}
diff --git a/apps/backend/src/routes/api.ts b/apps/backend/src/routes/api.ts
index db7bbe9..36e67b1 100644
--- a/apps/backend/src/routes/api.ts
+++ b/apps/backend/src/routes/api.ts
@@ -9,6 +9,7 @@ import {
} from "@stremlist/shared";
import { Hono } from "hono";
import { z } from "zod";
+import { scheduleBackgroundTask } from "../lib/background";
import { resend } from "../lib/resend";
import { supabase } from "../lib/supabase";
import {
@@ -25,6 +26,7 @@ import {
setUserRpdbApiKey,
} from "../services/user";
import { getWatchlistByConfig } from "../services/watchlist";
+import { prewarmWatchlists } from "../services/watchlist-prewarm";
const REFRESH_COOLDOWN_MS =
(Number.isFinite(Number(process.env.REFRESH_COOLDOWN_SECONDS))
@@ -190,6 +192,13 @@ const api = new Hono()
setUserRpdbApiKey(userId, normalizedRpdbApiKey),
]);
+ // A fresh installation already has a seeded watchlist ID, so an
+ // "ID-less rows only" check would miss its first scrape. Queue every
+ // saved watchlist and let the normal cache-first path skip warm entries.
+ scheduleBackgroundTask(() =>
+ prewarmWatchlists(userId, updatedWatchlists),
+ );
+
return c.json({ ok: true, watchlists: updatedWatchlists });
},
)
diff --git a/apps/backend/src/routes/manifest.ts b/apps/backend/src/routes/manifest.ts
index cd63697..9b11db0 100644
--- a/apps/backend/src/routes/manifest.ts
+++ b/apps/backend/src/routes/manifest.ts
@@ -1,4 +1,8 @@
-import { BASE_MANIFEST, ADDON_VERSION } from "@stremlist/shared";
+import {
+ BASE_MANIFEST,
+ ADDON_VERSION,
+ IMDB_USER_ID_PATTERN,
+} from "@stremlist/shared";
import type { StremioManifest } from "@stremlist/shared";
import { Hono } from "hono";
import { buildManifestCatalogs } from "../services/stremio-catalogs";
@@ -30,6 +34,19 @@ manifest.get("/:userId/manifest.json", async (c) => {
const userId = c.req.param("userId");
console.log(`Serving user-specific manifest for: ${userId}`);
+ if (!IMDB_USER_ID_PATTERN.test(userId)) {
+ return c.json(
+ {
+ ...structuredClone(BASE_MANIFEST),
+ behaviorHints: {
+ configurable: true,
+ configurationRequired: true,
+ },
+ },
+ 400,
+ );
+ }
+
try {
await ensureUser(userId);
const savedRpdbApiKey = await getUserRpdbApiKey(userId);
diff --git a/apps/backend/src/services/__tests__/imdb-scraper.test.ts b/apps/backend/src/services/__tests__/imdb-scraper.test.ts
index 07f42c7..0a07e85 100644
--- a/apps/backend/src/services/__tests__/imdb-scraper.test.ts
+++ b/apps/backend/src/services/__tests__/imdb-scraper.test.ts
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
+ getImdbList,
getImdbWatchlist,
fetchWatchlist,
normalizeImdbUserId,
@@ -78,6 +79,13 @@ function mockGraphQLResponse(
});
}
+function mockListGraphQLResponse(list: object | null) {
+ return new Response(JSON.stringify({ data: { list }, extensions: {} }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
// ---------------------------------------------------------------------------
// Unit tests — fetch is mocked
// ---------------------------------------------------------------------------
@@ -185,6 +193,94 @@ describe("getImdbWatchlist (unit)", () => {
expect(result).toEqual([]);
});
+ it("supports 15,000 items without crossing a cursor window", async () => {
+ const requestedPageSizes: number[] = [];
+ let itemOffset = 0;
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
+
+ vi.mocked(globalThis.fetch).mockImplementation((_url, init) => {
+ const body = JSON.parse(init?.body as string) as {
+ variables: { first: number };
+ };
+ const requested = body.variables.first;
+ requestedPageSizes.push(requested);
+ const edges = Array.from({ length: requested }, (_, index) =>
+ makeEdge({
+ id: `tt${String(itemOffset + index).padStart(7, "0")}`,
+ }),
+ );
+ itemOffset += requested;
+
+ return Promise.resolve(
+ mockGraphQLResponse({
+ id: "ls123",
+ visibility: { id: "PUBLIC" },
+ titleListItemSearch: {
+ total: 15_001,
+ edges,
+ pageInfo: {
+ hasNextPage: true,
+ endCursor: `item-${itemOffset}`,
+ },
+ },
+ }),
+ );
+ });
+
+ const result = await getImdbWatchlist("ur195879360");
+
+ expect(result).toHaveLength(15_000);
+ expect(requestedPageSizes).toEqual([
+ ...Array(13).fill(750),
+ 250,
+ ...Array(6).fill(750),
+ 500,
+ ]);
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining("15,000-item limit"),
+ );
+ });
+
+ it("reduces the final page size to avoid crossing IMDb's cursor boundary", async () => {
+ const requestedPageSizes: number[] = [];
+ let itemOffset = 0;
+
+ vi.mocked(globalThis.fetch).mockImplementation((_url, init) => {
+ const body = JSON.parse(init?.body as string) as {
+ variables: { first: number };
+ };
+ const requested = body.variables.first;
+ requestedPageSizes.push(requested);
+
+ const edges = Array.from({ length: requested }, (_, index) =>
+ makeEdge({
+ id: `tt${String(itemOffset + index).padStart(7, "0")}`,
+ }),
+ );
+ itemOffset += requested;
+
+ return Promise.resolve(
+ mockGraphQLResponse({
+ id: "ls123",
+ visibility: { id: "PUBLIC" },
+ titleListItemSearch: {
+ total: 1_000,
+ edges,
+ pageInfo: {
+ hasNextPage: itemOffset < 1_000,
+ endCursor: `item-${itemOffset}`,
+ },
+ },
+ }),
+ );
+ });
+
+ const result = await getImdbWatchlist("ur195879360");
+
+ expect(result).toHaveLength(1_000);
+ expect(requestedPageSizes).toEqual([750, 250]);
+ });
+
it("sends the correct request to the IMDb GraphQL endpoint", async () => {
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
mockGraphQLResponse({
@@ -217,6 +313,59 @@ describe("getImdbWatchlist (unit)", () => {
});
});
+describe("getImdbList (unit)", () => {
+ beforeEach(() => {
+ vi.spyOn(globalThis, "fetch");
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("starts a new page at the 10,000-item cursor boundary", async () => {
+ const requestedPageSizes: number[] = [];
+ let itemOffset = 0;
+
+ vi.mocked(globalThis.fetch).mockImplementation((_url, init) => {
+ const body = JSON.parse(init?.body as string) as {
+ variables: { first: number };
+ };
+ const requested = body.variables.first;
+ requestedPageSizes.push(requested);
+ const edges = Array.from({ length: requested }, (_, index) =>
+ makeEdge({
+ id: `tt${String(itemOffset + index).padStart(7, "0")}`,
+ }),
+ );
+ itemOffset += requested;
+
+ return Promise.resolve(
+ mockListGraphQLResponse({
+ id: "ls123456789",
+ visibility: { id: "PUBLIC" },
+ titleListItemSearch: {
+ total: 10_001,
+ edges,
+ pageInfo: {
+ hasNextPage: itemOffset < 10_001,
+ endCursor: `item-${itemOffset}`,
+ },
+ },
+ }),
+ );
+ });
+
+ const result = await getImdbList("ls123456789");
+
+ expect(result).toHaveLength(10_001);
+ expect(requestedPageSizes).toEqual([
+ ...Array(13).fill(750),
+ 250,
+ 1,
+ ]);
+ });
+});
+
// ---------------------------------------------------------------------------
// Unit tests — validateImdbWatchlist with mocked fetch
// ---------------------------------------------------------------------------
diff --git a/apps/backend/src/services/__tests__/watchlist-fetch.test.ts b/apps/backend/src/services/__tests__/watchlist-fetch.test.ts
new file mode 100644
index 0000000..955e72c
--- /dev/null
+++ b/apps/backend/src/services/__tests__/watchlist-fetch.test.ts
@@ -0,0 +1,83 @@
+import type { StremioMeta, WatchlistData } from "@stremlist/shared";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const scraperMocks = vi.hoisted(() => ({
+ fetchChart: vi.fn(),
+ fetchList: vi.fn(),
+ fetchWatchlist: vi.fn(),
+}));
+
+const cacheMocks = vi.hoisted(() => ({
+ findCachedMeta: vi.fn(),
+ getCachedWatchlist: vi.fn(),
+ writeCachedWatchlist: vi.fn(),
+}));
+
+vi.mock("../../lib/supabase", () => ({ supabase: {} }));
+vi.mock("../imdb-scraper", () => ({
+ ...scraperMocks,
+ buildPosterUrl: vi.fn((_id: string, poster: string | null) => poster),
+ classifyWatchlistError: vi.fn(),
+ isListId: vi.fn((id: string) => id.startsWith("ls")),
+}));
+vi.mock("../user", () => ({
+ getUserRpdbApiKey: vi.fn(),
+ getUserWatchlists: vi.fn(),
+}));
+vi.mock("../watchlist-cache", () => cacheMocks);
+
+import { getWatchlistByConfig } from "../watchlist";
+
+const MOVIE: StremioMeta = {
+ id: "tt0111161",
+ type: "movie",
+ name: "The Shawshank Redemption",
+ poster: null,
+ posterShape: "poster",
+ genres: [],
+ description: "",
+};
+
+describe("getWatchlistByConfig", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ cacheMocks.getCachedWatchlist.mockResolvedValue(null);
+ cacheMocks.writeCachedWatchlist.mockResolvedValue(
+ "11111111-1111-4111-8111-111111111111",
+ );
+ });
+
+ it("coalesces concurrent cache misses for the same watchlist source", async () => {
+ const releaseFetches: ((data: WatchlistData) => void)[] = [];
+ scraperMocks.fetchList.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ releaseFetches.push(resolve);
+ }),
+ );
+
+ const config = {
+ ownerUserId: "ur12345678",
+ watchlistId: "22222222-2222-4222-8222-222222222222",
+ imdbUserId: "ls123456789",
+ sortOption: "added_at-asc",
+ skipUserTimestamp: true,
+ };
+ const first = getWatchlistByConfig(config);
+ const second = getWatchlistByConfig(config);
+
+ await vi.waitFor(() => {
+ expect(cacheMocks.getCachedWatchlist).toHaveBeenCalledTimes(2);
+ });
+ releaseFetches.forEach((release) => {
+ release({ metas: [MOVIE] });
+ });
+
+ await expect(Promise.all([first, second])).resolves.toEqual([
+ { metas: [MOVIE] },
+ { metas: [MOVIE] },
+ ]);
+ expect(scraperMocks.fetchList).toHaveBeenCalledOnce();
+ expect(cacheMocks.writeCachedWatchlist).toHaveBeenCalledOnce();
+ });
+});
diff --git a/apps/backend/src/services/__tests__/watchlist-prewarm.test.ts b/apps/backend/src/services/__tests__/watchlist-prewarm.test.ts
new file mode 100644
index 0000000..09239bc
--- /dev/null
+++ b/apps/backend/src/services/__tests__/watchlist-prewarm.test.ts
@@ -0,0 +1,272 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const watchlistMocks = vi.hoisted(() => ({
+ getWatchlistByConfig: vi.fn(),
+}));
+const supabaseMocks = vi.hoisted(() => ({
+ rpc: vi.fn(),
+}));
+
+vi.mock("../watchlist", () => watchlistMocks);
+vi.mock("../../lib/supabase", () => ({
+ supabase: { rpc: supabaseMocks.rpc },
+}));
+
+import { prewarmWatchlists } from "../watchlist-prewarm";
+
+describe("prewarmWatchlists", () => {
+ beforeEach(() => {
+ watchlistMocks.getWatchlistByConfig.mockReset();
+ watchlistMocks.getWatchlistByConfig.mockResolvedValue({ metas: [] });
+ supabaseMocks.rpc.mockReset();
+ supabaseMocks.rpc.mockResolvedValue({ data: true, error: null });
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
+ });
+
+ it("uses the cache-first fetch path for every saved watchlist", async () => {
+ await prewarmWatchlists("ur12345678", [
+ {
+ id: "11111111-1111-4111-8111-111111111111",
+ imdbUserId: "ur12345678",
+ catalogTitle: "Mine",
+ sortOption: "added_at-asc",
+ displayMode: "split",
+ position: 0,
+ },
+ {
+ id: "22222222-2222-4222-8222-222222222222",
+ imdbUserId: "ls123456789",
+ catalogTitle: "List",
+ sortOption: "year-desc",
+ displayMode: "split",
+ position: 1,
+ },
+ ]);
+
+ expect(watchlistMocks.getWatchlistByConfig).toHaveBeenCalledTimes(2);
+ expect(watchlistMocks.getWatchlistByConfig).toHaveBeenNthCalledWith(1, {
+ ownerUserId: "ur12345678",
+ watchlistId: "11111111-1111-4111-8111-111111111111",
+ imdbUserId: "ur12345678",
+ sortOption: "added_at-asc",
+ rpdbApiKey: null,
+ skipUserTimestamp: true,
+ });
+ expect(watchlistMocks.getWatchlistByConfig).toHaveBeenNthCalledWith(2, {
+ ownerUserId: "ur12345678",
+ watchlistId: "22222222-2222-4222-8222-222222222222",
+ imdbUserId: "ls123456789",
+ sortOption: "year-desc",
+ rpdbApiKey: null,
+ skipUserTimestamp: true,
+ });
+ expect(console.log).toHaveBeenCalledWith(
+ expect.stringMatching(/^Prewarmed 2\/2 watchlists in \d+ms$/),
+ );
+ });
+
+ it("continues the batch when one prewarm fails", async () => {
+ watchlistMocks.getWatchlistByConfig.mockRejectedValueOnce(
+ new Error("IMDb unavailable"),
+ );
+ const error = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => undefined);
+
+ await expect(
+ prewarmWatchlists("ur12345678", [
+ {
+ id: "11111111-1111-4111-8111-111111111111",
+ imdbUserId: "ur12345678",
+ catalogTitle: "",
+ sortOption: "added_at-asc",
+ displayMode: "split",
+ position: 0,
+ },
+ {
+ id: "22222222-2222-4222-8222-222222222222",
+ imdbUserId: "ls123456789",
+ catalogTitle: "Still runs",
+ sortOption: "added_at-asc",
+ displayMode: "split",
+ position: 1,
+ },
+ ]),
+ ).resolves.toBeUndefined();
+
+ expect(watchlistMocks.getWatchlistByConfig).toHaveBeenCalledTimes(2);
+ expect(error).toHaveBeenCalledWith(
+ "Failed to prewarm watchlist 11111111-1111-4111-8111-111111111111:",
+ expect.any(Error),
+ );
+ expect(console.log).toHaveBeenCalledWith(
+ expect.stringMatching(/^Prewarmed 1\/2 watchlists in \d+ms$/),
+ );
+ });
+
+ it("coalesces overlapping batches for the same owner", async () => {
+ let finishFetch: (() => void) | undefined;
+ watchlistMocks.getWatchlistByConfig.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ finishFetch = () => {
+ resolve({ metas: [] });
+ };
+ }),
+ );
+ const watchlists = [
+ {
+ id: "11111111-1111-4111-8111-111111111111",
+ imdbUserId: "ur12345678",
+ catalogTitle: "Mine",
+ sortOption: "added_at-asc",
+ displayMode: "split" as const,
+ position: 0,
+ },
+ ];
+
+ const first = prewarmWatchlists("ur12345678", watchlists);
+ const second = prewarmWatchlists("ur12345678", watchlists);
+
+ expect(second).toBe(first);
+ await vi.waitFor(() => {
+ expect(watchlistMocks.getWatchlistByConfig).toHaveBeenCalledOnce();
+ });
+ finishFetch?.();
+ await Promise.all([first, second]);
+ });
+
+ it("runs at most two prewarms concurrently", async () => {
+ const finishFetches: (() => void)[] = [];
+ watchlistMocks.getWatchlistByConfig.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ finishFetches.push(() => {
+ resolve({ metas: [] });
+ });
+ }),
+ );
+ const watchlists = Array.from({ length: 3 }, (_, index) => ({
+ id: `${index + 1}1111111-1111-4111-8111-111111111111`,
+ imdbUserId: `ur1234567${index}`,
+ catalogTitle: String(index),
+ sortOption: "added_at-asc",
+ displayMode: "split" as const,
+ position: index,
+ }));
+
+ const batch = prewarmWatchlists("ur12345678", watchlists);
+
+ await vi.waitFor(() => {
+ expect(watchlistMocks.getWatchlistByConfig).toHaveBeenCalledTimes(2);
+ });
+ finishFetches[0]();
+ await vi.waitFor(() => {
+ expect(watchlistMocks.getWatchlistByConfig).toHaveBeenCalledTimes(3);
+ });
+ finishFetches.slice(1).forEach((finish) => {
+ finish();
+ });
+ await batch;
+ });
+
+ it("queues the latest changed watchlist set behind the active batch", async () => {
+ const finishFetches: (() => void)[] = [];
+ watchlistMocks.getWatchlistByConfig.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ finishFetches.push(() => {
+ resolve({ metas: [] });
+ });
+ }),
+ );
+ const firstWatchlists = [
+ {
+ id: "11111111-1111-4111-8111-111111111111",
+ imdbUserId: "ur12345678",
+ catalogTitle: "Mine",
+ sortOption: "added_at-asc",
+ displayMode: "split" as const,
+ position: 0,
+ },
+ ];
+ const changedWatchlists = [
+ ...firstWatchlists,
+ {
+ id: "22222222-2222-4222-8222-222222222222",
+ imdbUserId: "ur87654321",
+ catalogTitle: "Friend",
+ sortOption: "added_at-asc",
+ displayMode: "split" as const,
+ position: 1,
+ },
+ ];
+
+ const first = prewarmWatchlists("ur12345678", firstWatchlists);
+ const second = prewarmWatchlists("ur12345678", changedWatchlists);
+
+ expect(second).toBe(first);
+ await vi.waitFor(() => {
+ expect(watchlistMocks.getWatchlistByConfig).toHaveBeenCalledOnce();
+ });
+ finishFetches[0]();
+ await vi.waitFor(() => {
+ expect(watchlistMocks.getWatchlistByConfig).toHaveBeenCalledTimes(3);
+ });
+ finishFetches.slice(1).forEach((finish) => {
+ finish();
+ });
+ await first;
+ });
+
+ it("skips the batch when another instance holds the database lease", async () => {
+ supabaseMocks.rpc.mockResolvedValue({ data: false, error: null });
+
+ await prewarmWatchlists("ur12345678", [
+ {
+ id: "11111111-1111-4111-8111-111111111111",
+ imdbUserId: "ur12345678",
+ catalogTitle: "Mine",
+ sortOption: "added_at-asc",
+ displayMode: "split",
+ position: 0,
+ },
+ ]);
+
+ expect(supabaseMocks.rpc).toHaveBeenCalledWith(
+ "try_acquire_watchlist_prewarm_lease",
+ {
+ p_owner_user_id: "ur12345678",
+ p_lease_seconds: 600,
+ },
+ );
+ expect(watchlistMocks.getWatchlistByConfig).not.toHaveBeenCalled();
+ });
+
+ it("fails closed when the database lease cannot be checked", async () => {
+ const error = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => undefined);
+ supabaseMocks.rpc.mockResolvedValue({
+ data: null,
+ error: { message: "database unavailable" },
+ });
+
+ await prewarmWatchlists("ur12345678", [
+ {
+ id: "11111111-1111-4111-8111-111111111111",
+ imdbUserId: "ur12345678",
+ catalogTitle: "Mine",
+ sortOption: "added_at-asc",
+ displayMode: "split",
+ position: 0,
+ },
+ ]);
+
+ expect(watchlistMocks.getWatchlistByConfig).not.toHaveBeenCalled();
+ expect(error).toHaveBeenCalledWith(
+ "Failed to acquire prewarm lease for ur12345678:",
+ expect.objectContaining({ message: "database unavailable" }),
+ );
+ });
+});
diff --git a/apps/backend/src/services/imdb-scraper.ts b/apps/backend/src/services/imdb-scraper.ts
index adc6615..0fda691 100644
--- a/apps/backend/src/services/imdb-scraper.ts
+++ b/apps/backend/src/services/imdb-scraper.ts
@@ -15,12 +15,26 @@ import { shuffleArray } from "../utils";
const GRAPHQL_ENDPOINT = "https://api.graphql.imdb.com/";
const GRAPHQL_CLIENT_NAME = "imdb-next-desktop";
-// IMDb's GraphQL rejects the full-metadata query with "Too much data
-// requested" once `first` reaches ~1000. 250 stays comfortably under that and
-// matches the cursor index IMDb itself uses on imdb.com.
-const PAGE_SIZE = 250;
-// Safety stop for runaway pagination loops.
-const MAX_PAGES = 40;
+// 750 cuts the number of sequential IMDb requests by two thirds while keeping
+// each full-metadata response below the size where latency rises sharply.
+const PAGE_SIZE = 750;
+const IMDB_CURSOR_WINDOW_SIZE = 10_000;
+// Keep the user-facing item ceiling explicit, then derive the pagination safety
+// stop from it so changing PAGE_SIZE cannot silently change the supported limit.
+// This is intentionally bounded: every additional page is a sequential IMDb
+// request, so raising it further would make cold loads in Stremio even slower.
+const MAX_ITEMS = 15_000;
+const MAX_PAGES =
+ Math.floor(MAX_ITEMS / IMDB_CURSOR_WINDOW_SIZE) *
+ Math.ceil(IMDB_CURSOR_WINDOW_SIZE / PAGE_SIZE) +
+ Math.ceil((MAX_ITEMS % IMDB_CURSOR_WINDOW_SIZE) / PAGE_SIZE);
+
+function getPageSize(itemsFetched: number, targetItems: number): number {
+ const remaining = targetItems - itemsFetched;
+ const cursorWindowRemaining =
+ IMDB_CURSOR_WINDOW_SIZE - (itemsFetched % IMDB_CURSOR_WINDOW_SIZE);
+ return Math.min(PAGE_SIZE, remaining, cursorWindowRemaining);
+}
// Single source of truth for the `Title` node field selection. Both watchlist
// queries and all three chart queries return this exact node shape, so they all
@@ -381,11 +395,15 @@ export async function getImdbWatchlist(input: string): Promise {
const userId = await normalizeImdbUserId(input);
const edges: ImdbEdge[] = [];
let after: string | null = null;
+ let targetItems = MAX_ITEMS;
for (let page = 0; page < MAX_PAGES; page++) {
+ const first = getPageSize(edges.length, targetItems);
+ if (first <= 0) break;
+
const json = await queryImdbGraphQL("WatchListPage", WATCHLIST_QUERY, {
urConst: userId,
- first: PAGE_SIZE,
+ first,
after,
});
@@ -406,9 +424,22 @@ export async function getImdbWatchlist(input: string): Promise {
}
const search = list.titleListItemSearch;
- edges.push(...(search?.edges ?? []));
-
+ targetItems = Math.min(search?.total ?? MAX_ITEMS, MAX_ITEMS);
const pageInfo = search?.pageInfo;
+ const pageCapacity = Math.max(targetItems - edges.length, 0);
+ edges.push(...(search?.edges ?? []).slice(0, pageCapacity));
+
+ if (edges.length >= targetItems) {
+ if (
+ (search?.total ?? 0) > MAX_ITEMS ||
+ (targetItems === MAX_ITEMS && pageInfo?.hasNextPage)
+ ) {
+ console.warn(
+ `Watchlist for ${userId} exceeded the ${MAX_ITEMS.toLocaleString("en-US")}-item limit; remaining items truncated.`,
+ );
+ }
+ break;
+ }
if (!pageInfo?.hasNextPage || !pageInfo.endCursor) {
break;
}
@@ -416,7 +447,7 @@ export async function getImdbWatchlist(input: string): Promise {
if (page === MAX_PAGES - 1) {
console.warn(
- `Watchlist for ${userId} hit MAX_PAGES (${MAX_PAGES} × ${PAGE_SIZE} = ${MAX_PAGES * PAGE_SIZE}); remaining items truncated.`,
+ `Watchlist for ${userId} exceeded the ${MAX_ITEMS.toLocaleString("en-US")}-item limit; remaining items truncated.`,
);
}
}
@@ -765,11 +796,15 @@ export async function validateImdbList(
export async function getImdbList(listId: string): Promise {
const edges: ImdbEdge[] = [];
let after: string | null = null;
+ let targetItems = MAX_ITEMS;
for (let page = 0; page < MAX_PAGES; page++) {
+ const first = getPageSize(edges.length, targetItems);
+ if (first <= 0) break;
+
const json = await queryImdbGraphQL("ListPage", LIST_QUERY, {
listId,
- first: PAGE_SIZE,
+ first,
after,
});
@@ -792,9 +827,22 @@ export async function getImdbList(listId: string): Promise {
}
const search = list.titleListItemSearch;
- edges.push(...(search?.edges ?? []));
-
+ targetItems = Math.min(search?.total ?? MAX_ITEMS, MAX_ITEMS);
const pageInfo = search?.pageInfo;
+ const pageCapacity = Math.max(targetItems - edges.length, 0);
+ edges.push(...(search?.edges ?? []).slice(0, pageCapacity));
+
+ if (edges.length >= targetItems) {
+ if (
+ (search?.total ?? 0) > MAX_ITEMS ||
+ (targetItems === MAX_ITEMS && pageInfo?.hasNextPage)
+ ) {
+ console.warn(
+ `List ${listId} exceeded the ${MAX_ITEMS.toLocaleString("en-US")}-item limit; remaining items truncated.`,
+ );
+ }
+ break;
+ }
if (!pageInfo?.hasNextPage || !pageInfo.endCursor) {
break;
}
@@ -802,7 +850,7 @@ export async function getImdbList(listId: string): Promise {
if (page === MAX_PAGES - 1) {
console.warn(
- `List ${listId} hit MAX_PAGES (${MAX_PAGES} × ${PAGE_SIZE} = ${MAX_PAGES * PAGE_SIZE}); remaining items truncated.`,
+ `List ${listId} exceeded the ${MAX_ITEMS.toLocaleString("en-US")}-item limit; remaining items truncated.`,
);
}
}
diff --git a/apps/backend/src/services/watchlist-prewarm.ts b/apps/backend/src/services/watchlist-prewarm.ts
new file mode 100644
index 0000000..9d4b601
--- /dev/null
+++ b/apps/backend/src/services/watchlist-prewarm.ts
@@ -0,0 +1,136 @@
+import type { ConfigWatchlist } from "@stremlist/shared";
+import { supabase } from "../lib/supabase";
+import { getWatchlistByConfig } from "./watchlist";
+
+const PREWARM_CONCURRENCY = 2;
+const PREWARM_LEASE_SECONDS =
+ Number.isFinite(Number(process.env.PREWARM_LEASE_SECONDS)) &&
+ Number(process.env.PREWARM_LEASE_SECONDS) > 0
+ ? Number(process.env.PREWARM_LEASE_SECONDS)
+ : 600;
+
+async function acquirePrewarmLease(ownerUserId: string): Promise {
+ const { data, error } = await supabase.rpc(
+ "try_acquire_watchlist_prewarm_lease",
+ {
+ p_owner_user_id: ownerUserId,
+ p_lease_seconds: PREWARM_LEASE_SECONDS,
+ },
+ );
+ if (error) {
+ console.error(`Failed to acquire prewarm lease for ${ownerUserId}:`, error);
+ return false;
+ }
+ return data;
+}
+
+async function runPrewarmBatch(
+ ownerUserId: string,
+ watchlists: ConfigWatchlist[],
+): Promise {
+ const startedAt = performance.now();
+ let nextIndex = 0;
+ let prewarmed = 0;
+
+ async function worker(): Promise {
+ while (nextIndex < watchlists.length) {
+ const index = nextIndex;
+ nextIndex += 1;
+ const watchlist = watchlists[index];
+
+ try {
+ await getWatchlistByConfig({
+ ownerUserId,
+ watchlistId: watchlist.id,
+ imdbUserId: watchlist.imdbUserId,
+ sortOption: watchlist.sortOption,
+ // Prewarming only needs the canonical cache. Poster customization is
+ // applied later when Stremio requests the catalog.
+ rpdbApiKey: null,
+ skipUserTimestamp: true,
+ });
+ prewarmed += 1;
+ } catch (error) {
+ console.error(`Failed to prewarm watchlist ${watchlist.id}:`, error);
+ }
+ }
+ }
+
+ const workers = Array.from(
+ { length: Math.min(PREWARM_CONCURRENCY, watchlists.length) },
+ () => worker(),
+ );
+ await Promise.all(workers);
+
+ console.log(
+ `Prewarmed ${prewarmed}/${watchlists.length} watchlists in ${Math.round(performance.now() - startedAt)}ms`,
+ );
+}
+
+interface PrewarmState {
+ activeSignature: string;
+ pendingWatchlists: ConfigWatchlist[] | null;
+}
+
+interface InFlightPrewarm {
+ state: PrewarmState;
+ promise: Promise;
+}
+
+const inFlightBatches = new Map();
+
+function watchlistSignature(watchlists: ConfigWatchlist[]): string {
+ return watchlists
+ .map((watchlist) => `${watchlist.id}:${watchlist.imdbUserId}`)
+ .join("|");
+}
+
+async function runQueuedBatches(
+ ownerUserId: string,
+ firstWatchlists: ConfigWatchlist[],
+ state: PrewarmState,
+): Promise {
+ if (!(await acquirePrewarmLease(ownerUserId))) return;
+
+ let watchlists: ConfigWatchlist[] | null = firstWatchlists;
+ while (watchlists) {
+ state.activeSignature = watchlistSignature(watchlists);
+ await runPrewarmBatch(ownerUserId, watchlists);
+ watchlists = state.pendingWatchlists;
+ state.pendingWatchlists = null;
+ }
+}
+
+export function prewarmWatchlists(
+ ownerUserId: string,
+ watchlists: ConfigWatchlist[],
+): Promise {
+ const signature = watchlistSignature(watchlists);
+ const existing = inFlightBatches.get(ownerUserId);
+ if (existing) {
+ existing.state.pendingWatchlists =
+ signature === existing.state.activeSignature ? null : watchlists;
+ return existing.promise;
+ }
+
+ const state: PrewarmState = {
+ activeSignature: signature,
+ pendingWatchlists: null,
+ };
+ const batch = runQueuedBatches(ownerUserId, watchlists, state);
+ const inFlight = { state, promise: batch };
+ inFlightBatches.set(ownerUserId, inFlight);
+ void batch.then(
+ () => {
+ if (inFlightBatches.get(ownerUserId) === inFlight) {
+ inFlightBatches.delete(ownerUserId);
+ }
+ },
+ () => {
+ if (inFlightBatches.get(ownerUserId) === inFlight) {
+ inFlightBatches.delete(ownerUserId);
+ }
+ },
+ );
+ return batch;
+}
diff --git a/apps/backend/src/services/watchlist.ts b/apps/backend/src/services/watchlist.ts
index b3905a1..83ef303 100644
--- a/apps/backend/src/services/watchlist.ts
+++ b/apps/backend/src/services/watchlist.ts
@@ -77,6 +77,56 @@ export interface WatchlistFetchConfig {
noCacheFallback?: boolean;
}
+interface FreshWatchlist {
+ data: WatchlistData;
+ cachedAt: Date;
+ generation: string | null;
+}
+
+const inFlightRefreshes = new Map>();
+
+function refreshKey(config: WatchlistFetchConfig): string {
+ return `${config.watchlistId}:${config.imdbUserId}`;
+}
+
+async function fetchAndCacheWatchlist(
+ config: WatchlistFetchConfig,
+): Promise {
+ const fetcher = isChartId(config.imdbUserId)
+ ? fetchChart
+ : isListId(config.imdbUserId)
+ ? fetchList
+ : fetchWatchlist;
+ const data = await fetcher(config.imdbUserId, DEFAULT_SORT_OPTIONS, null);
+ const cachedAt = new Date();
+ const generation = await upsertCache(config.watchlistId, data, cachedAt);
+ return { data, cachedAt, generation };
+}
+
+function refreshWatchlist(
+ config: WatchlistFetchConfig,
+): Promise {
+ const key = refreshKey(config);
+ const existing = inFlightRefreshes.get(key);
+ if (existing) return existing;
+
+ const refresh = fetchAndCacheWatchlist(config);
+ inFlightRefreshes.set(key, refresh);
+ void refresh.then(
+ () => {
+ if (inFlightRefreshes.get(key) === refresh) {
+ inFlightRefreshes.delete(key);
+ }
+ },
+ () => {
+ if (inFlightRefreshes.get(key) === refresh) {
+ inFlightRefreshes.delete(key);
+ }
+ },
+ );
+ return refresh;
+}
+
export async function getWatchlistByConfig(
config: WatchlistFetchConfig,
): Promise {
@@ -109,27 +159,21 @@ export async function getWatchlistByConfig(
}
try {
- const fetcher = isChartId(config.imdbUserId)
- ? fetchChart
- : isListId(config.imdbUserId)
- ? fetchList
- : fetchWatchlist;
- // Fetch canonically so the cached blob is sort- and RPDB-key-agnostic.
- const fresh = await fetcher(config.imdbUserId, DEFAULT_SORT_OPTIONS, null);
- const cachedAt = new Date();
+ // A config save can prewarm at the same moment Stremio requests a catalog.
+ // Share the canonical scrape/cache write, then apply caller-specific
+ // sorting and poster customization below.
+ const {
+ data: fresh,
+ cachedAt,
+ generation,
+ } = await refreshWatchlist(config);
- const [generation] = await Promise.all([
- upsertCache(config.watchlistId, fresh, cachedAt),
- // Tier 2: only stamp the analytics timestamp on a real refresh.
- ...(config.skipUserTimestamp
- ? []
- : [
- supabase
- .from("users")
- .update({ last_fetched_at: cachedAt.toISOString() })
- .eq("imdb_user_id", config.ownerUserId),
- ]),
- ]);
+ if (!config.skipUserTimestamp) {
+ await supabase
+ .from("users")
+ .update({ last_fetched_at: cachedAt.toISOString() })
+ .eq("imdb_user_id", config.ownerUserId);
+ }
return resortCachedData(
fresh,
sortOptions,
diff --git a/apps/e2e/helpers/test-data.ts b/apps/e2e/helpers/test-data.ts
index db6e9df..6aea55d 100644
--- a/apps/e2e/helpers/test-data.ts
+++ b/apps/e2e/helpers/test-data.ts
@@ -16,6 +16,7 @@ export const PUBLIC_LIST = process.env.E2E_IMDB_LIST_ID ?? "ls055592025";
// a 13-digit id is far outside the allocated range.
export const UNKNOWN_USER = "ur9999999999999";
export const UNKNOWN_LIST = "ls9999999999999";
+export const MALFORMED_USER = "not-an-imdb-user";
// Account whose watchlist is deliberately kept private for these tests
// (maintainer-owned). The p-handle resolves to the same account, covering the
@@ -31,6 +32,7 @@ export const E2E_USER_IDS = [
PUBLIC_USER_2,
UNKNOWN_USER,
PRIVATE_USER,
+ MALFORMED_USER,
] as const;
// No stable private ls list is available; provide one via env to enable the
diff --git a/apps/e2e/tests/addon-api.spec.ts b/apps/e2e/tests/addon-api.spec.ts
index 6361720..e921cd2 100644
--- a/apps/e2e/tests/addon-api.spec.ts
+++ b/apps/e2e/tests/addon-api.spec.ts
@@ -20,6 +20,7 @@ import {
getCacheObjectKeys,
} from "../helpers/r2.js";
import {
+ MALFORMED_USER,
P_HANDLE,
PRIVATE_LIST,
PRIVATE_P_HANDLE,
@@ -96,6 +97,27 @@ test.describe("manifest", () => {
expect(manifest.catalogs[0].type).toBe("movie");
},
);
+
+ test(
+ "rejects malformed installation IDs",
+ { tag: "@local" },
+ async ({ request }) => {
+ try {
+ const response = await request.get(
+ `${BACKEND_URL}/${MALFORMED_USER}/manifest.json`,
+ );
+ expect(response.status()).toBe(400);
+ await expect(response.json()).resolves.toMatchObject({
+ behaviorHints: {
+ configurable: true,
+ configurationRequired: true,
+ },
+ });
+ } finally {
+ await resetDb();
+ }
+ },
+ );
});
test.describe("catalogs", () => {
diff --git a/apps/e2e/tests/configure-page.spec.ts b/apps/e2e/tests/configure-page.spec.ts
index 5f5e209..054f673 100644
--- a/apps/e2e/tests/configure-page.spec.ts
+++ b/apps/e2e/tests/configure-page.spec.ts
@@ -1,5 +1,5 @@
import { expect, test } from "@playwright/test";
-import { FRONTEND_URL } from "../env.js";
+import { BACKEND_URL, FRONTEND_URL } from "../env.js";
import { bootstrapUser, getConfig } from "../helpers/api.js";
import { resetDb } from "../helpers/db.js";
import {
@@ -34,6 +34,86 @@ test(
},
);
+test(
+ "manifest copy works through an accessible control",
+ { tag: "@local" },
+ async ({ context, page }) => {
+ await context.grantPermissions(["clipboard-read", "clipboard-write"], {
+ origin: FRONTEND_URL,
+ });
+ await bootstrapUser(PUBLIC_USER);
+ await page.goto(configureUrl(PUBLIC_USER));
+ await expect(page.getByText("Catalog 1")).toBeVisible();
+ const copyButton = page.getByRole("button", { name: "Copy manifest URL" });
+ await expect(copyButton).toBeVisible();
+
+ await copyButton.click();
+ await expect(
+ page.getByRole("button", { name: "Manifest URL copied" }),
+ ).toBeVisible();
+ await expect
+ .poll(() => page.evaluate(() => navigator.clipboard.readText()))
+ .toBe(`${BACKEND_URL}/${PUBLIC_USER}/manifest.json`);
+ },
+);
+
+test(
+ "clipboard denial explains how to copy the manifest URL manually",
+ { tag: "@local" },
+ async ({ page }) => {
+ await bootstrapUser(PUBLIC_USER);
+ await page.goto(configureUrl(PUBLIC_USER));
+ await expect(page.getByText("Catalog 1")).toBeVisible();
+ await page.evaluate(() => {
+ Object.defineProperty(navigator.clipboard, "writeText", {
+ configurable: true,
+ value: () =>
+ Promise.reject(new DOMException("Denied", "NotAllowedError")),
+ });
+ });
+
+ await page.getByRole("button", { name: "Copy manifest URL" }).click();
+ await expect(
+ page.getByText(
+ "Could not copy the manifest URL. Select it and copy it manually.",
+ ),
+ ).toBeVisible({ timeout: 2_000 });
+ },
+);
+
+test(
+ "failed configuration loads cannot overwrite saved settings and can retry",
+ { tag: "@local" },
+ async ({ page }) => {
+ await bootstrapUser(PUBLIC_USER);
+ let attempts = 0;
+ await page.route(`**/${PUBLIC_USER}/config`, async (route) => {
+ if (route.request().method() === "GET" && attempts++ === 0) {
+ await route.fulfill({
+ status: 503,
+ json: { error: "Configuration storage unavailable." },
+ });
+ return;
+ }
+ await route.continue();
+ });
+
+ await page.goto(configureUrl(PUBLIC_USER));
+ await expect(
+ page.getByText("Could not load your configuration. Please try again."),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Save", exact: true }),
+ ).not.toBeVisible();
+
+ await page.getByRole("button", { name: "Try again" }).click();
+ await expect(page.getByText("Catalog 1")).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Save", exact: true }),
+ ).toBeVisible();
+ },
+);
+
test(
"adds an ls list catalog and saves",
{ tag: "@local" },
diff --git a/apps/e2e/tests/home-onboarding.spec.ts b/apps/e2e/tests/home-onboarding.spec.ts
index 8ccd944..655d15d 100644
--- a/apps/e2e/tests/home-onboarding.spec.ts
+++ b/apps/e2e/tests/home-onboarding.spec.ts
@@ -69,3 +69,19 @@ test(
).toBeVisible();
},
);
+
+test(
+ "unknown routes offer a way back home",
+ { tag: "@local" },
+ async ({ page }) => {
+ await page.goto(`${FRONTEND_URL}/this-route-does-not-exist`);
+ await expect(
+ page.getByRole("heading", { name: "Page not found" }),
+ ).toBeVisible();
+ await page.getByRole("link", { name: "Return to home" }).click();
+ await expect(page).toHaveURL(`${FRONTEND_URL}/`);
+ await expect(
+ page.getByRole("heading", { name: "Connect IMDb to Stremio" }),
+ ).toBeVisible();
+ },
+);
diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx
index 2aa6063..caca442 100644
--- a/apps/frontend/src/App.tsx
+++ b/apps/frontend/src/App.tsx
@@ -4,6 +4,7 @@ import Home from "./pages/Home";
import Terms from "./pages/Terms";
import Changelog from "./pages/Changelog";
import Configure from "./pages/Configure";
+import NotFound from "./pages/NotFound";
export default function App() {
return (
@@ -13,6 +14,7 @@ export default function App() {
} />
} />
} />
+ } />
diff --git a/apps/frontend/src/components/AddonInstallActions.tsx b/apps/frontend/src/components/AddonInstallActions.tsx
index 96d2d14..48c1ce2 100644
--- a/apps/frontend/src/components/AddonInstallActions.tsx
+++ b/apps/frontend/src/components/AddonInstallActions.tsx
@@ -20,13 +20,19 @@ export default function AddonInstallActions({
className,
}: AddonInstallActionsProps) {
const [copied, setCopied] = useState(false);
+ const [copyError, setCopyError] = useState(false);
const urls = buildUrls(imdbUserId);
- const handleCopy = () => {
- navigator.clipboard.writeText(urls.addonUrl).then(() => {
+ const handleCopy = async () => {
+ setCopyError(false);
+ try {
+ await navigator.clipboard.writeText(urls.addonUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
- });
+ } catch {
+ setCopied(false);
+ setCopyError(true);
+ }
};
return (
@@ -53,10 +59,19 @@ export default function AddonInstallActions({
value={urls.addonUrl}
className="flex-1 font-mono text-sm bg-white"
/>
-
)}
- {userId && !loading && !userNotFound && (
+ {userId && !loading && !userNotFound && !loadError && (
+ ) : loadError ? (
+
+
+ Could not load your configuration. Please try again.
+ setLoadAttempt((current) => current + 1)}
+ className="border-red-300 bg-white text-red-700 hover:bg-red-100"
+ >
+ Try again
+
+
+
) : (
<>
diff --git a/apps/frontend/src/pages/NotFound.tsx b/apps/frontend/src/pages/NotFound.tsx
new file mode 100644
index 0000000..a0e833f
--- /dev/null
+++ b/apps/frontend/src/pages/NotFound.tsx
@@ -0,0 +1,33 @@
+import { Link } from "react-router";
+import Header from "../components/Header";
+import { useSEO } from "../hooks/useSEO";
+import { Button } from "@/components/ui/button";
+
+export default function NotFound() {
+ useSEO({
+ title: "Page not found - Stremlist",
+ description: "The requested Stremlist page could not be found.",
+ robots: "noindex, nofollow",
+ });
+
+ return (
+
+
+
+
+
+ 404
+
+
+ Page not found
+
+
+ This address does not match a Stremlist page.
+
+
+ Return to home
+
+
+
+ );
+}
diff --git a/packages/shared/src/database.types.ts b/packages/shared/src/database.types.ts
index 53040f4..5d53190 100644
--- a/packages/shared/src/database.types.ts
+++ b/packages/shared/src/database.types.ts
@@ -65,6 +65,7 @@ export type Database = {
is_active: boolean;
last_cache_served_at: string | null;
last_fetched_at: string;
+ prewarm_locked_until: string;
rpdb_api_key: string | null;
};
Insert: {
@@ -73,6 +74,7 @@ export type Database = {
is_active?: boolean;
last_cache_served_at?: string | null;
last_fetched_at?: string;
+ prewarm_locked_until?: string;
rpdb_api_key?: string | null;
};
Update: {
@@ -81,6 +83,7 @@ export type Database = {
is_active?: boolean;
last_cache_served_at?: string | null;
last_fetched_at?: string;
+ prewarm_locked_until?: string;
rpdb_api_key?: string | null;
};
Relationships: [];
@@ -90,7 +93,13 @@ export type Database = {
[_ in never]: never;
};
Functions: {
- [_ in never]: never;
+ try_acquire_watchlist_prewarm_lease: {
+ Args: {
+ p_lease_seconds: number;
+ p_owner_user_id: string;
+ };
+ Returns: boolean;
+ };
};
Enums: {
[_ in never]: never;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ed73b83..5b0429f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -32,6 +32,9 @@ importers:
'@supabase/supabase-js':
specifier: ^2.95.3
version: 2.95.3
+ '@vercel/functions':
+ specifier: ^3.9.5
+ version: 3.9.5(@aws-sdk/credential-provider-web-identity@3.972.76)(ws@8.19.0)
'@vercel/related-projects':
specifier: ^1.0.0
version: 1.0.0
@@ -1747,6 +1750,29 @@ packages:
vue-router:
optional: true
+ '@vercel/cli-config@0.2.4':
+ resolution: {integrity: sha512-kZ5SojbrV06GHoU6QIWGwDXLov+s9rWZ7QqdqKfJfBGCNUieGfgaCjeeenNy8Y+QC0bwC0dZ2B4l5Hvdmrgpdw==}
+
+ '@vercel/cli-exec@1.0.1':
+ resolution: {integrity: sha512-g9XerViJ/paZujufXYcu5XYI2vU2rtB4sgdpjUHde5RnOkdmpu0ngH46LCFGHoPXO/C+qDPSczIHIRN+8Q2YKQ==}
+ engines: {node: '>= 18'}
+
+ '@vercel/functions@3.9.5':
+ resolution: {integrity: sha512-EUfqlb7AzoEh7URlMNAO4jbJiLWz9grDBHvfjKTDvEP9c8y3DqX3SWPvfaQkUjtkm3b83flhaUUMuewdHa+qmw==}
+ engines: {node: '>= 20'}
+ peerDependencies:
+ '@aws-sdk/credential-provider-web-identity': '*'
+ ws: '>=8'
+ peerDependenciesMeta:
+ '@aws-sdk/credential-provider-web-identity':
+ optional: true
+ ws:
+ optional: true
+
+ '@vercel/oidc@3.8.5':
+ resolution: {integrity: sha512-RwXYtnt6za+5UO4IaLywN/6B95AlLqynPRUWRJxeJ/qufwkcLUbZNUxYtzT0uMpuraWhlNcGqPNGkTnZr4BGBw==}
+ engines: {node: '>= 20'}
+
'@vercel/related-projects@1.0.0':
resolution: {integrity: sha512-wGk91hdzpQMMSTCE9q6q5caKWMse9OzH3IeeC83SBZ/ZprvjPSusgftYBrbkDehV0gytRd22MFzYFaSwbVYiDw==}
@@ -2141,6 +2167,10 @@ packages:
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
+ execa@5.1.1:
+ resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
+ engines: {node: '>=10'}
+
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
@@ -2221,6 +2251,10 @@ packages:
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
engines: {node: '>=6'}
+ get-stream@6.0.1:
+ resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
+ engines: {node: '>=10'}
+
get-tsconfig@4.13.6:
resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==}
@@ -2266,6 +2300,10 @@ packages:
htmlparser2@10.1.0:
resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
+ human-signals@2.1.0:
+ resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
+ engines: {node: '>=10.17.0'}
+
husky@9.1.7:
resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==}
engines: {node: '>=18'}
@@ -2310,6 +2348,10 @@ packages:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
+ is-stream@2.0.1:
+ resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
+ engines: {node: '>=8'}
+
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
@@ -2317,6 +2359,9 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
+ jose@5.10.0:
+ resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
+
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -2464,10 +2509,17 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+ merge-stream@2.0.0:
+ resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
+
micromatch@4.0.8:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
+ mimic-fn@2.1.0:
+ resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
+ engines: {node: '>=6'}
+
mimic-function@5.0.1:
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
engines: {node: '>=18'}
@@ -2522,12 +2574,20 @@ packages:
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
+ npm-run-path@4.0.1:
+ resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
+ engines: {node: '>=8'}
+
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
+ onetime@5.1.2:
+ resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
+ engines: {node: '>=6'}
+
onetime@7.0.0:
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
engines: {node: '>=18'}
@@ -2536,6 +2596,10 @@ packages:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
+ os-paths@4.4.0:
+ resolution: {integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==}
+ engines: {node: '>= 6.0'}
+
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
@@ -2718,6 +2782,9 @@ packages:
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+ signal-exit@3.0.7:
+ resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
+
signal-exit@4.1.0:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
@@ -2759,6 +2826,10 @@ packages:
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
engines: {node: '>=12'}
+ strip-final-newline@2.0.0:
+ resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==}
+ engines: {node: '>=6'}
+
strip-json-comments@3.1.1:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
@@ -2990,6 +3061,14 @@ packages:
utf-8-validate:
optional: true
+ xdg-app-paths@5.5.1:
+ resolution: {integrity: sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ==}
+ engines: {node: '>= 6.0'}
+
+ xdg-portable@7.3.0:
+ resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==}
+ engines: {node: '>= 6.0'}
+
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -3011,6 +3090,9 @@ packages:
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
+ zod@4.1.11:
+ resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==}
+
snapshots:
'@aws-sdk/checksums@3.1000.29':
@@ -4338,6 +4420,28 @@ snapshots:
optionalDependencies:
react: 19.2.4
+ '@vercel/cli-config@0.2.4':
+ dependencies:
+ xdg-app-paths: 5.5.1
+ zod: 4.1.11
+
+ '@vercel/cli-exec@1.0.1':
+ dependencies:
+ execa: 5.1.1
+
+ '@vercel/functions@3.9.5(@aws-sdk/credential-provider-web-identity@3.972.76)(ws@8.19.0)':
+ dependencies:
+ '@vercel/oidc': 3.8.5
+ optionalDependencies:
+ '@aws-sdk/credential-provider-web-identity': 3.972.76
+ ws: 8.19.0
+
+ '@vercel/oidc@3.8.5':
+ dependencies:
+ '@vercel/cli-config': 0.2.4
+ '@vercel/cli-exec': 1.0.1
+ jose: 5.10.0
+
'@vercel/related-projects@1.0.0':
optionalDependencies:
ajv: 6.12.6
@@ -4801,6 +4905,18 @@ snapshots:
eventemitter3@5.0.4: {}
+ execa@5.1.1:
+ dependencies:
+ cross-spawn: 7.0.6
+ get-stream: 6.0.1
+ human-signals: 2.1.0
+ is-stream: 2.0.1
+ merge-stream: 2.0.0
+ npm-run-path: 4.0.1
+ onetime: 5.1.2
+ signal-exit: 3.0.7
+ strip-final-newline: 2.0.0
+
expect-type@1.3.0: {}
fast-deep-equal@3.1.3: {}
@@ -4856,6 +4972,8 @@ snapshots:
get-nonce@1.0.1: {}
+ get-stream@6.0.1: {}
+
get-tsconfig@4.13.6:
dependencies:
resolve-pkg-maps: 1.0.0
@@ -4893,6 +5011,8 @@ snapshots:
domutils: 3.2.2
entities: 7.0.1
+ human-signals@2.1.0: {}
+
husky@9.1.7: {}
iceberg-js@0.8.1: {}
@@ -4924,10 +5044,14 @@ snapshots:
is-number@7.0.0: {}
+ is-stream@2.0.1: {}
+
isexe@2.0.0: {}
jiti@2.6.1: {}
+ jose@5.10.0: {}
+
js-tokens@4.0.0: {}
js-yaml@4.1.1:
@@ -5055,11 +5179,15 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
+ merge-stream@2.0.0: {}
+
micromatch@4.0.8:
dependencies:
braces: 3.0.3
picomatch: 2.3.1
+ mimic-fn@2.1.0: {}
+
mimic-function@5.0.1: {}
minimatch@3.1.2:
@@ -5096,12 +5224,20 @@ snapshots:
node-releases@2.0.27: {}
+ npm-run-path@4.0.1:
+ dependencies:
+ path-key: 3.1.1
+
nth-check@2.1.1:
dependencies:
boolbase: 1.0.0
obug@2.1.1: {}
+ onetime@5.1.2:
+ dependencies:
+ mimic-fn: 2.1.0
+
onetime@7.0.0:
dependencies:
mimic-function: 5.0.1
@@ -5115,6 +5251,8 @@ snapshots:
type-check: 0.4.0
word-wrap: 1.2.5
+ os-paths@4.4.0: {}
+
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
@@ -5274,6 +5412,8 @@ snapshots:
siginfo@2.0.0: {}
+ signal-exit@3.0.7: {}
+
signal-exit@4.1.0: {}
slice-ansi@7.1.2:
@@ -5311,6 +5451,8 @@ snapshots:
dependencies:
ansi-regex: 6.2.2
+ strip-final-newline@2.0.0: {}
+
strip-json-comments@3.1.1: {}
supports-color@7.2.0:
@@ -5534,6 +5676,15 @@ snapshots:
ws@8.19.0: {}
+ xdg-app-paths@5.5.1:
+ dependencies:
+ os-paths: 4.4.0
+ xdg-portable: 7.3.0
+
+ xdg-portable@7.3.0:
+ dependencies:
+ os-paths: 4.4.0
+
yallist@3.1.1: {}
yaml@2.8.2: {}
@@ -5545,3 +5696,5 @@ snapshots:
zod: 3.25.76
zod@3.25.76: {}
+
+ zod@4.1.11: {}
diff --git a/supabase/.temp/cli-latest b/supabase/.temp/cli-latest
index 1dd6178..a0df631 100644
--- a/supabase/.temp/cli-latest
+++ b/supabase/.temp/cli-latest
@@ -1 +1 @@
-v2.75.0
\ No newline at end of file
+v2.116.0
\ No newline at end of file
diff --git a/supabase/migrations/20260901110000_add_watchlist_prewarm_lease.sql b/supabase/migrations/20260901110000_add_watchlist_prewarm_lease.sql
new file mode 100644
index 0000000..ee31a0d
--- /dev/null
+++ b/supabase/migrations/20260901110000_add_watchlist_prewarm_lease.sql
@@ -0,0 +1,29 @@
+ALTER TABLE public.users
+ADD COLUMN IF NOT EXISTS prewarm_locked_until timestamptz NOT NULL
+DEFAULT '1970-01-01 00:00:00+00'::timestamptz;
+
+CREATE OR REPLACE FUNCTION public.try_acquire_watchlist_prewarm_lease(
+ p_owner_user_id text,
+ p_lease_seconds integer
+)
+RETURNS boolean
+LANGUAGE sql
+SECURITY INVOKER
+SET search_path = ''
+AS $$
+ WITH claimed AS (
+ UPDATE public.users
+ SET prewarm_locked_until = clock_timestamp()
+ + make_interval(secs => LEAST(GREATEST(p_lease_seconds, 1), 3600))
+ WHERE imdb_user_id = p_owner_user_id
+ AND prewarm_locked_until <= clock_timestamp()
+ RETURNING 1
+ )
+ SELECT EXISTS (SELECT 1 FROM claimed);
+$$;
+
+REVOKE ALL ON FUNCTION public.try_acquire_watchlist_prewarm_lease(text, integer)
+FROM PUBLIC, anon, authenticated;
+GRANT EXECUTE
+ON FUNCTION public.try_acquire_watchlist_prewarm_lease(text, integer)
+TO service_role;