diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3678486..a00aa24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: pnpm - name: Install dependencies @@ -52,7 +52,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: pnpm - uses: supabase/setup-cli@v1 @@ -62,6 +62,9 @@ jobs: - name: Start local Supabase (db + REST only) run: supabase start -x gotrue,realtime,storage-api,imgproxy,studio,edge-runtime,logflare,vector,supavisor,mailpit,postgres-meta + - name: Test atomic configuration replacement + run: docker exec -i supabase_db_stremlist psql -U postgres -v ON_ERROR_STOP=1 < supabase/tests/replace_user_config.sql + - name: Start local R2-compatible store run: | docker run --rm -d --name stremlist-e2e-r2 \ @@ -113,7 +116,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 - name: Compute version bump from Conventional Commits id: bump diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24 diff --git a/README.md b/README.md index 95d2cd4..5bced86 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,36 @@ Stremlist is a Stremio addon that turns your IMDb watchlist into a Stremio catal - Browse IMDb watchlist items in Stremio - Supports one or multiple IMDb watchlists -- Supports sorting by title, year, rating, runtime, and random order +- Supports sorting by title, year, complete release date, rating, runtime, and random order +- Filter by genre or decade, or choose a sort directly from Stremio's Discover genre dropdown (one option at a time) +- Combine genre, decade, maximum runtime and minimum IMDb rating in each catalog configuration +- Search your configured lists from Stremio search +- Optional extra home catalogs: 90 min or less, Top rated, and Shuffle - Optional Rating Poster Database (RPDB) poster support via API key - Simple install flow through a hosted configuration UI - Cache-first watchlist serving with periodic auto-refresh and a manual "Refresh now" control - Lightweight backend with Supabase for user configuration and Cloudflare R2 for watchlist caching - Monorepo architecture with Turborepo (`apps` + `packages`) +Reinstall an existing addon to load the new dropdown options. Selecting a genre +or decade preserves the configured sort; selecting a sort temporarily overrides +it. Saved filters always apply together, including to search and extra catalogs. +The dropdown adds one further filter or overrides the sort; None clears only +that temporary selection. Extra catalogs reuse the original list and cache. + +Release Year sorts by the IMDb year; Release Date sorts by the complete date +returned by IMDb (which may differ from the original release year). Incomplete +dates sort last, without inventing a day or month. Refresh an older cache to +populate release dates. Date-added sorting uses IMDb list order. Shuffle stays +stable within a cache generation so scrolling does not repeat items. Popularity +is not offered: the tested IMDb meterRanking field reported an entitlement denial. + +Apply `supabase/migrations/20260914230000_catalog_settings.sql` before deploying +this version. The new JSON column defaults to an empty configuration. Older +clients that omit these settings preserve them; sending an empty object clears +them. Reinstall after enabling/disabling extra catalogs or adding search support; +changing only saved filters does not require reinstalling. + ## Monorepo Structure This repository follows the Turborepo recommended structure: @@ -41,7 +64,7 @@ This repository follows the Turborepo recommended structure: ### Prerequisites -- Node.js 20+ +- Node.js 24+ for development with Portless (`.node-version`) - pnpm 10+ ### Install @@ -52,23 +75,29 @@ pnpm install ### Run in Development -Run both apps: +Decrypt the backend environment first (see below), then run: ```bash -pnpm dev +pnpm dev # both apps through Portless +pnpm dev:tailnet # both apps; share the frontend over Tailscale HTTPS +pnpm dev:backend # backend only +pnpm dev:frontend # frontend only (requires a running backend) +pnpm exec portless list ``` -Run only one app: - -```bash -pnpm dev:backend -pnpm dev:frontend -``` +Portless 0.15.6 is pinned as a dev dependency. In the main checkout, the +local names are `https://stremlist.localhost` and +`https://api.stremlist.localhost`. Linked worktrees receive a branch prefix; +use the printed URLs or `pnpm exec portless list` instead of hardcoding them. +A previously configured proxy port (such as 1355) appears in these URLs too. -Default local URLs: +For access from another tailnet device, open the **Tailscale URL** printed for +the frontend. The browser uses `/api` on that same origin, and Vite forwards +requests to the matching worktree's backend. Stremio install links and configure +redirects use that origin too. No machine-specific browser API URL is needed. -- Backend: `http://localhost:7001` -- Frontend: Vite default (`http://localhost:5173` unless overridden) +See [the Portless development guide](docs/portless-development.md) for first-run +setup, HTTPS, environment overrides, plain-port fallback, and cleanup. ### Decrypt environment files diff --git a/apps/backend/package.json b/apps/backend/package.json index f73f907..893385b 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -12,7 +12,7 @@ } }, "scripts": { - "dev": "tsx watch --env-file=.env src/dev.ts", + "dev": "portless", "build": "tsx build.ts", "lint": "eslint .", "format": "prettier . --write", @@ -21,7 +21,9 @@ "test": "vitest run --exclude '**/*.stress.test.ts'", "test:stress": "vitest run src/services/__tests__/imdb-scraper.stress.test.ts", "typecheck": "tsc --noEmit", - "cleanup:invalid-users": "tsx src/scripts/cleanup-invalid-users.ts" + "cleanup:invalid-users": "tsx src/scripts/cleanup-invalid-users.ts", + "dev:app": "tsx watch --env-file=.env src/dev.ts", + "dev:tailnet": "portless" }, "dependencies": { "@aws-sdk/client-s3": "^3.1118.0", @@ -48,5 +50,10 @@ "tsx": "^4.7.1", "typescript": "^5.8.3", "vitest": "^4.0.18" + }, + "portless": { + "name": "api.stremlist", + "script": "dev:app", + "proxy": true } } diff --git a/apps/backend/src/__tests__/catalog-fallback.test.ts b/apps/backend/src/__tests__/catalog-fallback.test.ts index 6478dd9..cb44b3b 100644 --- a/apps/backend/src/__tests__/catalog-fallback.test.ts +++ b/apps/backend/src/__tests__/catalog-fallback.test.ts @@ -1,4 +1,4 @@ -import type { StremioMeta } from "@stremlist/shared"; +import type { StremioMeta } from "@stremlist/shared/stremio.types"; import { describe, it, expect, beforeEach, vi } from "vitest"; vi.mock("../lib/supabase", async () => { @@ -86,6 +86,50 @@ beforeEach(() => { }); describe("catalog route degrades gracefully on fetch failure", () => { + describe.each([scraper.ERROR_PRIVATE, scraper.ERROR_NOT_FOUND])( + "search when IMDb returns %s", + (error) => { + it.each([ + "/search=alien.json", + ".json?search=alien", + "/search=.json", + ".json?search=", + ])("returns no informational cards for %s", async (suffix) => { + seedUser(OWNER); + seedWatchlist(UUID_1); + vi.spyOn(scraper, "fetchWatchlist").mockRejectedValue(new Error(error)); + + const res = await app.request( + `/${OWNER}/catalog/movie/wl-${UUID_1}-movie${suffix}`, + ); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ metas: [] }); + }); + }, + ); + + it("searches stale cached titles when the IMDb list becomes private", async () => { + seedUser(OWNER); + seedWatchlist(UUID_1); + seedCache( + UUID_1, + [CACHED_MOVIE, { ...CACHED_MOVIE, id: "tt0078748", name: "Alien" }], + new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), + ); + vi.spyOn(scraper, "fetchWatchlist").mockRejectedValue( + new Error(scraper.ERROR_PRIVATE), + ); + + const res = await app.request( + `/${OWNER}/catalog/movie/wl-${UUID_1}-movie/search=alien.json`, + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as CatalogResponse; + expect(body.metas.map((meta) => meta.id)).toEqual(["tt0078748"]); + }); + it("returns a 200 'private' card when the IMDb list is private and there is no cache", async () => { seedUser(OWNER); seedWatchlist(UUID_1); @@ -156,18 +200,23 @@ describe("catalog route degrades gracefully on fetch failure", () => { expect(body.metas[0].id).toBe(CACHED_MOVIE.id); }); - it("keeps the 500 for an unexpected/transient server error", async () => { - seedUser(OWNER); - seedWatchlist(UUID_1); - vi.spyOn(scraper, "fetchWatchlist").mockRejectedValue( - new Error("ECONNRESET while talking to IMDb"), - ); - - const res = await requestMovieCatalog(); - - expect(res.status).toBe(500); - expect((await res.json()) as CatalogResponse).toEqual({ metas: [] }); - }); + it.each([".json", "/search=alien.json"])( + "keeps the 500 for an unexpected/transient server error at %s", + async (suffix) => { + seedUser(OWNER); + seedWatchlist(UUID_1); + vi.spyOn(scraper, "fetchWatchlist").mockRejectedValue( + new Error("ECONNRESET while talking to IMDb"), + ); + + const res = await app.request( + `/${OWNER}/catalog/movie/wl-${UUID_1}-movie${suffix}`, + ); + + expect(res.status).toBe(500); + expect((await res.json()) as CatalogResponse).toEqual({ metas: [] }); + }, + ); }); describe("catalog pagination", () => { @@ -255,3 +304,161 @@ describe("catalog pagination", () => { expect((await res.json()) as CatalogResponse).toEqual({ metas: [] }); }); }); + +describe("catalog dropdown filters", () => { + const titles: StremioMeta[] = [ + { + ...CACHED_MOVIE, + id: "tt0000001", + name: "Zulu", + genres: ["Drama"], + releaseInfo: "1990", + imdbRating: "7", + runtime: "2h 5m", + }, + { + ...CACHED_MOVIE, + id: "tt0000002", + name: "Alpha", + genres: ["Comedy"], + releaseInfo: "2000", + imdbRating: "9", + runtime: "45m", + }, + { + ...CACHED_MOVIE, + id: "tt0000003", + name: "Beta", + genres: ["Drama", "Comedy"], + releaseInfo: "1999", + imdbRating: "8", + runtime: "1h 30m", + }, + { ...CACHED_MOVIE, id: "tt0000004", name: "Unknown", genres: ["Drama"] }, + { + ...CACHED_MOVIE, + id: "tt0000005", + type: "series", + name: "Series", + genres: ["Comedy"], + releaseInfo: "1995", + runtime: "20m", + }, + ]; + + beforeEach(() => { + seedUser(OWNER); + seedWatchlist(UUID_1, "title-desc"); + seedCache(UUID_1, titles); + }); + + async function names(extra: string) { + const response = await app.request( + `/${OWNER}/catalog/movie/wl-${UUID_1}-movie/${extra}.json`, + ); + expect(response.status).toBe(200); + return ((await response.json()) as CatalogResponse).metas.map( + (meta) => meta.name, + ); + } + + it.each([ + ["Comedy", ["Beta", "Alpha"]], + ["1990s", ["Zulu", "Beta"]], + ["Date Added (Newest)", ["Unknown", "Beta", "Alpha", "Zulu"]], + ["Date Added (Oldest)", ["Zulu", "Alpha", "Beta", "Unknown"]], + ["Title (A-Z)", ["Alpha", "Beta", "Unknown", "Zulu"]], + ["IMDb Rating (Highest)", ["Alpha", "Beta", "Zulu", "Unknown"]], + ["Release Year (Newest)", ["Alpha", "Beta", "Zulu", "Unknown"]], + ["Shortest", ["Alpha", "Beta", "Zulu", "Unknown"]], + ["Longest", ["Zulu", "Beta", "Alpha", "Unknown"]], + ["Nonexistent", []], + ])("applies %s without changing the saved sort", async (filter, expected) => { + expect(await names(`genre=${encodeURIComponent(filter)}`)).toEqual( + expected, + ); + expect(db.getTable("user_watchlists")[0].sort_option).toBe("title-desc"); + }); + + it("handles both path parameter orders and query parameters", async () => { + expect(await names("genre=Comedy&skip=1")).toEqual(["Alpha"]); + expect(await names("skip=1&genre=Comedy")).toEqual(["Alpha"]); + const response = await app.request( + `/${OWNER}/catalog/movie/wl-${UUID_1}-movie.json?genre=Comedy&skip=1`, + ); + expect( + ((await response.json()) as CatalogResponse).metas.map( + (meta) => meta.name, + ), + ).toEqual(["Alpha"]); + }); + + it("filters the full catalog before taking a page", async () => { + cache.reset(); + seedCache( + UUID_1, + Array.from({ length: 410 }, (_, i) => ({ + ...CACHED_MOVIE, + id: `tt${String(i).padStart(7, "0")}`, + name: `Movie ${i}`, + genres: [i % 2 === 0 ? "Comedy" : "Drama"], + })), + ); + const first = await names("genre=Comedy"); + const second = await names("genre=Comedy&skip=100"); + const last = await names("genre=Comedy&skip=200"); + expect([first.length, second.length, last.length]).toEqual([100, 100, 5]); + expect(new Set([...first, ...second, ...last]).size).toBe(205); + }); + + it("keeps the dropdown shuffle stable between pages", async () => { + const first = await names("genre=Shuffle"); + expect(await names("genre=Shuffle")).toEqual(first); + expect(await names("genre=Shuffle&skip=2")).toEqual(first.slice(2)); + expect(first).toHaveLength(4); + }); + it("combines saved filters with the dropdown and search", async () => { + db.getTable("user_watchlists")[0].catalog_settings = { + genre: "Comedy", + decade: 1990, + maxRuntime: 90, + minRating: 8, + }; + expect(await names("genre=IMDb%20Rating%20(Highest)")).toEqual(["Beta"]); + expect(await names("search=bet")).toEqual(["Beta"]); + expect(await names("search=alpha")).toEqual([]); + expect(await names("genre=Drama&search=BETA")).toEqual(["Beta"]); + }); + + it("searches literal encoded ampersands, plus signs and accented titles", async () => { + cache.reset(); + seedCache(UUID_1, [{ ...CACHED_MOVIE, name: "Amélie & A+B" }]); + expect(await names(`search=${encodeURIComponent("amelie & a+b")}`)).toEqual( + ["Amélie & A+B"], + ); + expect(await names("search=%20%20")).toEqual([]); + }); + + it("only serves enabled preset catalogs and shares their source list", async () => { + const presetUrl = `/${OWNER}/catalog/movie/wl-${UUID_1}-movie--short.json`; + expect( + ((await (await app.request(presetUrl)).json()) as CatalogResponse).metas, + ).toEqual([]); + db.getTable("user_watchlists")[0].catalog_settings = { + presets: ["short", "rated", "shuffle"], + minRating: 8, + }; + const response = await app.request(presetUrl); + expect( + ((await response.json()) as CatalogResponse).metas.map( + (meta) => meta.name, + ), + ).toEqual(["Beta", "Alpha"]); + const rated = await app.request( + `/${OWNER}/catalog/movie/wl-${UUID_1}-movie--rated.json`, + ); + expect( + ((await rated.json()) as CatalogResponse).metas.map((meta) => meta.name), + ).toEqual(["Alpha", "Beta"]); + }); +}); diff --git a/apps/backend/src/__tests__/helpers/mock-supabase.ts b/apps/backend/src/__tests__/helpers/mock-supabase.ts index 7381402..14d2abc 100644 Binary files a/apps/backend/src/__tests__/helpers/mock-supabase.ts and b/apps/backend/src/__tests__/helpers/mock-supabase.ts differ diff --git a/apps/backend/src/__tests__/helpers/mock-watchlist-cache.ts b/apps/backend/src/__tests__/helpers/mock-watchlist-cache.ts index a40462c..3b83819 100644 --- a/apps/backend/src/__tests__/helpers/mock-watchlist-cache.ts +++ b/apps/backend/src/__tests__/helpers/mock-watchlist-cache.ts @@ -1,4 +1,7 @@ -import type { StremioMeta, WatchlistData } from "@stremlist/shared"; +import type { + StremioMeta, + WatchlistData, +} from "@stremlist/shared/stremio.types"; interface Entry { data: WatchlistData; diff --git a/apps/backend/src/__tests__/meta-cache.test.ts b/apps/backend/src/__tests__/meta-cache.test.ts index 86992c6..474f1d3 100644 --- a/apps/backend/src/__tests__/meta-cache.test.ts +++ b/apps/backend/src/__tests__/meta-cache.test.ts @@ -1,4 +1,7 @@ -import type { StremioManifest, StremioMeta } from "@stremlist/shared"; +import type { + StremioManifest, + StremioMeta, +} from "@stremlist/shared/stremio.types"; import { describe, it, expect, beforeEach, vi } from "vitest"; import app from "../index.js"; diff --git a/apps/backend/src/__tests__/refresh.test.ts b/apps/backend/src/__tests__/refresh.test.ts index c3d6513..2ec09e7 100644 --- a/apps/backend/src/__tests__/refresh.test.ts +++ b/apps/backend/src/__tests__/refresh.test.ts @@ -1,4 +1,4 @@ -import type { StremioMeta } from "@stremlist/shared"; +import type { StremioMeta } from "@stremlist/shared/stremio.types"; import { describe, it, expect, beforeEach, vi } from "vitest"; vi.mock("../lib/supabase", async () => { diff --git a/apps/backend/src/__tests__/watchlist-crud.test.ts b/apps/backend/src/__tests__/watchlist-crud.test.ts index 391602f..3e3d8ab 100644 --- a/apps/backend/src/__tests__/watchlist-crud.test.ts +++ b/apps/backend/src/__tests__/watchlist-crud.test.ts @@ -1,9 +1,24 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import type { CatalogSettings } from "@stremlist/shared/catalog-settings"; +import type { Database, Tables } from "@stremlist/shared/database.types"; import { describe, it, expect, beforeEach, vi } from "vitest"; import app from "../index.js"; +type ReplaceConfig = Database["public"]["Functions"]["replace_user_config"]; +const rpcMocks = vi.hoisted(() => ({ + rpc: vi.fn< + ( + name: "replace_user_config", + args: ReplaceConfig["Args"], + ) => Promise<{ + data: ReplaceConfig["Returns"] | null; + error: Error | null; + }> + >(), +})); + const backgroundMocks = vi.hoisted(() => ({ scheduleBackgroundTask: vi.fn(), })); @@ -15,7 +30,8 @@ vi.mock("../lib/background", () => backgroundMocks); vi.mock("../services/watchlist-prewarm", () => prewarmMocks); vi.mock("../lib/supabase", async () => { - return await import("./helpers/mock-supabase.js"); + const { supabase } = await import("./helpers/mock-supabase.js"); + return { supabase: { ...supabase, rpc: rpcMocks.rpc } }; }); vi.mock("../services/watchlist-cache", async () => { @@ -27,6 +43,7 @@ vi.mock("../lib/resend", () => ({ })); import { db } from "./helpers/mock-supabase.js"; +import { cache } from "./helpers/mock-watchlist-cache.js"; // --------------------------------------------------------------------------- // Helpers @@ -83,6 +100,7 @@ function postConfig( catalogTitle?: string; sortOption: string; position?: number; + catalogSettings?: CatalogSettings; }[]; }, ) { @@ -100,12 +118,34 @@ function postConfig( describe("Watchlist CRUD via API", () => { beforeEach(() => { db.reset(); + rpcMocks.rpc.mockReset(); + cache.reset(); seedUser(OWNER); backgroundMocks.scheduleBackgroundTask.mockReset(); prewarmMocks.prewarmWatchlists.mockReset(); prewarmMocks.prewarmWatchlists.mockResolvedValue(undefined); }); + it.each([ + { minRating: 11 }, + { maxRuntime: -1 }, + { decade: 1995 }, + { genre: "" }, + ])("rejects invalid catalog settings %j", async (catalogSettings) => { + seedWatchlist({ id: UUID_1 }); + const response = await postConfig(OWNER, { + watchlists: [ + { + id: UUID_1, + imdbUserId: OWNER, + sortOption: "title-asc", + catalogSettings, + }, + ], + }); + expect(response.status).toBe(400); + }); + // ---- GET /:userId/config ---- describe("GET /:userId/config", () => { @@ -169,119 +209,6 @@ describe("Watchlist CRUD via API", () => { expect(res.status).toBe(404); }); - it("creates new watchlists with Supabase-generated UUIDs", async () => { - const res = await postConfig(OWNER, { - watchlists: [ - { imdbUserId: OWNER, sortOption: "added_at-desc" }, - { imdbUserId: OTHER_IMDB, sortOption: "year-asc" }, - ], - }); - - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.ok).toBe(true); - expect(data.watchlists).toHaveLength(2); - - // IDs should be generated UUIDs - for (const wl of data.watchlists) { - expect(wl.id).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, - ); - } - - 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 () => { - seedWatchlist({ id: UUID_1, sortOption: "added_at-asc" }); - - const res = await postConfig(OWNER, { - watchlists: [{ id: UUID_1, imdbUserId: OWNER, sortOption: "year-asc" }], - }); - - const data = await res.json(); - expect(data.watchlists).toHaveLength(1); - expect(data.watchlists[0].id).toBe(UUID_1); - expect(data.watchlists[0].sortOption).toBe("year-asc"); - }); - - it("preserves IDs when updating catalog title", async () => { - seedWatchlist({ id: UUID_1, catalogTitle: "Old Title" }); - - const res = await postConfig(OWNER, { - watchlists: [ - { - id: UUID_1, - imdbUserId: OWNER, - catalogTitle: "New Title", - sortOption: "added_at-asc", - }, - ], - }); - - const data = await res.json(); - expect(data.watchlists[0].id).toBe(UUID_1); - expect(data.watchlists[0].catalogTitle).toBe("New Title"); - }); - - it("deletes removed watchlists", async () => { - seedWatchlist({ id: UUID_1, position: 0 }); - seedWatchlist({ - id: UUID_2, - imdbUserId: OTHER_IMDB, - position: 1, - }); - - // Save with only the first watchlist → second should be deleted - const res = await postConfig(OWNER, { - watchlists: [ - { id: UUID_1, imdbUserId: OWNER, sortOption: "added_at-asc" }, - ], - }); - - const data = await res.json(); - expect(data.watchlists).toHaveLength(1); - expect(data.watchlists[0].id).toBe(UUID_1); - - // Verify DB state - const rows = db.getTable("user_watchlists"); - expect(rows).toHaveLength(1); - expect(rows[0].id).toBe(UUID_1); - }); - - it("can add a new watchlist alongside existing ones", async () => { - seedWatchlist({ id: UUID_1, position: 0 }); - - const res = await postConfig(OWNER, { - watchlists: [ - { id: UUID_1, imdbUserId: OWNER, sortOption: "added_at-asc" }, - { imdbUserId: OTHER_IMDB, sortOption: "year-desc" }, - ], - }); - - const data = await res.json(); - expect(data.watchlists).toHaveLength(2); - expect(data.watchlists[0].id).toBe(UUID_1); - - // New watchlist gets a generated UUID - const newId = data.watchlists[1].id; - expect(newId).toBeDefined(); - expect(newId).not.toBe(UUID_1); - expect(data.watchlists[1].imdbUserId).toBe(OTHER_IMDB); - }); - it("rejects duplicate IMDb user IDs", async () => { const res = await postConfig(OWNER, { watchlists: [ @@ -325,109 +252,193 @@ describe("Watchlist CRUD via API", () => { }); }); - // ---- The exact bug scenario ---- - - describe("ID stability across saves (regression)", () => { - it("IDs returned from first save are stable on subsequent saves", async () => { - // Step 1: create two new watchlists (no IDs) - const res1 = await postConfig(OWNER, { - watchlists: [ - { imdbUserId: OWNER, sortOption: "added_at-asc" }, - { imdbUserId: OTHER_IMDB, sortOption: "added_at-asc" }, - ], + describe("configuration RPC boundary", () => { + const savedRow: Tables<"user_watchlists"> = { + id: UUID_1, + owner_user_id: OWNER, + imdb_user_id: OWNER, + catalog_title: "Saved title", + sort_option: "rating-desc", + display_mode: "split", + position: 0, + catalog_settings: { minRating: 8 }, + created_at: "2026-09-15T00:00:00Z", + updated_at: "2026-09-15T00:00:00Z", + }; + + beforeEach(() => { + rpcMocks.rpc.mockResolvedValue({ + data: [{ deleted_ids: [], watchlists: [savedRow] }], + error: null, }); + }); - const data1 = await res1.json(); - expect(data1.watchlists).toHaveLength(2); - const id1 = data1.watchlists[0].id; - const id2 = data1.watchlists[1].id; - - // Step 2: re-save with IDs from step 1, changing sort order - const res2 = await postConfig(OWNER, { + it("sends normalized rows in one RPC and returns/prewarms the committed rows", async () => { + const response = await postConfig(OWNER, { + rpdbApiKey: " secret-key ", watchlists: [ - { id: id1, imdbUserId: OWNER, sortOption: "year-asc" }, - { id: id2, imdbUserId: OTHER_IMDB, sortOption: "year-asc" }, + { + id: UUID_1, + imdbUserId: OWNER, + catalogTitle: "Updated", + sortOption: "title-asc", + position: 7, + }, + { imdbUserId: OTHER_IMDB, sortOption: "year-desc" }, ], }); + expect(response.status).toBe(200); + expect(rpcMocks.rpc).toHaveBeenCalledExactlyOnceWith( + "replace_user_config", + { + p_owner_user_id: OWNER, + p_rpdb_api_key: "secret-key", + p_watchlists: [ + { + id: UUID_1, + imdb_user_id: OWNER, + catalog_title: "Updated", + sort_option: "title-asc", + display_mode: "split", + position: 0, + }, + { + imdb_user_id: OTHER_IMDB, + catalog_title: "2", + sort_option: "year-desc", + display_mode: "split", + position: 1, + }, + ], + }, + ); + const expected = [ + { + id: UUID_1, + imdbUserId: OWNER, + catalogTitle: "Saved title", + sortOption: "rating-desc", + displayMode: "split", + position: 0, + catalogSettings: { minRating: 8 }, + }, + ]; + expect(await response.json()).toEqual({ + ok: true, + watchlists: expected.map((row) => ({ ...row, availableGenres: [] })), + }); + expect(backgroundMocks.scheduleBackgroundTask).toHaveBeenCalledOnce(); + const task = backgroundMocks.scheduleBackgroundTask.mock + .calls[0][0] as () => Promise; + await task(); + expect(prewarmMocks.prewarmWatchlists).toHaveBeenCalledExactlyOnceWith( + OWNER, + expected, + ); + }); - const data2 = await res2.json(); - expect(data2.watchlists[0].id).toBe(id1); - expect(data2.watchlists[1].id).toBe(id2); - expect(data2.watchlists[0].sortOption).toBe("year-asc"); - expect(data2.watchlists[1].sortOption).toBe("year-asc"); - - // Step 3: save a third time — IDs should still be the same - const res3 = await postConfig(OWNER, { + it("keeps omitted settings distinct from an explicit clear in the RPC payload", async () => { + await postConfig(OWNER, { watchlists: [ - { id: id1, imdbUserId: OWNER, sortOption: "rating-desc" }, - { id: id2, imdbUserId: OTHER_IMDB, sortOption: "rating-desc" }, + { id: UUID_1, imdbUserId: OWNER, sortOption: "title-asc" }, + { + id: UUID_2, + imdbUserId: OTHER_IMDB, + sortOption: "title-desc", + catalogSettings: {}, + }, ], }); - - const data3 = await res3.json(); - expect(data3.watchlists[0].id).toBe(id1); - expect(data3.watchlists[1].id).toBe(id2); + expect(rpcMocks.rpc.mock.calls[0][1].p_watchlists).toEqual([ + { + id: UUID_1, + imdb_user_id: OWNER, + catalog_title: "1", + sort_option: "title-asc", + display_mode: "split", + position: 0, + }, + { + id: UUID_2, + imdb_user_id: OTHER_IMDB, + catalog_title: "2", + sort_option: "title-desc", + display_mode: "split", + position: 1, + catalog_settings: {}, + }, + ]); }); - it("saving without IDs replaces all watchlists with fresh ones", async () => { - // Create initial watchlists - const res1 = await postConfig(OWNER, { + it("passes combined filters, custom values and presets through unchanged", async () => { + const catalogSettings: CatalogSettings = { + genre: "Comedy", + decade: 2090, + maxRuntime: 95, + minRating: 7.2, + presets: ["short", "rated", "shuffle"], + }; + const response = await postConfig(OWNER, { watchlists: [ - { imdbUserId: OWNER, sortOption: "added_at-asc" }, - { imdbUserId: OTHER_IMDB, sortOption: "added_at-asc" }, + { imdbUserId: OWNER, sortOption: "title-asc", catalogSettings }, ], }); - const data1 = await res1.json(); - const oldId1 = data1.watchlists[0].id; - const oldId2 = data1.watchlists[1].id; + expect(response.status).toBe(200); + expect(rpcMocks.rpc.mock.calls[0][1].p_watchlists).toEqual([ + { + imdb_user_id: OWNER, + catalog_title: "", + sort_option: "title-asc", + display_mode: "split", + position: 0, + catalog_settings: catalogSettings, + }, + ]); + }); - // Re-save WITHOUT IDs → should create new rows, delete old ones - const res2 = await postConfig(OWNER, { + it.each([undefined, "", " "])( + "normalizes an empty RPDB key (%j) to null", + async (rpdbApiKey) => { + await postConfig(OWNER, { + rpdbApiKey, + watchlists: [{ imdbUserId: OWNER, sortOption: "added_at-asc" }], + }); + expect(rpcMocks.rpc.mock.calls[0][1].p_rpdb_api_key).toBeNull(); + }, + ); + + it("deletes only the cache IDs returned by the committed transaction", async () => { + cache.seed(UUID_1, []); + cache.seed(UUID_2, []); + rpcMocks.rpc.mockResolvedValueOnce({ + data: [{ deleted_ids: [UUID_2], watchlists: [savedRow] }], + error: null, + }); + const response = await postConfig(OWNER, { watchlists: [ - { imdbUserId: OWNER, sortOption: "year-asc" }, - { imdbUserId: OTHER_IMDB, sortOption: "year-asc" }, + { id: UUID_1, imdbUserId: OWNER, sortOption: "title-asc" }, ], }); - - const data2 = await res2.json(); - expect(data2.watchlists).toHaveLength(2); - expect(data2.watchlists[0].id).not.toBe(oldId1); - expect(data2.watchlists[1].id).not.toBe(oldId2); - - // DB should only have 2 rows (old ones deleted) - const rows = db.getTable("user_watchlists"); - expect(rows).toHaveLength(2); + expect(response.status).toBe(200); + expect(cache.get(UUID_1)).not.toBeNull(); + expect(cache.get(UUID_2)).toBeNull(); }); - }); - - // ---- RPDB API key ---- - describe("RPDB API key", () => { - it("saves and returns RPDB API key", async () => { - const res = await postConfig(OWNER, { - rpdbApiKey: "test-rpdb-key", - watchlists: [{ imdbUserId: OWNER, sortOption: "added_at-asc" }], + it("keeps caches and skips prewarming when the RPC fails", async () => { + cache.seed(UUID_2, []); + rpcMocks.rpc.mockResolvedValueOnce({ + data: null, + error: new Error("Transaction rolled back"), }); - expect(res.status).toBe(200); - - const config = await getConfig(OWNER); - const json = await config.json(); - expect(json.rpdbApiKey).toBe("test-rpdb-key"); - }); - - it("clears RPDB API key when empty string is sent", async () => { - // Set a key first - const users = db.getTable("users"); - users[0].rpdb_api_key = "existing-key"; - - await postConfig(OWNER, { - rpdbApiKey: "", - watchlists: [{ imdbUserId: OWNER, sortOption: "added_at-asc" }], + const response = await postConfig(OWNER, { + rpdbApiKey: "new-key", + watchlists: [ + { id: UUID_1, imdbUserId: OWNER, sortOption: "title-asc" }, + ], }); - - const config = await getConfig(OWNER); - const json = await config.json(); - expect(json.rpdbApiKey).toBeNull(); + expect(response.status).toBe(500); + expect(cache.get(UUID_2)).not.toBeNull(); + expect(backgroundMocks.scheduleBackgroundTask).not.toHaveBeenCalled(); }); }); }); diff --git a/apps/backend/src/dev.ts b/apps/backend/src/dev.ts index 32bfa65..f8b695d 100644 --- a/apps/backend/src/dev.ts +++ b/apps/backend/src/dev.ts @@ -3,6 +3,6 @@ import app from "./index.js"; const port = parseInt(process.env.PORT ?? "7001", 10); -serve({ fetch: app.fetch, port }, (info) => { +serve({ fetch: app.fetch, port, hostname: process.env.HOST }, (info) => { console.log(`Stremlist backend running on http://localhost:${info.port}`); }); diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 9bb2c63..ba02d17 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -46,4 +46,3 @@ app.get("/health", async (c) => { }); export default app; -export type { ApiRoutes } from "./routes/api"; diff --git a/apps/backend/src/lib/supabase.ts b/apps/backend/src/lib/supabase.ts index 9918dd5..887485d 100644 --- a/apps/backend/src/lib/supabase.ts +++ b/apps/backend/src/lib/supabase.ts @@ -1,4 +1,4 @@ -import type { Database } from "@stremlist/shared"; +import type { Database } from "@stremlist/shared/database.types"; import { createClient } from "@supabase/supabase-js"; const supabaseUrl = process.env.SUPABASE_URL; diff --git a/apps/backend/src/routes/api.ts b/apps/backend/src/routes/api.ts index 36e67b1..9f618a6 100644 --- a/apps/backend/src/routes/api.ts +++ b/apps/backend/src/routes/api.ts @@ -4,14 +4,17 @@ import { IMDB_LIST_ID_PATTERN, IMDB_USER_ID_PATTERN, IMDB_WATCHLIST_SOURCE_ID_PATTERN, - isChartId, SORT_OPTIONS, -} from "@stremlist/shared"; + parseSortOption, +} from "@stremlist/shared/constants"; +import { isChartId } from "@stremlist/shared/imdb-charts"; import { Hono } from "hono"; import { z } from "zod"; import { scheduleBackgroundTask } from "../lib/background"; import { resend } from "../lib/resend"; import { supabase } from "../lib/supabase"; +import { withAvailableGenres } from "../services/catalog-genres"; +import { catalogSettingsSchema } from "../services/catalog-settings"; import { getImdbWatchlist, normalizeImdbUserId, @@ -23,7 +26,6 @@ import { getUser, getUserRpdbApiKey, replaceUserWatchlists, - setUserRpdbApiKey, } from "../services/user"; import { getWatchlistByConfig } from "../services/watchlist"; import { prewarmWatchlists } from "../services/watchlist-prewarm"; @@ -58,6 +60,7 @@ const configWatchlistBody = z.object({ sortOption: z.enum(sortOptionValues), displayMode: z.enum(displayModeValues).optional(), position: z.number().int().min(0).optional(), + catalogSettings: catalogSettingsSchema.optional(), }); const configBody = z.object({ rpdbApiKey: z.string().trim().optional(), @@ -108,7 +111,9 @@ const api = new Hono() return c.json({ error: "User not found. Install the addon first." }, 404); } const rpdbApiKey = await getUserRpdbApiKey(userId); - const watchlists = await getUserWatchlists(userId); + const watchlists = await withAvailableGenres( + await getUserWatchlists(userId), + ); return c.json({ rpdbApiKey, watchlists, @@ -171,6 +176,7 @@ const api = new Hono() sortOption: watchlist.sortOption, displayMode: watchlist.displayMode ?? "split", position: index, + catalogSettings: watchlist.catalogSettings, }; }); @@ -187,10 +193,22 @@ const api = new Hono() const normalizedRpdbApiKey = rpdbApiKey && rpdbApiKey.length > 0 ? rpdbApiKey : null; - const [updatedWatchlists] = await Promise.all([ - replaceUserWatchlists(userId, normalizedWatchlists), - setUserRpdbApiKey(userId, normalizedRpdbApiKey), - ]); + let updatedWatchlists; + try { + updatedWatchlists = await replaceUserWatchlists( + userId, + normalizedWatchlists, + normalizedRpdbApiKey, + ); + } catch (error) { + console.error("Failed to save user configuration:", error); + return c.json( + { + error: "Failed to save your configuration. Please try again later.", + }, + 500, + ); + } // A fresh installation already has a seeded watchlist ID, so an // "ID-less rows only" check would miss its first scrape. Queue every @@ -199,7 +217,10 @@ const api = new Hono() prewarmWatchlists(userId, updatedWatchlists), ); - return c.json({ ok: true, watchlists: updatedWatchlists }); + return c.json({ + ok: true, + watchlists: await withAvailableGenres(updatedWatchlists), + }); }, ) @@ -240,7 +261,7 @@ const api = new Hono() ownerUserId: userId, watchlistId: w.id, imdbUserId: w.imdbUserId, - sortOption: w.sortOption, + sort: parseSortOption(w.sortOption), rpdbApiKey, forceFresh: true, skipUserTimestamp: true, @@ -267,6 +288,7 @@ const api = new Hono() refreshed, failed, total: watchlists.length, + watchlists: await withAvailableGenres(watchlists), cooldownSeconds: REFRESH_COOLDOWN_MS / 1000, }); }) diff --git a/apps/backend/src/routes/catalog.ts b/apps/backend/src/routes/catalog.ts index 07cf9a3..1e25341 100644 --- a/apps/backend/src/routes/catalog.ts +++ b/apps/backend/src/routes/catalog.ts @@ -1,6 +1,13 @@ -import type { ConfigWatchlist, StremioMeta } from "@stremlist/shared"; +import type { + ConfigWatchlist, + StremioMeta, +} from "@stremlist/shared/stremio.types"; import { Hono } from "hono"; import type { Context } from "hono"; +import { + resolveCatalogSelection, + filterCatalog, +} from "../services/catalog-filters"; import { parseCatalogId } from "../services/catalog-id"; import { getUserRpdbApiKey, getUserWatchlistById } from "../services/user"; import { @@ -42,12 +49,22 @@ function buildUnavailableMeta( }; } -function parseSkip(c: Context): number | null { - const extra = routeParam(c, "extra"); - const value = extra - ? new URLSearchParams(extra.replace(/\.json$/u, "")).get("skip") - : c.req.query("skip"); - if (value === undefined || value === null || value === "") return 0; +function catalogExtra(c: Context): URLSearchParams { + const url = new URL(c.req.url); + // Hono decodes route params. Read the raw segment to keep encoded & and + + // inside search terms instead of treating them as parameter separators. + return routeParam(c, "extra") + ? new URLSearchParams( + url.pathname + .slice(url.pathname.lastIndexOf("/") + 1) + .replace(/\.json$/u, ""), + ) + : new URLSearchParams(url.search); +} + +function parseSkip(extra: URLSearchParams): number | null { + const value = extra.get("skip"); + if (value === null || value === "") return 0; const skip = Number(value); return Number.isSafeInteger(skip) && skip >= 0 ? skip : null; @@ -81,7 +98,9 @@ async function serveCatalog(c: Context) { return c.json({ metas: [] }); } - const skip = parseSkip(c); + const extra = catalogExtra(c); + const filter = extra.get("genre"); + const skip = parseSkip(extra); if (skip === null) { return c.json({ metas: [] }, 400); } @@ -106,18 +125,30 @@ async function serveCatalog(c: Context) { return c.json({ metas: [] }); } + const preset = parsedCatalog.preset; + if (preset && !watchlistConfig.catalogSettings?.presets?.includes(preset)) { + return c.json({ metas: [] }); + } + const selection = resolveCatalogSelection( + watchlistConfig.sortOption, + watchlistConfig.catalogSettings, + filter, + preset, + ); const rpdbApiKey = await getUserRpdbApiKey(userId); const watchlistData = await getWatchlistByConfig({ ownerUserId: userId, watchlistId: watchlistConfig.id, imdbUserId: watchlistConfig.imdbUserId, - sortOption: watchlistConfig.sortOption, + sort: selection.sort, rpdbApiKey, }); - const matchingMetas = watchlistData.metas.filter( - (item) => item.type === requestedType, + const matchingMetas = filterCatalog( + watchlistData.metas.filter((item) => item.type === requestedType), + selection.filters, + extra.get("search"), ); const metas = matchingMetas.slice(skip, skip + CATALOG_PAGE_SIZE); @@ -138,6 +169,10 @@ async function serveCatalog(c: Context) { console.warn( `Catalog unavailable for ${userId} (${err.reason}): ${requestedType}/${catalogId}`, ); + // Informational cards explain empty catalogs, but aren't search matches. + if (catalogExtra(c).has("search")) { + return c.json({ metas: [] }); + } return c.json({ metas: [ buildUnavailableMeta(err.reason, requestedType as "movie" | "series"), diff --git a/apps/backend/src/routes/manifest.ts b/apps/backend/src/routes/manifest.ts index 9b11db0..209cee8 100644 --- a/apps/backend/src/routes/manifest.ts +++ b/apps/backend/src/routes/manifest.ts @@ -2,9 +2,10 @@ import { BASE_MANIFEST, ADDON_VERSION, IMDB_USER_ID_PATTERN, -} from "@stremlist/shared"; -import type { StremioManifest } from "@stremlist/shared"; +} from "@stremlist/shared/constants"; +import type { StremioManifest } from "@stremlist/shared/stremio.types"; import { Hono } from "hono"; +import { withAvailableGenres } from "../services/catalog-genres"; import { buildManifestCatalogs } from "../services/stremio-catalogs"; import { ensureUser, @@ -50,7 +51,9 @@ manifest.get("/:userId/manifest.json", async (c) => { try { await ensureUser(userId); const savedRpdbApiKey = await getUserRpdbApiKey(userId); - const watchlists = await getUserWatchlists(userId); + const watchlists = await withAvailableGenres( + await getUserWatchlists(userId), + ); const userManifest: StremioManifest = { ...structuredClone(BASE_MANIFEST), diff --git a/apps/backend/src/scripts/cleanup-invalid-users.ts b/apps/backend/src/scripts/cleanup-invalid-users.ts index e5afb8d..b1da039 100644 --- a/apps/backend/src/scripts/cleanup-invalid-users.ts +++ b/apps/backend/src/scripts/cleanup-invalid-users.ts @@ -1,5 +1,5 @@ -import { IMDB_USER_ID_PATTERN } from "@stremlist/shared"; -import type { Database } from "@stremlist/shared"; +import { IMDB_USER_ID_PATTERN } from "@stremlist/shared/constants"; +import type { Database } from "@stremlist/shared/database.types"; import { createClient } from "@supabase/supabase-js"; import { config } from "dotenv"; import { readFileSync, writeFileSync } from "fs"; diff --git a/apps/backend/src/services/__tests__/catalog-filters.test.ts b/apps/backend/src/services/__tests__/catalog-filters.test.ts new file mode 100644 index 0000000..84cf592 --- /dev/null +++ b/apps/backend/src/services/__tests__/catalog-filters.test.ts @@ -0,0 +1,108 @@ +import type { StremioMeta } from "@stremlist/shared/stremio.types"; +import { describe, expect, it } from "vitest"; +import { filterCatalog, resolveCatalogSelection } from "../catalog-filters"; + +import { sortWatchlist } from "../watchlist-sort"; + +const base: StremioMeta = { + id: "tt1", + name: "Film", + type: "movie", + poster: null, + posterShape: "poster", + genres: [], + description: "", +}; +const metas: StremioMeta[] = [ + { + ...base, + id: "tt1", + runtime: "1h 30m", + imdbRating: "8", + releaseInfo: "2000", + released: "2000-10-01T00:00:00.000Z", + }, + { + ...base, + id: "tt2", + runtime: "2h 0m", + imdbRating: "7", + releaseInfo: "2000", + released: "2000-01-01T00:00:00.000Z", + }, + { ...base, id: "tt3", runtime: "2h 1m", imdbRating: "6" }, + { ...base, id: "tt4" }, +]; + +describe("catalog selection", () => { + it.each([ + ["90 min or less", ["tt1"]], + ["120 min or less", ["tt1", "tt2"]], + ["IMDb Rating 7+", ["tt1", "tt2"]], + ["IMDb Rating 8+", ["tt1"]], + ["Release Date (Newest)", ["tt1", "tt2", "tt3", "tt4"]], + ["Release Date (Oldest)", ["tt2", "tt1", "tt3", "tt4"]], + ])("applies %s including bounds and missing data", (filter, ids) => { + const selection = resolveCatalogSelection("added_at-asc", {}, filter); + expect( + filterCatalog( + sortWatchlist(metas, selection.sort, "test"), + selection.filters, + ).map((meta) => meta.id), + ).toEqual(ids); + expect(metas.map((meta) => meta.id)).toEqual(["tt1", "tt2", "tt3", "tt4"]); + }); +}); + +it.each([ + "Shortest", + "Longest", + "Release Date (Newest)", + "Release Date (Oldest)", +])( + "uses saved/preset ordering for %s ties, including missing values", + (filter) => { + const ties = metas.map((meta) => ({ + ...meta, + runtime: undefined, + released: undefined, + })); + for (const preset of [undefined, "rated", "shuffle"] as const) { + const fallback = resolveCatalogSelection( + "added_at-desc", + {}, + null, + preset, + ); + const selected = resolveCatalogSelection( + "added_at-desc", + {}, + filter, + preset, + ); + expect(sortWatchlist(ties, selected.sort, "generation")).toEqual( + sortWatchlist(ties, fallback.sort, "generation"), + ); + } + }, +); + +it("intersects saved and dropdown constraints rather than overwriting either", () => { + const both = [ + { ...metas[0], genres: ["Drama", "Comedy"] }, + { ...metas[1], genres: ["Drama"] }, + ]; + for (const [filter, settings, ids] of [ + ["Comedy", { genre: "Drama" }, ["tt1"]], + ["1990s", { decade: 2000 }, []], + ["120 min or less", { maxRuntime: 90 }, ["tt1"]], + ["90 min or less", { maxRuntime: 120 }, ["tt1"]], + ["IMDb Rating 7+", { minRating: 8 }, ["tt1"]], + ["IMDb Rating 8+", { minRating: 7 }, ["tt1"]], + ] as const) { + const selection = resolveCatalogSelection("added_at-asc", settings, filter); + expect( + filterCatalog(both, selection.filters).map((meta) => meta.id), + ).toEqual(ids); + } +}); diff --git a/apps/backend/src/services/__tests__/catalog-genres.test.ts b/apps/backend/src/services/__tests__/catalog-genres.test.ts new file mode 100644 index 0000000..af6b4be --- /dev/null +++ b/apps/backend/src/services/__tests__/catalog-genres.test.ts @@ -0,0 +1,100 @@ +import type { + ConfigWatchlist, + StremioMeta, +} from "@stremlist/shared/stremio.types"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock( + "../watchlist-cache", + async () => import("../../__tests__/helpers/mock-watchlist-cache"), +); + +import { cache } from "../../__tests__/helpers/mock-watchlist-cache"; +import { filterCatalog, resolveCatalogSelection } from "../catalog-filters"; +import { withAvailableGenres } from "../catalog-genres"; +import { catalogSettingsSchema } from "../catalog-settings"; +import { buildManifestCatalogs } from "../stremio-catalogs"; + +const watchlist: ConfigWatchlist = { + id: "77e10eda-0e07-4c60-8ec7-23fb1b1d0573", + imdbUserId: "ur12345678", + catalogTitle: "Picks", + sortOption: "added_at-asc", + displayMode: "split", + position: 0, +}; +const movie: StremioMeta = { + id: "tt1234567", + name: "Example", + type: "movie", + poster: null, + posterShape: "poster", + description: "", + genres: ["Drama", "New Genre", "Drama", ""], +}; + +beforeEach(() => { + cache.reset(); +}); + +describe("genres from cached IMDb titles", () => { + it("deduplicates and sorts genres, exposes them in manifests, and accepts new genres as filters", async () => { + cache.seed(watchlist.id, [ + movie, + { ...movie, id: "tt7654321", type: "series", genres: ["Comedy"] }, + ]); + const rows = await withAvailableGenres([watchlist]); + expect(rows[0].availableGenres).toEqual(["Comedy", "Drama", "New Genre"]); + for (const catalog of buildManifestCatalogs(rows)) { + expect( + catalog.extra?.find((extra) => extra.name === "genre")?.options, + ).toEqual(expect.arrayContaining(["Comedy", "Drama", "New Genre"])); + } + const settings = catalogSettingsSchema.parse({ genre: "New Genre" }); + const selection = resolveCatalogSelection( + watchlist.sortOption, + settings, + "New Genre", + ); + expect(filterCatalog([movie], selection.filters)).toEqual([movie]); + }); + + it("limits choices to the watchlist's display mode", async () => { + cache.seed(watchlist.id, [ + movie, + { ...movie, id: "tt7654321", type: "series", genres: ["Comedy"] }, + ]); + const rows = await withAvailableGenres([ + { ...watchlist, displayMode: "series" }, + ]); + expect(rows[0].availableGenres).toEqual(["Comedy"]); + }); + + it("preserves saved genre selections without a cache and keeps lists independent", async () => { + cache.seed(watchlist.id, [movie]); + const rows = await withAvailableGenres([ + watchlist, + { + ...watchlist, + id: "uncached", + catalogSettings: { genre: "Western", presets: ["short"] }, + }, + ]); + expect(rows[1].availableGenres).toEqual([]); + expect(rows[1].catalogSettings?.genre).toBe("Western"); + for (const catalog of buildManifestCatalogs([rows[1]])) { + const options = catalog.extra?.find( + (extra) => extra.name === "genre", + )?.options; + expect(options).toContain("Western"); + expect(options).not.toContain("Drama"); + } + }); + + it.each(["", " ", "a".repeat(101)])( + "rejects invalid genre strings", + (genre) => { + expect(catalogSettingsSchema.safeParse({ genre }).success).toBe(false); + }, + ); +}); diff --git a/apps/backend/src/services/__tests__/catalog-routing.test.ts b/apps/backend/src/services/__tests__/catalog-routing.test.ts index 53d9163..4fdb7d6 100644 --- a/apps/backend/src/services/__tests__/catalog-routing.test.ts +++ b/apps/backend/src/services/__tests__/catalog-routing.test.ts @@ -33,6 +33,7 @@ describe("manifest catalog generation", () => { id: "77e10eda-0e07-4c60-8ec7-23fb1b1d0573", imdbUserId: "ur12345678", catalogTitle: "Leo Picks", + availableGenres: ["Comedy"], sortOption: "added_at-asc", displayMode: "split", position: 0, @@ -41,6 +42,7 @@ describe("manifest catalog generation", () => { id: "3be4e39f-3e27-42e7-a69f-c14f0709de52", imdbUserId: "ur87654321", catalogTitle: "Family Queue", + availableGenres: ["Comedy"], sortOption: "title-asc", displayMode: "split", position: 1, @@ -50,6 +52,19 @@ describe("manifest catalog generation", () => { expect( catalogs.every((catalog) => catalog.extra?.[0]?.name === "skip"), ).toBe(true); + for (const catalog of catalogs) { + const genre = catalog.extra?.find((extra) => extra.name === "genre"); + expect(genre?.isRequired).toBe(false); + expect(genre?.optionsLimit).toBe(1); + expect(genre?.options).toEqual( + expect.arrayContaining([ + "1990s", + "Shuffle", + "Shortest", + "IMDb Rating (Highest)", + ]), + ); + } expect(catalogsWithoutExtra(catalogs)).toEqual([ { id: "wl-77e10eda-0e07-4c60-8ec7-23fb1b1d0573-movie", @@ -74,6 +89,42 @@ describe("manifest catalog generation", () => { ]); }); + it("exposes enabled presets on the home and keeps search on the base catalog", () => { + const catalogs = buildManifestCatalogs([ + { + id: "77e10eda-0e07-4c60-8ec7-23fb1b1d0573", + imdbUserId: "ur12345678", + catalogTitle: "Picks", + sortOption: "title-asc", + displayMode: "movie", + position: 0, + catalogSettings: { presets: ["short", "rated", "shuffle"] }, + }, + ]); + expect(catalogs.map((catalog) => catalog.id)).toEqual([ + "wl-77e10eda-0e07-4c60-8ec7-23fb1b1d0573-movie", + "wl-77e10eda-0e07-4c60-8ec7-23fb1b1d0573-movie--short", + "wl-77e10eda-0e07-4c60-8ec7-23fb1b1d0573-movie--rated", + "wl-77e10eda-0e07-4c60-8ec7-23fb1b1d0573-movie--shuffle", + ]); + expect(catalogs[0].extra?.some((extra) => extra.name === "search")).toBe( + true, + ); + expect( + catalogs + .slice(1) + .every( + (catalog) => !catalog.extra?.some((extra) => extra.name === "search"), + ), + ).toBe(true); + expect(parseCatalogId(catalogs[1].id)).toEqual({ + watchlistId: "77e10eda-0e07-4c60-8ec7-23fb1b1d0573", + type: "movie", + preset: "short", + }); + expect(parseCatalogId(`${catalogs[0].id}--unknown`)).toBeNull(); + }); + it("uses base Stremlist title when catalog title is empty", () => { const catalogs = buildManifestCatalogs([ { diff --git a/apps/backend/src/services/__tests__/imdb-charts.test.ts b/apps/backend/src/services/__tests__/imdb-charts.test.ts index 1f555aa..597170c 100644 --- a/apps/backend/src/services/__tests__/imdb-charts.test.ts +++ b/apps/backend/src/services/__tests__/imdb-charts.test.ts @@ -1,8 +1,5 @@ -import { - CHART_REGISTRY, - isChartId, - IMDB_WATCHLIST_SOURCE_ID_PATTERN, -} from "@stremlist/shared"; +import { IMDB_WATCHLIST_SOURCE_ID_PATTERN } from "@stremlist/shared/constants"; +import { CHART_REGISTRY, isChartId } from "@stremlist/shared/imdb-charts"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { fetchChart, normalizeImdbUserId } from "../imdb-scraper.js"; diff --git a/apps/backend/src/services/__tests__/imdb-scraper.test.ts b/apps/backend/src/services/__tests__/imdb-scraper.test.ts index 0a07e85..6233f34 100644 --- a/apps/backend/src/services/__tests__/imdb-scraper.test.ts +++ b/apps/backend/src/services/__tests__/imdb-scraper.test.ts @@ -455,6 +455,31 @@ describe("fetchWatchlist (unit)", () => { vi.restoreAllMocks(); }); + it("keeps complete release dates without inventing partial or invalid dates", async () => { + const dates = [ + { year: 2000, month: 2, day: 29 }, + { year: 2000 }, + { year: 2001, month: 2, day: 29 }, + ]; + const edges = dates.map((releaseDate, index) => { + const edge = makeEdge({ id: `tt000000${index}` }); + return { listItem: { ...edge.listItem, releaseDate } }; + }); + vi.mocked(globalThis.fetch).mockResolvedValueOnce( + mockGraphQLResponse({ + id: "ls123", + visibility: { id: "PUBLIC" }, + titleListItemSearch: { total: 3, edges }, + }), + ); + const { metas } = await fetchWatchlist("ur195879360"); + expect(metas.map((meta) => meta.released)).toEqual([ + "2000-02-29T00:00:00.000Z", + undefined, + undefined, + ]); + }); + it("returns metas for a public watchlist", async () => { const edges = [ makeEdge({ @@ -496,28 +521,6 @@ describe("fetchWatchlist (unit)", () => { expect(aot?.type).toBe("series"); }); - it("uses RPDB poster URLs when an RPDB API key is provided", async () => { - const edges = [makeEdge({ id: "tt0068646", title: "The Godfather" })]; - vi.mocked(globalThis.fetch).mockResolvedValueOnce( - mockGraphQLResponse({ - id: "ls123", - visibility: { id: "PUBLIC" }, - titleListItemSearch: { total: 1, edges }, - }), - ); - - const result = await fetchWatchlist( - "ur195879360", - { by: "added_at", order: "asc" }, - "my-rpdb-key", - ); - - expect(result.metas).toHaveLength(1); - expect(result.metas[0].poster).toBe( - "https://api.ratingposterdb.com/my-rpdb-key/imdb/poster-default/tt0068646.jpg?fallback=true", - ); - }); - it("filters out non-movie/series types (e.g. TV Episode)", async () => { const edges = [ makeEdge({ id: "tt0000001", type: "Movie" }), @@ -584,74 +587,21 @@ describe("fetchWatchlist (unit)", () => { } }); - it("sorts by title ascending", async () => { + it("preserves IMDb list order and original posters", async () => { const edges = [ makeEdge({ id: "tt0000003", title: "Zulu" }), makeEdge({ id: "tt0000001", title: "Alpha" }), - makeEdge({ id: "tt0000002", title: "Mango" }), - ]; - vi.mocked(globalThis.fetch).mockResolvedValueOnce( - mockGraphQLResponse({ - id: "ls123", - visibility: { id: "PUBLIC" }, - titleListItemSearch: { total: 3, edges }, - }), - ); - - const result = await fetchWatchlist("ur195879360", { - by: "title", - order: "asc", - }); - - expect(result.metas.map((m) => m.name)).toEqual(["Alpha", "Mango", "Zulu"]); - }); - - it("sorts by rating descending", async () => { - const edges = [ - makeEdge({ id: "tt0000001", title: "Low", rating: 5.0 }), - makeEdge({ id: "tt0000002", title: "High", rating: 9.5 }), - makeEdge({ id: "tt0000003", title: "Mid", rating: 7.0 }), - ]; - vi.mocked(globalThis.fetch).mockResolvedValueOnce( - mockGraphQLResponse({ - id: "ls123", - visibility: { id: "PUBLIC" }, - titleListItemSearch: { total: 3, edges }, - }), - ); - - const result = await fetchWatchlist("ur195879360", { - by: "rating", - order: "desc", - }); - - expect(result.metas.map((m) => m.imdbRating)).toEqual(["9.5", "7", "5"]); - }); - - it("sorts by year ascending", async () => { - const edges = [ - makeEdge({ id: "tt0000001", title: "New", year: 2020 }), - makeEdge({ id: "tt0000002", title: "Old", year: 1990 }), - makeEdge({ id: "tt0000003", title: "Mid", year: 2005 }), ]; vi.mocked(globalThis.fetch).mockResolvedValueOnce( mockGraphQLResponse({ id: "ls123", visibility: { id: "PUBLIC" }, - titleListItemSearch: { total: 3, edges }, + titleListItemSearch: { total: 2, edges }, }), ); - - const result = await fetchWatchlist("ur195879360", { - by: "year", - order: "asc", - }); - - expect(result.metas.map((m) => m.releaseInfo)).toEqual([ - "1990", - "2005", - "2020", - ]); + const { metas } = await fetchWatchlist("ur195879360"); + expect(metas.map((meta) => meta.name)).toEqual(["Zulu", "Alpha"]); + expect(metas[0].poster).toBe(edges[0].listItem.primaryImage.url); }); it("returns empty metas when all items are filtered out", async () => { diff --git a/apps/backend/src/services/__tests__/watchlist-cache.test.ts b/apps/backend/src/services/__tests__/watchlist-cache.test.ts index adac16c..c412af3 100644 --- a/apps/backend/src/services/__tests__/watchlist-cache.test.ts +++ b/apps/backend/src/services/__tests__/watchlist-cache.test.ts @@ -3,7 +3,7 @@ import { GetObjectCommand, PutObjectCommand, } from "@aws-sdk/client-s3"; -import type { StremioMeta } from "@stremlist/shared"; +import type { StremioMeta } from "@stremlist/shared/stremio.types"; import { beforeEach, describe, expect, it, vi } from "vitest"; function requiredKey(key: string | undefined): string { diff --git a/apps/backend/src/services/__tests__/watchlist-fetch.test.ts b/apps/backend/src/services/__tests__/watchlist-fetch.test.ts index 955e72c..b1db180 100644 --- a/apps/backend/src/services/__tests__/watchlist-fetch.test.ts +++ b/apps/backend/src/services/__tests__/watchlist-fetch.test.ts @@ -1,4 +1,7 @@ -import type { StremioMeta, WatchlistData } from "@stremlist/shared"; +import type { + StremioMeta, + WatchlistData, +} from "@stremlist/shared/stremio.types"; import { beforeEach, describe, expect, it, vi } from "vitest"; const scraperMocks = vi.hoisted(() => ({ @@ -26,6 +29,7 @@ vi.mock("../user", () => ({ })); vi.mock("../watchlist-cache", () => cacheMocks); +import type { WatchlistFetchConfig } from "../watchlist"; import { getWatchlistByConfig } from "../watchlist"; const MOVIE: StremioMeta = { @@ -56,11 +60,11 @@ describe("getWatchlistByConfig", () => { }), ); - const config = { + const config: WatchlistFetchConfig = { ownerUserId: "ur12345678", watchlistId: "22222222-2222-4222-8222-222222222222", imdbUserId: "ls123456789", - sortOption: "added_at-asc", + sort: { by: "added_at", order: "asc" }, skipUserTimestamp: true, }; const first = getWatchlistByConfig(config); diff --git a/apps/backend/src/services/__tests__/watchlist-prewarm.test.ts b/apps/backend/src/services/__tests__/watchlist-prewarm.test.ts index 59111df..d4fe391 100644 --- a/apps/backend/src/services/__tests__/watchlist-prewarm.test.ts +++ b/apps/backend/src/services/__tests__/watchlist-prewarm.test.ts @@ -1,4 +1,4 @@ -import type { ConfigWatchlist } from "@stremlist/shared"; +import type { ConfigWatchlist } from "@stremlist/shared/stremio.types"; import { beforeEach, describe, expect, it, vi } from "vitest"; const watchlistMocks = vi.hoisted(() => ({ @@ -103,7 +103,7 @@ describe("prewarmWatchlists", () => { ownerUserId: "ur12345678", watchlistId: "11111111-1111-4111-8111-111111111111", imdbUserId: "ur12345678", - sortOption: "added_at-asc", + sort: { by: "added_at", order: "asc" }, rpdbApiKey: null, skipUserTimestamp: true, }); @@ -111,7 +111,7 @@ describe("prewarmWatchlists", () => { ownerUserId: "ur12345678", watchlistId: "22222222-2222-4222-8222-222222222222", imdbUserId: "ls123456789", - sortOption: "year-desc", + sort: { by: "year", order: "desc" }, rpdbApiKey: null, skipUserTimestamp: true, }); diff --git a/apps/backend/src/services/__tests__/watchlist-sort.test.ts b/apps/backend/src/services/__tests__/watchlist-sort.test.ts new file mode 100644 index 0000000..721a908 --- /dev/null +++ b/apps/backend/src/services/__tests__/watchlist-sort.test.ts @@ -0,0 +1,37 @@ +import { parseSortOption } from "@stremlist/shared/constants"; +import type { StremioMeta } from "@stremlist/shared/stremio.types"; +import { describe, expect, it } from "vitest"; +import { sortWatchlist } from "../watchlist-sort"; + +const metas: StremioMeta[] = [ + { id: "tt1", name: "Zulu", releaseInfo: "2020", imdbRating: "5" }, + { id: "tt2", name: "Alpha", releaseInfo: "1990", imdbRating: "9.5" }, + { id: "tt3", name: "Mango", releaseInfo: "2005", imdbRating: "7" }, +].map((meta) => ({ + ...meta, + type: "movie", + poster: null, + posterShape: "poster", + genres: [], + description: "", +})); + +describe("watchlist sorting", () => { + it.each([ + ["added_at-asc", ["tt1", "tt2", "tt3"]], + ["added_at-desc", ["tt3", "tt2", "tt1"]], + ["title-asc", ["tt2", "tt3", "tt1"]], + ["title-desc", ["tt1", "tt3", "tt2"]], + ["year-asc", ["tt2", "tt3", "tt1"]], + ["year-desc", ["tt1", "tt3", "tt2"]], + ["rating-asc", ["tt1", "tt3", "tt2"]], + ["rating-desc", ["tt2", "tt3", "tt1"]], + ])("applies %s without mutating canonical order", (option, ids) => { + expect( + sortWatchlist(metas, parseSortOption(option), "generation").map( + (meta) => meta.id, + ), + ).toEqual(ids); + expect(metas.map((meta) => meta.id)).toEqual(["tt1", "tt2", "tt3"]); + }); +}); diff --git a/apps/backend/src/services/catalog-filters.ts b/apps/backend/src/services/catalog-filters.ts new file mode 100644 index 0000000..452397c --- /dev/null +++ b/apps/backend/src/services/catalog-filters.ts @@ -0,0 +1,111 @@ +import type { + CatalogPreset, + CatalogSettings, +} from "@stremlist/shared/catalog-settings"; +import { CATALOG_DECADES } from "@stremlist/shared/catalog-settings"; +import { parseSortOption } from "@stremlist/shared/constants"; +import type { StremioMeta } from "@stremlist/shared/stremio.types"; +import { runtimeMinutes } from "./watchlist-sort"; +import type { WatchlistSort } from "./watchlist-sort"; + +interface Selection { + sort?: WatchlistSort; + filters?: CatalogSettings; +} +const OPTIONS = new Map([ + ...[ + ["Date Added (Newest)", "added_at-desc"], + ["Date Added (Oldest)", "added_at-asc"], + ["Title (A-Z)", "title-asc"], + ["Title (Z-A)", "title-desc"], + ["Release Year (Newest)", "year-desc"], + ["Release Year (Oldest)", "year-asc"], + ["IMDb Rating (Highest)", "rating-desc"], + ["IMDb Rating (Lowest)", "rating-asc"], + ["Shuffle", "random"], + ].map(([label, sort]): [string, Selection] => [ + label, + { sort: parseSortOption(sort) }, + ]), + ["Shortest", { sort: { by: "runtime", order: "asc" } }], + ["Longest", { sort: { by: "runtime", order: "desc" } }], + ["90 min or less", { filters: { maxRuntime: 90 } }], + ["120 min or less", { filters: { maxRuntime: 120 } }], + ["IMDb Rating 7+", { filters: { minRating: 7 } }], + ["IMDb Rating 8+", { filters: { minRating: 8 } }], + ["Release Date (Newest)", { sort: { by: "released", order: "desc" } }], + ["Release Date (Oldest)", { sort: { by: "released", order: "asc" } }], + ...CATALOG_DECADES.map((decade): [string, Selection] => [ + decade, + { filters: { decade: Number.parseInt(decade, 10) } }, + ]), +]); +const PRESETS = { + short: { filters: { maxRuntime: 90 } }, + rated: { sort: parseSortOption("rating-desc") }, + shuffle: { sort: parseSortOption("random") }, +} satisfies Record; + +// Stremio exposes one genre dropdown for either a filter or a sort. +export const CATALOG_FILTER_OPTIONS = [...OPTIONS.keys()]; + +export function resolveCatalogSelection( + savedSort: string, + settings: CatalogSettings = {}, + filter: string | null = null, + preset?: CatalogPreset, +) { + const selected = OPTIONS.get(filter ?? "") ?? { + filters: { genre: filter ?? undefined }, + }; + const defaults: { + sort?: ReturnType; + filters?: CatalogSettings; + } = preset ? PRESETS[preset] : {}; + const baseSort = defaults.sort ?? parseSortOption(savedSort); + const sort = selected.sort ?? baseSort; + return { + // Runtime/date ties retain the saved or preset order, including seeded shuffle. + sort: + sort.by === "runtime" || sort.by === "released" + ? { ...sort, then: baseSort } + : sort, + filters: [settings, defaults.filters ?? {}, selected.filters ?? {}], + }; +} + +function normalizeSearch(value: string): string { + return value.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().trim(); +} + +export function filterCatalog( + metas: StremioMeta[], + filters: CatalogSettings[], + search?: string | null, +): StremioMeta[] { + const query = search == null ? null : normalizeSearch(search); + return metas.filter( + (meta) => + filters.every((settings) => { + if (settings.genre && !meta.genres.includes(settings.genre)) + return false; + if (settings.decade !== undefined) { + const year = Number.parseInt(meta.releaseInfo ?? "", 10); + if (!(year >= settings.decade && year < settings.decade + 10)) + return false; + } + if (settings.maxRuntime !== undefined) { + const runtime = runtimeMinutes(meta.runtime); + if (runtime === null || runtime > settings.maxRuntime) return false; + } + if ( + settings.minRating !== undefined && + !(Number.parseFloat(meta.imdbRating ?? "") >= settings.minRating) + ) + return false; + return true; + }) && + (query === null || + (query.length > 0 && normalizeSearch(meta.name).includes(query))), + ); +} diff --git a/apps/backend/src/services/catalog-genres.ts b/apps/backend/src/services/catalog-genres.ts new file mode 100644 index 0000000..ff2926c --- /dev/null +++ b/apps/backend/src/services/catalog-genres.ts @@ -0,0 +1,25 @@ +import type { ConfigWatchlist } from "@stremlist/shared/stremio.types"; +import { getCachedWatchlist } from "./watchlist-cache"; + +export async function withAvailableGenres( + watchlists: ConfigWatchlist[], +): Promise { + return Promise.all( + watchlists.map(async (watchlist) => { + const cached = await getCachedWatchlist(watchlist.id); + const genres = (cached?.data.metas ?? []) + .filter( + (meta) => + watchlist.displayMode === "split" || + meta.type === watchlist.displayMode, + ) + .flatMap((meta) => meta.genres); + return { + ...watchlist, + availableGenres: [ + ...new Set(genres.filter((genre) => genre.trim().length > 0)), + ].sort(), + }; + }), + ); +} diff --git a/apps/backend/src/services/catalog-id.ts b/apps/backend/src/services/catalog-id.ts index 45351f7..874132e 100644 --- a/apps/backend/src/services/catalog-id.ts +++ b/apps/backend/src/services/catalog-id.ts @@ -1,3 +1,5 @@ +import { CATALOG_PRESETS } from "@stremlist/shared/catalog-settings"; +import type { CatalogPreset } from "@stremlist/shared/catalog-settings"; const CATALOG_ID_PREFIX = "wl"; const CATALOG_ID_SEPARATOR = "-"; const PREFIX_OFFSET = CATALOG_ID_PREFIX.length + CATALOG_ID_SEPARATOR.length; @@ -9,13 +11,24 @@ export type CatalogContentType = "movie" | "series"; export function buildCatalogId( watchlistId: string, type: CatalogContentType, + preset?: CatalogPreset, ): string { - return `${CATALOG_ID_PREFIX}${CATALOG_ID_SEPARATOR}${watchlistId}${CATALOG_ID_SEPARATOR}${type}`; + return `${CATALOG_ID_PREFIX}${CATALOG_ID_SEPARATOR}${watchlistId}${CATALOG_ID_SEPARATOR}${type}${preset ? `--${preset}` : ""}`; } -export function parseCatalogId( - catalogId: string, -): { watchlistId: string; type: CatalogContentType } | null { +export function parseCatalogId(catalogId: string): { + watchlistId: string; + type: CatalogContentType; + preset?: CatalogPreset; +} | null { + const separator = catalogId.indexOf("--"); + if (separator !== -1) { + const preset = CATALOG_PRESETS.find( + (option) => option.id === catalogId.slice(separator + 2), + ); + const base = parseCatalogId(catalogId.slice(0, separator)); + return preset && base ? { ...base, preset: preset.id } : null; + } if (!catalogId.startsWith(`${CATALOG_ID_PREFIX}${CATALOG_ID_SEPARATOR}`)) { return null; } diff --git a/apps/backend/src/services/catalog-settings.ts b/apps/backend/src/services/catalog-settings.ts new file mode 100644 index 0000000..96c2a0a --- /dev/null +++ b/apps/backend/src/services/catalog-settings.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +export const catalogSettingsSchema = z.object({ + genre: z.string().trim().min(1).max(100).optional(), + decade: z.number().int().min(1880).max(2100).multipleOf(10).optional(), + maxRuntime: z.number().int().min(1).max(600).optional(), + minRating: z.number().min(0).max(10).optional(), + presets: z + .array(z.enum(["short", "rated", "shuffle"])) + .max(3) + .refine( + (values) => new Set(values).size === values.length, + "Duplicate presets", + ) + .optional(), +}); diff --git a/apps/backend/src/services/imdb-scraper.ts b/apps/backend/src/services/imdb-scraper.ts index 0fda691..8fa2911 100644 --- a/apps/backend/src/services/imdb-scraper.ts +++ b/apps/backend/src/services/imdb-scraper.ts @@ -1,16 +1,10 @@ -import { - CHART_BY_ID, - DEFAULT_SORT_OPTIONS, - FACEBOOK_EXTERNAL_HIT_USER_AGENT, - isChartId, -} from "@stremlist/shared"; +import { FACEBOOK_EXTERNAL_HIT_USER_AGENT } from "@stremlist/shared/constants"; +import { CHART_BY_ID, isChartId } from "@stremlist/shared/imdb-charts"; +import type { ChartEntry } from "@stremlist/shared/imdb-charts"; import type { - ChartEntry, - SortOptions, StremioMeta, WatchlistData, -} from "@stremlist/shared"; -import { shuffleArray } from "../utils"; +} from "@stremlist/shared/stremio.types"; const GRAPHQL_ENDPOINT = "https://api.graphql.imdb.com/"; const GRAPHQL_CLIENT_NAME = "imdb-next-desktop"; @@ -44,6 +38,7 @@ const TITLE_FRAGMENT = ` titleText { text } titleType { text } releaseYear { year } + releaseDate { year month day } ratingsSummary { aggregateRating } titleGenres { genres { genre { text } } } plot { plotText { plainText } } @@ -159,6 +154,7 @@ interface ImdbEdge { titleText?: { text: string }; titleType?: { text: string }; releaseYear?: { year: number }; + releaseDate?: { year?: number; month?: number; day?: number }; ratingsSummary?: { aggregateRating: number }; titleGenres?: { genres: { genre?: { text: string } }[]; @@ -221,6 +217,7 @@ interface ProcessedItem { title: string | null; type: string | null; year: number | null; + released?: string; rating: number | null; genres: string[]; plot: string | null; @@ -466,6 +463,7 @@ function processWatchlist(edges: ImdbEdge[]): ProcessedItem[] { title: movieData.titleText?.text ?? null, type: movieData.titleType?.text ?? null, year: movieData.releaseYear?.year ?? null, + released: releaseDate(movieData.releaseDate), rating: movieData.ratingsSummary?.aggregateRating ?? null, genres: [], plot: movieData.plot?.plotText?.plainText ?? null, @@ -512,49 +510,24 @@ function processWatchlist(edges: ImdbEdge[]): ProcessedItem[] { return items; } +function releaseDate(date: TitleNode["releaseDate"]): string | undefined { + if (!date?.year || !date.month || !date.day) return undefined; + const value = new Date(Date.UTC(date.year, date.month - 1, date.day)); + if ( + value.getUTCFullYear() !== date.year || + value.getUTCMonth() !== date.month - 1 || + value.getUTCDate() !== date.day + ) + return undefined; + return value.toISOString(); +} + function formatRuntime(seconds: number): string { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; } -function sortMetas(metas: StremioMeta[], options: SortOptions): StremioMeta[] { - const sorted = [...metas]; - const { by, order } = options; - const multiplier = order === "desc" ? -1 : 1; - - if (by === "added_at") { - if (order === "desc") { - sorted.reverse(); - } - return sorted; - } - - if (by === "random") { - return shuffleArray(sorted); - } - - sorted.sort((a, b) => { - switch (by) { - case "year": { - const ya = a.releaseInfo ? parseInt(a.releaseInfo, 10) || 0 : 0; - const yb = b.releaseInfo ? parseInt(b.releaseInfo, 10) || 0 : 0; - return (ya - yb) * multiplier; - } - case "rating": { - const ra = a.imdbRating ? parseFloat(a.imdbRating) || 0 : 0; - const rb = b.imdbRating ? parseFloat(b.imdbRating) || 0 : 0; - return (ra - rb) * multiplier; - } - case "title": - default: - return a.name.localeCompare(b.name) * multiplier; - } - }); - - return sorted; -} - // Stremio only has `movie` and `series` catalog types, so every single-video // IMDb title (theatrical, made-for-TV, short, etc.) maps to `movie`. Episodic // content (`TV Episode`) and non-video titles (`Video Game`, `Music Video`, @@ -569,11 +542,7 @@ const MOVIE_TYPES = new Set([ ]); const SERIES_TYPES = new Set(["TV Series", "TV Mini Series"]); -function convertToStremioFormat( - items: ProcessedItem[], - sortOptions: SortOptions, - rpdbApiKey?: string | null, -): StremioMeta[] { +function convertToStremioFormat(items: ProcessedItem[]): StremioMeta[] { const metas: StremioMeta[] = []; for (const item of items) { @@ -590,10 +559,11 @@ function convertToStremioFormat( const meta: StremioMeta = { id: item.id, name: item.title ?? "", - poster: buildPosterUrl(item.id, item.image_url, rpdbApiKey), + poster: item.image_url, posterShape: "poster", type: isMovie ? "movie" : "series", genres: item.genres, + ...(item.released ? { released: item.released } : {}), description: item.plot ?? "", }; @@ -616,7 +586,7 @@ function convertToStremioFormat( metas.push(meta); } - return sortMetas(metas, sortOptions); + return metas; } export function isListId(id: string): boolean { @@ -625,8 +595,6 @@ export function isListId(id: string): boolean { export async function fetchWatchlist( imdbUserId: string, - sortOptions: SortOptions = DEFAULT_SORT_OPTIONS, - rpdbApiKey?: string | null, ): Promise { console.log(`Fetching IMDb watchlist for user ${imdbUserId}...`); @@ -637,10 +605,8 @@ export async function fetchWatchlist( ); const processed = processWatchlist(edges); - const metas = convertToStremioFormat(processed, sortOptions, rpdbApiKey); - console.log( - `Converted ${metas.length} items to Stremio format (sorted by ${sortOptions.by}, ${sortOptions.order})`, - ); + const metas = convertToStremioFormat(processed); + console.log(`Converted ${metas.length} items to Stremio format`); return { metas }; } @@ -734,11 +700,7 @@ async function getChartEdges(entry: ChartEntry): Promise { } } -export async function fetchChart( - sourceId: string, - sortOptions: SortOptions = DEFAULT_SORT_OPTIONS, - rpdbApiKey?: string | null, -): Promise { +export async function fetchChart(sourceId: string): Promise { const entry = CHART_BY_ID.get(sourceId); if (!entry) { // Unknown chart id has no fetcher. Charts are public, so there's no @@ -755,10 +717,8 @@ export async function fetchChart( ); const processed = processWatchlist(edges); - const metas = convertToStremioFormat(processed, sortOptions, rpdbApiKey); - console.log( - `Converted ${metas.length} items to Stremio format (sorted by ${sortOptions.by}, ${sortOptions.order})`, - ); + const metas = convertToStremioFormat(processed); + console.log(`Converted ${metas.length} items to Stremio format`); return { metas }; } @@ -858,11 +818,7 @@ export async function getImdbList(listId: string): Promise { return edges; } -export async function fetchList( - listId: string, - sortOptions: SortOptions = DEFAULT_SORT_OPTIONS, - rpdbApiKey?: string | null, -): Promise { +export async function fetchList(listId: string): Promise { console.log(`Fetching IMDb list ${listId}...`); const edges = await getImdbList(listId); @@ -872,10 +828,8 @@ export async function fetchList( ); const processed = processWatchlist(edges); - const metas = convertToStremioFormat(processed, sortOptions, rpdbApiKey); - console.log( - `Converted ${metas.length} items to Stremio format (sorted by ${sortOptions.by}, ${sortOptions.order})`, - ); + const metas = convertToStremioFormat(processed); + console.log(`Converted ${metas.length} items to Stremio format`); return { metas }; } diff --git a/apps/backend/src/services/stremio-catalogs.ts b/apps/backend/src/services/stremio-catalogs.ts index 9d87e93..f98b5cf 100644 --- a/apps/backend/src/services/stremio-catalogs.ts +++ b/apps/backend/src/services/stremio-catalogs.ts @@ -1,6 +1,27 @@ -import type { ConfigWatchlist, StremioCatalog } from "@stremlist/shared"; +import { CATALOG_PRESETS } from "@stremlist/shared/catalog-settings"; +import type { + ConfigWatchlist, + StremioCatalog, +} from "@stremlist/shared/stremio.types"; +import { CATALOG_FILTER_OPTIONS } from "./catalog-filters"; import { buildCatalogId } from "./catalog-id"; +function catalogExtras( + genres: string[], + search = true, +): StremioCatalog["extra"] { + return [ + { name: "skip", isRequired: false }, + ...(search ? [{ name: "search" as const, isRequired: false }] : []), + { + name: "genre", + isRequired: false, + options: [...new Set([...CATALOG_FILTER_OPTIONS, ...genres])], + optionsLimit: 1, + }, + ]; +} + function buildCatalogName(baseTitle: string): string { const normalizedTitle = baseTitle.trim(); if (!normalizedTitle) { @@ -38,25 +59,43 @@ export function buildManifestCatalogs( ? watchlist.displayMode : "split"; + const genres = [ + ...new Set([ + ...(watchlist.availableGenres ?? []), + ...(watchlist.catalogSettings?.genre + ? [watchlist.catalogSettings.genre] + : []), + ]), + ].sort(); const movieCatalog: StremioCatalog = { id: buildCatalogId(watchlist.id, "movie"), name: buildCatalogName(effectiveTitle), type: "movie", - extra: [{ name: "skip", isRequired: false }], + extra: catalogExtras(genres), }; const seriesCatalog: StremioCatalog = { id: buildCatalogId(watchlist.id, "series"), name: buildCatalogName(effectiveTitle), type: "series", - extra: [{ name: "skip", isRequired: false }], + extra: catalogExtras(genres), }; - if (displayMode === "movie") { - return [movieCatalog]; - } - if (displayMode === "series") { - return [seriesCatalog]; - } - return [movieCatalog, seriesCatalog]; + const base = + displayMode === "movie" + ? [movieCatalog] + : displayMode === "series" + ? [seriesCatalog] + : [movieCatalog, seriesCatalog]; + return base.flatMap((catalog) => [ + catalog, + ...CATALOG_PRESETS.filter((preset) => + watchlist.catalogSettings?.presets?.includes(preset.id), + ).map((preset) => ({ + ...catalog, + id: buildCatalogId(watchlist.id, catalog.type, preset.id), + name: `${catalog.name} · ${preset.label}`, + extra: catalogExtras(genres, false), + })), + ]); }); } diff --git a/apps/backend/src/services/user.ts b/apps/backend/src/services/user.ts index 29c7f78..e350916 100644 --- a/apps/backend/src/services/user.ts +++ b/apps/backend/src/services/user.ts @@ -1,6 +1,9 @@ -import { DEFAULT_SORT_OPTION } from "@stremlist/shared"; -import type { ConfigWatchlist, Tables } from "@stremlist/shared"; +import type { CatalogSettings } from "@stremlist/shared/catalog-settings"; +import { DEFAULT_SORT_OPTION } from "@stremlist/shared/constants"; +import type { Tables } from "@stremlist/shared/database.types"; +import type { ConfigWatchlist } from "@stremlist/shared/stremio.types"; import { supabase } from "../lib/supabase"; +import { catalogSettingsSchema } from "./catalog-settings"; import { deleteCachedWatchlist } from "./watchlist-cache"; type User = Tables<"users">; @@ -13,11 +16,13 @@ interface UserConfigUpdateWatchlistRow { sortOption: string; displayMode?: string; position: number; + catalogSettings?: CatalogSettings; } const DEFAULT_WATCHLIST_TITLE = ""; function mapWatchlistRow(row: UserWatchlist): ConfigWatchlist { + const settings = catalogSettingsSchema.safeParse(row.catalog_settings); return { id: row.id, imdbUserId: row.imdb_user_id, @@ -25,6 +30,9 @@ function mapWatchlistRow(row: UserWatchlist): ConfigWatchlist { sortOption: row.sort_option, displayMode: row.display_mode as ConfigWatchlist["displayMode"], position: row.position, + ...(settings.success && Object.keys(settings.data).length > 0 + ? { catalogSettings: settings.data } + : {}), }; } @@ -143,109 +151,41 @@ export async function getUserWatchlistById( export async function replaceUserWatchlists( ownerUserId: string, watchlists: UserConfigUpdateWatchlistRow[], + rpdbApiKey: string | null, ): Promise { - const { data: existingRows, error: existingError } = await supabase - .from("user_watchlists") - .select("id") - .eq("owner_user_id", ownerUserId); - - if (existingError) { - console.error( - `Failed to fetch existing watchlists for ${ownerUserId}:`, - existingError.message, - ); - throw existingError; - } - - const hasId = ( - w: UserConfigUpdateWatchlistRow, - ): w is UserConfigUpdateWatchlistRow & { id: string } => !!w.id; - - const toUpdate = watchlists.filter(hasId); - const toInsert = watchlists.filter((w) => !w.id); - - const keepIds = new Set(toUpdate.map((w) => w.id)); - const existingIds = existingRows.map((row) => row.id); - const toDelete = existingIds.filter((id) => !keepIds.has(id)); - - if (toDelete.length > 0) { - const { error: deleteError } = await supabase - .from("user_watchlists") - .delete() - .eq("owner_user_id", ownerUserId) - .in("id", toDelete); - - if (deleteError) { - console.error( - `Failed to delete removed watchlists for ${ownerUserId}:`, - deleteError.message, - ); - throw deleteError; - } - - const cleanupResults = await Promise.allSettled( - toDelete.map((watchlistId) => deleteCachedWatchlist(watchlistId)), - ); - cleanupResults.forEach((result, index) => { - if (result.status === "rejected") { - console.error( - `Failed to delete R2 cache for removed watchlist ${toDelete[index]}:`, - result.reason, - ); - } - }); - } - - if (toUpdate.length > 0) { - const rows = toUpdate.map((w) => ({ - id: w.id, - owner_user_id: ownerUserId, + const { data, error } = await supabase.rpc("replace_user_config", { + p_owner_user_id: ownerUserId, + p_rpdb_api_key: rpdbApiKey, + p_watchlists: watchlists.map((w) => ({ + ...(w.id ? { id: w.id } : {}), imdb_user_id: w.imdbUserId, catalog_title: w.catalogTitle ?? "", sort_option: w.sortOption, display_mode: w.displayMode ?? "split", position: w.position, - updated_at: new Date().toISOString(), - })); - - const { error: upsertError } = await supabase - .from("user_watchlists") - .upsert(rows, { onConflict: "id" }); - - if (upsertError) { - console.error( - `Failed to update watchlists for ${ownerUserId}:`, - upsertError.message, - ); - throw upsertError; - } - } - - if (toInsert.length > 0) { - const rows = toInsert.map((w) => ({ - owner_user_id: ownerUserId, - imdb_user_id: w.imdbUserId, - catalog_title: w.catalogTitle ?? "", - sort_option: w.sortOption, - display_mode: w.displayMode ?? "split", - position: w.position, - })); - - const { error: insertError } = await supabase - .from("user_watchlists") - .insert(rows) - .select(); - - if (insertError) { + ...(w.catalogSettings === undefined + ? {} + : { catalog_settings: { ...w.catalogSettings } }), + })), + }); + if (error) throw error; + + const result = data[0]; + // External cache deletion cannot participate in the database transaction. + const cleanupResults = await Promise.allSettled( + result.deleted_ids.map((id) => deleteCachedWatchlist(id)), + ); + cleanupResults.forEach((cleanup, index) => { + if (cleanup.status === "rejected") { console.error( - `Failed to insert watchlists for ${ownerUserId}:`, - insertError.message, + `Failed to delete R2 cache for removed watchlist ${result.deleted_ids[index]}:`, + cleanup.reason, ); - throw insertError; } - } + }); - return getUserWatchlists(ownerUserId); + // The RPC aggregates complete user_watchlists rows from the same transaction. + return (result.watchlists as UserWatchlist[]).map(mapWatchlistRow); } export async function getUserRpdbApiKey( @@ -263,21 +203,3 @@ export async function getUserRpdbApiKey( return data.rpdb_api_key; } - -export async function setUserRpdbApiKey( - imdbUserId: string, - rpdbApiKey: string | null, -): Promise { - const { error } = await supabase - .from("users") - .update({ rpdb_api_key: rpdbApiKey }) - .eq("imdb_user_id", imdbUserId); - - if (error) { - console.error( - `Failed to update RPDB API key for ${imdbUserId}:`, - error.message, - ); - throw error; - } -} diff --git a/apps/backend/src/services/watchlist-cache.ts b/apps/backend/src/services/watchlist-cache.ts index 3645200..3541121 100644 --- a/apps/backend/src/services/watchlist-cache.ts +++ b/apps/backend/src/services/watchlist-cache.ts @@ -3,7 +3,10 @@ import { GetObjectCommand, PutObjectCommand, } from "@aws-sdk/client-s3"; -import type { StremioMeta, WatchlistData } from "@stremlist/shared"; +import type { + StremioMeta, + WatchlistData, +} from "@stremlist/shared/stremio.types"; import { z } from "zod"; import { randomUUID } from "node:crypto"; import { gunzipSync, gzipSync } from "node:zlib"; @@ -26,6 +29,7 @@ const stremioMetaSchema = z.object({ director: z.array(z.string()).optional(), cast: z.array(z.string()).optional(), runtime: z.string().optional(), + released: z.string().datetime().optional(), }); const catalogObjectSchema = z.object({ diff --git a/apps/backend/src/services/watchlist-prewarm.ts b/apps/backend/src/services/watchlist-prewarm.ts index 8a24ce1..2aa396b 100644 --- a/apps/backend/src/services/watchlist-prewarm.ts +++ b/apps/backend/src/services/watchlist-prewarm.ts @@ -1,4 +1,5 @@ -import type { ConfigWatchlist } from "@stremlist/shared"; +import { parseSortOption } from "@stremlist/shared/constants"; +import type { ConfigWatchlist } from "@stremlist/shared/stremio.types"; import { randomUUID } from "node:crypto"; import { supabase } from "../lib/supabase"; import { getUserWatchlists } from "./user"; @@ -64,7 +65,7 @@ async function runPrewarmBatch( ownerUserId, watchlistId: watchlist.id, imdbUserId: watchlist.imdbUserId, - sortOption: watchlist.sortOption, + sort: parseSortOption(watchlist.sortOption), // Prewarming only needs the canonical cache. Poster customization is // applied later when Stremio requests the catalog. rpdbApiKey: null, diff --git a/apps/backend/src/services/watchlist-sort.ts b/apps/backend/src/services/watchlist-sort.ts new file mode 100644 index 0000000..0309145 --- /dev/null +++ b/apps/backend/src/services/watchlist-sort.ts @@ -0,0 +1,60 @@ +import type { SortOptions } from "@stremlist/shared/constants"; +import type { StremioMeta } from "@stremlist/shared/stremio.types"; +import { shuffleArray } from "../utils"; + +export type WatchlistSort = Omit & { + by: SortOptions["by"] | "runtime" | "released"; + then?: SortOptions; +}; + +export function runtimeMinutes(runtime?: string): number | null { + const match = runtime?.match(/^(?:(\d+)h\s*)?(?:(\d+)m(?:in)?)?$/); + if (!match || (!match[1] && !match[2])) return null; + const minutes = Number(match[1] || 0) * 60 + Number(match[2] || 0); + return minutes > 0 ? minutes : null; +} + +export function sortWatchlist( + metas: StremioMeta[], + sort: WatchlistSort, + generation: string, +): StremioMeta[] { + const indices = metas.map((_, index) => index); + const ranks: number[] = []; + if (sort.by === "random" || sort.then?.by === "random") { + shuffleArray([...indices], generation).forEach((index, rank) => { + ranks[index] = rank; + }); + } + function compare(a: number, b: number, { by, order }: WatchlistSort): number { + const direction = order === "desc" ? -1 : 1; + if (by === "random") return ranks[a] - ranks[b]; + if (by === "added_at") return (a - b) * direction; + if (by === "title") + return metas[a].name.localeCompare(metas[b].name) * direction; + const value = (meta: StremioMeta): number => { + switch (by) { + case "year": + return Number.parseInt(meta.releaseInfo ?? "", 10) || 0; + case "rating": + return Number.parseFloat(meta.imdbRating ?? "") || 0; + case "runtime": + return runtimeMinutes(meta.runtime) ?? NaN; + case "released": + return Date.parse(meta.released ?? ""); + } + }; + const left = value(metas[a]); + const right = value(metas[b]); + // Runtime and complete dates put missing values last in either direction. + if (!Number.isFinite(left)) return Number.isFinite(right) ? 1 : 0; + if (!Number.isFinite(right)) return -1; + return (left - right) * direction; + } + return indices + .sort( + (a, b) => + compare(a, b, sort) || (sort.then ? compare(a, b, sort.then) : 0), + ) + .map((index) => metas[index]); +} diff --git a/apps/backend/src/services/watchlist.ts b/apps/backend/src/services/watchlist.ts index 83ef303..73760a8 100644 --- a/apps/backend/src/services/watchlist.ts +++ b/apps/backend/src/services/watchlist.ts @@ -1,12 +1,6 @@ -import { - DEFAULT_SORT_OPTION, - DEFAULT_SORT_OPTIONS, - isChartId, - parseSortOption, -} from "@stremlist/shared"; -import type { WatchlistData, SortOptions } from "@stremlist/shared"; +import { isChartId } from "@stremlist/shared/imdb-charts"; +import type { WatchlistData } from "@stremlist/shared/stremio.types"; import { supabase } from "../lib/supabase"; -import { shuffleArray } from "../utils"; import { buildPosterUrl, classifyWatchlistError, @@ -22,6 +16,8 @@ import { getCachedWatchlist, writeCachedWatchlist, } from "./watchlist-cache"; +import type { WatchlistSort } from "./watchlist-sort"; +import { sortWatchlist } from "./watchlist-sort"; export type WatchlistUnavailableReason = WatchlistErrorReason | "unavailable"; @@ -63,7 +59,7 @@ export interface WatchlistFetchConfig { ownerUserId: string; watchlistId: string; imdbUserId: string; - sortOption: string | null | undefined; + sort: WatchlistSort; rpdbApiKey?: string | null; forceFresh?: boolean; skipUserTimestamp?: boolean; @@ -97,7 +93,7 @@ async function fetchAndCacheWatchlist( : isListId(config.imdbUserId) ? fetchList : fetchWatchlist; - const data = await fetcher(config.imdbUserId, DEFAULT_SORT_OPTIONS, null); + const data = await fetcher(config.imdbUserId); const cachedAt = new Date(); const generation = await upsertCache(config.watchlistId, data, cachedAt); return { data, cachedAt, generation }; @@ -130,9 +126,6 @@ function refreshWatchlist( export async function getWatchlistByConfig( config: WatchlistFetchConfig, ): Promise { - const sortOptionStr = config.sortOption ?? DEFAULT_SORT_OPTION; - const sortOptions = parseSortOption(sortOptionStr); - // Cache-first happy path: a fresh R2 hit avoids both Supabase writes and IMDb // calls. The catalog stays canonical (added_at-asc, raw posters), so sort + // RPDB are always applied at serve time. @@ -151,7 +144,7 @@ export async function getWatchlistByConfig( ) { return resortCachedData( cached.data, - sortOptions, + config.sort, cached.generation, config.rpdbApiKey, ); @@ -176,7 +169,7 @@ export async function getWatchlistByConfig( } return resortCachedData( fresh, - sortOptions, + config.sort, generation ?? contentGeneration(config.watchlistId, fresh), config.rpdbApiKey, ); @@ -205,7 +198,7 @@ export async function getWatchlistByConfig( .eq("imdb_user_id", config.ownerUserId); return resortCachedData( cached.data, - sortOptions, + config.sort, cached.generation, config.rpdbApiKey, ); @@ -263,49 +256,16 @@ export async function findMetaInUserCache( function resortCachedData( data: WatchlistData, - sortOptions: SortOptions, + sortOptions: WatchlistSort, generation: string, rpdbApiKey?: string | null, ): WatchlistData { - const metas = [...data.metas]; - const { by, order } = sortOptions; - const multiplier = order === "desc" ? -1 : 1; - - if (by === "added_at") { - if (order === "desc") { - metas.reverse(); - } - return { metas: applyRpdbPostersToMetas(metas, rpdbApiKey) }; - } - - if (by === "random") { - return { - metas: applyRpdbPostersToMetas( - shuffleArray(metas, generation), - rpdbApiKey, - ), - }; - } - - metas.sort((a, b) => { - switch (by) { - case "year": { - const ya = a.releaseInfo ? parseInt(a.releaseInfo, 10) || 0 : 0; - const yb = b.releaseInfo ? parseInt(b.releaseInfo, 10) || 0 : 0; - return (ya - yb) * multiplier; - } - case "rating": { - const ra = a.imdbRating ? parseFloat(a.imdbRating) || 0 : 0; - const rb = b.imdbRating ? parseFloat(b.imdbRating) || 0 : 0; - return (ra - rb) * multiplier; - } - case "title": - default: - return a.name.localeCompare(b.name) * multiplier; - } - }); - - return { metas: applyRpdbPostersToMetas(metas, rpdbApiKey) }; + return { + metas: sortWatchlist(data.metas, sortOptions, generation).map((meta) => ({ + ...meta, + poster: buildPosterUrl(meta.id, meta.poster, rpdbApiKey), + })), + }; } function contentGeneration(watchlistId: string, data: WatchlistData): string { @@ -313,13 +273,3 @@ function contentGeneration(watchlistId: string, data: WatchlistData): string { .map((meta) => `${meta.type}:${meta.id}`) .join(",")}`; } - -function applyRpdbPostersToMetas( - metas: WatchlistData["metas"], - rpdbApiKey?: string | null, -): WatchlistData["metas"] { - return metas.map((meta) => ({ - ...meta, - poster: buildPosterUrl(meta.id, meta.poster, rpdbApiKey), - })); -} diff --git a/apps/backend/turbo.json b/apps/backend/turbo.json index 81fab8f..76457dc 100644 --- a/apps/backend/turbo.json +++ b/apps/backend/turbo.json @@ -14,6 +14,22 @@ "MONITOR_IMDB_USER_ID", "CRON_SECRET" ] + }, + "dev": { + "passThroughEnv": [ + "$TURBO_EXTENDS$", + "!PORTLESS_TAILSCALE", + "!PORTLESS_FUNNEL", + "!PORTLESS_NGROK" + ] + }, + "dev:tailnet": { + "passThroughEnv": [ + "$TURBO_EXTENDS$", + "!PORTLESS_TAILSCALE", + "!PORTLESS_FUNNEL", + "!PORTLESS_NGROK" + ] } } } diff --git a/apps/e2e/helpers/api.ts b/apps/e2e/helpers/api.ts index 341acce..3a82a60 100644 --- a/apps/e2e/helpers/api.ts +++ b/apps/e2e/helpers/api.ts @@ -3,7 +3,7 @@ import type { StremioMeta, UserConfigResponse, UserConfigUpdateWatchlist, -} from "@stremlist/shared"; +} from "@stremlist/shared/stremio.types"; import { hcWithType } from "@stremlist/backend/client"; import { BACKEND_URL } from "../env.js"; diff --git a/apps/e2e/helpers/db.ts b/apps/e2e/helpers/db.ts index a19cc47..c52ef6b 100644 --- a/apps/e2e/helpers/db.ts +++ b/apps/e2e/helpers/db.ts @@ -1,5 +1,5 @@ import { createClient } from "@supabase/supabase-js"; -import type { Database } from "@stremlist/shared"; +import type { Database } from "@stremlist/shared/database.types"; import { SUPABASE_SERVICE_ROLE_KEY, SUPABASE_URL } from "../env.js"; import { E2E_USER_IDS } from "./test-data.js"; import { deleteCacheObjects } from "./r2.js"; diff --git a/apps/frontend/lib/supabase/client.ts b/apps/frontend/lib/supabase/client.ts index 25aa383..68a8f38 100644 --- a/apps/frontend/lib/supabase/client.ts +++ b/apps/frontend/lib/supabase/client.ts @@ -1,5 +1,5 @@ import { createClient } from "@supabase/supabase-js"; -import type { Database } from "@stremlist/shared"; +import type { Database } from "@stremlist/shared/database.types"; const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; const supabaseKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY; diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 986e242..ab06b3f 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -4,15 +4,19 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "portless", "build": "tsc -b && vite build", "lint": "eslint .", "format": "prettier . --write", "format:check": "prettier . --check", "preview": "vite preview", - "typecheck": "tsc -b --noEmit" + "typecheck": "tsc -b --noEmit", + "dev:app": "vite", + "dev:tailnet": "portless --tailscale", + "test": "node --test scripts/dev-proxy.test.mjs" }, "dependencies": { + "@base-ui/react": "^1.8.0", "@dnd-kit/helpers": "^0.3.2", "@dnd-kit/react": "^0.3.2", "@hookform/resolvers": "^5.2.2", @@ -50,5 +54,10 @@ "prettier": "^3.8.1", "typescript": "~5.9.3", "vite": "^7.3.1" + }, + "portless": { + "name": "stremlist", + "script": "dev:app", + "proxy": true } } diff --git a/apps/frontend/scripts/dev-proxy.test.mjs b/apps/frontend/scripts/dev-proxy.test.mjs new file mode 100644 index 0000000..dc49789 --- /dev/null +++ b/apps/frontend/scripts/dev-proxy.test.mjs @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { createServer as createHttpServer } from "node:http"; +import { afterEach, beforeEach, test } from "node:test"; +import { createServer, loadConfigFromFile } from "vite"; + +const keys = [ + "PORTLESS_URL", + "VITE_BACKEND_URL", + "DEV_BACKEND_URL", + "VERCEL_RELATED_PROJECTS", +]; +let original; + +beforeEach(() => { + original = Object.fromEntries(keys.map((key) => [key, process.env[key]])); + for (const key of keys) process.env[key] = ""; +}); + +afterEach(() => { + for (const key of keys) { + if (original[key] === undefined) delete process.env[key]; + else process.env[key] = original[key]; + } +}); + +test("Portless proxies API paths and keeps Stremio configure redirects on the frontend", async (t) => { + const upstream = createHttpServer((request, response) => { + if (request.url === "/ur00000001/configure") { + response.writeHead(302, { + Location: "http://localhost:5173/configure?userId=ur00000001", + }); + response.end(); + return; + } + response.setHeader("Content-Type", "application/json"); + response.end( + JSON.stringify({ path: request.url, host: request.headers.host }), + ); + }); + upstream.listen(0, "127.0.0.1"); + await once(upstream, "listening"); + t.after(() => new Promise((resolve) => upstream.close(resolve))); + const target = `http://127.0.0.1:${upstream.address().port}`; + process.env.PORTLESS_URL = "https://test.stremlist.localhost"; + process.env.VITE_BACKEND_URL = "http://stale-host.invalid:7001"; + process.env.DEV_BACKEND_URL = target; + + const vite = await createServer({ + mode: "test", + logLevel: "silent", + server: { host: "127.0.0.1", port: 0, watch: null }, + }); + t.after(() => vite.close()); + await vite.listen(); + const origin = `http://127.0.0.1:${vite.httpServer.address().port}`; + assert.equal( + vite.config.define["import.meta.env.VITE_BACKEND_URL"], + '"/api"', + ); + const response = await fetch(`${origin}/api/manifest.json?probe=1`); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + path: "/manifest.json?probe=1", + host: new URL(target).host, + }); + const redirect = await fetch(`${origin}/api/ur00000001/configure`, { + redirect: "manual", + }); + assert.equal(redirect.status, 302); + assert.equal( + redirect.headers.get("location"), + "/configure?userId=ur00000001", + ); +}); + +test("direct development defaults to the same-origin API proxy", async () => { + const { config } = await loadConfigFromFile({ + command: "serve", + mode: "test", + }); + assert.equal(config.define["import.meta.env.VITE_BACKEND_URL"], '"/api"'); + assert.equal(config.server.proxy["/api"].target, "http://localhost:7001"); +}); + +test("explicit API URLs are preserved for the E2E harness and production builds", async () => { + process.env.VITE_BACKEND_URL = "http://127.0.0.1:7301"; + for (const command of ["serve", "build"]) { + const { config } = await loadConfigFromFile({ command, mode: "test" }); + assert.equal( + config.define["import.meta.env.VITE_BACKEND_URL"], + '"http://127.0.0.1:7301"', + ); + assert.equal(config.server.proxy, undefined); + } +}); diff --git a/apps/frontend/src/components/AddonInstallActions.tsx b/apps/frontend/src/components/AddonInstallActions.tsx index 48c1ce2..a406d5a 100644 --- a/apps/frontend/src/components/AddonInstallActions.tsx +++ b/apps/frontend/src/components/AddonInstallActions.tsx @@ -9,7 +9,10 @@ interface AddonInstallActionsProps { } function buildUrls(imdbUserId: string) { - const addonUrl = `${import.meta.env.VITE_BACKEND_URL}/${imdbUserId}/manifest.json`; + const addonUrl = new URL( + `${import.meta.env.VITE_BACKEND_URL}/${imdbUserId}/manifest.json`, + window.location.origin, + ).href; const webUrl = `https://web.stremio.com/#/addons?addon=${encodeURIComponent(addonUrl)}`; const stremioUrl = `stremio://${addonUrl.replace(/^https?:\/\//, "")}`; return { addonUrl, webUrl, stremioUrl }; diff --git a/apps/frontend/src/components/BuiltInCatalogPicker.tsx b/apps/frontend/src/components/BuiltInCatalogPicker.tsx index 84aa183..166a254 100644 --- a/apps/frontend/src/components/BuiltInCatalogPicker.tsx +++ b/apps/frontend/src/components/BuiltInCatalogPicker.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { flushSync } from "react-dom"; -import { CHART_REGISTRY } from "@stremlist/shared"; +import { CHART_REGISTRY } from "@stremlist/shared/imdb-charts"; import { Check, ChevronDown, ExternalLink, Plus, Sparkles } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; diff --git a/apps/frontend/src/components/CatalogFilterSettings.tsx b/apps/frontend/src/components/CatalogFilterSettings.tsx new file mode 100644 index 0000000..1f1f33a --- /dev/null +++ b/apps/frontend/src/components/CatalogFilterSettings.tsx @@ -0,0 +1,261 @@ +import { useId, useState } from "react"; +import { ChevronDown, SlidersHorizontal } from "lucide-react"; +import { + CATALOG_DECADES, + CATALOG_PRESETS, +} from "@stremlist/shared/catalog-settings"; +import type { CatalogSettings } from "@stremlist/shared/catalog-settings"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +// Radix Select reserves "" for "nothing selected", so the "any" option needs +// a real value of its own. +const ANY = "__any__"; + +type FilterKey = "genre" | "decade" | "maxRuntime" | "minRating"; + +type Filter = { + key: FilterKey; + label: string; + empty: string; + choices: readonly (string | number)[]; + suffix: string; +}; + +const NUMERIC_FILTERS: readonly Filter[] = [ + { + key: "decade", + label: "Decade", + empty: "All decades", + choices: CATALOG_DECADES.map((decade) => Number.parseInt(decade, 10)), + suffix: "s", + }, + { + key: "maxRuntime", + label: "Maximum runtime", + empty: "Any length", + choices: [60, 90, 120, 150, 180], + suffix: " min or less", + }, + { + key: "minRating", + label: "Minimum IMDb rating", + empty: "Any rating", + choices: [5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9], + suffix: " and above", + }, +]; + +const FILTER_KEYS: readonly FilterKey[] = [ + "genre", + "decade", + "maxRuntime", + "minRating", +]; + +export default function CatalogFilterSettings({ + value, + genres, + onChange, +}: { + value: CatalogSettings; + genres: string[]; + onChange: (settings: CatalogSettings) => void; +}) { + const [open, setOpen] = useState(false); + const panelId = useId(); + + const filters: readonly Filter[] = [ + { + key: "genre", + label: "Genre", + empty: "All genres", + choices: genres, + suffix: "", + }, + ...NUMERIC_FILTERS, + ]; + const activeFilters = FILTER_KEYS.filter( + (key) => value[key] !== undefined, + ).length; + const activeCount = activeFilters + (value.presets?.length ?? 0); + const genreLocked = genres.length === 0 && value.genre === undefined; + + return ( +
+ + + {/* Height reveal via grid-template-rows so the transition stays + interruptible and needs no measurement. Inner wrapper bleeds 4px on + each side so focus rings aren't clipped by overflow-hidden. */} +
+
+
+
+

+ All selected filters apply together, including in search. Use + Sort Order above to order the results. +

+ {activeFilters > 0 && ( + + )} +
+ +
+ {filters.map((filter) => { + const current = value[filter.key]; + const options = new Set(filter.choices); + if (current !== undefined) options.add(current); + const triggerId = `${panelId}-${filter.key}`; + return ( +
+ + + {filter.key === "genre" && genres.length === 0 && ( +

+ Genre choices appear after saving and refreshing this + list. +

+ )} +
+ ); + })} +
+ +
+

+ Extra catalogs on your Stremio home +

+

+ Use the same list and filters. Reinstall after changing these + options. +

+
+ {CATALOG_PRESETS.map((preset) => { + const checked = value.presets?.includes(preset.id) ?? false; + return ( + + ); + })} +
+
+
+
+
+
+ ); +} diff --git a/apps/frontend/src/components/SetupForm.tsx b/apps/frontend/src/components/SetupForm.tsx index 5ce89f4..e317bd7 100644 --- a/apps/frontend/src/components/SetupForm.tsx +++ b/apps/frontend/src/components/SetupForm.tsx @@ -1,7 +1,7 @@ import { useState, useRef, useCallback, useEffect } from "react"; import { Link } from "react-router"; import { ArrowRight, Info } from "lucide-react"; -import { IMDB_USER_ID_EXTRACT_PATTERN } from "@stremlist/shared"; +import { IMDB_USER_ID_EXTRACT_PATTERN } from "@stremlist/shared/constants"; import { api } from "../lib/api"; import AddonInstallActions from "./AddonInstallActions"; import { Input } from "@/components/ui/input"; diff --git a/apps/frontend/src/components/ui/checkbox.tsx b/apps/frontend/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..4fe3143 --- /dev/null +++ b/apps/frontend/src/components/ui/checkbox.tsx @@ -0,0 +1,26 @@ +import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"; +import { CheckIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) { + return ( + + + + + + ); +} + +export { Checkbox }; diff --git a/apps/frontend/src/index.css b/apps/frontend/src/index.css index a65839a..f70803d 100644 --- a/apps/frontend/src/index.css +++ b/apps/frontend/src/index.css @@ -56,6 +56,8 @@ --color-imdb: #f5c518; --color-imdb-dark: #e0b015; --color-stremlist: #0d8aee; + /* Strong ease-out for UI enter/reveal motion: starts fast, settles gently. */ + --ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1); } @layer base { diff --git a/apps/frontend/src/pages/Configure.tsx b/apps/frontend/src/pages/Configure.tsx index 6098efe..879c454 100644 --- a/apps/frontend/src/pages/Configure.tsx +++ b/apps/frontend/src/pages/Configure.tsx @@ -8,11 +8,17 @@ import { IMDB_USER_ID_EXTRACT_PATTERN, IMDB_WATCHLIST_SOURCE_ID_EXTRACT_PATTERN, IMDB_WATCHLIST_SOURCE_ID_PATTERN, +} from "@stremlist/shared/constants"; +import { CHART_REGISTRY, CHART_BY_ID, isChartId, -} from "@stremlist/shared"; -import type { UserConfigResponse, ConfigWatchlist } from "@stremlist/shared"; +} from "@stremlist/shared/imdb-charts"; +import type { CatalogSettings } from "@stremlist/shared/catalog-settings"; +import type { + UserConfigResponse, + ConfigWatchlist, +} from "@stremlist/shared/stremio.types"; import { Eye, EyeOff, @@ -25,6 +31,7 @@ import { } from "lucide-react"; import { DragDropProvider } from "@dnd-kit/react"; import { useSortable, isSortable } from "@dnd-kit/react/sortable"; +import CatalogFilterSettings from "../components/CatalogFilterSettings"; import Header from "../components/Header"; import AddonInstallActions from "../components/AddonInstallActions"; import BuiltInCatalogPicker from "../components/BuiltInCatalogPicker"; @@ -62,6 +69,8 @@ type WatchlistFormRow = { catalogTitle: string; sortOption: string; displayMode: string; + catalogSettings: CatalogSettings; + availableGenres: string[]; }; function getWatchlistReinstallSignature(rows: WatchlistFormRow[]): string { @@ -72,10 +81,11 @@ function getWatchlistReinstallSignature(rows: WatchlistFormRow[]): string { imdbUserId: row.imdbUserId.trim(), catalogTitle: row.catalogTitle.trim(), displayMode: row.displayMode, + presets: [...(row.catalogSettings.presets ?? [])].sort().join(","), })) .map( (item) => - `${item.index}|${item.id}|${item.imdbUserId}|${item.catalogTitle}|${item.displayMode}`, + `${item.index}|${item.id}|${item.imdbUserId}|${item.catalogTitle}|${item.displayMode}|${item.presets}`, ) .join("::"); } @@ -90,6 +100,8 @@ function createWatchlistRow( catalogTitle: partial?.catalogTitle ?? "", sortOption: partial?.sortOption ?? DEFAULT_SORT_OPTION, displayMode: partial?.displayMode ?? DEFAULT_DISPLAY_MODE, + catalogSettings: partial?.catalogSettings ?? {}, + availableGenres: partial?.availableGenres ?? [], }; } @@ -282,6 +294,13 @@ function SortableWatchlistRow({ )} + + onFieldChange(watchlist.localId, "catalogSettings", settings) + } + /> ); } @@ -377,6 +396,8 @@ export default function Configure() { catalogTitle: watchlist.catalogTitle, sortOption: watchlist.sortOption, displayMode: watchlist.displayMode, + catalogSettings: watchlist.catalogSettings, + availableGenres: watchlist.availableGenres, }), ); if (rows.length > 0) { @@ -562,6 +583,7 @@ export default function Configure() { sortOption: watchlist.sortOption, displayMode: watchlist.displayMode, position: index, + catalogSettings: watchlist.catalogSettings, })), }, }); @@ -581,7 +603,12 @@ export default function Configure() { current.map((row, index) => { const serverRow = saved.watchlists![index]; return serverRow - ? { ...row, id: serverRow.id, imdbUserId: serverRow.imdbUserId } + ? { + ...row, + id: serverRow.id, + imdbUserId: serverRow.imdbUserId, + availableGenres: serverRow.availableGenres ?? [], + } : row; }), ); @@ -617,6 +644,7 @@ export default function Configure() { ok: boolean; error?: string; lastFetchedAt?: string; + watchlists?: ConfigWatchlist[]; refreshed?: number; failed?: number; total?: number; @@ -631,6 +659,20 @@ export default function Configure() { if (typeof json.cooldownSeconds === "number") setCooldownSeconds(json.cooldownSeconds); if (json.lastFetchedAt) setLastFetchedAt(json.lastFetchedAt); + if (json.watchlists) { + const refreshedRows = json.watchlists; + setWatchlists((current) => + current.map((row) => { + const refreshedRow = refreshedRows.find( + (saved) => + saved.id === row.id && saved.imdbUserId === row.imdbUserId, + ); + return refreshedRow + ? { ...row, availableGenres: refreshedRow.availableGenres ?? [] } + : row; + }), + ); + } // Success feedback is the live "Last refreshed" label + cooldown countdown, // so only surface a message when some catalogs actually failed to refresh. diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index 2e9b421..87dd13e 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -1,25 +1,66 @@ import path from "path"; -import { defineConfig } from "vite"; +import { execFileSync } from "node:child_process"; +import { defineConfig, loadEnv } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { withRelatedProject } from "@vercel/related-projects"; // https://vite.dev/config/ -export default defineConfig({ - plugins: [react(), tailwindcss()], - server: { - host: true, - allowedHosts: [".ts.net"], - }, - resolve: { - alias: { "@": path.resolve(__dirname, "./src") }, - }, - define: { - "import.meta.env.VITE_BACKEND_URL": JSON.stringify( - withRelatedProject({ - projectName: "stremlist-backend", - defaultHost: process.env.VITE_BACKEND_URL ?? "http://localhost:7001", - }), - ), - }, +export default defineConfig(({ mode, command }) => { + const env = loadEnv(mode, __dirname, ["VITE_", "DEV_"]); + const useDevProxy = + command === "serve" && + (Boolean(process.env.PORTLESS_URL) || !env.VITE_BACKEND_URL); + const backendTarget = useDevProxy + ? env.DEV_BACKEND_URL || + (process.env.PORTLESS_URL + ? execFileSync("portless", ["get", "api.stremlist"], { + cwd: __dirname, + encoding: "utf8", + }).trim() + : "http://localhost:7001") + : undefined; + + return { + plugins: [react(), tailwindcss()], + server: { + host: true, + allowedHosts: ["dev-tower", ".ts.net"], + proxy: backendTarget + ? { + "/api": { + target: backendTarget, + changeOrigin: true, + rewrite: (url) => url.replace(/^\/api(?=\/|$)/, ""), + configure: (proxy) => { + proxy.on("proxyRes", (response, request) => { + // Stremio follows the addon URL to configure; keep that + // redirect on the browser's local or tailnet frontend origin. + if ( + /^\/ur\d+\/configure(?:\?|$)/.test(request.url ?? "") && + response.headers.location + ) { + const redirect = new URL(response.headers.location); + response.headers.location = `/configure${redirect.search}`; + } + }); + }, + }, + } + : undefined, + }, + resolve: { + alias: { "@": path.resolve(__dirname, "./src") }, + }, + define: { + "import.meta.env.VITE_BACKEND_URL": JSON.stringify( + useDevProxy + ? "/api" + : withRelatedProject({ + projectName: "stremlist-backend", + defaultHost: env.VITE_BACKEND_URL ?? "http://localhost:7001", + }), + ), + }, + }; }); diff --git a/bun.lock b/bun.lock deleted file mode 100644 index 3fecc2e..0000000 --- a/bun.lock +++ /dev/null @@ -1,100 +0,0 @@ -{ - "lockfileVersion": 1, - "workspaces": { - "": { - "name": "stremlist", - "devDependencies": { - "husky": "^9.1.7", - "lint-staged": "^16.2.7", - "turbo": "^2.8.10", - }, - }, - }, - "packages": { - "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], - - "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - - "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - - "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - - "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], - - "cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], - - "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], - - "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], - - "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - - "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], - - "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], - - "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], - - "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], - - "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], - - "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], - - "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], - - "lint-staged": ["lint-staged@16.3.3", "", { "dependencies": { "commander": "^14.0.3", "listr2": "^9.0.5", "micromatch": "^4.0.8", "string-argv": "^0.3.2", "tinyexec": "^1.0.2", "yaml": "^2.8.2" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-RLq2koZ5fGWrx7tcqx2tSTMQj4lRkfNJaebO/li/uunhCJbtZqwTuwPHpgIimAHHi/2nZIiGrkCHDCOeR1onxA=="], - - "listr2": ["listr2@9.0.5", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="], - - "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="], - - "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - - "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], - - "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], - - "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - - "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], - - "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], - - "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - - "slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], - - "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], - - "string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], - - "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - - "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], - - "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], - - "turbo": ["turbo@2.8.15", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.15", "turbo-darwin-arm64": "2.8.15", "turbo-linux-64": "2.8.15", "turbo-linux-arm64": "2.8.15", "turbo-windows-64": "2.8.15", "turbo-windows-arm64": "2.8.15" }, "bin": { "turbo": "bin/turbo" } }, "sha512-ERZf7pKOR155NKs/PZt1+83NrSEJfUL7+p9/TGZg/8xzDVMntXEFQlX4CsNJQTyu4h3j+dZYiQWOOlv5pssuHQ=="], - - "turbo-darwin-64": ["turbo-darwin-64@2.8.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-EElCh+Ltxex9lXYrouV3hHjKP3HFP31G91KMghpNHR/V99CkFudRcHcnWaorPbzAZizH1m8o2JkLL8rptgb8WQ=="], - - "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ORmvtqHiHwvNynSWvLIleyU8dKtwQ4ILk39VsEwfKSEzSHWYWYxZhBmD9GAGRPlNl7l7S1irrziBlDEGVpq+vQ=="], - - "turbo-linux-64": ["turbo-linux-64@2.8.15", "", { "os": "linux", "cpu": "x64" }, "sha512-Bk1E61a+PCWUTfhqfXFlhEJMLp6nak0J0Qt14IZX1og1zyaiBLkM6M1GQFbPpiWfbUcdLwRaYQhO0ySB07AJ8w=="], - - "turbo-linux-arm64": ["turbo-linux-arm64@2.8.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-3BX0Vk+XkP0uiZc8pkjQGNsAWjk5ojC53bQEMp6iuhSdWpEScEFmcT6p7DL7bcJmhP2mZ1HlAu0A48wrTGCtvg=="], - - "turbo-windows-64": ["turbo-windows-64@2.8.15", "", { "os": "win32", "cpu": "x64" }, "sha512-m14ogunMF+grHZ1jzxSCO6q0gEfF1tmr+0LU+j1QNd/M1X33tfKnQqmpkeUR/REsGjfUlkQlh6PAzqlT3cA3Pg=="], - - "turbo-windows-arm64": ["turbo-windows-arm64@2.8.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-HWh6dnzhl7nu5gRwXeqP61xbyDBNmQ4UCeWNa+si4/6RAtHlKEcZTNs7jf4U+oqBnbtv4uxbKZZPf/kN0EK4+A=="], - - "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], - - "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], - - "log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], - - "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - } -} diff --git a/docs/portless-development.md b/docs/portless-development.md new file mode 100644 index 0000000..0100f7e --- /dev/null +++ b/docs/portless-development.md @@ -0,0 +1,106 @@ +# Portless development + +This repo pins [Portless 0.15.6](https://github.com/vercel-labs/portless) and uses +Node.js 24 for development and CI. Production builds still use the configured +Vercel backend URL; the proxy described here is only for the dev server. + +## Start and share + +1. Install dependencies with `pnpm install` and decrypt `apps/backend/.env`. +2. Run `pnpm exec portless proxy start` once in an interactive terminal. Portless + creates and trusts a local certificate authority and starts its HTTPS proxy. + Standard port 443 and certificate trust may require sudo. A non-interactive + first run fails with setup instructions instead of waiting for a prompt. +3. Run `pnpm dev` for local development, or `pnpm dev:tailnet` to share the frontend. +4. Run `pnpm exec portless list` to see the current local and tailnet URLs. + +Tailscale must be installed, connected, and have HTTPS certificates enabled. +Its HTTPS URL is trusted on the Mac without installing the tower's local CA. +Sharing uses Tailscale Serve, accessible to your tailnet. We do not enable Funnel +or ngrok. Existing Serve routes are preserved: Portless picks the next available +HTTPS port (443, then 8443, 8444, …). Use the printed URL; the port can change +between runs as other apps start or stop. + +`PORTLESS_TAILSCALE=1 pnpm dev` also enables sharing. The backend Turbo tasks +exclude sharing environment variables so only the frontend needs a tailnet route. +Use `PORTLESS_TAILSCALE=0 pnpm dev` for local-only mode if your shell enables +sharing globally. Start through the root Turbo scripts for this behavior. + +## How requests travel + +```text +Mac browser → frontend tailnet HTTPS URL → Vite + └─ /api → Portless → backend +``` + +The package-level `portless` configuration names the frontend `stremlist` and +the backend `api.stremlist`, and runs each package's `dev:app` script. Both get +dynamic ports. The backend honors Portless's `PORT` and `HOST`; Portless injects +Vite's port and host flags automatically. + +Linked worktrees automatically prepend a sanitized branch name to both local +hostnames. Vite calls `portless get api.stremlist` from its own worktree to find +the matching backend, including the proxy's configured port and TLS mode. Keep +the backend package's name and this lookup in sync if renaming it. + +The browser calls relative `/api` URLs. Vite strips that prefix and sets +`changeOrigin: true` so Portless routes by the backend host, avoiding a proxy loop. +Portless supplies `NODE_EXTRA_CA_CERTS` so Vite trusts the backend's local HTTPS +certificate. Addon links become absolute URLs on the browser's current origin; +the `/api/:userId/configure` redirect returns to that same frontend. + +Production builds and the existing E2E harness keep their explicit +`VITE_BACKEND_URL`. The E2E harness starts Vite and the backend directly on its +own ports, without Portless or Tailscale. + +## Useful settings + +| Setting or command | When it helps | +| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `pnpm exec portless get stremlist` | Print this worktree's local frontend URL. | +| `pnpm exec portless get api.stremlist` | Print this worktree's local backend URL. | +| `DEV_BACKEND_URL` | Override the internal proxy target, e.g. an API running in Docker. Put it in `apps/frontend/.env.local` or export it before starting. | +| `VITE_BACKEND_URL` | Explicit browser-facing API URL for production or direct Vite/E2E runs. Portless development uses `/api` even if an old value exists. | +| `pnpm exec portless proxy start -p 1355` | Use an unprivileged proxy port. Initial CA trust can still require sudo. | +| `PORTLESS_STATE_DIR` | Isolate proxy state when needed; both apps must use the same state directory. Choose a different proxy port if another proxy is active. | +| `PORTLESS_SYNC_HOSTS=0` | Disable local hosts-file changes when the browser already resolves `.localhost`. | +| `pnpm exec portless hosts sync` | Repair local hostname resolution, particularly in Safari. This affects the machine running Portless, not other tailnet devices. | +| `pnpm exec portless trust` | Repair trust for the local CA. Tailnet HTTPS uses Tailscale certificates separately. | +| `PORTLESS=0 pnpm dev` | Bypass Portless and run the app commands directly. Backend defaults to 7001, Vite to 5173. | +| `pnpm --filter @stremlist/backend dev:app` | Start just the backend directly. | +| `pnpm --filter @stremlist/frontend dev:app` | Start just Vite directly. Without an explicit API URL, `/api` proxies to localhost:7001. | + +Portless remembers proxy port, TLS, TLD, and LAN settings across restarts. Start +with `portless list` and `portless doctor` when a URL differs from expectations. +Turbo forwards the relevant `PORTLESS_*` settings and proxy overrides to dev tasks. + +Other documented options are optional for this repo: + +- **LAN mode** (`--lan`) uses mDNS `.local` names for devices on the same LAN. + It is separate from tailnet sharing; we use `--tailscale` for the Mac. +- **Custom TLDs** (`--tld test`) and **wildcard hosts** (`--wildcard`) help with + subdomain-based apps. Stremlist does not need them. +- **Fixed app ports** (`--app-port`, `PORTLESS_APP_PORT`, or `appPort`) are useful + for external tools requiring one port. Avoid a shared fixed port for both apps + or multiple worktrees; dynamic assignment prevents collisions. +- **Custom certificates** (`--cert` / `--key`) can replace the local CA. + `--no-tls` disables local HTTPS, but HTTPS is useful for clipboard access and HMR. +- **OS service** (`portless service install`) keeps the shared proxy available + after reboot. It does not start the application dev processes. +- **Aliases** (`portless alias `) expose a separately managed service + such as a Docker container through the proxy. + +## Verify and stop + +Open the frontend URL and check `/api/health` for `{"status":"ok","database":"up"}`. +`/api/manifest.json` should return an addon manifest. + +Stop the owning `pnpm dev` / `pnpm dev:tailnet` process with Ctrl-C. Portless +removes its app routes and Serve registration; its shared proxy can remain up. +Do not run `proxy stop`, `prune`, or `clean` while other projects use it. `prune` +terminates orphaned app processes; `clean` removes shared state, local CA trust, +and hosts entries. `doctor` is read-only and is the first troubleshooting step. + +References reviewed: [README](https://github.com/vercel-labs/portless#readme), +[configuration](https://portless.sh/configuration), +[commands](https://portless.sh/commands), and the installed CLI's `--help`. diff --git a/package.json b/package.json index 970b1ab..998facb 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "test:e2e": "turbo run test:e2e", "typecheck": "turbo run typecheck", "generate:types": "pnpm supabase gen types typescript --project-id igbmxubzxjfdbgmawrtr --schema public > packages/shared/src/database.types.ts", - "prepare": "husky" + "prepare": "husky", + "dev:tailnet": "turbo run dev:tailnet" }, "engines": { "node": ">=20.0.0" @@ -40,6 +41,7 @@ "devDependencies": { "husky": "^9.1.7", "lint-staged": "^16.2.7", + "portless": "0.15.6", "turbo": "^2.9.6" } } diff --git a/packages/eslint-config/base.js b/packages/eslint-config/base.js index f42e6a9..34ae9d8 100644 --- a/packages/eslint-config/base.js +++ b/packages/eslint-config/base.js @@ -1,8 +1,10 @@ +import noBarrels from "./no-barrels.js"; import js from "@eslint/js"; import tseslint from "typescript-eslint"; /** @type {import("typescript-eslint").Config} */ export default [ + ...noBarrels, js.configs.recommended, ...tseslint.configs.recommended, ]; diff --git a/packages/eslint-config/hono.js b/packages/eslint-config/hono.js index 69237d0..4c914c8 100644 --- a/packages/eslint-config/hono.js +++ b/packages/eslint-config/hono.js @@ -1,5 +1,4 @@ +import noBarrels from "./no-barrels.js"; import honoConfig from "@hono/eslint-config"; -export default [ - ...honoConfig, -]; +export default [...noBarrels, ...honoConfig]; diff --git a/packages/eslint-config/no-barrels.js b/packages/eslint-config/no-barrels.js new file mode 100644 index 0000000..ea78db4 --- /dev/null +++ b/packages/eslint-config/no-barrels.js @@ -0,0 +1,32 @@ +export default [ + { + rules: { + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "@stremlist/shared", + message: + "Import from the defining @stremlist/shared subpath instead.", + }, + ], + }, + ], + }, + }, + { + files: ["**/index.{ts,tsx,js,jsx,mts,cts,mjs,cjs}"], + rules: { + "no-restricted-syntax": [ + "error", + { + selector: + "ExportAllDeclaration, ExportNamedDeclaration[source!=null], ExportNamedDeclaration > ExportSpecifier", + message: + "Barrel index files are not allowed. Import from the defining module instead.", + }, + ], + }, + }, +]; diff --git a/packages/shared/package.json b/packages/shared/package.json index 31a718f..e431a07 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -2,10 +2,12 @@ "name": "@stremlist/shared", "version": "0.0.1", "type": "module", - "main": "./src/index.ts", - "types": "./src/index.ts", "exports": { - ".": "./src/index.ts" + "./catalog-settings": "./src/catalog-settings.ts", + "./constants": "./src/constants.ts", + "./database.types": "./src/database.types.ts", + "./imdb-charts": "./src/imdb-charts.ts", + "./stremio.types": "./src/stremio.types.ts" }, "scripts": { "lint": "eslint .", diff --git a/packages/shared/src/catalog-settings.ts b/packages/shared/src/catalog-settings.ts new file mode 100644 index 0000000..7840489 --- /dev/null +++ b/packages/shared/src/catalog-settings.ts @@ -0,0 +1,21 @@ +export const CATALOG_PRESETS = [ + { id: "short", label: "90 min or less" }, + { id: "rated", label: "Top rated" }, + { id: "shuffle", label: "Shuffle" }, +] as const; + +export type CatalogPreset = (typeof CATALOG_PRESETS)[number]["id"]; + +export interface CatalogSettings { + genre?: string; + decade?: number; + maxRuntime?: number; + minRating?: number; + presets?: CatalogPreset[]; +} + +const currentDecade = Math.floor(new Date().getFullYear() / 10) * 10; +export const CATALOG_DECADES = Array.from( + { length: (currentDecade - 1880) / 10 + 1 }, + (_, index) => `${currentDecade - index * 10}s`, +); diff --git a/packages/shared/src/database.types.ts b/packages/shared/src/database.types.ts index f6a68db..f10dbb9 100644 --- a/packages/shared/src/database.types.ts +++ b/packages/shared/src/database.types.ts @@ -16,6 +16,7 @@ export type Database = { Tables: { user_watchlists: { Row: { + catalog_settings: Json; catalog_title: string; created_at: string; display_mode: string; @@ -27,6 +28,7 @@ export type Database = { updated_at: string; }; Insert: { + catalog_settings?: Json; catalog_title: string; created_at?: string; display_mode?: string; @@ -38,6 +40,7 @@ export type Database = { updated_at?: string; }; Update: { + catalog_settings?: Json; catalog_title?: string; created_at?: string; display_mode?: string; @@ -99,6 +102,14 @@ export type Database = { [_ in never]: never; }; Functions: { + replace_user_config: { + Args: { + p_owner_user_id: string; + p_rpdb_api_key: string | null; + p_watchlists: Json; + }; + Returns: { deleted_ids: string[]; watchlists: Json }[]; + }; finish_watchlist_prewarm: { Args: { p_completed_generation: number; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts deleted file mode 100644 index 881e147..0000000 --- a/packages/shared/src/index.ts +++ /dev/null @@ -1,58 +0,0 @@ -export type { - Database, - Json, - Tables, - TablesInsert, - TablesUpdate, -} from "./database.types"; - -export type { - WatchlistData, - StremioMeta, - ConfigWatchlist, - StremioManifest, - StremioCatalog, - StremioResource, - StremioConfigOption, - UserConfigResponse, - UserConfigUpdatePayload, - UserConfigUpdateWatchlist, -} from "./stremio.types"; - -export { - APP_NAME, - ADDON_VERSION, - APP_DESCRIPTION, - APP_LOGO, - APP_ID_PREFIX, - SORT_OPTIONS, - DEFAULT_SORT_OPTION, - DEFAULT_SORT_OPTIONS, - DISPLAY_MODE_OPTIONS, - DEFAULT_DISPLAY_MODE, - parseSortOption, - IMDB_USER_AGENT, - FACEBOOK_EXTERNAL_HIT_USER_AGENT, - BASE_MANIFEST, - IMDB_LIST_ID_PATTERN, - IMDB_USER_ID_PATTERN, - IMDB_WATCHLIST_SOURCE_ID_PATTERN, - IMDB_USER_ID_EXTRACT_PATTERN, - IMDB_WATCHLIST_SOURCE_ID_EXTRACT_PATTERN, -} from "./constants"; - -export type { - SortField, - SortOrder, - SortOptions, - DisplayMode, -} from "./constants"; - -export { - CHART_REGISTRY, - CHART_BY_ID, - CHART_ID_SET, - isChartId, -} from "./imdb-charts"; - -export type { ChartKind, ChartEntry } from "./imdb-charts"; diff --git a/packages/shared/src/stremio.types.ts b/packages/shared/src/stremio.types.ts index 542088d..ab43aa6 100644 --- a/packages/shared/src/stremio.types.ts +++ b/packages/shared/src/stremio.types.ts @@ -1,3 +1,4 @@ +import type { CatalogSettings } from "./catalog-settings"; import type { DisplayMode } from "./constants"; export interface WatchlistData { @@ -11,6 +12,8 @@ export interface ConfigWatchlist { sortOption: string; displayMode: DisplayMode; position: number; + availableGenres?: string[]; + catalogSettings?: CatalogSettings; } export interface UserConfigResponse { @@ -27,6 +30,7 @@ export interface UserConfigUpdateWatchlist { sortOption: string; displayMode?: DisplayMode; position?: number; + catalogSettings?: CatalogSettings; } export interface UserConfigUpdatePayload { @@ -47,6 +51,7 @@ export interface StremioMeta { director?: string[]; cast?: string[]; runtime?: string; + released?: string; } export interface StremioCatalog { @@ -54,8 +59,10 @@ export interface StremioCatalog { name: string; type: "movie" | "series"; extra?: { - name: "skip"; + name: "skip" | "genre" | "search"; isRequired?: boolean; + options?: string[]; + optionsLimit?: number; }[]; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b0429f..1de1240 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: lint-staged: specifier: ^16.2.7 version: 16.2.7 + portless: + specifier: 0.15.6 + version: 0.15.6 turbo: specifier: ^2.9.6 version: 2.9.16 @@ -132,6 +135,9 @@ importers: apps/frontend: dependencies: + '@base-ui/react': + specifier: ^1.8.0 + version: 1.8.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@dnd-kit/helpers': specifier: ^0.3.2 version: 0.3.2 @@ -430,6 +436,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -442,6 +452,33 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@base-ui/react@1.8.0': + resolution: {integrity: sha512-P0/1sxo6SBVZOklKMIedvTWqw2s2IQzi9x5bIVsXu980cuSOD4NeuRSs+/L7LZQfDkZP/uRZyGPyfFl/B1oH+Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@date-fns/tz': ^1.2.0 + '@types/react': ^17 || ^18 || ^19 + date-fns: ^4.0.0 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@date-fns/tz': + optional: true + '@types/react': + optional: true + date-fns: + optional: true + + '@base-ui/utils@0.4.0': + resolution: {integrity: sha512-bO9fz25kKtPf+aZVyfQrC0PDmJdmVni31W2hCS5/Owb+inwdIL3XU26pCPRPlt4LSxZrBgLwubXQXQlKaFEZzw==} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + '@dnd-kit/abstract@0.3.2': resolution: {integrity: sha512-uvPVK+SZYD6Viddn9M0K0JQdXknuVSxA/EbMlFRanve3P/XTc18oLa5zGftKSGjfQGmuzkZ34E26DSbly1zi3Q==} @@ -828,18 +865,33 @@ packages: '@floating-ui/core@1.7.4': resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + '@floating-ui/dom@1.7.5': resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + '@floating-ui/react-dom@2.1.7': resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@hono/eslint-config@2.0.6': resolution: {integrity: sha512-6qr2/YYjJil+gxyQRwLjXIevfkl0VyeQuipW7T0jpzs08nSs/w9m9i56BhRi4JDWPFP4+SKvELW1UuZVdaVTWg==} peerDependencies: @@ -2649,6 +2701,12 @@ packages: engines: {node: '>=20'} hasBin: true + portless@0.15.6: + resolution: {integrity: sha512-uOAwWLF32rmyEGFASzSO0VOaqb/AQxFCCzyZbPGd82UNNOfIEvc09zy92nroNibE5HNfzV4oVB0ObKbPXgkM9A==} + engines: {node: '>=24'} + os: [darwin, linux, win32] + hasBin: true + postal-mime@2.7.3: resolution: {integrity: sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==} @@ -2728,6 +2786,9 @@ packages: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} + reselect@5.3.0: + resolution: {integrity: sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==} + resend@6.9.2: resolution: {integrity: sha512-uIM6CQ08tS+hTCRuKBFbOBvHIGaEhqZe8s4FOgqsVXSbQLAhmNWpmUhG3UAtRnmcwTWFUqnHa/+Vux8YGPyDBA==} engines: {node: '>=20'} @@ -2953,6 +3014,11 @@ packages: '@types/react': optional: true + use-sync-external-store@1.7.0: + resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} hasBin: true @@ -3349,6 +3415,8 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 + '@babel/runtime@7.29.7': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -3372,6 +3440,29 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@base-ui/react@1.8.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.29.7 + '@base-ui/utils': 0.4.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/react-dom': 2.1.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/utils': 0.2.12 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + use-sync-external-store: 1.7.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + '@base-ui/utils@0.4.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.29.7 + '@floating-ui/utils': 0.2.12 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + reselect: 5.3.0 + use-sync-external-store: 1.7.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@dnd-kit/abstract@0.3.2': dependencies: '@dnd-kit/geometry': 0.3.2 @@ -3638,19 +3729,36 @@ snapshots: dependencies: '@floating-ui/utils': 0.2.10 + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + '@floating-ui/dom@1.7.5': dependencies: '@floating-ui/core': 1.7.4 '@floating-ui/utils': 0.2.10 + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + '@floating-ui/react-dom@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@floating-ui/dom': 1.7.5 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + '@floating-ui/react-dom@2.1.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/dom': 1.8.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + '@floating-ui/utils@0.2.10': {} + '@floating-ui/utils@0.2.12': {} + '@hono/eslint-config@2.0.6(@typescript-eslint/utils@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint/js': 9.39.2 @@ -5287,6 +5395,8 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + portless@0.15.6: {} + postal-mime@2.7.3: {} postcss@8.5.6: @@ -5349,6 +5459,8 @@ snapshots: react@19.2.4: {} + reselect@5.3.0: {} + resend@6.9.2: dependencies: postal-mime: 2.7.3 @@ -5586,6 +5698,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + use-sync-external-store@1.7.0(react@19.2.4): + dependencies: + react: 19.2.4 + uuid@10.0.0: {} vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): diff --git a/supabase/migrations/20260914230000_catalog_settings.sql b/supabase/migrations/20260914230000_catalog_settings.sql new file mode 100644 index 0000000..304d16c --- /dev/null +++ b/supabase/migrations/20260914230000_catalog_settings.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.user_watchlists + ADD COLUMN IF NOT EXISTS catalog_settings jsonb NOT NULL DEFAULT '{}'::jsonb; diff --git a/supabase/migrations/20260915080000_replace_user_config.sql b/supabase/migrations/20260915080000_replace_user_config.sql new file mode 100644 index 0000000..ad32241 --- /dev/null +++ b/supabase/migrations/20260915080000_replace_user_config.sql @@ -0,0 +1,91 @@ +CREATE FUNCTION public.replace_user_config( + p_owner_user_id text, + p_rpdb_api_key text, + p_watchlists jsonb +) +RETURNS TABLE (deleted_ids uuid[], watchlists jsonb) +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = '' +AS $$ +DECLARE + item jsonb; +BEGIN + -- Serialize replacements, including requests that only create new rows. + PERFORM 1 FROM public.users + WHERE imdb_user_id = p_owner_user_id FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'User not found'; + END IF; + + IF jsonb_typeof(p_watchlists) IS DISTINCT FROM 'array' THEN + RAISE EXCEPTION 'Watchlists must be an array'; + END IF; + + IF EXISTS ( + SELECT 1 FROM jsonb_array_elements(p_watchlists) AS entries(value) + WHERE value ? 'id' AND NOT EXISTS ( + SELECT 1 FROM public.user_watchlists uw + WHERE uw.id = (value->>'id')::uuid + AND uw.owner_user_id = p_owner_user_id + ) + ) THEN + RAISE EXCEPTION 'Watchlist does not belong to this user'; + END IF; + + IF EXISTS ( + SELECT value->>'id' FROM jsonb_array_elements(p_watchlists) AS entries(value) + WHERE value ? 'id' GROUP BY value->>'id' HAVING count(*) > 1 + ) THEN + RAISE EXCEPTION 'Duplicate watchlist ID'; + END IF; + + WITH removed AS ( + DELETE FROM public.user_watchlists uw + WHERE uw.owner_user_id = p_owner_user_id + AND NOT EXISTS ( + SELECT 1 FROM jsonb_array_elements(p_watchlists) AS entries(value) + WHERE (value->>'id')::uuid = uw.id + ) + RETURNING uw.id + ) + SELECT coalesce(array_agg(id), '{}'::uuid[]) INTO deleted_ids FROM removed; + + FOR item IN SELECT value FROM jsonb_array_elements(p_watchlists) + LOOP + INSERT INTO public.user_watchlists AS uw ( + id, owner_user_id, imdb_user_id, catalog_title, sort_option, + display_mode, position, catalog_settings + ) VALUES ( + coalesce((item->>'id')::uuid, gen_random_uuid()), + p_owner_user_id, item->>'imdb_user_id', item->>'catalog_title', + item->>'sort_option', item->>'display_mode', + (item->>'position')::integer, coalesce(item->'catalog_settings', '{}'::jsonb) + ) + ON CONFLICT (id) DO UPDATE SET + imdb_user_id = EXCLUDED.imdb_user_id, + catalog_title = EXCLUDED.catalog_title, + sort_option = EXCLUDED.sort_option, + display_mode = EXCLUDED.display_mode, + position = EXCLUDED.position, + -- Omitted settings preserve the locked row, not a client-side snapshot. + catalog_settings = CASE WHEN item ? 'catalog_settings' + THEN EXCLUDED.catalog_settings ELSE uw.catalog_settings END, + updated_at = now() + WHERE uw.owner_user_id = p_owner_user_id; + END LOOP; + + UPDATE public.users SET rpdb_api_key = p_rpdb_api_key + WHERE imdb_user_id = p_owner_user_id; + + SELECT coalesce(jsonb_agg(to_jsonb(uw) ORDER BY uw.position, uw.created_at), '[]'::jsonb) + INTO watchlists FROM public.user_watchlists uw + WHERE uw.owner_user_id = p_owner_user_id; + RETURN NEXT; +END; +$$; + +REVOKE ALL ON FUNCTION public.replace_user_config(text, text, jsonb) +FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.replace_user_config(text, text, jsonb) +TO service_role; diff --git a/supabase/tests/replace_user_config.sql b/supabase/tests/replace_user_config.sql new file mode 100644 index 0000000..e24d868 --- /dev/null +++ b/supabase/tests/replace_user_config.sql @@ -0,0 +1,88 @@ +-- Run with psql -v ON_ERROR_STOP=1 against a migrated local database. +BEGIN; + +INSERT INTO public.users (imdb_user_id, rpdb_api_key) +VALUES ('config-transaction-test', 'old-key'), ('config-other-test', NULL); +INSERT INTO public.user_watchlists ( + id, owner_user_id, imdb_user_id, catalog_title, position, catalog_settings +) VALUES + ('11111111-1111-4111-8111-111111111111', 'config-transaction-test', 'ur1', 'One', 0, '{"minRating":8}'), + ('22222222-2222-4222-8222-222222222222', 'config-transaction-test', 'ur2', 'Two', 1, '{"minRating":5}'), + ('33333333-3333-4333-8333-333333333333', 'config-transaction-test', 'ur3', 'Removed', 2, '{}'), + ('44444444-4444-4444-8444-444444444444', 'config-other-test', 'ur4', 'Other user', 0, '{}'); + +DO $$ +DECLARE + payload jsonb := '[ + {"id":"11111111-1111-4111-8111-111111111111","imdb_user_id":"ur1","catalog_title":"Changed","sort_option":"title-asc","display_mode":"split","position":0}, + {"id":"22222222-2222-4222-8222-222222222222","imdb_user_id":"ur2","catalog_title":"Two","sort_option":"title-desc","display_mode":"split","position":1,"catalog_settings":{}} + ]'; + before_rows jsonb; + result record; +BEGIN + SELECT jsonb_agg(to_jsonb(uw) ORDER BY id) INTO before_rows FROM public.user_watchlists uw; + + -- The second write fails after the deletion and first update have executed. + BEGIN + PERFORM public.replace_user_config('config-transaction-test', 'new-key', + jsonb_set(payload, '{1,imdb_user_id}', '"ur1"')); + RAISE EXCEPTION 'Expected unique violation'; + EXCEPTION WHEN unique_violation THEN NULL; + END; + ASSERT (SELECT jsonb_agg(to_jsonb(uw) ORDER BY id) FROM public.user_watchlists uw) = before_rows, + 'A failed write must roll back deletions and earlier updates'; + ASSERT (SELECT rpdb_api_key FROM public.users WHERE imdb_user_id = 'config-transaction-test') = 'old-key'; + + -- Failure in the final users update must roll back the watchlists too. + ALTER TABLE public.users ADD CONSTRAINT config_test_key CHECK (rpdb_api_key IS DISTINCT FROM 'reject-key'); + BEGIN + PERFORM public.replace_user_config('config-transaction-test', 'reject-key', payload); + RAISE EXCEPTION 'Expected check violation'; + EXCEPTION WHEN check_violation THEN NULL; + END; + ASSERT (SELECT jsonb_agg(to_jsonb(uw) ORDER BY id) FROM public.user_watchlists uw) = before_rows; + ALTER TABLE public.users DROP CONSTRAINT config_test_key; + + BEGIN + PERFORM public.replace_user_config('config-transaction-test', NULL, + jsonb_set(payload, '{0,id}', '"44444444-4444-4444-8444-444444444444"')); + RAISE EXCEPTION 'Expected ownership rejection'; + EXCEPTION WHEN raise_exception THEN + IF SQLERRM <> 'Watchlist does not belong to this user' THEN RAISE; END IF; + END; + ASSERT (SELECT jsonb_agg(to_jsonb(uw) ORDER BY id) FROM public.user_watchlists uw) = before_rows; + + BEGIN + PERFORM public.replace_user_config('config-transaction-test', NULL, + jsonb_set(payload, '{1,id}', payload->0->'id')); + RAISE EXCEPTION 'Expected duplicate ID rejection'; + EXCEPTION WHEN raise_exception THEN + IF SQLERRM <> 'Duplicate watchlist ID' THEN RAISE; END IF; + END; + ASSERT (SELECT jsonb_agg(to_jsonb(uw) ORDER BY id) FROM public.user_watchlists uw) = before_rows; + + SELECT * INTO result FROM public.replace_user_config('config-transaction-test', 'new-key', payload); + ASSERT result.deleted_ids = ARRAY['33333333-3333-4333-8333-333333333333'::uuid]; + ASSERT jsonb_array_length(result.watchlists) = 2; + ASSERT result.watchlists->0->>'id' = payload->0->>'id', 'Updates must preserve IDs'; + ASSERT result.watchlists->1->>'id' = payload->1->>'id'; + ASSERT result.watchlists->0->>'catalog_title' = 'Changed'; + ASSERT result.watchlists->0->>'sort_option' = 'title-asc'; + ASSERT result.watchlists->0->'catalog_settings' = '{"minRating":8}'::jsonb, 'Omitted settings must survive'; + ASSERT result.watchlists->1->'catalog_settings' = '{}'::jsonb, 'Explicit empty settings must clear'; + ASSERT (SELECT rpdb_api_key FROM public.users WHERE imdb_user_id = 'config-transaction-test') = 'new-key'; + + SELECT * INTO result FROM public.replace_user_config('config-transaction-test', NULL, + '[{"imdb_user_id":"ur1","catalog_title":"New","sort_option":"title-asc","display_mode":"split","position":0}]'); + ASSERT cardinality(result.deleted_ids) = 2; + ASSERT result.watchlists->0->'catalog_settings' = '{}'::jsonb; + ASSERT result.watchlists->0->>'id' IS NOT NULL; + ASSERT (SELECT rpdb_api_key FROM public.users WHERE imdb_user_id = 'config-transaction-test') IS NULL; + + ASSERT NOT has_function_privilege('anon', 'public.replace_user_config(text,text,jsonb)', 'EXECUTE'); + ASSERT NOT has_function_privilege('authenticated', 'public.replace_user_config(text,text,jsonb)', 'EXECUTE'); + ASSERT has_function_privilege('service_role', 'public.replace_user_config(text,text,jsonb)', 'EXECUTE'); +END; +$$; + +ROLLBACK; diff --git a/turbo.json b/turbo.json index f1c48f2..6a60a10 100644 --- a/turbo.json +++ b/turbo.json @@ -9,7 +9,15 @@ }, "dev": { "cache": false, - "persistent": true + "persistent": true, + "passThroughEnv": [ + "PORTLESS", + "PORTLESS_*", + "NODE_EXTRA_CA_CERTS", + "VITE_BACKEND_URL", + "DEV_BACKEND_URL", + "VERCEL_RELATED_PROJECTS" + ] }, "transit": { "dependsOn": ["^transit"] @@ -27,6 +35,18 @@ }, "typecheck": { "dependsOn": ["^build"] + }, + "dev:tailnet": { + "cache": false, + "persistent": true, + "passThroughEnv": [ + "PORTLESS", + "PORTLESS_*", + "NODE_EXTRA_CA_CERTS", + "VITE_BACKEND_URL", + "DEV_BACKEND_URL", + "VERCEL_RELATED_PROJECTS" + ] } } }