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" /> - + {copyError && ( +

+ Could not copy the manifest URL. Select it and copy it manually. +

+ )} ); diff --git a/apps/frontend/src/pages/Configure.tsx b/apps/frontend/src/pages/Configure.tsx index c6e5af2..6098efe 100644 --- a/apps/frontend/src/pages/Configure.tsx +++ b/apps/frontend/src/pages/Configure.tsx @@ -319,6 +319,8 @@ export default function Configure() { const [cooldownSeconds, setCooldownSeconds] = useState(60); const [now, setNow] = useState(() => Date.now()); const [userNotFound, setUserNotFound] = useState(false); + const [loadError, setLoadError] = useState(false); + const [loadAttempt, setLoadAttempt] = useState(0); const [showReinstallHint, setShowReinstallHint] = useState(false); const [watchlistBaselineSignature, setWatchlistBaselineSignature] = useState(""); @@ -332,6 +334,7 @@ export default function Configure() { setLoading(true); setUserNotFound(false); + setLoadError(false); setWatchlists([ createWatchlistRow({ imdbUserId: userId, @@ -353,6 +356,9 @@ export default function Configure() { setUserNotFound(true); return null; } + if (!res.ok) { + throw new Error("Failed to load configuration"); + } return res.json(); }) .then((raw) => { @@ -379,9 +385,9 @@ export default function Configure() { } } }) - .catch(() => {}) + .catch(() => setLoadError(true)) .finally(() => setLoading(false)); - }, [userId]); + }, [userId, loadAttempt]); // Tick once a second so the "last refreshed" label and the refresh cooldown // countdown stay live without per-event timers. @@ -669,7 +675,7 @@ export default function Configure() {

)} - {userId && !loading && !userNotFound && ( + {userId && !loading && !userNotFound && !loadError && (

+ ) : loadError ? ( + + +

Could not load your configuration. Please 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. +

+ +
+
+ ); +} 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;