diff --git a/cmd/silo/main.go b/cmd/silo/main.go index cbec4dd5..383b9eb8 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -866,23 +866,34 @@ func main() { if err != nil { slog.Warn("failed to seed metadata queues", "error", err) } else { + seedMovieQueue := func(folderID int) { + if movieQueueRepo == nil { + return + } + if err := movieQueueRepo.SyncForFolder(appCtx, folderID); err != nil { + slog.Warn("failed to seed movie match queue", "folder_id", folderID, "error", err) + } + } + seedSeriesQueue := func(folderID int) { + if seriesQueueRepo == nil { + return + } + if err := seriesQueueRepo.SyncForFolder(appCtx, folderID); err != nil { + slog.Warn("failed to seed series root queue", "folder_id", folderID, "error", err) + } + } for _, folder := range enabledFolders { if folder == nil { continue } switch strings.ToLower(strings.TrimSpace(folder.Type)) { case "movie", "movies": - if movieQueueRepo != nil { - if err := movieQueueRepo.SyncForFolder(appCtx, folder.ID); err != nil { - slog.Warn("failed to seed movie match queue", "folder_id", folder.ID, "error", err) - } - } + seedMovieQueue(folder.ID) case "series", "tv", "show", "tvshows": - if seriesQueueRepo != nil { - if err := seriesQueueRepo.SyncForFolder(appCtx, folder.ID); err != nil { - slog.Warn("failed to seed series root queue", "folder_id", folder.ID, "error", err) - } - } + seedSeriesQueue(folder.ID) + case "mixed": + seedSeriesQueue(folder.ID) + seedMovieQueue(folder.ID) } } } @@ -1665,6 +1676,9 @@ func main() { // Construct auth service for jellycompat login. userRepo := auth.NewUserRepository(deps.DB) + compatDeps.APIKeyValidator = auth.NewAPIKeyRepository(deps.DB) + compatDeps.APIKeyUserLoader = userRepo + compatDeps.ScanQueue = deps.LibraryScanQueue sessionRepo := auth.NewSessionRepository(deps.DB) jwtService := auth.NewJWTService( cfg.Auth.JWTSecret, diff --git a/docs/scan-api.md b/docs/scan-api.md index 70ebb0d2..0df2f89c 100644 --- a/docs/scan-api.md +++ b/docs/scan-api.md @@ -185,9 +185,22 @@ curl -X POST http://your-server:8090/api/v1/scan \ ## Integration with Autoscan -[Autoscan](https://github.com/Cloudbox/autoscan) monitors Sonarr, Radarr, and other sources for new downloads, then relays scan requests to media servers. To use Autoscan with Silo, configure a **manual/generic target** using a custom script or webhook that calls the Silo scan API. +[Autoscan](https://github.com/Cloudbox/autoscan) monitors Sonarr, Radarr, and +other sources for new downloads, then relays scan requests to media servers. +Silo supports Autoscan's stock Jellyfin target through the Jellyfin compatibility +server. -### Autoscan Custom Script Target +Use: + +- URL: Silo's Jellyfin compatibility URL, usually `http://your-server:8096` +- Token: a Silo admin API key beginning with `sa_` +- Target type: Autoscan `jellyfin` + +Autoscan discovers library roots from `GET /Library/VirtualFolders` and sends +changed paths to `POST /Library/Media/Updated`. The paths must be server-side +paths as Silo sees them. + +### Alternative: Autoscan Custom Script Target Create a script (e.g., `silo-scan.sh`) that Autoscan calls with the changed path: diff --git a/docs/superpowers/plans/2026-05-25-search-request-section.md b/docs/superpowers/plans/2026-05-25-search-request-section.md new file mode 100644 index 00000000..b276015c --- /dev/null +++ b/docs/superpowers/plans/2026-05-25-search-request-section.md @@ -0,0 +1,1915 @@ +# Search Request Section Implementation Plan + +Commands assume the repository root is the cwd. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a TMDB-backed "Request to Add" section beneath library results in both the Cmd+K search dialog (`GlobalSearch`) and the Catalog search results page, so users can discover and request items missing from the library without leaving the search flow. + +**Architecture:** No backend changes. Frontend fires two parallel react-query queries — library FTS via the existing `/api/v1/catalog` endpoint, and TMDB via the existing `/api/v1/requests/search` endpoint. A new `useCanRequest()` hook gates whether the TMDB query fires (admin `RequestsEnabled` + authenticated viewer with a profile). Per-row UI state (blocked / quota / pending / etc.) is driven by the backend-enriched `request.requestable` and `request.reason` fields already returned per result. The existing `useRequestSearch` hook is extended to forward `AbortSignal`, key its cache by viewer identity, and be invalidated on auth/profile/policy mutations. + +**Tech Stack:** React 18, TypeScript, vitest, @tanstack/react-query, react-router, Tailwind. All changes are in `web/` (Go backend untouched). + +**Reference spec:** `docs/superpowers/specs/2026-05-25-search-request-section-design.md` + +--- + +## File Structure + +**New files:** + +- `web/src/hooks/useCanRequest.ts` — gating hook returning `{ discoveryEnabled, submitDisabledReason }`. +- `web/src/hooks/useCanRequest.test.ts` — hook unit tests. +- `web/src/components/RequestToAddSection.tsx` — shared section component with `variant="dialog"` and `variant="grid"`. +- `web/src/components/RequestToAddSection.test.tsx` — component tests. + +**Modified files:** + +- `web/src/api/client.ts` — extend `api()` to forward `AbortSignal` from `RequestInit`. +- `web/src/hooks/queries/keys.ts` — extend `requestKeys.search()` to include viewer key. +- `web/src/hooks/queries/useRequests.ts` — extend `useRequestSearch` to accept `signal`, include viewer in key, and add invalidation helpers; wire invalidation into existing settings/limit mutations. +- `web/src/components/RequestPosterCard.tsx` — make `onRequest` and `isSubmitting` optional on `DiscoverProps`; suppress the hover Request button when `onRequest` is undefined. +- `web/src/components/GlobalSearch.tsx` — wire the second query and render `RequestToAddSection` with `variant="dialog"`. +- `web/src/components/GlobalSearch.test.tsx` — add tests for the new section behavior. +- `web/src/pages/Catalog.tsx` — render `RequestToAddSection` with `variant="grid"` below the existing `ItemGrid` when `source === "query"`. +- `web/src/pages/Catalog.test.ts` (or `.tsx` if new) — add tests for the section behavior in the full-page surface. + +--- + +## Design notes on `submitDisabledReason` + +The spec calls for `useCanRequest()` to return `submitDisabledReason: string | null`. The backend already enriches each TMDB result with per-row `request.requestable: boolean` and `request.reason?: string` via `enrichPage()` → `presence.Lookup()`. That per-row data is the canonical source of truth for the disabled state. The viewer-level field is included in the hook's return type for spec conformance and future use, but its value is `null` in this implementation. Per-row UI uses the result's own `request.requestable` and `request.reason` directly. This is consistent with the existing `RequestPosterCard` which already renders a "blocked" StatusRibbon when a row is not requestable. + +--- + +## Task 1: Pin `api()` `AbortSignal` forwarding via test + +**Files:** +- Create: `web/src/api/client.test.ts` + +`api()` at `web/src/api/client.ts:337` calls `fetch(\`/api/v1${path}\`, { ...options, headers })`. The `...options` spread already forwards `signal` to `fetch`, so behavior is correct today. This task does NOT change behavior — it adds a regression test that locks in the contract so a future refactor cannot accidentally drop signal forwarding. + +- [ ] **Step 1: Write the test** + +Create `web/src/api/client.test.ts`: + +```typescript +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { api } from "./client"; + +describe("api()", () => { + let originalFetch: typeof fetch; + + beforeEach(() => { + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("forwards AbortSignal from options to fetch", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + global.fetch = fetchMock as unknown as typeof fetch; + + const controller = new AbortController(); + await api("/test", { signal: controller.signal }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const call = fetchMock.mock.calls[0]!; + const init = call[1] as RequestInit; + expect(init.signal).toBe(controller.signal); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it passes** + +Run: `cd web && pnpm vitest run src/api/client.test.ts` +Expected: PASS — the existing `...options` spread already forwards `signal`. No code change required. + +- [ ] **Step 3: Commit** + +```bash +git add web/src/api/client.test.ts +git commit -m "test(api): pin AbortSignal forwarding contract on api()" +``` + +--- + +## Task 2: Create `useCanRequest()` gating hook + +**Files:** +- Create: `web/src/hooks/useCanRequest.ts` +- Create: `web/src/hooks/useCanRequest.test.ts` + +`useCanRequest()` reads `useRequestFeatureStatus()` and `useCurrentProfile()` and returns `{ discoveryEnabled, submitDisabledReason }`. Discovery is enabled only when the admin flag is on AND there is a profile loaded. Per the design note above, `submitDisabledReason` is always `null` in this implementation — per-row data drives the actual UI. + +- [ ] **Step 1: Write the failing test** + +Create `web/src/hooks/useCanRequest.test.ts`: + +```typescript +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const mocks = vi.hoisted(() => ({ + useRequestFeatureStatus: vi.fn(), + useCurrentProfile: vi.fn(), +})); + +vi.mock("@/hooks/queries/useRequests", () => ({ + useRequestFeatureStatus: () => mocks.useRequestFeatureStatus(), +})); + +vi.mock("@/hooks/useCurrentProfile", () => ({ + useCurrentProfile: () => mocks.useCurrentProfile(), +})); + +import { useCanRequest } from "./useCanRequest"; + +function CaptureHook({ onResult }: { onResult: (r: ReturnType) => void }) { + const result = useCanRequest(); + onResult(result); + return null; +} + +function render(child: ReactNode) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return renderToStaticMarkup({child}); +} + +describe("useCanRequest", () => { + it("returns discoveryEnabled=false when requests_enabled is false", () => { + mocks.useRequestFeatureStatus.mockReturnValue({ data: { requests_enabled: false } }); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p1" } }); + + let captured: ReturnType | null = null; + render( { captured = r; }} />); + + expect(captured).toEqual({ discoveryEnabled: false, submitDisabledReason: null }); + }); + + it("returns discoveryEnabled=false when there is no profile", () => { + mocks.useRequestFeatureStatus.mockReturnValue({ data: { requests_enabled: true } }); + mocks.useCurrentProfile.mockReturnValue({ profile: null }); + + let captured: ReturnType | null = null; + render( { captured = r; }} />); + + expect(captured).toEqual({ discoveryEnabled: false, submitDisabledReason: null }); + }); + + it("returns discoveryEnabled=true when requests are enabled and there is a profile", () => { + mocks.useRequestFeatureStatus.mockReturnValue({ data: { requests_enabled: true } }); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p1" } }); + + let captured: ReturnType | null = null; + render( { captured = r; }} />); + + expect(captured).toEqual({ discoveryEnabled: true, submitDisabledReason: null }); + }); + + it("returns discoveryEnabled=false while the feature status is still loading", () => { + mocks.useRequestFeatureStatus.mockReturnValue({ data: undefined }); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p1" } }); + + let captured: ReturnType | null = null; + render( { captured = r; }} />); + + expect(captured).toEqual({ discoveryEnabled: false, submitDisabledReason: null }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd web && pnpm vitest run src/hooks/useCanRequest.test.ts` +Expected: FAIL — `useCanRequest` does not exist yet. + +- [ ] **Step 3: Create the hook** + +Create `web/src/hooks/useCanRequest.ts`: + +```typescript +import { useCurrentProfile } from "@/hooks/useCurrentProfile"; +import { useRequestFeatureStatus } from "@/hooks/queries/useRequests"; + +export interface CanRequestState { + discoveryEnabled: boolean; + submitDisabledReason: string | null; +} + +export function useCanRequest(): CanRequestState { + const status = useRequestFeatureStatus(); + const { profile } = useCurrentProfile(); + const discoveryEnabled = Boolean(status.data?.requests_enabled) && Boolean(profile?.id); + return { + discoveryEnabled, + submitDisabledReason: null, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd web && pnpm vitest run src/hooks/useCanRequest.test.ts` +Expected: PASS, all four cases. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/hooks/useCanRequest.ts web/src/hooks/useCanRequest.test.ts +git commit -m "feat(hooks): add useCanRequest gating hook for discovery eligibility" +``` + +--- + +## Task 3: Extend `requestKeys.search` to include viewer identity + +**Files:** +- Modify: `web/src/hooks/queries/keys.ts:135-136` + +Add a `viewerKey` parameter so the cache cannot serve results across viewer changes. + +- [ ] **Step 1: Update the key shape** + +Open `web/src/hooks/queries/keys.ts` and replace lines 135-136: + +```typescript + search: (mediaType: string, query: string, page: number, viewerKey: string) => + ["requests", "search", viewerKey, mediaType, query, page] as const, +``` + +- [ ] **Step 2: Run the type check to see callers that need updating** + +Run: `cd web && pnpm tsc --noEmit` +Expected: TypeScript errors at every call site of `requestKeys.search(...)`. Note the file paths reported. + +- [ ] **Step 3: Commit the key change alone** + +The next task updates the callers. Keep this commit focused. + +```bash +git add web/src/hooks/queries/keys.ts +git commit -m "refactor(keys): add viewerKey to requestKeys.search" +``` + +--- + +## Task 4: Extend `useRequestSearch` with signal, viewer key, staleTime, and enabled option + +**Files:** +- Modify: `web/src/hooks/queries/useRequests.ts:151-166` + +Update `useRequestSearch` so it (a) accepts and forwards a `signal` from react-query, (b) keys the cache by the current viewer's `profile.id`, (c) uses a 5-minute `staleTime` (the spec value), and (d) accepts an optional `enabled` override so callers can gate it on `discoveryEnabled` without firing the query when disallowed. + +- [ ] **Step 1: Write the failing test** + +Append to `web/src/hooks/queries/useRequests.test.ts` (create the file if missing): + +```typescript +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const mocks = vi.hoisted(() => ({ + useQuery: vi.fn(), + useCurrentProfile: vi.fn(), + api: vi.fn(), +})); + +vi.mock("@tanstack/react-query", async () => { + const actual = + await vi.importActual("@tanstack/react-query"); + return { + ...actual, + useQuery: (...args: unknown[]) => mocks.useQuery(...args), + }; +}); + +vi.mock("@/hooks/useCurrentProfile", () => ({ + useCurrentProfile: () => mocks.useCurrentProfile(), +})); + +vi.mock("@/api/client", () => ({ + api: (...args: unknown[]) => mocks.api(...args), +})); + +import { useRequestSearch } from "./useRequests"; + +function render(node: React.ReactNode) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return renderToStaticMarkup({node}); +} + +function CallHook(props: { mediaType: "movie" | "series" | "all"; q: string; page?: number }) { + useRequestSearch(props.mediaType, props.q, props.page ?? 1); + return null; +} + +describe("useRequestSearch", () => { + it("includes the current profile id in the query key", () => { + mocks.useQuery.mockReset(); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "profile-1" } }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { queryKey: readonly unknown[] }; + expect(options.queryKey).toEqual(["requests", "search", "profile-1", "all", "dune", 1]); + }); + + it("uses 'anon' as the viewer key when there is no profile", () => { + mocks.useQuery.mockReset(); + mocks.useCurrentProfile.mockReturnValue({ profile: null }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { queryKey: readonly unknown[] }; + expect(options.queryKey).toEqual(["requests", "search", "anon", "movie", "dune", 1]); + }); + + it("forwards the react-query signal to api()", async () => { + mocks.useQuery.mockReset(); + mocks.api.mockResolvedValue({ page: 1, total_pages: 0, total_results: 0, results: [] }); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "profile-1" } }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { + queryFn: (ctx: { signal: AbortSignal }) => unknown; + }; + const controller = new AbortController(); + await options.queryFn({ signal: controller.signal }); + + expect(mocks.api).toHaveBeenCalledTimes(1); + const apiCall = mocks.api.mock.calls[0]!; + expect(apiCall[0]).toContain("/requests/search?"); + const init = apiCall[1] as RequestInit; + expect(init.signal).toBe(controller.signal); + }); + + it("uses a 5-minute staleTime", () => { + mocks.useQuery.mockReset(); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p" } }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { staleTime: number }; + expect(options.staleTime).toBe(5 * 60 * 1000); + }); + + it("respects the enabled option override", () => { + mocks.useQuery.mockReset(); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p" } }); + + function CallHookWithOpt({ enabled }: { enabled: boolean }) { + useRequestSearch("all", "dune", 1, { enabled }); + return null; + } + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { enabled: boolean }; + expect(options.enabled).toBe(false); + }); + + it("does not include enabled override when option omitted (defaults to true)", () => { + mocks.useQuery.mockReset(); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p" } }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { enabled: boolean }; + // Internally `normalizedQuery.length > 1` is true, and the default enabled override + // is true, so this should resolve to true. + expect(options.enabled).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd web && pnpm vitest run src/hooks/queries/useRequests.test.ts` +Expected: FAIL — the existing hook does not include profile in the key, does not pass signal, and uses `REQUESTS_STALE_TIME` (30s). + +- [ ] **Step 3: Update the hook** + +Replace lines 151-166 of `web/src/hooks/queries/useRequests.ts` with: + +```typescript +import { useCurrentProfile } from "@/hooks/useCurrentProfile"; + +const REQUEST_SEARCH_STALE_TIME = 5 * 60 * 1000; + +export interface UseRequestSearchOptions { + /** When false, suppresses the query regardless of the query string. Default: true. */ + enabled?: boolean; +} + +export function useRequestSearch( + mediaType: RequestSearchMediaType, + query: string, + page = 1, + options: UseRequestSearchOptions = {}, +) { + const normalizedQuery = query.trim(); + const { profile } = useCurrentProfile(); + const viewerKey = profile?.id ?? "anon"; + const enabledOverride = options.enabled ?? true; + return useQuery({ + queryKey: requestKeys.search(mediaType, normalizedQuery, page, viewerKey), + queryFn: ({ signal }) => { + const params = new URLSearchParams({ + q: normalizedQuery, + media_type: mediaType, + page: String(page), + }); + return api(`/requests/search?${params}`, { signal }); + }, + enabled: enabledOverride && normalizedQuery.length > 1, + staleTime: REQUEST_SEARCH_STALE_TIME, + }); +} +``` + +Note: the `useCurrentProfile` import must be added near the top of the file. The `REQUEST_SEARCH_STALE_TIME` constant goes near the top alongside `REQUESTS_STALE_TIME`. Existing callers (e.g., `Requests.tsx:140`) pass three arguments and continue to work — the new fourth `options` parameter defaults to `{}`. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd web && pnpm vitest run src/hooks/queries/useRequests.test.ts` +Expected: PASS, all four cases. + +- [ ] **Step 5: Run the full type check** + +Run: `cd web && pnpm tsc --noEmit` +Expected: PASS. No call sites should break (this hook's external signature is unchanged). + +- [ ] **Step 6: Commit** + +```bash +git add web/src/hooks/queries/useRequests.ts web/src/hooks/queries/useRequests.test.ts +git commit -m "feat(requests): key useRequestSearch by viewer, forward signal, raise staleTime" +``` + +--- + +## Task 5: Invalidate request search cache on policy & settings mutations + +**Files:** +- Modify: `web/src/hooks/queries/useRequests.ts:53-56` (extend `invalidateRequestSurfaces`) +- Modify: `web/src/hooks/queries/useRequests.ts:262-288` (`useUpdateRequestSettings`) +- Modify: `web/src/hooks/queries/useRequests.ts:345-362` (`useUpdateRequestUserLimit`) + +The existing `invalidateRequestSurfaces` invalidates `requestKeys.all`, which is `["requests"]`. React-query's invalidation matches by key prefix, so this *already* invalidates `requestKeys.search(...)` because that key starts with `["requests", "search", ...]`. Verify this and add a focused test rather than introducing new helpers. + +- [ ] **Step 1: Add a test asserting invalidation behavior** + +Append to `web/src/hooks/queries/useRequests.test.ts`: + +```typescript +import { QueryClient as RealQueryClient } from "@tanstack/react-query"; +import { requestKeys } from "./keys"; + +describe("requestKeys.all invalidation", () => { + it("invalidates entries under requestKeys.search() when invalidating requestKeys.all", async () => { + const client = new RealQueryClient(); + client.setQueryData(requestKeys.search("all", "dune", 1, "profile-1"), { sentinel: true }); + + expect(client.getQueryData(requestKeys.search("all", "dune", 1, "profile-1"))).toEqual({ + sentinel: true, + }); + + await client.invalidateQueries({ queryKey: requestKeys.all }); + + const state = client.getQueryState(requestKeys.search("all", "dune", 1, "profile-1")); + expect(state?.isInvalidated).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it passes** + +Run: `cd web && pnpm vitest run src/hooks/queries/useRequests.test.ts` +Expected: PASS. This documents that the existing `invalidateRequestSurfaces` already cascades to search results. + +- [ ] **Step 3: Add a comment in useRequests.ts** + +In `web/src/hooks/queries/useRequests.ts`, replace the `invalidateRequestSurfaces` function (lines 53-56) with: + +```typescript +function invalidateRequestSurfaces(queryClient: ReturnType) { + // requestKeys.all = ["requests"] — invalidating it cascades to every nested key, + // including requestKeys.search(...). Settings and per-user limit mutations rely + // on this to re-fetch viewer-scoped search results when policy changes. + queryClient.invalidateQueries({ queryKey: requestKeys.all }); + queryClient.invalidateQueries({ queryKey: adminKeys.requestsRoot() }); +} +``` + +- [ ] **Step 4: Add a test that profile change invalidates results** + +Append to `web/src/hooks/queries/useRequests.test.ts`: + +```typescript +describe("viewer-scoped cache isolation", () => { + it("does not return profile-1 results when keyed by profile-2", () => { + const client = new RealQueryClient(); + client.setQueryData(requestKeys.search("all", "dune", 1, "profile-1"), { + results: [{ tmdb_id: 1 }], + }); + + expect(client.getQueryData(requestKeys.search("all", "dune", 1, "profile-2"))).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `cd web && pnpm vitest run src/hooks/queries/useRequests.test.ts` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add web/src/hooks/queries/useRequests.ts web/src/hooks/queries/useRequests.test.ts +git commit -m "test(requests): document viewer-keyed cache isolation and invalidation cascade" +``` + +--- + +## Task 6: Make `RequestPosterCard.DiscoverProps` request handler optional + +**Files:** +- Modify: `web/src/components/RequestPosterCard.tsx:9-16` (DiscoverProps) +- Modify: `web/src/components/RequestPosterCard.tsx:40-50` (DiscoverCard signature) +- Modify: `web/src/components/RequestPosterCard.tsx:95-120` (hover button render) + +For the new search context, we don't want the inline-submit hover button. Make `onRequest` and `isSubmitting` optional, and only render the hover button when `onRequest` is defined. + +- [ ] **Step 1: Write the failing test** + +Create `web/src/components/RequestPosterCard.test.tsx`: + +```typescript +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router"; +import RequestPosterCard from "./RequestPosterCard"; +import type { RequestMediaResult } from "@/api/types"; + +const requestable: RequestMediaResult = { + media_type: "movie", + tmdb_id: 42, + title: "Test Movie", + availability: "missing", + request: { requestable: true }, +}; + +describe("RequestPosterCard (discover variant)", () => { + it("renders the hover Request button when onRequest is provided", () => { + const markup = renderToStaticMarkup( + + {}} + /> + , + ); + expect(markup).toContain("Request"); + }); + + it("does not render the hover Request button when onRequest is omitted", () => { + const markup = renderToStaticMarkup( + + + , + ); + // The hover button has class "rounded-full bg-white"; check that pattern is absent. + expect(markup).not.toContain("rounded-full bg-white"); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd web && pnpm vitest run src/components/RequestPosterCard.test.tsx` +Expected: FAIL — the second test fails because `RequestPosterCard` currently requires `onRequest` and `isSubmitting`, and even with placeholder values it would still render the button. + +- [ ] **Step 3: Update DiscoverProps** + +In `web/src/components/RequestPosterCard.tsx`, replace lines 9-16 with: + +```typescript +type DiscoverProps = { + variant: "discover"; + item: RequestMediaResult; + /** Called when the inline hover Request button is clicked. Omit to suppress the button. */ + onRequest?: () => void; + /** Displays the spinner state on the hover Request button. Ignored when onRequest is omitted. */ + isSubmitting?: boolean; + /** When true, fills the parent (use inside grids). Default: fixed carousel width. */ + fluid?: boolean; +}; +``` + +- [ ] **Step 4: Update the DiscoverCard component signature and render** + +Replace lines 40-50 of `RequestPosterCard.tsx`: + +```typescript +function DiscoverCard({ + item, + isSubmitting, + onRequest, + fluid, +}: { + item: RequestMediaResult; + isSubmitting?: boolean; + onRequest?: () => void; + fluid?: boolean; +}) { +``` + +Replace lines 95-120 (the conditional hover button) with: + +```typescript + {requestable && onRequest && ( +
+ +
+ )} +``` + +Also update the call site at line 30 (in the dispatcher) to spread props correctly: + +```typescript + return ( + + ); +``` + +(This is already the existing shape — verify it still type-checks now that the inner DiscoverProps fields are optional.) + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `cd web && pnpm vitest run src/components/RequestPosterCard.test.tsx` +Expected: PASS, both cases. + +- [ ] **Step 6: Run the full type check** + +Run: `cd web && pnpm tsc --noEmit` +Expected: PASS. Existing callers still pass both fields, so no breaks. + +- [ ] **Step 7: Commit** + +```bash +git add web/src/components/RequestPosterCard.tsx web/src/components/RequestPosterCard.test.tsx +git commit -m "feat(request-card): make onRequest optional on discover variant" +``` + +--- + +## Task 7: Create `RequestToAddSection` — dialog variant + +**Files:** +- Create: `web/src/components/RequestToAddSection.tsx` +- Create: `web/src/components/RequestToAddSection.test.tsx` + +A self-contained component that owns: +- The TMDB query (via `useRequestSearch`) gated by `useCanRequest().discoveryEnabled` +- Filtering out results already in the library (`availability === "available"`) +- Section header copy: "Request to Add" when `libraryHadHits=true`, "Not in your library, but you can request" when `libraryHadHits=false` +- Two render variants: `dialog` (compact rows, max 4) and `grid` (poster cards, max 20) +- Silent omit on error or empty TMDB + +This task implements the dialog variant only; Task 8 adds the grid variant. + +- [ ] **Step 1: Write the failing test for the dialog variant** + +Create `web/src/components/RequestToAddSection.test.tsx`: + +```typescript +import type { ReactNode } from "react"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const mocks = vi.hoisted(() => ({ + useCanRequest: vi.fn(), + useRequestSearch: vi.fn(), + useDebounce: vi.fn(), +})); + +vi.mock("@/hooks/useCanRequest", () => ({ + useCanRequest: () => mocks.useCanRequest(), +})); + +vi.mock("@/hooks/queries/useRequests", () => ({ + useRequestSearch: (...args: unknown[]) => mocks.useRequestSearch(...args), +})); + +vi.mock("@/hooks/useDebounce", () => ({ + useDebounce: (v: T) => mocks.useDebounce(v) ?? v, +})); + +import { RequestToAddSection } from "./RequestToAddSection"; +import type { RequestMediaResult } from "@/api/types"; + +function render(child: ReactNode) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return renderToStaticMarkup( + + {child} + , + ); +} + +const missingResult = (overrides: Partial = {}): RequestMediaResult => ({ + media_type: "movie", + tmdb_id: 1, + title: "Dune: Prophecy", + year: 2024, + availability: "missing", + request: { requestable: true }, + ...overrides, +}); + +const availableResult = (overrides: Partial = {}): RequestMediaResult => ({ + media_type: "movie", + tmdb_id: 2, + title: "Dune", + year: 2021, + availability: "available", + request: { requestable: false }, + ...overrides, +}); + +describe("RequestToAddSection (dialog variant)", () => { + beforeEach(() => { + mocks.useCanRequest.mockReset(); + mocks.useRequestSearch.mockReset(); + mocks.useDebounce.mockReset(); + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useDebounce.mockImplementation((v: unknown) => v); + }); + + it("renders nothing when discovery is disabled", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); + mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: false }); + + const markup = render(); + expect(markup).toBe(""); + }); + + it("passes enabled=false to useRequestSearch when discovery is disabled so no network call fires", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); + mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: false }); + + render(); + + const call = mocks.useRequestSearch.mock.calls.at(-1); + expect(call?.[0]).toBe("all"); + expect(call?.[1]).toBe("dune"); + expect(call?.[2]).toBe(1); + expect(call?.[3]).toEqual({ enabled: false }); + }); + + it("passes enabled=true to useRequestSearch when discovery is enabled", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useRequestSearch.mockReturnValue({ + data: { page: 1, total_pages: 1, total_results: 0, results: [] }, + isLoading: false, + isError: false, + }); + + render(); + + const call = mocks.useRequestSearch.mock.calls.at(-1); + expect(call?.[3]).toEqual({ enabled: true }); + }); + + it("renders 'Request to Add' header when library had hits", () => { + mocks.useRequestSearch.mockReturnValue({ + data: { page: 1, total_pages: 1, total_results: 1, results: [missingResult()] }, + isLoading: false, + isError: false, + }); + const markup = render(); + expect(markup).toContain("Request to Add"); + expect(markup).toContain("Dune: Prophecy"); + }); + + it("renders soft framing when library had 0 hits", () => { + mocks.useRequestSearch.mockReturnValue({ + data: { page: 1, total_pages: 1, total_results: 1, results: [missingResult()] }, + isLoading: false, + isError: false, + }); + const markup = render( + , + ); + expect(markup).toContain("Not in your library, but you can request"); + expect(markup).not.toContain("Request to Add"); + }); + + it("filters out results already available in the library", () => { + mocks.useRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 2, + results: [availableResult(), missingResult()], + }, + isLoading: false, + isError: false, + }); + const markup = render(); + expect(markup).toContain("Dune: Prophecy"); + expect(markup).not.toContain('"Dune"'); + }); + + it("renders nothing when TMDB returned an error", () => { + mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: true }); + const markup = render(); + expect(markup).toBe(""); + }); + + it("renders nothing when all TMDB results are already in the library", () => { + mocks.useRequestSearch.mockReturnValue({ + data: { page: 1, total_pages: 1, total_results: 1, results: [availableResult()] }, + isLoading: false, + isError: false, + }); + const markup = render(); + expect(markup).toBe(""); + }); + + it("limits the dialog variant to at most 4 rows", () => { + const many = Array.from({ length: 10 }, (_, i) => + missingResult({ tmdb_id: i + 100, title: `Result ${i}` }), + ); + mocks.useRequestSearch.mockReturnValue({ + data: { page: 1, total_pages: 1, total_results: many.length, results: many }, + isLoading: false, + isError: false, + }); + const markup = render(); + expect(markup).toContain("Result 0"); + expect(markup).toContain("Result 3"); + expect(markup).not.toContain("Result 4"); + }); + + it("renders the disabled affordance and reason when a row is not requestable", () => { + mocks.useRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 1, + results: [ + missingResult({ + tmdb_id: 7, + title: "Quota Capped Movie", + request: { requestable: false, reason: "quota_exhausted" }, + }), + ], + }, + isLoading: false, + isError: false, + }); + + const markup = render(); + + expect(markup).toContain("Quota Capped Movie"); + // The active "Request" amber chip is suppressed; a muted reason chip is shown instead. + expect(markup).not.toContain("bg-amber-400/15"); + // formatRequestReason("quota_exhausted") yields a human label that must be present. + expect(markup).toMatch(/title="[^"]+"/); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd web && pnpm vitest run src/components/RequestToAddSection.test.tsx` +Expected: FAIL — the component does not exist yet. + +- [ ] **Step 3: Create the component** + +Create `web/src/components/RequestToAddSection.tsx`: + +```typescript +import { Link } from "react-router"; +import { Film, Tv } from "lucide-react"; +import { useCanRequest } from "@/hooks/useCanRequest"; +import { useRequestSearch } from "@/hooks/queries/useRequests"; +import type { RequestMediaResult } from "@/api/types"; +import { formatRequestReason, tmdbImageURL } from "@/lib/mediaRequests"; +import { cn } from "@/lib/utils"; + +const DIALOG_LIMIT = 4; +const GRID_LIMIT = 20; + +export type RequestToAddSectionProps = { + variant: "dialog" | "grid"; + query: string; + /** True when the library FTS returned ≥1 hit. Drives header copy. */ + libraryHadHits: boolean; +}; + +export function RequestToAddSection({ variant, query, libraryHadHits }: RequestToAddSectionProps) { + const { discoveryEnabled } = useCanRequest(); + // Gate the TMDB query firing on discovery eligibility. The `!discoveryEnabled` + // early return below hides the UI, but the hook still runs unconditionally + // (rules of hooks) — passing `enabled` is what prevents the network call. + const search = useRequestSearch("all", query, 1, { enabled: discoveryEnabled }); + + if (!discoveryEnabled) return null; + if (search.isError) return null; + + const filtered = (search.data?.results ?? []).filter( + (item) => item.availability !== "available", + ); + if (filtered.length === 0) return null; + + const limit = variant === "dialog" ? DIALOG_LIMIT : GRID_LIMIT; + const visible = filtered.slice(0, limit); + + if (variant === "dialog") { + return ; + } + return ; +} + +function HeaderCopy({ libraryHadHits, count }: { libraryHadHits: boolean; count: number }) { + if (libraryHadHits) { + return ( +
+ Request to Add + + {count} + +
+ ); + } + return ( +
+ Not in your library, but you can request: +
+ ); +} + +function DialogVariant({ + items, + libraryHadHits, +}: { + items: RequestMediaResult[]; + libraryHadHits: boolean; +}) { + return ( +
+ +
    + {items.map((item) => ( +
  • + +
  • + ))} +
+
+ ); +} + +function DialogRow({ item }: { item: RequestMediaResult }) { + const poster = tmdbImageURL(item.poster_path); + const Icon = item.media_type === "series" ? Tv : Film; + const requestable = item.request.requestable; + const reasonLabel = !requestable + ? item.request.reason + ? formatRequestReason(item.request.reason) + : "Blocked" + : null; + return ( + +
+ {poster ? ( + + ) : ( +
+ +
+ )} +
+
+
{item.title}
+
+ {item.year ? `${item.year} · ` : ""} + {item.media_type === "series" ? "Series" : "Movie"} +
+
+ {requestable ? ( + + Request + + ) : ( + + {reasonLabel} + + )} + + ); +} + +function GridVariant({ + items: _items, + libraryHadHits: _libraryHadHits, +}: { + items: RequestMediaResult[]; + libraryHadHits: boolean; +}) { + // Implemented in Task 8. + return null; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd web && pnpm vitest run src/components/RequestToAddSection.test.tsx` +Expected: PASS, all dialog-variant cases. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/components/RequestToAddSection.tsx web/src/components/RequestToAddSection.test.tsx +git commit -m "feat(search): add RequestToAddSection dialog variant" +``` + +--- + +## Task 8: Add the grid variant to `RequestToAddSection` + +**Files:** +- Modify: `web/src/components/RequestToAddSection.tsx` (`GridVariant`) +- Modify: `web/src/components/RequestToAddSection.test.tsx` (add grid coverage) + +- [ ] **Step 1: Write the failing test** + +Append to `web/src/components/RequestToAddSection.test.tsx`: + +```typescript +describe("RequestToAddSection (grid variant)", () => { + beforeEach(() => { + mocks.useCanRequest.mockReset(); + mocks.useRequestSearch.mockReset(); + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + }); + + it("renders a card per result with the Request to Add header when library had hits", () => { + mocks.useRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 2, + results: [ + missingResult({ tmdb_id: 1, title: "Dune: Prophecy" }), + missingResult({ tmdb_id: 2, title: "Dune (1984)" }), + ], + }, + isLoading: false, + isError: false, + }); + const markup = render(); + expect(markup).toContain("Request to Add"); + expect(markup).toContain("Dune: Prophecy"); + expect(markup).toContain("Dune (1984)"); + }); + + it("renders the soft framing in the grid variant when library had 0 hits", () => { + mocks.useRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 1, + results: [missingResult({ tmdb_id: 1, title: "Dune: Prophecy" })], + }, + isLoading: false, + isError: false, + }); + const markup = render( + , + ); + expect(markup).toContain("Not in your library, but you can request"); + }); + + it("limits the grid to at most 20 cards", () => { + const many = Array.from({ length: 30 }, (_, i) => + missingResult({ tmdb_id: i + 100, title: `Result ${i}` }), + ); + mocks.useRequestSearch.mockReturnValue({ + data: { page: 1, total_pages: 1, total_results: many.length, results: many }, + isLoading: false, + isError: false, + }); + const markup = render(); + expect(markup).toContain("Result 0"); + expect(markup).toContain("Result 19"); + expect(markup).not.toContain("Result 20"); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd web && pnpm vitest run src/components/RequestToAddSection.test.tsx` +Expected: FAIL — the grid variant renders `null`. + +- [ ] **Step 3: Implement `GridVariant`** + +Replace the `GridVariant` placeholder in `web/src/components/RequestToAddSection.tsx`: + +```typescript +import RequestPosterCard from "./RequestPosterCard"; + +function GridVariant({ + items, + libraryHadHits, +}: { + items: RequestMediaResult[]; + libraryHadHits: boolean; +}) { + return ( +
+
+
+

+ {libraryHadHits ? "Request to Add" : "Not in your library, but you can request"} +

+
+
+
+ {items.map((item) => ( + + ))} +
+
+ ); +} +``` + +(`onRequest` and `isSubmitting` are intentionally omitted — Task 6 made them optional so the hover button is suppressed.) + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd web && pnpm vitest run src/components/RequestToAddSection.test.tsx` +Expected: PASS, all dialog and grid cases. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/components/RequestToAddSection.tsx web/src/components/RequestToAddSection.test.tsx +git commit -m "feat(search): add RequestToAddSection grid variant for Catalog page" +``` + +--- + +## Task 9: Integrate `RequestToAddSection` into `GlobalSearch` + +**Files:** +- Modify: `web/src/components/GlobalSearch.tsx` +- Modify: `web/src/components/GlobalSearch.test.tsx` + +GlobalSearch hoists the TMDB query alongside the library query so it can suppress the "No matches" empty state while TMDB is still pending or has results to show. The section renders inside the same scrollable list. The TMDB debounce is 400ms (vs library's 200ms). + +- [ ] **Step 1: Write the failing tests** + +The existing `GlobalSearch.test.tsx` mocks `useQuery` globally. Because GlobalSearch now calls multiple hooks that internally use `useQuery` (library preview + TMDB search), the mock returns the same response for both. Switch the test scaffolding to mock the specific hooks we use rather than `useQuery` itself. + +Replace the top of `web/src/components/GlobalSearch.test.tsx` (the existing `mocks`, the `useQuery` mock, and the `useDebounce` mock) with: + +```typescript +const mocks = vi.hoisted(() => ({ + useQuery: vi.fn(), + useCanRequest: vi.fn(), + useRequestSearch: vi.fn(), +})); + +vi.mock("@tanstack/react-query", async () => { + const actual = + await vi.importActual("@tanstack/react-query"); + return { + ...actual, + useQuery: (...args: unknown[]) => mocks.useQuery(...args), + }; +}); + +vi.mock("@/hooks/useCanRequest", () => ({ + useCanRequest: () => mocks.useCanRequest(), +})); + +vi.mock("@/hooks/queries/useRequests", () => ({ + useRequestSearch: (...args: unknown[]) => mocks.useRequestSearch(...args), +})); + +vi.mock("@/hooks/useDebounce", () => ({ + useDebounce: (v: T) => v, +})); +``` + +Then update the `beforeEach` to set default mocks: + +```typescript + beforeEach(() => { + mocks.useQuery.mockReset(); + mocks.useCanRequest.mockReset(); + mocks.useRequestSearch.mockReset(); + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); + mocks.useRequestSearch.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + }); + mocks.useQuery.mockReturnValue({ + data: { total: 50, has_more: true, items: [browseFixture] }, + isFetching: false, + isError: false, + }); + }); +``` + +Now add a section-wiring `describe` block at the end of the file: + +```typescript +vi.mock("@/components/RequestToAddSection", () => ({ + RequestToAddSection: ({ + variant, + query, + libraryHadHits, + }: { + variant: string; + query: string; + libraryHadHits: boolean; + }) => ( +
+ variant={variant} query={query} libraryHadHits={String(libraryHadHits)} +
+ ), +})); + +describe("GlobalSearch + RequestToAddSection wiring", () => { + it("renders the section with libraryHadHits=true when library returned results", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 1, + results: [ + { media_type: "movie", tmdb_id: 1, title: "X", availability: "missing", request: { requestable: true } }, + ], + }, + isLoading: false, + isError: false, + }); + const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "Dune" }); + + expect(markup).toContain('data-testid="request-section"'); + expect(markup).toContain('libraryHadHits="true"'); + expect(markup).toContain('variant="dialog"'); + }); + + it("renders the section with libraryHadHits=false when library returned 0 results", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useQuery.mockReturnValue({ + data: { total: 0, has_more: false, items: [] }, + isFetching: false, + isError: false, + }); + mocks.useRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 1, + results: [ + { media_type: "movie", tmdb_id: 1, title: "X", availability: "missing", request: { requestable: true } }, + ], + }, + isLoading: false, + isError: false, + }); + const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "ThisDoesNotExist" }); + + expect(markup).toContain('libraryHadHits="false"'); + }); + + it("does not call useRequestSearch with enabled=true when discoveryEnabled is false", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); + renderSearchMarkup({ defaultOpen: true, initialQuery: "Dune" }); + + const call = mocks.useRequestSearch.mock.calls.at(-1); + expect(call?.[3]).toEqual({ enabled: false }); + }); + + it("does not mount RequestToAddSection when discovery is disabled", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); + const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "Dune" }); + + expect(markup).not.toContain('data-testid="request-section"'); + }); + + it("suppresses 'No matches' when library is empty and TMDB is still loading", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useQuery.mockReturnValue({ + data: { total: 0, has_more: false, items: [] }, + isFetching: false, + isError: false, + }); + mocks.useRequestSearch.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + }); + const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "Pending" }); + + expect(markup).not.toContain("No matches"); + }); + + it("suppresses 'No matches' when library is empty and TMDB has missing results", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useQuery.mockReturnValue({ + data: { total: 0, has_more: false, items: [] }, + isFetching: false, + isError: false, + }); + mocks.useRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 1, + results: [ + { media_type: "movie", tmdb_id: 1, title: "X", availability: "missing", request: { requestable: true } }, + ], + }, + isLoading: false, + isError: false, + }); + const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "FoundOnTmdb" }); + + expect(markup).not.toContain("No matches"); + }); + + it("still shows 'No matches' when both library and TMDB are empty", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useQuery.mockReturnValue({ + data: { total: 0, has_more: false, items: [] }, + isFetching: false, + isError: false, + }); + mocks.useRequestSearch.mockReturnValue({ + data: { page: 1, total_pages: 1, total_results: 0, results: [] }, + isLoading: false, + isError: false, + }); + const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "ZzzNothing" }); + + expect(markup).toContain("No matches"); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd web && pnpm vitest run src/components/GlobalSearch.test.tsx` +Expected: FAIL — the section is not yet wired in, and `useRequestSearch` is not called from GlobalSearch. + +- [ ] **Step 3: Wire the section into GlobalSearch** + +In `web/src/components/GlobalSearch.tsx`, add imports near the top: + +```typescript +import { RequestToAddSection } from "./RequestToAddSection"; +import { useCanRequest } from "@/hooks/useCanRequest"; +import { useRequestSearch } from "@/hooks/queries/useRequests"; +``` + +Add a new constant near the top with the other constants: + +```typescript +const TMDB_DEBOUNCE_MS = 400; +``` + +Inside the `GlobalSearch` component, after the existing `debouncedQuery` line, add a second debounce for TMDB and lift the TMDB query: + +```typescript + const tmdbDebouncedQuery = useDebounce(query.trim(), TMDB_DEBOUNCE_MS); + const canRequest = useCanRequest(); + const tmdbQuery = useRequestSearch("all", tmdbDebouncedQuery, 1, { + enabled: canRequest.discoveryEnabled, + }); + const tmdbMissingCount = + tmdbQuery.data?.results?.filter((r) => r.availability !== "available").length ?? 0; + const tmdbStillLoading = + canRequest.discoveryEnabled && tmdbDebouncedQuery.length > 1 && tmdbQuery.isLoading; + const tmdbWillRender = canRequest.discoveryEnabled && tmdbMissingCount > 0; +``` + +Update the existing `showEmpty` computation to suppress the empty state while TMDB might still produce a result: + +```typescript + const showEmpty = + !previewQuery.isFetching && + debouncedQuery.length > 0 && + items.length === 0 && + !previewQuery.isError && + !tmdbStillLoading && + !tmdbWillRender; +``` + +Then in the `showResultsPanel` JSX block, add the `` render below the `items.map(...)` loop. Replace lines 237-281 with: + +```typescript + {showResultsPanel && ( +
+
+ {showLoading && ( +
+ Searching... +
+ )} + {showError && ( +
+ Could not load results. Press Enter to open the search page. +
+ )} + {showEmpty && ( +
+ No matches +
+ )} + {items.map((item, i) => ( + + ))} + {tmdbDebouncedQuery.length > 1 && canRequest.discoveryEnabled && ( + 0} + /> + )} +
+
+ {items.length} results found +
+
+ {total > PREVIEW_LIMIT ? ( +

+ Showing top {PREVIEW_LIMIT} of {total}. Press Enter for all results. +

+ ) : ( +

Press Enter to open the full search page.

+ )} +
+
+ )} +``` + +Note that `RequestToAddSection` ALSO calls `useRequestSearch` internally — react-query dedupes by query key, so this is a single network call. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd web && pnpm vitest run src/components/GlobalSearch.test.tsx` +Expected: PASS, including the existing tests plus the new section-wiring tests. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/components/GlobalSearch.tsx web/src/components/GlobalSearch.test.tsx +git commit -m "feat(search): render RequestToAddSection in the Cmd+K dialog with empty-state suppression" +``` + +--- + +## Task 10: Integrate `RequestToAddSection` into `Catalog` + +**Files:** +- Modify: `web/src/pages/Catalog.tsx` +- Create: `web/src/pages/Catalog.test.tsx` (if not present) + +Add the grid variant below the existing `ItemGrid` when `state.source === "query"` and there is a query. The page also lifts the TMDB query so it can keep the `ItemGrid` in a loading state (instead of showing "No items found") while TMDB is still pending or has missing results. + +- [ ] **Step 1: Write the failing tests** + +Inspect `web/src/pages/`. If a `Catalog.test.tsx` already exists, append to it; otherwise create it. + +```typescript +import type { ReactNode } from "react"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const mocks = vi.hoisted(() => ({ + useCatalogWindow: vi.fn(), + useCanRequest: vi.fn(), + useRequestSearch: vi.fn(), +})); + +vi.mock("@/hooks/queries/catalog", () => ({ + useCatalogWindow: (...args: unknown[]) => mocks.useCatalogWindow(...args), + createCatalogSearchState: (source: string, params: Record) => ({ + source, + ...params, + }), + fetchCatalogPage: vi.fn(), +})); + +vi.mock("@/hooks/useCanRequest", () => ({ + useCanRequest: () => mocks.useCanRequest(), +})); + +vi.mock("@/hooks/queries/useRequests", () => ({ + useRequestSearch: (...args: unknown[]) => mocks.useRequestSearch(...args), +})); + +vi.mock("@/components/RequestToAddSection", () => ({ + RequestToAddSection: ({ + variant, + query, + libraryHadHits, + }: { + variant: string; + query: string; + libraryHadHits: boolean; + }) => ( +
+ variant={variant} query={query} libraryHadHits={String(libraryHadHits)} +
+ ), +})); + +vi.mock("@/components/ItemGrid", () => ({ + default: ({ totalItems, loading }: { totalItems: number; loading: boolean }) => ( +
+ ), +})); + +import Catalog from "./Catalog"; + +function render(initialEntry: string) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return renderToStaticMarkup( + + + + + , + ); +} + +describe("Catalog + RequestToAddSection wiring", () => { + beforeEach(() => { + mocks.useCatalogWindow.mockReset(); + mocks.useCanRequest.mockReset(); + mocks.useRequestSearch.mockReset(); + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); + mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: false }); + }); + + it("renders the grid variant when source=query and library has results", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useCatalogWindow.mockReturnValue({ + data: { + title: 'Results for "dune"', + totalItems: 2, + pages: new Map([[0, [{ content_id: "lib-1", title: "Dune", type: "movie", year: 2021 }]]]), + }, + isLoading: false, + }); + + const markup = render("/catalog?source=query&q=dune"); + + expect(markup).toContain('data-testid="request-section"'); + expect(markup).toContain('variant="grid"'); + expect(markup).toContain('libraryHadHits="true"'); + }); + + it("renders the grid variant with libraryHadHits=false when library has 0 hits", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useCatalogWindow.mockReturnValue({ + data: { title: 'Results for "noresults"', totalItems: 0, pages: new Map() }, + isLoading: false, + }); + + const markup = render("/catalog?source=query&q=noresults"); + + expect(markup).toContain('libraryHadHits="false"'); + }); + + it("does not render the section when source is not query", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useCatalogWindow.mockReturnValue({ + data: { title: "Favorites", totalItems: 0, pages: new Map() }, + isLoading: false, + }); + + const markup = render("/catalog?source=favorites"); + expect(markup).not.toContain('data-testid="request-section"'); + }); + + it("does not render the section when discovery is disabled", () => { + // Default beforeEach sets discoveryEnabled=false; assert the parent gate blocks the mount. + mocks.useCatalogWindow.mockReturnValue({ + data: { + title: 'Results for "dune"', + totalItems: 2, + pages: new Map([[0, [{ content_id: "lib-1", title: "Dune", type: "movie", year: 2021 }]]]), + }, + isLoading: false, + }); + + const markup = render("/catalog?source=query&q=dune"); + expect(markup).not.toContain('data-testid="request-section"'); + }); + + it("passes enabled=false to useRequestSearch when discoveryEnabled is false", () => { + mocks.useCatalogWindow.mockReturnValue({ + data: { title: 'Results for "dune"', totalItems: 0, pages: new Map() }, + isLoading: false, + }); + + render("/catalog?source=query&q=dune"); + + const call = mocks.useRequestSearch.mock.calls.at(-1); + expect(call?.[3]).toEqual({ enabled: false }); + }); + + it("keeps ItemGrid in a loading state when library is empty and TMDB is still loading", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useCatalogWindow.mockReturnValue({ + data: { title: 'Results for "dune"', totalItems: 0, pages: new Map() }, + isLoading: false, + }); + mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: true, isError: false }); + + const markup = render("/catalog?source=query&q=dune"); + + expect(markup).toContain('data-loading="true"'); + }); + + it("keeps ItemGrid in a loading state when library is empty and TMDB has missing results", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useCatalogWindow.mockReturnValue({ + data: { title: 'Results for "dune"', totalItems: 0, pages: new Map() }, + isLoading: false, + }); + mocks.useRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 1, + results: [ + { media_type: "movie", tmdb_id: 1, title: "X", availability: "missing", request: { requestable: true } }, + ], + }, + isLoading: false, + isError: false, + }); + + const markup = render("/catalog?source=query&q=dune"); + + expect(markup).toContain('data-loading="true"'); + }); + + it("renders the normal ItemGrid empty state when both library and TMDB are empty", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useCatalogWindow.mockReturnValue({ + data: { title: 'Results for "zzz"', totalItems: 0, pages: new Map() }, + isLoading: false, + }); + mocks.useRequestSearch.mockReturnValue({ + data: { page: 1, total_pages: 1, total_results: 0, results: [] }, + isLoading: false, + isError: false, + }); + + const markup = render("/catalog?source=query&q=zzz"); + + expect(markup).toContain('data-loading="false"'); + expect(markup).toContain('data-total="0"'); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd web && pnpm vitest run src/pages/Catalog.test.tsx` +Expected: FAIL — the section is not rendered and the loading-state coordination is not implemented. + +- [ ] **Step 3: Wire the section into Catalog** + +In `web/src/pages/Catalog.tsx`, add imports: + +```typescript +import { RequestToAddSection } from "@/components/RequestToAddSection"; +import { useCanRequest } from "@/hooks/useCanRequest"; +import { useRequestSearch } from "@/hooks/queries/useRequests"; +``` + +Inside `CatalogResults`, after the existing `useCatalogWindow` call (around line 99-103), add: + +```typescript + const canRequest = useCanRequest(); + const isQuerySource = state.source === "query" && Boolean(state.q); + const tmdbQuery = useRequestSearch("all", state.q ?? "", 1, { + enabled: canRequest.discoveryEnabled && isQuerySource, + }); + const tmdbMissingCount = + tmdbQuery.data?.results?.filter((r) => r.availability !== "available").length ?? 0; + const libraryEmpty = (catalogQuery.data?.totalItems ?? 0) === 0; + const tmdbPendingForEmptyLibrary = + isQuerySource && canRequest.discoveryEnabled && libraryEmpty && tmdbQuery.isLoading; + const tmdbWillRenderForEmptyLibrary = + isQuerySource && canRequest.discoveryEnabled && libraryEmpty && tmdbMissingCount > 0; + const itemGridLoading = + catalogQuery.isLoading || tmdbPendingForEmptyLibrary || tmdbWillRenderForEmptyLibrary; +``` + +Update the `` prop: + +```typescript + +``` + +After the `ItemGrid`, before the `ConfirmDialog`, render the section: + +```typescript + {isQuerySource && canRequest.discoveryEnabled ? ( + 0} + /> + ) : null} + + /`. +5. In an admin context, toggle `RequestsEnabled` off via the admin UI. Re-open Cmd+K and confirm the section does NOT appear. +6. Slow the network (devtools throttling) and search again. Confirm library results appear immediately while the section is pending; the section appears once TMDB returns. + +- [ ] **Step 4: If any scenario fails, file the gap and stop here** + +Do not paper over UI regressions. Each failing scenario gets a short bug report (file path, expected, actual). The implementation plan ends with manual confirmation, not with a brittle "looks good". + +--- + +## Verification summary (run before opening MR) + +- `cd web && pnpm run lint` → PASS +- `cd web && pnpm run format:check` → PASS +- `cd web && pnpm test` → PASS +- `make verify-local-paths` → PASS +- Manual smoke per Task 12 → PASS + +--- + +## Deviations from the spec (recorded for the MR description) + +- **`submitDisabledReason` is always `null` in this implementation.** The spec defines this as a viewer-level signal fed by `EffectivePolicy.LimitMode` and quota state, but the frontend has no API surface today that exposes the viewer's effective policy as a single value. Per-row disabled state is driven by `result.request.requestable` and `result.request.reason`, which the backend already enriches per result. The `submitDisabledReason` field is retained in the `useCanRequest()` return type as a forward-compatible stub. Populating it would require a small backend addition to `/api/v1/requests/status` (out of scope here per the spec's "no backend changes" framing). diff --git a/docs/superpowers/plans/2026-05-26-metadata-curation-permission.md b/docs/superpowers/plans/2026-05-26-metadata-curation-permission.md new file mode 100644 index 00000000..6a5a7673 --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-metadata-curation-permission.md @@ -0,0 +1,1780 @@ +# Metadata Curation Permission Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add one assignable `metadata_curation` account permission that lets non-admin users edit, refresh, and rematch metadata only for items in libraries they are allowed to access. + +**Architecture:** Store durable permission keys on `users.permissions` and keep server authorization out of JWT claims so permission changes take effect from current database state. Add a shared item-scoped permission middleware that grants admins all access, grants `metadata_curation` users only when every library containing the target item is inside `users.library_ids`, and leaves unrelated admin surfaces admin-only. Frontend item-detail metadata controls use effective permissions from `/auth/me`; admin user-management screens edit the assigned permission array. + +**Tech Stack:** Go, chi middleware, PostgreSQL migrations, pgx, React, TypeScript, TanStack Query, existing Silo admin/user APIs. + +--- + +## Commands + +Commands assume the repository root is the cwd. + +--- + +## File Structure + +- Create `migrations/140_user_permissions.up.sql` + - Add `users.permissions text[] NOT NULL DEFAULT '{}'::text[]`. + +- Create `migrations/140_user_permissions.down.sql` + - Drop `users.permissions`. + +- Modify `migrations/001_schema.up.sql` + - Add `permissions text[] DEFAULT '{}'::text[] NOT NULL` to the base `users` table. + +- Modify `internal/database/testdata/migrations/001_create_users.up.sql` + - Keep lightweight test schema aligned with `models.User` scanning. + +- Modify `internal/models/user.go` + - Add assigned permission fields to user models and create/update inputs. + +- Create `internal/auth/permissions.go` + - Define permission constants, validation, assigned/effective permission helpers. + +- Create `internal/auth/permissions_test.go` + - Cover permission validation, de-duplication, and admin effective permissions. + +- Modify `internal/auth/repository.go` + - Read/write `users.permissions`. + - Bump `access_policy_revision` when permissions change. + +- Modify `internal/api/handlers/auth.go` + - Add effective `permissions` to `/auth/me` and login responses. + +- Modify `internal/api/handlers/admin.go` + - Add assigned `permissions` to admin user create/update/list/detail APIs. + - Include permission changes in session revocation. + +- Create `internal/api/middleware/permissions.go` + - Add item-scoped metadata curation authorization middleware and PostgreSQL target-library resolver. + +- Create `internal/api/middleware/permissions_test.go` + - Unit test authorization behavior without a database by faking user and target-library resolvers. + +- Modify `internal/api/router.go` + - Instantiate the permission middleware. + - Move item metadata edit/refresh/match routes out from the admin-only group and behind metadata curation middleware. + - Keep image, people, marker, library, settings, users, jobs list, and full admin routes admin-only. + +- Modify `internal/api/handlers/admin_jobs.go` + - Allow non-admin callers to read only their own `item_refresh` job by ID so refresh polling works. + - Keep list access admin-only. + +- Create or modify `internal/api/handlers/admin_jobs_test.go` + - Test the job read predicate. + +- Modify `web/src/api/types.ts` + - Add `permissions` to `User`, `AdminUser`, `CreateUserRequest`, and `UpdateUserRequest`. + +- Create `web/src/lib/permissions.ts` + - Add shared frontend permission constants and helpers. + +- Modify `web/src/pages/AdminUsers.tsx` + - Add a Metadata Curation switch to create/edit user forms. + +- Modify `web/src/pages/AdminUserDetail.tsx` + - Display and edit assigned Metadata Curation permission on the user detail page. + +- Modify `web/src/pages/ItemDetail/components/ActionBar.tsx` + - Split full-admin overflow actions from metadata-curation actions. + +- Modify item detail content files: + - `web/src/pages/ItemDetail/MovieContent.tsx` + - `web/src/pages/ItemDetail/SeriesContent.tsx` + - `web/src/pages/ItemDetail/SeasonContent.tsx` + - `web/src/pages/ItemDetail/EpisodeContent.tsx` + - Use metadata curation permission for refresh/edit/match controls while preserving admin-only controls such as media locations, play history, and intro marker redetection. + +Do not add permission groups. Do not make metadata curation a profile setting. Do not broaden this first pass to people metadata, image selection, marker refresh, or library-wide refresh. + +--- + +## Behavioral Contract + +- Admin users can do everything they can do today. +- Non-admin users with assigned `metadata_curation` can: + - `PATCH /api/v1/admin/items/{id}/metadata` + - `POST /api/v1/admin/items/{id}/refresh-metadata` + - `POST /api/v1/admin/items/{id}/match/search` + - `POST /api/v1/admin/items/{id}/match/apply` +- Non-admin metadata curators cannot use: + - library-wide metadata refresh + - image apply/search routes + - people metadata routes + - marker/intro refresh routes + - full admin navigation/routes + - admin job list +- `users.library_ids IS NULL` means unrestricted library access. +- `users.library_ids = '{}'` means no library access. +- A non-admin curator may mutate an item only when every library containing the target item is inside `users.library_ids`. +- For seasons and episodes, the target library set is resolved from the parent series library membership. +- Permission checks load current user policy from the database. JWTs continue to carry only coarse `role`. +- `/auth/me` returns effective permissions for UI decisions. Admin role implies `metadata_curation` in that effective list. +- Admin user APIs return assigned permissions, not effective permissions, so admins can see what is explicitly granted. + +--- + +### Task 1: Add Permission Storage And Domain Helpers + +**Files:** +- Create: `migrations/140_user_permissions.up.sql` +- Create: `migrations/140_user_permissions.down.sql` +- Modify: `migrations/001_schema.up.sql` +- Modify: `internal/database/testdata/migrations/001_create_users.up.sql` +- Modify: `internal/models/user.go` +- Create: `internal/auth/permissions.go` +- Create: `internal/auth/permissions_test.go` +- Modify: `internal/auth/repository.go` + +- [ ] **Step 1: Add the database migration** + +Create `migrations/140_user_permissions.up.sql`: + +```sql +ALTER TABLE public.users + ADD COLUMN IF NOT EXISTS permissions text[] NOT NULL DEFAULT '{}'::text[]; + +UPDATE public.users +SET permissions = '{}'::text[] +WHERE permissions IS NULL; +``` + +Create `migrations/140_user_permissions.down.sql`: + +```sql +ALTER TABLE public.users + DROP COLUMN IF EXISTS permissions; +``` + +Update `migrations/001_schema.up.sql` so the base `public.users` definition contains: + +```sql + role text, + permissions text[] DEFAULT '{}'::text[] NOT NULL, + enabled boolean DEFAULT true, +``` + +Update `internal/database/testdata/migrations/001_create_users.up.sql` so its `users` table has the same `permissions text[] DEFAULT '{}'::text[] NOT NULL` column near `role`. + +- [ ] **Step 2: Add permission fields to user models** + +Update `internal/models/user.go`: + +```go +type User struct { + ID int + Email string + Username string + PasswordHash string + LocalPasswordLoginEnabled bool + Role string + Permissions []string + Enabled bool + LibraryIDs []int // nullable in PG (nil = all libraries) + MaxPlaybackQuality string + AccessPolicyRevision int64 + MaxStreams int + MaxTranscodes int + MaxProfiles int + DownloadAllowed bool + DownloadTranscodeAllowed bool + CreatedAt time.Time + UpdatedAt time.Time +} +``` + +Add permissions to create/update inputs: + +```go +type CreateUserInput struct { + Email string + Username string + Password string + LocalPasswordLoginEnabled *bool + Role string + Permissions []string + LibraryIDs []int + MaxPlaybackQuality string + MaxStreams *int + MaxTranscodes *int + MaxProfiles *int + DownloadAllowed *bool + DownloadTranscodeAllowed *bool +} + +type UpdateUserInput struct { + Email *string + Username *string + Password *string + LocalPasswordLoginEnabled *bool + Role *string + Permissions *[]string + Enabled *bool + LibraryIDs *[]int + MaxPlaybackQuality *string + MaxStreams *int + MaxTranscodes *int + MaxProfiles *int + DownloadAllowed *bool + DownloadTranscodeAllowed *bool +} +``` + +- [ ] **Step 3: Add permission constants and validation** + +Create `internal/auth/permissions.go`: + +```go +package auth + +import ( + "fmt" + "sort" + "strings" + + "github.com/Silo-Server/silo-server/internal/models" +) + +type Permission string + +const PermissionMetadataCuration Permission = "metadata_curation" + +var assignablePermissions = map[Permission]struct{}{ + PermissionMetadataCuration: {}, +} + +var effectiveAdminPermissions = []string{ + string(PermissionMetadataCuration), +} + +func NormalizePermissions(values []string) ([]string, error) { + if len(values) == 0 { + return []string{}, nil + } + + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, raw := range values { + key := strings.TrimSpace(raw) + if key == "" { + continue + } + permission := Permission(key) + if _, ok := assignablePermissions[permission]; !ok { + return nil, fmt.Errorf("unknown permission %q", key) + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, key) + } + sort.Strings(out) + return out, nil +} + +func HasAssignedPermission(user *models.User, permission Permission) bool { + if user == nil { + return false + } + for _, value := range user.Permissions { + if value == string(permission) { + return true + } + } + return false +} + +func HasEffectivePermission(user *models.User, permission Permission) bool { + if user == nil || !user.Enabled { + return false + } + if user.Role == "admin" { + return true + } + return HasAssignedPermission(user, permission) +} + +func EffectivePermissions(user *models.User) []string { + if user == nil || !user.Enabled { + return []string{} + } + if user.Role == "admin" { + return append([]string(nil), effectiveAdminPermissions...) + } + permissions, err := NormalizePermissions(user.Permissions) + if err != nil { + return []string{} + } + return permissions +} +``` + +- [ ] **Step 4: Add permission helper tests** + +Create `internal/auth/permissions_test.go`: + +```go +package auth + +import ( + "reflect" + "testing" + + "github.com/Silo-Server/silo-server/internal/models" +) + +func TestNormalizePermissions_DeduplicatesAndSorts(t *testing.T) { + got, err := NormalizePermissions([]string{ + " metadata_curation ", + "metadata_curation", + "", + }) + if err != nil { + t.Fatalf("NormalizePermissions returned error: %v", err) + } + want := []string{"metadata_curation"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("permissions = %#v, want %#v", got, want) + } +} + +func TestNormalizePermissions_RejectsUnknownPermission(t *testing.T) { + if _, err := NormalizePermissions([]string{"server_owner"}); err == nil { + t.Fatal("expected unknown permission error") + } +} + +func TestHasEffectivePermission_AdminImpliesMetadataCuration(t *testing.T) { + user := &models.User{Role: "admin", Enabled: true} + if !HasEffectivePermission(user, PermissionMetadataCuration) { + t.Fatal("admin should have metadata curation") + } +} + +func TestHasEffectivePermission_UserRequiresAssignedPermission(t *testing.T) { + user := &models.User{Role: "user", Enabled: true} + if HasEffectivePermission(user, PermissionMetadataCuration) { + t.Fatal("plain user should not have metadata curation") + } + user.Permissions = []string{"metadata_curation"} + if !HasEffectivePermission(user, PermissionMetadataCuration) { + t.Fatal("assigned user should have metadata curation") + } +} +``` + +- [ ] **Step 5: Update `internal/auth/repository.go` scanning and writes** + +Update `allColumns`: + +```go +const allColumns = `id, email, username, password_hash, local_password_login_enabled, role, permissions, enabled, + library_ids, max_playback_quality, access_policy_revision, + max_streams, max_transcodes, max_profiles, download_allowed, + download_transcode_allowed, created_at, updated_at` +``` + +Add `&u.Permissions` immediately after `&u.Role` in both `scanUser` and `scanUsers`. + +In `Create`, normalize permissions and insert them: + +```go +permissions, err := NormalizePermissions(input.Permissions) +if err != nil { + return nil, err +} + +cols := []string{"email", "username", "password_hash", "local_password_login_enabled", "role", "permissions", "library_ids", "max_playback_quality"} +args := []any{ + input.Email, + input.Username, + string(hash), + localPasswordLoginEnabled, + input.Role, + permissions, + input.LibraryIDs, + input.MaxPlaybackQuality, +} +``` + +In `Update`, add: + +```go +if input.Permissions != nil { + permissions, err := NormalizePermissions(*input.Permissions) + if err != nil { + return err + } + setClauses = append(setClauses, fmt.Sprintf("permissions = $%d", argIndex)) + args = append(args, permissions) + argIndex++ +} +``` + +Before appending `updated_at = NOW()`, bump policy revision when access policy changes: + +```go +if input.Role != nil || + input.Enabled != nil || + input.LibraryIDs != nil || + input.MaxPlaybackQuality != nil || + input.Permissions != nil { + setClauses = append(setClauses, "access_policy_revision = access_policy_revision + 1") +} +``` + +- [ ] **Step 6: Run focused auth tests** + +Run: + +```bash +go test ./internal/auth -run 'TestNormalizePermissions|TestHasEffectivePermission' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add migrations/140_user_permissions.up.sql migrations/140_user_permissions.down.sql migrations/001_schema.up.sql internal/database/testdata/migrations/001_create_users.up.sql internal/models/user.go internal/auth/permissions.go internal/auth/permissions_test.go internal/auth/repository.go +git commit -m "feat(auth): add assignable user permissions" +``` + +--- + +### Task 2: Surface Permissions In Auth And Admin User APIs + +**Files:** +- Modify: `internal/api/handlers/auth.go` +- Modify: `internal/api/handlers/admin.go` +- Modify: `web/src/api/types.ts` + +- [ ] **Step 1: Add permissions to auth user responses** + +In `internal/api/handlers/auth.go`, update `userResponse`: + +```go +type userResponse struct { + ID int `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Role string `json:"role"` + Permissions []string `json:"permissions"` + DownloadAllowed bool `json:"download_allowed"` + Impersonation *impersonationResponse `json:"impersonation,omitempty"` +} +``` + +Update `buildUserResponse`: + +```go +resp := userResponse{ + ID: user.ID, + Username: user.Username, + Email: user.Email, + Role: user.Role, + Permissions: auth.EffectivePermissions(user), + DownloadAllowed: user.DownloadAllowed, +} +``` + +- [ ] **Step 2: Add assigned permissions to admin user requests/responses** + +In `internal/api/handlers/admin.go`, add `Permissions []string` to `createUserRequest`: + +```go +type createUserRequest struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + Role string `json:"role"` + Permissions []string `json:"permissions"` + CreateDefaultProfile bool `json:"create_default_profile"` + DefaultProfileName string `json:"default_profile_name,omitempty"` + LibraryIDs []int `json:"library_ids"` + MaxPlaybackQuality string `json:"max_playback_quality"` + MaxStreams *int `json:"max_streams,omitempty"` + MaxTranscodes *int `json:"max_transcodes,omitempty"` + MaxProfiles *int `json:"max_profiles,omitempty"` + DownloadAllowed *bool `json:"download_allowed,omitempty"` + DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"` +} +``` + +Add a reusable JSON field for optional string slices: + +```go +type updateStringSliceField struct { + Set bool + Value []string +} + +func (f *updateStringSliceField) UnmarshalJSON(data []byte) error { + f.Set = true + if bytes.Equal(bytes.TrimSpace(data), []byte("null")) { + f.Value = []string{} + return nil + } + return json.Unmarshal(data, &f.Value) +} + +func (f updateStringSliceField) Ptr() *[]string { + if !f.Set { + return nil + } + value := append([]string(nil), f.Value...) + return &value +} +``` + +Add it to `updateUserRequest`: + +```go +Permissions updateStringSliceField `json:"permissions,omitempty"` +``` + +Add assigned permissions to `adminUserResponse`: + +```go +Permissions []string `json:"permissions"` +``` + +Update `toAdminUserResponse`: + +```go +Permissions: append([]string(nil), u.Permissions...), +``` + +- [ ] **Step 3: Validate and persist admin user permissions** + +In `HandleCreateUser`, normalize before calling the provisioner: + +```go +permissions, err := auth.NormalizePermissions(req.Permissions) +if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return +} +``` + +Pass into `models.CreateUserInput`: + +```go +Permissions: permissions, +``` + +In `HandleUpdateUser`, normalize only when present: + +```go +var permissions *[]string +if req.Permissions.Set { + normalized, err := auth.NormalizePermissions(req.Permissions.Value) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + permissions = &normalized +} +``` + +Pass into `models.UpdateUserInput`: + +```go +Permissions: permissions, +``` + +Update session revocation: + +```go +func updateRequiresSessionRevocation(req updateUserRequest) bool { + return req.Password != nil || + req.Role != nil || + req.Enabled != nil || + req.LibraryIDs.Set || + req.Permissions.Set || + req.MaxPlaybackQuality != nil +} +``` + +- [ ] **Step 4: Update frontend API types** + +In `web/src/api/types.ts`, update `User`: + +```ts +export interface User { + id: number; + username: string; + email: string; + role: string; + permissions: string[]; + download_allowed: boolean; + impersonation?: ImpersonationInfo | null; +} +``` + +Update `AdminUser`: + +```ts +export interface AdminUser { + id: number; + username: string; + email: string; + role: string; + permissions: string[]; + enabled: boolean; + library_ids: number[] | null; + max_playback_quality: string; + max_streams: number; + max_transcodes: number; + max_profiles: number; + download_allowed: boolean; + download_transcode_allowed: boolean; + created_at: string; + updated_at: string; + last_active_at?: string; +} +``` + +Update request types: + +```ts +export interface CreateUserRequest { + username: string; + email: string; + password: string; + role: string; + permissions?: string[]; + create_default_profile?: boolean; + default_profile_name?: string; + library_ids?: number[] | null; + max_playback_quality?: string; + max_streams?: number; + max_transcodes?: number; + max_profiles?: number; + download_allowed?: boolean; + download_transcode_allowed?: boolean; +} + +export interface UpdateUserRequest { + username?: string; + email?: string; + password?: string; + role?: string; + permissions?: string[]; + enabled?: boolean; + library_ids?: number[] | null; + max_playback_quality?: string; + max_streams?: number; + max_transcodes?: number; + max_profiles?: number; + download_allowed?: boolean; + download_transcode_allowed?: boolean; +} +``` + +- [ ] **Step 5: Run focused compile checks** + +Run: + +```bash +go test ./internal/api/handlers -run 'TestNonExistent' -count=1 +``` + +Expected: package compiles and reports no tests to run or PASS. + +Run: + +```bash +cd web && pnpm exec tsc --noEmit +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/api/handlers/auth.go internal/api/handlers/admin.go web/src/api/types.ts +git commit -m "feat(auth): expose user permissions" +``` + +--- + +### Task 3: Add Item-Scoped Metadata Curation Middleware + +**Files:** +- Create: `internal/api/middleware/permissions.go` +- Create: `internal/api/middleware/permissions_test.go` + +- [ ] **Step 1: Write middleware tests first** + +Create `internal/api/middleware/permissions_test.go`: + +```go +package middleware + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/models" +) + +type fakePermissionUserLoader struct { + user *models.User + err error +} + +func (f fakePermissionUserLoader) GetByID(context.Context, int) (*models.User, error) { + return f.user, f.err +} + +type fakeTargetLibraryResolver struct { + ids []int + err error +} + +func (f fakeTargetLibraryResolver) ResolveMetadataTargetLibraryIDs(context.Context, string) ([]int, error) { + return f.ids, f.err +} + +func requestWithItemID(role string) *http.Request { + req := httptest.NewRequest(http.MethodPost, "/admin/items/item-1/refresh-metadata", nil) + ctx := SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: role, TokenType: auth.TokenTypeAccess}) + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add("id", "item-1") + ctx = context.WithValue(ctx, chi.RouteCtxKey, routeCtx) + return req.WithContext(ctx) +} + +func runMetadataCurationMiddleware(user *models.User, libraryIDs []int, role string) int { + mw := NewPermissionMiddleware( + fakePermissionUserLoader{user: user}, + fakeTargetLibraryResolver{ids: libraryIDs}, + ) + next := mw.RequireMetadataCurationForItem(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + rec := httptest.NewRecorder() + next.ServeHTTP(rec, requestWithItemID(role)) + return rec.Code +} + +func TestRequireMetadataCurationForItem_AllowsAdmin(t *testing.T) { + code := runMetadataCurationMiddleware(nil, nil, "admin") + if code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", code, http.StatusNoContent) + } +} + +func TestRequireMetadataCurationForItem_RejectsUserWithoutPermission(t *testing.T) { + user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: []int{1}, Permissions: nil} + code := runMetadataCurationMiddleware(user, []int{1}, "user") + if code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", code, http.StatusForbidden) + } +} + +func TestRequireMetadataCurationForItem_AllowsUnrestrictedCurator(t *testing.T) { + user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: nil, Permissions: []string{"metadata_curation"}} + code := runMetadataCurationMiddleware(user, []int{1, 2}, "user") + if code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", code, http.StatusNoContent) + } +} + +func TestRequireMetadataCurationForItem_AllowsWhenAllTargetLibrariesAreAllowed(t *testing.T) { + user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: []int{1, 2, 3}, Permissions: []string{"metadata_curation"}} + code := runMetadataCurationMiddleware(user, []int{1, 3}, "user") + if code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", code, http.StatusNoContent) + } +} + +func TestRequireMetadataCurationForItem_RejectsWhenAnyTargetLibraryIsOutsideAccess(t *testing.T) { + user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: []int{1}, Permissions: []string{"metadata_curation"}} + code := runMetadataCurationMiddleware(user, []int{1, 2}, "user") + if code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", code, http.StatusForbidden) + } +} + +func TestRequireMetadataCurationForItem_NotFoundWhenTargetHasNoLibraries(t *testing.T) { + user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: nil, Permissions: []string{"metadata_curation"}} + code := runMetadataCurationMiddleware(user, nil, "user") + if code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", code, http.StatusNotFound) + } +} +``` + +- [ ] **Step 2: Run tests and verify they fail to compile** + +Run: + +```bash +go test ./internal/api/middleware -run 'TestRequireMetadataCurationForItem' -count=1 +``` + +Expected: FAIL because `NewPermissionMiddleware` does not exist. + +- [ ] **Step 3: Implement middleware and target-library resolver** + +Create `internal/api/middleware/permissions.go`: + +```go +package middleware + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/models" +) + +type PermissionUserLoader interface { + GetByID(ctx context.Context, id int) (*models.User, error) +} + +type MetadataTargetLibraryResolver interface { + ResolveMetadataTargetLibraryIDs(ctx context.Context, contentID string) ([]int, error) +} + +type PermissionMiddleware struct { + users PermissionUserLoader + libraries MetadataTargetLibraryResolver +} + +func NewPermissionMiddleware(users PermissionUserLoader, libraries MetadataTargetLibraryResolver) *PermissionMiddleware { + return &PermissionMiddleware{users: users, libraries: libraries} +} + +func (m *PermissionMiddleware) RequireMetadataCurationForItem(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims := GetClaims(r.Context()) + if claims == nil { + writeUnauthorized(w, "Authentication required") + return + } + if claims.Role == "admin" { + next.ServeHTTP(w, r) + return + } + if m == nil || m.users == nil || m.libraries == nil { + writeForbidden(w, "Metadata curation permission required") + return + } + + contentID := chi.URLParam(r, "id") + if contentID == "" { + writePermissionError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + + user, err := m.users.GetByID(r.Context(), claims.UserID) + if err != nil || user == nil || !user.Enabled { + writeForbidden(w, "Metadata curation permission required") + return + } + if !auth.HasEffectivePermission(user, auth.PermissionMetadataCuration) { + writeForbidden(w, "Metadata curation permission required") + return + } + + targetLibraries, err := m.libraries.ResolveMetadataTargetLibraryIDs(r.Context(), contentID) + if err != nil { + writePermissionError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve item libraries") + return + } + if len(targetLibraries) == 0 { + writePermissionError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + if !metadataTargetWithinUserLibraries(user.LibraryIDs, targetLibraries) { + writeForbidden(w, "Item is outside your assigned libraries") + return + } + + next.ServeHTTP(w, r) + }) +} + +func metadataTargetWithinUserLibraries(allowed []int, target []int) bool { + if allowed == nil { + return true + } + if len(target) == 0 { + return false + } + allowedSet := make(map[int]struct{}, len(allowed)) + for _, id := range allowed { + allowedSet[id] = struct{}{} + } + for _, id := range target { + if _, ok := allowedSet[id]; !ok { + return false + } + } + return true +} + +type PGMetadataTargetLibraryResolver struct { + Pool *pgxpool.Pool +} + +func NewPGMetadataTargetLibraryResolver(pool *pgxpool.Pool) *PGMetadataTargetLibraryResolver { + return &PGMetadataTargetLibraryResolver{Pool: pool} +} + +func (r *PGMetadataTargetLibraryResolver) ResolveMetadataTargetLibraryIDs(ctx context.Context, contentID string) ([]int, error) { + if r == nil || r.Pool == nil { + return nil, fmt.Errorf("database not configured") + } + rows, err := r.Pool.Query(ctx, ` + WITH target_root AS ( + SELECT mi.content_id + FROM media_items mi + WHERE mi.content_id = $1 + UNION + SELECT s.series_id + FROM seasons s + WHERE s.content_id = $1 + UNION + SELECT e.series_id + FROM episodes e + WHERE e.content_id = $1 + ) + SELECT DISTINCT mil.media_folder_id + FROM target_root tr + JOIN media_item_libraries mil ON mil.content_id = tr.content_id + ORDER BY mil.media_folder_id`, contentID) + if err != nil { + return nil, err + } + defer rows.Close() + + var ids []int + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +func writePermissionError(w http.ResponseWriter, status int, code, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(errorResponse{Error: code, Message: message}) +} +``` + +- [ ] **Step 4: Run middleware tests** + +Run: + +```bash +go test ./internal/api/middleware -run 'TestRequireMetadataCurationForItem' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/api/middleware/permissions.go internal/api/middleware/permissions_test.go +git commit -m "feat(api): authorize item metadata curation" +``` + +--- + +### Task 4: Wire Routes And Scoped Job Polling + +**Files:** +- Modify: `internal/api/router.go` +- Modify: `internal/api/handlers/admin_jobs.go` +- Create or modify: `internal/api/handlers/admin_jobs_test.go` + +- [ ] **Step 1: Add job access predicate tests** + +Create `internal/api/handlers/admin_jobs_test.go` if it does not exist, or append to it: + +```go +package handlers + +import ( + "testing" + + "github.com/Silo-Server/silo-server/internal/adminjob" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/models" +) + +func TestCanReadAdminJob_AdminCanReadAnyJob(t *testing.T) { + claims := &auth.Claims{UserID: 1, Role: "admin"} + job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeCatalogExport} + if !canReadAdminJob(claims, job) { + t.Fatal("admin should be allowed to read any job") + } +} + +func TestCanReadAdminJob_CreatorCanReadOwnItemRefreshJob(t *testing.T) { + claims := &auth.Claims{UserID: 2, Role: "user"} + job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeItemRefresh} + if !canReadAdminJob(claims, job) { + t.Fatal("creator should be allowed to read own item refresh job") + } +} + +func TestCanReadAdminJob_CreatorCannotReadOwnNonItemRefreshJob(t *testing.T) { + claims := &auth.Claims{UserID: 2, Role: "user"} + job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeCatalogExport} + if canReadAdminJob(claims, job) { + t.Fatal("non-admin should not read non-item-refresh jobs") + } +} + +func TestCanReadAdminJob_OtherUserCannotReadItemRefreshJob(t *testing.T) { + claims := &auth.Claims{UserID: 3, Role: "user"} + job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeItemRefresh} + if canReadAdminJob(claims, job) { + t.Fatal("non-admin should not read another user's item refresh job") + } +} +``` + +- [ ] **Step 2: Run tests and verify they fail** + +Run: + +```bash +go test ./internal/api/handlers -run 'TestCanReadAdminJob' -count=1 +``` + +Expected: FAIL because `canReadAdminJob` does not exist. + +- [ ] **Step 3: Implement scoped job reads** + +In `internal/api/handlers/admin_jobs.go`, update `HandleGet` after loading the job: + +```go +claims := apimw.GetClaims(r.Context()) +if !canReadAdminJob(claims, job) { + writeError(w, http.StatusForbidden, "forbidden", "Admin access required") + return +} + +response := adminJobToResponse(r, job, h.store) +if claims == nil || claims.Role != "admin" { + response.RequestPayload = json.RawMessage(`{}`) + response.PublicURL = "" + response.DownloadURL = "" + response.DownloadExpiresAt = nil +} +writeJSON(w, http.StatusOK, response) +``` + +Add the helper near `currentAdminUserID`: + +```go +func canReadAdminJob(claims *auth.Claims, job *models.AdminJob) bool { + if claims == nil || job == nil { + return false + } + if claims.Role == "admin" { + return true + } + return job.JobType == adminjob.JobTypeItemRefresh && job.CreatedByUserID == claims.UserID +} +``` + +Add the `auth` import if it is not already present: + +```go +"github.com/Silo-Server/silo-server/internal/auth" +``` + +- [ ] **Step 4: Instantiate permission middleware in the router** + +In `internal/api/router.go`, after `viewerAccessMiddleware` setup, add: + +```go +var permissionMiddleware *apimw.PermissionMiddleware +if userRepo != nil && deps.DB != nil { + permissionMiddleware = apimw.NewPermissionMiddleware( + userRepo, + apimw.NewPGMetadataTargetLibraryResolver(deps.DB), + ) +} +``` + +- [ ] **Step 5: Split `/admin` routes** + +Replace the single admin route group: + +```go +r.Route("/admin", func(r chi.Router) { + r.Use(apimw.RequireAdmin) + // current admin route declarations +}) +``` + +with this shape: + +```go +r.Route("/admin", func(r chi.Router) { + metadataItemAccess := apimw.RequireAdmin + if permissionMiddleware != nil { + metadataItemAccess = permissionMiddleware.RequireMetadataCurationForItem + } + + r.Group(func(r chi.Router) { + r.Use(metadataItemAccess) + r.Post("/items/{id}/refresh-metadata", adminHandler.HandleRefreshItemMetadata) + r.Patch("/items/{id}/metadata", adminHandler.HandleUpdateItemMetadata) + if adminMatchHandler != nil { + r.Post("/items/{id}/match/search", adminMatchHandler.HandleSearchItemMatchCandidates) + r.Post("/items/{id}/match/apply", adminMatchHandler.HandleApplyItemMatch) + } + }) + + if adminJobsHandler != nil { + r.Get("/jobs/{id}", adminJobsHandler.HandleGet) + } + + r.Group(func(r chi.Router) { + r.Use(apimw.RequireAdmin) + + r.Get("/users", adminHandler.HandleListUsers) + r.Post("/users", adminHandler.HandleCreateUser) + r.Get("/users/{id}", adminHandler.HandleGetUser) + r.Put("/users/{id}", adminHandler.HandleUpdateUser) + r.Delete("/users/{id}", adminHandler.HandleDeleteUser) + r.Post("/users/{id}/impersonate", adminHandler.HandleImpersonateUser) + + // Move these existing route declarations into this admin-only group + // without changing their handler names: + // users, user profiles/settings/device settings, devices, sessions, + // playback history, unmatched, stats, settings, section settings, + // item marker/intro refresh, people refresh/update, item images, + // filesystem browse, catalog seed import/export, plugins, logs, + // subtitle providers, tasks, task metrics, scans, nodes, requests, + // history imports, sections, collections, collection groups, + // recommendation admin routes, system routes, API keys, and rate limits. + // + // Do not duplicate /items/{id}/refresh-metadata, + // /items/{id}/metadata, /items/{id}/match/search, + // /items/{id}/match/apply, or /jobs/{id}. + + if adminJobsHandler != nil { + r.Route("/jobs", func(r chi.Router) { + r.Get("/", adminJobsHandler.HandleList) + }) + } + }) +}) +``` + +When moving route declarations, compare against the current `r.Route("/admin", ...)` block and keep every admin-only path not listed in the duplication warning in the `RequireAdmin` group with the same path and handler. + +- [ ] **Step 6: Run focused backend checks** + +Run: + +```bash +go test ./internal/api/handlers -run 'TestCanReadAdminJob' -count=1 +``` + +Expected: PASS. + +Run: + +```bash +go test ./internal/api/middleware -run 'TestRequireMetadataCurationForItem' -count=1 +``` + +Expected: PASS. + +Run: + +```bash +go test ./internal/api -run 'TestNonExistent' -count=1 +``` + +Expected: package compiles and reports no tests to run or PASS. + +- [ ] **Step 7: Commit** + +```bash +git add internal/api/router.go internal/api/handlers/admin_jobs.go internal/api/handlers/admin_jobs_test.go +git commit -m "feat(api): route metadata curation by permission" +``` + +--- + +### Task 5: Add Frontend Permission Helpers + +**Files:** +- Create: `web/src/lib/permissions.ts` + +- [ ] **Step 1: Add shared helper** + +Create `web/src/lib/permissions.ts`: + +```ts +import type { User } from "@/api/types"; + +export const PERMISSION_METADATA_CURATION = "metadata_curation"; + +export function hasPermission( + user: Pick | null | undefined, + permission: string, +) { + if (!user) return false; + if (user.role === "admin") return true; + return Array.isArray(user.permissions) && user.permissions.includes(permission); +} + +export function canCurateMetadata(user: Pick | null | undefined) { + return hasPermission(user, PERMISSION_METADATA_CURATION); +} +``` + +- [ ] **Step 2: Run frontend type check** + +Run: + +```bash +cd web && pnpm exec tsc --noEmit +``` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add web/src/lib/permissions.ts +git commit -m "feat(web): add permission helpers" +``` + +--- + +### Task 6: Add Metadata Curation Toggle To User Management + +**Files:** +- Modify: `web/src/pages/AdminUsers.tsx` +- Modify: `web/src/pages/AdminUserDetail.tsx` + +- [ ] **Step 1: Add helpers local to each user form file** + +In both files, import: + +```ts +import { PERMISSION_METADATA_CURATION } from "@/lib/permissions"; +``` + +Add local helpers near other small helpers: + +```ts +function hasAssignedPermission(permissions: string[] | undefined, permission: string) { + return Array.isArray(permissions) && permissions.includes(permission); +} + +function setAssignedPermission(permissions: string[], permission: string, enabled: boolean) { + const next = new Set(permissions); + if (enabled) { + next.add(permission); + } else { + next.delete(permission); + } + return Array.from(next).sort(); +} +``` + +- [ ] **Step 2: Update `AdminUsers.tsx` create/edit form state and submit bodies** + +Inside `UserForm`, add: + +```ts +const [permissions, setPermissions] = useState(user?.permissions ?? []); +const metadataCurationId = useId(); +``` + +In the update body: + +```ts +permissions, +``` + +In the create body: + +```ts +permissions, +``` + +In the Access tab, after `LibraryAccessSelector`, add: + +```tsx +
+
+ +

+ Edit, refresh, and rematch metadata within assigned libraries. +

+
+ + setPermissions((current) => + setAssignedPermission(current, PERMISSION_METADATA_CURATION, checked), + ) + } + /> +
+``` + +- [ ] **Step 3: Update `AdminUserDetail.tsx` edit form and summary** + +In the user detail summary near role/library/download rows, add a row: + +```tsx + +``` + +Inside `EditUserForm`, add: + +```ts +const [permissions, setPermissions] = useState(user.permissions ?? []); +const metadataCurationId = useId(); +``` + +In the update body: + +```ts +permissions, +``` + +In the Access tab, after `LibraryAccessSelector`, add: + +```tsx +
+
+ +

+ Edit, refresh, and rematch metadata within assigned libraries. +

+
+ + setPermissions((current) => + setAssignedPermission(current, PERMISSION_METADATA_CURATION, checked), + ) + } + /> +
+``` + +- [ ] **Step 4: Run frontend lint/type check** + +Run: + +```bash +cd web && pnpm exec tsc --noEmit +``` + +Expected: PASS. + +Run: + +```bash +cd web && pnpm run lint +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/pages/AdminUsers.tsx web/src/pages/AdminUserDetail.tsx +git commit -m "feat(web): assign metadata curation permission" +``` + +--- + +### Task 7: Show Item Metadata Controls For Curators + +**Files:** +- Modify: `web/src/pages/ItemDetail/components/ActionBar.tsx` +- Modify: `web/src/pages/ItemDetail/MovieContent.tsx` +- Modify: `web/src/pages/ItemDetail/SeriesContent.tsx` +- Modify: `web/src/pages/ItemDetail/SeasonContent.tsx` +- Modify: `web/src/pages/ItemDetail/EpisodeContent.tsx` + +- [ ] **Step 1: Split ActionBar full-admin and metadata-curation actions** + +In `ActionBarProps`, add: + +```ts +canCurateMetadata?: boolean; +``` + +Destructure it: + +```ts +canCurateMetadata = false, +``` + +Add derived booleans near `hasOverflowActions`: + +```ts +const hasAdminActions = Boolean( + isAdmin && (contentId || onRedetectIntro), +); +const hasMetadataActions = Boolean( + canCurateMetadata && (onRefresh || onEditMetadata || onMatchItem), +); +``` + +Replace the existing `{isAdmin && (...)}` block in the overflow menu with: + +```tsx +{(hasAdminActions || hasMetadataActions) && ( + <> + {hasOverflowActions && } + {isAdmin && contentId && ( + + navigate(`/admin/history?media_item_id=${encodeURIComponent(contentId)}`) + } + > + View Play History + + )} + {canCurateMetadata && onRefresh && ( + { + setRefreshDialogOpen(true); + }} + > + {isRefreshing && } + Refresh Metadata + + )} + {isAdmin && onRedetectIntro && ( + + + Re-detect Intro Markers + + )} + {canCurateMetadata && onEditMetadata && ( + + + Edit Metadata + + )} + {canCurateMetadata && onMatchItem && ( + + + Match Item + + )} + +)} +``` + +Keep `RefreshMetadataDialog` mounted as it is today. + +- [ ] **Step 2: Update movie item detail** + +In `web/src/pages/ItemDetail/MovieContent.tsx`, import: + +```ts +import { canCurateMetadata as canCurateMetadataForUser } from "@/lib/permissions"; +``` + +After `isAdmin`: + +```ts +const canCurateMetadata = canCurateMetadataForUser(user); +``` + +Update `ActionBar` props: + +```tsx +isAdmin={isAdmin} +canCurateMetadata={canCurateMetadata} +onEditMetadata={canCurateMetadata ? () => setEditOpen(true) : undefined} +onMatchItem={canCurateMetadata ? () => setMatchOpen(true) : undefined} +``` + +Update dialog rendering: + +```tsx +{canCurateMetadata && } +{canCurateMetadata && ( + +)} +``` + +Keep media locations admin-only: + +```tsx +{isAdmin && } +``` + +- [ ] **Step 3: Update series item detail** + +In `web/src/pages/ItemDetail/SeriesContent.tsx`, import: + +```ts +import { canCurateMetadata as canCurateMetadataForUser } from "@/lib/permissions"; +``` + +After `isAdmin`: + +```ts +const canCurateMetadata = canCurateMetadataForUser(user); +``` + +Update `ActionBar`: + +```tsx +isAdmin={isAdmin} +canCurateMetadata={canCurateMetadata} +onEditMetadata={canCurateMetadata ? () => setEditOpen(true) : undefined} +onMatchItem={canCurateMetadata ? () => setMatchOpen(true) : undefined} +``` + +Update dialog rendering: + +```tsx +{canCurateMetadata && } +{canCurateMetadata && ( + +)} +``` + +- [ ] **Step 4: Update season item detail** + +In `web/src/pages/ItemDetail/SeasonContent.tsx`, import: + +```ts +import { canCurateMetadata as canCurateMetadataForUser } from "@/lib/permissions"; +``` + +After `isAdmin`: + +```ts +const canCurateMetadata = canCurateMetadataForUser(user); +``` + +Update `ActionBar`: + +```tsx +isAdmin={isAdmin} +canCurateMetadata={canCurateMetadata} +onEditMetadata={canCurateMetadata ? () => setEditOpen(true) : undefined} +``` + +Update dialog rendering: + +```tsx +{canCurateMetadata && } +``` + +- [ ] **Step 5: Update episode item detail** + +In `web/src/pages/ItemDetail/EpisodeContent.tsx`, import: + +```ts +import { canCurateMetadata as canCurateMetadataForUser } from "@/lib/permissions"; +``` + +After `isAdmin`: + +```ts +const canCurateMetadata = canCurateMetadataForUser(user); +``` + +Update `ActionBar`: + +```tsx +isAdmin={isAdmin} +canCurateMetadata={canCurateMetadata} +onRedetectIntro={isAdmin ? () => redetectIntroMutation.mutate(item.content_id) : undefined} +onEditMetadata={canCurateMetadata ? () => setEditOpen(true) : undefined} +``` + +Keep media locations and intro redetection admin-only. Update dialog rendering to use `canCurateMetadata`. + +- [ ] **Step 6: Run frontend checks** + +Run: + +```bash +cd web && pnpm exec tsc --noEmit +``` + +Expected: PASS. + +Run: + +```bash +cd web && pnpm run lint +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add web/src/pages/ItemDetail/components/ActionBar.tsx web/src/pages/ItemDetail/MovieContent.tsx web/src/pages/ItemDetail/SeriesContent.tsx web/src/pages/ItemDetail/SeasonContent.tsx web/src/pages/ItemDetail/EpisodeContent.tsx +git commit -m "feat(web): show metadata tools to curators" +``` + +--- + +### Task 8: End-To-End Verification + +**Files:** +- No new files. + +- [ ] **Step 1: Run focused backend tests** + +Run: + +```bash +go test ./internal/auth ./internal/api/middleware ./internal/api/handlers -run 'TestNormalizePermissions|TestHasEffectivePermission|TestRequireMetadataCurationForItem|TestCanReadAdminJob' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 2: Run broader API compile/test check** + +Run: + +```bash +go test ./internal/api/... ./internal/auth/... -count=1 +``` + +Expected: PASS. + +- [ ] **Step 3: Run frontend checks** + +Run: + +```bash +cd web && pnpm exec tsc --noEmit +``` + +Expected: PASS. + +Run: + +```bash +cd web && pnpm run lint +``` + +Expected: PASS. + +- [ ] **Step 4: Verify local path hygiene** + +Run: + +```bash +make verify-local-paths +``` + +Expected: PASS. + +- [ ] **Step 5: Manual behavior verification** + +Use an admin account to create or edit a normal user with: + +```text +permissions = ["metadata_curation"] +library_ids = [one library containing a known item] +``` + +Then verify: + +```text +1. The user can open that item. +2. The item detail overflow menu shows Refresh Metadata, Edit Metadata, and Match Item. +3. The user can save a small metadata edit for that item. +4. The user can search match candidates for that item. +5. The user can queue a metadata refresh and the web UI observes the job completion. +6. The same user cannot edit, refresh, or rematch an item whose target library set includes a library outside their assigned library IDs. +7. The same user cannot open full admin pages such as /admin/users or /admin/settings. +8. The same user cannot call image apply, people update, marker refresh, library refresh, or admin job list endpoints. +9. An admin account can still use all existing admin metadata and non-metadata routes. +``` + +- [ ] **Step 6: Commit verification-only fixes if any** + +If verification exposes small follow-up fixes, commit them with a scoped message: + +```bash +git add +git commit -m "fix(auth): tighten metadata curation access" +``` + +--- + +## Acceptance Criteria + +- `users.permissions` stores assigned account permission keys. +- `metadata_curation` is the only assignable permission in this first pass. +- `/auth/me` and login responses include effective permissions. +- Admin user APIs include assigned permissions and reject unknown permission keys. +- Non-admin users without `metadata_curation` remain forbidden from item metadata mutation routes. +- Non-admin users with `metadata_curation` can edit, refresh, and match only items fully contained by their account-level allowed libraries. +- Seasons and episodes inherit library scope from their parent series. +- Metadata refresh polling works for curators without exposing the admin job list. +- Full admin UI and unrelated admin APIs remain admin-only. +- Frontend item metadata controls appear for admins and metadata curators; admin-only controls remain admin-only. + +--- + +## Risks And Notes + +- Existing access tokens still carry `role`, but permission checks must load the user from the database. Do not add permission claims to JWTs for server authorization. +- Revoking sessions on permission changes follows the existing admin user update pattern and prevents stale frontend auth state from lingering. +- Item metadata is global. The subset check must require all target libraries to be allowed, not merely one matching library. +- Do not use profile library restrictions for this authorization check. This is an account-level permission bounded by `users.library_ids`. +- The first pass intentionally excludes custom permission groups. The `users.permissions text[]` shape is enough to add future permission keys without redesigning storage. diff --git a/docs/superpowers/plans/2026-05-26-tmdb-duplicate-tie-breaker.md b/docs/superpowers/plans/2026-05-26-tmdb-duplicate-tie-breaker.md new file mode 100644 index 00000000..44bd4dec --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-tmdb-duplicate-tie-breaker.md @@ -0,0 +1,976 @@ +# TMDB Duplicate Tie-Breaker Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Auto-match duplicate same-title/year provider candidates when one candidate has clearly richer metadata, while still refusing uncertain matches. + +**Architecture:** Keep the existing title/year/ID scorer as the primary gate. Add a secondary detail-score path that runs only for near-tied duplicate candidates, enriches those candidates through the configured metadata provider chain, and accepts a winner only when the richness gap is strong. Manual match search can keep showing all candidates unchanged. + +**Tech Stack:** Go, PostgreSQL-backed metadata service, existing metadata provider interfaces, `go test`. + +--- + +## File Structure + +- Modify `internal/metadata/match_candidates.go` + - Add detail-score fields to `MatchCandidate`. + - Add pure helper functions for duplicate-tie detection and metadata richness scoring. + - Update `selectInitialMatchCandidate` to use detail score only when the normal score gap rejects an otherwise duplicate tie. + +- Modify `internal/metadata/match_candidates_test.go` + - Add focused unit tests for TMDB duplicate tie resolution. + - Add tests proving detail score does not override non-duplicate near matches. + +- Modify `internal/metadata/service.go` + - Enrich candidate detail scores before selecting an initial match. + - Use the existing configured provider chain and `MetadataProvider.GetMetadata`, so the behavior works with installed TMDB/TVDB plugins instead of hard-coding a TMDB client. + +- Modify `internal/metadata/service_test.go` or the nearest existing service-level test file if `service_test.go` already contains `MetadataService.Process` fakes + - Add one integration-style unit test proving two identical TMDB candidates can be disambiguated after detail enrichment. + +Do not add migrations. Do not add frontend changes. Do not persist the detail score; it is a transient matching decision signal. + +--- + +### Task 1: Add Detail-Score Tie-Breaker Unit Tests + +**Files:** +- Modify: `internal/metadata/match_candidates_test.go` + +- [ ] **Step 1: Add failing tests for duplicate tie selection** + +Append these tests after `TestSelectInitialMatchCandidate_AcceptsProviderTitleWithRepeatedYear`: + +```go +func TestSelectInitialMatchCandidate_UsesDetailScoreForDuplicateProviderTie(t *testing.T) { + winner, ok := selectInitialMatchCandidate( + &MatchHints{ + Title: "UFC 4 Revenge of the Warriors", + Year: 1994, + Type: "movie", + }, + []MatchCandidate{ + { + Title: "UFC 4: Revenge of the Warriors", + Year: 1994, + ContentType: "movie", + ProviderIDs: map[string]string{"tmdb": "1558410"}, + Sources: []string{"tmdb"}, + DetailScore: 18, + }, + { + Title: "UFC 4: Revenge of the Warriors", + Year: 1994, + ContentType: "movie", + ProviderIDs: map[string]string{"tmdb": "17508", "imdb": "tt0487980"}, + Sources: []string{"tmdb"}, + DetailScore: 46, + }, + }, + ) + if !ok || winner == nil { + t.Fatal("expected richer duplicate TMDB candidate to be accepted") + } + if got := winner.ProviderIDs["tmdb"]; got != "17508" { + t.Fatalf("winner tmdb = %q, want 17508", got) + } +} + +func TestSelectInitialMatchCandidate_RejectsDuplicateTieWithoutClearDetailGap(t *testing.T) { + winner, ok := selectInitialMatchCandidate( + &MatchHints{ + Title: "UFC 4 Revenge of the Warriors", + Year: 1994, + Type: "movie", + }, + []MatchCandidate{ + { + Title: "UFC 4: Revenge of the Warriors", + Year: 1994, + ContentType: "movie", + ProviderIDs: map[string]string{"tmdb": "1558410"}, + Sources: []string{"tmdb"}, + DetailScore: 28, + }, + { + Title: "UFC 4: Revenge of the Warriors", + Year: 1994, + ContentType: "movie", + ProviderIDs: map[string]string{"tmdb": "17508"}, + Sources: []string{"tmdb"}, + DetailScore: 34, + }, + }, + ) + if ok || winner != nil { + t.Fatal("expected duplicate tie without clear detail gap to remain unmatched") + } +} + +func TestSelectInitialMatchCandidate_DetailScoreDoesNotOverrideDifferentTitleTie(t *testing.T) { + winner, ok := selectInitialMatchCandidate( + &MatchHints{ + Title: "UFC 4 Revenge of the Warriors", + Year: 1994, + Type: "movie", + }, + []MatchCandidate{ + { + Title: "UFC 4: Revenge of the Warriors", + Year: 1994, + ContentType: "movie", + ProviderIDs: map[string]string{"tmdb": "17508"}, + Sources: []string{"tmdb"}, + DetailScore: 22, + }, + { + Title: "UFC 4: The Alternate Fights", + Year: 1994, + ContentType: "movie", + ProviderIDs: map[string]string{"tmdb": "999999"}, + Sources: []string{"tmdb"}, + DetailScore: 80, + }, + }, + ) + if ok || winner != nil { + t.Fatal("expected richer different-title candidate to be rejected") + } +} +``` + +- [ ] **Step 2: Run tests and verify they fail** + +Run: + +```bash +go test ./internal/metadata -run 'TestSelectInitialMatchCandidate_UsesDetailScoreForDuplicateProviderTie|TestSelectInitialMatchCandidate_RejectsDuplicateTieWithoutClearDetailGap|TestSelectInitialMatchCandidate_DetailScoreDoesNotOverrideDifferentTitleTie' -count=1 +``` + +Expected: fail because `MatchCandidate.DetailScore` does not exist. + +- [ ] **Step 3: Commit the failing tests** + +```bash +git add internal/metadata/match_candidates_test.go +git commit -m "test(metadata): cover duplicate candidate tie breaking" +``` + +--- + +### Task 2: Implement Pure Detail-Score Selection + +**Files:** +- Modify: `internal/metadata/match_candidates.go` + +- [ ] **Step 1: Add transient detail fields to `MatchCandidate`** + +Update the struct near the top of `internal/metadata/match_candidates.go`: + +```go +type MatchCandidate struct { + Title string `json:"title"` + Year int `json:"year"` + ContentType string `json:"content_type"` + ProviderIDs map[string]string `json:"provider_ids"` + ImageURL string `json:"image_url,omitempty"` + Overview string `json:"overview,omitempty"` + Sources []string `json:"sources"` + AgreementHints []string `json:"agreement_hints"` + DetailScore int `json:"-"` +} +``` + +- [ ] **Step 2: Add duplicate-tie helper constants and functions** + +Add these helpers after `providerIDRichness`: + +```go +const ( + minimumDetailTieBreakScore = 20 + minimumDetailTieBreakGap = 12 +) + +func duplicateTieBreakWinner(hints *MatchHints, scoredCandidates []scoredMatchCandidate) (*MatchCandidate, bool) { + if hints == nil || len(scoredCandidates) < 2 { + return nil, false + } + best := scoredCandidates[0] + if best.candidate.DetailScore < minimumDetailTieBreakScore { + return nil, false + } + + contenders := []scoredMatchCandidate{best} + for i := 1; i < len(scoredCandidates); i++ { + next := scoredCandidates[i] + if best.score-next.score >= 15 { + break + } + if duplicateTieBreakComparable(hints, best.candidate, next.candidate) { + contenders = append(contenders, next) + } + } + if len(contenders) < 2 { + return nil, false + } + + sort.SliceStable(contenders, func(i, j int) bool { + return contenders[i].candidate.DetailScore > contenders[j].candidate.DetailScore + }) + if contenders[0].candidate.DetailScore-contenders[1].candidate.DetailScore < minimumDetailTieBreakGap { + return nil, false + } + return &contenders[0].candidate, true +} + +func duplicateTieBreakComparable(hints *MatchHints, left, right MatchCandidate) bool { + if left.Year != 0 && right.Year != 0 && left.Year != right.Year { + return false + } + if hints.Year != 0 { + if left.Year != 0 && left.Year != hints.Year { + return false + } + if right.Year != 0 && right.Year != hints.Year { + return false + } + } + if strings.TrimSpace(left.ContentType) != "" && + strings.TrimSpace(right.ContentType) != "" && + !strings.EqualFold(left.ContentType, right.ContentType) { + return false + } + if inferTitleSimilarity(left.Title, right.Title, hints.Year) != 1 { + return false + } + if inferTitleSimilarity(hints.Title, left.Title, hints.Year) != 1 { + return false + } + if inferTitleSimilarity(hints.Title, right.Title, hints.Year) != 1 { + return false + } + return samePrimaryProvider(left.ProviderIDs, right.ProviderIDs) +} + +func samePrimaryProvider(left, right map[string]string) bool { + for _, key := range canonicalCandidateIDKeys { + leftValue := strings.TrimSpace(left[key]) + rightValue := strings.TrimSpace(right[key]) + if leftValue != "" && rightValue != "" { + return true + } + } + return false +} +``` + +- [ ] **Step 3: Promote the local scored type so helpers can use it** + +Move the `scored` type out of `selectInitialMatchCandidate` and rename it: + +```go +type scoredMatchCandidate struct { + candidate MatchCandidate + score float64 +} +``` + +Place it immediately above `selectInitialMatchCandidate`. + +- [ ] **Step 4: Update `selectInitialMatchCandidate` to use the helper** + +Replace the first half of `selectInitialMatchCandidate` with: + +```go +func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate) (*MatchCandidate, bool) { + if len(candidates) == 0 { + return nil, false + } + + scoredCandidates := make([]scoredMatchCandidate, 0, len(candidates)) + for _, candidate := range candidates { + scoredCandidates = append(scoredCandidates, scoredMatchCandidate{ + candidate: candidate, + score: scoreMatchCandidate(hints, candidate), + }) + } + sort.SliceStable(scoredCandidates, func(i, j int) bool { + return scoredCandidates[i].score > scoredCandidates[j].score + }) + + best := scoredCandidates[0] + if trustedHintIDsPresent(hints) { + if candidateMatchesTrustedIDs(hints, best.candidate) { + return &best.candidate, true + } + return nil, false + } + + if best.score < 55 { + return nil, false + } + if len(scoredCandidates) == 1 { + if best.score < 70 { + return nil, false + } + return &best.candidate, true + } + if best.score-scoredCandidates[1].score < 15 { + return duplicateTieBreakWinner(hints, scoredCandidates) + } + return &best.candidate, true +} +``` + +- [ ] **Step 5: Run focused tests and verify they pass** + +Run: + +```bash +go test ./internal/metadata -run 'TestSelectInitialMatchCandidate_UsesDetailScoreForDuplicateProviderTie|TestSelectInitialMatchCandidate_RejectsDuplicateTieWithoutClearDetailGap|TestSelectInitialMatchCandidate_DetailScoreDoesNotOverrideDifferentTitleTie' -count=1 +``` + +Expected: pass. + +- [ ] **Step 6: Run nearby candidate tests** + +Run: + +```bash +go test ./internal/metadata -run 'TestSelectInitialMatchCandidate|TestSelectRefreshMatchCandidate' -count=1 +``` + +Expected: pass. + +- [ ] **Step 7: Commit pure selector change** + +```bash +git add internal/metadata/match_candidates.go internal/metadata/match_candidates_test.go +git commit -m "fix(metadata): resolve rich duplicate candidate ties" +``` + +--- + +### Task 3: Add Metadata Completeness Scoring + +**Files:** +- Modify: `internal/metadata/match_candidates.go` +- Modify: `internal/metadata/match_candidates_test.go` + +- [ ] **Step 1: Add failing tests for metadata completeness** + +Append these tests near the other scoring tests in `internal/metadata/match_candidates_test.go`: + +```go +func TestMetadataCompletenessScorePrefersExternalIDsAndRichFields(t *testing.T) { + rich := &MetadataResult{ + HasMetadata: true, + ProviderIDs: map[string]string{"tmdb": "17508", "imdb": "tt0487980"}, + Title: "UFC 4: Revenge of the Warriors", + Overview: "UFC 4 was a mixed martial arts event.", + Year: 1994, + Runtime: 99, + PosterPath: "tmdb://poster/17508.jpg", + BackdropPath: "tmdb://backdrop/17508.jpg", + Tagline: "Revenge of the Warriors", + OriginalTitle: "UFC 4: Revenge of the Warriors", + Studios: []string{"Ultimate Fighting Championship"}, + Keywords: []string{"mixed martial arts"}, + Ratings: Ratings{TMDB: 7.4}, + People: []models.ItemPerson{ + {Name: "Royce Gracie", Role: "Self", Type: "actor", OrderIndex: 0}, + {Name: "Dan Severn", Role: "Self", Type: "actor", OrderIndex: 1}, + }, + } + thin := &MetadataResult{ + HasMetadata: true, + ProviderIDs: map[string]string{"tmdb": "1558410"}, + Title: "UFC 4: Revenge of the Warriors", + Overview: "UFC 4 used an eight-man tournament format.", + Year: 1994, + Runtime: 90, + PosterPath: "tmdb://poster/1558410.jpg", + People: []models.ItemPerson{ + {Name: "Marcus Bossett", Type: "actor", OrderIndex: 0}, + }, + } + + richScore := metadataCompletenessScore(rich) + thinScore := metadataCompletenessScore(thin) + if richScore-thinScore < minimumDetailTieBreakGap { + t.Fatalf("richScore - thinScore = %d, want at least %d; rich=%d thin=%d", + richScore-thinScore, minimumDetailTieBreakGap, richScore, thinScore) + } +} + +func TestMetadataCompletenessScoreHandlesNilAndEmptyMetadata(t *testing.T) { + if got := metadataCompletenessScore(nil); got != 0 { + t.Fatalf("nil score = %d, want 0", got) + } + if got := metadataCompletenessScore(&MetadataResult{}); got != 0 { + t.Fatalf("empty score = %d, want 0", got) + } +} +``` + +- [ ] **Step 2: Run tests and verify they fail** + +Run: + +```bash +go test ./internal/metadata -run 'TestMetadataCompletenessScore' -count=1 +``` + +Expected: fail because `metadataCompletenessScore` is undefined. + +- [ ] **Step 3: Add completeness scoring helper** + +Add this helper after `providerIDRichness` in `internal/metadata/match_candidates.go`: + +```go +func metadataCompletenessScore(result *MetadataResult) int { + if result == nil || !result.HasMetadata { + return 0 + } + score := 0 + if strings.TrimSpace(result.ProviderIDs["imdb"]) != "" { + score += 18 + } + if strings.TrimSpace(result.ProviderIDs["tvdb"]) != "" { + score += 18 + } + if strings.TrimSpace(result.ProviderIDs["tmdb"]) != "" { + score += 4 + } + if strings.TrimSpace(result.Title) != "" { + score += 4 + } + if strings.TrimSpace(result.OriginalTitle) != "" { + score += 2 + } + if strings.TrimSpace(result.Overview) != "" { + score += 6 + } + if result.Year != 0 { + score += 4 + } + if result.Runtime > 0 { + score += 3 + } + if strings.TrimSpace(result.PosterPath) != "" { + score += 4 + } + if strings.TrimSpace(result.BackdropPath) != "" { + score += 5 + } + if strings.TrimSpace(result.Homepage) != "" { + score += 3 + } + if len(result.Studios) > 0 { + score += 3 + } + if len(result.Networks) > 0 { + score += 2 + } + if len(result.Countries) > 0 { + score += 2 + } + if len(result.Keywords) > 0 { + score += 2 + } + if result.Ratings.TMDB > 0 { + score += 2 + } + if strings.TrimSpace(result.ContentRating) != "" { + score += 2 + } + score += boundedCountScore(len(result.People), 10) + return score +} + +func boundedCountScore(count, max int) int { + if count <= 0 { + return 0 + } + if count > max { + return max + } + return count +} +``` + +- [ ] **Step 4: Run completeness tests** + +Run: + +```bash +go test ./internal/metadata -run 'TestMetadataCompletenessScore' -count=1 +``` + +Expected: pass. + +- [ ] **Step 5: Run all candidate tests** + +Run: + +```bash +go test ./internal/metadata -run 'TestSelectInitialMatchCandidate|TestSelectRefreshMatchCandidate|TestMetadataCompletenessScore' -count=1 +``` + +Expected: pass. + +- [ ] **Step 6: Commit completeness scoring** + +```bash +git add internal/metadata/match_candidates.go internal/metadata/match_candidates_test.go +git commit -m "fix(metadata): score candidate metadata completeness" +``` + +--- + +### Task 4: Enrich Near-Duplicate Candidates Before Initial Selection + +**Files:** +- Modify: `internal/metadata/service.go` + +- [ ] **Step 1: Add candidate enrichment call in initial match flow** + +In `internal/metadata/service.go`, inside the `ModeInitialMatch` case, find: + +```go +candidates := NormalizeCandidates(allResults, contentType) +if winner, ok := selectInitialMatchCandidate(req.Hints, candidates); ok && winner != nil { + for k, v := range winner.ProviderIDs { + if v != "" { + accumulatedIDs[k] = v + } + } +} +``` + +Replace it with: + +```go +candidates := NormalizeCandidates(allResults, contentType) +s.enrichInitialMatchDuplicateCandidates(ctx, req, itemChain, candidates) +if winner, ok := selectInitialMatchCandidate(req.Hints, candidates); ok && winner != nil { + for k, v := range winner.ProviderIDs { + if v != "" { + accumulatedIDs[k] = v + } + } +} +``` + +- [ ] **Step 2: Add enrichment helpers** + +Add these helpers near `processInternal` helper functions in `internal/metadata/service.go`: + +```go +func (s *MetadataService) enrichInitialMatchDuplicateCandidates( + ctx context.Context, + req ProcessRequest, + itemChain []Provider, + candidates []MatchCandidate, +) { + if req.Hints == nil || len(candidates) < 2 { + return + } + indexes := candidateIndexesNeedingDetailScores(req.Hints, candidates) + if len(indexes) < 2 { + return + } + for _, index := range indexes { + candidates[index].DetailScore = s.detailScoreForCandidate(ctx, req, itemChain, candidates[index]) + } +} + +func candidateIndexesNeedingDetailScores(hints *MatchHints, candidates []MatchCandidate) []int { + if hints == nil || len(candidates) < 2 { + return nil + } + scoredCandidates := make([]scoredMatchCandidate, 0, len(candidates)) + for _, candidate := range candidates { + scoredCandidates = append(scoredCandidates, scoredMatchCandidate{ + candidate: candidate, + score: scoreMatchCandidate(hints, candidate), + }) + } + sort.SliceStable(scoredCandidates, func(i, j int) bool { + return scoredCandidates[i].score > scoredCandidates[j].score + }) + if scoredCandidates[0].score < 55 { + return nil + } + if len(scoredCandidates) < 2 || scoredCandidates[0].score-scoredCandidates[1].score >= 15 { + return nil + } + + indexes := make([]int, 0, len(candidates)) + for index, candidate := range candidates { + if duplicateTieBreakComparable(hints, scoredCandidates[0].candidate, candidate) { + indexes = append(indexes, index) + } + } + return indexes +} + +func (s *MetadataService) detailScoreForCandidate( + ctx context.Context, + req ProcessRequest, + itemChain []Provider, + candidate MatchCandidate, +) int { + accumulator := &MetadataResult{ + ProviderIDs: copyMap(candidate.ProviderIDs), + } + for _, provider := range itemChain { + metadataProvider, ok := provider.(MetadataProvider) + if !ok { + continue + } + result, err := metadataProvider.GetMetadata(ctx, MetadataRequest{ + ProviderIDs: copyMap(accumulator.ProviderIDs), + ContentType: candidate.ContentType, + Language: req.Language, + FilePath: req.Hints.FilePath, + RepresentativeFilePath: req.Hints.RepresentativeFilePath, + ObservedRootPath: req.Hints.ObservedRootPath, + AllGroupFilePaths: append([]string(nil), req.Hints.AllGroupFilePaths...), + PrimarySidecarSearchPaths: append([]string(nil), req.Hints.PrimarySidecarSearchPaths...), + GroupTitle: req.Hints.Title, + GroupYear: req.Hints.Year, + }) + if err != nil || result == nil || !result.HasMetadata { + continue + } + mergeProviderIDs(accumulator, result) + mergeMetadataResult(accumulator, result) + } + return metadataCompletenessScore(accumulator) +} +``` + +- [ ] **Step 3: Add `sort` import if needed** + +If `internal/metadata/service.go` does not already import `sort`, add it to the existing import block: + +```go +import ( + "sort" +) +``` + +Do not create a second import block. + +- [ ] **Step 4: Run compile-focused metadata tests** + +Run: + +```bash +go test ./internal/metadata -run 'TestSelectInitialMatchCandidate|TestMetadataCompletenessScore' -count=1 +``` + +Expected: pass. + +- [ ] **Step 5: Run package tests** + +Run: + +```bash +go test ./internal/metadata -count=1 +``` + +Expected: pass. + +- [ ] **Step 6: Commit service enrichment** + +```bash +git add internal/metadata/service.go internal/metadata/match_candidates.go internal/metadata/match_candidates_test.go +git commit -m "fix(metadata): enrich duplicate candidates before auto match" +``` + +--- + +### Task 5: Add Service-Level Regression Test + +**Files:** +- Modify: `internal/metadata/service_test.go` if it exists +- Otherwise modify the existing metadata service test file that already defines fake metadata providers + +- [ ] **Step 1: Locate existing service fake providers** + +Run: + +```bash +rg -n "type .*Provider|GetMetadata\\(|Search\\(" internal/metadata/*test.go +``` + +Expected: output includes existing fake provider definitions. Use the file that already tests `MetadataService.Process`. + +- [ ] **Step 2: Add a fake provider if the selected test file does not already have one** + +Add this fake to the selected test file: + +```go +type duplicateSearchAndMetadataProvider struct { + searchResults []SearchResult + metadataByID map[string]*MetadataResult +} + +func (p *duplicateSearchAndMetadataProvider) Slug() string { return "tmdb" } + +func (p *duplicateSearchAndMetadataProvider) Name() string { return "TMDB" } + +func (p *duplicateSearchAndMetadataProvider) ForTypes() []string { + return []string{"movie"} +} + +func (p *duplicateSearchAndMetadataProvider) Search(context.Context, SearchQuery) ([]SearchResult, error) { + return append([]SearchResult(nil), p.searchResults...), nil +} + +func (p *duplicateSearchAndMetadataProvider) GetMetadata(_ context.Context, req MetadataRequest) (*MetadataResult, error) { + tmdbID := req.ProviderIDs["tmdb"] + if result, ok := p.metadataByID[tmdbID]; ok { + clone := *result + clone.ProviderIDs = copyMap(result.ProviderIDs) + clone.People = append([]models.ItemPerson(nil), result.People...) + return &clone, nil + } + return nil, ErrMetadataNotFound +} +``` + +- [ ] **Step 3: Add regression test for UFC 4 duplicate selection** + +Add this test to the selected file, adapting only the existing service-construction helper name if the file already has one: + +```go +func TestProcessInitialMatchSelectsRicherDuplicateTMDBCandidate(t *testing.T) { + ctx := context.Background() + provider := &duplicateSearchAndMetadataProvider{ + searchResults: []SearchResult{ + { + Name: "UFC 4: Revenge of the Warriors", + Year: 1994, + Provider: "tmdb", + ProviderIDs: map[string]string{"tmdb": "1558410"}, + ImageURL: "tmdb://poster/1558410.jpg", + Overview: "UFC 4 used an eight-man tournament format.", + }, + { + Name: "UFC 4: Revenge of the Warriors", + Year: 1994, + Provider: "tmdb", + ProviderIDs: map[string]string{"tmdb": "17508"}, + ImageURL: "tmdb://poster/17508.jpg", + Overview: "UFC 4 was a mixed martial arts event.", + }, + }, + metadataByID: map[string]*MetadataResult{ + "1558410": { + HasMetadata: true, + ProviderIDs: map[string]string{"tmdb": "1558410"}, + Title: "UFC 4: Revenge of the Warriors", + Overview: "UFC 4 used an eight-man tournament format.", + Year: 1994, + Runtime: 90, + PosterPath: "tmdb://poster/1558410.jpg", + }, + "17508": { + HasMetadata: true, + ProviderIDs: map[string]string{"tmdb": "17508", "imdb": "tt0487980"}, + Title: "UFC 4: Revenge of the Warriors", + Overview: "UFC 4 was a mixed martial arts event.", + Year: 1994, + Runtime: 99, + PosterPath: "tmdb://poster/17508.jpg", + BackdropPath: "tmdb://backdrop/17508.jpg", + Homepage: "http://www.ufc.com/index.cfm?fa=eventdetail.fightCard&eid=5", + People: []models.ItemPerson{ + {Name: "Royce Gracie", Role: "Self", Type: "actor", OrderIndex: 0}, + {Name: "Dan Severn", Role: "Self", Type: "actor", OrderIndex: 1}, + {Name: "Keith Hackney", Role: "Self", Type: "actor", OrderIndex: 2}, + }, + }, + }, + } + + service := newTestMetadataService(t, []Provider{provider}) + result, err := service.Process(ctx, ProcessRequest{ + ContentID: "local-ufc-4", + FolderID: "7", + Mode: ModeInitialMatch, + Hints: &MatchHints{ + ContentID: "local-ufc-4", + Title: "UFC 4 Revenge of the Warriors", + Year: 1994, + Type: "movie", + FilePath: "/sports/movies/UFC/UFC 4 Revenge of the Warriors (1994)/UFC 4 Revenge of the Warriors (1994) SDTV.avi", + }, + }) + if err != nil { + t.Fatalf("Process returned error: %v", err) + } + if result == nil || !result.Updated { + t.Fatalf("Process result = %#v, want updated result", result) + } + + item := mustGetTestMediaItem(t, service, "local-ufc-4") + if item.TmdbID != "17508" { + t.Fatalf("item.TmdbID = %q, want 17508", item.TmdbID) + } + if item.ImdbID != "tt0487980" { + t.Fatalf("item.ImdbID = %q, want tt0487980", item.ImdbID) + } +} +``` + +If the repository uses differently named helpers, keep the same assertions and wire the provider into the existing helper. The test must assert the persisted item has TMDB `17508` and IMDb `tt0487980`. + +- [ ] **Step 4: Run the new service test and verify it fails before helper wiring is complete** + +Run: + +```bash +go test ./internal/metadata -run 'TestProcessInitialMatchSelectsRicherDuplicateTMDBCandidate' -count=1 +``` + +Expected: fail if helper names are not wired yet, or pass if the selected test harness already supports fake chains. + +- [ ] **Step 5: Wire the test to existing metadata service test helpers** + +Use the selected file’s existing constructors and repositories. The final test must use a real `MetadataService.Process` call, not a direct call to `selectInitialMatchCandidate`. + +- [ ] **Step 6: Run the service regression test** + +Run: + +```bash +go test ./internal/metadata -run 'TestProcessInitialMatchSelectsRicherDuplicateTMDBCandidate' -count=1 +``` + +Expected: pass. + +- [ ] **Step 7: Commit regression coverage** + +```bash +git add internal/metadata/*test.go +git commit -m "test(metadata): verify rich TMDB duplicate auto match" +``` + +--- + +### Task 6: Verify on the Dev Server + +**Files:** +- No code files + +- [ ] **Step 1: Run targeted local verification** + +Run: + +```bash +go test ./internal/metadata -run 'TestSelectInitialMatchCandidate|TestSelectRefreshMatchCandidate|TestMetadataCompletenessScore|TestProcessInitialMatchSelectsRicherDuplicateTMDBCandidate' -count=1 +``` + +Expected: pass. + +- [ ] **Step 2: Run broader affected package verification** + +Run: + +```bash +go test ./internal/metadata ./internal/scanner ./internal/libraryingest ./internal/taskmanager -count=1 +``` + +Expected: pass. + +- [ ] **Step 3: Deploy to dev** + +Run: + +```bash +make dev-deploy +``` + +Expected: build succeeds and Docker Compose restarts the dev server. + +- [ ] **Step 4: Confirm dev readiness** + +Run: + +```bash +ssh root@100.86.116.20 'curl -s http://localhost:8090/api/v1/ready' +``` + +Expected: + +```json +{"status":"ok"} +``` + +- [ ] **Step 5: Requeue the UFC 4 movie row** + +Run: + +```bash +ssh root@100.86.116.20 "docker exec silo-postgres-1 psql -U continuum -d continuum -P pager=off -c \"UPDATE movie_match_queue SET available_at = now() - interval '1 hour', last_attempted_at = NULL, updated_at = now() WHERE media_file_id = 2425791;\"" +``` + +Expected: + +```text +UPDATE 1 +``` + +- [ ] **Step 6: Trigger or wait for metadata matching** + +Run: + +```bash +ssh root@100.86.116.20 "docker exec silo-postgres-1 psql -U continuum -d continuum -P pager=off -c \"SELECT media_file_id, available_at, last_attempted_at, attempt_count, last_error FROM movie_match_queue WHERE media_file_id = 2425791;\"" +``` + +Expected after the worker claims the row: `last_attempted_at` is non-null and newer than the requeue time. + +- [ ] **Step 7: Verify the item matched to TMDB 17508** + +Run: + +```bash +ssh root@100.86.116.20 "docker exec silo-postgres-1 psql -U continuum -d continuum -P pager=off -c \"SELECT mf.id AS file_id, mi.content_id, mi.title, mi.year, mi.status, mi.tmdb_id, mi.imdb_id FROM media_files mf JOIN media_items mi ON mi.content_id = mf.content_id WHERE mf.id = 2425791;\"" +``` + +Expected row: + +```text + file_id | content_id | title | year | status | tmdb_id | imdb_id +---------+--------------------+-------------------------------+------+---------+---------+----------- + 2425791 | 126715023410790404 | UFC 4: Revenge of the Warriors | 1994 | matched | 17508 | tt0487980 +``` + +- [ ] **Step 8: Commit any deployment-only notes are not needed** + +No commit for dev verification output. Keep the repository clean except for code/test changes. + +--- + +## Self-Review + +Spec coverage: +- Auto-match still refuses uncertain duplicate ties: Task 2. +- Correct TMDB duplicate can be selected when metadata richness is clearly better: Tasks 2, 3, 4, 5. +- No hard dependency on TMDB-only client code: Task 4 uses `MetadataProvider`. +- Runtime is weak and does not override richer metadata: Task 3 weights runtime at `3`, external IDs and rich fields higher. +- Manual search remains unchanged: no frontend/API candidate response change is planned. + +Placeholder scan: +- No `TBD`, `TODO`, `implement later`, or "write tests for the above" placeholders remain. +- The one service-test helper adaptation step is constrained to existing test harness names and includes exact required assertions. + +Type consistency: +- `MatchCandidate.DetailScore` is defined before selector tests use it. +- `scoredMatchCandidate` is used by both `selectInitialMatchCandidate` and service enrichment. +- `metadataCompletenessScore` accepts `*MetadataResult`, matching provider `GetMetadata` results. diff --git a/docs/superpowers/specs/2026-05-24-jellyfin-autoscan-scan-compat-design.md b/docs/superpowers/specs/2026-05-24-jellyfin-autoscan-scan-compat-design.md new file mode 100644 index 00000000..35117085 --- /dev/null +++ b/docs/superpowers/specs/2026-05-24-jellyfin-autoscan-scan-compat-design.md @@ -0,0 +1,166 @@ +# Jellyfin Autoscan Scan Compatibility Design + +## Goal + +Make Silo work with Autoscan's stock Jellyfin target. Autoscan should be able to +point at Silo's Jellyfin compatibility URL, use a Silo admin API key as the +Jellyfin token, discover Silo library roots, and notify Silo about changed media +paths without a custom script. + +This design is intentionally scoped to Jellyfin compatibility only. Emby routes +and aliases are out of scope. + +## Current State + +Silo already has a native admin scan API at `POST /api/v1/scan`. It accepts +either `library_id`, `path`, or both, resolves the target to a full-library, +subtree, or single-file scan, and dispatches through the existing scan queue or +scanner path. + +Silo's Jellyfin compatibility server currently supports enough read/playback +routes for Jellyfin clients, including `GET /System/Info` and +`GET /Library/VirtualFolders`, but it does not expose Jellyfin's scan notification +endpoint. `GET /Library/VirtualFolders` also returns empty `Locations`, which +prevents Autoscan from matching incoming paths to Jellyfin libraries. + +Autoscan's Jellyfin target uses this flow: + +1. `GET /System/Info` with `X-Emby-Token`. +2. `GET /Library/VirtualFolders` with `X-Emby-Token`. +3. `POST /Library/Media/Updated` with `X-Emby-Token` and a body shaped like: + + ```json + { + "Updates": [ + { + "path": "/media/tv/Show/Season 01/Episode.mkv", + "updateType": "Modified" + } + ] + } + ``` + +## Compatibility Surface + +Add a small Jellyfin scan compatibility adapter under `internal/jellycompat`. +The adapter should own Jellyfin scan/discovery semantics and translate them into +Silo's existing scan behavior. + +The first supported route set is: + +- `GET /System/Info`: already present, but should accept Silo admin API keys on + the Autoscan path. +- `GET /Library/VirtualFolders`: return enabled Silo libraries with real + configured root paths in `Locations`. +- `POST /Library/Media/Updated`: accept Autoscan update payloads and enqueue + equivalent Silo scans. + +Do not add Emby-specific routes such as `/emby/Library/SelectableMediaFolders` +or `/emby/Library/Media/Updated` in this pass. + +## Authentication + +For the Jellyfin scan/discovery routes needed by Autoscan, allow a Silo admin API +key (`sa_...`) in the token locations Autoscan uses: + +- `X-Emby-Token` +- `X-Mediabrowser-Token` +- `Authorization: Bearer` +- `api_key` query parameter + +The API key must resolve to an enabled Silo admin user. Non-admin API keys must +receive a non-2xx authorization error. Existing Jellyfin compatibility session +tokens should continue to work for normal Jellyfin client routes; this change +should not broadly weaken playback or browse authorization. + +## Library Discovery + +`GET /Library/VirtualFolders` should include `Locations` using the exact +server-side paths configured on each enabled Silo library. Autoscan appends a +trailing slash internally and compares incoming paths against these roots, so the +paths must be real filesystem paths as Silo sees them. + +Disabled libraries should be omitted from the Autoscan discovery response because +they are not valid scan targets. + +## Scan Notification Behavior + +`POST /Library/Media/Updated` should parse every `Updates[]` entry with a +non-empty `path`. The first pass ignores `updateType`; Autoscan sends +`Modified`, and Silo's existing path resolver determines the correct scan mode. + +Each update path should use the same effective target resolution as +`POST /api/v1/scan`: + +- A path equal to a configured library root becomes a full-library scan. +- A directory under a configured root becomes a subtree scan. +- A supported media file under a configured root becomes a file scan. +- Paths outside all libraries, missing paths, permission failures, special files, + disabled libraries, and unsupported file extensions are rejected. + +For requests containing multiple updates, resolution should be all-or-fail: +validate every update first, enqueue nothing if any update is invalid, and return +a non-2xx error. This avoids Autoscan seeing success while Silo silently drops +part of the request. + +When all updates are valid, enqueue each resolved scan independently and let the +existing scan queue deduplicate or serialize overlapping work. The compatibility +adapter should not implement a separate deduplication policy. + +The successful response can be `204 No Content`; Autoscan only requires a 2xx. + +## Component Boundaries + +Keep the compatibility layer small and explicit: + +- Add a Jellyfin scan handler in `internal/jellycompat` for + `Library/Media/Updated` and Autoscan-facing `VirtualFolders`. +- Share scan target resolution with the native scan API by extracting the + resolver/enqueue logic behind a small interface or helper. Avoid duplicating + path classification rules in two packages. +- Reuse the existing API key repository and user lookup logic for admin API key + validation rather than creating a Jellyfin-specific API key store. +- Continue routing normal playback, browse, and user-data Jellyfin endpoints + through the existing compat session authenticator. + +## Error Handling + +Return non-2xx responses for invalid scan notifications so Autoscan can treat the +target as failed: + +- `401 Unauthorized` for missing or invalid tokens. +- `403 Forbidden` for valid non-admin keys. +- `400 Bad Request` for malformed JSON, empty update lists, empty paths, paths + outside libraries, missing paths, unsupported files, and other validation + failures. +- `409 Conflict` for paths that map only to a disabled library. +- `503 Service Unavailable` if the scanner or scan queue is unavailable. +- `500 Internal Server Error` for unexpected repository or enqueue failures. + +The response body may use Silo's existing JSON error shape where practical. + +## Testing + +Add focused backend tests for this compatibility surface: + +- Admin API key auth is accepted by Autoscan routes. +- Non-admin or invalid keys are rejected. +- `GET /Library/VirtualFolders` includes enabled library `Locations`. +- `POST /Library/Media/Updated` maps a valid file or directory path into an + enqueued Silo scan. +- Multi-update requests are all-or-fail and do not enqueue partial scans when + one path is invalid. + +No frontend tests are needed. + +## Documentation + +Update `docs/scan-api.md` to explain that Autoscan can use its stock Jellyfin +target: + +- URL: Silo's Jellyfin compatibility URL, usually `http://host:8096`. +- Token: a Silo admin API key beginning with `sa_`. +- Paths: server-side paths as seen by Silo. + +Keep the custom script/webhook example as an alternative for users who do not +want to expose the Jellyfin compatibility endpoint. diff --git a/docs/superpowers/specs/2026-05-25-search-request-section-design.md b/docs/superpowers/specs/2026-05-25-search-request-section-design.md new file mode 100644 index 00000000..bc15b977 --- /dev/null +++ b/docs/superpowers/specs/2026-05-25-search-request-section-design.md @@ -0,0 +1,105 @@ +# Search Request Section Design + +## Goal + +Surface TMDB-backed "requestable" results inside the main catalog search so users can discover and request items that aren't in their library without leaving the search flow. The library remains the primary surface; requestable results are an additive, clearly delimited section that never blocks or displaces library results. + +## Behavior + +### Layout (both surfaces) + +- The library section renders first using existing FTS results. No changes to library ranking, pagination, or row layout. +- A "Request to Add" section renders below the library results when: + - admin `RequestsEnabled = true`, AND + - the viewer has a profile, AND + - the TMDB query returns at least one result that is not already in the library. +- The Cmd+K search dialog (`GlobalSearch`) shows up to 4 TMDB rows beneath a single "Not in your library?" CTA strip. +- The full search results page (`Catalog`) shows a section divider, a section header, then a grid of up to 20 TMDB cards on initial render. +- Clicking any TMDB row or card navigates to the existing `/requests/{media_type}/{tmdb_id}` detail page. The detail page is responsible for the actual request action and confirmation. + +### Section header copy + +- When library has ≥1 hit: header reads "Request to Add". +- When library has 0 hits and TMDB has ≥1 hit: header is replaced by a soft framing — "Not in your library, but you can request" — and there is no separate empty state for library. +- When both sources return 0 results: the existing "No matches" / "No items found" empty state is unchanged; no requestable section renders. + +### Quota / blocked viewers + +Discovery eligibility (whether the TMDB query fires) is separate from submission eligibility (whether the row's request CTA is active): + +- **Discovery eligibility** is gated only by global/identity preconditions: admin `RequestsEnabled = true`, the viewer is authenticated, and has a profile. If any of these is false, the TMDB query does not fire and the section is not rendered. +- **Submission eligibility** is per-viewer policy: quota-exhausted, individually blocked (`UserLimit.LimitMode = "blocked"`), or otherwise restricted. When discovery is allowed but submission is not, the section still renders, each row's request affordance is disabled, and a tooltip surfaces the reason. Rows remain clickable and still navigate to the detail page, which is responsible for displaying the full policy state. + +This keeps search-side UX consistent with what the detail page would show for the same viewer. + +### Performance + +- Library results never wait on TMDB. The two queries fire concurrently from the client; the library section paints as soon as FTS returns. +- TMDB query is debounced at 400ms; library query stays at the current 200ms. +- TMDB query is cancelled in-flight when the query string changes. This requires extending `useRequestSearch` to accept and forward `{ signal }` to `api` (it does not today); see Architecture. +- TMDB error or timeout silently omits the section; no error banner. +- React-query `staleTime`: 5 minutes for TMDB results (reduces external calls and respects TMDB rate limits), 60 seconds for library results (matches the existing `GlobalSearch` preview). The 5-minute window is only safe because the cache key includes viewer identity (see Architecture); cross-viewer reuse is impossible. + +## Architecture + +- No backend changes to existing endpoints. The frontend coordinates two parallel queries. +- Library on the results page: existing `useCatalogWindow` against `/api/v1/catalog?source=query`. +- Library in the Cmd+K dialog: existing `previewQuery` pattern using `fetchCatalogPage` against the same endpoint. +- TMDB: existing `useRequestSearch` hook against `/api/v1/requests/search`, used by both surfaces — see required extensions below. +- Deduplication is handled server-side by the existing `enrichPage()` → `presence.Lookup()` flow on `/requests/search`. Client filters TMDB results where `availability == "available"` so they don't shadow library rows. + +### Gating hook (`useCanRequest`) + +The new hook splits its return into two independent signals: + +- `discoveryEnabled: boolean` — true when admin `RequestsEnabled = true` AND the viewer is authenticated with a profile. This is the only signal that controls whether the TMDB query fires. +- `submitDisabledReason: string | null` — null when the viewer can submit; otherwise one of `"blocked"`, `"quota_exhausted"`, or a future reason key. Passed through `RequestToAddSection` to per-row UI to disable the request CTA and populate its tooltip. + +Per-viewer policy state (`EffectivePolicy.LimitMode`, quota counters) feeds `submitDisabledReason` and is never used to suppress the query. + +### `useRequestSearch` extensions + +The existing hook is reused but must be extended before it can back this feature safely: + +- **Pass through `{ signal }`**: the query function currently does not accept the react-query `signal`. Update it to accept the signal and forward it to `api` so in-flight TMDB requests are cancelled on query change, unmount, or viewer change. +- **Key by viewer identity**: extend `requestKeys.search(...)` to include the active `profile_id` (and `user_id` if profile alone is insufficient to identify the policy holder). This prevents cached results from being served across viewer changes and makes the 5-minute `staleTime` safe. +- **Invalidate on policy or identity change**: invalidate `requestKeys.search()` queries when any of the following occurs in the SPA: login/logout, profile switch, admin `RequestsEnabled` toggle, `UserLimit` mutation affecting the current viewer, or quota reset/refresh. The invalidation hooks live alongside the existing auth/profile/settings stores. + +## Components + +- `web/src/hooks/useCanRequest.ts` (new): exposes `{ discoveryEnabled, submitDisabledReason }` derived from settings + viewer identity + policy as described in Architecture. +- `web/src/hooks/queries/useRequests.ts` (modified): extend `useRequestSearch` and `requestKeys.search(...)` to accept/forward `{ signal }`, include viewer identity in the query key, and expose invalidation helpers used by the auth/profile/settings stores. +- `web/src/components/RequestToAddSection.tsx` (new): renders the section in two variants: + - `variant="dialog"` — compact row layout for `GlobalSearch`. + - `variant="grid"` — poster grid using existing `RequestPosterCard` for `Catalog`. + Accepts `submitDisabledReason` and propagates it to per-row CTAs. +- `web/src/components/GlobalSearch.tsx` (modified): wires the second query, passes results into `RequestToAddSection` with `variant="dialog"`. +- `web/src/pages/Catalog.tsx` (modified): renders `RequestToAddSection` with `variant="grid"` below the existing `ItemGrid` when the source is `query`. + +## Edge cases + +- TMDB returns only items already available in the library: section is omitted (after client filter). +- Library has hits but TMDB is still loading: library renders immediately; section shows a compact skeleton in its slot, then either renders or vanishes. +- Library has 0 hits and TMDB is still pending: the page suppresses the "No matches" empty state and shows a single loading indicator until TMDB resolves. Only after TMDB returns 0 (or errors) does the empty state render. +- TMDB query never fires (discovery gated off): library follows its existing behavior including the standard empty state. +- Viewer logs out, switches profile, or admin disables `RequestsEnabled` mid-query: `useCanRequest()` re-evaluates and `discoveryEnabled` flips to false; the in-flight TMDB request is cancelled via its forwarded `signal`, and cached entries under the previous viewer identity are invalidated so they cannot be re-served. +- Admin updates `UserLimit` for the current viewer while results are cached: the settings/limit mutation triggers a `requestKeys.search()` invalidation; the next paint re-fetches with the new `submitDisabledReason`. +- Source is not `query` (e.g., `favorites`, `watchlist`, `history`, `section`): section never renders. + +## Out of scope + +- Backend changes to `/api/v1/catalog` or any merged endpoint. +- Inline request submission from search results (the detail page continues to own request creation). +- Surfacing requestable results in any non-search context (home, library browse, etc.). +- Person / cast results from TMDB. Only movie and series results are shown. + +## Verification + +Commands assume the repository root is the cwd. + +- `cd web && pnpm run lint` +- `cd web && pnpm run format:check` +- Frontend component tests for `GlobalSearch`, `Catalog`, and `RequestToAddSection` covering: library-only results, library + TMDB, TMDB-only (library empty), both-empty, TMDB error, blocked viewer (section renders, CTAs disabled), quota-exhausted viewer (section renders, CTAs disabled), requests-globally-off (no TMDB query fired, no section). +- Hook tests for `useCanRequest` across the matrix of `RequestsEnabled`, auth state, profile presence, and policy states, asserting that `discoveryEnabled` and `submitDisabledReason` are independent. +- Hook/integration tests for the extended `useRequestSearch`: confirm `signal` forwarding cancels in-flight requests on query change, confirm cache entries are not shared across `profile_id` keys, and confirm the relevant store mutations invalidate `requestKeys.search()`. +- Manual smoke in the dev frontend: confirm library results are not delayed when TMDB is slow or errors; confirm the dialog and full-page surfaces both show the section under matching conditions. diff --git a/docs/superpowers/specs/2026-05-26-page-back-component-design.md b/docs/superpowers/specs/2026-05-26-page-back-component-design.md new file mode 100644 index 00000000..a4a1614b --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-page-back-component-design.md @@ -0,0 +1,110 @@ +# PageBack Component Design + +## Goal + +Replace the inconsistent collection of inline back affordances across user-facing pages with a single shared `PageBack` component, placed in the top-left of every non-root page at a stable pixel offset that does not drift with title length, hero content, or page layout. + +Today, back navigation is implemented eight different ways across the app (`DetailBreadcrumb` chevron inside the hero, outline ` + ); +} diff --git a/web/src/components/RequestPosterCard.test.tsx b/web/src/components/RequestPosterCard.test.tsx new file mode 100644 index 00000000..904c9bcd --- /dev/null +++ b/web/src/components/RequestPosterCard.test.tsx @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router"; +import RequestPosterCard from "./RequestPosterCard"; +import type { RequestMediaResult } from "@/api/types"; + +const requestable: RequestMediaResult = { + media_type: "movie", + tmdb_id: 42, + title: "Test Movie", + availability: "missing", + request: { requestable: true }, +}; + +describe("RequestPosterCard (discover variant)", () => { + it("renders the hover Request button when onRequest is provided", () => { + const markup = renderToStaticMarkup( + + {}} + /> + , + ); + // Must render an actual + + + + ); +} diff --git a/web/src/components/admin/subtitles/AdminSubtitlesFilters.tsx b/web/src/components/admin/subtitles/AdminSubtitlesFilters.tsx new file mode 100644 index 00000000..ea57242c --- /dev/null +++ b/web/src/components/admin/subtitles/AdminSubtitlesFilters.tsx @@ -0,0 +1,118 @@ +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import { LANGUAGES } from "@/player/utils/languageNames"; +import { SUBTITLE_PROVIDER_OPTIONS } from "./subtitleAdminStyles"; + +const ALL = "all"; + +interface AdminSubtitlesFiltersProps { + provider: string; + language: string; + userId: string; + search: string; + users: Array<{ id: number; username: string }>; + onProviderChange: (value: string) => void; + onLanguageChange: (value: string) => void; + onUserChange: (value: string) => void; + onSearchChange: (value: string) => void; + onReset: () => void; +} + +export default function AdminSubtitlesFilters({ + provider, + language, + userId, + search, + users, + onProviderChange, + onLanguageChange, + onUserChange, + onSearchChange, + onReset, +}: AdminSubtitlesFiltersProps) { + return ( +
+
+
+ onSearchChange(event.target.value)} + placeholder="Search release name…" + className="font-mono text-xs sm:max-w-sm" + aria-label="Search subtitle release name" + /> +
+ +
+ {SUBTITLE_PROVIDER_OPTIONS.map((option) => { + const active = provider === option.value; + return ( + + ); + })} +
+ +
+ + + + + +
+
+
+ ); +} + +export { ALL as FILTER_ALL }; diff --git a/web/src/components/admin/subtitles/AdminSubtitlesTable.tsx b/web/src/components/admin/subtitles/AdminSubtitlesTable.tsx new file mode 100644 index 00000000..bdedf8ee --- /dev/null +++ b/web/src/components/admin/subtitles/AdminSubtitlesTable.tsx @@ -0,0 +1,272 @@ +import { useState } from "react"; +import { Link } from "react-router"; +import type { AdminDownloadedSubtitle } from "@/api/types"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { downloadAdminSubtitle } from "@/hooks/queries/admin/subtitles"; +import { getLanguageName } from "@/player/utils/languageNames"; +import { cn } from "@/lib/utils"; +import { Download, Ear, Loader2, Pencil, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import AdminSubtitleEditSheet from "./AdminSubtitleEditSheet"; +import { + basenameFromPath, + formatChipClass, + languageChipClass, + providerBadgeClass, + providerLabel, + staggerRowClass, +} from "./subtitleAdminStyles"; + +interface AdminSubtitlesTableProps { + subtitles: AdminDownloadedSubtitle[]; + hasActiveFilters: boolean; + onResetFilters: () => void; + onDelete: (subtitle: AdminDownloadedSubtitle) => void; + isDeleting: boolean; +} + +function formatRelative(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + const deltaMs = Date.now() - date.getTime(); + const minutes = Math.floor(deltaMs / 60000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + return date.toLocaleDateString(); +} + +export default function AdminSubtitlesTable({ + subtitles, + hasActiveFilters, + onResetFilters, + onDelete, + isDeleting, +}: AdminSubtitlesTableProps) { + const [editTarget, setEditTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [downloadingId, setDownloadingId] = useState(null); + + async function handleDownload(subtitle: AdminDownloadedSubtitle) { + setDownloadingId(subtitle.id); + try { + await downloadAdminSubtitle(subtitle); + toast.success("Subtitle downloaded"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to download subtitle"); + } finally { + setDownloadingId(null); + } + } + + if (subtitles.length === 0) { + return ( +
+
+ + + +
+

+ {hasActiveFilters ? "No subtitles match these filters" : "No stored subtitles yet"} +

+

+ {hasActiveFilters + ? "Try widening the provider, language, or uploader filters to see more results." + : "User uploads and provider downloads will appear here once subtitles are stored in S3."} +

+ {hasActiveFilters && ( + + )} +
+ ); + } + + return ( + <> +
+ + + + Media + File + Language + Provider + Release + Format + HI + Uploader + Added + Actions + + + + {subtitles.map((subtitle, index) => ( + + +
+ {subtitle.media_content_id ? ( + + {subtitle.media_title || subtitle.media_content_id} + + ) : ( +
+ {subtitle.media_title || "Unknown media"} +
+ )} + {subtitle.media_type === "episode" && ( + + Episode + + )} +
+
+ + {basenameFromPath(subtitle.file_path)} + + + + + {subtitle.language} + + + {getLanguageName(subtitle.language)} + + + + + + {providerLabel(subtitle.provider)} + + + + {subtitle.release_name || "—"} + + + + .{subtitle.format} + + + + {subtitle.hearing_impaired ? ( + + + ) : null} + + {subtitle.uploader_username || "—"} + + {formatRelative(subtitle.created_at)} + + +
+ + + +
+
+
+ ))} +
+
+
+ + { + if (!open) setEditTarget(null); + }} + /> + + { + if (!open) setDeleteTarget(null); + }} + title="Delete subtitle?" + description={ + deleteTarget + ? `Remove ${providerLabel(deleteTarget.provider)} ${deleteTarget.language.toUpperCase()} subtitles for "${deleteTarget.media_title || "this media"}"? This deletes the stored file from S3.` + : "" + } + confirmLabel="Delete" + variant="destructive" + isPending={isDeleting} + onConfirm={() => { + if (deleteTarget) { + onDelete(deleteTarget); + setDeleteTarget(null); + } + }} + /> + + ); +} diff --git a/web/src/components/admin/subtitles/subtitleAdminStyles.ts b/web/src/components/admin/subtitles/subtitleAdminStyles.ts new file mode 100644 index 00000000..4f5b4411 --- /dev/null +++ b/web/src/components/admin/subtitles/subtitleAdminStyles.ts @@ -0,0 +1,57 @@ +import { cn } from "@/lib/utils"; + +export const SUBTITLE_PROVIDER_OPTIONS = [ + { value: "all", label: "All" }, + { value: "upload", label: "Upload" }, + { value: "opensubtitles", label: "OpenSubtitles" }, + { value: "subdl", label: "SubDL" }, + { value: "subsource", label: "SubSource" }, +] as const; + +export function providerBadgeClass(provider: string): string { + switch (provider) { + case "upload": + return "border-amber-500/35 bg-amber-500/12 text-amber-100"; + case "opensubtitles": + return "border-sky-500/30 bg-sky-500/10 text-sky-100"; + case "subdl": + return "border-emerald-500/30 bg-emerald-500/10 text-emerald-100"; + case "subsource": + return "border-violet-500/30 bg-violet-500/10 text-violet-100"; + default: + return "border-border/70 bg-muted/40 text-muted-foreground"; + } +} + +export function providerLabel(provider: string): string { + return SUBTITLE_PROVIDER_OPTIONS.find((option) => option.value === provider)?.label ?? provider; +} + +export function languageChipClass(): string { + return "border-primary/25 bg-primary/10 text-foreground"; +} + +export function formatChipClass(): string { + return "border-border/60 bg-muted/30 font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground"; +} + +export function staggerRowClass(index: number): string { + const capped = Math.min(index, 8); + return cn("motion-safe:animate-in motion-safe:fade-in motion-safe:duration-300", { + "motion-safe:delay-0": capped === 0, + "motion-safe:delay-[40ms]": capped === 1, + "motion-safe:delay-[80ms]": capped === 2, + "motion-safe:delay-[120ms]": capped === 3, + "motion-safe:delay-[160ms]": capped === 4, + "motion-safe:delay-[200ms]": capped === 5, + "motion-safe:delay-[240ms]": capped === 6, + "motion-safe:delay-[280ms]": capped === 7, + "motion-safe:delay-[320ms]": capped >= 8, + }); +} + +export function basenameFromPath(filePath: string): string { + if (!filePath) return "—"; + const parts = filePath.split(/[/\\]/); + return parts[parts.length - 1] || filePath; +} diff --git a/web/src/components/subtitles/SubtitleUploadForm.tsx b/web/src/components/subtitles/SubtitleUploadForm.tsx new file mode 100644 index 00000000..543ac340 --- /dev/null +++ b/web/src/components/subtitles/SubtitleUploadForm.tsx @@ -0,0 +1,406 @@ +import { useCallback, useRef, useState } from "react"; +import { Loader2, Upload } from "lucide-react"; + +import type { SubtitleLanguageDetection } from "@/api/types"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import { LANGUAGES, getLanguageName } from "@/player/utils/languageNames"; + +const ACCEPTED_SUBTITLE_EXTENSIONS = ".srt,.vtt,.ass,.ssa,.sub"; +const ACCEPTED_SUBTITLE_EXTENSION_LIST = ["srt", "vtt", "ass", "ssa", "sub"] as const; + +export interface SubtitleUploadInput { + mediaFileId: number; + file: File; + language?: string; + languageOverride?: boolean; + hearingImpaired: boolean; +} + +interface SubtitleUploadFormProps { + mediaFileId: number; + upload: (input: SubtitleUploadInput) => Promise; + detectLanguage?: (file: File, fallbackLanguage?: string) => Promise; + onSuccess: () => void; + onError?: (message: string) => void; + variant?: "player" | "default"; + defaultLanguage?: string; +} + +function isAcceptedSubtitleFile(file: File): boolean { + const extension = file.name.split(".").pop()?.toLowerCase() ?? ""; + return ACCEPTED_SUBTITLE_EXTENSION_LIST.includes( + extension as (typeof ACCEPTED_SUBTITLE_EXTENSION_LIST)[number], + ); +} + +function detectionSourceLabel(source: SubtitleLanguageDetection["source"]): string { + switch (source) { + case "filename": + return "filename"; + case "metadata": + return "file metadata"; + case "content": + return "subtitle text"; + case "manual": + return "manual selection"; + default: + return "detection"; + } +} + +export function SubtitleUploadForm({ + mediaFileId, + upload, + detectLanguage, + onSuccess, + onError, + variant = "default", + defaultLanguage = "en", +}: SubtitleUploadFormProps) { + const fileInputRef = useRef(null); + const dragDepthRef = useRef(0); + const detectRequestRef = useRef(0); + const [language, setLanguage] = useState(defaultLanguage); + const [hearingImpaired, setHearingImpaired] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const [uploading, setUploading] = useState(false); + const [detectingLanguage, setDetectingLanguage] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const [detectionSource, setDetectionSource] = useState< + SubtitleLanguageDetection["source"] | null + >(null); + const [languageOverride, setLanguageOverride] = useState(false); + const [error, setError] = useState(null); + + const isPlayer = variant === "player"; + + const reportError = useCallback( + (message: string) => { + setError(message); + onError?.(message); + }, + [onError], + ); + + const runLanguageDetection = useCallback( + async (file: File, fallbackLanguage: string) => { + if (!detectLanguage) { + return; + } + + const requestId = ++detectRequestRef.current; + setDetectingLanguage(true); + + try { + const result = await detectLanguage(file, fallbackLanguage); + if (requestId !== detectRequestRef.current) { + return; + } + if (result.language) { + setLanguage(result.language); + setDetectionSource(result.source); + setLanguageOverride(false); + } + } catch (err) { + if (requestId !== detectRequestRef.current) { + return; + } + setDetectionSource(null); + reportError(err instanceof Error ? err.message : "Failed to detect subtitle language"); + } finally { + if (requestId === detectRequestRef.current) { + setDetectingLanguage(false); + } + } + }, + [detectLanguage, reportError], + ); + + const selectFile = useCallback( + (file: File | null | undefined) => { + if (!file) { + return; + } + if (!isAcceptedSubtitleFile(file)) { + reportError("Unsupported file type. Use SRT, VTT, ASS, SSA, or SUB."); + return; + } + setSelectedFile(file); + setError(null); + void runLanguageDetection(file, language); + }, + [language, reportError, runLanguageDetection], + ); + + const handleFileChange = (event: React.ChangeEvent) => { + selectFile(event.target.files?.[0]); + }; + + const handleBrowseClick = () => { + fileInputRef.current?.click(); + }; + + const handleDragEnter = (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + dragDepthRef.current += 1; + setIsDragging(true); + }; + + const handleDragOver = (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = "copy"; + }; + + const handleDragLeave = (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) { + setIsDragging(false); + } + }; + + const handleDrop = (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + dragDepthRef.current = 0; + setIsDragging(false); + + const file = event.dataTransfer.files[0]; + selectFile(file); + }; + + const handleLanguageChange = (value: string) => { + setLanguage(value); + setDetectionSource("manual"); + setLanguageOverride(true); + }; + + const handleUpload = async () => { + if (!selectedFile) { + reportError("Choose a subtitle file to upload"); + return; + } + + setUploading(true); + setError(null); + + try { + await upload({ + mediaFileId, + file: selectedFile, + language, + languageOverride, + hearingImpaired, + }); + setSelectedFile(null); + setDetectionSource(null); + setLanguageOverride(false); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + onSuccess(); + } catch (err) { + reportError(err instanceof Error ? err.message : "Upload failed"); + } finally { + setUploading(false); + } + }; + + return ( +
+
+

+ Upload subtitle +

+

+ Drag and drop or browse for SRT, VTT, ASS, SSA, or SUB files up to 5 MB. Language is + detected automatically when possible. +

+
+ + + +
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleBrowseClick(); + } + }} + onDragEnter={handleDragEnter} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + className={cn( + "flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-4 py-6 text-center transition-colors", + isPlayer + ? isDragging + ? "border-white/50 bg-white/10" + : "border-white/20 bg-white/5 hover:border-white/35 hover:bg-white/10" + : isDragging + ? "border-primary bg-primary/5" + : "border-border/70 bg-muted/20 hover:border-border hover:bg-muted/40", + )} + > +
+ +
+
+ {isPlayer ? ( + + ) : ( + + )} + {detectingLanguage ? ( +

+ Detecting language… +

+ ) : detectionSource && detectionSource !== "manual" ? ( +

+ Detected {getLanguageName(language)} from {detectionSourceLabel(detectionSource)} +

+ ) : null} +
+ + {isPlayer ? ( + + ) : ( +
+ + +
+ )} + + {isPlayer ? ( + + ) : ( + + )} +
+ + {selectedFile && ( +

+ Selected: {selectedFile.name} +

+ )} + + {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/web/src/hooks/queries/admin/subtitles.ts b/web/src/hooks/queries/admin/subtitles.ts index 278b2296..e95d60b8 100644 --- a/web/src/hooks/queries/admin/subtitles.ts +++ b/web/src/hooks/queries/admin/subtitles.ts @@ -1,6 +1,10 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { api } from "@/api/client"; +import { api, apiDownload } from "@/api/client"; import type { + AdminDownloadedSubtitle, + AdminDownloadedSubtitlesFilters, + AdminDownloadedSubtitlesResponse, + AdminUpdateDownloadedSubtitleRequest, SubtitleProviderConfig, SubtitleProviderUpdateRequest, SubtitleProviderTestResponse, @@ -10,6 +14,71 @@ import { toast } from "sonner"; const ADMIN_STALE_TIME = 30_000; +function buildDownloadedSubtitlesQuery(filters: AdminDownloadedSubtitlesFilters): string { + const params = new URLSearchParams(); + if (filters.provider) params.set("provider", filters.provider); + if (filters.language) params.set("language", filters.language); + if (filters.userId != null) params.set("user_id", String(filters.userId)); + if (filters.mediaFileId != null) params.set("media_file_id", String(filters.mediaFileId)); + if (filters.q) params.set("q", filters.q); + params.set("limit", String(filters.limit ?? 50)); + params.set("offset", String(filters.offset ?? 0)); + const query = params.toString(); + return query ? `/admin/subtitles?${query}` : "/admin/subtitles"; +} + +export function useAdminDownloadedSubtitles(filters: AdminDownloadedSubtitlesFilters) { + return useQuery({ + queryKey: adminKeys.downloadedSubtitles(filters), + queryFn: () => + api(buildDownloadedSubtitlesQuery(filters)).then( + (data) => data ?? { subtitles: [], total: 0, uploads: 0, provider_downloads: 0 }, + ), + staleTime: ADMIN_STALE_TIME, + }); +} + +export function useAdminUpdateDownloadedSubtitle() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, patch }: { id: number; patch: AdminUpdateDownloadedSubtitleRequest }) => + api<{ subtitle: AdminDownloadedSubtitle }>(`/admin/subtitles/${id}`, { + method: "PATCH", + body: JSON.stringify(patch), + }), + onSuccess: () => { + toast.success("Subtitle updated"); + queryClient.invalidateQueries({ queryKey: ["admin", "downloadedSubtitles"] }); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to update subtitle"); + }, + }); +} + +export function useAdminDeleteDownloadedSubtitle() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => + api(`/admin/subtitles/${id}`, { + method: "DELETE", + }), + onSuccess: () => { + toast.success("Subtitle deleted"); + queryClient.invalidateQueries({ queryKey: ["admin", "downloadedSubtitles"] }); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to delete subtitle"); + }, + }); +} + +export async function downloadAdminSubtitle(subtitle: AdminDownloadedSubtitle): Promise { + const base = subtitle.release_name?.trim() || `subtitle-${subtitle.id}`; + const filename = base.includes(".") ? base : `${base}.${subtitle.format}`; + await apiDownload(`/admin/subtitles/${subtitle.id}/download`, filename); +} + export function useSubtitleProviders() { return useQuery({ queryKey: adminKeys.subtitleProviders(), diff --git a/web/src/hooks/queries/keys.ts b/web/src/hooks/queries/keys.ts index 4e98afd1..b0e8272c 100644 --- a/web/src/hooks/queries/keys.ts +++ b/web/src/hooks/queries/keys.ts @@ -132,8 +132,8 @@ export const requestKeys = { sort: string, page: number, ) => ["requests", "discover", "browse", kind, slug, mediaType ?? "", sort, page] as const, - search: (mediaType: string, query: string, page: number) => - ["requests", "search", mediaType, query, page] as const, + search: (mediaType: string, query: string, page: number, viewerKey: string) => + ["requests", "search", viewerKey, mediaType, query, page] as const, detail: (mediaType: string, tmdbID: number) => ["requests", "detail", mediaType, tmdbID] as const, mine: (params: Record) => ["requests", "mine", params] as const, }; @@ -360,6 +360,15 @@ export const adminKeys = { operationalLogs: (params: Record) => ["admin", "logs", "app", params] as const, auditLogs: (params: Record) => ["admin", "logs", "audit", params] as const, subtitleProviders: () => ["admin", "subtitleProviders"] as const, + downloadedSubtitles: (params: { + provider?: string; + language?: string; + userId?: number; + mediaFileId?: number; + q?: string; + limit?: number; + offset?: number; + }) => ["admin", "downloadedSubtitles", params] as const, historyImportSources: () => ["admin", "historyImportSources"] as const, historyImportExternalUsers: (sourceId: number) => ["admin", "historyImportSources", sourceId, "users"] as const, diff --git a/web/src/hooks/queries/subtitles.ts b/web/src/hooks/queries/subtitles.ts index c9267598..b56f6594 100644 --- a/web/src/hooks/queries/subtitles.ts +++ b/web/src/hooks/queries/subtitles.ts @@ -5,8 +5,10 @@ import { api } from "@/api/client"; import type { DownloadedSubtitle, SubtitleDownloadRequest, + SubtitleLanguageDetection, SubtitleSearchRequest, SubtitleSearchResponse, + SubtitleUploadRequest, } from "@/api/types"; import { subtitleKeys } from "./keys"; @@ -15,6 +17,34 @@ interface DownloadSubtitleResponse { subtitle: DownloadedSubtitle; } +function buildSubtitleUploadFormData(request: SubtitleUploadRequest): FormData { + const form = new FormData(); + form.set("media_file_id", String(request.media_file_id)); + if (request.language) { + form.set("language", request.language); + } + if (request.language_override) { + form.set("language_override", "true"); + } + form.set("file", request.file); + if (request.release_name) { + form.set("release_name", request.release_name); + } + if (request.hearing_impaired) { + form.set("hearing_impaired", "true"); + } + return form; +} + +function buildSubtitleDetectFormData(file: File, language?: string): FormData { + const form = new FormData(); + form.set("file", file); + if (language) { + form.set("language", language); + } + return form; +} + export async function fetchDownloadedSubtitles( mediaFileId: number, options?: RequestInit, @@ -48,6 +78,29 @@ export async function downloadSubtitle( }); } +export async function uploadSubtitle( + request: SubtitleUploadRequest, + options?: RequestInit, +): Promise { + return api("/subtitles/upload", { + ...options, + method: "POST", + body: buildSubtitleUploadFormData(request), + }); +} + +export async function detectSubtitleLanguage( + file: File, + language?: string, + options?: RequestInit, +): Promise { + return api("/subtitles/detect-language", { + ...options, + method: "POST", + body: buildSubtitleDetectFormData(file, language), + }); +} + export function useDownloadedSubtitles(mediaFileId: number | undefined) { return useQuery({ queryKey: mediaFileId != null ? subtitleKeys.downloaded(mediaFileId) : subtitleKeys.all, @@ -72,3 +125,20 @@ export function useDownloadSubtitle() { }, }); } + +export function useUploadSubtitle() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (request: SubtitleUploadRequest) => uploadSubtitle(request), + onSuccess: async (_response, request) => { + toast.success("Subtitle uploaded"); + await queryClient.invalidateQueries({ + queryKey: subtitleKeys.downloaded(request.media_file_id), + }); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to upload subtitle"); + }, + }); +} diff --git a/web/src/hooks/queries/useRequests.test.tsx b/web/src/hooks/queries/useRequests.test.tsx new file mode 100644 index 00000000..4ea70af2 --- /dev/null +++ b/web/src/hooks/queries/useRequests.test.tsx @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { requestKeys } from "./keys"; + +const mocks = vi.hoisted(() => ({ + useQuery: vi.fn(), + useCurrentProfile: vi.fn(), + api: vi.fn(), +})); + +vi.mock("@tanstack/react-query", async () => { + const actual = + await vi.importActual("@tanstack/react-query"); + return { + ...actual, + useQuery: (...args: unknown[]) => mocks.useQuery(...args), + }; +}); + +vi.mock("@/hooks/useCurrentProfile", () => ({ + useCurrentProfile: () => mocks.useCurrentProfile(), +})); + +vi.mock("@/api/client", () => ({ + api: (...args: unknown[]) => mocks.api(...args), +})); + +import { useRequestSearch } from "./useRequests"; + +function render(node: ReactNode) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return renderToStaticMarkup({node}); +} + +function CallHook(props: { mediaType: "movie" | "series" | "all"; q: string; page?: number }) { + useRequestSearch(props.mediaType, props.q, props.page ?? 1); + return null; +} + +describe("useRequestSearch", () => { + beforeEach(() => { + mocks.useQuery.mockReset(); + mocks.useCurrentProfile.mockReset(); + mocks.api.mockReset(); + }); + + it("includes the current profile id in the query key", () => { + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "profile-1" } }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { queryKey: readonly unknown[] }; + expect(options.queryKey).toEqual(["requests", "search", "profile-1", "all", "dune", 1]); + }); + + it("uses 'anon' as the viewer key when there is no profile", () => { + mocks.useCurrentProfile.mockReturnValue({ profile: null }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { queryKey: readonly unknown[] }; + expect(options.queryKey).toEqual(["requests", "search", "anon", "movie", "dune", 1]); + }); + + it("forwards the react-query signal to api()", async () => { + mocks.api.mockResolvedValue({ page: 1, total_pages: 0, total_results: 0, results: [] }); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "profile-1" } }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { + queryFn: (ctx: { signal: AbortSignal }) => unknown; + }; + const controller = new AbortController(); + await options.queryFn({ signal: controller.signal }); + + expect(mocks.api).toHaveBeenCalledTimes(1); + const apiCall = mocks.api.mock.calls[0]!; + expect(apiCall[0]).toContain("/requests/search?"); + const init = apiCall[1] as RequestInit; + expect(init.signal).toBe(controller.signal); + }); + + it("keeps the existing Requests page staleTime by default", () => { + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p" } }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { staleTime: number }; + expect(options.staleTime).toBe(30 * 1000); + }); + + it("allows callers to opt into a longer staleTime", () => { + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p" } }); + + function CallHookWithStaleTime() { + useRequestSearch("all", "dune", 1, { staleTime: 5 * 60 * 1000 }); + return null; + } + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { staleTime: number }; + expect(options.staleTime).toBe(5 * 60 * 1000); + }); + + it("respects the enabled option override", () => { + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p" } }); + + function CallHookWithOpt({ enabled }: { enabled: boolean }) { + useRequestSearch("all", "dune", 1, { enabled }); + return null; + } + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { enabled: boolean }; + expect(options.enabled).toBe(false); + }); + + it("does not include enabled override when option omitted (defaults to true)", () => { + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p" } }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { enabled: boolean }; + expect(options.enabled).toBe(true); + }); + + it("does not require profile by default", () => { + mocks.useCurrentProfile.mockReturnValue({ profile: null }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { enabled: boolean }; + expect(options.enabled).toBe(true); + }); + + it("can require a profile before fetching", () => { + mocks.useCurrentProfile.mockReturnValue({ profile: null }); + + function CallHookRequiringProfile() { + useRequestSearch("all", "dune", 1, { requireProfile: true }); + return null; + } + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { enabled: boolean }; + expect(options.enabled).toBe(false); + }); +}); + +describe("requestKeys.all invalidation", () => { + it("invalidates entries under requestKeys.search() when invalidating requestKeys.all", async () => { + const client = new QueryClient(); + client.setQueryData(requestKeys.search("all", "dune", 1, "profile-1"), { sentinel: true }); + + expect(client.getQueryData(requestKeys.search("all", "dune", 1, "profile-1"))).toEqual({ + sentinel: true, + }); + + await client.invalidateQueries({ queryKey: requestKeys.all }); + + const state = client.getQueryState(requestKeys.search("all", "dune", 1, "profile-1")); + expect(state?.isInvalidated).toBe(true); + }); +}); + +describe("viewer-scoped cache isolation", () => { + it("does not return profile-1 results when keyed by profile-2", () => { + const client = new QueryClient(); + client.setQueryData(requestKeys.search("all", "dune", 1, "profile-1"), { + results: [{ tmdb_id: 1 }], + }); + + expect(client.getQueryData(requestKeys.search("all", "dune", 1, "profile-2"))).toBeUndefined(); + }); +}); diff --git a/web/src/hooks/queries/useRequests.ts b/web/src/hooks/queries/useRequests.ts index 1566ee08..f9423dda 100644 --- a/web/src/hooks/queries/useRequests.ts +++ b/web/src/hooks/queries/useRequests.ts @@ -1,6 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { api } from "@/api/client"; +import { useCurrentProfile } from "@/hooks/useCurrentProfile"; import type { CreateMediaRequestInput, DiscoverBrowseKind, @@ -51,6 +52,9 @@ function buildListQuery(params: RequestListParams = {}) { } function invalidateRequestSurfaces(queryClient: ReturnType) { + // requestKeys.all = ["requests"], so invalidating it cascades to nested keys, + // including requestKeys.search(...). Policy mutations rely on this to refresh + // viewer-scoped search results when request eligibility changes. queryClient.invalidateQueries({ queryKey: requestKeys.all }); queryClient.invalidateQueries({ queryKey: adminKeys.requestsRoot() }); } @@ -148,20 +152,44 @@ export function useRequestMediaDetail(mediaType: RequestMediaType, tmdbID: numbe }); } -export function useRequestSearch(mediaType: RequestSearchMediaType, query: string, page = 1) { +export interface UseRequestSearchOptions { + /** When false, suppresses the query regardless of the query string. Default: true. */ + enabled?: boolean; + /** When true, suppresses the query until the active profile is loaded. Default: false. */ + requireProfile?: boolean; + /** Cache freshness window for this search surface. Default: existing Requests page timing. */ + staleTime?: number; +} + +export function useRequestSearch( + mediaType: RequestSearchMediaType, + query: string, + page = 1, + options: UseRequestSearchOptions = {}, +) { const normalizedQuery = query.trim(); + const { profile } = useCurrentProfile(); + // Use a sentinel viewerKey when there is no profile so the cache key is stable, + // but suppress the actual fetch — see the `enabled` gate below. This prevents + // any anonymous request results from being written into a bucket that could + // later be read by a different viewer. + const viewerKey = profile?.id ?? "anon"; + const enabledOverride = options.enabled ?? true; + const requireProfile = options.requireProfile ?? false; + return useQuery({ - queryKey: requestKeys.search(mediaType, normalizedQuery, page), - queryFn: () => { + queryKey: requestKeys.search(mediaType, normalizedQuery, page, viewerKey), + queryFn: ({ signal }) => { const params = new URLSearchParams({ q: normalizedQuery, media_type: mediaType, page: String(page), }); - return api(`/requests/search?${params}`); + return api(`/requests/search?${params}`, { signal }); }, - enabled: normalizedQuery.length > 1, - staleTime: REQUESTS_STALE_TIME, + enabled: + enabledOverride && normalizedQuery.length > 1 && (!requireProfile || Boolean(profile?.id)), + staleTime: options.staleTime ?? REQUESTS_STALE_TIME, }); } diff --git a/web/src/hooks/useAuth.test.ts b/web/src/hooks/useAuth.test.ts index 7607c208..b0a04934 100644 --- a/web/src/hooks/useAuth.test.ts +++ b/web/src/hooks/useAuth.test.ts @@ -40,6 +40,7 @@ describe("initializeAuthSession", () => { username: "admin", email: "admin@example.com", role: "admin", + permissions: [], download_allowed: true, impersonation: null, }); diff --git a/web/src/hooks/useCanRequest.test.tsx b/web/src/hooks/useCanRequest.test.tsx new file mode 100644 index 00000000..d5ece562 --- /dev/null +++ b/web/src/hooks/useCanRequest.test.tsx @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const mocks = vi.hoisted(() => ({ + useRequestFeatureStatus: vi.fn(), + useCurrentProfile: vi.fn(), +})); + +vi.mock("@/hooks/queries/useRequests", () => ({ + useRequestFeatureStatus: () => mocks.useRequestFeatureStatus(), +})); + +vi.mock("@/hooks/useCurrentProfile", () => ({ + useCurrentProfile: () => mocks.useCurrentProfile(), +})); + +import { useCanRequest } from "./useCanRequest"; + +function CaptureHook({ onResult }: { onResult: (r: ReturnType) => void }) { + const result = useCanRequest(); + onResult(result); + return null; +} + +function render(child: ReactNode) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return renderToStaticMarkup({child}); +} + +describe("useCanRequest", () => { + it("returns discoveryEnabled=false when requests_enabled is false", () => { + mocks.useRequestFeatureStatus.mockReturnValue({ + data: { requests_enabled: false }, + isLoading: false, + }); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p1" } }); + + let captured: ReturnType | null = null; + render( + { + captured = r; + }} + />, + ); + + expect(captured).toEqual({ + discoveryEnabled: false, + isResolving: false, + submitDisabledReason: null, + }); + }); + + it("returns discoveryEnabled=false when there is no profile", () => { + mocks.useRequestFeatureStatus.mockReturnValue({ + data: { requests_enabled: true }, + isLoading: false, + }); + mocks.useCurrentProfile.mockReturnValue({ profile: null }); + + let captured: ReturnType | null = null; + render( + { + captured = r; + }} + />, + ); + + expect(captured).toEqual({ + discoveryEnabled: false, + isResolving: false, + submitDisabledReason: null, + }); + }); + + it("returns discoveryEnabled=true when requests are enabled and there is a profile", () => { + mocks.useRequestFeatureStatus.mockReturnValue({ + data: { requests_enabled: true }, + isLoading: false, + }); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p1" } }); + + let captured: ReturnType | null = null; + render( + { + captured = r; + }} + />, + ); + + expect(captured).toEqual({ + discoveryEnabled: true, + isResolving: false, + submitDisabledReason: null, + }); + }); + + it("returns isResolving=true and discoveryEnabled=false while the feature status is still loading", () => { + mocks.useRequestFeatureStatus.mockReturnValue({ data: undefined, isLoading: true }); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p1" } }); + + let captured: ReturnType | null = null; + render( + { + captured = r; + }} + />, + ); + + expect(captured).toEqual({ + discoveryEnabled: false, + isResolving: true, + submitDisabledReason: null, + }); + }); +}); diff --git a/web/src/hooks/useCanRequest.ts b/web/src/hooks/useCanRequest.ts new file mode 100644 index 00000000..a26e4ea0 --- /dev/null +++ b/web/src/hooks/useCanRequest.ts @@ -0,0 +1,26 @@ +import { useRequestFeatureStatus } from "@/hooks/queries/useRequests"; +import { useCurrentProfile } from "@/hooks/useCurrentProfile"; + +export interface CanRequestState { + discoveryEnabled: boolean; + /** + * True while we cannot yet decide if discovery is on — feature status + * is still loading. Consumers should suppress empty-state UI in this + * window to avoid a flash before the request section appears. + */ + isResolving: boolean; + submitDisabledReason: string | null; +} + +export function useCanRequest(): CanRequestState { + const status = useRequestFeatureStatus(); + const { profile } = useCurrentProfile(); + const discoveryEnabled = Boolean(status.data?.requests_enabled) && Boolean(profile?.id); + const isResolving = status.isLoading; + + return { + discoveryEnabled, + isResolving, + submitDisabledReason: null, + }; +} diff --git a/web/src/lib/documentTitle.ts b/web/src/lib/documentTitle.ts index 93e68a36..a4f02200 100644 --- a/web/src/lib/documentTitle.ts +++ b/web/src/lib/documentTitle.ts @@ -31,6 +31,7 @@ const ADMIN_TITLES: Record = { recommendations: "Admin Recommendations", requests: "Admin Requests", sections: "Admin Sections", + subtitles: "Admin Subtitles", settings: "Admin Settings", tasks: "Admin Tasks", users: "Admin Users", diff --git a/web/src/lib/permissions.ts b/web/src/lib/permissions.ts new file mode 100644 index 00000000..81d28771 --- /dev/null +++ b/web/src/lib/permissions.ts @@ -0,0 +1,30 @@ +import type { User } from "@/api/types"; + +export const PERMISSION_METADATA_CURATION = "metadata_curation"; + +export function hasPermission( + user: Pick | null | undefined, + permission: string, +) { + if (!user) return false; + if (user.role === "admin") return true; + return Array.isArray(user.permissions) && user.permissions.includes(permission); +} + +export function canCurateMetadata(user: Pick | null | undefined) { + return hasPermission(user, PERMISSION_METADATA_CURATION); +} + +export function hasAssignedPermission(permissions: string[] | undefined, permission: string) { + return Array.isArray(permissions) && permissions.includes(permission); +} + +export function setAssignedPermission(permissions: string[], permission: string, enabled: boolean) { + const next = new Set(permissions); + if (enabled) { + next.add(permission); + } else { + next.delete(permission); + } + return Array.from(next).sort(); +} diff --git a/web/src/pages/AdminDashboard.tsx b/web/src/pages/AdminDashboard.tsx index d10db99d..1b21c88a 100644 --- a/web/src/pages/AdminDashboard.tsx +++ b/web/src/pages/AdminDashboard.tsx @@ -40,34 +40,15 @@ import type { } from "@/api/types"; export default function AdminDashboard() { - const { data: stats, isLoading: statsLoading, refetch: refetchStats } = useAdminStats(); - const { - data: sessions = [], - isLoading: sessionsLoading, - refetch: refreshSessions, - } = useAdminSessions(); - const { data: libraries = [] } = useAdminLibraries(); - const { data: users = [] } = useAdminUsers(); + const statsQuery = useAdminStats(); + const sessionsQuery = useAdminSessions(); + const librariesQuery = useAdminLibraries(); + const usersQuery = useAdminUsers(); const scanAll = useScanAllLibraries(); - const loading = statsLoading || sessionsLoading; - - if (loading) - return ( -
-
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
-
- {Array.from({ length: 2 }).map((_, i) => ( - - ))} -
- -
- ); + const sessions = sessionsQuery.data ?? []; + const libraries = librariesQuery.data ?? []; + const users = usersQuery.data ?? []; return (
@@ -84,8 +65,8 @@ export default function AdminDashboard() { variant="outline" size="sm" onClick={() => { - refetchStats(); - void refreshSessions(); + void statsQuery.refetch(); + void sessionsQuery.refetch(); }} > @@ -99,7 +80,7 @@ export default function AdminDashboard() { scanAll.mutate(); } }} - disabled={scanAll.isPending} + disabled={scanAll.isPending || libraries.length === 0} > Scan All Libraries @@ -107,56 +88,67 @@ export default function AdminDashboard() {
- {/* Stats row */} - {stats && } + - {stats?.watch_provider_activity && ( - + {statsQuery.data?.watch_provider_activity && ( + )} - {/* Now Playing */} - {sessions.length > 0 && ( -
-
-
Now Playing
- - View all {sessions.length} streams › - -
-
- {sessions.slice(0, 4).map((session) => ( - - ))} -
- {sessions.length > 4 && ( - - +{sessions.length - 4} more active streams - - )} -
- )} + - {/* Two-column: Libraries + Users */}
- - + +
- {/* Recent Activity */} - + ); } // --- Sub-components --- -function StatsRow({ stats, sessionCount }: { stats: AdminStats; sessionCount: number }) { +function StatsRow({ + stats, + sessionCount, + isLoading, + error, +}: { + stats: AdminStats | undefined; + sessionCount: number; + isLoading: boolean; + error: unknown; +}) { + if (isLoading || !stats) { + if (error) { + return ; + } + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ); + } + const storageGB = stats.total_storage_bytes / (1024 * 1024 * 1024); const storageTB = storageGB / 1024; const storageDisplay = @@ -411,7 +403,71 @@ function StreamCard({ session }: { session: AdminSession }) { ); } -function LibrariesCard({ libraries }: { libraries: LibraryType[] }) { +function NowPlayingSection({ + sessions, + isLoading, + error, +}: { + sessions: AdminSession[]; + isLoading: boolean; + error: unknown; +}) { + if (error) return null; + + if (isLoading) { + return ( +
+
+
Now Playing
+
+
+ {Array.from({ length: 2 }).map((_, i) => ( + + ))} +
+
+ ); + } + + if (sessions.length === 0) return null; + + return ( +
+
+
Now Playing
+ + View all {sessions.length} streams › + +
+
+ {sessions.slice(0, 4).map((session) => ( + + ))} +
+ {sessions.length > 4 && ( + + +{sessions.length - 4} more active streams + + )} +
+ ); +} + +function LibrariesCard({ + libraries, + isLoading, + error, +}: { + libraries: LibraryType[]; + isLoading: boolean; + error: unknown; +}) { const scanLibrary = useScanLibrary(); return ( @@ -426,7 +482,11 @@ function LibrariesCard({ libraries }: { libraries: LibraryType[] }) { - {libraries.length === 0 ? ( + {isLoading ? ( + + ) : error ? ( + + ) : libraries.length === 0 ? (
No libraries configured.
@@ -478,7 +538,15 @@ function LibrariesCard({ libraries }: { libraries: LibraryType[] }) { ); } -function UsersCard({ users }: { users: AdminUser[] }) { +function UsersCard({ + users, + isLoading, + error, +}: { + users: AdminUser[]; + isLoading: boolean; + error: unknown; +}) { const navigate = useNavigate(); return ( @@ -493,7 +561,11 @@ function UsersCard({ users }: { users: AdminUser[] }) { - {users.length === 0 ? ( + {isLoading ? ( + + ) : error ? ( + + ) : users.length === 0 ? (
No users.
) : ( @@ -543,8 +615,16 @@ function UsersCard({ users }: { users: AdminUser[] }) { ); } -function ActivityCard({ sessions }: { sessions: AdminSession[] }) { - if (sessions.length === 0) return null; +function ActivityCard({ + sessions, + isLoading, + error, +}: { + sessions: AdminSession[]; + isLoading: boolean; + error: unknown; +}) { + if (!isLoading && !error && sessions.length === 0) return null; return ( @@ -558,43 +638,49 @@ function ActivityCard({ sessions }: { sessions: AdminSession[] }) { -
- {sessions.slice(0, 10).map((s) => { - const isEp = s.series_name && s.season_number != null && s.episode_number != null; - const title = isEp - ? s.episode_name || `S${s.season_number}E${s.episode_number}` - : s.media_title || `File #${s.media_file_id}`; - const username = s.username || `User #${s.user_id}`; - return ( -
-
- -
-
-
- {username} - {" started watching "} - - {title} - + {isLoading ? ( + + ) : error ? ( + + ) : ( +
+ {sessions.slice(0, 10).map((s) => { + const isEp = s.series_name && s.season_number != null && s.episode_number != null; + const title = isEp + ? s.episode_name || `S${s.season_number}E${s.episode_number}` + : s.media_title || `File #${s.media_file_id}`; + const username = s.username || `User #${s.user_id}`; + return ( +
+
+
-
- {getTimeAgo(s.started_at)} +
+
+ {username} + {" started watching "} + + {title} + +
+
+ {getTimeAgo(s.started_at)} +
+
+
+
-
- -
-
- ); - })} -
+ ); + })} +
+ )} ); @@ -614,3 +700,43 @@ function getTimeAgo(dateStr: string): string { const days = Math.floor(hours / 24); return `${days}d ago`; } + +function SectionError({ message }: { message: string }) { + return
{message}
; +} + +function LibrarySkeletonRows() { + return ( + <> + {Array.from({ length: 3 }).map((_, i) => ( + + ))} + + ); +} + +function UserSkeletonRows() { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ); +} + +function ActivitySkeletonRows() { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+ ); +} diff --git a/web/src/pages/AdminStats.tsx b/web/src/pages/AdminStats.tsx index 112a7154..db2da80a 100644 --- a/web/src/pages/AdminStats.tsx +++ b/web/src/pages/AdminStats.tsx @@ -9,14 +9,14 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { Skeleton } from "@/components/ui/skeleton"; import { Film, FileVideo, Users, Play } from "lucide-react"; +import type { AdminSession, AdminStats } from "@/api/types"; export default function AdminStats() { - const { data: stats, isLoading: statsLoading } = useAdminStats(); - const { data: sessions = [], isLoading: sessionsLoading } = useAdminSessions(); - const loading = statsLoading || sessionsLoading; - - if (loading) return
Loading stats...
; + const statsQuery = useAdminStats(); + const sessionsQuery = useAdminSessions(); + const sessions = sessionsQuery.data ?? []; return (
@@ -30,64 +30,107 @@ export default function AdminStats() {
- {stats && ( -
- } - /> - } - /> - } /> - } - /> + + + +
+ ); +} + +function StatsCards({ + stats, + sessionCount, + isLoading, + error, +}: { + stats: AdminStats | undefined; + sessionCount: number; + isLoading: boolean; + error: unknown; +}) { + if (error) { + return ; + } + if (isLoading || !stats) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ); + } + return ( +
+ } /> + } /> + } /> + } /> +
+ ); +} + +function SessionsSection({ + sessions, + isLoading, + error, +}: { + sessions: AdminSession[]; + isLoading: boolean; + error: unknown; +}) { + return ( +
+

Active playback sessions

+ {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : error ? ( + + ) : sessions.length === 0 ? ( +
+ No active sessions. +
+ ) : ( +
+
+ + + Session ID + User ID + File ID + Method + Started + + + + {sessions.map((s) => ( + + {s.session_id.slice(0, 8)}... + {s.user_id} + {s.media_file_id} + {s.play_method} + + {new Date(s.started_at).toLocaleString()} + + + ))} + +
)} - -
-

Active playback sessions

- {sessions.length === 0 ? ( -
- No active sessions. -
- ) : ( -
- - - - Session ID - User ID - File ID - Method - Started - - - - {sessions.map((s) => ( - - - {s.session_id.slice(0, 8)}... - - {s.user_id} - {s.media_file_id} - {s.play_method} - - {new Date(s.started_at).toLocaleString()} - - - ))} - -
-
- )} -
); } @@ -105,3 +148,11 @@ function StatCard({ title, value, icon }: { title: string; value: number; icon: ); } + +function SectionError({ message }: { message: string }) { + return ( +
+ {message} +
+ ); +} diff --git a/web/src/pages/AdminSubtitles.tsx b/web/src/pages/AdminSubtitles.tsx new file mode 100644 index 00000000..8e4eac59 --- /dev/null +++ b/web/src/pages/AdminSubtitles.tsx @@ -0,0 +1,200 @@ +import { useMemo, useState } from "react"; +import { useSearchParams } from "react-router"; +import type { AdminDownloadedSubtitle } from "@/api/types"; +import AdminSubtitlesFilters, { + FILTER_ALL, +} from "@/components/admin/subtitles/AdminSubtitlesFilters"; +import AdminSubtitlesTable from "@/components/admin/subtitles/AdminSubtitlesTable"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + useAdminDeleteDownloadedSubtitle, + useAdminDownloadedSubtitles, +} from "@/hooks/queries/admin/subtitles"; +import { useAdminUsers } from "@/hooks/queries/admin/users"; + +const PAGE_SIZE_OPTIONS = ["25", "50", "100"] as const; + +export default function AdminSubtitles() { + const [searchParams, setSearchParams] = useSearchParams(); + const { data: users = [] } = useAdminUsers(); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(25); + const deleteMutation = useAdminDeleteDownloadedSubtitle(); + + const provider = searchParams.get("provider") ?? FILTER_ALL; + const language = searchParams.get("language") ?? FILTER_ALL; + const userId = searchParams.get("user_id") ?? FILTER_ALL; + const search = searchParams.get("q") ?? ""; + + const filters = useMemo( + () => ({ + provider: provider !== FILTER_ALL ? provider : undefined, + language: language !== FILTER_ALL ? language : undefined, + userId: userId !== FILTER_ALL ? Number(userId) : undefined, + q: search.trim() || undefined, + limit: pageSize, + offset: page * pageSize, + }), + [language, page, pageSize, provider, search, userId], + ); + + const subtitlesQuery = useAdminDownloadedSubtitles(filters); + const subtitles = subtitlesQuery.data?.subtitles ?? []; + const total = subtitlesQuery.data?.total ?? 0; + const uploads = subtitlesQuery.data?.uploads ?? 0; + const providerDownloads = subtitlesQuery.data?.provider_downloads ?? 0; + const languageCount = new Set(subtitles.map((row) => row.language)).size; + + const hasActiveFilters = + provider !== FILTER_ALL || + language !== FILTER_ALL || + userId !== FILTER_ALL || + search.trim().length > 0; + + function updateFilter(key: string, value: string) { + const next = new URLSearchParams(searchParams); + if (value === FILTER_ALL || value.trim() === "") { + next.delete(key); + } else { + next.set(key, value); + } + setPage(0); + setSearchParams(next, { replace: true }); + } + + function resetFilters() { + setPage(0); + setSearchParams(new URLSearchParams(), { replace: true }); + } + + function handleDelete(subtitle: AdminDownloadedSubtitle) { + deleteMutation.mutate(subtitle.id); + } + + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + const canPrev = page > 0; + const canNext = (page + 1) * pageSize < total; + + if (subtitlesQuery.isLoading) { + return ( +
+
+ + +
+ + + {Array.from({ length: 6 }).map((_, index) => ( + + ))} +
+ ); + } + + return ( +
+
+
+

Subtitles

+

+ Manage stored subtitle files across the library — user uploads and provider downloads. +

+
+
+ +
+ + + + +
+ + updateFilter("provider", value)} + onLanguageChange={(value) => updateFilter("language", value)} + onUserChange={(value) => updateFilter("user_id", value)} + onSearchChange={(value) => updateFilter("q", value)} + onReset={resetFilters} + /> + + + + {total > 0 && ( +
+

+ Showing {page * pageSize + 1}–{Math.min((page + 1) * pageSize, total)} of {total} +

+
+ + + + Page {page + 1} of {pageCount} + + +
+
+ )} +
+ ); +} + +function StatBlock({ label, value }: { label: string; value: number }) { + return ( +
+
+ {label} +
+
{value.toLocaleString()}
+
+ ); +} diff --git a/web/src/pages/AdminUserDetail.tsx b/web/src/pages/AdminUserDetail.tsx index a2a47faa..2f9fe769 100644 --- a/web/src/pages/AdminUserDetail.tsx +++ b/web/src/pages/AdminUserDetail.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useId, useMemo, useState } from "react"; import type { FormEvent } from "react"; import { useParams, Link } from "react-router"; import { @@ -66,6 +66,11 @@ import { playbackQualityValueFromPreset, type PlaybackQualityPreset, } from "@/lib/playback-quality"; +import { + PERMISSION_METADATA_CURATION, + hasAssignedPermission, + setAssignedPermission, +} from "@/lib/permissions"; import { RegistrySettingControl } from "@/components/settings/RegistrySettingControl"; import { formatSettingValue, getSettingDefinition } from "@/lib/settingsManifest"; import { @@ -275,6 +280,14 @@ function OverviewTab({ user }: { user: AdminUser }) {
+ void const [password, setPassword] = useState(""); const [role, setRole] = useState(user.role); const [enabled, setEnabled] = useState(user.enabled); + const [permissions, setPermissions] = useState(user.permissions ?? []); const [libraryIDs, setLibraryIDs] = useState(user.library_ids); const [maxStreams, setMaxStreams] = useState(user.max_streams); const [maxTranscodes, setMaxTranscodes] = useState(user.max_transcodes); @@ -889,6 +903,7 @@ function EditUserForm({ user, onClose }: { user: AdminUser; onClose: () => void const [downloadTranscodeAllowed, setDownloadTranscodeAllowed] = useState( user.download_transcode_allowed, ); + const metadataCurationId = useId(); const updateMutation = useUpdateUser(); function handleSubmit(e: FormEvent) { @@ -897,6 +912,7 @@ function EditUserForm({ user, onClose }: { user: AdminUser; onClose: () => void username, email, role, + permissions, enabled, library_ids: libraryIDs, max_streams: maxStreams, @@ -982,6 +998,23 @@ function EditUserForm({ user, onClose }: { user: AdminUser; onClose: () => void value={libraryIDs} onChange={setLibraryIDs} /> +
+
+ +

+ Edit, refresh, and rematch metadata within assigned libraries. +

+
+ + setPermissions((current) => + setAssignedPermission(current, PERMISSION_METADATA_CURATION, checked), + ) + } + /> +
diff --git a/web/src/pages/AdminUsers.tsx b/web/src/pages/AdminUsers.tsx index 5c277324..eedf6d78 100644 --- a/web/src/pages/AdminUsers.tsx +++ b/web/src/pages/AdminUsers.tsx @@ -59,6 +59,11 @@ import { playbackQualityValueFromPreset, type PlaybackQualityPreset, } from "@/lib/playback-quality"; +import { + PERMISSION_METADATA_CURATION, + hasAssignedPermission, + setAssignedPermission, +} from "@/lib/permissions"; const PAGE_SIZE_OPTIONS = ["25", "50", "100"] as const; type UserSortField = "username" | "email" | "role" | "enabled" | "created_at" | "last_active_at"; @@ -522,6 +527,7 @@ function UserForm({ user, onClose }: { user: AdminUser | null; onClose: () => vo const [password, setPassword] = useState(""); const [role, setRole] = useState(user?.role ?? "user"); const [enabled, setEnabled] = useState(user?.enabled ?? true); + const [permissions, setPermissions] = useState(user?.permissions ?? []); const [libraryIDs, setLibraryIDs] = useState(user?.library_ids ?? null); const [maxStreams, setMaxStreams] = useState( user?.max_streams ?? Number(settings?.["defaults.max_streams"] ?? "6"), @@ -549,6 +555,7 @@ function UserForm({ user, onClose }: { user: AdminUser | null; onClose: () => vo const passwordId = useId(); const roleId = useId(); const enabledId = useId(); + const metadataCurationId = useId(); const downloadAllowedId = useId(); const downloadTranscodeAllowedId = useId(); const maxStreamsId = useId(); @@ -566,6 +573,7 @@ function UserForm({ user, onClose }: { user: AdminUser | null; onClose: () => vo username, email, role, + permissions, enabled, library_ids: libraryIDs, max_streams: maxStreams, @@ -583,6 +591,7 @@ function UserForm({ user, onClose }: { user: AdminUser | null; onClose: () => vo email, password, role, + permissions, create_default_profile: true, max_streams: maxStreams, max_transcodes: maxTranscodes, @@ -682,6 +691,23 @@ function UserForm({ user, onClose }: { user: AdminUser | null; onClose: () => vo value={libraryIDs} onChange={setLibraryIDs} /> +
+
+ +

+ Edit, refresh, and rematch metadata within assigned libraries. +

+
+ + setPermissions((current) => + setAssignedPermission(current, PERMISSION_METADATA_CURATION, checked), + ) + } + /> +
diff --git a/web/src/pages/Catalog.test.tsx b/web/src/pages/Catalog.test.tsx index 16d69715..bfcdd5cf 100644 --- a/web/src/pages/Catalog.test.tsx +++ b/web/src/pages/Catalog.test.tsx @@ -10,6 +10,8 @@ let latestNavigateTo: string | null = null; const mockUseCatalogWindow = vi.fn(); const mockUseCatalogFilters = vi.fn(); const mockItemGrid = vi.fn(); +const mockUseCanRequest = vi.fn(); +const mockUseRequestSearch = vi.fn(); vi.mock("react-router", async () => { const actual = await vi.importActual("react-router"); @@ -38,6 +40,30 @@ vi.mock("@/hooks/queries/catalog", () => ({ useCatalogMetadataFilters: (...args: unknown[]) => mockUseCatalogFilters(...args), })); +vi.mock("@/hooks/useCanRequest", () => ({ + useCanRequest: () => mockUseCanRequest(), +})); + +vi.mock("@/hooks/queries/useRequests", () => ({ + useRequestSearch: (...args: unknown[]) => mockUseRequestSearch(...args), +})); + +vi.mock("@/components/RequestToAddSection", () => ({ + RequestToAddSection: ({ + variant, + query, + libraryHadHits, + }: { + variant: string; + query: string; + libraryHadHits: boolean; + }) => ( +
+ {`variant="${variant}" query="${query}" libraryHadHits="${String(libraryHadHits)}"`} +
+ ), +})); + vi.mock("@/hooks/useAuth", () => ({ AuthProvider: ({ children }: { children: ReactNode }) => <>{children}, useAuth: () => ({ @@ -89,10 +115,19 @@ vi.mock("@/components/ItemGrid", () => ({ items?: Array<{ title: string }>; totalItems?: number; pageSize?: number; + loading?: boolean; onVisibleRangeChange?: (start: number, end: number) => void; }) => { mockItemGrid(props); - return
{props.items?.map((item) => item.title).join(",")}
; + return ( +
+ {props.items?.map((item) => item.title).join(",")} +
+ ); }, })); @@ -152,6 +187,14 @@ describe("Catalog page", () => { mockUseCatalogWindow.mockReset(); mockUseCatalogFilters.mockReset(); mockItemGrid.mockReset(); + mockUseCanRequest.mockReset(); + mockUseRequestSearch.mockReset(); + mockUseCanRequest.mockReturnValue({ + discoveryEnabled: false, + isResolving: false, + submitDisabledReason: null, + }); + mockUseRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: false }); mockUseCatalogWindow.mockReturnValue({ data: { @@ -273,7 +316,7 @@ describe("Catalog page", () => { expect(markup).toContain("Settings"); }); - it("redirects the retired user plugins settings route back to appearance settings", () => { + it("redirects the retired user plugins settings route back to playback settings", () => { appInitialEntries = ["/settings/plugins"]; renderToStaticMarkup( @@ -282,6 +325,236 @@ describe("Catalog page", () => { , ); - expect(latestNavigateTo).toBe("appearance"); + expect(latestNavigateTo).toBe("/settings/playback"); + }); + + it("renders the request grid variant when source=query and library has results", () => { + mockUseCanRequest.mockReturnValue({ + discoveryEnabled: true, + isResolving: false, + submitDisabledReason: null, + }); + mockUseRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 1, + results: [ + { + media_type: "movie", + tmdb_id: 1, + title: "X", + availability: "missing", + request: { requestable: true }, + }, + ], + }, + isLoading: false, + isError: false, + }); + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).toContain('data-testid="request-section"'); + expect(markup).toContain("variant="grid""); + expect(markup).toContain("libraryHadHits="true""); + }); + + it("renders the request grid variant with libraryHadHits=false when library has 0 hits", () => { + mockUseCanRequest.mockReturnValue({ + discoveryEnabled: true, + isResolving: false, + submitDisabledReason: null, + }); + mockUseCatalogWindow.mockReturnValue({ + data: { title: 'Results for "heat"', totalItems: 0, pages: new Map() }, + isLoading: false, + }); + mockUseRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 1, + results: [ + { + media_type: "movie", + tmdb_id: 1, + title: "X", + availability: "missing", + request: { requestable: true }, + }, + ], + }, + isLoading: false, + isError: false, + }); + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).toContain("libraryHadHits="false""); + }); + + it("does not render the request section when source is not query", () => { + appInitialEntries = ["/catalog?source=favorites"]; + mockUseCanRequest.mockReturnValue({ + discoveryEnabled: true, + isResolving: false, + submitDisabledReason: null, + }); + mockUseCatalogWindow.mockReturnValue({ + data: { title: "Favorites", totalItems: 0, pages: new Map() }, + isLoading: false, + }); + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).not.toContain('data-testid="request-section"'); + }); + + it("does not render the request section when discovery is disabled", () => { + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).not.toContain('data-testid="request-section"'); + }); + + it("passes enabled=false to useRequestSearch when discoveryEnabled is false", () => { + renderToStaticMarkup( + + + , + ); + + const call = mockUseRequestSearch.mock.calls[mockUseRequestSearch.mock.calls.length - 1]; + expect(call?.[3]).toEqual({ + enabled: false, + requireProfile: true, + staleTime: 5 * 60 * 1000, + }); + }); + + it("hides ItemGrid when library is empty and TMDB is still loading (request section will rescue)", () => { + mockUseCanRequest.mockReturnValue({ + discoveryEnabled: true, + isResolving: false, + submitDisabledReason: null, + }); + mockUseCatalogWindow.mockReturnValue({ + data: { title: 'Results for "heat"', totalItems: 0, pages: new Map() }, + isLoading: false, + }); + mockUseRequestSearch.mockReturnValue({ data: undefined, isLoading: true, isError: false }); + + const markup = renderToStaticMarkup( + + + , + ); + + // Previously this case forced ItemGrid into loading=true, rendering 24 fake + // skeletons forever above the section. Now ItemGrid is hidden entirely. + expect(markup).not.toContain('data-kind="item-grid"'); + }); + + it("hides ItemGrid when library is empty and TMDB has missing results", () => { + mockUseCanRequest.mockReturnValue({ + discoveryEnabled: true, + isResolving: false, + submitDisabledReason: null, + }); + mockUseCatalogWindow.mockReturnValue({ + data: { title: 'Results for "heat"', totalItems: 0, pages: new Map() }, + isLoading: false, + }); + mockUseRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 1, + results: [ + { + media_type: "movie", + tmdb_id: 1, + title: "X", + availability: "missing", + request: { requestable: true }, + }, + ], + }, + isLoading: false, + isError: false, + }); + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).not.toContain('data-kind="item-grid"'); + expect(markup).toContain('data-testid="request-section"'); + }); + + it("hides ItemGrid when library is empty and discovery feature status is still resolving", () => { + mockUseCanRequest.mockReturnValue({ + discoveryEnabled: false, + isResolving: true, + submitDisabledReason: null, + }); + mockUseCatalogWindow.mockReturnValue({ + data: { title: 'Results for "heat"', totalItems: 0, pages: new Map() }, + isLoading: false, + }); + + const markup = renderToStaticMarkup( + + + , + ); + + // Avoids the empty-state flash before the feature status resolves and TMDB + // either rescues with results or confirms there are none. + expect(markup).not.toContain('data-kind="item-grid"'); + }); + + it("renders the normal ItemGrid empty state when both library and TMDB are empty", () => { + mockUseCanRequest.mockReturnValue({ + discoveryEnabled: true, + isResolving: false, + submitDisabledReason: null, + }); + mockUseCatalogWindow.mockReturnValue({ + data: { title: 'Results for "heat"', totalItems: 0, pages: new Map() }, + isLoading: false, + }); + mockUseRequestSearch.mockReturnValue({ + data: { page: 1, total_pages: 1, total_results: 0, results: [] }, + isLoading: false, + isError: false, + }); + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).toContain('data-loading="false"'); + expect(markup).toContain('data-total="0"'); }); }); diff --git a/web/src/pages/Catalog.tsx b/web/src/pages/Catalog.tsx index ec28a0cf..688f8d1e 100644 --- a/web/src/pages/Catalog.tsx +++ b/web/src/pages/Catalog.tsx @@ -4,10 +4,14 @@ import { CheckSquare, Search, Trash2, X } from "lucide-react"; import type { BrowseItem } from "@/api/types"; import ItemGrid from "@/components/ItemGrid"; +import { RequestToAddSection } from "@/components/RequestToAddSection"; import { Button } from "@/components/ui/button"; import CatalogFiltersPanel from "@/components/catalog/CatalogFiltersPanel"; import { useCatalogWindow } from "@/hooks/queries/catalog"; import { useRemoveHistory } from "@/hooks/queries/history"; +import { useRequestSearch } from "@/hooks/queries/useRequests"; +import { useCanRequest } from "@/hooks/useCanRequest"; +import { useDebounce } from "@/hooks/useDebounce"; import { useDocumentTitle } from "@/hooks/useDocumentTitle"; import SearchBar from "@/components/SearchBar"; import { ConfirmDialog } from "@/components/ConfirmDialog"; @@ -101,6 +105,28 @@ function CatalogResults({ visibleRange, includeTotal: showExactResultCount, }); + const canRequest = useCanRequest(); + const isQuerySource = state.source === "query" && Boolean(state.q); + // Add a 200ms TMDB debounce on top of SearchBar's 200ms input debounce so the + // TMDB plugin isn't hit at the same cadence as the local library query. + const tmdbDebouncedQ = useDebounce(state.q ?? "", 200); + const tmdbQuery = useRequestSearch("all", tmdbDebouncedQ, 1, { + enabled: canRequest.discoveryEnabled && isQuerySource, + requireProfile: true, + staleTime: 5 * 60 * 1000, + }); + const tmdbMissingCount = + tmdbQuery.data?.results?.filter((result) => result.availability !== "available").length ?? 0; + const libraryHasResults = (catalogQuery.data?.totalItems ?? 0) > 0; + const libraryEmpty = !catalogQuery.isLoading && !libraryHasResults; + // When the library is empty and the request section will (or might) render, + // hide ItemGrid entirely. The previous approach pinned ItemGrid's `loading` + // prop to true, which renders 24 skeleton tiles forever above the section. + const tmdbMayRescueLibrary = + isQuerySource && + libraryEmpty && + (canRequest.isResolving || + (canRequest.discoveryEnabled && (tmdbQuery.isLoading || tmdbMissingCount > 0))); const loadedHistoryItems = useMemo(() => { if (!isHistorySource) { return [] as BrowseItem[]; @@ -249,16 +275,26 @@ function CatalogResults({ )} - + {tmdbMayRescueLibrary ? null : ( + + )} + + {isQuerySource && canRequest.discoveryEnabled ? ( + + ) : null} - - +
+ + Collection not found The selected collection could not be loaded. @@ -51,21 +45,14 @@ export default function CollectionEditor() { if (collection && isImportedCollection(collection)) { return ( -
-
- -
-

{collection.name}

-

- Edit what's local — name, libraries, sharing. Source-managed details (URL, schedule, - item ordering) are locked. -

-
+
+ +
+

{collection.name}

+

+ Edit what's local — name, libraries, sharing. Source-managed details (URL, schedule, + item ordering) are locked. +

-
- -
-

Edit {collection.name}

-

- Manual collections are curated by adding titles directly. -

-
+
+ +
+

Edit {collection.name}

+

+ Manual collections are curated by adding titles directly. +

navigate("/collections")} />
diff --git a/web/src/pages/ItemDetail/DetailHero.tsx b/web/src/pages/ItemDetail/DetailHero.tsx index 91bcf73f..db59dc37 100644 --- a/web/src/pages/ItemDetail/DetailHero.tsx +++ b/web/src/pages/ItemDetail/DetailHero.tsx @@ -28,6 +28,7 @@ interface DetailHeroProps { scoreRow?: ReactNode; crewLine?: ReactNode; variant?: "full" | "compact"; + topNav?: ReactNode; } export default function DetailHero({ @@ -52,6 +53,7 @@ export default function DetailHero({ scoreRow, crewLine, variant = "full", + topNav, }: DetailHeroProps) { const [backdropLoaded, setBackdropLoaded] = useState(false); const [posterLoaded, setPosterLoaded] = useState(false); @@ -88,6 +90,7 @@ export default function DetailHero({ return (
+ {topNav} {(backdropUrl || backdropPlaceholder) && (
{ mocks.useRating.mockReturnValue({ data: { rating: 3, rated_at: "2026-03-22T00:00:00Z" } }); }); - it("links the season breadcrumb and back button to the resolved season page", () => { + it("links the season breadcrumb segment to the resolved season page", () => { const markup = renderToStaticMarkup( @@ -233,7 +233,7 @@ describe("EpisodeContent", () => { ); expect(countOccurrences(markup, 'href="/item/series-1"')).toBe(1); - expect(countOccurrences(markup, 'href="/item/season-1"')).toBe(2); + expect(countOccurrences(markup, 'href="/item/season-1"')).toBe(1); expect(markup).toContain(">Season 1<"); }); @@ -258,7 +258,7 @@ describe("EpisodeContent", () => { , ); - expect(countOccurrences(markup, 'href="/item/season-99"')).toBe(2); + expect(countOccurrences(markup, 'href="/item/season-99"')).toBe(1); expect(markup).toContain(">Season 99<"); }); diff --git a/web/src/pages/ItemDetail/EpisodeContent.tsx b/web/src/pages/ItemDetail/EpisodeContent.tsx index aaa380f1..b4aeed55 100644 --- a/web/src/pages/ItemDetail/EpisodeContent.tsx +++ b/web/src/pages/ItemDetail/EpisodeContent.tsx @@ -16,6 +16,7 @@ import CrewList from "@/components/CrewList"; import DownloadVersionPicker from "@/components/DownloadVersionPicker"; import EditMetadataDialog from "@/components/EditMetadataDialog"; import MediaLocations from "@/components/MediaLocations"; +import PageBack from "@/components/PageBack"; import EpisodeCarousel from "./components/EpisodeCarousel"; import DetailHero from "./DetailHero"; import MetadataBadges from "./components/MetadataBadges"; @@ -35,6 +36,7 @@ import { type EpisodeNavigationState, } from "./itemDetailLayout"; import { getWatchedActionLabel } from "./watchedState"; +import { canCurateMetadata as canCurateMetadataForUser } from "@/lib/permissions"; function formatDuration(minutes: number): string { if (minutes <= 0) return ""; @@ -49,6 +51,7 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e useAmbientColor(item.backdrop_thumbhash); const { user } = useAuth(); const isAdmin = user?.role === "admin"; + const canCurateMetadata = canCurateMetadataForUser(user); const { profile: currentProfile } = useCurrentProfile(); const [editOpen, setEditOpen] = useState(false); const [downloadOpen, setDownloadOpen] = useState(false); @@ -221,6 +224,7 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e
} context={
@@ -294,12 +298,15 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e watchedLabel={getWatchedActionLabel(item)} onToggleWatched={() => watchedMutation.mutate(!(item.user_data?.played ?? false))} isUpdatingWatched={watchedMutation.isPending} - onRefresh={(mode) => - refreshMetadataMutation.mutate({ - item, - mode, - onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }), - }) + onRefresh={ + canCurateMetadata + ? (mode) => + refreshMetadataMutation.mutate({ + item, + mode, + onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }), + }) + : undefined } isRefreshing={refreshMetadataMutation.isPending} onRedetectIntro={ @@ -307,7 +314,8 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e } isRedetectingIntro={redetectIntroMutation.isPending} isAdmin={isAdmin} - onEditMetadata={isAdmin ? () => setEditOpen(true) : undefined} + canCurateMetadata={canCurateMetadata} + onEditMetadata={canCurateMetadata ? () => setEditOpen(true) : undefined} versions={item.versions ?? []} playbackVariants={item.playback_variants} selectedVersion={selectedVersion} @@ -340,7 +348,7 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e />
- {isAdmin && } + {canCurateMetadata && } {/* More Episodes carousel — most useful, so show first */} {siblingsLoading ? ( @@ -367,7 +375,9 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e {item.crew && item.crew.length > 0 && }
- {isAdmin && } + {canCurateMetadata && ( + + )} } context="Movie" studioLabel={firstStudio} backdropUrl={item.backdrop_url} @@ -238,17 +242,21 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov isFavorite={isFavorite} onToggleWatchlist={() => toggleWatchlistMutation.mutate(inWatchlist)} inWatchlist={inWatchlist} - onRefresh={(mode) => - refreshMetadataMutation.mutate({ - item, - mode, - onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }), - }) + onRefresh={ + canCurateMetadata + ? (mode) => + refreshMetadataMutation.mutate({ + item, + mode, + onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }), + }) + : undefined } isRefreshing={refreshMetadataMutation.isPending} isAdmin={isAdmin} - onEditMetadata={isAdmin ? () => setEditOpen(true) : undefined} - onMatchItem={isAdmin ? () => setMatchOpen(true) : undefined} + canCurateMetadata={canCurateMetadata} + onEditMetadata={canCurateMetadata ? () => setEditOpen(true) : undefined} + onMatchItem={canCurateMetadata ? () => setMatchOpen(true) : undefined} versions={item.versions} playbackVariants={item.playback_variants} selectedVersion={selectedVersion} @@ -283,7 +291,7 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov />
- {isAdmin && } + {canCurateMetadata && } {item.cast && item.cast.length > 0 && (
@@ -307,8 +315,10 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov ) )}
- {isAdmin && } - {isAdmin && ( + {canCurateMetadata && ( + + )} + {canCurateMetadata && ( } context={breadcrumb} backdropUrl={item.backdrop_url} backdropThumbhash={item.backdrop_thumbhash} @@ -103,16 +107,20 @@ export default function SeasonContent({ item }: { item: ItemDetail & { type: "se watchedLabel={getWatchedActionLabel(item)} onToggleWatched={() => watchedMutation.mutate(!(item.user_data?.played ?? false))} isUpdatingWatched={watchedMutation.isPending} - onRefresh={(mode) => - refreshMetadataMutation.mutate({ - item, - mode, - onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }), - }) + onRefresh={ + canCurateMetadata + ? (mode) => + refreshMetadataMutation.mutate({ + item, + mode, + onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }), + }) + : undefined } isRefreshing={refreshMetadataMutation.isPending} isAdmin={isAdmin} - onEditMetadata={isAdmin ? () => setEditOpen(true) : undefined} + canCurateMetadata={canCurateMetadata} + onEditMetadata={canCurateMetadata ? () => setEditOpen(true) : undefined} /> } /> @@ -144,7 +152,9 @@ export default function SeasonContent({ item }: { item: ItemDetail & { type: "se
)}
- {isAdmin && } + {canCurateMetadata && ( + + )}
); } diff --git a/web/src/pages/ItemDetail/SeriesContent.tsx b/web/src/pages/ItemDetail/SeriesContent.tsx index de46882a..bfd52db5 100644 --- a/web/src/pages/ItemDetail/SeriesContent.tsx +++ b/web/src/pages/ItemDetail/SeriesContent.tsx @@ -14,6 +14,7 @@ import CastCarousel from "@/components/CastCarousel"; import CrewList from "@/components/CrewList"; import EditMetadataDialog from "@/components/EditMetadataDialog"; import MatchItemDialog from "@/components/MatchItemDialog"; +import PageBack from "@/components/PageBack"; import RecommendationGrid from "@/components/RecommendationGrid"; import DetailHero from "./DetailHero"; import SeasonCarousel from "./SeasonCarousel"; @@ -25,12 +26,14 @@ import ActionBar from "./components/ActionBar"; import { SeasonCarouselSkeleton, RecommendationGridSkeleton } from "./components/SectionSkeletons"; import { getSeasonDisplayTitle, resolveSeriesPrimaryAction } from "./itemDetailLayout"; import { getWatchedActionLabel } from "./watchedState"; +import { canCurateMetadata as canCurateMetadataForUser } from "@/lib/permissions"; export default function SeriesContent({ item }: { item: ItemDetail & { type: "series" } }) { const navigate = useNavigate(); useAmbientColor(item.backdrop_thumbhash); const { user } = useAuth(); const isAdmin = user?.role === "admin"; + const canCurateMetadata = canCurateMetadataForUser(user); const isFavorite = item.user_state?.is_favorite ?? false; const inWatchlist = item.user_state?.in_watchlist ?? false; @@ -116,6 +119,7 @@ export default function SeriesContent({ item }: { item: ItemDetail & { type: "se
} context="Series" studioLabel={firstNetwork} backdropUrl={item.backdrop_url} @@ -156,17 +160,21 @@ export default function SeriesContent({ item }: { item: ItemDetail & { type: "se isFavorite={isFavorite} onToggleWatchlist={() => toggleWatchlistMutation.mutate(inWatchlist)} inWatchlist={inWatchlist} - onRefresh={(mode) => - refreshMetadataMutation.mutate({ - item, - mode, - onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }), - }) + onRefresh={ + canCurateMetadata + ? (mode) => + refreshMetadataMutation.mutate({ + item, + mode, + onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }), + }) + : undefined } isRefreshing={refreshMetadataMutation.isPending} isAdmin={isAdmin} - onEditMetadata={isAdmin ? () => setEditOpen(true) : undefined} - onMatchItem={isAdmin ? () => setMatchOpen(true) : undefined} + canCurateMetadata={canCurateMetadata} + onEditMetadata={canCurateMetadata ? () => setEditOpen(true) : undefined} + onMatchItem={canCurateMetadata ? () => setMatchOpen(true) : undefined} rating={item.user_rating ?? null} onRatingChange={handleRatingChange} /> @@ -213,8 +221,10 @@ export default function SeriesContent({ item }: { item: ItemDetail & { type: "se ) )}
- {isAdmin && } - {isAdmin && ( + {canCurateMetadata && ( + + )} + {canCurateMetadata && ( void; onMatchItem?: () => void; isAdmin?: boolean; + canCurateMetadata?: boolean; versions?: FileVersion[]; playbackVariants?: PlaybackVariant[]; selectedVersion?: FileVersion | null; @@ -121,6 +122,7 @@ export default function ActionBar({ onEditMetadata, onMatchItem, isAdmin = false, + canCurateMetadata = false, versions, playbackVariants, selectedVersion, @@ -226,6 +228,10 @@ export default function ActionBar({ const hasOverflowActions = Boolean( restartHref || onToggleWatchlist || onDownload || onSearchSubtitles, ); + const hasAdminActions = Boolean(isAdmin && (contentId || onRedetectIntro)); + const hasMetadataActions = Boolean( + canCurateMetadata && (onRefresh || onEditMetadata || onMatchItem), + ); const formattedResumeTime = formatPlaybackTime(resumePositionSeconds ?? 0); const percentComplete = @@ -379,10 +385,10 @@ export default function ActionBar({ Search Subtitles )} - {isAdmin && ( + {(hasAdminActions || hasMetadataActions) && ( <> {hasOverflowActions && } - {contentId && ( + {isAdmin && contentId && ( navigate(`/admin/history?media_item_id=${encodeURIComponent(contentId)}`) @@ -391,7 +397,7 @@ export default function ActionBar({ View Play History )} - {onRefresh && ( + {canCurateMetadata && onRefresh && ( { @@ -402,19 +408,19 @@ export default function ActionBar({ Refresh Metadata )} - {onRedetectIntro && ( + {isAdmin && onRedetectIntro && ( Re-detect Intro Markers )} - {onEditMetadata && ( + {canCurateMetadata && onEditMetadata && ( Edit Metadata )} - {onMatchItem && ( + {canCurateMetadata && onMatchItem && ( Match Item diff --git a/web/src/pages/ItemDetail/components/DetailBreadcrumb.tsx b/web/src/pages/ItemDetail/components/DetailBreadcrumb.tsx index 259c9fc5..95eb7627 100644 --- a/web/src/pages/ItemDetail/components/DetailBreadcrumb.tsx +++ b/web/src/pages/ItemDetail/components/DetailBreadcrumb.tsx @@ -1,5 +1,5 @@ import { Link } from "react-router"; -import { ChevronLeft, ChevronRight } from "lucide-react"; +import { ChevronRight } from "lucide-react"; interface BreadcrumbSegment { label: string; @@ -13,44 +13,32 @@ interface DetailBreadcrumbProps { export default function DetailBreadcrumb({ segments }: DetailBreadcrumbProps) { if (segments.length === 0) return null; - const backSegment = [...segments].reverse().find((segment) => segment.href); - return ( ); diff --git a/web/src/pages/ItemDetail/components/EpisodeCarousel.tsx b/web/src/pages/ItemDetail/components/EpisodeCarousel.tsx index 298eae08..581fdaac 100644 --- a/web/src/pages/ItemDetail/components/EpisodeCarousel.tsx +++ b/web/src/pages/ItemDetail/components/EpisodeCarousel.tsx @@ -3,6 +3,7 @@ import { Link } from "react-router"; import { Check, Play, ChevronLeft, ChevronRight } from "lucide-react"; import type { EpisodeListItem } from "@/api/types"; import { decodeThumbhash } from "@/lib/thumbhash"; +import { cn } from "@/lib/utils"; import MediaItemMenu from "@/components/MediaItemMenu"; import type { EpisodeNavigationState } from "../itemDetailLayout"; import { useCarouselEmbla } from "@/hooks/useCarouselEmbla"; @@ -48,7 +49,7 @@ export default function EpisodeCarousel({ )} -
+
    {episodes.map((ep) => { const isCurrent = ep.episode_number === currentEpisodeNumber; @@ -79,12 +80,16 @@ export default function EpisodeCarousel({
    )} + {isCurrent && ( +
    + + + + + Now Viewing +
    + )} {ep.user_data?.played && (
    @@ -135,7 +149,12 @@ export default function EpisodeCarousel({ hasPartialProgress={progress != null} />
    - +

    Episode {ep.episode_number}

    diff --git a/web/src/pages/ItemDetail/components/SubtitleSearchDialog.tsx b/web/src/pages/ItemDetail/components/SubtitleSearchDialog.tsx index 8aeba9e0..f29288f8 100644 --- a/web/src/pages/ItemDetail/components/SubtitleSearchDialog.tsx +++ b/web/src/pages/ItemDetail/components/SubtitleSearchDialog.tsx @@ -21,11 +21,14 @@ import { import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { searchSubtitles, + detectSubtitleLanguage, useDownloadSubtitle, useDownloadedSubtitles, + useUploadSubtitle, } from "@/hooks/queries/subtitles"; import { cn } from "@/lib/utils"; import { LANGUAGES, getLanguageName } from "@/player/utils/languageNames"; +import { SubtitleUploadForm } from "@/components/subtitles/SubtitleUploadForm"; import { buildQualitySummary } from "./VersionFlyout"; interface SubtitleSearchDialogProps { @@ -39,6 +42,7 @@ const providerInfo: Record = { opensubtitles: { abbr: "OS", className: "bg-amber-500/15 text-amber-700 dark:text-amber-300" }, subdl: { abbr: "SDL", className: "bg-sky-500/15 text-sky-700 dark:text-sky-300" }, subsource: { abbr: "SS", className: "bg-rose-500/15 text-rose-700 dark:text-rose-300" }, + upload: { abbr: "UP", className: "bg-violet-500/15 text-violet-700 dark:text-violet-300" }, }; function scoreTone(score: number): { text: string; ring: string; bg: string } { @@ -77,6 +81,7 @@ export default function SubtitleSearchDialog({ title, }: SubtitleSearchDialogProps) { const downloadSubtitleMutation = useDownloadSubtitle(); + const uploadSubtitleMutation = useUploadSubtitle(); const downloadedQuery = useDownloadedSubtitles(open ? version?.file_id : undefined); const searchAbortRef = useRef(null); @@ -190,13 +195,41 @@ export default function SubtitleSearchDialog({ [downloadSubtitleMutation, downloadedQuery, version], ); + const handleUpload = useCallback( + async (input: { + mediaFileId: number; + file: File; + language?: string; + languageOverride?: boolean; + hearingImpaired: boolean; + }) => { + await uploadSubtitleMutation.mutateAsync({ + media_file_id: input.mediaFileId, + file: input.file, + language: input.language, + language_override: input.languageOverride, + hearing_impaired: input.hearingImpaired, + }); + }, + [uploadSubtitleMutation], + ); + + const handleDetectLanguage = useCallback( + (file: File, fallbackLanguage?: string) => detectSubtitleLanguage(file, fallbackLanguage), + [], + ); + + const handleUploadSuccess = useCallback(async () => { + await downloadedQuery.refetch(); + }, [downloadedQuery]); + const versionLabel = version ? buildQualitySummary(version) : ""; return ( - Search Subtitles + Add Subtitles {title} {versionLabel ? ` \u00B7 ${versionLabel}` : ""} @@ -205,28 +238,42 @@ export default function SubtitleSearchDialog({
    -
    - + {version && ( + + )} - +
    +

    Search online

    +
    + + + +
    {searchError && ( diff --git a/web/src/pages/PersonDetail.tsx b/web/src/pages/PersonDetail.tsx index 44905f94..b0d583a8 100644 --- a/web/src/pages/PersonDetail.tsx +++ b/web/src/pages/PersonDetail.tsx @@ -8,6 +8,7 @@ import { createEmptyQueryDefinition } from "@/api/types"; import type { CatalogSearchState } from "@/pages/catalogSearchParams"; import EditPersonDialog from "@/components/EditPersonDialog"; import ItemGrid from "@/components/ItemGrid"; +import PageBack from "@/components/PageBack"; import { Button } from "@/components/ui/button"; import { useCatalogWindow } from "@/hooks/queries/catalog"; import { personKeys } from "@/hooks/queries/keys"; @@ -76,8 +77,9 @@ export default function PersonDetail() { return (
    {/* Person Header */} -
    -
    +
    + +
    {/* Photo */}
    diff --git a/web/src/pages/ProfileCustomizeHome.tsx b/web/src/pages/ProfileCustomizeHome.tsx index 460d01af..cd39182b 100644 --- a/web/src/pages/ProfileCustomizeHome.tsx +++ b/web/src/pages/ProfileCustomizeHome.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import PageBack from "@/components/PageBack"; import ProfileSectionRow from "@/components/ProfileSectionRow"; import RecipeGalleryModal from "@/components/RecipeGallery/RecipeGalleryModal"; import RecipeConfigDrawer from "@/components/RecipeGallery/RecipeConfigDrawer"; @@ -207,8 +208,9 @@ export default function ProfileCustomizeHome() { } return ( -
    -
    +
    + +

    Customize home

    -
    ); } @@ -63,6 +60,7 @@ export default function RequestDetail() {
    } context={} studioLabel={studioLabel} backdropUrl={backdropUrl} @@ -79,7 +77,6 @@ export default function RequestDetail() { createRequest.isPending && createRequest.variables?.tmdb_id === item.tmdb_id } onRequest={() => createRequest.mutate(requestInputFromMediaResult(item))} - onBack={() => navigate(-1)} /> } /> @@ -198,12 +195,10 @@ function RequestActions({ item, isSubmitting, onRequest, - onBack, }: { item: RequestMediaDetail; isSubmitting: boolean; onRequest: () => void; - onBack: () => void; }) { const requestable = item.request.requestable; const statusLabel = item.request.status ? formatRequestStatus(item.request.status) : null; @@ -213,16 +208,6 @@ function RequestActions({ return (
    - - {requestable ? ( -
    -
    -

    {title}

    -

    - {step === 1 - ? "Tune the filters until the cards below show the collection you want." - : isEdit - ? "Update naming, artwork, and sharing for this collection." - : "Give your new collection a name, artwork, and sharing rules."} -

    -
    - +
    +
    +

    {title}

    +

    + {step === 1 + ? "Tune the filters until the cards below show the collection you want." + : isEdit + ? "Update naming, artwork, and sharing for this collection." + : "Give your new collection a name, artwork, and sharing rules."} +

    +
    ); } diff --git a/web/src/pages/settings/WebhookSyncSettings.tsx b/web/src/pages/settings/WebhookSyncSettings.tsx index d85f2dab..b34fda51 100644 --- a/web/src/pages/settings/WebhookSyncSettings.tsx +++ b/web/src/pages/settings/WebhookSyncSettings.tsx @@ -678,7 +678,7 @@ export default function WebhookSyncSettings() {

    - Activity from unmapped users goes to this profile. + The signed-in external user is linked to this profile when the connection is created.

    ( options: RequestInit = {}, ): Promise { const headers: Record = { - "Content-Type": "application/json", ...(options.headers as Record), }; + if (!(options.body instanceof FormData)) { + headers["Content-Type"] = "application/json"; + } const token = config.getAccessToken(); if (token) {