diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 00000000..d22600a2 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Resolve via git toplevel so the hook works from any cwd (e.g., when +# git invokes hooks from within a non-root subdirectory). +"$(git rev-parse --show-toplevel)/scripts/check-local-path-leaks.sh" --cached diff --git a/AGENTS.md b/AGENTS.md index 7c863965..a72adb36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,6 +3,8 @@ ## Project Structure & Module Organization `cmd/silo` contains the main server entrypoint. Backend code lives in `internal/`, organized by domain (`api`, `catalog`, `metadata`, `playback`, `scanner`, `jellycompat`, etc.); keep new code in the package that owns the behavior instead of creating catch-all helpers. Database changes belong in `migrations/` as paired numbered `.up.sql` and `.down.sql` files. The React frontend lives in `web/src/`, with feature code split across `components/`, `pages/`, `hooks/`, `player/`, and `lib/`. Reference material belongs in `docs/architecture/` or `docs/superpowers/{specs,plans}/`; ad hoc SQL helpers live in `scripts/`. +When creating or editing `docs/superpowers/specs/` or `docs/superpowers/plans/`, never include local absolute filesystem paths or transient worktree IDs. Use repository-relative paths and wording like "Commands assume the repository root is the cwd." + This repository is a VERY EARLY WIP. Proposing sweeping changes that improve long-term maintainability is encouraged. @@ -56,6 +58,7 @@ Run before opening a merge request: - `cd web && pnpm run lint` - `cd web && pnpm run format:check` +- `make verify-local-paths` For local services, start PostgreSQL and Redis with `docker compose up -d postgres redis`. diff --git a/Makefile b/Makefile index 4deea2ce..32cff7f3 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: frontend build dev-frontend dev-backend dev-proxy dev-transcode lint clean jellyfin-web-bundle migrate-continuum-check +.PHONY: frontend build dev-frontend dev-backend dev-proxy dev-transcode lint clean jellyfin-web-bundle migrate-continuum-check verify-local-paths install-hooks GIT_COMMON_DIR := $(strip $(shell git rev-parse --git-common-dir 2>/dev/null)) MAIN_CHECKOUT_ROOT := $(if $(GIT_COMMON_DIR),$(abspath $(GIT_COMMON_DIR)/..)) @@ -43,6 +43,18 @@ lint: golangci-lint run cd web && pnpm run lint +# Check committed content for local machine path leaks. +verify-local-paths: + scripts/check-local-path-leaks.sh + +# Install repo-local git hooks for this checkout/worktree. +install-hooks: + @existing="$$(git config --local core.hooksPath 2>/dev/null || true)"; \ + if [ -n "$$existing" ] && [ "$$existing" != ".githooks" ]; then \ + echo "warning: overwriting existing local core.hooksPath ($$existing) with .githooks"; \ + fi + git config core.hooksPath .githooks + # Fetch and build the pinned Jellyfin Web bundle jellyfin-web-bundle: JELLYFIN_WEB_OUTPUT_DIR=$(JELLYFIN_WEB_OUTPUT_DIR) scripts/fetch-jellyfin-web.sh diff --git a/cmd/silo/main.go b/cmd/silo/main.go index 487745f7..e470ed37 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -65,6 +65,9 @@ import ( "github.com/Silo-Server/silo-server/internal/proxy" "github.com/Silo-Server/silo-server/internal/ratelimit" "github.com/Silo-Server/silo-server/internal/recommendations" + mediarequests "github.com/Silo-Server/silo-server/internal/requests" + "github.com/Silo-Server/silo-server/internal/requests/radarr" + "github.com/Silo-Server/silo-server/internal/requests/sonarr" "github.com/Silo-Server/silo-server/internal/s3client" "github.com/Silo-Server/silo-server/internal/scanner" "github.com/Silo-Server/silo-server/internal/scanqueue" @@ -1264,6 +1267,17 @@ func main() { if watchProviderService != nil { taskMgr.Register(tasks.NewSyncWatchProvidersTask(watchProviderService)) } + requestReconcileSvc := mediarequests.NewService( + mediarequests.NewRepository(deps.DB), + nil, + mediarequests.NewCatalogPresence( + catalog.NewItemRepository(deps.DB), + catalog.NewProviderIDRepository(deps.DB), + ), + ) + requestReconcileSvc.SetSecretResolver(settingsRepo) + requestReconcileSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil)) + taskMgr.Register(tasks.NewReconcileRequestsTask(requestReconcileSvc, 100)) reconcileProviderIDRepo := catalog.NewProviderIDRepository(deps.DB) reconcileEpisodeRepo := catalog.NewEpisodeRepository(deps.DB) historyResolver := watchstate.NewStableIdentityResolver(nil, reconcileEpisodeRepo, reconcileProviderIDRepo) diff --git a/docs/superpowers/plans/2026-05-24-discover-studios-networks-genres.md b/docs/superpowers/plans/2026-05-24-discover-studios-networks-genres.md new file mode 100644 index 00000000..957c67b1 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-discover-studios-networks-genres.md @@ -0,0 +1,3246 @@ +# Discover: Studios, Networks, Genres — 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 Overseerr-style brand-card discovery (Studios, Networks, Genres) on top of the existing request system, with three new list endpoints, three new browse endpoints, and three new frontend carousels + a shared browse page. + +**Architecture:** Additive extension of `internal/requests/` (new files: `discover_bundle.go`, `discover_logo_cache.go`, `discover_brand.go`) plus `internal/metadata/tmdb/client.go` additions (`DiscoverPage`, `GetCompany`, `GetNetwork`, `WithCompanies` / `WithNetworks` params). Frontend adds 2 components, 1 page, query hooks, and 3 routes. No new tables, no migrations, no admin UI. Caches are process-local. + +**Tech Stack:** Go (chi, pgx, stdlib `singleflight`), TypeScript (React 18, React Router v6, TanStack Query, shadcn/ui, Tailwind). + +**Spec:** `docs/superpowers/specs/2026-05-24-discover-studios-networks-genres-design.md` (commit `413cf7b8`). + +**Worktree:** All paths below are relative to the repository root. Commands assume the repository root is the cwd. + +--- + +## File Structure + +### Created files + +| Path | Responsibility | +|---|---| +| `internal/requests/discover_bundle.go` | Bundled `BundledStudio`, `BundledNetwork`, `BundledGenre` constants and slug → entity helpers. | +| `internal/requests/discover_bundle_test.go` | Slug lookup, integrity (no duplicate slugs, all required fields populated). | +| `internal/requests/discover_logo_cache.go` | In-memory 24h cache for TMDB company/network `logo_path`. Singleflight on miss. | +| `internal/requests/discover_logo_cache_test.go` | TTL expiry, miss returns `nil` path, parallel access does not double-fetch. | +| `internal/requests/discover_brand.go` | `ListStudios`, `ListNetworks`, `ListGenres`, `BrowseStudio`, `BrowseNetwork`, `BrowseGenre` service methods + response types. | +| `web/src/components/BrandCard.tsx` | Renders one studio/network/genre card (brand color + TMDB logo or gradient + text). | +| `web/src/components/BrandCarousel.tsx` | Horizontal-scroll carousel of `BrandCard`s. | +| `web/src/pages/RequestBrowse.tsx` | Shared browse page for `/requests/browse/{studio,network,genre}/:slug`. | + +### Modified files + +| Path | What changes | +|---|---| +| `internal/metadata/tmdb/types.go` | Extend `DiscoverParams` with `WithCompanies []int` and `WithNetworks []int`. Add `Company` and `Network` response types. | +| `internal/metadata/tmdb/client.go` | Wire new params into `buildDiscoverQuery`; add `DiscoverPage`, `GetCompany`, `GetNetwork` methods. | +| `internal/metadata/tmdb/client_test.go` | New test cases for `WithCompanies` / `WithNetworks` query construction, `DiscoverPage` happy path, `GetCompany` / `GetNetwork`. | +| `internal/requests/service.go` | Extend the `TMDBClient` interface near the top (add `DiscoverPage`, `GetCompany`, `GetNetwork`). Add `LogoCache` setter. | +| `internal/requests/service_test.go` | Extend `fakeTMDBClient` to satisfy the expanded interface. Add tests for `ListStudios`/`Networks`/`Genres`, `BrowseStudio`/`Network`/`Genre`. | +| `internal/api/handlers/requests.go` | Add 6 handler methods + extend the `RequestService` interface near the top of the file. | +| `internal/api/handlers/requests_test.go` | Tests for the 6 new endpoints. *(Create if it does not exist; otherwise extend.)* | +| `internal/api/router.go` | Wire 6 new routes under `/requests/discover/...`. | +| `web/src/api/types.ts` | Add `DiscoverStudio`, `DiscoverNetwork`, `DiscoverGenre`, `DiscoverBrowseResponse`. | +| `web/src/hooks/queries/keys.ts` | Add `requestKeys.discoverStudios()`, `discoverNetworks()`, `discoverGenres()`, `discoverBrowse(...)`. | +| `web/src/hooks/queries/requests.ts` | Add `useDiscoverStudios`, `useDiscoverNetworks`, `useDiscoverGenres`, `useRequestBrowse`. | +| `web/src/pages/Requests.tsx` | Append three `BrandCarousel` sections after the existing six discovery carousels. | +| `web/src/App.tsx` | Add 3 new routes (`/requests/browse/studio/:slug`, `/network/:slug`, `/genre/:slug`). | + +--- + +## Task 1: TMDB DiscoverParams — add WithCompanies and WithNetworks + +**Files:** +- Modify: `internal/metadata/tmdb/types.go:65-79` (`DiscoverParams` struct) +- Modify: `internal/metadata/tmdb/client.go:513-560` (`buildDiscoverQuery`) +- Test: `internal/metadata/tmdb/client_test.go` (extend existing `TestDiscoverMovieBuildsQuery`-style test) + +- [ ] **Step 1: Write the failing test** + +Add to `internal/metadata/tmdb/client_test.go` at the bottom of the file, just before any helper definitions: + +```go +func TestDiscoverIncludesCompaniesAndNetworks(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if got := q.Get("with_companies"); got != "420,2" { + t.Errorf("with_companies = %q, want 420,2", got) + } + if got := q.Get("with_networks"); got != "213,49" { + t.Errorf("with_networks = %q, want 213,49", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"page":1,"total_pages":1,"total_results":0,"results":[]}`)) + })) + defer server.Close() + + client := NewClient("test-key", 1000) + client.SetBaseURL(server.URL) + + _, err := client.Discover(context.Background(), "movie", DiscoverParams{ + SortBy: "popularity.desc", + WithCompanies: []int{420, 2}, + WithNetworks: []int{213, 49}, + Limit: 5, + }) + if err != nil { + t.Fatalf("Discover returned error: %v", err) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +go test ./internal/metadata/tmdb/ -run TestDiscoverIncludesCompaniesAndNetworks -v +``` + +Expected: FAIL — compile error (`unknown field WithCompanies in struct literal of type DiscoverParams`). + +- [ ] **Step 3: Add fields to DiscoverParams** + +In `internal/metadata/tmdb/types.go`, modify the `DiscoverParams` struct to add two new fields after `WithoutGenres`: + +```go +type DiscoverParams struct { + WithGenres []int + WithoutGenres []int + WithCompanies []int + WithNetworks []int + SortBy string + VoteCountGte int + VoteAverageGte float64 + ReleaseDateGte string + ReleaseDateLte string + Certifications []string + CertificationLte string + WithRuntimeGte int + WithRuntimeLte int + OriginalLanguage string + Limit int +} +``` + +In `internal/metadata/tmdb/client.go`, modify `buildDiscoverQuery` to wire the new fields. After the `WithoutGenres` block (around line 520), insert: + +```go + if companies := joinIntSlice(params.WithCompanies, ","); companies != "" { + values.Set("with_companies", companies) + } + if networks := joinIntSlice(params.WithNetworks, ","); networks != "" { + values.Set("with_networks", networks) + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +go test ./internal/metadata/tmdb/ -run TestDiscoverIncludesCompaniesAndNetworks -v +go test ./internal/metadata/tmdb/ -v +``` + +Expected: PASS for the new test; all other TMDB tests still PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/metadata/tmdb/types.go internal/metadata/tmdb/client.go internal/metadata/tmdb/client_test.go +git commit -m "feat(tmdb): add with_companies and with_networks to discover params" +``` + +--- + +## Task 2: TMDB client — DiscoverPage (returns full MediaPage with sort + page) + +The existing `Client.Discover` method returns `[]CollectionResult` — only `{ID, MediaType, Title}`. The browse endpoints need the full result shape (posters, overviews, vote averages). We add a new `DiscoverPage` method that wraps the same TMDB endpoint but returns `*MediaPage` (with full `MediaResult` items) and exposes single-page semantics (`page` + `sort` directly, no internal limit-based pagination). + +**Files:** +- Modify: `internal/metadata/tmdb/client.go` (add `DiscoverPage` method below `Discover`) +- Test: `internal/metadata/tmdb/client_test.go` (new test cases) + +- [ ] **Step 1: Write the failing test** + +Add to `internal/metadata/tmdb/client_test.go`: + +```go +func TestDiscoverPageMovieReturnsFullResults(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/discover/movie" { + http.NotFound(w, r) + return + } + q := r.URL.Query() + if got := q.Get("sort_by"); got != "popularity.desc" { + t.Errorf("sort_by = %q, want popularity.desc", got) + } + if got := q.Get("with_companies"); got != "420" { + t.Errorf("with_companies = %q, want 420", got) + } + if got := q.Get("page"); got != "2" { + t.Errorf("page = %q, want 2", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "page": 2, + "total_pages": 8, + "total_results": 160, + "results": [ + {"id": 24428, "title": "The Avengers", "release_date": "2012-04-25", "poster_path": "/p.jpg", "overview": "earth's mightiest", "popularity": 100.5, "vote_average": 7.7} + ] + }`)) + })) + defer server.Close() + + client := NewClient("test-key", 1000) + client.SetBaseURL(server.URL) + + page, err := client.DiscoverPage(context.Background(), "movie", DiscoverParams{ + SortBy: "popularity.desc", + WithCompanies: []int{420}, + }, 2) + if err != nil { + t.Fatalf("DiscoverPage: %v", err) + } + if page.Page != 2 || page.TotalPages != 8 || page.TotalResults != 160 { + t.Fatalf("page = %+v", page) + } + if len(page.Results) != 1 { + t.Fatalf("results = %d, want 1", len(page.Results)) + } + got := page.Results[0] + if got.ID != 24428 || got.MediaType != "movie" || got.Title != "The Avengers" || got.Year != 2012 { + t.Errorf("result = %+v", got) + } + if got.PosterPath != "/p.jpg" || got.Overview != "earth's mightiest" { + t.Errorf("result detail mismatch: %+v", got) + } +} + +func TestDiscoverPageTVUsesFirstAirDate(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/discover/tv" { + http.NotFound(w, r) + return + } + q := r.URL.Query() + if got := q.Get("with_networks"); got != "213" { + t.Errorf("with_networks = %q, want 213", got) + } + if got := q.Get("first_air_date.gte"); got != "" { + t.Errorf("first_air_date.gte = %q, want empty", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "page": 1, + "total_pages": 1, + "total_results": 1, + "results": [ + {"id": 1399, "name": "Game of Thrones", "first_air_date": "2011-04-17", "poster_path": "/g.jpg"} + ] + }`)) + })) + defer server.Close() + + client := NewClient("test-key", 1000) + client.SetBaseURL(server.URL) + + page, err := client.DiscoverPage(context.Background(), "tv", DiscoverParams{ + SortBy: "vote_average.desc", + WithNetworks: []int{213}, + }, 1) + if err != nil { + t.Fatalf("DiscoverPage tv: %v", err) + } + if len(page.Results) != 1 { + t.Fatalf("results = %d, want 1", len(page.Results)) + } + got := page.Results[0] + if got.MediaType != "series" || got.Title != "Game of Thrones" || got.Year != 2011 { + t.Errorf("result = %+v", got) + } +} + +func TestDiscoverPageRejectsInvalidMediaType(t *testing.T) { + client := NewClient("test-key", 1000) + _, err := client.DiscoverPage(context.Background(), "all", DiscoverParams{SortBy: "popularity.desc"}, 1) + if err == nil { + t.Fatal("expected error for invalid media type") + } +} + +func TestDiscoverPageRequiresSortBy(t *testing.T) { + client := NewClient("test-key", 1000) + _, err := client.DiscoverPage(context.Background(), "movie", DiscoverParams{}, 1) + if err == nil { + t.Fatal("expected error when sort_by is empty") + } +} + +func TestDiscoverPageDefaultsToPage1(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("page"); got != "1" { + t.Errorf("page = %q, want 1", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"page":1,"total_pages":1,"total_results":0,"results":[]}`)) + })) + defer server.Close() + + client := NewClient("test-key", 1000) + client.SetBaseURL(server.URL) + + if _, err := client.DiscoverPage(context.Background(), "movie", DiscoverParams{SortBy: "popularity.desc"}, 0); err != nil { + t.Fatalf("DiscoverPage: %v", err) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +go test ./internal/metadata/tmdb/ -run TestDiscoverPage -v +``` + +Expected: FAIL — `client.DiscoverPage` undefined. + +- [ ] **Step 3: Implement DiscoverPage** + +Add to `internal/metadata/tmdb/client.go` directly after the existing `Discover` method (after line ~509): + +```go +// DiscoverPage fetches a single page from TMDB's /discover/{movie,tv} endpoint +// and returns the full MediaPage shape (with posters, overviews, etc.) so the +// request system can enrich it with availability and request state. Unlike +// Discover (which is intended for collection templates and returns just IDs +// and titles), DiscoverPage exposes single-page semantics: callers control +// pagination explicitly. +func (c *Client) DiscoverPage(ctx context.Context, mediaType string, params DiscoverParams, page int) (*MediaPage, error) { + switch mediaType { + case "movie", "tv": + default: + return nil, fmt.Errorf("tmdb: invalid media type for discover: %q", mediaType) + } + if strings.TrimSpace(params.SortBy) == "" { + return nil, fmt.Errorf("tmdb: discover requires sort_by") + } + if page <= 0 { + page = 1 + } + + query := buildDiscoverQuery(mediaType, params) + "&page=" + strconv.Itoa(page) + path := "/discover/" + mediaType + "?" + query + + if mediaType == "tv" { + var resp paginatedResponse[mediaTVResponse] + if err := c.doGet(ctx, path, &resp); err != nil { + return nil, err + } + return normalizeTVPage(resp), nil + } + var resp paginatedResponse[mediaMovieResponse] + if err := c.doGet(ctx, path, &resp); err != nil { + return nil, err + } + return normalizeMoviePage(resp), nil +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +go test ./internal/metadata/tmdb/ -v +``` + +Expected: All TMDB tests PASS (including the 5 new `TestDiscoverPage*` tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/metadata/tmdb/client.go internal/metadata/tmdb/client_test.go +git commit -m "feat(tmdb): add DiscoverPage returning full MediaPage with pagination" +``` + +--- + +## Task 3: TMDB client — GetCompany and GetNetwork + +We need to fetch `logo_path` per studio/network. TMDB's `/company/{id}` and `/network/{id}` endpoints return the canonical entity. + +**Files:** +- Modify: `internal/metadata/tmdb/types.go` (add `Company`, `Network` types) +- Modify: `internal/metadata/tmdb/client.go` (add `GetCompany`, `GetNetwork` methods) +- Test: `internal/metadata/tmdb/client_test.go` + +- [ ] **Step 1: Write the failing tests** + +Add to `internal/metadata/tmdb/client_test.go`: + +```go +func TestGetCompany(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/company/420" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":420,"name":"Marvel Studios","logo_path":"/hUze.png"}`)) + })) + defer server.Close() + + client := NewClient("test-key", 1000) + client.SetBaseURL(server.URL) + + company, err := client.GetCompany(context.Background(), 420) + if err != nil { + t.Fatalf("GetCompany: %v", err) + } + if company.ID != 420 || company.Name != "Marvel Studios" || company.LogoPath != "/hUze.png" { + t.Errorf("company = %+v", company) + } +} + +func TestGetCompanyMissingLogoReturnsEmpty(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":420,"name":"Marvel Studios","logo_path":null}`)) + })) + defer server.Close() + + client := NewClient("test-key", 1000) + client.SetBaseURL(server.URL) + + company, err := client.GetCompany(context.Background(), 420) + if err != nil { + t.Fatalf("GetCompany: %v", err) + } + if company.LogoPath != "" { + t.Errorf("logo_path = %q, want empty for null", company.LogoPath) + } +} + +func TestGetNetwork(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/network/213" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":213,"name":"Netflix","logo_path":"/wuU9.png"}`)) + })) + defer server.Close() + + client := NewClient("test-key", 1000) + client.SetBaseURL(server.URL) + + network, err := client.GetNetwork(context.Background(), 213) + if err != nil { + t.Fatalf("GetNetwork: %v", err) + } + if network.ID != 213 || network.Name != "Netflix" || network.LogoPath != "/wuU9.png" { + t.Errorf("network = %+v", network) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +go test ./internal/metadata/tmdb/ -run "TestGetCompany|TestGetNetwork" -v +``` + +Expected: FAIL — `GetCompany` and `GetNetwork` undefined. + +- [ ] **Step 3: Add Company / Network types and methods** + +In `internal/metadata/tmdb/types.go`, append after the existing types: + +```go +// Company is the decoded payload of TMDB's /company/{id} endpoint. +type Company struct { + ID int `json:"id"` + Name string `json:"name"` + LogoPath string `json:"logo_path"` +} + +// Network is the decoded payload of TMDB's /network/{id} endpoint. +type Network struct { + ID int `json:"id"` + Name string `json:"name"` + LogoPath string `json:"logo_path"` +} +``` + +In `internal/metadata/tmdb/client.go`, append after the `DiscoverPage` method (added in Task 2): + +```go +// GetCompany fetches a TMDB company (production studio) by ID. Used to +// resolve logo paths for the bundled discovery studios. +func (c *Client) GetCompany(ctx context.Context, id int) (*Company, error) { + if id <= 0 { + return nil, fmt.Errorf("tmdb: invalid company id: %d", id) + } + var company Company + if err := c.doGet(ctx, fmt.Sprintf("/company/%d", id), &company); err != nil { + return nil, err + } + return &company, nil +} + +// GetNetwork fetches a TMDB TV network by ID. Used to resolve logo paths +// for the bundled discovery networks. +func (c *Client) GetNetwork(ctx context.Context, id int) (*Network, error) { + if id <= 0 { + return nil, fmt.Errorf("tmdb: invalid network id: %d", id) + } + var network Network + if err := c.doGet(ctx, fmt.Sprintf("/network/%d", id), &network); err != nil { + return nil, err + } + return &network, nil +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +go test ./internal/metadata/tmdb/ -v +``` + +Expected: All TMDB tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/metadata/tmdb/types.go internal/metadata/tmdb/client.go internal/metadata/tmdb/client_test.go +git commit -m "feat(tmdb): add GetCompany and GetNetwork for logo path resolution" +``` + +--- + +## Task 4: Bundled discovery registry + +Create the compile-time list of studios, networks, and genres. Each has a slug, display name, TMDB ID(s), and presentation hints (brand color or gradient). + +**Files:** +- Create: `internal/requests/discover_bundle.go` +- Create: `internal/requests/discover_bundle_test.go` + +- [ ] **Step 1: Write the failing tests** + +Create `internal/requests/discover_bundle_test.go`: + +```go +package requests + +import ( + "strings" + "testing" +) + +func TestBundleHasExpectedCounts(t *testing.T) { + if len(BundledStudios) != 10 { + t.Errorf("BundledStudios = %d, want 10", len(BundledStudios)) + } + if len(BundledNetworks) != 10 { + t.Errorf("BundledNetworks = %d, want 10", len(BundledNetworks)) + } + if len(BundledGenres) != 8 { + t.Errorf("BundledGenres = %d, want 8", len(BundledGenres)) + } +} + +func TestBundleStudiosHaveRequiredFields(t *testing.T) { + for _, s := range BundledStudios { + if s.TMDBID <= 0 { + t.Errorf("studio %q missing TMDBID", s.Slug) + } + if strings.TrimSpace(s.Slug) == "" { + t.Errorf("studio %+v missing Slug", s) + } + if strings.TrimSpace(s.DisplayName) == "" { + t.Errorf("studio %q missing DisplayName", s.Slug) + } + if !strings.HasPrefix(s.BrandColor, "#") { + t.Errorf("studio %q BrandColor must start with #, got %q", s.Slug, s.BrandColor) + } + } +} + +func TestBundleNetworksHaveRequiredFields(t *testing.T) { + for _, n := range BundledNetworks { + if n.TMDBID <= 0 { + t.Errorf("network %q missing TMDBID", n.Slug) + } + if strings.TrimSpace(n.Slug) == "" { + t.Errorf("network %+v missing Slug", n) + } + if strings.TrimSpace(n.DisplayName) == "" { + t.Errorf("network %q missing DisplayName", n.Slug) + } + if !strings.HasPrefix(n.BrandColor, "#") { + t.Errorf("network %q BrandColor must start with #, got %q", n.Slug, n.BrandColor) + } + } +} + +func TestBundleGenresHaveRequiredFields(t *testing.T) { + for _, g := range BundledGenres { + if strings.TrimSpace(g.Slug) == "" { + t.Errorf("genre %+v missing Slug", g) + } + if strings.TrimSpace(g.DisplayName) == "" { + t.Errorf("genre %q missing DisplayName", g.Slug) + } + if g.MovieID <= 0 { + t.Errorf("genre %q must have MovieID > 0 in v1", g.Slug) + } + if !strings.HasPrefix(g.GradientFrom, "#") || !strings.HasPrefix(g.GradientTo, "#") { + t.Errorf("genre %q gradient must use # hex, got from=%q to=%q", g.Slug, g.GradientFrom, g.GradientTo) + } + } +} + +func TestBundleSlugsAreUniqueWithinKind(t *testing.T) { + seen := map[string]string{} + for _, s := range BundledStudios { + key := "studio:" + s.Slug + if prior, ok := seen[key]; ok { + t.Errorf("duplicate studio slug %q (also %q)", s.Slug, prior) + } + seen[key] = s.DisplayName + } + for _, n := range BundledNetworks { + key := "network:" + n.Slug + if prior, ok := seen[key]; ok { + t.Errorf("duplicate network slug %q (also %q)", n.Slug, prior) + } + seen[key] = n.DisplayName + } + for _, g := range BundledGenres { + key := "genre:" + g.Slug + if prior, ok := seen[key]; ok { + t.Errorf("duplicate genre slug %q (also %q)", g.Slug, prior) + } + seen[key] = g.DisplayName + } +} + +func TestFindStudioBySlug(t *testing.T) { + got, ok := FindStudioBySlug("marvel-studios") + if !ok { + t.Fatal("expected marvel-studios to exist") + } + if got.DisplayName != "Marvel Studios" { + t.Errorf("display = %q, want Marvel Studios", got.DisplayName) + } + + if _, ok := FindStudioBySlug("not-a-real-studio"); ok { + t.Error("expected unknown slug to return false") + } +} + +func TestFindNetworkBySlug(t *testing.T) { + got, ok := FindNetworkBySlug("netflix") + if !ok { + t.Fatal("expected netflix to exist") + } + if got.DisplayName != "Netflix" { + t.Errorf("display = %q, want Netflix", got.DisplayName) + } +} + +func TestFindGenreBySlug(t *testing.T) { + got, ok := FindGenreBySlug("action") + if !ok { + t.Fatal("expected action to exist") + } + if got.MovieID != 28 { + t.Errorf("movie id = %d, want 28", got.MovieID) + } +} + +func TestGenresWithoutTVEquivalentHaveZeroSeriesID(t *testing.T) { + horror, ok := FindGenreBySlug("horror") + if !ok { + t.Fatal("expected horror to exist") + } + if horror.SeriesID != 0 { + t.Errorf("horror.SeriesID = %d, want 0", horror.SeriesID) + } + romance, ok := FindGenreBySlug("romance") + if !ok { + t.Fatal("expected romance to exist") + } + if romance.SeriesID != 0 { + t.Errorf("romance.SeriesID = %d, want 0", romance.SeriesID) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +go test ./internal/requests/ -run "TestBundle|TestFind|TestGenresWithout" -v +``` + +Expected: FAIL — symbols undefined. + +- [ ] **Step 3: Implement the bundle** + +Create `internal/requests/discover_bundle.go`: + +```go +package requests + +// BundledStudio is a curated movie studio surfaced in the request discover +// section. The TMDB ID identifies the company in /discover/movie?with_companies=. +type BundledStudio struct { + TMDBID int + Slug string + DisplayName string + BrandColor string // hex, used as card background +} + +// BundledNetwork is a curated TV network surfaced in the request discover +// section. The TMDB ID identifies the network in /discover/tv?with_networks=. +type BundledNetwork struct { + TMDBID int + Slug string + DisplayName string + BrandColor string +} + +// BundledGenre is a curated genre. MovieID is the TMDB movie genre ID; +// SeriesID is the TMDB tv genre ID, or 0 when no TV equivalent exists +// (e.g., Horror, Romance — TV has no direct match for these in TMDB). +type BundledGenre struct { + Slug string + DisplayName string + GradientFrom string + GradientTo string + MovieID int + SeriesID int +} + +// BundledStudios is the compile-time list of studios shown in the Studios +// carousel. Order is preservation order — render in this order. +var BundledStudios = []BundledStudio{ + {TMDBID: 2, Slug: "walt-disney-pictures", DisplayName: "Walt Disney Pictures", BrandColor: "#003087"}, + {TMDBID: 3, Slug: "pixar", DisplayName: "Pixar", BrandColor: "#0a85ca"}, + {TMDBID: 420, Slug: "marvel-studios", DisplayName: "Marvel Studios", BrandColor: "#ed1d24"}, + {TMDBID: 1, Slug: "lucasfilm", DisplayName: "Lucasfilm", BrandColor: "#000000"}, + {TMDBID: 174, Slug: "warner-bros-pictures", DisplayName: "Warner Bros. Pictures", BrandColor: "#004c97"}, + {TMDBID: 33, Slug: "universal-pictures", DisplayName: "Universal Pictures", BrandColor: "#1f2a44"}, + {TMDBID: 4, Slug: "paramount-pictures", DisplayName: "Paramount Pictures", BrandColor: "#0066b3"}, + {TMDBID: 5, Slug: "sony-pictures", DisplayName: "Sony Pictures", BrandColor: "#bf2f38"}, + {TMDBID: 25, Slug: "20th-century-studios", DisplayName: "20th Century Studios", BrandColor: "#000000"}, + {TMDBID: 10342, Slug: "studio-ghibli", DisplayName: "Studio Ghibli", BrandColor: "#1a4d2e"}, +} + +// BundledNetworks is the compile-time list of networks shown in the Networks +// carousel. Order is preservation order. +var BundledNetworks = []BundledNetwork{ + {TMDBID: 213, Slug: "netflix", DisplayName: "Netflix", BrandColor: "#e50914"}, + {TMDBID: 2739, Slug: "disney-plus", DisplayName: "Disney+", BrandColor: "#0e3c7d"}, + {TMDBID: 2552, Slug: "apple-tv-plus", DisplayName: "Apple TV+", BrandColor: "#000000"}, + {TMDBID: 49, Slug: "hbo", DisplayName: "HBO", BrandColor: "#000000"}, + {TMDBID: 453, Slug: "hulu", DisplayName: "Hulu", BrandColor: "#1ce783"}, + {TMDBID: 1024, Slug: "amazon-prime-video", DisplayName: "Amazon Prime Video", BrandColor: "#00a8e1"}, + {TMDBID: 3186, Slug: "max", DisplayName: "Max", BrandColor: "#002be7"}, + {TMDBID: 4330, Slug: "paramount-plus", DisplayName: "Paramount+", BrandColor: "#0064ff"}, + {TMDBID: 4, Slug: "bbc", DisplayName: "BBC", BrandColor: "#000000"}, + {TMDBID: 88, Slug: "fx", DisplayName: "FX", BrandColor: "#000000"}, +} + +// BundledGenres is the compile-time list of genres shown in the Genres +// carousel. SeriesID = 0 means the genre has no direct TV equivalent and +// the browse page hides the Series tab. +var BundledGenres = []BundledGenre{ + {Slug: "action", DisplayName: "Action", GradientFrom: "#dc2626", GradientTo: "#7f1d1d", MovieID: 28, SeriesID: 10759}, + {Slug: "comedy", DisplayName: "Comedy", GradientFrom: "#fbbf24", GradientTo: "#b45309", MovieID: 35, SeriesID: 35}, + {Slug: "drama", DisplayName: "Drama", GradientFrom: "#64748b", GradientTo: "#1e293b", MovieID: 18, SeriesID: 18}, + {Slug: "sci-fi", DisplayName: "Sci-Fi", GradientFrom: "#7c3aed", GradientTo: "#312e81", MovieID: 878, SeriesID: 10765}, + {Slug: "horror", DisplayName: "Horror", GradientFrom: "#7f1d1d", GradientTo: "#1f2937", MovieID: 27, SeriesID: 0}, + {Slug: "romance", DisplayName: "Romance", GradientFrom: "#ec4899", GradientTo: "#831843", MovieID: 10749, SeriesID: 0}, + {Slug: "animation", DisplayName: "Animation", GradientFrom: "#06b6d4", GradientTo: "#155e75", MovieID: 16, SeriesID: 16}, + {Slug: "documentary", DisplayName: "Documentary", GradientFrom: "#475569", GradientTo: "#0f172a", MovieID: 99, SeriesID: 99}, +} + +// FindStudioBySlug looks up a bundled studio by slug. Returns (zero, false) +// if not found. +func FindStudioBySlug(slug string) (BundledStudio, bool) { + for _, s := range BundledStudios { + if s.Slug == slug { + return s, true + } + } + return BundledStudio{}, false +} + +// FindNetworkBySlug looks up a bundled network by slug. +func FindNetworkBySlug(slug string) (BundledNetwork, bool) { + for _, n := range BundledNetworks { + if n.Slug == slug { + return n, true + } + } + return BundledNetwork{}, false +} + +// FindGenreBySlug looks up a bundled genre by slug. +func FindGenreBySlug(slug string) (BundledGenre, bool) { + for _, g := range BundledGenres { + if g.Slug == slug { + return g, true + } + } + return BundledGenre{}, false +} +``` + +> **Note on TMDB IDs:** The IDs above are the conventional Overseerr/TMDB defaults for these brands. Verify a small sample manually with `curl "https://api.themoviedb.org/3/company/420?api_key=…"` before merging — if any are wrong, swap the ID and add an `_id_verified` test if helpful. This step does not block test progress because no test asserts a specific TMDB ID. + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +go test ./internal/requests/ -run "TestBundle|TestFind|TestGenresWithout" -v +``` + +Expected: All 8 new bundle tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/requests/discover_bundle.go internal/requests/discover_bundle_test.go +git commit -m "feat(requests): add bundled studio/network/genre registry" +``` + +--- + +## Task 5: Logo cache with TTL and singleflight + +In-memory cache for TMDB `logo_path`. Singleflight (`golang.org/x/sync/singleflight`) deduplicates concurrent misses. + +**Files:** +- Create: `internal/requests/discover_logo_cache.go` +- Create: `internal/requests/discover_logo_cache_test.go` + +- [ ] **Step 1: Check whether singleflight is available** + +```bash +grep -r "golang.org/x/sync/singleflight" go.sum | head -1 +``` + +If found, the dependency is already present (no go.mod change needed). If not, you'll need `go get golang.org/x/sync` — Step 3 covers this. + +- [ ] **Step 2: Write the failing tests** + +Create `internal/requests/discover_logo_cache_test.go`: + +```go +package requests + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" +) + +type fakeLogoLookup struct { + calls atomic.Int64 + logoFor map[int]string + err error +} + +func (f *fakeLogoLookup) Lookup(_ context.Context, id int) (string, error) { + f.calls.Add(1) + if f.err != nil { + return "", f.err + } + return f.logoFor[id], nil +} + +func TestLogoCacheReturnsCachedValueAfterFirstCall(t *testing.T) { + lookup := &fakeLogoLookup{logoFor: map[int]string{420: "/hUze.png"}} + cache := newLogoCache(lookup.Lookup, time.Hour) + + for i := 0; i < 3; i++ { + got, err := cache.Get(context.Background(), 420) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got != "/hUze.png" { + t.Errorf("got %q, want /hUze.png", got) + } + } + if calls := lookup.calls.Load(); calls != 1 { + t.Errorf("upstream calls = %d, want 1", calls) + } +} + +func TestLogoCacheExpiresAfterTTL(t *testing.T) { + lookup := &fakeLogoLookup{logoFor: map[int]string{1: "/a.png"}} + cache := newLogoCache(lookup.Lookup, 10*time.Millisecond) + + if _, err := cache.Get(context.Background(), 1); err != nil { + t.Fatalf("first Get: %v", err) + } + time.Sleep(25 * time.Millisecond) + if _, err := cache.Get(context.Background(), 1); err != nil { + t.Fatalf("second Get: %v", err) + } + if calls := lookup.calls.Load(); calls != 2 { + t.Errorf("calls = %d, want 2", calls) + } +} + +func TestLogoCacheSingleflightDeduplicatesParallelMisses(t *testing.T) { + lookup := &fakeLogoLookup{logoFor: map[int]string{1: "/a.png"}} + cache := newLogoCache(func(ctx context.Context, id int) (string, error) { + time.Sleep(20 * time.Millisecond) + return lookup.Lookup(ctx, id) + }, time.Hour) + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := cache.Get(context.Background(), 1); err != nil { + t.Errorf("Get: %v", err) + } + }() + } + wg.Wait() + if calls := lookup.calls.Load(); calls != 1 { + t.Errorf("calls = %d, want 1 (singleflight should dedupe)", calls) + } +} + +func TestLogoCacheReturnsErrorAndDoesNotCacheFailure(t *testing.T) { + failure := errors.New("upstream boom") + lookup := &fakeLogoLookup{err: failure} + cache := newLogoCache(lookup.Lookup, time.Hour) + + if _, err := cache.Get(context.Background(), 1); !errors.Is(err, failure) { + t.Fatalf("first err = %v, want %v", err, failure) + } + if _, err := cache.Get(context.Background(), 1); !errors.Is(err, failure) { + t.Fatalf("second err = %v, want %v", err, failure) + } + if calls := lookup.calls.Load(); calls != 2 { + t.Errorf("calls = %d, want 2 (errors must not be cached)", calls) + } +} + +func TestLogoCacheEmptyPathIsCachedAsMiss(t *testing.T) { + lookup := &fakeLogoLookup{logoFor: map[int]string{1: ""}} + cache := newLogoCache(lookup.Lookup, time.Hour) + + for i := 0; i < 3; i++ { + got, err := cache.Get(context.Background(), 1) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got != "" { + t.Errorf("got %q, want empty", got) + } + } + if calls := lookup.calls.Load(); calls != 1 { + t.Errorf("calls = %d, want 1 (empty strings should still be cached)", calls) + } +} +``` + +- [ ] **Step 3: Run tests to verify they fail** + +```bash +go test ./internal/requests/ -run TestLogoCache -v +``` + +Expected: FAIL — `newLogoCache` undefined. + +If singleflight is not yet in go.mod (very unlikely, but possible), fetch it now: + +```bash +go get golang.org/x/sync/singleflight +``` + +- [ ] **Step 4: Implement the cache** + +Create `internal/requests/discover_logo_cache.go`: + +```go +package requests + +import ( + "context" + "sync" + "time" + + "golang.org/x/sync/singleflight" +) + +// LogoLookupFunc resolves a TMDB entity ID to a logo path. +type LogoLookupFunc func(ctx context.Context, id int) (string, error) + +type logoCacheEntry struct { + path string + expiresAt time.Time +} + +// logoCache caches TMDB company/network logo paths with a TTL. Concurrent +// misses for the same ID are deduplicated via singleflight so we don't +// hammer TMDB on first-load bursts. +type logoCache struct { + lookup LogoLookupFunc + ttl time.Duration + group singleflight.Group + + mu sync.RWMutex + entries map[int]logoCacheEntry +} + +func newLogoCache(lookup LogoLookupFunc, ttl time.Duration) *logoCache { + return &logoCache{ + lookup: lookup, + ttl: ttl, + entries: map[int]logoCacheEntry{}, + } +} + +// Get returns the cached logo path for id, fetching it via the lookup function +// on miss. Empty strings (TMDB returned no logo) are cached as misses — we +// still avoid refetching them within the TTL window. Errors are not cached. +func (c *logoCache) Get(ctx context.Context, id int) (string, error) { + if cached, ok := c.read(id); ok { + return cached, nil + } + + key := keyFromID(id) + value, err, _ := c.group.Do(key, func() (any, error) { + if cached, ok := c.read(id); ok { + return cached, nil + } + path, err := c.lookup(ctx, id) + if err != nil { + return "", err + } + c.write(id, path) + return path, nil + }) + if err != nil { + return "", err + } + return value.(string), nil +} + +func (c *logoCache) read(id int) (string, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + entry, ok := c.entries[id] + if !ok || time.Now().After(entry.expiresAt) { + return "", false + } + return entry.path, true +} + +func (c *logoCache) write(id int, path string) { + c.mu.Lock() + defer c.mu.Unlock() + c.entries[id] = logoCacheEntry{ + path: path, + expiresAt: time.Now().Add(c.ttl), + } +} + +func keyFromID(id int) string { + return "logo:" + itoaSmall(id) +} + +func itoaSmall(n int) string { + if n == 0 { + return "0" + } + var buf [20]byte + pos := len(buf) + negative := false + if n < 0 { + negative = true + n = -n + } + for n > 0 { + pos-- + buf[pos] = byte('0' + n%10) + n /= 10 + } + if negative { + pos-- + buf[pos] = '-' + } + return string(buf[pos:]) +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +go test ./internal/requests/ -run TestLogoCache -v +``` + +Expected: All 5 logo-cache tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/requests/discover_logo_cache.go internal/requests/discover_logo_cache_test.go +git commit -m "feat(requests): add singleflight logo cache for TMDB company/network logos" +``` + +> Note: this task has 6 steps because of the optional `go get` check. If singleflight was already in go.mod, Step 1 was instant. + +--- + +## Task 6: Extend TMDBClient interface and add ListStudios / ListNetworks / ListGenres + +This task wires the bundle and logo cache into the service. The interface extension lets the service use `DiscoverPage` / `GetCompany` / `GetNetwork` from the TMDB client. + +**Files:** +- Modify: `internal/requests/service.go:14-18` (extend `TMDBClient` interface; add `LogoCache` field; add setter) +- Create: `internal/requests/discover_brand.go` (will hold all 6 List/Browse methods; this task adds List* only) +- Modify: `internal/requests/service_test.go` (extend `fakeTMDBClient`; add tests for List* methods) + +- [ ] **Step 1: Find and read the existing fakeTMDBClient** + +```bash +grep -n "fakeTMDBClient" internal/requests/service_test.go | head -5 +``` + +Look up the struct definition (likely near line 230 or in test helpers). You'll add `DiscoverPage`, `GetCompany`, `GetNetwork` methods to it in Step 4. + +- [ ] **Step 2: Write the failing tests** + +Append to `internal/requests/service_test.go`: + +```go +func TestListStudiosReturnsBundleWithLogos(t *testing.T) { + tmdbClient := &fakeTMDBClient{ + companies: map[int]*tmdb.Company{ + 420: {ID: 420, Name: "Marvel Studios", LogoPath: "/hUze.png"}, + }, + } + service := newTestServiceWithTMDB(newFakeStore(), tmdbClient) + + studios, err := service.ListStudios(context.Background(), testViewer(1)) + if err != nil { + t.Fatalf("ListStudios: %v", err) + } + if len(studios) != len(BundledStudios) { + t.Fatalf("len = %d, want %d", len(studios), len(BundledStudios)) + } + + var marvel *DiscoverBrandCard + for i := range studios { + if studios[i].Slug == "marvel-studios" { + marvel = &studios[i] + break + } + } + if marvel == nil { + t.Fatal("marvel-studios missing from response") + } + if marvel.LogoURL == nil || *marvel.LogoURL == "" { + t.Errorf("expected non-nil logo URL for marvel, got %v", marvel.LogoURL) + } +} + +func TestListStudiosToleratesLogoLookupFailure(t *testing.T) { + tmdbClient := &fakeTMDBClient{ + companies: map[int]*tmdb.Company{}, + companyErr: map[int]error{ + 420: errors.New("tmdb down"), + }, + } + service := newTestServiceWithTMDB(newFakeStore(), tmdbClient) + + studios, err := service.ListStudios(context.Background(), testViewer(1)) + if err != nil { + t.Fatalf("ListStudios should not fail wholesale: %v", err) + } + if len(studios) != len(BundledStudios) { + t.Fatalf("len = %d, want %d", len(studios), len(BundledStudios)) + } + for _, s := range studios { + if s.Slug == "marvel-studios" { + if s.LogoURL != nil { + t.Errorf("expected nil logo URL on failure, got %v", *s.LogoURL) + } + return + } + } + t.Error("marvel-studios missing") +} + +func TestListNetworksReturnsBundle(t *testing.T) { + tmdbClient := &fakeTMDBClient{ + networks: map[int]*tmdb.Network{ + 213: {ID: 213, Name: "Netflix", LogoPath: "/wuU9.png"}, + }, + } + service := newTestServiceWithTMDB(newFakeStore(), tmdbClient) + + networks, err := service.ListNetworks(context.Background(), testViewer(1)) + if err != nil { + t.Fatalf("ListNetworks: %v", err) + } + if len(networks) != len(BundledNetworks) { + t.Fatalf("len = %d, want %d", len(networks), len(BundledNetworks)) + } +} + +func TestListGenresReturnsBundleWithSeriesSupportFlag(t *testing.T) { + service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{}) + + genres, err := service.ListGenres(context.Background(), testViewer(1)) + if err != nil { + t.Fatalf("ListGenres: %v", err) + } + if len(genres) != len(BundledGenres) { + t.Fatalf("len = %d, want %d", len(genres), len(BundledGenres)) + } + for _, g := range genres { + switch g.Slug { + case "action", "comedy", "drama", "sci-fi", "animation", "documentary": + if !g.SeriesSupported { + t.Errorf("%s should support series", g.Slug) + } + case "horror", "romance": + if g.SeriesSupported { + t.Errorf("%s should not support series", g.Slug) + } + } + if g.GradientFrom == "" || g.GradientTo == "" { + t.Errorf("%s missing gradient", g.Slug) + } + if g.LogoURL != nil { + t.Errorf("%s should not have a logo URL", g.Slug) + } + } +} +``` + +- [ ] **Step 3: Run tests to verify they fail** + +```bash +go test ./internal/requests/ -run "TestListStudios|TestListNetworks|TestListGenres" -v +``` + +Expected: FAIL — `service.ListStudios`, `DiscoverBrandCard`, etc. undefined; and `fakeTMDBClient.companies` field unknown. + +- [ ] **Step 4: Extend the TMDBClient interface in service.go** + +In `internal/requests/service.go`, modify the `TMDBClient` interface (lines 14-18) to add three methods: + +```go +type TMDBClient interface { + SearchMedia(ctx context.Context, mediaType, query string, page int) (*tmdb.MediaPage, error) + DiscoverSection(ctx context.Context, section string, page int) (*tmdb.MediaPage, error) + GetMediaDetail(ctx context.Context, mediaType string, id int) (*tmdb.MediaDetail, error) + DiscoverPage(ctx context.Context, mediaType string, params tmdb.DiscoverParams, page int) (*tmdb.MediaPage, error) + GetCompany(ctx context.Context, id int) (*tmdb.Company, error) + GetNetwork(ctx context.Context, id int) (*tmdb.Network, error) +} +``` + +The concrete `*tmdb.Client` already satisfies the expanded interface (Tasks 2 and 3 added those methods). + +Add to the `Service` struct (around line 52-60): + +```go +type Service struct { + store Store + tmdb TMDBClient + presence PresenceResolver + secrets SecretResolver + movieAdapter MovieFulfillmentAdapter + seriesAdapter SeriesFulfillmentAdapter + companyLogos *logoCache + networkLogos *logoCache + Now func() time.Time +} +``` + +Modify `NewService` (around line 71) to initialize the caches: + +```go +func NewService(store Store, tmdbClient TMDBClient, presence PresenceResolver) *Service { + svc := &Service{ + store: store, + tmdb: tmdbClient, + presence: presence, + Now: func() time.Time { return time.Now().UTC() }, + } + svc.companyLogos = newLogoCache(func(ctx context.Context, id int) (string, error) { + company, err := tmdbClient.GetCompany(ctx, id) + if err != nil { + return "", err + } + return company.LogoPath, nil + }, 24*time.Hour) + svc.networkLogos = newLogoCache(func(ctx context.Context, id int) (string, error) { + network, err := tmdbClient.GetNetwork(ctx, id) + if err != nil { + return "", err + } + return network.LogoPath, nil + }, 24*time.Hour) + return svc +} +``` + +- [ ] **Step 5: Extend the fake TMDB client in tests** + +In `internal/requests/service_test.go`, find the `fakeTMDBClient` struct and extend it. Add the fields and methods (locate the struct, then add to its definition and method list): + +```go +type fakeTMDBClient struct { + page *tmdb.MediaPage + detail *tmdb.MediaDetail + externalIDs *tmdb.ExternalIDs + companies map[int]*tmdb.Company + companyErr map[int]error + networks map[int]*tmdb.Network + networkErr map[int]error + discoverPage *tmdb.MediaPage + discoverErr error +} + +// (existing SearchMedia / DiscoverSection / GetMediaDetail / GetExternalIDs methods stay) + +func (f *fakeTMDBClient) DiscoverPage(_ context.Context, _ string, _ tmdb.DiscoverParams, _ int) (*tmdb.MediaPage, error) { + if f.discoverErr != nil { + return nil, f.discoverErr + } + if f.discoverPage != nil { + return f.discoverPage, nil + } + return &tmdb.MediaPage{Results: []tmdb.MediaResult{}}, nil +} + +func (f *fakeTMDBClient) GetCompany(_ context.Context, id int) (*tmdb.Company, error) { + if err, ok := f.companyErr[id]; ok { + return nil, err + } + if c, ok := f.companies[id]; ok { + return c, nil + } + return &tmdb.Company{ID: id}, nil +} + +func (f *fakeTMDBClient) GetNetwork(_ context.Context, id int) (*tmdb.Network, error) { + if err, ok := f.networkErr[id]; ok { + return nil, err + } + if n, ok := f.networks[id]; ok { + return n, nil + } + return &tmdb.Network{ID: id}, nil +} +``` + +> The existing `SearchMedia`, `DiscoverSection`, `GetMediaDetail`, and `GetExternalIDs` methods on `fakeTMDBClient` are untouched. Only add the three new methods and the new struct fields. + +- [ ] **Step 6: Create discover_brand.go with response type and ListStudios/Networks/Genres** + +Create `internal/requests/discover_brand.go`: + +```go +package requests + +import ( + "context" + "fmt" + "log/slog" + + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" +) + +// DiscoverBrandCard is one card on the Studios / Networks / Genres carousels. +// Studios and networks carry a TMDB ID and a brand color + lazy-fetched logo. +// Genres carry no TMDB ID at this layer (they hold two — movie and series IDs — +// in the bundle), and render with a gradient + display name instead of a logo. +type DiscoverBrandCard struct { + TMDBID int `json:"tmdb_id,omitempty"` + Slug string `json:"slug"` + DisplayName string `json:"display_name"` + BrandColor string `json:"brand_color,omitempty"` + LogoURL *string `json:"logo_url,omitempty"` + GradientFrom string `json:"gradient_from,omitempty"` + GradientTo string `json:"gradient_to,omitempty"` + SeriesSupported bool `json:"series_supported,omitempty"` +} + +// ListStudios returns the bundled studios with lazily-fetched logo URLs. +// A failed logo lookup for any individual studio yields a card with +// LogoURL = nil; the response is never failed wholesale. +func (s *Service) ListStudios(ctx context.Context, _ Viewer) ([]DiscoverBrandCard, error) { + if s == nil || s.tmdb == nil { + return nil, fmt.Errorf("request service is not configured") + } + out := make([]DiscoverBrandCard, 0, len(BundledStudios)) + for _, studio := range BundledStudios { + card := DiscoverBrandCard{ + TMDBID: studio.TMDBID, + Slug: studio.Slug, + DisplayName: studio.DisplayName, + BrandColor: studio.BrandColor, + LogoURL: s.resolveLogoURL(ctx, s.companyLogos, studio.TMDBID, "company", studio.Slug), + } + out = append(out, card) + } + return out, nil +} + +// ListNetworks returns the bundled TV networks with lazily-fetched logo URLs. +func (s *Service) ListNetworks(ctx context.Context, _ Viewer) ([]DiscoverBrandCard, error) { + if s == nil || s.tmdb == nil { + return nil, fmt.Errorf("request service is not configured") + } + out := make([]DiscoverBrandCard, 0, len(BundledNetworks)) + for _, network := range BundledNetworks { + card := DiscoverBrandCard{ + TMDBID: network.TMDBID, + Slug: network.Slug, + DisplayName: network.DisplayName, + BrandColor: network.BrandColor, + LogoURL: s.resolveLogoURL(ctx, s.networkLogos, network.TMDBID, "network", network.Slug), + } + out = append(out, card) + } + return out, nil +} + +// ListGenres returns the bundled genres. Each card carries gradient hints +// (no logo URL) and a SeriesSupported flag for the browse page to decide +// whether to show the Series tab. +func (s *Service) ListGenres(_ context.Context, _ Viewer) ([]DiscoverBrandCard, error) { + if s == nil { + return nil, fmt.Errorf("request service is not configured") + } + out := make([]DiscoverBrandCard, 0, len(BundledGenres)) + for _, genre := range BundledGenres { + out = append(out, DiscoverBrandCard{ + Slug: genre.Slug, + DisplayName: genre.DisplayName, + GradientFrom: genre.GradientFrom, + GradientTo: genre.GradientTo, + SeriesSupported: genre.SeriesID > 0, + }) + } + return out, nil +} + +// resolveLogoURL fetches the cached logo path and renders it as a TMDB image +// URL. Returns nil on lookup failure or when TMDB has no logo for the entity. +// "kind" is "company" or "network", used only for log messages. +func (s *Service) resolveLogoURL(ctx context.Context, cache *logoCache, id int, kind, slug string) *string { + if cache == nil { + return nil + } + path, err := cache.Get(ctx, id) + if err != nil { + slog.Warn("requests: logo lookup failed", "kind", kind, "slug", slug, "id", id, "error", err) + return nil + } + if path == "" { + return nil + } + url := tmdbImageURL(path, "w300") + return &url +} + +// tmdbImageURL is the public TMDB image CDN URL for a given file path and size. +// All TMDB poster/logo/backdrop paths begin with "/" and are valid here. +func tmdbImageURL(path, size string) string { + return "https://image.tmdb.org/t/p/" + size + path +} +``` + +If `tmdbImageURL` already exists elsewhere in the codebase, use the existing one and drop the helper here. Check first: + +```bash +grep -rn "image.tmdb.org/t/p" internal/ web/src/ | head +``` + +- [ ] **Step 7: Run tests to verify they pass** + +```bash +go test ./internal/requests/ -v +``` + +Expected: All new tests PASS; existing tests still PASS. + +- [ ] **Step 8: Commit** + +```bash +git add internal/requests/service.go internal/requests/discover_brand.go internal/requests/service_test.go +git commit -m "feat(requests): add ListStudios/ListNetworks/ListGenres with logo cache" +``` + +> This task has 8 steps because the interface extension, struct field, constructor, fake client, and three List methods all interlock. Each step is still 2-5 minutes. + +--- + +## Task 7: BrowseStudio / BrowseNetwork / BrowseGenre service methods + +**Files:** +- Modify: `internal/requests/discover_brand.go` (add three Browse methods + the response type) +- Modify: `internal/requests/service_test.go` (add browse tests) + +- [ ] **Step 1: Write the failing tests** + +Append to `internal/requests/service_test.go`: + +```go +func TestBrowseStudioReturnsEnrichedMovies(t *testing.T) { + tmdbClient := &fakeTMDBClient{discoverPage: &tmdb.MediaPage{ + Page: 1, + TotalPages: 2, + TotalResults: 20, + Results: []tmdb.MediaResult{ + {ID: 24428, MediaType: "movie", Title: "The Avengers", Year: 2012, Popularity: 100.5}, + }, + }} + service := newTestServiceWithTMDB(newFakeStore(), tmdbClient) + + resp, err := service.BrowseStudio(context.Background(), testViewer(1), "marvel-studios", "popularity", 1) + if err != nil { + t.Fatalf("BrowseStudio: %v", err) + } + if resp.Kind != "studio" || resp.Slug != "marvel-studios" || resp.MediaType != MediaTypeMovie { + t.Errorf("resp = %+v", resp) + } + if resp.Page != 1 || resp.TotalPages != 2 { + t.Errorf("pagination = %d/%d", resp.Page, resp.TotalPages) + } + if len(resp.Results) != 1 || resp.Results[0].TMDBID != 24428 { + t.Errorf("results = %+v", resp.Results) + } + if resp.Results[0].Availability == "" { + t.Error("availability should be enriched") + } +} + +func TestBrowseStudioUnknownSlugReturnsNotFound(t *testing.T) { + service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{}) + _, err := service.BrowseStudio(context.Background(), testViewer(1), "not-a-studio", "popularity", 1) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } +} + +func TestBrowseStudioRejectsBadSort(t *testing.T) { + service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{}) + _, err := service.BrowseStudio(context.Background(), testViewer(1), "marvel-studios", "made-up-sort", 1) + if !errors.Is(err, ErrInvalidInput) { + t.Fatalf("err = %v, want ErrInvalidInput", err) + } +} + +func TestBrowseStudioDefaultsBlankSortToPopularity(t *testing.T) { + tmdbClient := &fakeTMDBClient{discoverPage: &tmdb.MediaPage{Results: []tmdb.MediaResult{}}} + service := newTestServiceWithTMDB(newFakeStore(), tmdbClient) + + resp, err := service.BrowseStudio(context.Background(), testViewer(1), "marvel-studios", "", 1) + if err != nil { + t.Fatalf("BrowseStudio: %v", err) + } + if resp.Sort != "popularity" { + t.Errorf("sort = %q, want popularity (default)", resp.Sort) + } +} + +func TestBrowseNetworkReturnsSeries(t *testing.T) { + tmdbClient := &fakeTMDBClient{discoverPage: &tmdb.MediaPage{ + Page: 1, TotalPages: 1, TotalResults: 1, + Results: []tmdb.MediaResult{ + {ID: 1399, MediaType: "series", Title: "Game of Thrones", Year: 2011}, + }, + }} + service := newTestServiceWithTMDB(newFakeStore(), tmdbClient) + + resp, err := service.BrowseNetwork(context.Background(), testViewer(1), "netflix", "popularity", 1) + if err != nil { + t.Fatalf("BrowseNetwork: %v", err) + } + if resp.MediaType != MediaTypeSeries { + t.Errorf("media_type = %q, want series", resp.MediaType) + } +} + +func TestBrowseGenreRequiresMediaType(t *testing.T) { + service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{}) + _, err := service.BrowseGenre(context.Background(), testViewer(1), "action", "", "popularity", 1) + if !errors.Is(err, ErrInvalidInput) { + t.Fatalf("err = %v, want ErrInvalidInput", err) + } +} + +func TestBrowseGenreSeriesRejectedWhenUnsupported(t *testing.T) { + service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{}) + _, err := service.BrowseGenre(context.Background(), testViewer(1), "horror", "series", "popularity", 1) + if !errors.Is(err, ErrInvalidInput) { + t.Fatalf("err = %v, want ErrInvalidInput for horror+series", err) + } +} + +func TestBrowseGenreMovieReturnsResults(t *testing.T) { + tmdbClient := &fakeTMDBClient{discoverPage: &tmdb.MediaPage{ + Results: []tmdb.MediaResult{{ID: 1, MediaType: "movie", Title: "Movie"}}, + }} + service := newTestServiceWithTMDB(newFakeStore(), tmdbClient) + + resp, err := service.BrowseGenre(context.Background(), testViewer(1), "action", "movie", "popularity", 1) + if err != nil { + t.Fatalf("BrowseGenre: %v", err) + } + if resp.Kind != "genre" || resp.Slug != "action" || resp.MediaType != MediaTypeMovie { + t.Errorf("resp = %+v", resp) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +go test ./internal/requests/ -run "TestBrowse" -v +``` + +Expected: FAIL — undefined methods. + +- [ ] **Step 3: Implement the Browse methods** + +Append to `internal/requests/discover_brand.go`: + +```go +// DiscoverBrowseResponse is the shape returned by the browse endpoints. +// Results share the same MediaResult shape as search and the existing +// discovery sections, so the frontend can reuse RequestPosterCard. +type DiscoverBrowseResponse struct { + Kind string `json:"kind"` + Slug string `json:"slug"` + DisplayName string `json:"display_name"` + BrandColor string `json:"brand_color,omitempty"` + LogoURL *string `json:"logo_url,omitempty"` + MediaType MediaType `json:"media_type"` + Sort string `json:"sort"` + Page int `json:"page"` + TotalPages int `json:"total_pages"` + Results []MediaResult `json:"results"` +} + +// Allowed sort tokens at the API surface. Internally each maps to a TMDB +// sort_by value. All variants are descending — no ascending equivalents. +var validBrowseSorts = map[string]string{ + "popularity": "popularity.desc", + "vote_average": "vote_average.desc", + "release_date": "primary_release_date.desc", // overridden to first_air_date.desc for tv +} + +const defaultBrowseSort = "popularity" + +// BrowseStudio returns a page of movies from a bundled studio, enriched with +// Silo availability and request state. Unknown slug → ErrNotFound. Unknown +// sort → ErrInvalidInput. +func (s *Service) BrowseStudio(ctx context.Context, viewer Viewer, slug, sort string, page int) (*DiscoverBrowseResponse, error) { + studio, ok := FindStudioBySlug(slug) + if !ok { + return nil, ErrNotFound + } + tmdbSort, sortKey, err := normalizeBrowseSort(sort, "movie") + if err != nil { + return nil, err + } + tmdbPage, err := s.tmdb.DiscoverPage(ctx, "movie", tmdb.DiscoverParams{ + SortBy: tmdbSort, + WithCompanies: []int{studio.TMDBID}, + VoteCountGte: voteCountFloorForSort(sortKey), + }, page) + if err != nil { + return nil, err + } + enriched, err := s.enrichPage(ctx, viewer, tmdbPage) + if err != nil { + return nil, err + } + return &DiscoverBrowseResponse{ + Kind: "studio", + Slug: studio.Slug, + DisplayName: studio.DisplayName, + BrandColor: studio.BrandColor, + LogoURL: s.resolveLogoURL(ctx, s.companyLogos, studio.TMDBID, "company", studio.Slug), + MediaType: MediaTypeMovie, + Sort: sortKey, + Page: enriched.Page, + TotalPages: enriched.TotalPages, + Results: enriched.Results, + }, nil +} + +// BrowseNetwork returns a page of series from a bundled TV network. +func (s *Service) BrowseNetwork(ctx context.Context, viewer Viewer, slug, sort string, page int) (*DiscoverBrowseResponse, error) { + network, ok := FindNetworkBySlug(slug) + if !ok { + return nil, ErrNotFound + } + tmdbSort, sortKey, err := normalizeBrowseSort(sort, "tv") + if err != nil { + return nil, err + } + tmdbPage, err := s.tmdb.DiscoverPage(ctx, "tv", tmdb.DiscoverParams{ + SortBy: tmdbSort, + WithNetworks: []int{network.TMDBID}, + VoteCountGte: voteCountFloorForSort(sortKey), + }, page) + if err != nil { + return nil, err + } + enriched, err := s.enrichPage(ctx, viewer, tmdbPage) + if err != nil { + return nil, err + } + return &DiscoverBrowseResponse{ + Kind: "network", + Slug: network.Slug, + DisplayName: network.DisplayName, + BrandColor: network.BrandColor, + LogoURL: s.resolveLogoURL(ctx, s.networkLogos, network.TMDBID, "network", network.Slug), + MediaType: MediaTypeSeries, + Sort: sortKey, + Page: enriched.Page, + TotalPages: enriched.TotalPages, + Results: enriched.Results, + }, nil +} + +// BrowseGenre returns a page of movies or series from a bundled genre. +// Requires a media_type. For genres with SeriesID = 0 a media_type=series +// call returns ErrInvalidInput. +func (s *Service) BrowseGenre(ctx context.Context, viewer Viewer, slug string, rawMediaType MediaType, sort string, page int) (*DiscoverBrowseResponse, error) { + genre, ok := FindGenreBySlug(slug) + if !ok { + return nil, ErrNotFound + } + mediaType, err := normalizeMediaType(rawMediaType) + if err != nil { + return nil, fmt.Errorf("%w: media_type is required for genre browse", ErrInvalidInput) + } + var ( + tmdbMediaType string + genreID int + ) + switch mediaType { + case MediaTypeMovie: + tmdbMediaType = "movie" + genreID = genre.MovieID + case MediaTypeSeries: + tmdbMediaType = "tv" + genreID = genre.SeriesID + } + if genreID == 0 { + return nil, fmt.Errorf("%w: %s has no %s equivalent", ErrInvalidInput, slug, mediaType) + } + tmdbSort, sortKey, err := normalizeBrowseSort(sort, tmdbMediaType) + if err != nil { + return nil, err + } + tmdbPage, err := s.tmdb.DiscoverPage(ctx, tmdbMediaType, tmdb.DiscoverParams{ + SortBy: tmdbSort, + WithGenres: []int{genreID}, + VoteCountGte: voteCountFloorForSort(sortKey), + }, page) + if err != nil { + return nil, err + } + enriched, err := s.enrichPage(ctx, viewer, tmdbPage) + if err != nil { + return nil, err + } + return &DiscoverBrowseResponse{ + Kind: "genre", + Slug: genre.Slug, + DisplayName: genre.DisplayName, + MediaType: mediaType, + Sort: sortKey, + Page: enriched.Page, + TotalPages: enriched.TotalPages, + Results: enriched.Results, + }, nil +} + +// normalizeBrowseSort accepts the API sort key, returns the TMDB sort_by +// string, the API-facing sort key (after defaulting), and an error if the +// sort key is unrecognized. TMDB tv discover uses first_air_date instead +// of primary_release_date for release_date sort. +func normalizeBrowseSort(sort, tmdbMediaType string) (string, string, error) { + if sort == "" { + sort = defaultBrowseSort + } + tmdbSort, ok := validBrowseSorts[sort] + if !ok { + return "", "", fmt.Errorf("%w: unknown sort %q", ErrInvalidInput, sort) + } + if sort == "release_date" && tmdbMediaType == "tv" { + tmdbSort = "first_air_date.desc" + } + return tmdbSort, sort, nil +} + +// voteCountFloorForSort applies vote_count.gte=100 to vote_average sorts so +// the top of the list is not dominated by titles with 1 perfect vote. +func voteCountFloorForSort(sortKey string) int { + if sortKey == "vote_average" { + return 100 + } + return 0 +} +``` + +- [ ] **Step 4: Verify ErrNotFound and ErrInvalidInput exist** + +```bash +grep -n "ErrNotFound\|ErrInvalidInput" internal/requests/errors.go +``` + +Both should already exist (used by `GetDetail` and `Discover`). If not, add them to `errors.go`. + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +go test ./internal/requests/ -v +``` + +Expected: All new Browse tests PASS; all existing tests still PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/requests/discover_brand.go internal/requests/service_test.go +git commit -m "feat(requests): add BrowseStudio/BrowseNetwork/BrowseGenre service methods" +``` + +--- + +## Task 8: HTTP handlers for the 6 new endpoints + +**Files:** +- Modify: `internal/api/handlers/requests.go` (extend `RequestService` interface; add 6 handler methods) +- Create or modify: `internal/api/handlers/requests_test.go` — check first: + +```bash +ls internal/api/handlers/requests_test.go 2>/dev/null && echo exists || echo missing +``` + +- [ ] **Step 1: Write the failing tests** + +If `requests_test.go` exists, append. Otherwise create it with the following preamble + tests. Read the existing file first if present to match style and imports. + +```go +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + + mediarequests "github.com/Silo-Server/silo-server/internal/requests" +) + +// fakeRequestService implements the handlers.RequestService interface for +// tests. Each method returns a value plus an error injectable by tests. +type fakeRequestService struct { + listStudiosFn func() ([]mediarequests.DiscoverBrandCard, error) + listNetworksFn func() ([]mediarequests.DiscoverBrandCard, error) + listGenresFn func() ([]mediarequests.DiscoverBrandCard, error) + browseFn func(kind, slug string, mediaType mediarequests.MediaType, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) +} + +// ... only the new methods are implemented here. Tests that need other +// methods (Search, Discover, etc.) construct a separate fake. + +func (f *fakeRequestService) ListStudios(_ context.Context, _ mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error) { + if f.listStudiosFn != nil { + return f.listStudiosFn() + } + return nil, nil +} + +func (f *fakeRequestService) ListNetworks(_ context.Context, _ mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error) { + if f.listNetworksFn != nil { + return f.listNetworksFn() + } + return nil, nil +} + +func (f *fakeRequestService) ListGenres(_ context.Context, _ mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error) { + if f.listGenresFn != nil { + return f.listGenresFn() + } + return nil, nil +} + +func (f *fakeRequestService) BrowseStudio(_ context.Context, _ mediarequests.Viewer, slug, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) { + return f.browseFn("studio", slug, mediarequests.MediaTypeMovie, sort, page) +} + +func (f *fakeRequestService) BrowseNetwork(_ context.Context, _ mediarequests.Viewer, slug, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) { + return f.browseFn("network", slug, mediarequests.MediaTypeSeries, sort, page) +} + +func (f *fakeRequestService) BrowseGenre(_ context.Context, _ mediarequests.Viewer, slug string, mediaType mediarequests.MediaType, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) { + return f.browseFn("genre", slug, mediaType, sort, page) +} + +// All other RequestService methods stubbed to return zero values so the +// fake satisfies the full interface. These should not be exercised by +// the discover/browse tests. +func (f *fakeRequestService) Search(context.Context, mediarequests.Viewer, string, mediarequests.MediaType, int) (*mediarequests.MediaPage, error) { + return nil, nil +} +func (f *fakeRequestService) Discover(context.Context, mediarequests.Viewer, string, int) (*mediarequests.DiscoverySection, error) { + return nil, nil +} +func (f *fakeRequestService) DiscoverAll(context.Context, mediarequests.Viewer) ([]mediarequests.DiscoverySection, error) { + return nil, nil +} +func (f *fakeRequestService) GetDetail(context.Context, mediarequests.Viewer, mediarequests.MediaType, int) (*mediarequests.MediaDetail, error) { + return nil, nil +} +func (f *fakeRequestService) CreateRequest(context.Context, mediarequests.Viewer, mediarequests.CreateRequestInput) (*mediarequests.Request, error) { + return nil, nil +} +func (f *fakeRequestService) ListMine(context.Context, mediarequests.Viewer, mediarequests.ListFilter) ([]*mediarequests.Request, error) { + return nil, nil +} +func (f *fakeRequestService) ListAdmin(context.Context, mediarequests.Viewer, mediarequests.ListFilter) ([]*mediarequests.Request, error) { + return nil, nil +} +func (f *fakeRequestService) GetRequest(context.Context, mediarequests.Viewer, string) (*mediarequests.Request, error) { + return nil, nil +} +func (f *fakeRequestService) Approve(context.Context, mediarequests.Viewer, string) (*mediarequests.Request, error) { + return nil, nil +} +func (f *fakeRequestService) Decline(context.Context, mediarequests.Viewer, string, string) (*mediarequests.Request, error) { + return nil, nil +} +func (f *fakeRequestService) Retry(context.Context, mediarequests.Viewer, string) (*mediarequests.Request, error) { + return nil, nil +} +func (f *fakeRequestService) GetSettings(context.Context, mediarequests.Viewer) (mediarequests.Settings, error) { + return mediarequests.Settings{}, nil +} +func (f *fakeRequestService) UpdateSettings(context.Context, mediarequests.Viewer, mediarequests.Settings) (mediarequests.Settings, error) { + return mediarequests.Settings{}, nil +} +func (f *fakeRequestService) GetUserLimit(context.Context, mediarequests.Viewer, int) (*mediarequests.UserLimit, error) { + return nil, nil +} +func (f *fakeRequestService) UpsertUserLimit(context.Context, mediarequests.Viewer, mediarequests.UserLimit) (*mediarequests.UserLimit, error) { + return nil, nil +} +func (f *fakeRequestService) ListIntegrations(context.Context, mediarequests.Viewer) ([]mediarequests.Integration, error) { + return nil, nil +} +func (f *fakeRequestService) UpsertIntegration(context.Context, mediarequests.Viewer, mediarequests.Integration) (*mediarequests.Integration, error) { + return nil, nil +} +func (f *fakeRequestService) LoadIntegrationOptions(context.Context, mediarequests.Viewer, mediarequests.Integration) (*mediarequests.IntegrationOptions, error) { + return nil, nil +} + +// authedRequest is a tiny helper that injects a viewer context. The exact +// mechanism is project-specific — match whatever the existing request +// tests use. If no test helper exists yet, use the dev-friendly approach +// of adding the viewer to context via apimw.WithViewer or equivalent. +func authedRequest(method, target string) *http.Request { + req := httptest.NewRequest(method, target, nil) + // Inject a viewer the request handler expects. Reference how the existing + // HandleSearch tests do this (if any) — otherwise the simplest path is + // to add a small helper in handlers/testing that sets the same context + // key the production middleware sets. See `requestViewer` in this package. + return req +} + +func TestHandleListStudiosReturnsJSON(t *testing.T) { + logo := "https://image.tmdb.org/t/p/w300/x.png" + svc := &fakeRequestService{ + listStudiosFn: func() ([]mediarequests.DiscoverBrandCard, error) { + return []mediarequests.DiscoverBrandCard{ + {TMDBID: 420, Slug: "marvel-studios", DisplayName: "Marvel Studios", BrandColor: "#ed1d24", LogoURL: &logo}, + }, nil + }, + } + h := NewRequestsHandler(svc) + + rec := httptest.NewRecorder() + h.HandleListStudios(rec, authedRequest("GET", "/api/v1/requests/discover/studios")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var body struct { + Studios []mediarequests.DiscoverBrandCard `json:"studios"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if len(body.Studios) != 1 || body.Studios[0].Slug != "marvel-studios" { + t.Errorf("studios = %+v", body.Studios) + } +} + +func TestHandleBrowseStudioRejectsUnknownSort(t *testing.T) { + svc := &fakeRequestService{ + browseFn: func(kind, slug string, _ mediarequests.MediaType, sort string, _ int) (*mediarequests.DiscoverBrowseResponse, error) { + return nil, mediarequests.ErrInvalidInput + }, + } + h := NewRequestsHandler(svc) + + req := authedRequest("GET", "/api/v1/requests/discover/browse/studio/marvel-studios?sort=garbage") + rctx := chi.NewRouteContext() + rctx.URLParams.Add("slug", "marvel-studios") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + rec := httptest.NewRecorder() + h.HandleBrowseStudio(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } +} + +func TestHandleBrowseStudioUnknownSlugReturns404(t *testing.T) { + svc := &fakeRequestService{ + browseFn: func(string, string, mediarequests.MediaType, string, int) (*mediarequests.DiscoverBrowseResponse, error) { + return nil, mediarequests.ErrNotFound + }, + } + h := NewRequestsHandler(svc) + + req := authedRequest("GET", "/api/v1/requests/discover/browse/studio/ghosts") + rctx := chi.NewRouteContext() + rctx.URLParams.Add("slug", "ghosts") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + rec := httptest.NewRecorder() + h.HandleBrowseStudio(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } +} + +func TestHandleBrowseGenreRequiresMediaType(t *testing.T) { + svc := &fakeRequestService{ + browseFn: func(_ string, _ string, mt mediarequests.MediaType, _ string, _ int) (*mediarequests.DiscoverBrowseResponse, error) { + if strings.TrimSpace(string(mt)) == "" { + return nil, mediarequests.ErrInvalidInput + } + return &mediarequests.DiscoverBrowseResponse{Kind: "genre"}, nil + }, + } + h := NewRequestsHandler(svc) + + req := authedRequest("GET", "/api/v1/requests/discover/browse/genre/action") + rctx := chi.NewRouteContext() + rctx.URLParams.Add("slug", "action") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + rec := httptest.NewRecorder() + h.HandleBrowseGenre(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } +} +``` + +> **Important about `authedRequest`:** the existing request handlers use a `requestViewer` helper that pulls a viewer struct from the request context (look at `internal/api/handlers/requests.go:HandleSearch` and adjacent helpers). Find how *existing* request handler tests stub the viewer (or how middleware injects it) before finishing this step. If there is no existing helper, add a minimal one to the test file that mirrors the middleware contract — usually `apimw.WithProfileContext(...)` or similar. Do not invent a fake context key; reuse the production one. + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +go test ./internal/api/handlers/ -run "TestHandleListStudios|TestHandleBrowse" -v +``` + +Expected: FAIL — handlers undefined; `ErrInvalidInput` / `ErrNotFound` constants are reused from `mediarequests`. + +- [ ] **Step 3: Extend the RequestService interface** + +In `internal/api/handlers/requests.go`, modify the `RequestService` interface (lines 17-38) to add six methods. Keep the existing methods and append: + +```go +type RequestService interface { + Search(ctx context.Context, viewer mediarequests.Viewer, query string, mediaType mediarequests.MediaType, page int) (*mediarequests.MediaPage, error) + Discover(ctx context.Context, viewer mediarequests.Viewer, section string, page int) (*mediarequests.DiscoverySection, error) + DiscoverAll(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverySection, error) + GetDetail(ctx context.Context, viewer mediarequests.Viewer, mediaType mediarequests.MediaType, tmdbID int) (*mediarequests.MediaDetail, error) + CreateRequest(ctx context.Context, viewer mediarequests.Viewer, input mediarequests.CreateRequestInput) (*mediarequests.Request, error) + ListMine(ctx context.Context, viewer mediarequests.Viewer, filter mediarequests.ListFilter) ([]*mediarequests.Request, error) + ListAdmin(ctx context.Context, viewer mediarequests.Viewer, filter mediarequests.ListFilter) ([]*mediarequests.Request, error) + GetRequest(ctx context.Context, viewer mediarequests.Viewer, id string) (*mediarequests.Request, error) + Approve(ctx context.Context, viewer mediarequests.Viewer, id string) (*mediarequests.Request, error) + Decline(ctx context.Context, viewer mediarequests.Viewer, id, reason string) (*mediarequests.Request, error) + Retry(ctx context.Context, viewer mediarequests.Viewer, id string) (*mediarequests.Request, error) + GetSettings(ctx context.Context, viewer mediarequests.Viewer) (mediarequests.Settings, error) + UpdateSettings(ctx context.Context, viewer mediarequests.Viewer, settings mediarequests.Settings) (mediarequests.Settings, error) + GetUserLimit(ctx context.Context, viewer mediarequests.Viewer, userID int) (*mediarequests.UserLimit, error) + UpsertUserLimit(ctx context.Context, viewer mediarequests.Viewer, limit mediarequests.UserLimit) (*mediarequests.UserLimit, error) + ListIntegrations(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.Integration, error) + UpsertIntegration(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error) + LoadIntegrationOptions(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.IntegrationOptions, error) + + ListStudios(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error) + ListNetworks(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error) + ListGenres(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error) + BrowseStudio(ctx context.Context, viewer mediarequests.Viewer, slug, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) + BrowseNetwork(ctx context.Context, viewer mediarequests.Viewer, slug, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) + BrowseGenre(ctx context.Context, viewer mediarequests.Viewer, slug string, mediaType mediarequests.MediaType, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) +} +``` + +- [ ] **Step 4: Implement the six handler methods** + +Append to `internal/api/handlers/requests.go`: + +```go +func (h *RequestsHandler) HandleListStudios(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, true) + if !ok { + return + } + studios, err := h.service.ListStudios(r.Context(), viewer) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, struct { + Studios []mediarequests.DiscoverBrandCard `json:"studios"` + }{Studios: studios}) +} + +func (h *RequestsHandler) HandleListNetworks(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, true) + if !ok { + return + } + networks, err := h.service.ListNetworks(r.Context(), viewer) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, struct { + Networks []mediarequests.DiscoverBrandCard `json:"networks"` + }{Networks: networks}) +} + +func (h *RequestsHandler) HandleListGenres(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, true) + if !ok { + return + } + genres, err := h.service.ListGenres(r.Context(), viewer) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, struct { + Genres []mediarequests.DiscoverBrandCard `json:"genres"` + }{Genres: genres}) +} + +func (h *RequestsHandler) HandleBrowseStudio(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, true) + if !ok { + return + } + page, ok := parsePositiveIntQuery(w, r, "page", 1) + if !ok { + return + } + slug := strings.TrimSpace(chi.URLParam(r, "slug")) + sort := strings.TrimSpace(r.URL.Query().Get("sort")) + resp, err := h.service.BrowseStudio(r.Context(), viewer, slug, sort, page) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, resp) +} + +func (h *RequestsHandler) HandleBrowseNetwork(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, true) + if !ok { + return + } + page, ok := parsePositiveIntQuery(w, r, "page", 1) + if !ok { + return + } + slug := strings.TrimSpace(chi.URLParam(r, "slug")) + sort := strings.TrimSpace(r.URL.Query().Get("sort")) + resp, err := h.service.BrowseNetwork(r.Context(), viewer, slug, sort, page) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, resp) +} + +func (h *RequestsHandler) HandleBrowseGenre(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, true) + if !ok { + return + } + page, ok := parsePositiveIntQuery(w, r, "page", 1) + if !ok { + return + } + slug := strings.TrimSpace(chi.URLParam(r, "slug")) + sort := strings.TrimSpace(r.URL.Query().Get("sort")) + mediaType := mediarequests.MediaType(strings.TrimSpace(r.URL.Query().Get("media_type"))) + resp, err := h.service.BrowseGenre(r.Context(), viewer, slug, mediaType, sort, page) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, resp) +} +``` + +- [ ] **Step 5: Confirm `writeRequestServiceError` maps ErrNotFound → 404 and ErrInvalidInput → 400** + +```bash +grep -n "writeRequestServiceError\|ErrNotFound\|ErrInvalidInput" internal/api/handlers/requests.go | head +``` + +The existing helper should already map these — if not, look for an `errors.Is` block on the existing handlers and confirm it handles the same cases. If it doesn't, extend it now. (The existing `HandleGetDetail` returns 404 for `ErrNotFound`, so the helper almost certainly already maps that.) + +- [ ] **Step 6: Run tests to verify they pass** + +```bash +go test ./internal/api/handlers/ -v +``` + +Expected: All new handler tests PASS; existing tests still PASS. + +- [ ] **Step 7: Commit** + +```bash +git add internal/api/handlers/requests.go internal/api/handlers/requests_test.go +git commit -m "feat(api): add discover brand and browse handlers" +``` + +--- + +## Task 9: Wire the 6 new routes in router.go + +**Files:** +- Modify: `internal/api/router.go:1394-1403` (the existing `requestHandler` route block) + +- [ ] **Step 1: Add the routes** + +In `internal/api/router.go`, find the existing block (around line 1394): + +```go +if requestHandler != nil { + r.Route("/requests", func(r chi.Router) { + r.Get("/search", requestHandler.HandleSearch) + r.Get("/discover", requestHandler.HandleDiscover) + r.Get("/discover/{section}", requestHandler.HandleDiscoverSection) + r.Get("/detail/{media_type}/{tmdb_id}", requestHandler.HandleGetDetail) + r.Post("/", requestHandler.HandleCreate) + r.Get("/mine", requestHandler.HandleListMine) + r.Get("/{id}", requestHandler.HandleGet) + }) +} +``` + +Insert four new routes **before** the catch-all `/{id}` (so chi doesn't bind `studios` / `networks` / `genres` / `browse` to the `{id}` route): + +```go +if requestHandler != nil { + r.Route("/requests", func(r chi.Router) { + r.Get("/search", requestHandler.HandleSearch) + r.Get("/discover", requestHandler.HandleDiscover) + r.Get("/discover/studios", requestHandler.HandleListStudios) + r.Get("/discover/networks", requestHandler.HandleListNetworks) + r.Get("/discover/genres", requestHandler.HandleListGenres) + r.Get("/discover/browse/studio/{slug}", requestHandler.HandleBrowseStudio) + r.Get("/discover/browse/network/{slug}", requestHandler.HandleBrowseNetwork) + r.Get("/discover/browse/genre/{slug}", requestHandler.HandleBrowseGenre) + r.Get("/discover/{section}", requestHandler.HandleDiscoverSection) + r.Get("/detail/{media_type}/{tmdb_id}", requestHandler.HandleGetDetail) + r.Post("/", requestHandler.HandleCreate) + r.Get("/mine", requestHandler.HandleListMine) + r.Get("/{id}", requestHandler.HandleGet) + }) +} +``` + +> **Route ordering matters in chi:** `/discover/studios` must come *before* `/discover/{section}` so the literal route wins. Same for the browse routes vs the trailing catch-all `/{id}`. + +- [ ] **Step 2: Build to confirm the router compiles** + +```bash +go build ./... +``` + +Expected: clean build. + +- [ ] **Step 3: Run the full request-system test suite** + +```bash +go test ./internal/requests/... ./internal/api/... ./internal/metadata/tmdb/... +``` + +Expected: all PASS. + +- [ ] **Step 4: Commit** + +```bash +git add internal/api/router.go +git commit -m "feat(api): wire discover studios/networks/genres routes" +``` + +--- + +## Task 10: Backend smoke test (manual) + +**Files:** none modified. + +- [ ] **Step 1: Start the backend in dev mode** + +```bash +make dev-backend +``` + +In another terminal (or the same one once it's daemonized), capture a request token following whatever the local dev login flow looks like (the worktree should already be configured with `make dev-*` working). + +- [ ] **Step 2: Probe the three list endpoints** + +```bash +curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/requests/discover/studios | jq '.studios | length' +curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/requests/discover/networks | jq '.networks | length' +curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/requests/discover/genres | jq '.genres | length' +``` + +Expected: 10, 10, 8. + +Inspect a single studio entry: + +```bash +curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/requests/discover/studios | jq '.studios[0]' +``` + +Expected: a card with non-empty `logo_url` after the first call resolves it. + +- [ ] **Step 3: Probe a browse endpoint** + +```bash +curl -s -H "Authorization: Bearer $TOKEN" "http://localhost:8080/api/v1/requests/discover/browse/studio/marvel-studios?page=1&sort=popularity" | jq '.results | length, .page, .total_pages' +``` + +Expected: non-zero result count; page=1; total_pages > 1. + +- [ ] **Step 4: Probe genre browse with each media type and an unsupported series case** + +```bash +curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $TOKEN" "http://localhost:8080/api/v1/requests/discover/browse/genre/action?media_type=movie" +curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $TOKEN" "http://localhost:8080/api/v1/requests/discover/browse/genre/action?media_type=series" +curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $TOKEN" "http://localhost:8080/api/v1/requests/discover/browse/genre/horror?media_type=series" +curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $TOKEN" "http://localhost:8080/api/v1/requests/discover/browse/studio/not-real-slug" +``` + +Expected: 200, 200, 400, 404. + +- [ ] **Step 5: Stop the dev backend** + +```bash +# Use whatever mechanism `make dev-backend` uses to stop (Ctrl-C in its tmux pane, etc.). +``` + +No commit for this step — manual verification only. + +--- + +## Task 11: Frontend types + +**Files:** +- Modify: `web/src/api/types.ts` + +- [ ] **Step 1: Add the new types** + +Append to `web/src/api/types.ts` (find an appropriate location near the existing request types — likely after `RequestMediaResult` / `RequestMediaPage`): + +```typescript +export interface DiscoverBrandCard { + tmdb_id?: number; + slug: string; + display_name: string; + brand_color?: string; + logo_url?: string | null; + gradient_from?: string; + gradient_to?: string; + series_supported?: boolean; +} + +export interface DiscoverStudiosResponse { + studios: DiscoverBrandCard[]; +} + +export interface DiscoverNetworksResponse { + networks: DiscoverBrandCard[]; +} + +export interface DiscoverGenresResponse { + genres: DiscoverBrandCard[]; +} + +export type DiscoverBrowseKind = "studio" | "network" | "genre"; + +export interface DiscoverBrowseResponse { + kind: DiscoverBrowseKind; + slug: string; + display_name: string; + brand_color?: string; + logo_url?: string | null; + media_type: RequestMediaType; + sort: "popularity" | "vote_average" | "release_date"; + page: number; + total_pages: number; + results: RequestMediaResult[]; +} +``` + +Make sure `RequestMediaType` and `RequestMediaResult` are already exported from this file (they should be — they're used by the existing search/discover surface). + +- [ ] **Step 2: Verify types compile** + +```bash +cd web && pnpm tsc --noEmit +``` + +Expected: no errors. If the existing `RequestMediaType` is not exported, export it now (small one-line change at its definition). + +- [ ] **Step 3: Commit** + +```bash +git add web/src/api/types.ts +git commit -m "feat(web): add discover brand and browse response types" +``` + +--- + +## Task 12: Frontend query keys and hooks + +**Files:** +- Modify: `web/src/hooks/queries/keys.ts` +- Modify: `web/src/hooks/queries/requests.ts` + +- [ ] **Step 1: Inspect existing requestKeys** + +```bash +grep -n "requestKeys" web/src/hooks/queries/keys.ts +``` + +Read the section that defines `requestKeys` so the new helpers match style. + +- [ ] **Step 2: Add the new key builders** + +In `web/src/hooks/queries/keys.ts`, extend the `requestKeys` object to include: + +```typescript +discoverStudios: () => [...requestKeys.all, "discover", "studios"] as const, +discoverNetworks: () => [...requestKeys.all, "discover", "networks"] as const, +discoverGenres: () => [...requestKeys.all, "discover", "genres"] as const, +discoverBrowse: ( + kind: "studio" | "network" | "genre", + slug: string, + mediaType: string | undefined, + sort: string, + page: number, +) => + [ + ...requestKeys.all, + "discover", + "browse", + kind, + slug, + mediaType ?? "", + sort, + page, + ] as const, +``` + +Match the style of the existing entries — they likely use the `[...requestKeys.all, "..."] as const` pattern. + +- [ ] **Step 3: Add the new hooks** + +In `web/src/hooks/queries/requests.ts`, append after the existing `useRequestDiscovery` hook: + +```typescript +const DISCOVER_BRAND_STALE_TIME = 24 * 60 * 60 * 1000; // 24h, matches server cache +const BROWSE_STALE_TIME = 60 * 1000; // 60s, keeps request state fresh + +export function useDiscoverStudios() { + return useQuery({ + queryKey: requestKeys.discoverStudios(), + queryFn: () => + api("/requests/discover/studios").then((data) => data.studios ?? []), + staleTime: DISCOVER_BRAND_STALE_TIME, + }); +} + +export function useDiscoverNetworks() { + return useQuery({ + queryKey: requestKeys.discoverNetworks(), + queryFn: () => + api("/requests/discover/networks").then( + (data) => data.networks ?? [], + ), + staleTime: DISCOVER_BRAND_STALE_TIME, + }); +} + +export function useDiscoverGenres() { + return useQuery({ + queryKey: requestKeys.discoverGenres(), + queryFn: () => + api("/requests/discover/genres").then((data) => data.genres ?? []), + staleTime: DISCOVER_BRAND_STALE_TIME, + }); +} + +export interface UseRequestBrowseArgs { + kind: DiscoverBrowseKind; + slug: string; + mediaType?: RequestMediaType; + sort: "popularity" | "vote_average" | "release_date"; + page: number; +} + +export function useRequestBrowse({ kind, slug, mediaType, sort, page }: UseRequestBrowseArgs) { + return useQuery({ + queryKey: requestKeys.discoverBrowse(kind, slug, mediaType, sort, page), + queryFn: () => { + const params = new URLSearchParams({ sort, page: String(page) }); + if (mediaType) params.set("media_type", mediaType); + return api( + `/requests/discover/browse/${kind}/${encodeURIComponent(slug)}?${params}`, + ); + }, + enabled: slug.trim().length > 0 && (kind !== "genre" || Boolean(mediaType)), + staleTime: BROWSE_STALE_TIME, + }); +} +``` + +Update the imports at the top of the file to include the new types: + +```typescript +import type { + // ...existing imports... + DiscoverBrandCard, + DiscoverBrowseKind, + DiscoverBrowseResponse, + DiscoverGenresResponse, + DiscoverNetworksResponse, + DiscoverStudiosResponse, + RequestMediaType, +} from "@/api/types"; +``` + +- [ ] **Step 4: Verify types compile** + +```bash +cd web && pnpm tsc --noEmit +``` + +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/hooks/queries/keys.ts web/src/hooks/queries/requests.ts +git commit -m "feat(web): add discover brand and browse query hooks" +``` + +--- + +## Task 13: BrandCard and BrandCarousel components + +**Files:** +- Create: `web/src/components/BrandCard.tsx` +- Create: `web/src/components/BrandCarousel.tsx` + +- [ ] **Step 1: Build BrandCard** + +Create `web/src/components/BrandCard.tsx`: + +```typescript +import { useNavigate } from "react-router"; +import type { DiscoverBrandCard, DiscoverBrowseKind } from "@/api/types"; +import { cn } from "@/lib/utils"; + +interface BrandCardProps { + kind: DiscoverBrowseKind; + card: DiscoverBrandCard; + defaultMediaTypeForGenre?: "movie" | "series"; +} + +export default function BrandCard({ kind, card, defaultMediaTypeForGenre = "movie" }: BrandCardProps) { + const navigate = useNavigate(); + + function handleClick() { + const base = `/requests/browse/${kind}/${encodeURIComponent(card.slug)}`; + if (kind === "genre") { + const initial = card.series_supported && defaultMediaTypeForGenre === "series" ? "series" : "movie"; + navigate(`${base}?media_type=${initial}`); + } else { + navigate(base); + } + } + + const isGenre = kind === "genre"; + const background = isGenre + ? `linear-gradient(135deg, ${card.gradient_from ?? "#475569"}, ${card.gradient_to ?? "#0f172a"})` + : card.brand_color || "#1f2937"; + + return ( + + ); +} +``` + +- [ ] **Step 2: Build BrandCarousel** + +Create `web/src/components/BrandCarousel.tsx`: + +```typescript +import type { DiscoverBrandCard, DiscoverBrowseKind } from "@/api/types"; +import { Skeleton } from "@/components/ui/skeleton"; +import BrandCard from "./BrandCard"; + +interface BrandCarouselProps { + kind: DiscoverBrowseKind; + title: string; + cards: DiscoverBrandCard[] | undefined; + isLoading: boolean; + isError: boolean; + onRetry?: () => void; +} + +export default function BrandCarousel({ + kind, + title, + cards, + isLoading, + isError, + onRetry, +}: BrandCarouselProps) { + return ( +
+
+

+ {title} +

+ {isError && onRetry && ( + + )} +
+
+
+ {isLoading + ? Array.from({ length: 8 }).map((_, idx) => ( + + )) + : isError + ? ( +
+ Could not load {title.toLowerCase()}. +
+ ) + : (cards ?? []).map((card) => ( + + ))} +
+
+
+ ); +} +``` + +- [ ] **Step 3: Verify types compile** + +```bash +cd web && pnpm tsc --noEmit +``` + +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add web/src/components/BrandCard.tsx web/src/components/BrandCarousel.tsx +git commit -m "feat(web): add BrandCard and BrandCarousel components" +``` + +--- + +## Task 14: Append Studios, Networks, Genres carousels to Requests.tsx + +**Files:** +- Modify: `web/src/pages/Requests.tsx` + +- [ ] **Step 1: Wire the new hooks at the top of the component** + +In `web/src/pages/Requests.tsx`, near the existing hook calls (around line 122): + +```typescript +const discovery = useRequestDiscovery(); +const studios = useDiscoverStudios(); +const networks = useDiscoverNetworks(); +const genres = useDiscoverGenres(); +const search = useRequestSearch(mediaType, submittedQuery, searchPage); +// ... +``` + +Update the import block at the top of the file to add the three new hooks: + +```typescript +import { + useCreateMediaRequest, + useDiscoverGenres, + useDiscoverNetworks, + useDiscoverStudios, + useMyMediaRequests, + useRequestDiscovery, + useRequestSearch, +} from "@/hooks/queries/requests"; +import BrandCarousel from "@/components/BrandCarousel"; +``` + +- [ ] **Step 2: Render the three new sections after the existing discovery carousels** + +Find the discovery rendering block (around line 205, inside `TabsContent value="discover"`). After the existing `.map` that renders `DiscoverySectionRow` for each section, append three `BrandCarousel` instances: + +```tsx +{(discovery.data ?? []).map((section) => ( + +))} + studios.refetch()} +/> + networks.refetch()} +/> + genres.refetch()} +/> +``` + +- [ ] **Step 3: Verify the page compiles and renders** + +```bash +cd web && pnpm tsc --noEmit +cd web && pnpm run lint +``` + +Expected: no type errors, no new lint warnings. + +- [ ] **Step 4: Commit** + +```bash +git add web/src/pages/Requests.tsx +git commit -m "feat(web): render studios/networks/genres carousels in Requests discover" +``` + +--- + +## Task 15: RequestBrowse page + +**Files:** +- Create: `web/src/pages/RequestBrowse.tsx` + +- [ ] **Step 1: Build the page** + +Create `web/src/pages/RequestBrowse.tsx`: + +```typescript +import { Link, useParams, useSearchParams } from "react-router"; +import { ArrowLeft } from "lucide-react"; +import RequestPosterCard from "@/components/RequestPosterCard"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useDocumentTitle } from "@/hooks/useDocumentTitle"; +import { + useCreateMediaRequest, + useRequestBrowse, +} from "@/hooks/queries/requests"; +import { requestInputFromMediaResult } from "@/lib/mediaRequests"; +import type { DiscoverBrowseKind, RequestMediaResult, RequestMediaType } from "@/api/types"; +import { cn } from "@/lib/utils"; + +const SORT_OPTIONS: { value: "popularity" | "vote_average" | "release_date"; label: string }[] = [ + { value: "popularity", label: "Popularity" }, + { value: "vote_average", label: "Rating" }, + { value: "release_date", label: "Release date" }, +]; + +interface RequestBrowseProps { + kind: DiscoverBrowseKind; +} + +export default function RequestBrowse({ kind }: RequestBrowseProps) { + const { slug = "" } = useParams<{ slug: string }>(); + const [searchParams, setSearchParams] = useSearchParams(); + + const sort = (searchParams.get("sort") ?? "popularity") as + | "popularity" + | "vote_average" + | "release_date"; + const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1); + + // Genre routes drive media_type from the URL; studio is always movie, + // network is always series. + const mediaTypeFromQuery = (searchParams.get("media_type") as RequestMediaType | null) ?? undefined; + const mediaType: RequestMediaType | undefined = + kind === "studio" ? "movie" : kind === "network" ? "series" : mediaTypeFromQuery ?? "movie"; + + const browse = useRequestBrowse({ kind, slug, mediaType, sort, page }); + const createRequest = useCreateMediaRequest(); + + const title = browse.data?.display_name ?? slug; + useDocumentTitle(title ? `${title} – Requests` : "Requests"); + + function updateSort(next: string) { + const params = new URLSearchParams(searchParams); + params.set("sort", next); + params.set("page", "1"); + setSearchParams(params, { replace: true }); + } + + function updateMediaType(next: RequestMediaType) { + const params = new URLSearchParams(searchParams); + params.set("media_type", next); + params.set("page", "1"); + setSearchParams(params, { replace: true }); + } + + function goToPage(next: number) { + const params = new URLSearchParams(searchParams); + params.set("page", String(next)); + setSearchParams(params, { replace: false }); + window.scrollTo({ top: 0, behavior: "smooth" }); + } + + function submitRequest(item: RequestMediaResult) { + createRequest.mutate(requestInputFromMediaResult(item)); + } + + // We can't know from the browse response alone whether series is supported + // for this genre (the browse endpoint only returns the data for the active + // media_type). The carousel hides the click-through to series for + // unsupported genres, so if a user lands here with media_type=series the + // server already returned 400. We optimistically render both tabs and let + // the backend 400 if a user manually edits the URL to ?media_type=series + // for a genre that doesn't support it. v2 idea: pass series_supported + // through the browse response so we can hide the tab proactively. + + const totalPages = browse.data?.total_pages ?? 0; + const results = browse.data?.results ?? []; + + if (browse.isError) { + // Surface 404s differently from generic failures. + const status = (browse.error as { status?: number } | undefined)?.status; + if (status === 404) { + return ( +
+

+ {kind === "studio" ? "Studio" : kind === "network" ? "Network" : "Genre"} not found. +

+ + Back to Requests + +
+ ); + } + } + + return ( +
+
+ + Back to Requests + +
+
+ +
+

{title}

+

+ {browse.isLoading + ? "Loading…" + : results.length > 0 + ? `Page ${page} of ${totalPages}` + : "No results."} +

+
+
+ +
+ + {kind === "genre" && ( + updateMediaType(value as RequestMediaType)} + > + + Movies + Series + + + )} +
+ +
+ {browse.isLoading ? ( +
+ {Array.from({ length: 12 }).map((_, idx) => ( + + ))} +
+ ) : results.length === 0 ? ( +

+ Nothing matched — try a different sort. +

+ ) : ( +
+ {results.map((item) => ( + submitRequest(item)} + isSubmitting={createRequest.isPending && createRequest.variables?.tmdb_id === item.tmdb_id} + /> + ))} +
+ )} +
+ + {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} +
+ ); +} + +function BrowseHeaderTile({ + browse, + kind, + fallback, +}: { + browse: DiscoverBrowseHeader | undefined; + kind: DiscoverBrowseKind; + fallback: string; +}) { + if (!browse) { + return ( +
+ ); + } + if (kind === "genre") { + // Genre browse responses do not carry gradient hints (gradients live on + // the bundle, returned only by the list endpoints). Use a neutral + // background for the header tile here. The carousel card on /requests + // shows the gradient. + return ( +
+ {browse.display_name || fallback} +
+ ); + } + return ( +
+ {browse.logo_url ? ( + {browse.display_name} + ) : ( + + {browse.display_name || fallback} + + )} +
+ ); +} + +type DiscoverBrowseHeader = { + brand_color?: string; + logo_url?: string | null; + display_name?: string; +}; +``` + +> The browse response does not carry `gradient_from` / `gradient_to` (those live on `DiscoverBrandCard` and are returned only by the list endpoints in Task 6). The genre browse header tile uses a neutral background; the carousel card on `/requests` is where the gradient shows. This keeps the response shapes consistent with the spec. + +- [ ] **Step 2: Verify types compile** + +```bash +cd web && pnpm tsc --noEmit +``` + +Expected: no type errors. + +- [ ] **Step 3: Commit** + +```bash +git add web/src/pages/RequestBrowse.tsx +git commit -m "feat(web): add RequestBrowse page for studio/network/genre browse" +``` + +--- + +## Task 16: Wire the 3 new routes in App.tsx + +**Files:** +- Modify: `web/src/App.tsx` + +- [ ] **Step 1: Import the new page** + +In `web/src/App.tsx`, add to the imports near `import RequestDetail from "@/pages/RequestDetail";` (line 36): + +```typescript +import RequestBrowse from "@/pages/RequestBrowse"; +``` + +- [ ] **Step 2: Add the three routes** + +In the route definitions, find the line for `/requests/:mediaType/:tmdbId` (around line 452) and insert three new routes after it: + +```tsx +} /> +} /> +} /> +} /> +} /> +``` + +> Order: place the browse routes *after* the existing `:mediaType/:tmdbId` route. Since `browse` is a literal path segment, React Router resolves the literal match first regardless of order, but keeping related routes together helps readability. + +- [ ] **Step 3: Verify the app builds** + +```bash +cd web && pnpm tsc --noEmit +cd web && pnpm run build +``` + +Expected: clean build. + +- [ ] **Step 4: Commit** + +```bash +git add web/src/App.tsx +git commit -m "feat(web): wire studio/network/genre browse routes" +``` + +--- + +## Task 17: Lint and full manual verification + +**Files:** none modified. + +- [ ] **Step 1: Run all linters** + +```bash +make lint +cd web && pnpm run lint +cd web && pnpm run format:check +``` + +Expected: all PASS. If `format:check` reports diffs, run `pnpm run format` then re-stage and verify. + +- [ ] **Step 2: Run the full Go test suite** + +```bash +go test ./internal/requests/... ./internal/api/... ./internal/metadata/tmdb/... +``` + +Expected: all PASS. + +- [ ] **Step 3: Manual verification in the browser** + +```bash +make dev-backend # in one terminal +make dev-frontend # in another +``` + +In a browser, navigate to `http://localhost:5173/requests`. Verify: + +- Three new carousels appear below the existing six. +- Studio cards show logos after the first 1-2 seconds (lazy fetch from TMDB). +- Genre cards show gradient + name. +- Clicking a studio card navigates to the browse page; results appear; sort dropdown works; pagination Prev/Next works. +- Clicking a genre card navigates to the genre browse page; Movies tab is selected by default; Series tab is hidden for Horror / Romance. +- An unknown slug (e.g., `/requests/browse/studio/garbage`) shows the "not found" message. + +- [ ] **Step 4: If any cosmetic issue surfaces during manual verification, fix it, run lint, and amend the most recent commit OR create a follow-up commit** + +If creating a follow-up commit: + +```bash +git add +git commit -m "fix(web): " +``` + +- [ ] **Step 5: Final repo state check** + +```bash +git log --oneline -20 +git status +``` + +Expected: working tree clean; recent commits trace the implementation path. Confirm no stray `console.log`, debug prints, or temp files. + +No commit for this task — it's all verification. + +--- + +## Self-Review Notes + +These were checked while writing the plan; resolved inline. + +**Spec coverage:** +- All six service methods (List* + Browse*) → Tasks 6, 7 +- All six handlers + routes → Tasks 8, 9 +- TMDB client extensions (params + Discover + Get*) → Tasks 1, 2, 3 +- Bundle + logo cache → Tasks 4, 5 +- Frontend carousels, page, hooks, types, routes → Tasks 11-16 +- Lint/manual verification → Task 17 + +**Type consistency:** +- `DiscoverBrandCard` and `DiscoverBrowseResponse` field names match across Go (`internal/requests/discover_brand.go`) and TypeScript (`web/src/api/types.ts`). +- Service method signatures match handler interface signatures (Task 8 Step 3). +- The `RequestService` interface in the handler file is the single source of truth for what the service must implement. + +**Edge cases covered by tests:** +- Logo lookup failure → Task 6 test `TestListStudiosToleratesLogoLookupFailure` +- Genre without series → Tasks 4, 7 (`TestBrowseGenreSeriesRejectedWhenUnsupported`) +- Unknown slug → Task 7 (`TestBrowseStudioUnknownSlugReturnsNotFound`) +- Unknown sort → Task 7 (`TestBrowseStudioRejectsBadSort`) +- Missing media_type on genre → Task 7 (`TestBrowseGenreRequiresMediaType`) +- Logo cache TTL + singleflight + empty path → Task 5 + +**Known caveats deferred to runtime:** +- Bundled TMDB IDs are conventional defaults; verify a sample of them manually against `/company/{id}` and `/network/{id}` during Task 4 / Task 10 if uncertain. Adjust the constants if any are wrong — no test asserts a specific TMDB ID. +- The `BrowseHeaderTile` for genre browse uses a neutral background (not a gradient); the gradient appears on the carousel card on `/requests` where the bundle data flows through. +- **Browse response caching:** the spec mentions a 15-min "existing TMDB response cache wrapper" for browse results, but no such wrapper currently exists in the codebase (each `client.DiscoverSection` / `client.DiscoverPage` call hits TMDB directly). For v1 we lean on (a) react-query's 60s stale time on the frontend (Task 12), (b) TMDB's `retryAfterOrDefault` backoff for rate-limit recovery (existing in `tmdb/client.go`). If sustained load surfaces an issue, add a server-side LRU + TTL in front of `DiscoverPage` as a follow-up. This deviation is intentional and documented; do not block v1 on adding the wrapper. +- The Series tab on the genre browse page is always rendered (Task 15). For genres without TV equivalents (Horror, Romance), the carousel cards on `/requests` link to the movie tab by default, and the backend returns 400 for `media_type=series` — surfacing as an error toast if a user manually edits the URL. A future improvement could propagate `series_supported` through the browse response to hide the Series tab proactively. diff --git a/docs/superpowers/plans/2026-05-25-provider-id-availability-backfill.md b/docs/superpowers/plans/2026-05-25-provider-id-availability-backfill.md new file mode 100644 index 00000000..bb6e1adf --- /dev/null +++ b/docs/superpowers/plans/2026-05-25-provider-id-availability-backfill.md @@ -0,0 +1,1459 @@ +# Provider ID Availability Backfill 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:** Make request availability and catalog matching work when local items are missing TMDB IDs, while repairing those missing TMDB IDs when a confident TVDB/IMDb cross-reference is found. + +**Architecture:** Preserve TMDB as the public request identifier, but make internal presence checks provider-aware by hydrating TMDB results with external IDs and matching local catalog rows by TMDB, TVDB, or IMDb. Fix metadata candidate normalization so compatible provider results union their IDs instead of discarding richer candidates, then use existing refresh paths to repair already-matched catalog items. Add a small sibling plugin fix so TVDB title search emits remote IDs it already receives from TVDB. + +**Tech Stack:** Go, PostgreSQL/pgx, existing Silo metadata/request/adminjob packages, TMDB client external ID endpoint, TVDB plugin provider package. + +--- + +## Validated Findings + +Commands assume the repository root is the cwd unless a task explicitly says to run from a sibling repository. + +- Dev item `120983767174086659` is `The Rookie: Feds`, `type=series`, `status=matched`, with `tvdb_id=420105`, `imdb_id=tt18076310`, and empty `tmdb_id`. +- The corresponding `media_item_provider_ids` rows contain only `tvdb=420105` and `imdb=tt18076310`. +- Live TMDB resolves both `tvdb=420105` and `imdb=tt18076310` to TMDB TV ID `201992`. +- Live TVDB extended series data for `420105` includes a TMDB remote ID of `201992`. +- The request service currently checks availability with only `tmdb_id` through `requests.PresenceResolver.LookupTMDB`. +- `CreateRequest` checks availability before calling `enrichExternalIDs`, so a request can be created for an item already present locally by TVDB/IMDb. +- Metadata candidate normalization groups only exact provider ID fingerprints. A TVDB candidate with `{tvdb, imdb}` and a TMDB candidate with `{tmdb, tvdb, imdb}` remain separate, then source order can select the poorer TVDB candidate and prevent TMDB from being persisted. +- The TVDB plugin already fills remote IDs for direct ID and metadata paths, but `searchByTitle` ignores `SearchResult.RemoteIDs`. +- Quick library refresh does not schedule an item solely because `tmdb_id` is missing. + +## File Structure + +### Server Repository + +- Modify `internal/metadata/match_candidates.go`: merge compatible candidates by overlapping non-conflicting canonical provider IDs and prefer richer provider ID sets during match scoring. +- Modify `internal/metadata/match_candidates_test.go`: cover compatible ID union and conflicting ID separation. +- Modify `internal/catalog/item_repo.go`: add provider-aware external ID lookup for enabled-library presence checks while preserving `LookupTMDBIDs`. +- Modify `internal/catalog/item_repo_test.go`: test the query helper used by provider-aware lookup. +- Modify `internal/catalog/provider_id_repo.go`: add a transactional `AttachTMDBID` repair helper that updates both `media_items.tmdb_id` and `media_item_provider_ids`. +- Modify `internal/catalog/provider_id_repo_test.go`: add normalization-level coverage for the new helper's input rules. +- Modify `internal/requests/presence.go`: replace TMDB-only presence resolution with candidate-based provider-aware presence and best-effort TMDB backfill. +- Create `internal/requests/presence_test.go`: test presence matching by TVDB and backfill behavior using fakes. +- Modify `internal/requests/service.go`: hydrate TMDB search/detail/create/reconcile candidates with external IDs before availability checks. +- Modify `internal/requests/service_test.go`: cover search availability, create blocking, and reconcile completion when only TVDB/IMDb is present locally. +- Modify `internal/metadata/refresh_debt.go`: add provider ID incomplete refresh debt. +- Modify `internal/metadata/refresh_debt_repo.go`: expose the new reason in metrics. +- Modify `internal/metadata/refresh_debt_test.go`: test provider ID incomplete debt. +- Modify `internal/adminjob/library_refresh.go`: include matched items missing TMDB IDs in quick library refresh. +- Modify `cmd/silo/main.go` and `internal/api/router.go`: wire `catalog.ProviderIDRepository` into request presence. + +### TVDB Plugin Repository + +- Modify `provider/provider.go`: fill remote IDs in title search results. +- Modify `provider/provider_test.go`: prove title search returns `tmdb` and `imdb` provider IDs when TVDB search payload includes `remote_ids`. + +--- + +## Task 1: Merge Compatible Metadata Candidates + +**Files:** +- Modify: `internal/metadata/match_candidates.go` +- Modify: `internal/metadata/match_candidates_test.go` + +- [ ] **Step 1: Write failing tests for compatible provider ID union** + +Add these cases to `TestNormalizeCandidates` in `internal/metadata/match_candidates_test.go`: + +```go +{ + name: "merge compatible candidates with overlapping provider IDs", + results: []SearchResult{ + { + Name: "The Rookie: Feds", + Year: 2022, + Provider: "tvdb", + ProviderIDs: map[string]string{"tvdb": "420105", "imdb": "tt18076310"}, + }, + { + Name: "The Rookie: Feds", + Year: 2022, + Provider: "tmdb", + ProviderIDs: map[string]string{"tmdb": "201992", "tvdb": "420105", "imdb": "tt18076310"}, + }, + }, + content: "series", + wantLen: 1, + check: func(t *testing.T, candidates []MatchCandidate) { + c := candidates[0] + if c.ProviderIDs["tmdb"] != "201992" { + t.Fatalf("tmdb id = %q, want 201992", c.ProviderIDs["tmdb"]) + } + if c.ProviderIDs["tvdb"] != "420105" || c.ProviderIDs["imdb"] != "tt18076310" { + t.Fatalf("provider ids = %+v, want tvdb and imdb preserved", c.ProviderIDs) + } + if len(c.Sources) != 2 { + t.Fatalf("sources = %+v, want two providers", c.Sources) + } + }, +}, +{ + name: "do not merge candidates with conflicting overlapping provider IDs", + results: []SearchResult{ + { + Name: "Show A", + Year: 2022, + Provider: "tvdb", + ProviderIDs: map[string]string{"tvdb": "420105", "imdb": "tt18076310"}, + }, + { + Name: "Show B", + Year: 2022, + Provider: "tmdb", + ProviderIDs: map[string]string{"tmdb": "201992", "tvdb": "999999", "imdb": "tt18076310"}, + }, + }, + content: "series", + wantLen: 2, + check: func(t *testing.T, candidates []MatchCandidate) { + if len(candidates) != 2 { + t.Fatalf("len(candidates) = %d, want 2", len(candidates)) + } + }, +}, +``` + +- [ ] **Step 2: Run the new tests and verify they fail** + +Run: + +```bash +go test ./internal/metadata -run TestNormalizeCandidates -count=1 +``` + +Expected: FAIL because compatible candidates are still grouped by exact provider ID fingerprint. + +- [ ] **Step 3: Implement compatible candidate grouping** + +In `internal/metadata/match_candidates.go`, add these helpers near `normalizedKey`: + +```go +var canonicalCandidateIDKeys = []string{"tmdb", "tvdb", "imdb"} + +func compatibleProviderIDs(left, right map[string]string) bool { + overlap := false + for _, key := range canonicalCandidateIDKeys { + lv := strings.TrimSpace(left[key]) + rv := strings.TrimSpace(right[key]) + if lv == "" || rv == "" { + continue + } + if lv != rv { + return false + } + overlap = true + } + return overlap +} + +func providerIDRichness(ids map[string]string) int { + score := 0 + for _, key := range canonicalCandidateIDKeys { + if strings.TrimSpace(ids[key]) != "" { + score++ + } + } + return score +} +``` + +Then change the bucket selection in `NormalizeCandidates` so it first reuses an existing compatible bucket before falling back to `normalizedKey`: + +```go +key := "" +for _, existingKey := range ordered { + if compatibleProviderIDs(buckets[existingKey].candidate.ProviderIDs, sr.ProviderIDs) { + key = existingKey + break + } +} +if key == "" { + key = normalizedKey(sr.ProviderIDs) + if key == "" { + key = sr.Provider + ":" + sr.Name + ":" + strings.Repeat("?", len(ordered)) + } +} +``` + +Update `scoreMatchCandidate` so richer canonical ID sets break ties without overpowering trusted ID correctness: + +```go +if len(candidate.ProviderIDs) > 0 { + score += 5 + score += float64(providerIDRichness(candidate.ProviderIDs)) +} +``` + +- [ ] **Step 4: Run metadata tests** + +Run: + +```bash +go test ./internal/metadata -run 'TestNormalizeCandidates|TestSelectInitialMatchCandidate|TestRefresh' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/metadata/match_candidates.go internal/metadata/match_candidates_test.go +git commit -m "fix(metadata): merge compatible provider ids" +``` + +--- + +## Task 2: Add Provider-Aware Catalog Presence Lookup + +**Files:** +- Modify: `internal/catalog/item_repo.go` +- Modify: `internal/catalog/item_repo_test.go` +- Modify: `internal/catalog/provider_id_repo.go` +- Modify: `internal/catalog/provider_id_repo_test.go` + +- [ ] **Step 1: Add failing query-shape test for provider-aware lookup** + +In `internal/catalog/item_repo_test.go`, add: + +```go +func TestLookupExternalIDsSQLChecksProviderTableAndDirectColumns(t *testing.T) { + sql := lookupExternalIDsSQL() + + for _, want := range []string{ + "FROM requested r", + "JOIN media_item_provider_ids mip", + "mip.provider = r.provider", + "mip.provider_id = r.provider_id", + "COALESCE(mi.tmdb_id, '') = r.provider_id", + "COALESCE(mi.tvdb_id, '') = r.provider_id", + "COALESCE(mi.imdb_id, '') = r.provider_id", + "JOIN media_folders mf ON mf.id = mil.media_folder_id", + "mf.enabled = true", + } { + if !strings.Contains(sql, want) { + t.Fatalf("lookupExternalIDsSQL missing %q:\n%s", want, sql) + } + } +} +``` + +- [ ] **Step 2: Run the query-shape test and verify it fails** + +Run: + +```bash +go test ./internal/catalog -run TestLookupExternalIDsSQLChecksProviderTableAndDirectColumns -count=1 +``` + +Expected: FAIL because `lookupExternalIDsSQL` does not exist. + +- [ ] **Step 3: Add lookup types and SQL helper** + +In `internal/catalog/item_repo.go`, add these types near `MediaTMDBRow`: + +```go +type ExternalIDLookupCandidate struct { + TMDBID string + TVDBID string + IMDbID string +} + +type ExternalIDMatchRow struct { + QueryTMDBID string + MediaID string + MatchedProvider string + LibraryID string + Title string +} +``` + +Add this SQL helper below `LookupTMDBIDs`: + +```go +func lookupExternalIDsSQL() string { + return ` + WITH requested(query_tmdb_id, provider, provider_id, ord) AS ( + SELECT * FROM unnest($1::text[], $2::text[], $3::text[], $4::int[]) + ), + direct_matches AS ( + SELECT r.query_tmdb_id, mi.content_id, r.provider, mil.media_folder_id::text, mi.title, r.ord, + CASE r.provider WHEN 'tmdb' THEN 0 WHEN 'tvdb' THEN 1 WHEN 'imdb' THEN 2 ELSE 3 END AS provider_rank + FROM requested r + JOIN media_items mi + ON mi.type = $5 + AND ( + (r.provider = 'tmdb' AND COALESCE(mi.tmdb_id, '') = r.provider_id) + OR (r.provider = 'tvdb' AND COALESCE(mi.tvdb_id, '') = r.provider_id) + OR (r.provider = 'imdb' AND COALESCE(mi.imdb_id, '') = r.provider_id) + ) + JOIN media_item_libraries mil ON mil.content_id = mi.content_id + JOIN media_folders mf ON mf.id = mil.media_folder_id + WHERE mf.enabled = true + ), + provider_matches AS ( + SELECT r.query_tmdb_id, mi.content_id, r.provider, mil.media_folder_id::text, mi.title, r.ord, + CASE r.provider WHEN 'tmdb' THEN 0 WHEN 'tvdb' THEN 1 WHEN 'imdb' THEN 2 ELSE 3 END AS provider_rank + FROM requested r + JOIN media_item_provider_ids mip + ON mip.provider = r.provider + AND mip.provider_id = r.provider_id + AND mip.item_type = $5 + JOIN media_items mi ON mi.content_id = mip.content_id AND mi.type = $5 + JOIN media_item_libraries mil ON mil.content_id = mi.content_id + JOIN media_folders mf ON mf.id = mil.media_folder_id + WHERE mf.enabled = true + ) + SELECT DISTINCT ON (query_tmdb_id) + query_tmdb_id, content_id, provider, media_folder_id, title + FROM ( + SELECT * FROM direct_matches + UNION ALL + SELECT * FROM provider_matches + ) matches + ORDER BY query_tmdb_id, provider_rank ASC, ord ASC, content_id ASC, media_folder_id ASC` +} +``` + +- [ ] **Step 4: Implement `LookupExternalIDs`** + +Add this method to `internal/catalog/item_repo.go`: + +```go +func (r *ItemRepository) LookupExternalIDs( + ctx context.Context, + mediaType string, + candidates []ExternalIDLookupCandidate, +) ([]ExternalIDMatchRow, error) { + if len(candidates) == 0 { + return nil, nil + } + + queryTMDBIDs := make([]string, 0, len(candidates)*3) + providers := make([]string, 0, len(candidates)*3) + providerIDs := make([]string, 0, len(candidates)*3) + ordinals := make([]int32, 0, len(candidates)*3) + + appendID := func(candidate ExternalIDLookupCandidate, provider, providerID string, ordinal int) { + providerID = strings.TrimSpace(providerID) + if providerID == "" { + return + } + queryTMDBIDs = append(queryTMDBIDs, strings.TrimSpace(candidate.TMDBID)) + providers = append(providers, provider) + providerIDs = append(providerIDs, providerID) + ordinals = append(ordinals, int32(ordinal)) + } + + for i, candidate := range candidates { + appendID(candidate, "tmdb", candidate.TMDBID, i) + appendID(candidate, "tvdb", candidate.TVDBID, i) + appendID(candidate, "imdb", candidate.IMDbID, i) + } + if len(providerIDs) == 0 { + return nil, nil + } + + rows, err := r.pool.Query(ctx, lookupExternalIDsSQL(), queryTMDBIDs, providers, providerIDs, ordinals, mediaType) + if err != nil { + return nil, fmt.Errorf("lookup external ids: %w", err) + } + defer rows.Close() + + out := make([]ExternalIDMatchRow, 0) + for rows.Next() { + var row ExternalIDMatchRow + if err := rows.Scan(&row.QueryTMDBID, &row.MediaID, &row.MatchedProvider, &row.LibraryID, &row.Title); err != nil { + return nil, fmt.Errorf("scanning external id lookup row: %w", err) + } + out = append(out, row) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating external id lookup rows: %w", err) + } + return out, nil +} +``` + +Change `LookupTMDBIDs` to delegate to the new lookup: + +```go +func (r *ItemRepository) LookupTMDBIDs(ctx context.Context, mediaType string, tmdbIDs []string) ([]MediaTMDBRow, error) { + if len(tmdbIDs) == 0 { + return nil, nil + } + candidates := make([]ExternalIDLookupCandidate, 0, len(tmdbIDs)) + for _, id := range tmdbIDs { + if strings.TrimSpace(id) != "" { + candidates = append(candidates, ExternalIDLookupCandidate{TMDBID: id}) + } + } + rows, err := r.LookupExternalIDs(ctx, mediaType, candidates) + if err != nil { + return nil, err + } + out := make([]MediaTMDBRow, 0, len(rows)) + for _, row := range rows { + out = append(out, MediaTMDBRow{ + MediaID: row.MediaID, + TMDBID: row.QueryTMDBID, + LibraryID: row.LibraryID, + Title: row.Title, + }) + } + return out, nil +} +``` + +- [ ] **Step 5: Add TMDB attach helper** + +In `internal/catalog/provider_id_repo.go`, add `strconv` to imports and add: + +```go +func (r *ProviderIDRepository) AttachTMDBID(ctx context.Context, contentID, itemType string, tmdbID int) error { + contentID = strings.TrimSpace(contentID) + itemType = strings.TrimSpace(itemType) + if contentID == "" { + return fmt.Errorf("content_id is required") + } + if itemType == "" { + return fmt.Errorf("item_type is required") + } + if tmdbID <= 0 { + return fmt.Errorf("tmdb_id must be positive") + } + + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin attach tmdb transaction: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + + tmdbText := strconv.Itoa(tmdbID) + if _, err := tx.Exec(ctx, ` + UPDATE media_items + SET tmdb_id = COALESCE(NULLIF(tmdb_id, ''), $1), + updated_at = NOW() + WHERE content_id = $2 + AND type = $3 + `, tmdbText, contentID, itemType); err != nil { + return fmt.Errorf("updating media item tmdb id: %w", err) + } + + if _, err := tx.Exec(ctx, ` + INSERT INTO media_item_provider_ids (content_id, item_type, provider, provider_id, created_at, updated_at) + VALUES ($1, $2, 'tmdb', $3, NOW(), NOW()) + ON CONFLICT (content_id, provider) DO UPDATE + SET item_type = EXCLUDED.item_type, + provider_id = EXCLUDED.provider_id, + updated_at = NOW() + `, contentID, itemType, tmdbText); err != nil { + return fmt.Errorf("upserting media item tmdb provider id: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit attach tmdb transaction: %w", err) + } + return nil +} +``` + +- [ ] **Step 6: Add helper input tests** + +In `internal/catalog/provider_id_repo_test.go`, add: + +```go +func TestNormalizeDurableProviderIDsKeepsTMDBFirstForBackfill(t *testing.T) { + entries := normalizeDurableProviderIDs(map[string]string{ + "tvdb": "420105", + "imdb": "tt18076310", + "tmdb": "201992", + }) + if len(entries) != 3 { + t.Fatalf("len(entries) = %d, want 3", len(entries)) + } + if entries[0].Provider != "tmdb" || entries[0].ProviderID != "201992" { + t.Fatalf("first entry = (%q, %q), want tmdb/201992", entries[0].Provider, entries[0].ProviderID) + } +} +``` + +- [ ] **Step 7: Run catalog tests** + +Run: + +```bash +go test ./internal/catalog -run 'TestLookupExternalIDsSQLChecksProviderTableAndDirectColumns|TestNormalizeDurableProviderIDs' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add internal/catalog/item_repo.go internal/catalog/item_repo_test.go internal/catalog/provider_id_repo.go internal/catalog/provider_id_repo_test.go +git commit -m "feat(catalog): lookup media presence by external ids" +``` + +--- + +## Task 3: Make Request Presence Provider-Aware and Backfill TMDB + +**Files:** +- Modify: `internal/requests/presence.go` +- Create: `internal/requests/presence_test.go` +- Modify: `cmd/silo/main.go` +- Modify: `internal/api/router.go` + +- [ ] **Step 1: Write failing presence tests** + +Create `internal/requests/presence_test.go`: + +```go +package requests + +import ( + "context" + "testing" + + "github.com/Silo-Server/silo-server/internal/catalog" +) + +type fakePresenceLookup struct { + rows []catalog.ExternalIDMatchRow + got []catalog.ExternalIDLookupCandidate +} + +func (f *fakePresenceLookup) LookupExternalIDs(_ context.Context, _ string, candidates []catalog.ExternalIDLookupCandidate) ([]catalog.ExternalIDMatchRow, error) { + f.got = append([]catalog.ExternalIDLookupCandidate(nil), candidates...) + return f.rows, nil +} + +type fakeTMDBBackfiller struct { + contentID string + itemType string + tmdbID int +} + +func (f *fakeTMDBBackfiller) AttachTMDBID(_ context.Context, contentID, itemType string, tmdbID int) error { + f.contentID = contentID + f.itemType = itemType + f.tmdbID = tmdbID + return nil +} + +func TestCatalogPresenceMatchesByTVDBAndBackfillsTMDB(t *testing.T) { + tvdbID := 420105 + lookup := &fakePresenceLookup{rows: []catalog.ExternalIDMatchRow{{ + QueryTMDBID: "201992", + MediaID: "120983767174086659", + MatchedProvider: "tvdb", + LibraryID: "2", + Title: "The Rookie: Feds", + }}} + backfill := &fakeTMDBBackfiller{} + presence := &CatalogPresence{items: lookup, tmdbBackfill: backfill} + + result, err := presence.Lookup(context.Background(), MediaTypeSeries, []PresenceCandidate{{ + TMDBID: 201992, + TVDBID: &tvdbID, + IMDbID: "tt18076310", + }}) + if err != nil { + t.Fatalf("Lookup returned error: %v", err) + } + if !result[201992].Available { + t.Fatalf("available = false, want true") + } + if result[201992].MatchedProvider != "tvdb" { + t.Fatalf("matched provider = %q, want tvdb", result[201992].MatchedProvider) + } + if backfill.contentID != "120983767174086659" || backfill.itemType != "series" || backfill.tmdbID != 201992 { + t.Fatalf("backfill = %+v, want content 120983767174086659 series tmdb 201992", backfill) + } +} + +func TestCatalogPresenceKeepsLookupTMDBCompatibility(t *testing.T) { + lookup := &fakePresenceLookup{rows: []catalog.ExternalIDMatchRow{{ + QueryTMDBID: "550", + MediaID: "movie-1", + MatchedProvider: "tmdb", + LibraryID: "1", + Title: "Fight Club", + }}} + presence := &CatalogPresence{items: lookup} + + result, err := presence.LookupTMDB(context.Background(), MediaTypeMovie, []int{550}) + if err != nil { + t.Fatalf("LookupTMDB returned error: %v", err) + } + if !result[550] { + t.Fatalf("result[550] = false, want true") + } + if len(lookup.got) != 1 || lookup.got[0].TMDBID != "550" { + t.Fatalf("lookup candidates = %+v, want tmdb candidate", lookup.got) + } +} +``` + +- [ ] **Step 2: Run presence tests and verify they fail** + +Run: + +```bash +go test ./internal/requests -run TestCatalogPresence -count=1 +``` + +Expected: FAIL because `PresenceCandidate`, `CatalogPresence.Lookup`, and `tmdbBackfill` do not exist. + +- [ ] **Step 3: Replace presence interface while keeping TMDB compatibility** + +In `internal/requests/presence.go`, change the types to: + +```go +type PresenceCandidate struct { + TMDBID int + TVDBID *int + IMDbID string +} + +type PresenceMatch struct { + Available bool + ContentID string + MatchedProvider string +} + +type PresenceResolver interface { + Lookup(ctx context.Context, mediaType MediaType, candidates []PresenceCandidate) (map[int]PresenceMatch, error) +} + +type presenceItemLookup interface { + LookupExternalIDs(ctx context.Context, mediaType string, candidates []catalog.ExternalIDLookupCandidate) ([]catalog.ExternalIDMatchRow, error) +} + +type tmdbBackfiller interface { + AttachTMDBID(ctx context.Context, contentID, itemType string, tmdbID int) error +} + +type CatalogPresence struct { + items presenceItemLookup + tmdbBackfill tmdbBackfiller +} +``` + +Update the constructor: + +```go +func NewCatalogPresence(items *catalog.ItemRepository, providerIDs ...*catalog.ProviderIDRepository) *CatalogPresence { + var backfill tmdbBackfiller + if len(providerIDs) > 0 { + backfill = providerIDs[0] + } + return &CatalogPresence{items: items, tmdbBackfill: backfill} +} +``` + +Add the provider-aware lookup: + +```go +func (p *CatalogPresence) Lookup(ctx context.Context, mediaType MediaType, candidates []PresenceCandidate) (map[int]PresenceMatch, error) { + out := map[int]PresenceMatch{} + if p == nil || p.items == nil || len(candidates) == 0 { + return out, nil + } + + lookupCandidates := make([]catalog.ExternalIDLookupCandidate, 0, len(candidates)) + for _, candidate := range candidates { + if candidate.TMDBID <= 0 { + continue + } + row := catalog.ExternalIDLookupCandidate{TMDBID: strconv.Itoa(candidate.TMDBID), IMDbID: strings.TrimSpace(candidate.IMDbID)} + if candidate.TVDBID != nil && *candidate.TVDBID > 0 { + row.TVDBID = strconv.Itoa(*candidate.TVDBID) + } + lookupCandidates = append(lookupCandidates, row) + } + if len(lookupCandidates) == 0 { + return out, nil + } + + rows, err := p.items.LookupExternalIDs(ctx, string(mediaType), lookupCandidates) + if err != nil { + return nil, err + } + for _, row := range rows { + id, err := strconv.Atoi(row.QueryTMDBID) + if err != nil || id <= 0 { + continue + } + out[id] = PresenceMatch{ + Available: true, + ContentID: row.MediaID, + MatchedProvider: row.MatchedProvider, + } + if row.MatchedProvider != "tmdb" && p.tmdbBackfill != nil { + if err := p.tmdbBackfill.AttachTMDBID(ctx, row.MediaID, string(mediaType), id); err != nil { + slog.Warn("requests: failed to backfill tmdb id from presence lookup", + "content_id", row.MediaID, + "media_type", mediaType, + "tmdb_id", id, + "matched_provider", row.MatchedProvider, + "error", err) + } + } + } + return out, nil +} +``` + +Keep a compatibility wrapper for existing call sites and tests: + +```go +func (p *CatalogPresence) LookupTMDB(ctx context.Context, mediaType MediaType, tmdbIDs []int) (map[int]bool, error) { + candidates := make([]PresenceCandidate, 0, len(tmdbIDs)) + for _, id := range tmdbIDs { + if id > 0 { + candidates = append(candidates, PresenceCandidate{TMDBID: id}) + } + } + matches, err := p.Lookup(ctx, mediaType, candidates) + if err != nil { + return nil, err + } + out := map[int]bool{} + for id, match := range matches { + out[id] = match.Available + } + return out, nil +} +``` + +Add imports: + +```go +import ( + "context" + "log/slog" + "strconv" + "strings" + + "github.com/Silo-Server/silo-server/internal/catalog" +) +``` + +- [ ] **Step 4: Wire provider ID repository into request presence** + +In `internal/api/router.go`, change request service construction from: + +```go +mediarequests.NewCatalogPresence(itemRepo), +``` + +to: + +```go +mediarequests.NewCatalogPresence(itemRepo, providerIDRepo), +``` + +In `cmd/silo/main.go`, change reconcile service construction from: + +```go +mediarequests.NewCatalogPresence(catalog.NewItemRepository(deps.DB)), +``` + +to: + +```go +mediarequests.NewCatalogPresence( + catalog.NewItemRepository(deps.DB), + catalog.NewProviderIDRepository(deps.DB), +), +``` + +- [ ] **Step 5: Run request presence tests** + +Run: + +```bash +go test ./internal/requests -run TestCatalogPresence -count=1 +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/requests/presence.go internal/requests/presence_test.go internal/api/router.go cmd/silo/main.go +git commit -m "feat(requests): match catalog presence by external ids" +``` + +--- + +## Task 4: Hydrate TMDB External IDs Before Availability Checks + +**Files:** +- Modify: `internal/requests/service.go` +- Modify: `internal/requests/service_test.go` + +- [ ] **Step 1: Update request fake presence** + +In `internal/requests/service_test.go`, replace the fake presence with: + +```go +type fakePresence struct { + available map[MediaType]map[int]bool + byTVDB map[MediaType]map[int]int + got []PresenceCandidate +} + +func (f *fakePresence) Lookup(_ context.Context, mediaType MediaType, candidates []PresenceCandidate) (map[int]PresenceMatch, error) { + out := map[int]PresenceMatch{} + f.got = append(f.got, candidates...) + for _, candidate := range candidates { + if f.available != nil && f.available[mediaType][candidate.TMDBID] { + out[candidate.TMDBID] = PresenceMatch{Available: true, MatchedProvider: "tmdb"} + continue + } + if candidate.TVDBID != nil && f.byTVDB != nil { + if tmdbID, ok := f.byTVDB[mediaType][*candidate.TVDBID]; ok && tmdbID == candidate.TMDBID { + out[candidate.TMDBID] = PresenceMatch{Available: true, MatchedProvider: "tvdb"} + } + } + } + return out, nil +} +``` + +- [ ] **Step 2: Update fake TMDB external IDs** + +In `fakeTMDBClient`, add: + +```go +externalIDsByID map[int]*tmdb.ExternalIDs +externalIDCalls []int +``` + +Replace `GetExternalIDs` with: + +```go +func (f *fakeTMDBClient) GetExternalIDs(_ context.Context, _ string, id int) (*tmdb.ExternalIDs, error) { + f.externalIDCalls = append(f.externalIDCalls, id) + if f.externalIDsByID != nil { + return f.externalIDsByID[id], nil + } + return f.externalIDs, nil +} +``` + +- [ ] **Step 3: Write failing search/create/reconcile tests** + +Add these tests to `internal/requests/service_test.go`: + +```go +func TestSearchMarksSeriesAvailableByHydratedTVDBID(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + tmdbClient := &fakeTMDBClient{ + page: &tmdb.MediaPage{ + Page: 1, + Results: []tmdb.MediaResult{{ + ID: 201992, + MediaType: "series", + Title: "The Rookie: Feds", + Year: 2022, + }}, + }, + externalIDsByID: map[int]*tmdb.ExternalIDs{ + 201992: {TVDBID: 420105, IMDbID: "tt18076310"}, + }, + } + presence := &fakePresence{byTVDB: map[MediaType]map[int]int{ + MediaTypeSeries: {420105: 201992}, + }} + service := NewService(store, tmdbClient, presence) + + page, err := service.Search(context.Background(), testViewer(1), "rookie feds", MediaTypeSeries, 1) + if err != nil { + t.Fatalf("Search returned error: %v", err) + } + if got := page.Results[0].Availability; got != AvailabilityAvailable { + t.Fatalf("availability = %q, want available", got) + } + if page.Results[0].Request.Reason != "already_available" { + t.Fatalf("request reason = %q, want already_available", page.Results[0].Request.Reason) + } + if len(presence.got) != 1 || presence.got[0].TVDBID == nil || *presence.got[0].TVDBID != 420105 { + t.Fatalf("presence candidates = %+v, want hydrated tvdb id", presence.got) + } +} + +func TestCreateRequestBlocksWhenHydratedTVDBIDIsAvailable(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + tmdbClient := &fakeTMDBClient{externalIDs: &tmdb.ExternalIDs{TVDBID: 420105, IMDbID: "tt18076310"}} + presence := &fakePresence{byTVDB: map[MediaType]map[int]int{ + MediaTypeSeries: {420105: 201992}, + }} + service := NewService(store, tmdbClient, presence) + + _, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ + MediaType: MediaTypeSeries, + TMDBID: 201992, + Title: "The Rookie: Feds", + }) + if !errors.Is(err, ErrAlreadyAvailable) { + t.Fatalf("err = %v, want ErrAlreadyAvailable", err) + } + if len(store.created) != 0 { + t.Fatalf("created requests = %d, want 0", len(store.created)) + } +} + +func TestReconcileRequestsCompletesByStoredTVDBID(t *testing.T) { + store := newFakeStore() + tvdbID := 420105 + store.candidates = []*Request{{ + ID: "req-1", + MediaType: MediaTypeSeries, + TMDBID: 201992, + TVDBID: &tvdbID, + Status: StatusQueued, + Outcome: OutcomeActive, + }} + presence := &fakePresence{byTVDB: map[MediaType]map[int]int{ + MediaTypeSeries: {420105: 201992}, + }} + service := NewService(store, &fakeTMDBClient{}, presence) + + result, err := service.ReconcileRequests(context.Background(), 100) + if err != nil { + t.Fatalf("ReconcileRequests returned error: %v", err) + } + if result.Completed != 1 { + t.Fatalf("completed = %d, want 1", result.Completed) + } +} +``` + +- [ ] **Step 4: Run the new tests and verify they fail** + +Run: + +```bash +go test ./internal/requests -run 'TestSearchMarksSeriesAvailableByHydratedTVDBID|TestCreateRequestBlocksWhenHydratedTVDBIDIsAvailable|TestReconcileRequestsCompletesByStoredTVDBID' -count=1 +``` + +Expected: FAIL because the service does not hydrate external IDs before availability. + +- [ ] **Step 5: Add candidate hydration helpers** + +In `internal/requests/service.go`, add: + +```go +func (s *Service) lookupPresence(ctx context.Context, mediaType MediaType, candidates []PresenceCandidate) (map[int]PresenceMatch, error) { + if s.presence == nil { + return map[int]PresenceMatch{}, nil + } + return s.presence.Lookup(ctx, mediaType, candidates) +} + +func availabilityBoolMap(matches map[int]PresenceMatch) map[int]bool { + out := map[int]bool{} + for id, match := range matches { + out[id] = match.Available + } + return out +} + +func requestPresenceCandidate(req Request) PresenceCandidate { + candidate := PresenceCandidate{ + TMDBID: req.TMDBID, + IMDbID: strings.TrimSpace(req.IMDbID), + } + if req.TVDBID != nil && *req.TVDBID > 0 { + tvdbID := *req.TVDBID + candidate.TVDBID = &tvdbID + } + return candidate +} + +func createPresenceCandidate(input CreateRequestInput) PresenceCandidate { + candidate := PresenceCandidate{ + TMDBID: input.TMDBID, + IMDbID: strings.TrimSpace(input.IMDbID), + } + if input.TVDBID != nil && *input.TVDBID > 0 { + tvdbID := *input.TVDBID + candidate.TVDBID = &tvdbID + } + return candidate +} + +func (s *Service) hydratePresenceCandidate(ctx context.Context, mediaType MediaType, candidate PresenceCandidate) PresenceCandidate { + if candidate.TMDBID <= 0 { + return candidate + } + client, ok := s.tmdb.(TMDBExternalIDClient) + if !ok { + return candidate + } + externalIDs, err := client.GetExternalIDs(ctx, tmdbMediaType(mediaType), candidate.TMDBID) + if err != nil || externalIDs == nil { + return candidate + } + if candidate.IMDbID == "" { + candidate.IMDbID = strings.TrimSpace(externalIDs.IMDbID) + } + if candidate.TVDBID == nil && externalIDs.TVDBID > 0 { + tvdbID := externalIDs.TVDBID + candidate.TVDBID = &tvdbID + } + return candidate +} + +func tmdbMediaType(mediaType MediaType) string { + if mediaType == MediaTypeSeries { + return "tv" + } + return "movie" +} +``` + +Replace `lookupAvailable` with: + +```go +func (s *Service) lookupAvailable(ctx context.Context, mediaType MediaType, ids []int) (map[int]bool, error) { + candidates := make([]PresenceCandidate, 0, len(ids)) + for _, id := range ids { + if id > 0 { + candidates = append(candidates, s.hydratePresenceCandidate(ctx, mediaType, PresenceCandidate{TMDBID: id})) + } + } + matches, err := s.lookupPresence(ctx, mediaType, candidates) + if err != nil { + return nil, err + } + return availabilityBoolMap(matches), nil +} +``` + +- [ ] **Step 6: Reorder create availability check after external ID enrichment** + +In `CreateRequest`, move: + +```go +s.enrichExternalIDs(ctx, &normalized) +``` + +so it happens immediately after `normalizeCreateInput` and before `lookupAvailable`. + +Then change the availability check to: + +```go +matches, err := s.lookupPresence(ctx, normalized.MediaType, []PresenceCandidate{createPresenceCandidate(normalized)}) +if err != nil { + return nil, err +} +if matches[normalized.TMDBID].Available { + return nil, ErrAlreadyAvailable +} +``` + +- [ ] **Step 7: Use stored request IDs during reconciliation** + +Change `requestAvailable` to: + +```go +func (s *Service) requestAvailable(ctx context.Context, req Request) (bool, error) { + matches, err := s.lookupPresence(ctx, req.MediaType, []PresenceCandidate{requestPresenceCandidate(req)}) + if err != nil { + return false, err + } + return matches[req.TMDBID].Available, nil +} +``` + +- [ ] **Step 8: Run request tests** + +Run: + +```bash +go test ./internal/requests -count=1 +``` + +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add internal/requests/service.go internal/requests/service_test.go +git commit -m "fix(requests): hydrate external ids before availability" +``` + +--- + +## Task 5: Schedule Existing Missing-TMDB Items for Refresh + +**Files:** +- Modify: `internal/metadata/refresh_debt.go` +- Modify: `internal/metadata/refresh_debt_repo.go` +- Modify: `internal/metadata/refresh_debt_test.go` +- Modify: `internal/adminjob/library_refresh.go` + +- [ ] **Step 1: Add failing refresh debt tests** + +In `internal/metadata/refresh_debt_test.go`, add: + +```go +func TestRefreshDebtReasonsForItemFlagsMissingTMDBWithOtherProviderIDs(t *testing.T) { + item := &models.MediaItem{ + Type: "series", + Status: "matched", + TvdbID: "420105", + ImdbID: "tt18076310", + TmdbID: "", + } + + mask := refreshDebtReasonsForItem(item) + if !hasRefreshDebtReason(mask, RefreshDebtReasonProviderIDIncomplete) { + t.Fatalf("reason mask = %d, want provider id incomplete", mask) + } +} + +func TestRefreshDebtReasonsForItemDoesNotFlagProviderIDIncompleteWithoutAlternateIDs(t *testing.T) { + item := &models.MediaItem{ + Type: "series", + Status: "matched", + TmdbID: "", + } + + mask := refreshDebtReasonsForItem(item) + if hasRefreshDebtReason(mask, RefreshDebtReasonProviderIDIncomplete) { + t.Fatalf("reason mask = %d, did not want provider id incomplete", mask) + } +} +``` + +- [ ] **Step 2: Run refresh debt tests and verify they fail** + +Run: + +```bash +go test ./internal/metadata -run TestRefreshDebtReasonsForItem -count=1 +``` + +Expected: FAIL because `RefreshDebtReasonProviderIDIncomplete` does not exist. + +- [ ] **Step 3: Add provider ID incomplete reason** + +In `internal/metadata/refresh_debt.go`, add the reason after `RefreshDebtReasonStaleProviderID`: + +```go +RefreshDebtReasonProviderIDIncomplete +``` + +Update `refreshDebtPriority`: + +```go +case hasRefreshDebtReason(reasonMask, RefreshDebtReasonStaleProviderID): + return 250 +case hasRefreshDebtReason(reasonMask, RefreshDebtReasonProviderIDIncomplete): + return 240 +``` + +Update `refreshDebtReasonsForItem`: + +```go +if hasProviderIDRefreshDebt(item) { + reasonMask |= RefreshDebtReasonProviderIDIncomplete +} +``` + +Add: + +```go +func hasProviderIDRefreshDebt(item *models.MediaItem) bool { + if item == nil || !strings.EqualFold(strings.TrimSpace(item.Status), "matched") { + return false + } + if strings.TrimSpace(item.TmdbID) != "" { + return false + } + return strings.TrimSpace(item.TvdbID) != "" || strings.TrimSpace(item.ImdbID) != "" +} +``` + +- [ ] **Step 4: Add metrics label** + +In `internal/metadata/refresh_debt_repo.go`, add the reason definition: + +```go +{reason: "provider_id_incomplete", mask: RefreshDebtReasonProviderIDIncomplete}, +``` + +Place it after `stale_provider_id`. + +- [ ] **Step 5: Include missing TMDB in quick library refresh** + +In `internal/adminjob/library_refresh.go`, add this OR branch to the quick-mode second predicate: + +```sql + OR ( + COALESCE(mi.tmdb_id, '') = '' + AND ( + COALESCE(mi.tvdb_id, '') <> '' + OR COALESCE(mi.imdb_id, '') <> '' + ) + ) +``` + +The resulting quick-mode condition should still require at least one external ID through the first predicate: + +```sql + AND ( + COALESCE(mi.tmdb_id, '') <> '' + OR COALESCE(mi.tvdb_id, '') <> '' + OR COALESCE(mi.imdb_id, '') <> '' + ) +``` + +- [ ] **Step 6: Run metadata and adminjob tests** + +Run: + +```bash +go test ./internal/metadata ./internal/adminjob -count=1 +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add internal/metadata/refresh_debt.go internal/metadata/refresh_debt_repo.go internal/metadata/refresh_debt_test.go internal/adminjob/library_refresh.go +git commit -m "fix(metadata): refresh items missing tmdb ids" +``` + +--- + +## Task 6: Fix TVDB Plugin Title Search Remote IDs + +**Files:** +- Modify in sibling `silo-plugin-tvdb` repository: `provider/provider.go` +- Modify in sibling `silo-plugin-tvdb` repository: `provider/provider_test.go` + +- [ ] **Step 1: Switch to the TVDB plugin repository** + +Run from the sibling plugin repository root: + +```bash +pwd +``` + +Expected: path ends with `silo-plugin-tvdb`. + +- [ ] **Step 2: Add failing title search test** + +In `provider/provider_test.go`, add: + +```go +func TestProviderSearchByTitleIncludesRemoteIDs(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case r.Method == http.MethodPost && r.URL.Path == "/login": + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "success", + "data": map[string]any{ + "token": "test-token", + }, + }) + case r.Method == http.MethodGet && r.URL.Path == "/search": + if r.URL.Query().Get("query") != "The Rookie: Feds" { + t.Fatalf("query = %q, want The Rookie: Feds", r.URL.Query().Get("query")) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "success", + "data": []map[string]any{{ + "name": "The Rookie: Feds", + "year": "2022", + "tvdb_id": "420105", + "overview": "A spinoff series.", + "remote_ids": []map[string]any{ + {"type": 12, "id": "201992", "sourceName": "TheMovieDB.com"}, + {"type": 2, "id": "tt18076310", "sourceName": "IMDB"}, + }, + }}, + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + } + })) + defer server.Close() + + client := NewClient(1000) + client.SetBaseURL(server.URL) + p := NewProviderWithClient(client) + + results, err := p.Search(context.Background(), metadata.SearchQuery{ + Title: "The Rookie: Feds", + ContentType: "series", + }) + if err != nil { + t.Fatalf("Search returned error: %v", err) + } + if len(results) != 1 { + t.Fatalf("len(results) = %d, want 1", len(results)) + } + ids := results[0].ProviderIDs + if ids["tvdb"] != "420105" || ids["tmdb"] != "201992" || ids["imdb"] != "tt18076310" { + t.Fatalf("provider ids = %+v, want tvdb/tmdb/imdb", ids) + } +} +``` + +- [ ] **Step 3: Run the TVDB plugin test and verify it fails** + +Run from the TVDB plugin repository root: + +```bash +go test ./provider -run TestProviderSearchByTitleIncludesRemoteIDs -count=1 +``` + +Expected: FAIL because `searchByTitle` returns only `tvdb`. + +- [ ] **Step 4: Fill remote IDs in title search** + +In `provider/provider.go`, replace the body of the `for _, r := range results` loop in `searchByTitle` with: + +```go +ids := map[string]string{"tvdb": r.TVDBID} +fillRemoteIDs(ids, r.RemoteIDs) +out = append(out, metadata.SearchResult{ + Name: r.Name, + Year: extractYear(r.Year), + ProviderIDs: ids, + ImageURL: r.ImageURL, + Overview: r.Overview, + Provider: p.Slug(), +}) +``` + +- [ ] **Step 5: Run TVDB plugin tests** + +Run from the TVDB plugin repository root: + +```bash +go test ./provider -count=1 +``` + +Expected: PASS. + +- [ ] **Step 6: Commit in the TVDB plugin repository** + +Run from the TVDB plugin repository root: + +```bash +git add provider/provider.go provider/provider_test.go +git commit -m "fix(tvdb): include remote ids in title search" +``` + +--- + +## Task 7: Full Verification + +**Files:** +- Server repository verification only. +- TVDB plugin repository verification only. + +- [ ] **Step 1: Run focused server tests** + +Run from the server repository root: + +```bash +go test ./internal/metadata ./internal/catalog ./internal/requests ./internal/adminjob -count=1 +``` + +Expected: PASS. + +- [ ] **Step 2: Run TVDB plugin tests** + +Run from the TVDB plugin repository root: + +```bash +go test ./provider -count=1 +``` + +Expected: PASS. + +- [ ] **Step 3: Build the server** + +Run from the server repository root: + +```bash +make build +``` + +Expected: build completes without Go, TypeScript, or frontend bundling errors. + +- [ ] **Step 4: Validate the repair behavior in development** + +After deploying or running the patched server against the development stack, trigger a quick refresh for the TV Shows library or run the reconcile task. Then query item `120983767174086659`: + +```sql +SELECT content_id, title, tmdb_id, tvdb_id, imdb_id, status +FROM media_items +WHERE content_id = '120983767174086659'; +``` + +Expected row includes: + +```text +content_id=120983767174086659 +title=The Rookie: Feds +tmdb_id=201992 +tvdb_id=420105 +imdb_id=tt18076310 +status=matched +``` + +Also check durable provider IDs: + +```sql +SELECT provider, provider_id +FROM media_item_provider_ids +WHERE content_id = '120983767174086659' +ORDER BY provider; +``` + +Expected rows include: + +```text +imdb | tt18076310 +tmdb | 201992 +tvdb | 420105 +``` + +- [ ] **Step 5: Validate request availability** + +Search requests for `The Rookie: Feds`. + +Expected: TMDB result `201992` is marked unavailable to request with request reason `already_available`. + +--- + +## Execution Notes + +- Do not add a migration for the refresh debt reason; reason masks are stored as integers and existing rows remain valid. +- The request API still accepts TMDB IDs only. TVDB/IMDb are internal matching hints and should not change client contracts. +- Presence backfill is best effort. Availability must still return `already_available` even if the TMDB repair write hits a uniqueness conflict or transient DB error. +- TMDB external ID hydration should fail open to TMDB-only matching when TMDB external ID lookup fails. +- If the TVDB plugin change is not released immediately, the server-side compatible candidate merge still improves cases where the TMDB plugin supplies the richer candidate. diff --git a/docs/superpowers/plans/request-system-implementation.md b/docs/superpowers/plans/request-system-implementation.md new file mode 100644 index 00000000..89127dac --- /dev/null +++ b/docs/superpowers/plans/request-system-implementation.md @@ -0,0 +1,218 @@ +# Media Request System Implementation Plan + +Spec: [Media Request System Spec](../specs/request-system.md) + +## Objective + +Implement the request system in small phases so Silo can first persist and +display requests safely, then add external fulfillment and reconciliation. + +## Phase 1: Backend Foundation + +Goal: persist requests, enforce requestability rules, and expose enough API for +the web UI to search/discover/request without Radarr or Sonarr side effects. + +### Database + +Add migration `139_media_requests.{up,down}.sql` unless a newer migration number +exists at implementation time. + +Tables: + +- `media_requests` +- `media_request_events` +- `request_user_limits` +- `request_settings` +- `request_integrations` + +Important constraints and indexes: + +- Partial unique index for active requests by `(media_type, provider, tmdb_id)`. +- Index request lookups by `requested_by_user_id`, `requested_by_profile_id`, + `status`, `outcome`, and `created_at`. +- Index quota checks by `(requested_by_user_id, created_at)`. +- Foreign keys to `users` where possible. +- Profile IDs should remain text because user profile storage is per-user. + +### Backend Package + +Create `internal/requests` with: + +- domain types for media type, status, outcome, settings, quota, discovery item +- repository for request CRUD, request events, settings, integrations, and quota + checks +- service for request creation, requestability, approval, decline, retry, and + user/admin listing +- TMDB discovery/search client wrapper or extension over the existing TMDB + client +- catalog presence resolver using existing `catalog.ItemRepository` provider ID + lookups where possible + +Core service methods: + +- `Search(ctx, viewer, query, mediaType, page)` +- `Discover(ctx, viewer, section, page)` +- `CreateRequest(ctx, viewer, input)` +- `ListMine(ctx, viewer, filters)` +- `ListAdmin(ctx, filters)` +- `Approve(ctx, admin, requestID)` +- `Decline(ctx, admin, requestID, reason)` +- `Retry(ctx, admin, requestID)` +- `ResolveRequestability(ctx, viewer, mediaType, tmdbID)` + +### API Handlers + +Add `internal/api/handlers/requests.go`. + +Profile routes: + +- `GET /api/v1/requests/search` +- `GET /api/v1/requests/discover` +- `GET /api/v1/requests/discover/{section}` +- `POST /api/v1/requests` +- `GET /api/v1/requests/mine` +- `GET /api/v1/requests/{id}` + +Admin routes: + +- `GET /api/v1/admin/requests` +- `POST /api/v1/admin/requests/{id}/approve` +- `POST /api/v1/admin/requests/{id}/decline` +- `POST /api/v1/admin/requests/{id}/retry` +- `GET /api/v1/admin/request-settings` +- `PUT /api/v1/admin/request-settings` +- `GET /api/v1/admin/request-integrations` +- `PUT /api/v1/admin/request-integrations` + +Router wiring should follow existing profile/admin grouping in +`internal/api/router.go`. + +### Phase 1 Behavior + +- Search and discovery call TMDB directly. +- Results are enriched with local availability, existing request status, and + requestability. +- Creating a request enforces: + - requests enabled + - per-user quota + - not already locally available + - no active request for the same `(media_type, tmdb_id)` + - user not blocked from requesting +- Auto approval may set status to `approved`, but Phase 1 does not submit to + Radarr or Sonarr yet. +- Admin approval moves `pending` to `approved`. +- Admin decline sets `outcome = declined`. +- Retry can be accepted only for failed requests, but can be a no-op until + fulfillment exists. + +### Phase 1 Verification + +Minimal backend tests are warranted because the requestability and quota logic +is critical: + +- active duplicate request blocks creation +- declined/cancelled/failed/completed requests do not block a new request +- quota counts every created request in the rolling window +- quota is per user, not per profile +- search/discovery enrichment hides requester identity + +No frontend tests unless specifically requested. + +## Phase 2: Radarr And Sonarr Fulfillment + +Goal: approved requests are submitted to Radarr/Sonarr and move to `queued`. + +Add adapter interfaces in `internal/requests`: + +- `MovieFulfillmentAdapter` +- `SeriesFulfillmentAdapter` + +Built-in implementations: + +- `internal/requests/radarr` +- `internal/requests/sonarr` + +Radarr support: + +- connection check +- root folders +- quality profiles +- tags +- existing movie lookup by TMDB ID +- movie lookup/hydration by TMDB ID +- add movie + +Sonarr support: + +- connection check +- root folders +- quality profiles +- tags +- existing series lookup by TVDB ID +- series lookup/hydration by search term or TVDB-bearing result +- add series + +Submission should be idempotent: + +- If the item already exists in Radarr/Sonarr, store the external ID and mark + `queued` rather than failing. +- If the external API accepts the item, store the external ID and mark `queued`. +- If submission fails, set `outcome = failed` and persist `last_error`. + +## Phase 3: Reconciliation Worker + +Goal: keep request status current after submission. + +Add a periodic worker that: + +- polls active `queued` and `downloading` requests +- checks Radarr/Sonarr queue/history/activity to detect active downloads +- checks Silo catalog provider IDs to mark completed +- records status transitions in `media_request_events` + +Completion must be based on Silo catalog presence, not Radarr/Sonarr status +alone. + +## Phase 4: Web UI + +Goal: expose the feature in the admin web UI and user web UI. + +User pages: + +- request discovery page with direct TMDB sections +- TMDB search mode +- own requests page +- request status badges and disabled-state reasons + +Admin pages: + +- request queue with status/outcome filters +- approve/decline/retry actions +- request settings +- Radarr/Sonarr connection settings and connection checks +- per-user overrides + +Frontend API additions should live in `web/src/api/types.ts` and the existing +query hook structure. + +## Phase 5: Polish And Follow-Up + +Potential follow-ups after V1: + +- notifications for approval/completion/failure +- season-specific requests +- multiple Radarr/Sonarr routing profiles +- quality-tier request choices +- request notes visible to admins +- plugin SDK fulfillment adapter boundary +- client app surfaces for Android and Apple + +## Open Implementation Notes + +- TMDB image URLs should use the same image handling convention as existing + metadata/search UI where possible. +- API keys for Radarr/Sonarr must not be returned by API responses. +- Request status should be evented later if the UI needs realtime updates, but + polling is enough for V1. +- The request discovery service may cache raw TMDB responses, but request and + availability enrichment must happen per API request. diff --git a/docs/superpowers/specs/2026-05-24-discover-studios-networks-genres-design.md b/docs/superpowers/specs/2026-05-24-discover-studios-networks-genres-design.md new file mode 100644 index 00000000..78154283 --- /dev/null +++ b/docs/superpowers/specs/2026-05-24-discover-studios-networks-genres-design.md @@ -0,0 +1,351 @@ +# Discover: Studios, Networks, and Genres + +Status: design approved 2026-05-24 + +## Dependency + +This feature extends the media request system spec'd in +`docs/superpowers/specs/request-system.md`. It depends on `internal/requests/`, +`internal/metadata/tmdb/`, `Requests.tsx`, and the existing +`/api/v1/requests/discover/*` route prefix. Commands assume the repository root +is the cwd. + +## Goal + +Add an Overseerr-style browse-by-brand experience to the request discover +surface. Users land on `/requests`, see existing Trending / Popular / Upcoming / +On Air carousels, and below them three new carousels of brand cards: + +- **Studios** — Disney, Pixar, Marvel, etc. Click → movie browse page. +- **Networks** — Netflix, HBO, Apple TV+, etc. Click → series browse page. +- **Genres** — Action, Comedy, Drama, etc. Click → genre browse page with + Movies / Series tabs. + +Each brand card is a curated entry tied to a TMDB company, network, or genre +ID. Click-through lands on a paginated, sortable grid of TMDB results enriched +with Silo availability and request state — the same enrichment used by the +existing six discovery sections. + +## V1 Scope + +- A bundled, compile-time list of ~10 studios, ~10 networks, and ~8 genres. +- Logos fetched from TMDB at runtime (`company.logo_path`, + `network.logo_path`), cached server-side for 24h. +- Genre cards rendered with a gradient + display name (TMDB has no logos for + genres). +- Three new discover list endpoints returning brand cards. +- Three new browse endpoints returning enriched paginated TMDB results. +- Sort by Popularity (default), Vote Average, or Release Date. +- Studios browse: movies only. Networks browse: series only. Genres browse: + Movies / Series tabs, Series tab hidden when the genre has no TV equivalent. + +## Non-Goals (v1) + +- Admin UI for editing the bundled list — compile-time only. +- Cross-filter on the browse page (e.g., Marvel + Action). +- Infinite scroll — page-based pagination only. +- Studios browse showing series, networks browse showing movies — strict 1:1 + media-type mapping. +- Per-user favorites or pinned studios. +- Search-within-browse-page. +- Backdrop variants for genre cards — gradient + text only. +- Internationalized brand display names — English only. + +## Architecture + +New domain code lives under `internal/requests/` alongside the existing request +system files. No new top-level package, no sub-package — keep the layout flat +to match the rest of `internal/requests/`. + +New files: + +- `discover_bundle.go` — hardcoded `BundledStudio`, `BundledNetwork`, + `BundledGenre` slices. Slug → entity lookup helpers. +- `discover_logo_cache.go` — in-memory `map[int]string` for TMDB company and + network `logo_path`. Lazy fetch, 24h TTL, singleflight to dedupe concurrent + misses. +- `discover_brand.go` — `ListStudios`, `ListNetworks`, `ListGenres`, + `BrowseStudio`, `BrowseNetwork`, `BrowseGenre` methods. Reuses + `enrichResults` from the existing service for availability + request state. + +Extensions to existing code: + +- `internal/metadata/tmdb/client.go` + - Add `WithCompanies []int` and `WithNetworks []int` to `DiscoverParams`. + - Add `GetCompany(ctx, id)` and `GetNetwork(ctx, id)` helpers that return the + canonical entity (we only need `logo_path` and `name` from each). +- `internal/api/handlers/requests.go` — six new handler methods. +- `internal/api/router.go` — wire the six new routes under + `/api/v1/requests/discover/...`. + +No new database tables. No migrations. Stateful caches are process-local. + +## Data Model + +### Go types (constants and request/response shapes) + +```go +type BundledStudio struct { + TMDBID int // TMDB company ID + Slug string // URL slug, e.g. "marvel-studios" + DisplayName string + BrandColor string // hex, e.g. "#ed1d24" +} + +type BundledNetwork struct { + TMDBID int + Slug string + DisplayName string + BrandColor string +} + +type BundledGenre struct { + Slug string + DisplayName string + GradientFrom string // hex + GradientTo string // hex + MovieID int // TMDB movie genre ID, always set in v1 + SeriesID int // TMDB tv genre ID, 0 if no TV equivalent +} + +type DiscoverBrandCard struct { + TMDBID int `json:"tmdb_id,omitempty"` // omitted for genres + Slug string `json:"slug"` + DisplayName string `json:"display_name"` + BrandColor string `json:"brand_color,omitempty"` // studios/networks + LogoURL *string `json:"logo_url,omitempty"` // studios/networks; null on lookup failure + GradientFrom string `json:"gradient_from,omitempty"` // genres + GradientTo string `json:"gradient_to,omitempty"` // genres + SeriesSupported bool `json:"series_supported,omitempty"` // genres; true iff SeriesID > 0 +} + +type DiscoverBrowseResponse struct { + Kind string `json:"kind"` // "studio" | "network" | "genre" + Slug string `json:"slug"` + DisplayName string `json:"display_name"` + BrandColor string `json:"brand_color,omitempty"` + LogoURL *string `json:"logo_url,omitempty"` + MediaType MediaType `json:"media_type"` + Sort string `json:"sort"` + Page int `json:"page"` + TotalPages int `json:"total_pages"` + Results []MediaResult `json:"results"` // existing internal/requests.MediaResult type, same as search/discover +} +``` + +### Bundled v1 contents + +Exact TMDB IDs verified during implementation against `/company/{id}` and +`/network/{id}` responses. The lists below are starting defaults; the team can +adjust during plan review. + +**Studios (10):** Walt Disney Pictures, Pixar, Marvel Studios, Lucasfilm, +Warner Bros. Pictures, Universal Pictures, Paramount Pictures, Sony Pictures, +20th Century Studios, Studio Ghibli. + +**Networks (10):** Netflix, Disney+, Apple TV+, HBO, Hulu, Amazon Prime Video, +Max, Paramount+, BBC, FX. + +**Genres (8):** + +| Slug | Display | Movie ID | Series ID | +|---|---|---|---| +| `action` | Action | 28 | 10759 *(Action & Adventure)* | +| `comedy` | Comedy | 35 | 35 | +| `drama` | Drama | 18 | 18 | +| `sci-fi` | Sci-Fi | 878 | 10765 *(Sci-Fi & Fantasy)* | +| `horror` | Horror | 27 | 0 *(no TV equivalent)* | +| `romance` | Romance | 10749 | 0 *(no TV equivalent)* | +| `animation` | Animation | 16 | 16 | +| `documentary` | Documentary | 99 | 99 | + +Genres with `SeriesID = 0` set `SeriesSupported = false`; the Series tab is +hidden on the browse page. + +## API Surface + +All routes are profile-scoped, served under the existing +`/api/v1/requests/discover/` prefix, with the same auth middleware as existing +discover routes. + +### List endpoints + +```http +GET /api/v1/requests/discover/studios +GET /api/v1/requests/discover/networks +GET /api/v1/requests/discover/genres +``` + +Each returns `{ "studios" | "networks" | "genres": [DiscoverBrandCard, ...] }` +in bundle order. A failed logo lookup yields a card with `logo_url: null` — +the response is not failed wholesale. + +### Browse endpoints + +```http +GET /api/v1/requests/discover/browse/studio/{slug}?page=1&sort=popularity +GET /api/v1/requests/discover/browse/network/{slug}?page=1&sort=popularity +GET /api/v1/requests/discover/browse/genre/{slug}?media_type=movie&page=1&sort=popularity +``` + +- `sort` ∈ `popularity` (default), `vote_average`, `release_date`. All three + are descending — most popular / highest rated / most recent first. There is + no ascending variant in v1. + - `popularity` → TMDB `popularity.desc` + - `vote_average` → TMDB `vote_average.desc` with `vote_count.gte=100` to + filter low-vote noise + - `release_date` → TMDB `primary_release_date.desc` (movies) or + `first_air_date.desc` (series) +- `media_type` ∈ `movie`, `series`. + - **Required** on `genre` browse. + - **Omitted** on `studio` browse (always movie) and `network` browse (always + series). + - Invalid combo (e.g., `media_type=series` for a genre with + `SeriesSupported = false`) → 400. +- `{slug}` is the bundle slug. Unknown slug → 404. +- `page` defaults to 1. + +Response: `DiscoverBrowseResponse` — see Go types above. `results` items share +the shape of existing search/discover results and carry the same `availability` +and `request: { status, requestable }` fields. + +## Caching + +| Resource | Cache | Lifetime | +|---|---|---| +| Studio/network `logo_path` | In-memory `map[int]string`, singleflight on miss | 24h | +| Browse responses (TMDB discover results) | Existing TMDB response cache wrapper | 15 min | +| List responses (`/discover/studios` etc.) | In-memory, keyed by `(kind, locale)` | 24h | + +All caches are process-local. Restart clears them and they re-fill lazily. + +## Frontend + +### New components (`web/src/components/`) + +- `BrandCarousel.tsx` — horizontal-scroll carousel for brand cards. Same + scroll behavior as the existing `MediaCarousel`, slot dimensions differ + (~140x80 instead of poster aspect ratio). +- `BrandCard.tsx` — renders one brand card. Props: + `{ kind, slug, displayName, brandColor?, logoUrl?, gradientFrom?, gradientTo?, movieSupported?, seriesSupported? }`. + Studios/networks render with `brandColor` + `` (or + centered `displayName` text fallback if `logoUrl` is null). Genres render + with a CSS gradient (`gradientFrom` → `gradientTo`) and centered display + name. Click → `useNavigate()` to the matching browse route. + +### `Requests.tsx` changes + +Append three new sections after the existing six discovery carousels: + +1. `BrandCarousel` for Studios — backed by `useDiscoverStudios()`. +2. `BrandCarousel` for Networks — backed by `useDiscoverNetworks()`. +3. `BrandCarousel` for Genres — backed by `useDiscoverGenres()`. + +Skeleton states reuse the existing `Skeleton` import. If a list endpoint +fails, only that carousel shows an inline retry — the rest of the page +remains. + +### New routes (`web/src/App.tsx`) + +```text +/requests/browse/studio/:slug +/requests/browse/network/:slug +/requests/browse/genre/:slug +``` + +All three render `RequestBrowse.tsx`. The page reads `kind` from the route, +`slug` from `useParams`, and `media_type` + `sort` + `page` from query string. + +### New page (`web/src/pages/RequestBrowse.tsx`) + +Layout: + +- Header: Back link, brand card preview (or gradient for genres), display + name, result count, and a sort `Select` dropdown. +- Genre pages only: shadcn `Tabs` for Movies / Series, with the Series tab + hidden if `series_supported = false`. The active tab drives the + `media_type` query param. +- Grid: reuses existing `RequestPosterCard` — no changes to that component + since the browse response item shape mirrors the search response item shape. +- Pagination: page-based with Prev/Next buttons matching the `searchPage` + pattern in `Requests.tsx`. + +Empty/error states: + +- Unknown slug → "Studio/Network/Genre not found" + link back to `/requests`. +- Empty results → "Nothing matched — try a different sort." +- Network error → existing toast pattern from the request hooks. + +### New hooks (`web/src/hooks/queries/useRequests.ts`) + +- `useDiscoverStudios()`, `useDiscoverNetworks()`, `useDiscoverGenres()` — + 24h stale time matching the server cache. +- `useRequestBrowse({ kind, slug, mediaType?, sort, page })` — 60-90s stale + time so request state stays fresh. + +### New types (`web/src/api/types.ts`) + +- `DiscoverStudio`, `DiscoverNetwork`, `DiscoverGenre`, + `DiscoverBrowseResult`. + +### Sidebar + +No changes. The existing "Requests" sidebar entry covers the new routes. + +## Failure modes and edge cases + +- **Logo fetch fails for one entity** — card returned with `logo_url: null`; + rest of the list unaffected. Logged as a warning. +- **Genre has no TV equivalent** — `series_supported: false`; Series tab + hidden; `media_type=series` requests for that genre return 400. +- **Browse returns zero results** — empty state on the page; carousel cards + are not pre-checked for result counts. +- **Pagination past TMDB's page-500 cap** — `total_pages` reflects TMDB's + response; the Next button disables when `page == total_pages`. +- **TMDB rate-limited** — existing `retryAfterOrDefault` backoff in the TMDB + client applies. Sustained rate-limiting returns 503; cached carousels are + unaffected. +- **`requests_enabled = false`** — all new endpoints return 403 like the rest + of `/requests/*`. Sidebar entry is hidden by existing behavior. + +## Testing + +Backend: + +- `discover_bundle_test.go` — slug lookups, unknown slug, integrity (no + duplicate slugs, every entry has required fields). +- `discover_logo_cache_test.go` — TTL expiry, miss returns `nil` path, parallel + access does not double-fetch (singleflight). +- `internal/metadata/tmdb/client_test.go` — extend with `WithCompanies` and + `WithNetworks` query construction cases. +- `internal/requests/service_test.go` — extend with `ListStudios/Networks/ + Genres` (bundled list returned with logo lookups stubbed) and + `BrowseStudio/Network/Genre` (enriched TMDB results, verify `availability` + + `request` state). +- `internal/api/handlers/requests_test.go` — six new endpoints. Cover auth, + invalid `media_type` on genre browse, unknown slug → 404, unknown `sort` → + 400, `requests_enabled = false` → 403. + +Frontend: manual verification via `make dev-frontend` against a backend with +the new endpoints. Existing request system commits did not add frontend unit +tests; this feature follows suit. + +## Acceptance criteria + +- `/requests` shows Studios, Networks, and Genres carousels below the existing + six discovery sections. +- Clicking a studio card navigates to `/requests/browse/studio/{slug}` and + renders a paginated, sortable grid of movies enriched with availability and + request state. +- Clicking a network card navigates to `/requests/browse/network/{slug}` with + series. +- Clicking a genre card navigates to `/requests/browse/genre/{slug}` with + Movies/Series tabs; the Series tab is hidden when `series_supported = false`. +- Sort options (Popularity, Vote Average, Release Date) work on all browse + pages and update the URL. +- A card with a failed logo lookup falls back to text and does not break the + carousel. +- Existing request creation, status, quota, and Radarr/Sonarr fulfillment flows + are unchanged and still pass their tests. +- `make lint`, `cd web && pnpm run lint`, and `cd web && pnpm run format:check` + all pass. diff --git a/docs/superpowers/specs/2026-05-25-request-search-all-design.md b/docs/superpowers/specs/2026-05-25-request-search-all-design.md new file mode 100644 index 00000000..52ec1392 --- /dev/null +++ b/docs/superpowers/specs/2026-05-25-request-search-all-design.md @@ -0,0 +1,25 @@ +# Request Search All Design + +## Goal + +Request search should show movies and series together by default, while still allowing users and clients to filter to only movies or only series. + +## Behavior + +- `/requests/search` accepts `media_type=all` and treats a missing `media_type` the same way. +- `media_type=movie` and `media_type=series` keep their current filtered behavior. +- Mixed search returns only TMDB movie and TV results. People and other TMDB multi-search result types are excluded. +- Detail pages and request creation stay strict: they continue to accept only `movie` or `series`. + +## Architecture + +- The TMDB client owns provider-specific mixed search by mapping `all` to TMDB `/search/multi`. +- The request service normalizes search media type separately from detail/create media type validation. +- The Requests page defaults its filter to `All` and sends `media_type=all` for submitted searches. + +## Verification + +Commands assume the repository root is the cwd. + +- Run focused Go tests for `internal/metadata/tmdb` and `internal/requests`. +- Run frontend lint/type verification for the web code touched by the filter type change. diff --git a/docs/superpowers/specs/request-system.md b/docs/superpowers/specs/request-system.md new file mode 100644 index 00000000..11c67deb --- /dev/null +++ b/docs/superpowers/specs/request-system.md @@ -0,0 +1,417 @@ +# Media Request System Spec + +## Goal + +Build a Silo-native request system that lets authenticated users discover and +search TMDB movies and series, request missing media, and have approved +requests fulfilled through Radarr and Sonarr. + +Silo owns request state, request limits, approval policy, visibility, and +catalog availability detection. Radarr and Sonarr are external fulfillment +adapters. + +## V1 Scope + +- TMDB movie and series search. +- Direct TMDB-backed discovery sections for requestable media. +- One canonical request per media item. +- Radarr fulfillment for movies. +- Sonarr fulfillment for whole series. +- Manual approval and configurable auto approval. +- Global rolling request limits with per-user overrides. +- User-facing request status badges on search and discovery results. +- Admin queue for all requests. +- Background reconciliation with Radarr, Sonarr, and the Silo catalog. + +## Non-Goals For V1 + +- Season-specific or episode-specific requests. +- Multiple Radarr or Sonarr instance routing. +- User-selected quality tiers per request. +- Public/global request lists for non-admin users. +- Request comments or conversation threads. +- Client app updates outside the server web UI. +- Collection-backed request discovery. + +## Request Visibility + +Requests are not treated as highly private, but Silo should avoid exposing a +global request list to normal users. + +- Non-admin users can list only their own submitted requests. +- Admin users can list all requests and see requester, profile, and audit + details. +- Search and discovery results include lightweight request state for the item. +- Search and discovery results must not expose requester identity, notes, or + user counts to non-admin users. + +If an item has already been requested, other users cannot request it. The item +should show the existing request status instead. + +Example search/discovery item state: + +```json +{ + "request": { + "status": "queued", + "requestable": false + } +} +``` + +## Status Model + +The primary request status tracks the normal fulfillment path: + +- `pending`: submitted and waiting for approval. +- `approved`: approved in Silo, but not yet handed to Radarr or Sonarr. +- `queued`: accepted by Radarr or Sonarr and waiting/searching. +- `downloading`: Radarr or Sonarr reports active download/import activity. +- `completed`: Silo catalog contains the requested media after a scan and + provider-ID match. + +Exceptional outcomes are separate from the main status ladder: + +- `declined` +- `cancelled` +- `failed` + +V1 assumption: declined, cancelled, and failed requests no longer block a new +request for the same item, but they still count against historical request +limits. + +## Request Limits + +Admins configure a global rolling quota: + +- `enabled` +- `max_requests` +- `window_days` + +Admins can define per-user override modes: + +- inherit global +- custom `max_requests` and `window_days` +- unlimited +- blocked from requesting + +Quota rules: + +- A new request counts when a request row is created. +- Every created request counts regardless of final status or outcome. +- Pending, approved, queued, downloading, completed, declined, cancelled, and + failed requests all count during the rolling window. +- Duplicate attempts for an already-requested item do not count because no new + request is created. +- Limits are enforced per Silo user account, not per household profile. +- The active profile is still recorded for audit and display. + +## Auto Approval + +Auto approval supports a global default plus per-user override: + +- inherit global +- always manual +- auto approve +- blocked + +Auto approval only applies when: + +- Requests are enabled. +- The user is within quota. +- The item is not already available in Silo. +- The item does not already have an active request. +- The relevant Radarr or Sonarr integration is configured. + +## Discovery + +Request discovery is separate from Silo collections. It should call TMDB +directly and enrich the results with Silo availability and request state. It +must not create, sync, or depend on `library_collections`. + +V1 discovery sections: + +- `trending_movies`: `/trending/movie/week` +- `trending_series`: `/trending/tv/week` +- `popular_movies`: `/movie/popular` +- `popular_series`: `/tv/popular` +- `upcoming_movies`: `/movie/upcoming` +- `on_air_series`: `/tv/on_the_air` + +Discovery responses should include: + +- media type +- TMDB ID +- title +- year or first air year +- overview +- poster/backdrop URLs or paths +- local availability +- lightweight request state + +Example: + +```json +{ + "media_type": "movie", + "tmdb_id": 550, + "title": "Fight Club", + "year": 1999, + "poster_path": "/pB8BM7pdSp6B6Ih7QZ4DrQ3PmJK.jpg", + "availability": "missing", + "request": { + "status": null, + "requestable": true + } +} +``` + +Discovery implementation notes: + +- Add a dedicated request discovery service under the request system. +- It may extend or reuse the low-level TMDB HTTP client. +- It must not use collection templates, collection sync, or collection + persistence. +- Cache raw TMDB section responses briefly, for example 15-60 minutes. +- Enrich request and availability state at request time so status remains + current. + +## Search + +Search should call TMDB directly for movie and TV results, then enrich each +result with: + +- local Silo availability +- existing request status +- requestability under current settings and quota + +Search should not require the item to exist in the Silo catalog. + +## Fulfillment + +### Movies + +1. User selects a TMDB movie search/discovery result. +2. Silo validates quota, availability, duplicate request state, and policy. +3. Silo creates a request. +4. If auto approved, or after manual admin approval, Silo looks up/adds the + movie in Radarr. +5. Silo stores the Radarr movie ID and moves the request to `queued`. +6. Background reconciliation updates the request to `downloading` from Radarr + activity/history. +7. Silo marks the request `completed` only after the Silo catalog sees the + matching TMDB ID. + +### Series + +1. User selects a TMDB TV search/discovery result. +2. Silo resolves the TVDB ID needed by Sonarr. +3. Silo validates quota, availability, duplicate request state, and policy. +4. Silo creates a request. +5. If auto approved, or after manual admin approval, Silo adds the whole series + in Sonarr using the default add behavior. +6. Silo stores the Sonarr series ID and moves the request to `queued`. +7. Background reconciliation updates the request to `downloading` from Sonarr + activity/history. +8. Silo marks the request `completed` only after the Silo catalog sees the + matching TMDB or TVDB ID. + +## Radarr Integration + +The Radarr adapter should use Radarr v3 API endpoints: + +- `GET /api/v3/system/status` for connection checks. +- `GET /api/v3/rootfolder` for root folder choices. +- `GET /api/v3/qualityprofile` for quality profiles. +- `GET /api/v3/tag` for tag choices. +- `GET /api/v3/movie?tmdbId={id}` to detect existing Radarr items. +- `GET /api/v3/movie/lookup/tmdb?tmdbId={id}` to hydrate add payloads. +- `POST /api/v3/movie` to add approved movies. + +Configured movie options: + +- root folder +- quality profile +- tags +- minimum availability +- search on add + +## Sonarr Integration + +The Sonarr adapter should use Sonarr v3 API endpoints: + +- `GET /api/v3/system/status` for connection checks. +- `GET /api/v3/rootfolder` for root folder choices. +- `GET /api/v3/qualityprofile` for quality profiles. +- `GET /api/v3/tag` for tag choices. +- `GET /api/v3/series?tvdbId={id}` to detect existing Sonarr items. +- `GET /api/v3/series/lookup?term={term}` to hydrate add payloads. +- `POST /api/v3/series` to add approved series. + +Configured series options: + +- root folder +- quality profile +- tags +- series type +- season folder +- search for missing episodes on add + +## API Surface + +Profile-scoped routes: + +- `GET /api/v1/requests/search?q=&media_type=movie|series` +- `GET /api/v1/requests/discover` +- `GET /api/v1/requests/discover/{section}?page=1` +- `POST /api/v1/requests` +- `GET /api/v1/requests/mine` +- `GET /api/v1/requests/{id}` + +Admin routes: + +- `GET /api/v1/admin/requests` +- `POST /api/v1/admin/requests/{id}/approve` +- `POST /api/v1/admin/requests/{id}/decline` +- `POST /api/v1/admin/requests/{id}/retry` +- `GET /api/v1/admin/request-settings` +- `PUT /api/v1/admin/request-settings` +- `GET /api/v1/admin/request-users/{user_id}/limit` +- `PUT /api/v1/admin/request-users/{user_id}/limit` +- `GET /api/v1/admin/request-integrations` +- `PUT /api/v1/admin/request-integrations` + +## Data Model + +### `media_requests` + +Canonical media request records. + +Suggested fields: + +- `id` +- `media_type` +- `provider` +- `tmdb_id` +- `tvdb_id` +- `imdb_id` +- `title` +- `year` +- `poster_path` +- `overview` +- `status` +- `outcome` +- `requested_by_user_id` +- `requested_by_profile_id` +- `integration_kind` +- `external_id` +- `external_status` +- `last_error` +- `created_at` +- `updated_at` +- `approved_at` +- `completed_at` + +Add a uniqueness constraint for active requests by `(media_type, provider, +tmdb_id)`. Declined, cancelled, failed, and completed requests should not block +a future request. + +### `media_request_events` + +Audit trail for request lifecycle changes. + +Suggested fields: + +- `id` +- `request_id` +- `event_type` +- `actor_user_id` +- `actor_profile_id` +- `message` +- `metadata` +- `created_at` + +### `request_user_limits` + +Per-user quota and approval overrides. + +Suggested fields: + +- `user_id` +- `limit_mode` +- `max_requests` +- `window_days` +- `approval_mode` +- `updated_at` + +### `request_settings` + +Global request settings. + +Suggested fields: + +- `requests_enabled` +- `global_max_requests` +- `global_window_days` +- `global_auto_approval_enabled` +- `updated_at` + +### `request_integrations` + +Radarr and Sonarr configuration. API keys should use the existing sensitive +settings pattern rather than being returned from API responses. The expected +sensitive setting references are `requests.radarr.api_key` and +`requests.sonarr.api_key`. + +Suggested fields: + +- `kind` +- `enabled` +- `base_url` +- `api_key_ref` +- `root_folder` +- `quality_profile_id` +- `tags` +- `options` +- `last_check_at` +- `last_check_status` +- `last_check_error` +- `updated_at` + +## UI Requirements + +User UI: + +- Request discovery page with TMDB sections. +- Search page or search mode that includes TMDB results. +- Clear badges for available, requested, and requestable states. +- Own requests page. +- Request button disabled when unavailable due to existing request, local + availability, quota, or disabled system settings. + +Admin UI: + +- Request queue with filters by status and outcome. +- Approve, decline, and retry actions. +- Request settings page. +- Radarr and Sonarr configuration with connection checks. +- Per-user limit and approval override controls. + +## Acceptance Criteria + +- A user can discover trending/popular/upcoming TMDB movies and series without + touching Silo collections. +- A user can search TMDB and request a missing movie or whole series. +- Search and discovery items show whether they are available, already + requested, or requestable. +- A user cannot request an item that already has an active request. +- Request limits count every created request within the rolling window, + regardless of final status. +- Admins can configure global limits and per-user overrides. +- Admins can configure Radarr and Sonarr, approve/decline/retry requests, and + see request audit history. +- Auto-approved requests are submitted to Radarr/Sonarr without admin action. +- Requests progress through `pending`, `approved`, `queued`, `downloading`, and + `completed`. +- `completed` means Silo has scanned and matched the media in its catalog, not + merely that Radarr or Sonarr reports completion. diff --git a/internal/adminjob/library_refresh.go b/internal/adminjob/library_refresh.go index 5e2bc259..c504bc11 100644 --- a/internal/adminjob/library_refresh.go +++ b/internal/adminjob/library_refresh.go @@ -110,6 +110,14 @@ func (l *PGLibraryRefreshItemLister) ListLibraryItems(ctx context.Context, libra OR COALESCE(mi.logo_path, '') LIKE '%//logo/%' OR mi.refresh_failures > 0 OR mi.episode_metadata_incomplete = TRUE + OR ( + LOWER(TRIM(COALESCE(mi.status, ''))) = 'matched' + AND COALESCE(mi.tmdb_id, '') = '' + AND ( + COALESCE(mi.tvdb_id, '') <> '' + OR COALESCE(mi.imdb_id, '') <> '' + ) + ) OR EXISTS ( SELECT 1 FROM stale_media_ids smi diff --git a/internal/api/handlers/admin.go b/internal/api/handlers/admin.go index c5b02de0..de9fdfed 100644 --- a/internal/api/handlers/admin.go +++ b/internal/api/handlers/admin.go @@ -1025,6 +1025,8 @@ var sensitiveSettingKeys = map[string]bool{ "tmdb.api_key": true, "introdb.api_key": true, "mdblist.api_key": true, + "requests.radarr.api_key": true, + "requests.sonarr.api_key": true, "watchsync.trakt.client_id": true, "watchsync.trakt.client_secret": true, "watchsync.simkl.client_id": true, diff --git a/internal/api/handlers/requests.go b/internal/api/handlers/requests.go new file mode 100644 index 00000000..ed908a2c --- /dev/null +++ b/internal/api/handlers/requests.go @@ -0,0 +1,618 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + mediarequests "github.com/Silo-Server/silo-server/internal/requests" +) + +type RequestService interface { + Search(ctx context.Context, viewer mediarequests.Viewer, query string, mediaType mediarequests.MediaType, page int) (*mediarequests.MediaPage, error) + Discover(ctx context.Context, viewer mediarequests.Viewer, section string, page int) (*mediarequests.DiscoverySection, error) + DiscoverAll(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverySection, error) + GetDetail(ctx context.Context, viewer mediarequests.Viewer, mediaType mediarequests.MediaType, tmdbID int) (*mediarequests.MediaDetail, error) + CreateRequest(ctx context.Context, viewer mediarequests.Viewer, input mediarequests.CreateRequestInput) (*mediarequests.Request, error) + ListMine(ctx context.Context, viewer mediarequests.Viewer, filter mediarequests.ListFilter) ([]*mediarequests.Request, error) + ListAdmin(ctx context.Context, viewer mediarequests.Viewer, filter mediarequests.ListFilter) ([]*mediarequests.Request, error) + GetRequest(ctx context.Context, viewer mediarequests.Viewer, id string) (*mediarequests.Request, error) + Approve(ctx context.Context, viewer mediarequests.Viewer, id string) (*mediarequests.Request, error) + Decline(ctx context.Context, viewer mediarequests.Viewer, id, reason string) (*mediarequests.Request, error) + Cancel(ctx context.Context, viewer mediarequests.Viewer, id, reason string) (*mediarequests.Request, error) + Retry(ctx context.Context, viewer mediarequests.Viewer, id string) (*mediarequests.Request, error) + GetSettings(ctx context.Context, viewer mediarequests.Viewer) (mediarequests.Settings, error) + UpdateSettings(ctx context.Context, viewer mediarequests.Viewer, settings mediarequests.Settings) (mediarequests.Settings, error) + GetUserLimit(ctx context.Context, viewer mediarequests.Viewer, userID int) (*mediarequests.UserLimit, error) + UpsertUserLimit(ctx context.Context, viewer mediarequests.Viewer, limit mediarequests.UserLimit) (*mediarequests.UserLimit, error) + ListIntegrations(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.Integration, error) + UpsertIntegration(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error) + UpsertIntegrations(ctx context.Context, viewer mediarequests.Viewer, integrations []mediarequests.Integration) ([]mediarequests.Integration, error) + LoadIntegrationOptions(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.IntegrationOptions, error) + + ListStudios(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error) + ListNetworks(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error) + ListGenres(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error) + BrowseStudio(ctx context.Context, viewer mediarequests.Viewer, slug, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) + BrowseNetwork(ctx context.Context, viewer mediarequests.Viewer, slug, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) + BrowseGenre(ctx context.Context, viewer mediarequests.Viewer, slug string, mediaType mediarequests.MediaType, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) +} + +type RequestsHandler struct { + service RequestService +} + +func NewRequestsHandler(service RequestService) *RequestsHandler { + return &RequestsHandler{service: service} +} + +func (h *RequestsHandler) HandleSearch(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, true) + if !ok { + return + } + page, ok := parsePositiveIntQuery(w, r, "page", 1) + if !ok { + return + } + result, err := h.service.Search( + r.Context(), + viewer, + r.URL.Query().Get("q"), + mediarequests.MediaType(r.URL.Query().Get("media_type")), + page, + ) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, result) +} + +func (h *RequestsHandler) HandleDiscover(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, true) + if !ok { + return + } + sections, err := h.service.DiscoverAll(r.Context(), viewer) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, struct { + Sections []mediarequests.DiscoverySection `json:"sections"` + }{Sections: sections}) +} + +func (h *RequestsHandler) HandleDiscoverSection(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, true) + if !ok { + return + } + page, ok := parsePositiveIntQuery(w, r, "page", 1) + if !ok { + return + } + section, err := h.service.Discover(r.Context(), viewer, chi.URLParam(r, "section"), page) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, section) +} + +func (h *RequestsHandler) HandleListStudios(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, true) + if !ok { + return + } + studios, err := h.service.ListStudios(r.Context(), viewer) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, struct { + Studios []mediarequests.DiscoverBrandCard `json:"studios"` + }{Studios: studios}) +} + +func (h *RequestsHandler) HandleListNetworks(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, true) + if !ok { + return + } + networks, err := h.service.ListNetworks(r.Context(), viewer) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, struct { + Networks []mediarequests.DiscoverBrandCard `json:"networks"` + }{Networks: networks}) +} + +func (h *RequestsHandler) HandleListGenres(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, true) + if !ok { + return + } + genres, err := h.service.ListGenres(r.Context(), viewer) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, struct { + Genres []mediarequests.DiscoverBrandCard `json:"genres"` + }{Genres: genres}) +} + +func (h *RequestsHandler) HandleBrowseStudio(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, true) + if !ok { + return + } + page, ok := parsePositiveIntQuery(w, r, "page", 1) + if !ok { + return + } + slug := strings.TrimSpace(chi.URLParam(r, "slug")) + sort := strings.TrimSpace(r.URL.Query().Get("sort")) + resp, err := h.service.BrowseStudio(r.Context(), viewer, slug, sort, page) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, resp) +} + +func (h *RequestsHandler) HandleBrowseNetwork(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, true) + if !ok { + return + } + page, ok := parsePositiveIntQuery(w, r, "page", 1) + if !ok { + return + } + slug := strings.TrimSpace(chi.URLParam(r, "slug")) + sort := strings.TrimSpace(r.URL.Query().Get("sort")) + resp, err := h.service.BrowseNetwork(r.Context(), viewer, slug, sort, page) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, resp) +} + +func (h *RequestsHandler) HandleBrowseGenre(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, true) + if !ok { + return + } + page, ok := parsePositiveIntQuery(w, r, "page", 1) + if !ok { + return + } + slug := strings.TrimSpace(chi.URLParam(r, "slug")) + sort := strings.TrimSpace(r.URL.Query().Get("sort")) + mediaType := mediarequests.MediaType(strings.TrimSpace(r.URL.Query().Get("media_type"))) + resp, err := h.service.BrowseGenre(r.Context(), viewer, slug, mediaType, sort, page) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, resp) +} + +func (h *RequestsHandler) HandleGetDetail(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, true) + if !ok { + return + } + mediaType := mediarequests.MediaType(strings.TrimSpace(chi.URLParam(r, "media_type"))) + tmdbID, err := strconv.Atoi(strings.TrimSpace(chi.URLParam(r, "tmdb_id"))) + if err != nil || tmdbID <= 0 { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid tmdb id") + return + } + detail, err := h.service.GetDetail(r.Context(), viewer, mediaType, tmdbID) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, detail) +} + +func (h *RequestsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, true) + if !ok { + return + } + var input mediarequests.CreateRequestInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + req, err := h.service.CreateRequest(r.Context(), viewer, input) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusCreated, req) +} + +func (h *RequestsHandler) HandleListMine(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, true) + if !ok { + return + } + requests, err := h.service.ListMine(r.Context(), viewer, parseRequestListFilter(r)) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, struct { + Requests []*mediarequests.Request `json:"requests"` + }{Requests: requests}) +} + +func (h *RequestsHandler) HandleGet(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, true) + if !ok { + return + } + req, err := h.service.GetRequest(r.Context(), viewer, chi.URLParam(r, "id")) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, req) +} + +func (h *RequestsHandler) HandleAdminList(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, false) + if !ok { + return + } + requests, err := h.service.ListAdmin(r.Context(), viewer, parseRequestListFilter(r)) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, struct { + Requests []*mediarequests.Request `json:"requests"` + }{Requests: requests}) +} + +func (h *RequestsHandler) HandleApprove(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, false) + if !ok { + return + } + req, err := h.service.Approve(r.Context(), viewer, chi.URLParam(r, "id")) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, req) +} + +func (h *RequestsHandler) HandleDecline(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, false) + if !ok { + return + } + var body struct { + Reason string `json:"reason"` + } + if r.Body != nil { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + } + req, err := h.service.Decline(r.Context(), viewer, chi.URLParam(r, "id"), body.Reason) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, req) +} + +func (h *RequestsHandler) HandleCancel(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, false) + if !ok { + return + } + var body struct { + Reason string `json:"reason"` + } + if r.Body != nil { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + } + req, err := h.service.Cancel(r.Context(), viewer, chi.URLParam(r, "id"), body.Reason) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, req) +} + +func (h *RequestsHandler) HandleRetry(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, false) + if !ok { + return + } + req, err := h.service.Retry(r.Context(), viewer, chi.URLParam(r, "id")) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, req) +} + +func (h *RequestsHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, false) + if !ok { + return + } + settings, err := h.service.GetSettings(r.Context(), viewer) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, settings) +} + +func (h *RequestsHandler) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, false) + if !ok { + return + } + var settings mediarequests.Settings + if err := json.NewDecoder(r.Body).Decode(&settings); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + updated, err := h.service.UpdateSettings(r.Context(), viewer, settings) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, updated) +} + +func (h *RequestsHandler) HandleListIntegrations(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, false) + if !ok { + return + } + integrations, err := h.service.ListIntegrations(r.Context(), viewer) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, struct { + Integrations []requestIntegrationResponse `json:"integrations"` + }{Integrations: toIntegrationResponses(integrations)}) +} + +func (h *RequestsHandler) HandleUpdateIntegrations(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, false) + if !ok { + return + } + var body struct { + Integrations []mediarequests.Integration `json:"integrations"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + updated, err := h.service.UpsertIntegrations(r.Context(), viewer, body.Integrations) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, struct { + Integrations []requestIntegrationResponse `json:"integrations"` + }{Integrations: toIntegrationResponses(updated)}) +} + +func (h *RequestsHandler) HandleLoadIntegrationOptions(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, false) + if !ok { + return + } + var integration mediarequests.Integration + if r.Body != nil { + if err := json.NewDecoder(r.Body).Decode(&integration); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + } + integration.Kind = chi.URLParam(r, "kind") + options, err := h.service.LoadIntegrationOptions(r.Context(), viewer, integration) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, options) +} + +func (h *RequestsHandler) HandleGetUserLimit(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, false) + if !ok { + return + } + userID, ok := parsePositivePathInt(w, r, "user_id") + if !ok { + return + } + limit, err := h.service.GetUserLimit(r.Context(), viewer, userID) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, limit) +} + +func (h *RequestsHandler) HandleUpdateUserLimit(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, false) + if !ok { + return + } + userID, ok := parsePositivePathInt(w, r, "user_id") + if !ok { + return + } + var limit mediarequests.UserLimit + if err := json.NewDecoder(r.Body).Decode(&limit); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + limit.UserID = userID + updated, err := h.service.UpsertUserLimit(r.Context(), viewer, limit) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, updated) +} + +func requestViewer(w http.ResponseWriter, r *http.Request, requireProfile bool) (mediarequests.Viewer, bool) { + claims := apimw.GetClaims(r.Context()) + if claims == nil || claims.UserID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return mediarequests.Viewer{}, false + } + profileID := strings.TrimSpace(apimw.GetProfileID(r.Context())) + if requireProfile && profileID == "" { + writeError(w, http.StatusBadRequest, "profile_required", "Profile is required") + return mediarequests.Viewer{}, false + } + return mediarequests.Viewer{ + UserID: claims.UserID, + ProfileID: profileID, + IsAdmin: claims.Role == "admin", + }, true +} + +func parseRequestListFilter(r *http.Request) mediarequests.ListFilter { + q := r.URL.Query() + limit, _ := strconv.Atoi(q.Get("limit")) + offset, _ := strconv.Atoi(q.Get("offset")) + return mediarequests.ListFilter{ + Status: mediarequests.Status(strings.TrimSpace(q.Get("status"))), + Outcome: mediarequests.Outcome(strings.TrimSpace(q.Get("outcome"))), + Limit: limit, + Offset: offset, + } +} + +func parsePositiveIntQuery(w http.ResponseWriter, r *http.Request, key string, fallback int) (int, bool) { + raw := strings.TrimSpace(r.URL.Query().Get(key)) + if raw == "" { + return fallback, true + } + value, err := strconv.Atoi(raw) + if err != nil || value <= 0 { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid "+key) + return 0, false + } + return value, true +} + +func parsePositivePathInt(w http.ResponseWriter, r *http.Request, key string) (int, bool) { + value, err := strconv.Atoi(strings.TrimSpace(chi.URLParam(r, key))) + if err != nil || value <= 0 { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid "+key) + return 0, false + } + return value, true +} + +type requestIntegrationResponse struct { + Kind string `json:"kind"` + Enabled bool `json:"enabled"` + BaseURL string `json:"base_url"` + HasAPIKey bool `json:"has_api_key"` + RootFolder string `json:"root_folder"` + QualityProfileID *int `json:"quality_profile_id,omitempty"` + Tags []int `json:"tags"` + Options map[string]any `json:"options"` + LastCheckAt *time.Time `json:"last_check_at,omitempty"` + LastCheckStatus string `json:"last_check_status,omitempty"` + LastCheckError string `json:"last_check_error,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +func toIntegrationResponses(integrations []mediarequests.Integration) []requestIntegrationResponse { + out := make([]requestIntegrationResponse, 0, len(integrations)) + for _, integration := range integrations { + out = append(out, requestIntegrationResponse{ + Kind: integration.Kind, + Enabled: integration.Enabled, + BaseURL: integration.BaseURL, + HasAPIKey: strings.TrimSpace(integration.APIKeyRef) != "", + RootFolder: integration.RootFolder, + QualityProfileID: integration.QualityProfileID, + Tags: integration.Tags, + Options: integration.Options, + LastCheckAt: integration.LastCheckAt, + LastCheckStatus: integration.LastCheckStatus, + LastCheckError: integration.LastCheckError, + UpdatedAt: integration.UpdatedAt, + }) + } + return out +} + +func writeRequestServiceError(w http.ResponseWriter, err error) { + var quota mediarequests.QuotaError + switch { + case errors.As(err, "a): + writeJSON(w, http.StatusTooManyRequests, struct { + Error string `json:"error"` + Message string `json:"message"` + Used int `json:"used"` + Limit int `json:"limit"` + WindowDays int `json:"window_days"` + }{ + Error: "quota_exceeded", + Message: "Request quota exceeded", + Used: quota.Used, + Limit: quota.Limit, + WindowDays: quota.WindowDays, + }) + case errors.Is(err, mediarequests.ErrInvalidInput), errors.Is(err, mediarequests.ErrInvalidMediaType): + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + case errors.Is(err, mediarequests.ErrRequestsDisabled): + writeError(w, http.StatusForbidden, "requests_disabled", "Requests are disabled") + case errors.Is(err, mediarequests.ErrUserBlocked): + writeError(w, http.StatusForbidden, "requesting_blocked", "User is blocked from requesting") + case errors.Is(err, mediarequests.ErrAlreadyAvailable): + writeError(w, http.StatusConflict, "already_available", "Media is already available") + case errors.Is(err, mediarequests.ErrAlreadyRequested): + writeError(w, http.StatusConflict, "already_requested", "Media is already requested") + case errors.Is(err, mediarequests.ErrForbidden): + writeError(w, http.StatusForbidden, "forbidden", "Request access denied") + case errors.Is(err, mediarequests.ErrNotFound): + writeError(w, http.StatusNotFound, "not_found", "Request not found") + case errors.Is(err, mediarequests.ErrInvalidState): + writeError(w, http.StatusConflict, "invalid_state", "Request is not in a valid state for this action") + default: + writeError(w, http.StatusInternalServerError, "internal_error", "Request operation failed") + } +} diff --git a/internal/api/handlers/requests_test.go b/internal/api/handlers/requests_test.go new file mode 100644 index 00000000..c84934d4 --- /dev/null +++ b/internal/api/handlers/requests_test.go @@ -0,0 +1,241 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/auth" + mediarequests "github.com/Silo-Server/silo-server/internal/requests" +) + +type fakeRequestService struct { + listStudiosFn func() ([]mediarequests.DiscoverBrandCard, error) + listNetworksFn func() ([]mediarequests.DiscoverBrandCard, error) + listGenresFn func() ([]mediarequests.DiscoverBrandCard, error) + browseFn func(kind, slug string, mediaType mediarequests.MediaType, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) +} + +func (f *fakeRequestService) ListStudios(context.Context, mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error) { + if f.listStudiosFn != nil { + return f.listStudiosFn() + } + return nil, nil +} + +func (f *fakeRequestService) ListNetworks(context.Context, mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error) { + if f.listNetworksFn != nil { + return f.listNetworksFn() + } + return nil, nil +} + +func (f *fakeRequestService) ListGenres(context.Context, mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error) { + if f.listGenresFn != nil { + return f.listGenresFn() + } + return nil, nil +} + +func (f *fakeRequestService) BrowseStudio(_ context.Context, _ mediarequests.Viewer, slug, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) { + return f.browseFn("studio", slug, mediarequests.MediaTypeMovie, sort, page) +} + +func (f *fakeRequestService) BrowseNetwork(_ context.Context, _ mediarequests.Viewer, slug, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) { + return f.browseFn("network", slug, mediarequests.MediaTypeSeries, sort, page) +} + +func (f *fakeRequestService) BrowseGenre(_ context.Context, _ mediarequests.Viewer, slug string, mediaType mediarequests.MediaType, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) { + return f.browseFn("genre", slug, mediaType, sort, page) +} + +func (f *fakeRequestService) Search(context.Context, mediarequests.Viewer, string, mediarequests.MediaType, int) (*mediarequests.MediaPage, error) { + return nil, nil +} + +func (f *fakeRequestService) Discover(context.Context, mediarequests.Viewer, string, int) (*mediarequests.DiscoverySection, error) { + return nil, nil +} + +func (f *fakeRequestService) DiscoverAll(context.Context, mediarequests.Viewer) ([]mediarequests.DiscoverySection, error) { + return nil, nil +} + +func (f *fakeRequestService) GetDetail(context.Context, mediarequests.Viewer, mediarequests.MediaType, int) (*mediarequests.MediaDetail, error) { + return nil, nil +} + +func (f *fakeRequestService) CreateRequest(context.Context, mediarequests.Viewer, mediarequests.CreateRequestInput) (*mediarequests.Request, error) { + return nil, nil +} + +func (f *fakeRequestService) ListMine(context.Context, mediarequests.Viewer, mediarequests.ListFilter) ([]*mediarequests.Request, error) { + return nil, nil +} + +func (f *fakeRequestService) ListAdmin(context.Context, mediarequests.Viewer, mediarequests.ListFilter) ([]*mediarequests.Request, error) { + return nil, nil +} + +func (f *fakeRequestService) GetRequest(context.Context, mediarequests.Viewer, string) (*mediarequests.Request, error) { + return nil, nil +} + +func (f *fakeRequestService) Approve(context.Context, mediarequests.Viewer, string) (*mediarequests.Request, error) { + return nil, nil +} + +func (f *fakeRequestService) Decline(context.Context, mediarequests.Viewer, string, string) (*mediarequests.Request, error) { + return nil, nil +} + +func (f *fakeRequestService) Cancel(context.Context, mediarequests.Viewer, string, string) (*mediarequests.Request, error) { + return nil, nil +} + +func (f *fakeRequestService) Retry(context.Context, mediarequests.Viewer, string) (*mediarequests.Request, error) { + return nil, nil +} + +func (f *fakeRequestService) GetSettings(context.Context, mediarequests.Viewer) (mediarequests.Settings, error) { + return mediarequests.Settings{}, nil +} + +func (f *fakeRequestService) UpdateSettings(context.Context, mediarequests.Viewer, mediarequests.Settings) (mediarequests.Settings, error) { + return mediarequests.Settings{}, nil +} + +func (f *fakeRequestService) GetUserLimit(context.Context, mediarequests.Viewer, int) (*mediarequests.UserLimit, error) { + return nil, nil +} + +func (f *fakeRequestService) UpsertUserLimit(context.Context, mediarequests.Viewer, mediarequests.UserLimit) (*mediarequests.UserLimit, error) { + return nil, nil +} + +func (f *fakeRequestService) ListIntegrations(context.Context, mediarequests.Viewer) ([]mediarequests.Integration, error) { + return nil, nil +} + +func (f *fakeRequestService) UpsertIntegration(context.Context, mediarequests.Viewer, mediarequests.Integration) (*mediarequests.Integration, error) { + return nil, nil +} + +func (f *fakeRequestService) UpsertIntegrations(context.Context, mediarequests.Viewer, []mediarequests.Integration) ([]mediarequests.Integration, error) { + return nil, nil +} + +func (f *fakeRequestService) LoadIntegrationOptions(context.Context, mediarequests.Viewer, mediarequests.Integration) (*mediarequests.IntegrationOptions, error) { + return nil, nil +} + +func authedRequest(method, target string) *http.Request { + req := httptest.NewRequest(method, target, nil) + ctx := apimw.SetClaims(req.Context(), &auth.Claims{ + UserID: 1, + Role: "user", + TokenType: auth.TokenTypeAccess, + }) + ctx = apimw.SetProfileID(ctx, "profile-1") + return req.WithContext(ctx) +} + +func TestHandleListStudiosReturnsJSON(t *testing.T) { + logo := "https://image.tmdb.org/t/p/w300/x.png" + svc := &fakeRequestService{ + listStudiosFn: func() ([]mediarequests.DiscoverBrandCard, error) { + return []mediarequests.DiscoverBrandCard{ + {TMDBID: 420, Slug: "marvel-studios", DisplayName: "Marvel Studios", LogoURL: &logo}, + }, nil + }, + } + h := NewRequestsHandler(svc) + + rec := httptest.NewRecorder() + h.HandleListStudios(rec, authedRequest("GET", "/api/v1/requests/discover/studios")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var body struct { + Studios []mediarequests.DiscoverBrandCard `json:"studios"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if len(body.Studios) != 1 || body.Studios[0].Slug != "marvel-studios" { + t.Errorf("studios = %+v", body.Studios) + } +} + +func TestHandleBrowseStudioRejectsUnknownSort(t *testing.T) { + svc := &fakeRequestService{ + browseFn: func(kind, slug string, _ mediarequests.MediaType, sort string, _ int) (*mediarequests.DiscoverBrowseResponse, error) { + return nil, mediarequests.ErrInvalidInput + }, + } + h := NewRequestsHandler(svc) + + req := authedRequest("GET", "/api/v1/requests/discover/browse/studio/marvel-studios?sort=garbage") + rctx := chi.NewRouteContext() + rctx.URLParams.Add("slug", "marvel-studios") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + rec := httptest.NewRecorder() + h.HandleBrowseStudio(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } +} + +func TestHandleBrowseStudioUnknownSlugReturns404(t *testing.T) { + svc := &fakeRequestService{ + browseFn: func(string, string, mediarequests.MediaType, string, int) (*mediarequests.DiscoverBrowseResponse, error) { + return nil, mediarequests.ErrNotFound + }, + } + h := NewRequestsHandler(svc) + + req := authedRequest("GET", "/api/v1/requests/discover/browse/studio/ghosts") + rctx := chi.NewRouteContext() + rctx.URLParams.Add("slug", "ghosts") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + rec := httptest.NewRecorder() + h.HandleBrowseStudio(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } +} + +func TestHandleBrowseGenreRequiresMediaType(t *testing.T) { + svc := &fakeRequestService{ + browseFn: func(_ string, _ string, mt mediarequests.MediaType, _ string, _ int) (*mediarequests.DiscoverBrowseResponse, error) { + if strings.TrimSpace(string(mt)) == "" { + return nil, mediarequests.ErrInvalidInput + } + return &mediarequests.DiscoverBrowseResponse{Kind: "genre"}, nil + }, + } + h := NewRequestsHandler(svc) + + req := authedRequest("GET", "/api/v1/requests/discover/browse/genre/action") + rctx := chi.NewRouteContext() + rctx.URLParams.Add("slug", "action") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + rec := httptest.NewRecorder() + h.HandleBrowseGenre(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/api/router.go b/internal/api/router.go index 06b7aae8..20983d51 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -46,6 +46,9 @@ import ( "github.com/Silo-Server/silo-server/internal/plugins" "github.com/Silo-Server/silo-server/internal/ratelimit" "github.com/Silo-Server/silo-server/internal/recommendations" + mediarequests "github.com/Silo-Server/silo-server/internal/requests" + "github.com/Silo-Server/silo-server/internal/requests/radarr" + "github.com/Silo-Server/silo-server/internal/requests/sonarr" "github.com/Silo-Server/silo-server/internal/s3client" "github.com/Silo-Server/silo-server/internal/scanner" "github.com/Silo-Server/silo-server/internal/scanqueue" @@ -323,6 +326,7 @@ func NewRouter(deps Dependencies) chi.Router { var detailSvc *catalog.DetailService var calendarRepo *catalog.CalendarRepository var webhookSyncHandler *handlers.WebhookSyncHandler + var requestHandler *handlers.RequestsHandler if deps.DB != nil { browseRepo := catalog.NewBrowseRepository(deps.DB) itemRepo = catalog.NewItemRepository(deps.DB) @@ -382,6 +386,19 @@ func NewRouter(deps Dependencies) chi.Router { itemsHandler, ) + tmdbAPIKey := "" + if deps.Config != nil { + tmdbAPIKey = deps.Config.TMDBAPIKey + } + requestSvc := mediarequests.NewService( + mediarequests.NewRepository(deps.DB), + tmdb.NewClient(tmdbAPIKey, 40), + mediarequests.NewCatalogPresence(itemRepo, providerIDRepo), + ) + requestSvc.SetSecretResolver(settingsRepo) + requestSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil)) + requestHandler = handlers.NewRequestsHandler(requestSvc) + if deps.PersonRepo != nil { peopleHandler = handlers.NewPeopleHandler(deps.PersonRepo, browseRepo, itemRepo, detailSvc) peopleHandler.SetItemsHandler(itemsHandler) @@ -1374,6 +1391,26 @@ func NewRouter(deps Dependencies) chi.Router { }) } + if requestHandler != nil { + r.Route("/requests", func(r chi.Router) { + r.Use(apimw.RequireProfile) + r.Get("/search", requestHandler.HandleSearch) + r.Get("/discover", requestHandler.HandleDiscover) + r.Get("/discover/studios", requestHandler.HandleListStudios) + r.Get("/discover/networks", requestHandler.HandleListNetworks) + r.Get("/discover/genres", requestHandler.HandleListGenres) + r.Get("/discover/browse/studio/{slug}", requestHandler.HandleBrowseStudio) + r.Get("/discover/browse/network/{slug}", requestHandler.HandleBrowseNetwork) + r.Get("/discover/browse/genre/{slug}", requestHandler.HandleBrowseGenre) + r.Get("/discover/{section}", requestHandler.HandleDiscoverSection) + r.Get("/detail/{media_type}/{tmdb_id}", requestHandler.HandleGetDetail) + r.Post("/", requestHandler.HandleCreate) + r.Get("/mine", requestHandler.HandleListMine) + r.Get("/{id}", requestHandler.HandleGet) + r.Post("/{id}/cancel", requestHandler.HandleCancel) + }) + } + // Settings routes (user-scoped, no profile required). if settingsHandler != nil { r.Route("/settings", func(r chi.Router) { @@ -1870,6 +1907,21 @@ func NewRouter(deps Dependencies) chi.Router { r.Put("/api-keys/{id}/tier", apiKeyHandler.HandleAdminUpdateTier) } + if requestHandler != nil { + r.Get("/requests", requestHandler.HandleAdminList) + r.Post("/requests/{id}/approve", requestHandler.HandleApprove) + r.Post("/requests/{id}/decline", requestHandler.HandleDecline) + r.Post("/requests/{id}/cancel", requestHandler.HandleCancel) + r.Post("/requests/{id}/retry", requestHandler.HandleRetry) + r.Get("/request-settings", requestHandler.HandleGetSettings) + r.Put("/request-settings", requestHandler.HandleUpdateSettings) + r.Get("/request-users/{user_id}/limit", requestHandler.HandleGetUserLimit) + r.Put("/request-users/{user_id}/limit", requestHandler.HandleUpdateUserLimit) + r.Get("/request-integrations", requestHandler.HandleListIntegrations) + r.Put("/request-integrations", requestHandler.HandleUpdateIntegrations) + r.Post("/request-integrations/{kind}/options", requestHandler.HandleLoadIntegrationOptions) + } + if deps.ActivityLogRepo != nil { adminIPHandler := handlers.NewAdminIPHandler(deps.ActivityLogRepo) r.Get("/users/{id}/ips", adminIPHandler.HandleGetUserIPs) diff --git a/internal/catalog/item_repo.go b/internal/catalog/item_repo.go index f398c006..c2ebcb23 100644 --- a/internal/catalog/item_repo.go +++ b/internal/catalog/item_repo.go @@ -1330,6 +1330,20 @@ type MediaTMDBRow struct { Title string } +type ExternalIDLookupCandidate struct { + TMDBID string + TVDBID string + IMDbID string +} + +type ExternalIDMatchRow struct { + QueryTMDBID string + MediaID string + MatchedProvider string + LibraryID string + Title string +} + // LookupTMDBIDs returns one row per matching media item that has its tmdb_id // in the supplied list and is linked to at least one enabled library. // mediaType is "movie" or "series" (silo's internal naming). @@ -1341,38 +1355,121 @@ func (r *ItemRepository) LookupTMDBIDs(ctx context.Context, mediaType string, tm if len(tmdbIDs) == 0 { return nil, nil } - // Convert string IDs to int: TMDB IDs are stored as text in media_items - // but the plugin sends them as strings. We do an ANY match directly on - // the text column so no int conversion is required. - rows, err := r.pool.Query(ctx, ` - SELECT DISTINCT ON (mi.content_id) - mi.content_id, - COALESCE(mi.tmdb_id, ''), - mil.media_folder_id::text, - mi.title - FROM media_items mi - JOIN media_item_libraries mil ON mil.content_id = mi.content_id - JOIN media_folders mf ON mf.id = mil.media_folder_id - WHERE mi.tmdb_id = ANY($1) - AND mi.type = $2 - AND mf.enabled = true - ORDER BY mi.content_id, mil.media_folder_id ASC - `, tmdbIDs, mediaType) + candidates := make([]ExternalIDLookupCandidate, 0, len(tmdbIDs)) + for _, id := range tmdbIDs { + if strings.TrimSpace(id) != "" { + candidates = append(candidates, ExternalIDLookupCandidate{TMDBID: id}) + } + } + rows, err := r.LookupExternalIDs(ctx, mediaType, candidates) if err != nil { - return nil, fmt.Errorf("lookup tmdb ids: %w", err) + return nil, err + } + out := make([]MediaTMDBRow, 0, len(rows)) + for _, row := range rows { + out = append(out, MediaTMDBRow{ + MediaID: row.MediaID, + TMDBID: row.QueryTMDBID, + LibraryID: row.LibraryID, + Title: row.Title, + }) + } + return out, nil +} + +func lookupExternalIDsSQL() string { + return ` + WITH requested(query_tmdb_id, provider, provider_id, ord) AS ( + SELECT * FROM unnest($1::text[], $2::text[], $3::text[], $4::int[]) + ), + direct_matches AS ( + SELECT r.query_tmdb_id, mi.content_id, r.provider, mil.media_folder_id::text, mi.title, r.ord, + CASE r.provider WHEN 'tmdb' THEN 0 WHEN 'tvdb' THEN 1 WHEN 'imdb' THEN 2 ELSE 3 END AS provider_rank + FROM requested r + JOIN media_items mi + ON mi.type = $5 + AND ( + (r.provider = 'tmdb' AND mi.tmdb_id <> '' AND mi.tmdb_id = r.provider_id) + OR (r.provider = 'tvdb' AND mi.tvdb_id <> '' AND mi.tvdb_id = r.provider_id) + OR (r.provider = 'imdb' AND mi.imdb_id <> '' AND mi.imdb_id = r.provider_id) + ) + JOIN media_item_libraries mil ON mil.content_id = mi.content_id + JOIN media_folders mf ON mf.id = mil.media_folder_id + WHERE mf.enabled = true + ), + provider_matches AS ( + SELECT r.query_tmdb_id, mi.content_id, r.provider, mil.media_folder_id::text, mi.title, r.ord, + CASE r.provider WHEN 'tmdb' THEN 0 WHEN 'tvdb' THEN 1 WHEN 'imdb' THEN 2 ELSE 3 END AS provider_rank + FROM requested r + JOIN media_item_provider_ids mip + ON mip.provider = r.provider + AND mip.provider_id = r.provider_id + AND mip.item_type = $5 + JOIN media_items mi ON mi.content_id = mip.content_id AND mi.type = $5 + JOIN media_item_libraries mil ON mil.content_id = mi.content_id + JOIN media_folders mf ON mf.id = mil.media_folder_id + WHERE mf.enabled = true + ) + SELECT DISTINCT ON (query_tmdb_id) + query_tmdb_id, content_id, provider, media_folder_id, title + FROM ( + SELECT * FROM direct_matches + UNION ALL + SELECT * FROM provider_matches + ) matches + ORDER BY query_tmdb_id, provider_rank ASC, ord ASC, content_id ASC, media_folder_id ASC` +} + +func (r *ItemRepository) LookupExternalIDs( + ctx context.Context, + mediaType string, + candidates []ExternalIDLookupCandidate, +) ([]ExternalIDMatchRow, error) { + if len(candidates) == 0 { + return nil, nil + } + + queryTMDBIDs := make([]string, 0, len(candidates)*3) + providers := make([]string, 0, len(candidates)*3) + providerIDs := make([]string, 0, len(candidates)*3) + ordinals := make([]int32, 0, len(candidates)*3) + + appendID := func(candidate ExternalIDLookupCandidate, provider, providerID string, ordinal int) { + providerID = strings.TrimSpace(providerID) + if providerID == "" { + return + } + queryTMDBIDs = append(queryTMDBIDs, strings.TrimSpace(candidate.TMDBID)) + providers = append(providers, provider) + providerIDs = append(providerIDs, providerID) + ordinals = append(ordinals, int32(ordinal)) + } + + for i, candidate := range candidates { + appendID(candidate, "tmdb", candidate.TMDBID, i) + appendID(candidate, "tvdb", candidate.TVDBID, i) + appendID(candidate, "imdb", candidate.IMDbID, i) + } + if len(providerIDs) == 0 { + return nil, nil + } + + rows, err := r.pool.Query(ctx, lookupExternalIDsSQL(), queryTMDBIDs, providers, providerIDs, ordinals, mediaType) + if err != nil { + return nil, fmt.Errorf("lookup external ids: %w", err) } defer rows.Close() - var out []MediaTMDBRow + out := make([]ExternalIDMatchRow, 0) for rows.Next() { - var row MediaTMDBRow - if err := rows.Scan(&row.MediaID, &row.TMDBID, &row.LibraryID, &row.Title); err != nil { - return nil, fmt.Errorf("scanning tmdb lookup row: %w", err) + var row ExternalIDMatchRow + if err := rows.Scan(&row.QueryTMDBID, &row.MediaID, &row.MatchedProvider, &row.LibraryID, &row.Title); err != nil { + return nil, fmt.Errorf("scanning external id lookup row: %w", err) } out = append(out, row) } if err := rows.Err(); err != nil { - return nil, fmt.Errorf("iterating tmdb lookup rows: %w", err) + return nil, fmt.Errorf("iterating external id lookup rows: %w", err) } return out, nil } diff --git a/internal/catalog/item_repo_test.go b/internal/catalog/item_repo_test.go index d39f2784..b8c396ad 100644 --- a/internal/catalog/item_repo_test.go +++ b/internal/catalog/item_repo_test.go @@ -191,6 +191,37 @@ func TestItemRepo_GetByExternalIDs_NilSliceStillBindsArg(t *testing.T) { _ = args } +func TestLookupExternalIDsSQLChecksProviderTableAndDirectColumns(t *testing.T) { + sql := lookupExternalIDsSQL() + + for _, want := range []string{ + "FROM requested r", + "JOIN media_item_provider_ids mip", + "mip.provider = r.provider", + "mip.provider_id = r.provider_id", + "mip.item_type = $5", + "mi.type = $5", + "mi.tmdb_id <> '' AND mi.tmdb_id = r.provider_id", + "mi.tvdb_id <> '' AND mi.tvdb_id = r.provider_id", + "mi.imdb_id <> '' AND mi.imdb_id = r.provider_id", + "JOIN media_folders mf ON mf.id = mil.media_folder_id", + "mf.enabled = true", + } { + if !strings.Contains(sql, want) { + t.Fatalf("lookupExternalIDsSQL missing %q:\n%s", want, sql) + } + } + for _, disallowed := range []string{ + "COALESCE(mi.tmdb_id, '') = r.provider_id", + "COALESCE(mi.tvdb_id, '') = r.provider_id", + "COALESCE(mi.imdb_id, '') = r.provider_id", + } { + if strings.Contains(sql, disallowed) { + t.Fatalf("lookupExternalIDsSQL should use indexable direct predicate, found %q:\n%s", disallowed, sql) + } + } +} + // TestItemRepo_Search_UsesWindowCount asserts that buildSearchSQL emits a // single-pass paged SELECT that includes COUNT(*) OVER () so Search no longer // needs a separate count query before the data fetch (audit 2026-05-01 §3.11). diff --git a/internal/catalog/provider_id_repo.go b/internal/catalog/provider_id_repo.go index 6d0386ca..6022f846 100644 --- a/internal/catalog/provider_id_repo.go +++ b/internal/catalog/provider_id_repo.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "sort" + "strconv" "strings" "github.com/jackc/pgx/v5" @@ -30,6 +31,102 @@ func NewProviderIDRepository(pool *pgxpool.Pool) *ProviderIDRepository { return &ProviderIDRepository{pool: pool} } +func (r *ProviderIDRepository) AttachTMDBID(ctx context.Context, contentID, itemType string, tmdbID int) error { + contentID = strings.TrimSpace(contentID) + itemType = strings.TrimSpace(itemType) + if contentID == "" { + return fmt.Errorf("content_id is required") + } + if itemType == "" { + return fmt.Errorf("item_type is required") + } + if tmdbID <= 0 { + return fmt.Errorf("tmdb_id must be positive") + } + + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin attach tmdb transaction: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + + tmdbText := strconv.Itoa(tmdbID) + var existingType, existingTMDBID string + if err := tx.QueryRow(ctx, ` + SELECT type, COALESCE(tmdb_id, '') + FROM media_items + WHERE content_id = $1 + FOR UPDATE + `, contentID).Scan(&existingType, &existingTMDBID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("loading media item for tmdb attach: content not found") + } + return fmt.Errorf("loading media item for tmdb attach: %w", err) + } + if existingType != itemType { + return fmt.Errorf("media item type mismatch: got %q, want %q", existingType, itemType) + } + if existingTMDBID != "" && existingTMDBID != tmdbText { + return fmt.Errorf("media item tmdb id conflict: got %q, want %q", existingTMDBID, tmdbText) + } + + var existingProviderTMDBID string + err = tx.QueryRow(ctx, ` + SELECT provider_id + FROM media_item_provider_ids + WHERE content_id = $1 AND provider = 'tmdb' + FOR UPDATE + `, contentID).Scan(&existingProviderTMDBID) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("loading media item tmdb provider id: %w", err) + } + if existingProviderTMDBID != "" && existingProviderTMDBID != tmdbText { + return fmt.Errorf("media item tmdb provider id conflict: got %q, want %q", existingProviderTMDBID, tmdbText) + } + + var existingOwnerContentID string + err = tx.QueryRow(ctx, ` + SELECT content_id + FROM media_items + WHERE type = $1 + AND tmdb_id = $2 + AND content_id <> $3 + LIMIT 1 + `, itemType, tmdbText, contentID).Scan(&existingOwnerContentID) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("checking tmdb id owner: %w", err) + } + if existingOwnerContentID != "" { + return fmt.Errorf("tmdb id %q already belongs to content_id %q", tmdbText, existingOwnerContentID) + } + + if _, err := tx.Exec(ctx, ` + UPDATE media_items + SET tmdb_id = $1, + updated_at = NOW() + WHERE content_id = $2 + AND type = $3 + `, tmdbText, contentID, itemType); err != nil { + return fmt.Errorf("updating media item tmdb id: %w", err) + } + + if _, err := tx.Exec(ctx, ` + INSERT INTO media_item_provider_ids (content_id, item_type, provider, provider_id, created_at, updated_at) + VALUES ($1, $2, 'tmdb', $3, NOW(), NOW()) + ON CONFLICT (content_id, provider) DO UPDATE + SET item_type = EXCLUDED.item_type, + provider_id = EXCLUDED.provider_id, + updated_at = NOW() + `, contentID, itemType, tmdbText); err != nil { + return fmt.Errorf("upserting media item tmdb provider id: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit attach tmdb transaction: %w", err) + } + return nil +} + const providerIDColumns = `content_id, item_type, provider, provider_id, created_at, updated_at` var excludedProviderIDs = map[string]struct{}{ diff --git a/internal/catalog/provider_id_repo_test.go b/internal/catalog/provider_id_repo_test.go index 29472b9a..6f8c016d 100644 --- a/internal/catalog/provider_id_repo_test.go +++ b/internal/catalog/provider_id_repo_test.go @@ -40,3 +40,17 @@ func TestNormalizeDurableProviderIDsOrdersCanonicalKeysFirst(t *testing.T) { } } } + +func TestNormalizeDurableProviderIDsKeepsTMDBFirstForBackfill(t *testing.T) { + entries := normalizeDurableProviderIDs(map[string]string{ + "tvdb": "420105", + "imdb": "tt18076310", + "tmdb": "201992", + }) + if len(entries) != 3 { + t.Fatalf("len(entries) = %d, want 3", len(entries)) + } + if entries[0].Provider != "tmdb" || entries[0].ProviderID != "201992" { + t.Fatalf("first entry = (%q, %q), want tmdb/201992", entries[0].Provider, entries[0].ProviderID) + } +} diff --git a/internal/metadata/match_candidates.go b/internal/metadata/match_candidates.go index 9fac62fa..742dfc72 100644 --- a/internal/metadata/match_candidates.go +++ b/internal/metadata/match_candidates.go @@ -25,12 +25,40 @@ type MatchCandidate struct { AgreementHints []string `json:"agreement_hints"` } +var canonicalCandidateIDKeys = []string{"tmdb", "tvdb", "imdb"} + +func compatibleProviderIDs(left, right map[string]string) bool { + overlap := false + for _, key := range canonicalCandidateIDKeys { + lv := strings.TrimSpace(left[key]) + rv := strings.TrimSpace(right[key]) + if lv == "" || rv == "" { + continue + } + if lv != rv { + return false + } + overlap = true + } + return overlap +} + +func providerIDRichness(ids map[string]string) int { + score := 0 + for _, key := range canonicalCandidateIDKeys { + if strings.TrimSpace(ids[key]) != "" { + score++ + } + } + return score +} + // normalizedKey returns a stable grouping key from provider IDs. // Results with identical provider ID fingerprints (the exact set of // tmdb/tvdb/imdb key=value pairs) are considered the same candidate. func normalizedKey(ids map[string]string) string { var parts []string - for _, k := range []string{"tmdb", "tvdb", "imdb"} { + for _, k := range canonicalCandidateIDKeys { if v, ok := ids[k]; ok && v != "" { parts = append(parts, k+"="+v) } @@ -59,7 +87,16 @@ func NormalizeCandidates(results []SearchResult, contentType string) []MatchCand buckets := make(map[string]*bucket) for _, sr := range results { - key := normalizedKey(sr.ProviderIDs) + key := "" + for _, existingKey := range ordered { + if compatibleProviderIDs(buckets[existingKey].candidate.ProviderIDs, sr.ProviderIDs) { + key = existingKey + break + } + } + if key == "" { + key = normalizedKey(sr.ProviderIDs) + } if key == "" { // Cannot group by provider IDs; create a synthetic unique key. key = sr.Provider + ":" + sr.Name + ":" + strings.Repeat("?", len(ordered)) @@ -193,6 +230,7 @@ func scoreMatchCandidate(hints *MatchHints, candidate MatchCandidate) float64 { if len(candidate.ProviderIDs) > 0 { score += 5 + score += float64(providerIDRichness(candidate.ProviderIDs)) } return score diff --git a/internal/metadata/match_candidates_test.go b/internal/metadata/match_candidates_test.go index e2535214..ffba429d 100644 --- a/internal/metadata/match_candidates_test.go +++ b/internal/metadata/match_candidates_test.go @@ -61,6 +61,61 @@ func TestNormalizeCandidates(t *testing.T) { } }, }, + { + name: "merge compatible candidates with overlapping provider IDs", + results: []SearchResult{ + { + Name: "The Rookie: Feds", + Year: 2022, + Provider: "tvdb", + ProviderIDs: map[string]string{"tvdb": "420105", "imdb": "tt18076310"}, + }, + { + Name: "The Rookie: Feds", + Year: 2022, + Provider: "tmdb", + ProviderIDs: map[string]string{"tmdb": "201992", "tvdb": "420105", "imdb": "tt18076310"}, + }, + }, + content: "series", + wantLen: 1, + check: func(t *testing.T, candidates []MatchCandidate) { + c := candidates[0] + if c.ProviderIDs["tmdb"] != "201992" { + t.Fatalf("tmdb id = %q, want 201992", c.ProviderIDs["tmdb"]) + } + if c.ProviderIDs["tvdb"] != "420105" || c.ProviderIDs["imdb"] != "tt18076310" { + t.Fatalf("provider ids = %+v, want tvdb and imdb preserved", c.ProviderIDs) + } + if len(c.Sources) != 2 { + t.Fatalf("sources = %+v, want two providers", c.Sources) + } + }, + }, + { + name: "do not merge candidates with conflicting overlapping provider IDs", + results: []SearchResult{ + { + Name: "Show A", + Year: 2022, + Provider: "tvdb", + ProviderIDs: map[string]string{"tvdb": "420105", "imdb": "tt18076310"}, + }, + { + Name: "Show B", + Year: 2022, + Provider: "tmdb", + ProviderIDs: map[string]string{"tmdb": "201992", "tvdb": "999999", "imdb": "tt18076310"}, + }, + }, + content: "series", + wantLen: 2, + check: func(t *testing.T, candidates []MatchCandidate) { + if len(candidates) != 2 { + t.Fatalf("len(candidates) = %d, want 2", len(candidates)) + } + }, + }, { name: "no recognized provider IDs gets synthetic key and stays separate", results: []SearchResult{ diff --git a/internal/metadata/refresh_debt.go b/internal/metadata/refresh_debt.go index 0e57dc4b..eaca1fb3 100644 --- a/internal/metadata/refresh_debt.go +++ b/internal/metadata/refresh_debt.go @@ -8,10 +8,11 @@ import ( ) const ( - RefreshDebtReasonEpisodeIncomplete int64 = 1 << iota - RefreshDebtReasonStaleProviderID - RefreshDebtReasonRefreshFailure - RefreshDebtReasonCoreMetadataIncomplete + RefreshDebtReasonEpisodeIncomplete int64 = 1 + RefreshDebtReasonStaleProviderID int64 = 2 + RefreshDebtReasonRefreshFailure int64 = 4 + RefreshDebtReasonCoreMetadataIncomplete int64 = 8 + RefreshDebtReasonProviderIDIncomplete int64 = 16 ) const ( @@ -43,6 +44,8 @@ func refreshDebtPriority(reasonMask int64) int { return 300 case hasRefreshDebtReason(reasonMask, RefreshDebtReasonStaleProviderID): return 250 + case hasRefreshDebtReason(reasonMask, RefreshDebtReasonProviderIDIncomplete): + return 240 case hasRefreshDebtReason(reasonMask, RefreshDebtReasonRefreshFailure): return 200 case hasRefreshDebtReason(reasonMask, RefreshDebtReasonCoreMetadataIncomplete): @@ -95,12 +98,25 @@ func refreshDebtReasonsForItem(item *models.MediaItem) int64 { if hasCoreMetadataRefreshDebt(item) { reasonMask |= RefreshDebtReasonCoreMetadataIncomplete } + if hasProviderIDRefreshDebt(item) { + reasonMask |= RefreshDebtReasonProviderIDIncomplete + } if item.RefreshFailures > 0 && strings.EqualFold(strings.TrimSpace(item.Status), "matched") { reasonMask |= RefreshDebtReasonRefreshFailure } return reasonMask } +func hasProviderIDRefreshDebt(item *models.MediaItem) bool { + if item == nil || !strings.EqualFold(strings.TrimSpace(item.Status), "matched") { + return false + } + if strings.TrimSpace(item.TmdbID) != "" { + return false + } + return strings.TrimSpace(item.TvdbID) != "" || strings.TrimSpace(item.ImdbID) != "" +} + func hasCoreMetadataRefreshDebt(item *models.MediaItem) bool { if item == nil || !strings.EqualFold(strings.TrimSpace(item.Status), "matched") { return false diff --git a/internal/metadata/refresh_debt_repo.go b/internal/metadata/refresh_debt_repo.go index 741b3712..de19aa9b 100644 --- a/internal/metadata/refresh_debt_repo.go +++ b/internal/metadata/refresh_debt_repo.go @@ -448,6 +448,7 @@ func (r *RefreshDebtRepository) GetMetrics(ctx context.Context, sampleLimit int) }{ {reason: "episode_incomplete", mask: RefreshDebtReasonEpisodeIncomplete}, {reason: "stale_provider_id", mask: RefreshDebtReasonStaleProviderID}, + {reason: "provider_id_incomplete", mask: RefreshDebtReasonProviderIDIncomplete}, {reason: "refresh_failure", mask: RefreshDebtReasonRefreshFailure}, {reason: "core_metadata_incomplete", mask: RefreshDebtReasonCoreMetadataIncomplete}, } diff --git a/internal/metadata/refresh_debt_test.go b/internal/metadata/refresh_debt_test.go index 64f197a7..b69d0f81 100644 --- a/internal/metadata/refresh_debt_test.go +++ b/internal/metadata/refresh_debt_test.go @@ -39,6 +39,66 @@ func TestRefreshDebtReasonsForItemSkipsUnmatchedFailureOnly(t *testing.T) { } } +func TestRefreshDebtReasonsForItemFlagsMissingTMDBWithOtherProviderIDs(t *testing.T) { + item := &models.MediaItem{ + Type: "series", + Status: "matched", + TvdbID: "420105", + ImdbID: "tt18076310", + TmdbID: "", + } + + mask := refreshDebtReasonsForItem(item) + if !hasRefreshDebtReason(mask, RefreshDebtReasonProviderIDIncomplete) { + t.Fatalf("reason mask = %d, want provider id incomplete", mask) + } +} + +func TestRefreshDebtReasonsForItemDoesNotFlagProviderIDIncompleteWithoutAlternateIDs(t *testing.T) { + item := &models.MediaItem{ + Type: "series", + Status: "matched", + TmdbID: "", + } + + mask := refreshDebtReasonsForItem(item) + if hasRefreshDebtReason(mask, RefreshDebtReasonProviderIDIncomplete) { + t.Fatalf("reason mask = %d, did not want provider id incomplete", mask) + } +} + +func TestRefreshDebtReasonMaskValuesAreStable(t *testing.T) { + tests := map[string]int64{ + "episode_incomplete": 1, + "stale_provider_id": 2, + "refresh_failure": 4, + "core_metadata_incomplete": 8, + "provider_id_incomplete": 16, + } + got := map[string]int64{ + "episode_incomplete": RefreshDebtReasonEpisodeIncomplete, + "stale_provider_id": RefreshDebtReasonStaleProviderID, + "refresh_failure": RefreshDebtReasonRefreshFailure, + "core_metadata_incomplete": RefreshDebtReasonCoreMetadataIncomplete, + "provider_id_incomplete": RefreshDebtReasonProviderIDIncomplete, + } + for name, want := range tests { + if got[name] != want { + t.Fatalf("%s mask = %d, want %d", name, got[name], want) + } + } +} + +func TestRefreshDebtPriorityProviderIDIncomplete(t *testing.T) { + if got := refreshDebtPriority(RefreshDebtReasonProviderIDIncomplete); got != 240 { + t.Fatalf("provider id incomplete priority = %d, want 240", got) + } + combined := RefreshDebtReasonProviderIDIncomplete | RefreshDebtReasonStaleProviderID + if got := refreshDebtPriority(combined); got != 250 { + t.Fatalf("combined stale/provider priority = %d, want stale priority 250", got) + } +} + func TestNextRefreshDelayEpisodeSchedule(t *testing.T) { reasonMask := RefreshDebtReasonEpisodeIncomplete cases := []struct { diff --git a/internal/metadata/service.go b/internal/metadata/service.go index 6e235d20..39c13fa1 100644 --- a/internal/metadata/service.go +++ b/internal/metadata/service.go @@ -1985,7 +1985,11 @@ func (s *MetadataService) syncRefreshDebtFailure(ctx context.Context, contentID return err } reasonMask |= staleReason - if strings.EqualFold(strings.TrimSpace(item.Status), "matched") { + if strings.EqualFold(strings.TrimSpace(item.Status), "matched") && + !hasRefreshDebtReason(reasonMask, RefreshDebtReasonProviderIDIncomplete) { + // Items missing provider IDs fail for that reason, not because the + // refresh itself errored. Tagging them with RefreshFailure muddies + // the metrics — the priority logic already prefers ProviderIDIncomplete. reasonMask |= RefreshDebtReasonRefreshFailure } if reasonMask == 0 { diff --git a/internal/metadata/tmdb/client.go b/internal/metadata/tmdb/client.go index 8a871929..57bab21b 100644 --- a/internal/metadata/tmdb/client.go +++ b/internal/metadata/tmdb/client.go @@ -7,40 +7,55 @@ import ( "io" "net/http" "net/url" + "sort" "strconv" "strings" "time" + "github.com/Silo-Server/silo-server/internal/cache" + "golang.org/x/sync/singleflight" "golang.org/x/time/rate" ) const ( defaultBaseURL = "https://api.themoviedb.org/3" - defaultAPIKey = "4ef0d7355d9ffb5151e987764708ce96" + projectAPIKey = "4ef0d7355d9ffb5151e987764708ce96" maxRetries = 3 maxResponseBody = 1 << 20 // 1 MB maxCollectionPresetResults = 500 + defaultResponseCacheTTL = 2 * time.Hour ) // Client is an HTTP client for the TMDB collection preset API surface. type Client struct { - httpClient *http.Client - apiKey string - baseURL string - limiter *rate.Limiter + httpClient *http.Client + apiKey string + baseURL string + limiter *rate.Limiter + discoverSectionCache *cache.TTLCache[*MediaPage] + discoverPageCache *cache.TTLCache[*MediaPage] + externalIDCache *cache.TTLCache[*ExternalIDs] + cacheGroup singleflight.Group + responseCacheTTL time.Duration } // NewClient creates a TMDB API client with the given API key and rate limit -// (requests per second). If apiKey is empty the built-in project key is used. +// (requests per second). If apiKey is empty, Silo's public project API key is +// used. func NewClient(apiKey string, rateLimit int) *Client { + apiKey = strings.TrimSpace(apiKey) if apiKey == "" { - apiKey = defaultAPIKey + apiKey = projectAPIKey } return &Client{ - httpClient: &http.Client{Timeout: 30 * time.Second}, - apiKey: apiKey, - baseURL: defaultBaseURL, - limiter: rate.NewLimiter(rate.Limit(rateLimit), rateLimit), + httpClient: &http.Client{Timeout: 30 * time.Second}, + apiKey: apiKey, + baseURL: defaultBaseURL, + limiter: rate.NewLimiter(rate.Limit(rateLimit), rateLimit), + discoverSectionCache: cache.NewTTLCache[*MediaPage](), + discoverPageCache: cache.NewTTLCache[*MediaPage](), + externalIDCache: cache.NewTTLCache[*ExternalIDs](), + responseCacheTTL: defaultResponseCacheTTL, } } @@ -49,6 +64,22 @@ func (c *Client) SetBaseURL(url string) { c.baseURL = url } +// Close releases background cache sweepers owned by the client. +func (c *Client) Close() { + if c == nil { + return + } + if c.discoverSectionCache != nil { + c.discoverSectionCache.Close() + } + if c.discoverPageCache != nil { + c.discoverPageCache.Close() + } + if c.externalIDCache != nil { + c.externalIDCache.Close() + } +} + // doGet executes a GET request against the TMDB API with rate limiting, // exponential backoff on 5xx/429, and JSON decoding into dest. func (c *Client) doGet(ctx context.Context, path string, dest any) error { @@ -133,6 +164,250 @@ func retryAfterOrDefault(resp *http.Response, attempt int) time.Duration { return time.Duration(1< 0 { + c.discoverSectionCache.Set(cacheKey, cached, c.responseCacheTTL) + } + return cached, nil + }) + if err != nil { + return nil, err + } + pageValue, ok := value.(*MediaPage) + if !ok { + return nil, fmt.Errorf("tmdb: invalid cached discovery section response") + } + return cloneMediaPage(pageValue), nil +} + +func (c *Client) fetchDiscoverSection(ctx context.Context, section string, page int) (*MediaPage, error) { + values := url.Values{} + values.Set("page", strconv.Itoa(page)) + + switch section { + case "trending_movies": + var resp paginatedResponse[mediaMovieResponse] + if err := c.doGet(ctx, "/trending/movie/week?"+values.Encode(), &resp); err != nil { + return nil, err + } + return normalizeMoviePage(resp), nil + case "trending_series": + var resp paginatedResponse[mediaTVResponse] + if err := c.doGet(ctx, "/trending/tv/week?"+values.Encode(), &resp); err != nil { + return nil, err + } + return normalizeTVPage(resp), nil + case "popular_movies": + var resp paginatedResponse[mediaMovieResponse] + if err := c.doGet(ctx, "/movie/popular?"+values.Encode(), &resp); err != nil { + return nil, err + } + return normalizeMoviePage(resp), nil + case "popular_series": + var resp paginatedResponse[mediaTVResponse] + if err := c.doGet(ctx, "/tv/popular?"+values.Encode(), &resp); err != nil { + return nil, err + } + return normalizeTVPage(resp), nil + case "upcoming_movies": + var resp paginatedResponse[mediaMovieResponse] + if err := c.doGet(ctx, "/movie/upcoming?"+values.Encode(), &resp); err != nil { + return nil, err + } + return normalizeMoviePage(resp), nil + case "on_air_series": + var resp paginatedResponse[mediaTVResponse] + if err := c.doGet(ctx, "/tv/on_the_air?"+values.Encode(), &resp); err != nil { + return nil, err + } + return normalizeTVPage(resp), nil + default: + return nil, fmt.Errorf("tmdb: invalid discovery section: %q", section) + } +} + +func cloneMediaPage(page *MediaPage) *MediaPage { + if page == nil { + return nil + } + cloned := *page + if page.Results != nil { + cloned.Results = append([]MediaResult(nil), page.Results...) + } + return &cloned +} + +func normalizeMoviePage(resp paginatedResponse[mediaMovieResponse]) *MediaPage { + page := &MediaPage{ + Page: resp.Page, + TotalPages: resp.TotalPages, + TotalResults: resp.TotalResults, + Results: make([]MediaResult, 0, len(resp.Results)), + } + for _, item := range resp.Results { + page.Results = append(page.Results, MediaResult{ + ID: item.ID, + MediaType: "movie", + Title: item.Title, + Overview: item.Overview, + PosterPath: item.PosterPath, + BackdropPath: item.BackdropPath, + ReleaseDate: item.ReleaseDate, + Year: releaseYear(item.ReleaseDate), + Popularity: item.Popularity, + VoteAverage: item.VoteAverage, + }) + } + return page +} + +func normalizeTVPage(resp paginatedResponse[mediaTVResponse]) *MediaPage { + page := &MediaPage{ + Page: resp.Page, + TotalPages: resp.TotalPages, + TotalResults: resp.TotalResults, + Results: make([]MediaResult, 0, len(resp.Results)), + } + for _, item := range resp.Results { + page.Results = append(page.Results, MediaResult{ + ID: item.ID, + MediaType: "series", + Title: item.Name, + Overview: item.Overview, + PosterPath: item.PosterPath, + BackdropPath: item.BackdropPath, + ReleaseDate: item.FirstAirDate, + Year: releaseYear(item.FirstAirDate), + Popularity: item.Popularity, + VoteAverage: item.VoteAverage, + }) + } + return page +} + +func normalizeMultiSearchPage(resp paginatedResponse[mediaMultiSearchResponse]) *MediaPage { + page := &MediaPage{ + Page: resp.Page, + TotalPages: resp.TotalPages, + TotalResults: resp.TotalResults, + Results: make([]MediaResult, 0, len(resp.Results)), + } + for _, item := range resp.Results { + switch item.MediaType { + case "movie": + page.Results = append(page.Results, MediaResult{ + ID: item.ID, + MediaType: "movie", + Title: item.Title, + Overview: item.Overview, + PosterPath: item.PosterPath, + BackdropPath: item.BackdropPath, + ReleaseDate: item.ReleaseDate, + Year: releaseYear(item.ReleaseDate), + Popularity: item.Popularity, + VoteAverage: item.VoteAverage, + }) + case "tv": + page.Results = append(page.Results, MediaResult{ + ID: item.ID, + MediaType: "series", + Title: item.Name, + Overview: item.Overview, + PosterPath: item.PosterPath, + BackdropPath: item.BackdropPath, + ReleaseDate: item.FirstAirDate, + Year: releaseYear(item.FirstAirDate), + Popularity: item.Popularity, + VoteAverage: item.VoteAverage, + }) + } + } + return page +} + +func releaseYear(value string) int { + if len(value) < 4 { + return 0 + } + year, err := strconv.Atoi(value[:4]) + if err != nil { + return 0 + } + return year +} + func normalizeCollectionPreset(preset, mediaType, timeWindow string) (string, string, string, string, error) { switch preset { case "trending": @@ -358,6 +633,75 @@ func (c *Client) Discover(ctx context.Context, mediaType string, params Discover return results, nil } +// DiscoverPage fetches a single page from TMDB's /discover/{movie,tv} endpoint +// and returns the full MediaPage shape (with posters, overviews, etc.) so the +// request system can enrich it with availability and request state. Unlike +// Discover (which is intended for collection templates and returns just IDs +// and titles), DiscoverPage exposes single-page semantics: callers control +// pagination explicitly. +func (c *Client) DiscoverPage(ctx context.Context, mediaType string, params DiscoverParams, page int) (*MediaPage, error) { + switch mediaType { + case "movie", "tv": + default: + return nil, fmt.Errorf("tmdb: invalid media type for discover: %q", mediaType) + } + if strings.TrimSpace(params.SortBy) == "" { + return nil, fmt.Errorf("tmdb: discover requires sort_by") + } + if page <= 0 { + page = 1 + } + + query := buildDiscoverQuery(mediaType, params) + "&page=" + strconv.Itoa(page) + path := "/discover/" + mediaType + "?" + query + cacheKey := "discover_page:" + path + if c.discoverPageCache != nil { + if cached, ok := c.discoverPageCache.Get(cacheKey); ok { + return cloneMediaPage(cached), nil + } + } + + value, err, _ := c.cacheGroup.Do(cacheKey, func() (any, error) { + if c.discoverPageCache != nil { + if cached, ok := c.discoverPageCache.Get(cacheKey); ok { + return cached, nil + } + } + page, err := c.fetchDiscoverPage(ctx, mediaType, path) + if err != nil { + return nil, err + } + cached := cloneMediaPage(page) + if c.discoverPageCache != nil && c.responseCacheTTL > 0 { + c.discoverPageCache.Set(cacheKey, cached, c.responseCacheTTL) + } + return cached, nil + }) + if err != nil { + return nil, err + } + pageValue, ok := value.(*MediaPage) + if !ok { + return nil, fmt.Errorf("tmdb: invalid cached discover page response") + } + return cloneMediaPage(pageValue), nil +} + +func (c *Client) fetchDiscoverPage(ctx context.Context, mediaType, path string) (*MediaPage, error) { + if mediaType == "tv" { + var resp paginatedResponse[mediaTVResponse] + if err := c.doGet(ctx, path, &resp); err != nil { + return nil, err + } + return normalizeTVPage(resp), nil + } + var resp paginatedResponse[mediaMovieResponse] + if err := c.doGet(ctx, path, &resp); err != nil { + return nil, err + } + return normalizeMoviePage(resp), nil +} + // buildDiscoverQuery composes the TMDB discover query string (without the // leading "?" and without page or api_key — doGet handles those). func buildDiscoverQuery(mediaType string, params DiscoverParams) string { @@ -370,6 +714,12 @@ func buildDiscoverQuery(mediaType string, params DiscoverParams) string { if without := joinIntSlice(params.WithoutGenres, ","); without != "" { values.Set("without_genres", without) } + if companies := joinIntSlice(params.WithCompanies, ","); companies != "" { + values.Set("with_companies", companies) + } + if networks := joinIntSlice(params.WithNetworks, ","); networks != "" { + values.Set("with_networks", networks) + } if params.VoteCountGte > 0 { values.Set("vote_count.gte", strconv.Itoa(params.VoteCountGte)) } @@ -464,21 +814,322 @@ func (c *Client) GetCollection(ctx context.Context, id int) (*Collection, error) }, nil } +// GetMediaDetail fetches a single TMDB movie or series with credits, external +// IDs, recommendations, and the appropriate certification feed, returning a +// normalized MediaDetail. mediaType accepts Silo-facing "movie" or "series". +// +// Cast is sorted by TMDB billing order and capped at 24 entries to keep the +// payload bounded. +func (c *Client) GetMediaDetail(ctx context.Context, mediaType string, id int) (*MediaDetail, error) { + if id <= 0 { + return nil, fmt.Errorf("tmdb: media id must be > 0 (got %d)", id) + } + + switch mediaType { + case "movie": + path := fmt.Sprintf("/movie/%d?append_to_response=credits,external_ids,recommendations,release_dates", id) + var resp movieDetailResponse + if err := c.doGet(ctx, path, &resp); err != nil { + return nil, err + } + return normalizeMovieDetail(&resp), nil + case "series", "tv": + path := fmt.Sprintf("/tv/%d?append_to_response=credits,external_ids,recommendations,content_ratings", id) + var resp tvDetailResponse + if err := c.doGet(ctx, path, &resp); err != nil { + return nil, err + } + return normalizeTVDetail(&resp), nil + default: + return nil, fmt.Errorf("tmdb: invalid media type for detail: %q", mediaType) + } +} + +func normalizeMovieDetail(resp *movieDetailResponse) *MediaDetail { + detail := &MediaDetail{ + MediaType: "movie", + ID: resp.ID, + IMDbID: resp.IMDbID, + Title: resp.Title, + OriginalTitle: resp.OriginalTitle, + Tagline: resp.Tagline, + Overview: resp.Overview, + PosterPath: resp.PosterPath, + BackdropPath: resp.BackdropPath, + ReleaseDate: resp.ReleaseDate, + Year: releaseYear(resp.ReleaseDate), + Runtime: resp.Runtime, + Genres: namesFromGenres(resp.Genres), + VoteAverage: resp.VoteAverage, + VoteCount: resp.VoteCount, + Status: resp.Status, + Homepage: resp.Homepage, + ContentRating: pickMovieCertification(resp.ReleaseDates), + } + for _, company := range resp.ProductionCompanies { + if name := strings.TrimSpace(company.Name); name != "" { + detail.ProductionCompanies = append(detail.ProductionCompanies, name) + } + } + if resp.ExternalIDs != nil { + detail.TVDBID = resp.ExternalIDs.TVDBID + if detail.IMDbID == "" { + detail.IMDbID = resp.ExternalIDs.IMDbID + } + } + if resp.Credits != nil { + detail.Cast = normalizeCast(resp.Credits.Cast) + detail.Director = pickDirector(resp.Credits.Crew) + } + if resp.Recommendations != nil { + detail.Recommendations = make([]MediaResult, 0, len(resp.Recommendations.Results)) + for _, item := range resp.Recommendations.Results { + detail.Recommendations = append(detail.Recommendations, MediaResult{ + ID: item.ID, + MediaType: "movie", + Title: item.Title, + Overview: item.Overview, + PosterPath: item.PosterPath, + BackdropPath: item.BackdropPath, + ReleaseDate: item.ReleaseDate, + Year: releaseYear(item.ReleaseDate), + Popularity: item.Popularity, + VoteAverage: item.VoteAverage, + }) + } + } + return detail +} + +func normalizeTVDetail(resp *tvDetailResponse) *MediaDetail { + detail := &MediaDetail{ + MediaType: "series", + ID: resp.ID, + Title: resp.Name, + OriginalTitle: resp.OriginalName, + Tagline: resp.Tagline, + Overview: resp.Overview, + PosterPath: resp.PosterPath, + BackdropPath: resp.BackdropPath, + ReleaseDate: resp.FirstAirDate, + FirstAirDate: resp.FirstAirDate, + LastAirDate: resp.LastAirDate, + Year: releaseYear(resp.FirstAirDate), + Genres: namesFromGenres(resp.Genres), + VoteAverage: resp.VoteAverage, + VoteCount: resp.VoteCount, + Status: resp.Status, + Homepage: resp.Homepage, + NumberOfSeasons: resp.NumberOfSeasons, + NumberOfEpisodes: resp.NumberOfEpisodes, + ContentRating: pickTVRating(resp.ContentRatings), + } + if len(resp.EpisodeRunTime) > 0 { + detail.Runtime = resp.EpisodeRunTime[0] + } + for _, network := range resp.Networks { + if name := strings.TrimSpace(network.Name); name != "" { + detail.Networks = append(detail.Networks, name) + } + } + if resp.ExternalIDs != nil { + detail.IMDbID = resp.ExternalIDs.IMDbID + detail.TVDBID = resp.ExternalIDs.TVDBID + } + if resp.Credits != nil { + detail.Cast = normalizeCast(resp.Credits.Cast) + } + for _, person := range resp.CreatedBy { + if name := strings.TrimSpace(person.Name); name != "" { + detail.Creators = append(detail.Creators, name) + } + } + if resp.Recommendations != nil { + detail.Recommendations = make([]MediaResult, 0, len(resp.Recommendations.Results)) + for _, item := range resp.Recommendations.Results { + detail.Recommendations = append(detail.Recommendations, MediaResult{ + ID: item.ID, + MediaType: "series", + Title: item.Name, + Overview: item.Overview, + PosterPath: item.PosterPath, + BackdropPath: item.BackdropPath, + ReleaseDate: item.FirstAirDate, + Year: releaseYear(item.FirstAirDate), + Popularity: item.Popularity, + VoteAverage: item.VoteAverage, + }) + } + } + return detail +} + +func namesFromGenres(genres []genreEntry) []string { + if len(genres) == 0 { + return nil + } + out := make([]string, 0, len(genres)) + for _, g := range genres { + if name := strings.TrimSpace(g.Name); name != "" { + out = append(out, name) + } + } + return out +} + +// normalizeCast sorts by billing order and caps the result so the response +// payload stays bounded — the request detail UI surfaces only the top of the +// list anyway. +func normalizeCast(cast []castEntry) []MediaCastMember { + if len(cast) == 0 { + return nil + } + sorted := make([]castEntry, len(cast)) + copy(sorted, cast) + sort.SliceStable(sorted, func(i, j int) bool { + return sorted[i].Order < sorted[j].Order + }) + const maxCast = 24 + if len(sorted) > maxCast { + sorted = sorted[:maxCast] + } + out := make([]MediaCastMember, 0, len(sorted)) + for _, member := range sorted { + name := strings.TrimSpace(member.Name) + if name == "" { + continue + } + out = append(out, MediaCastMember{ + Name: name, + Character: strings.TrimSpace(member.Character), + ProfilePath: member.ProfilePath, + Order: member.Order, + }) + } + return out +} + +func pickDirector(crew []crewEntry) string { + for _, member := range crew { + if strings.EqualFold(member.Job, "Director") { + return strings.TrimSpace(member.Name) + } + } + return "" +} + +// pickMovieCertification picks the US theatrical certification if available, +// then falls back to any non-empty US certification, then any non-empty +// certification at all. Type 3 is theatrical in TMDB's release-type taxonomy. +func pickMovieCertification(rd *releaseDatesResponse) string { + if rd == nil { + return "" + } + var fallbackUS, fallbackAny string + for _, country := range rd.Results { + isUS := strings.EqualFold(country.ISO3166, "US") + for _, entry := range country.ReleaseDates { + cert := strings.TrimSpace(entry.Certification) + if cert == "" { + continue + } + if isUS && entry.Type == 3 { + return cert + } + if isUS && fallbackUS == "" { + fallbackUS = cert + } + if fallbackAny == "" { + fallbackAny = cert + } + } + } + if fallbackUS != "" { + return fallbackUS + } + return fallbackAny +} + +func pickTVRating(cr *contentRatingsResponse) string { + if cr == nil { + return "" + } + var fallback string + for _, entry := range cr.Results { + rating := strings.TrimSpace(entry.Rating) + if rating == "" { + continue + } + if strings.EqualFold(entry.ISO3166, "US") { + return rating + } + if fallback == "" { + fallback = rating + } + } + return fallback +} + // GetExternalIDs fetches external IDs for a TMDB movie or TV entry. +// Uses the dedicated external_ids endpoint instead of append_to_response on +// the full detail, which would return a 100+ KB payload to extract a handful +// of identifiers. func (c *Client) GetExternalIDs(ctx context.Context, mediaType string, id int) (*ExternalIDs, error) { var path string switch mediaType { case "movie": - path = fmt.Sprintf("/movie/%d?append_to_response=external_ids", id) + path = fmt.Sprintf("/movie/%d/external_ids", id) case "tv": - path = fmt.Sprintf("/tv/%d?append_to_response=external_ids", id) + path = fmt.Sprintf("/tv/%d/external_ids", id) default: return nil, fmt.Errorf("tmdb: invalid media type: %q", mediaType) } - var resp externalIDsResponse + cacheKey := "external_ids:" + path + if c.externalIDCache != nil { + if cached, ok := c.externalIDCache.Get(cacheKey); ok { + return cloneExternalIDs(cached), nil + } + } + + value, err, _ := c.cacheGroup.Do(cacheKey, func() (any, error) { + if c.externalIDCache != nil { + if cached, ok := c.externalIDCache.Get(cacheKey); ok { + return cached, nil + } + } + ids, err := c.fetchExternalIDs(ctx, path) + if err != nil { + return nil, err + } + cached := cloneExternalIDs(ids) + if c.externalIDCache != nil && c.responseCacheTTL > 0 { + c.externalIDCache.Set(cacheKey, cached, c.responseCacheTTL) + } + return cached, nil + }) + if err != nil { + return nil, err + } + ids, ok := value.(*ExternalIDs) + if !ok { + return nil, fmt.Errorf("tmdb: invalid cached external IDs response") + } + return cloneExternalIDs(ids), nil +} + +func cloneExternalIDs(ids *ExternalIDs) *ExternalIDs { + if ids == nil { + return nil + } + cloned := *ids + return &cloned +} + +func (c *Client) fetchExternalIDs(ctx context.Context, path string) (*ExternalIDs, error) { + var resp ExternalIDs if err := c.doGet(ctx, path, &resp); err != nil { return nil, err } - return resp.ExternalIDs, nil + return &resp, nil } diff --git a/internal/metadata/tmdb/client_test.go b/internal/metadata/tmdb/client_test.go index 6a179d63..cb444f5f 100644 --- a/internal/metadata/tmdb/client_test.go +++ b/internal/metadata/tmdb/client_test.go @@ -4,9 +4,28 @@ import ( "context" "net/http" "net/http/httptest" + "sync/atomic" "testing" ) +func TestNewClientUsesProjectAPIKeyWhenEmpty(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("api_key"); got != projectAPIKey { + t.Fatalf("api_key query = %q, want project API key", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"page":1,"total_pages":1,"total_results":0,"results":[]}`)) + })) + defer server.Close() + + client := NewClient("", 1000) + client.SetBaseURL(server.URL) + + if _, err := client.GetCollectionPreset(context.Background(), "trending", "all", "day", 10); err != nil { + t.Fatalf("GetCollectionPreset returned error: %v", err) + } +} + func TestGetCollectionPresetTrending(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/trending/all/day" { @@ -52,6 +71,212 @@ func TestGetCollectionPresetTrending(t *testing.T) { } } +func TestDiscoverSectionCachesSuccess(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.URL.Path != "/movie/popular" { + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("page"); got != "1" { + t.Fatalf("page query = %q, want 1", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "page": 1, + "total_pages": 1, + "total_results": 1, + "results": [ + {"id": 11, "title": "Cached Movie", "overview": "from tmdb"} + ] + }`)) + })) + defer server.Close() + + client := NewClient("test-key", 1000) + client.SetBaseURL(server.URL) + + first, err := client.DiscoverSection(context.Background(), "popular_movies", 1) + if err != nil { + t.Fatalf("first DiscoverSection returned error: %v", err) + } + second, err := client.DiscoverSection(context.Background(), "popular_movies", 1) + if err != nil { + t.Fatalf("second DiscoverSection returned error: %v", err) + } + + if got := calls.Load(); got != 1 { + t.Fatalf("upstream calls = %d, want 1", got) + } + for name, page := range map[string]*MediaPage{"first": first, "second": second} { + if page == nil || len(page.Results) != 1 { + t.Fatalf("%s page results = %#v, want one result", name, page) + } + if page.Results[0].ID != 11 || page.Results[0].Title != "Cached Movie" { + t.Fatalf("%s result = %+v", name, page.Results[0]) + } + } +} + +func TestDiscoverSectionCacheKeyIncludesPage(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.URL.Path != "/movie/popular" { + http.NotFound(w, r) + return + } + page := r.URL.Query().Get("page") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "page": ` + page + `, + "total_pages": 2, + "total_results": 2, + "results": [ + {"id": ` + page + `, "title": "Movie ` + page + `"} + ] + }`)) + })) + defer server.Close() + + client := NewClient("test-key", 1000) + client.SetBaseURL(server.URL) + + first, err := client.DiscoverSection(context.Background(), "popular_movies", 1) + if err != nil { + t.Fatalf("page 1 DiscoverSection returned error: %v", err) + } + second, err := client.DiscoverSection(context.Background(), "popular_movies", 2) + if err != nil { + t.Fatalf("page 2 DiscoverSection returned error: %v", err) + } + + if got := calls.Load(); got != 2 { + t.Fatalf("upstream calls = %d, want 2", got) + } + if first.Results[0].ID != 1 || second.Results[0].ID != 2 { + t.Fatalf("cached pages collapsed unexpectedly: first=%+v second=%+v", first.Results[0], second.Results[0]) + } +} + +func TestDiscoverSectionDoesNotCacheClientErrors(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + call := calls.Add(1) + if r.URL.Path != "/movie/popular" { + http.NotFound(w, r) + return + } + if call == 1 { + http.Error(w, `{"status_message":"bad section"}`, http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "page": 1, + "total_pages": 1, + "total_results": 1, + "results": [ + {"id": 22, "title": "Recovered Movie"} + ] + }`)) + })) + defer server.Close() + + client := NewClient("test-key", 1000) + client.SetBaseURL(server.URL) + + if _, err := client.DiscoverSection(context.Background(), "popular_movies", 1); err == nil { + t.Fatal("first DiscoverSection expected error") + } + page, err := client.DiscoverSection(context.Background(), "popular_movies", 1) + if err != nil { + t.Fatalf("second DiscoverSection returned error: %v", err) + } + if got := calls.Load(); got != 2 { + t.Fatalf("upstream calls = %d, want 2", got) + } + if page == nil || len(page.Results) != 1 || page.Results[0].ID != 22 { + t.Fatalf("second page = %#v, want recovered movie", page) + } +} + +func TestDiscoverSectionReturnsClonedCachedPage(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.URL.Path != "/movie/popular" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "page": 1, + "total_pages": 1, + "total_results": 1, + "results": [ + {"id": 33, "title": "Immutable Movie"} + ] + }`)) + })) + defer server.Close() + + client := NewClient("test-key", 1000) + client.SetBaseURL(server.URL) + + first, err := client.DiscoverSection(context.Background(), "popular_movies", 1) + if err != nil { + t.Fatalf("first DiscoverSection returned error: %v", err) + } + first.Results[0].Title = "mutated by caller" + + second, err := client.DiscoverSection(context.Background(), "popular_movies", 1) + if err != nil { + t.Fatalf("second DiscoverSection returned error: %v", err) + } + + if got := calls.Load(); got != 1 { + t.Fatalf("upstream calls = %d, want 1", got) + } + if second.Results[0].Title != "Immutable Movie" { + t.Fatalf("cached title = %q, want Immutable Movie", second.Results[0].Title) + } +} + +func TestGetExternalIDsCachesSuccess(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.URL.Path != "/movie/123/external_ids" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"imdb_id":"tt123","tvdb_id":456}`)) + })) + defer server.Close() + + client := NewClient("test-key", 1000) + client.SetBaseURL(server.URL) + + first, err := client.GetExternalIDs(context.Background(), "movie", 123) + if err != nil { + t.Fatalf("first GetExternalIDs returned error: %v", err) + } + second, err := client.GetExternalIDs(context.Background(), "movie", 123) + if err != nil { + t.Fatalf("second GetExternalIDs returned error: %v", err) + } + + if got := calls.Load(); got != 1 { + t.Fatalf("upstream calls = %d, want 1", got) + } + if first.IMDbID != "tt123" || first.TVDBID != 456 || second.IMDbID != "tt123" || second.TVDBID != 456 { + t.Fatalf("external IDs = first %+v second %+v", first, second) + } +} + func TestDiscoverMovieAppliesFilters(t *testing.T) { calls := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -204,6 +429,34 @@ func TestDiscoverTVUsesFirstAirDate(t *testing.T) { } } +func TestDiscoverIncludesCompaniesAndNetworks(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if got := q.Get("with_companies"); got != "420,2" { + t.Errorf("with_companies = %q, want 420,2", got) + } + if got := q.Get("with_networks"); got != "213,49" { + t.Errorf("with_networks = %q, want 213,49", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"page":1,"total_pages":1,"total_results":0,"results":[]}`)) + })) + defer server.Close() + + client := NewClient("test-key", 1000) + client.SetBaseURL(server.URL) + + _, err := client.Discover(context.Background(), "movie", DiscoverParams{ + SortBy: "popularity.desc", + WithCompanies: []int{420, 2}, + WithNetworks: []int{213, 49}, + Limit: 5, + }) + if err != nil { + t.Fatalf("Discover returned error: %v", err) + } +} + func TestDiscoverRejectsInvalidMediaType(t *testing.T) { client := NewClient("test-key", 1000) _, err := client.Discover(context.Background(), "all", DiscoverParams{SortBy: "popularity.desc"}) @@ -220,6 +473,302 @@ func TestDiscoverRequiresSortBy(t *testing.T) { } } +func TestDiscoverPageMovieReturnsFullResults(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/discover/movie" { + http.NotFound(w, r) + return + } + q := r.URL.Query() + if got := q.Get("sort_by"); got != "popularity.desc" { + t.Errorf("sort_by = %q, want popularity.desc", got) + } + if got := q.Get("with_companies"); got != "420" { + t.Errorf("with_companies = %q, want 420", got) + } + if got := q.Get("page"); got != "2" { + t.Errorf("page = %q, want 2", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "page": 2, + "total_pages": 8, + "total_results": 160, + "results": [ + {"id": 24428, "title": "The Avengers", "release_date": "2012-04-25", "poster_path": "/p.jpg", "overview": "earth's mightiest", "popularity": 100.5, "vote_average": 7.7} + ] + }`)) + })) + defer server.Close() + + client := NewClient("test-key", 1000) + client.SetBaseURL(server.URL) + + page, err := client.DiscoverPage(context.Background(), "movie", DiscoverParams{ + SortBy: "popularity.desc", + WithCompanies: []int{420}, + }, 2) + if err != nil { + t.Fatalf("DiscoverPage: %v", err) + } + if page.Page != 2 || page.TotalPages != 8 || page.TotalResults != 160 { + t.Fatalf("page = %+v", page) + } + if len(page.Results) != 1 { + t.Fatalf("results = %d, want 1", len(page.Results)) + } + got := page.Results[0] + if got.ID != 24428 || got.MediaType != "movie" || got.Title != "The Avengers" || got.Year != 2012 { + t.Errorf("result = %+v", got) + } + if got.PosterPath != "/p.jpg" || got.Overview != "earth's mightiest" { + t.Errorf("result detail mismatch: %+v", got) + } +} + +func TestDiscoverPageTVUsesFirstAirDate(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/discover/tv" { + http.NotFound(w, r) + return + } + q := r.URL.Query() + if got := q.Get("with_networks"); got != "213" { + t.Errorf("with_networks = %q, want 213", got) + } + if got := q.Get("first_air_date.gte"); got != "" { + t.Errorf("first_air_date.gte = %q, want empty", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "page": 1, + "total_pages": 1, + "total_results": 1, + "results": [ + {"id": 1399, "name": "Game of Thrones", "first_air_date": "2011-04-17", "poster_path": "/g.jpg"} + ] + }`)) + })) + defer server.Close() + + client := NewClient("test-key", 1000) + client.SetBaseURL(server.URL) + + page, err := client.DiscoverPage(context.Background(), "tv", DiscoverParams{ + SortBy: "vote_average.desc", + WithNetworks: []int{213}, + }, 1) + if err != nil { + t.Fatalf("DiscoverPage tv: %v", err) + } + if len(page.Results) != 1 { + t.Fatalf("results = %d, want 1", len(page.Results)) + } + got := page.Results[0] + if got.MediaType != "series" || got.Title != "Game of Thrones" || got.Year != 2011 { + t.Errorf("result = %+v", got) + } +} + +func TestDiscoverPageRejectsInvalidMediaType(t *testing.T) { + client := NewClient("test-key", 1000) + _, err := client.DiscoverPage(context.Background(), "all", DiscoverParams{SortBy: "popularity.desc"}, 1) + if err == nil { + t.Fatal("expected error for invalid media type") + } +} + +func TestDiscoverPageRequiresSortBy(t *testing.T) { + client := NewClient("test-key", 1000) + _, err := client.DiscoverPage(context.Background(), "movie", DiscoverParams{}, 1) + if err == nil { + t.Fatal("expected error when sort_by is empty") + } +} + +func TestDiscoverPageDefaultsToPage1(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("page"); got != "1" { + t.Errorf("page = %q, want 1", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"page":1,"total_pages":1,"total_results":0,"results":[]}`)) + })) + defer server.Close() + + client := NewClient("test-key", 1000) + client.SetBaseURL(server.URL) + + if _, err := client.DiscoverPage(context.Background(), "movie", DiscoverParams{SortBy: "popularity.desc"}, 0); err != nil { + t.Fatalf("DiscoverPage: %v", err) + } +} + +func TestSearchMediaMovie(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/search/movie" { + http.NotFound(w, r) + return + } + q := r.URL.Query() + if got := q.Get("query"); got != "fight club" { + t.Fatalf("query = %q, want fight club", got) + } + if got := q.Get("include_adult"); got != "false" { + t.Fatalf("include_adult = %q, want false", got) + } + if got := q.Get("page"); got != "2" { + t.Fatalf("page = %q, want 2", got) + } + if got := q.Get("api_key"); got != "test-key" { + t.Fatalf("api_key query = %q, want test-key", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "page": 2, + "total_pages": 5, + "total_results": 50, + "results": [ + { + "id": 550, + "title": "Fight Club", + "overview": "overview", + "poster_path": "/poster.jpg", + "backdrop_path": "/backdrop.jpg", + "release_date": "1999-10-15", + "popularity": 10.5, + "vote_average": 8.4 + } + ] + }`)) + })) + defer server.Close() + + client := NewClient("test-key", 1000) + client.SetBaseURL(server.URL) + + page, err := client.SearchMedia(context.Background(), "movie", "fight club", 2) + if err != nil { + t.Fatalf("SearchMedia returned error: %v", err) + } + if page.Page != 2 || page.TotalPages != 5 || len(page.Results) != 1 { + t.Fatalf("page = %+v, want page metadata and one result", page) + } + result := page.Results[0] + if result.ID != 550 || result.MediaType != "movie" || result.Year != 1999 { + t.Fatalf("result = %+v, want normalized movie result", result) + } +} + +func TestSearchMediaAllUsesMultiSearchAndFiltersToMoviesAndSeries(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/search/multi" { + http.NotFound(w, r) + return + } + q := r.URL.Query() + if got := q.Get("query"); got != "fight club" { + t.Fatalf("query = %q, want fight club", got) + } + if got := q.Get("include_adult"); got != "false" { + t.Fatalf("include_adult = %q, want false", got) + } + if got := q.Get("page"); got != "2" { + t.Fatalf("page = %q, want 2", got) + } + if got := q.Get("api_key"); got != "test-key" { + t.Fatalf("api_key query = %q, want test-key", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "page": 2, + "total_pages": 4, + "total_results": 40, + "results": [ + { + "id": 550, + "media_type": "movie", + "title": "Fight Club", + "release_date": "1999-10-15" + }, + { + "id": 1399, + "media_type": "tv", + "name": "Fight Club: The Series", + "first_air_date": "2020-01-01" + }, + { + "id": 123, + "media_type": "person", + "name": "A Performer" + } + ] + }`)) + })) + defer server.Close() + + client := NewClient("test-key", 1000) + client.SetBaseURL(server.URL) + + page, err := client.SearchMedia(context.Background(), "all", "fight club", 2) + if err != nil { + t.Fatalf("SearchMedia returned error: %v", err) + } + if page.Page != 2 || page.TotalPages != 4 || page.TotalResults != 40 { + t.Fatalf("page metadata = %+v, want TMDB pagination metadata", page) + } + if len(page.Results) != 2 { + t.Fatalf("len(results) = %d, want 2", len(page.Results)) + } + if page.Results[0].ID != 550 || page.Results[0].MediaType != "movie" || page.Results[0].Year != 1999 { + t.Fatalf("results[0] = %+v, want normalized movie", page.Results[0]) + } + if page.Results[1].ID != 1399 || page.Results[1].MediaType != "series" || page.Results[1].Year != 2020 { + t.Fatalf("results[1] = %+v, want normalized series", page.Results[1]) + } +} + +func TestDiscoverSectionTrendingSeries(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/trending/tv/week" { + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("page"); got != "1" { + t.Fatalf("page = %q, want 1", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "page": 1, + "total_pages": 1, + "total_results": 1, + "results": [ + { + "id": 1399, + "name": "Game of Thrones", + "first_air_date": "2011-04-17" + } + ] + }`)) + })) + defer server.Close() + + client := NewClient("test-key", 1000) + client.SetBaseURL(server.URL) + + page, err := client.DiscoverSection(context.Background(), "trending_series", 1) + if err != nil { + t.Fatalf("DiscoverSection returned error: %v", err) + } + if len(page.Results) != 1 { + t.Fatalf("len(results) = %d, want 1", len(page.Results)) + } + result := page.Results[0] + if result.ID != 1399 || result.MediaType != "series" || result.Year != 2011 { + t.Fatalf("result = %+v, want normalized series result", result) + } +} + func TestGetCollection(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/collection/86311" { @@ -304,23 +853,18 @@ func TestGetCollectionRejectsNonPositiveID(t *testing.T) { func TestGetExternalIDs(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/movie/123" { + if r.URL.Path != "/movie/123/external_ids" { http.NotFound(w, r) return } - if got := r.URL.Query().Get("append_to_response"); got != "external_ids" { - t.Fatalf("append_to_response = %q, want external_ids", got) - } if got := r.URL.Query().Get("api_key"); got != "test-key" { t.Fatalf("api_key query = %q, want test-key", got) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ - "external_ids": { - "imdb_id": "tt0133093", - "tvdb_id": 12345 - } + "imdb_id": "tt0133093", + "tvdb_id": 12345 }`)) })) defer server.Close() diff --git a/internal/metadata/tmdb/types.go b/internal/metadata/tmdb/types.go index 3395d674..e3cffe4a 100644 --- a/internal/metadata/tmdb/types.go +++ b/internal/metadata/tmdb/types.go @@ -35,12 +35,43 @@ type CollectionResult struct { Title string } +// MediaResult is a normalized TMDB movie or TV result for request search and +// discovery surfaces. MediaType is Silo-facing: "movie" or "series". +// +// PosterPath and BackdropPath are raw TMDB path fragments (e.g. "/abc.jpg"). +// Callers must compose the full URL by prepending the TMDB image base +// (https://image.tmdb.org/t/p/{size}); the values are not browser-loadable +// on their own. +type MediaResult struct { + ID int + MediaType string + Title string + Overview string + PosterPath string + BackdropPath string + ReleaseDate string + Year int + Popularity float64 + VoteAverage float64 +} + +// MediaPage is a paginated TMDB search/discovery response normalized for the +// request system. +type MediaPage struct { + Page int + TotalPages int + TotalResults int + Results []MediaResult +} + // DiscoverParams mirrors the TMDB `/discover/{movie,tv}` query parameters and // is shaped to map 1:1 onto TMDBDiscoverSpec. Limit caps the total results // the client paginates over. type DiscoverParams struct { WithGenres []int WithoutGenres []int + WithCompanies []int + WithNetworks []int SortBy string VoteCountGte int VoteAverageGte float64 @@ -95,11 +126,212 @@ type collectionResponsePart struct { ReleaseDate string `json:"release_date"` } -type externalIDsResponse struct { - ExternalIDs *ExternalIDs `json:"external_ids"` +type mediaMovieResponse struct { + ID int `json:"id"` + Title string `json:"title"` + Overview string `json:"overview"` + PosterPath string `json:"poster_path"` + BackdropPath string `json:"backdrop_path"` + ReleaseDate string `json:"release_date"` + Popularity float64 `json:"popularity"` + VoteAverage float64 `json:"vote_average"` +} + +type mediaTVResponse struct { + ID int `json:"id"` + Name string `json:"name"` + Overview string `json:"overview"` + PosterPath string `json:"poster_path"` + BackdropPath string `json:"backdrop_path"` + FirstAirDate string `json:"first_air_date"` + Popularity float64 `json:"popularity"` + VoteAverage float64 `json:"vote_average"` +} + +type mediaMultiSearchResponse struct { + ID int `json:"id"` + MediaType string `json:"media_type"` + Title string `json:"title"` + Name string `json:"name"` + Overview string `json:"overview"` + PosterPath string `json:"poster_path"` + BackdropPath string `json:"backdrop_path"` + ReleaseDate string `json:"release_date"` + FirstAirDate string `json:"first_air_date"` + Popularity float64 `json:"popularity"` + VoteAverage float64 `json:"vote_average"` } type apiError struct { StatusMessage string `json:"status_message"` StatusCode int `json:"status_code"` } + +// MediaDetail is a normalized TMDB detail payload for the request system. +// MediaType is Silo-facing: "movie" or "series". Series-specific fields are +// zero-valued for movies and vice versa. +type MediaDetail struct { + MediaType string + ID int + IMDbID string + TVDBID int + Title string + OriginalTitle string + Tagline string + Overview string + PosterPath string + BackdropPath string + ReleaseDate string + Year int + Runtime int + Genres []string + VoteAverage float64 + VoteCount int + Status string + Homepage string + ContentRating string + ProductionCompanies []string + + NumberOfSeasons int + NumberOfEpisodes int + FirstAirDate string + LastAirDate string + Networks []string + + Cast []MediaCastMember + Director string + Creators []string + Recommendations []MediaResult +} + +// MediaCastMember is a single cast member entry from a TMDB credits response, +// normalized for the request detail surface. +type MediaCastMember struct { + Name string + Character string + ProfilePath string + Order int +} + +// genreEntry / companyEntry / networkEntry / personEntry mirror small object +// shapes from the TMDB JSON. They're internal to the decode path. +type genreEntry struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type companyEntry struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type networkEntry struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type creditsResponse struct { + Cast []castEntry `json:"cast"` + Crew []crewEntry `json:"crew"` +} + +type castEntry struct { + ID int `json:"id"` + Name string `json:"name"` + Character string `json:"character"` + ProfilePath string `json:"profile_path"` + Order int `json:"order"` +} + +type crewEntry struct { + ID int `json:"id"` + Name string `json:"name"` + Job string `json:"job"` + Department string `json:"department"` +} + +type personEntry struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type recommendationsMovieResponse struct { + Results []mediaMovieResponse `json:"results"` +} + +type recommendationsTVResponse struct { + Results []mediaTVResponse `json:"results"` +} + +type releaseDatesResponse struct { + Results []releaseDatesCountryEntry `json:"results"` +} + +type releaseDatesCountryEntry struct { + ISO3166 string `json:"iso_3166_1"` + ReleaseDates []releaseDateEntry `json:"release_dates"` +} + +type releaseDateEntry struct { + Certification string `json:"certification"` + ReleaseDate string `json:"release_date"` + Type int `json:"type"` +} + +type contentRatingsResponse struct { + Results []contentRatingEntry `json:"results"` +} + +type contentRatingEntry struct { + ISO3166 string `json:"iso_3166_1"` + Rating string `json:"rating"` +} + +type movieDetailResponse struct { + ID int `json:"id"` + IMDbID string `json:"imdb_id"` + Title string `json:"title"` + OriginalTitle string `json:"original_title"` + Tagline string `json:"tagline"` + Overview string `json:"overview"` + PosterPath string `json:"poster_path"` + BackdropPath string `json:"backdrop_path"` + ReleaseDate string `json:"release_date"` + Runtime int `json:"runtime"` + Genres []genreEntry `json:"genres"` + VoteAverage float64 `json:"vote_average"` + VoteCount int `json:"vote_count"` + Status string `json:"status"` + Homepage string `json:"homepage"` + ProductionCompanies []companyEntry `json:"production_companies"` + Credits *creditsResponse `json:"credits"` + ExternalIDs *ExternalIDs `json:"external_ids"` + Recommendations *recommendationsMovieResponse `json:"recommendations"` + ReleaseDates *releaseDatesResponse `json:"release_dates"` +} + +type tvDetailResponse struct { + ID int `json:"id"` + Name string `json:"name"` + OriginalName string `json:"original_name"` + Tagline string `json:"tagline"` + Overview string `json:"overview"` + PosterPath string `json:"poster_path"` + BackdropPath string `json:"backdrop_path"` + FirstAirDate string `json:"first_air_date"` + LastAirDate string `json:"last_air_date"` + EpisodeRunTime []int `json:"episode_run_time"` + NumberOfSeasons int `json:"number_of_seasons"` + NumberOfEpisodes int `json:"number_of_episodes"` + Genres []genreEntry `json:"genres"` + VoteAverage float64 `json:"vote_average"` + VoteCount int `json:"vote_count"` + Status string `json:"status"` + Homepage string `json:"homepage"` + Networks []networkEntry `json:"networks"` + CreatedBy []personEntry `json:"created_by"` + Credits *creditsResponse `json:"credits"` + ExternalIDs *ExternalIDs `json:"external_ids"` + Recommendations *recommendationsTVResponse `json:"recommendations"` + ContentRatings *contentRatingsResponse `json:"content_ratings"` +} diff --git a/internal/requests/arrclient/client.go b/internal/requests/arrclient/client.go new file mode 100644 index 00000000..e5d3a135 --- /dev/null +++ b/internal/requests/arrclient/client.go @@ -0,0 +1,120 @@ +package arrclient + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const maxResponseBody = 1 << 20 + +type Client struct { + baseURL string + apiKey string + httpClient *http.Client +} + +type HTTPError struct { + StatusCode int + Body string +} + +type DecodeError struct { + StatusCode int + Err error +} + +func (e HTTPError) Error() string { + if e.Body == "" { + return fmt.Sprintf("arr: HTTP %d", e.StatusCode) + } + return fmt.Sprintf("arr: HTTP %d: %s", e.StatusCode, e.Body) +} + +func (e DecodeError) Error() string { + return fmt.Sprintf("arr: decode response: %v", e.Err) +} + +func (e DecodeError) Unwrap() error { + return e.Err +} + +func IsEmptyOrTruncatedDecodeError(err error) bool { + var decodeErr DecodeError + if !errors.As(err, &decodeErr) { + return false + } + return errors.Is(decodeErr.Err, io.EOF) || errors.Is(decodeErr.Err, io.ErrUnexpectedEOF) +} + +func New(baseURL, apiKey string, httpClient *http.Client) *Client { + if httpClient == nil { + httpClient = &http.Client{Timeout: 30 * time.Second} + } + return &Client{ + baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"), + apiKey: strings.TrimSpace(apiKey), + httpClient: httpClient, + } +} + +func (c *Client) GetJSON(ctx context.Context, path string, dest any) error { + return c.DoJSON(ctx, http.MethodGet, path, nil, dest) +} + +func (c *Client) PostJSON(ctx context.Context, path string, body, dest any) error { + return c.DoJSON(ctx, http.MethodPost, path, body, dest) +} + +func (c *Client) DoJSON(ctx context.Context, method, path string, body, dest any) error { + if c.baseURL == "" { + return fmt.Errorf("arr: base url is required") + } + if c.apiKey == "" { + return fmt.Errorf("arr: api key is required") + } + + var reader io.Reader + if body != nil { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(body); err != nil { + return fmt.Errorf("arr: encode request: %w", err) + } + reader = &buf + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader) + if err != nil { + return fmt.Errorf("arr: create request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Api-Key", c.apiKey) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("arr: request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody)) + return HTTPError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))} + } + if dest == nil || resp.StatusCode == http.StatusNoContent { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBody)) + return nil + } + if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(dest); err != nil { + return DecodeError{StatusCode: resp.StatusCode, Err: err} + } + return nil +} diff --git a/internal/requests/arrclient/options.go b/internal/requests/arrclient/options.go new file mode 100644 index 00000000..3e2dd456 --- /dev/null +++ b/internal/requests/arrclient/options.go @@ -0,0 +1,34 @@ +package arrclient + +import ( + "strconv" + "strings" +) + +func BoolOption(options map[string]any, key string, fallback bool) bool { + value, ok := options[key] + if !ok { + return fallback + } + switch typed := value.(type) { + case bool: + return typed + case string: + parsed, err := strconv.ParseBool(strings.TrimSpace(typed)) + if err == nil { + return parsed + } + } + return fallback +} + +func StringOption(options map[string]any, key, fallback string) string { + value, ok := options[key] + if !ok { + return fallback + } + if typed, ok := value.(string); ok && strings.TrimSpace(typed) != "" { + return strings.TrimSpace(typed) + } + return fallback +} diff --git a/internal/requests/arrclient/queue.go b/internal/requests/arrclient/queue.go new file mode 100644 index 00000000..31caf33d --- /dev/null +++ b/internal/requests/arrclient/queue.go @@ -0,0 +1,85 @@ +package arrclient + +import "strings" + +type QueueResource struct { + ID int `json:"id,omitempty"` + MovieID int `json:"movieId,omitempty"` + SeriesID int `json:"seriesId,omitempty"` + Title string `json:"title,omitempty"` + Status string `json:"status,omitempty"` + TrackedDownloadStatus string `json:"trackedDownloadStatus,omitempty"` + TrackedDownloadState string `json:"trackedDownloadState,omitempty"` + DownloadID string `json:"downloadId,omitempty"` + OutputPath string `json:"outputPath,omitempty"` +} + +type QueueEvaluation struct { + State string + ExternalStatus string + Message string +} + +const ( + QueueStateQueued = "queued" + QueueStateDownloading = "downloading" + QueueStateFailed = "failed" +) + +func EvaluateQueue(resources []QueueResource) QueueEvaluation { + if len(resources) == 0 { + return QueueEvaluation{State: QueueStateQueued, ExternalStatus: "not_in_queue"} + } + + queued := false + downloadingStatus := "" + for _, resource := range resources { + status := strings.TrimSpace(resource.Status) + state := strings.TrimSpace(resource.TrackedDownloadState) + externalStatus := joinStatus(status, state) + + if status == "failed" || state == "failed" || state == "failedPending" { + return QueueEvaluation{ + State: QueueStateFailed, + ExternalStatus: externalStatus, + Message: queueFailureMessage(resource, externalStatus), + } + } + if status == "downloading" || state == "downloading" || state == "importPending" || state == "importing" { + if downloadingStatus == "" { + downloadingStatus = externalStatus + } + queued = true + continue + } + queued = true + } + if downloadingStatus != "" { + return QueueEvaluation{State: QueueStateDownloading, ExternalStatus: downloadingStatus} + } + if queued { + return QueueEvaluation{State: QueueStateQueued, ExternalStatus: "queued"} + } + return QueueEvaluation{State: QueueStateQueued, ExternalStatus: "unknown"} +} + +func joinStatus(status, state string) string { + switch { + case status != "" && state != "": + return status + "/" + state + case status != "": + return status + case state != "": + return state + default: + return "unknown" + } +} + +func queueFailureMessage(resource QueueResource, externalStatus string) string { + title := strings.TrimSpace(resource.Title) + if title == "" { + return "external queue failed: " + externalStatus + } + return "external queue failed for " + title + ": " + externalStatus +} diff --git a/internal/requests/arrclient/queue_test.go b/internal/requests/arrclient/queue_test.go new file mode 100644 index 00000000..df441e40 --- /dev/null +++ b/internal/requests/arrclient/queue_test.go @@ -0,0 +1,20 @@ +package arrclient + +import "testing" + +func TestEvaluateQueueFailureWinsOverDownloading(t *testing.T) { + result := EvaluateQueue([]QueueResource{ + {Status: "downloading", TrackedDownloadState: "downloading"}, + {Title: "Bad Release", Status: "queued", TrackedDownloadState: "failedPending"}, + }) + + if result.State != QueueStateFailed { + t.Fatalf("state = %q, want failed", result.State) + } + if result.ExternalStatus != "queued/failedPending" { + t.Fatalf("external status = %q, want queued/failedPending", result.ExternalStatus) + } + if result.Message == "" { + t.Fatal("message should describe the queue failure") + } +} diff --git a/internal/requests/arrclient/resources.go b/internal/requests/arrclient/resources.go new file mode 100644 index 00000000..eee11bc6 --- /dev/null +++ b/internal/requests/arrclient/resources.go @@ -0,0 +1,103 @@ +package arrclient + +import ( + "context" + "strconv" + + mediarequests "github.com/Silo-Server/silo-server/internal/requests" +) + +type RootFolderResource struct { + Path string `json:"path"` + FreeSpace int64 `json:"freeSpace"` + TotalSpace int64 `json:"totalSpace"` + Accessible bool `json:"accessible"` +} + +type QualityProfileResource struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type TagResource struct { + ID int `json:"id"` + Label string `json:"label"` +} + +func ListRootFolders(ctx context.Context, client *Client) ([]mediarequests.IntegrationRootFolder, error) { + var resources []RootFolderResource + if err := client.GetJSON(ctx, "/api/v3/rootfolder", &resources); err != nil { + return nil, err + } + out := make([]mediarequests.IntegrationRootFolder, 0, len(resources)) + for _, resource := range resources { + out = append(out, mediarequests.IntegrationRootFolder{ + Path: resource.Path, + FreeSpace: resource.FreeSpace, + TotalSpace: resource.TotalSpace, + Accessible: resource.Accessible, + }) + } + return out, nil +} + +func ListQualityProfiles(ctx context.Context, client *Client) ([]mediarequests.IntegrationQualityProfile, error) { + var resources []QualityProfileResource + if err := client.GetJSON(ctx, "/api/v3/qualityprofile", &resources); err != nil { + return nil, err + } + out := make([]mediarequests.IntegrationQualityProfile, 0, len(resources)) + for _, resource := range resources { + out = append(out, mediarequests.IntegrationQualityProfile{ + ID: resource.ID, + Name: resource.Name, + }) + } + return out, nil +} + +func ListTags(ctx context.Context, client *Client) ([]mediarequests.IntegrationTag, error) { + var resources []TagResource + if err := client.GetJSON(ctx, "/api/v3/tag", &resources); err != nil { + return nil, err + } + out := make([]mediarequests.IntegrationTag, 0, len(resources)) + for _, resource := range resources { + out = append(out, mediarequests.IntegrationTag{ + ID: resource.ID, + Label: resource.Label, + }) + } + return out, nil +} + +// AcceptedWithoutResponse returns a FulfillmentResult marking the submission +// as accepted by the downstream integration but with no external id captured +// (typically a 201 with empty body when lookup recovery also fails). +func AcceptedWithoutResponse(kind string) mediarequests.FulfillmentResult { + return mediarequests.FulfillmentResult{ + IntegrationKind: kind, + ExternalStatus: "accepted_without_response", + } +} + +// StatusFromQueueEvaluation translates an arrclient.QueueEvaluation into the +// mediarequests.FulfillmentStatus shape shared by Radarr and Sonarr clients. +func StatusFromQueueEvaluation(kind string, externalID int, evaluation QueueEvaluation) mediarequests.FulfillmentStatus { + status := mediarequests.StatusQueued + outcome := mediarequests.Outcome("") + if evaluation.State == QueueStateDownloading { + status = mediarequests.StatusDownloading + } + if evaluation.State == QueueStateFailed { + outcome = mediarequests.OutcomeFailed + } + return mediarequests.FulfillmentStatus{ + Status: status, + Outcome: outcome, + IntegrationKind: kind, + ExternalID: strconv.Itoa(externalID), + ExternalStatus: evaluation.ExternalStatus, + Message: evaluation.Message, + } +} diff --git a/internal/requests/discover_brand.go b/internal/requests/discover_brand.go new file mode 100644 index 00000000..cf95ff09 --- /dev/null +++ b/internal/requests/discover_brand.go @@ -0,0 +1,268 @@ +package requests + +import ( + "context" + "fmt" + "strings" + + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" +) + +// DiscoverBrandCard is one card on the Studios / Networks / Genres carousels. +// Studios and networks carry a TMDB ID and a logo URL rendered with TMDB's +// duotone filter; genres carry gradient hints and a display name instead. +type DiscoverBrandCard struct { + TMDBID int `json:"tmdb_id,omitempty"` + Slug string `json:"slug"` + DisplayName string `json:"display_name"` + LogoURL *string `json:"logo_url,omitempty"` + GradientFrom string `json:"gradient_from,omitempty"` + GradientTo string `json:"gradient_to,omitempty"` + SeriesSupported bool `json:"series_supported,omitempty"` +} + +// ListStudios returns the bundled studios with their curated logo URLs. +func (s *Service) ListStudios(_ context.Context, _ Viewer) ([]DiscoverBrandCard, error) { + if s == nil { + return nil, fmt.Errorf("request service is not configured") + } + out := make([]DiscoverBrandCard, 0, len(BundledStudios)) + for _, studio := range BundledStudios { + out = append(out, DiscoverBrandCard{ + TMDBID: studio.TMDBID, + Slug: studio.Slug, + DisplayName: studio.DisplayName, + LogoURL: duotoneLogoURL(studio.LogoPath), + }) + } + return out, nil +} + +// ListNetworks returns the bundled TV networks with their curated logo URLs. +func (s *Service) ListNetworks(_ context.Context, _ Viewer) ([]DiscoverBrandCard, error) { + if s == nil { + return nil, fmt.Errorf("request service is not configured") + } + out := make([]DiscoverBrandCard, 0, len(BundledNetworks)) + for _, network := range BundledNetworks { + out = append(out, DiscoverBrandCard{ + TMDBID: network.TMDBID, + Slug: network.Slug, + DisplayName: network.DisplayName, + LogoURL: duotoneLogoURL(network.LogoPath), + }) + } + return out, nil +} + +// ListGenres returns the bundled genres. Each card carries gradient hints +// (no logo URL) and a SeriesSupported flag for the browse page to decide +// whether to show the Series tab. +func (s *Service) ListGenres(_ context.Context, _ Viewer) ([]DiscoverBrandCard, error) { + if s == nil { + return nil, fmt.Errorf("request service is not configured") + } + out := make([]DiscoverBrandCard, 0, len(BundledGenres)) + for _, genre := range BundledGenres { + out = append(out, DiscoverBrandCard{ + Slug: genre.Slug, + DisplayName: genre.DisplayName, + GradientFrom: genre.GradientFrom, + GradientTo: genre.GradientTo, + SeriesSupported: genre.SeriesID > 0, + }) + } + return out, nil +} + +// duotoneLogoURL returns a TMDB CDN URL that recolors the logo into a +// white-on-light-gray duotone. Studio/network logos vary wildly in color +// and contrast; the duotone treatment keeps every card legible against a +// neutral background. Returns nil for empty paths. +func duotoneLogoURL(path string) *string { + if path == "" { + return nil + } + url := "https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)" + path + return &url +} + +// DiscoverBrowseResponse is the shape returned by the browse endpoints. +// Results share the same MediaResult shape as search and the existing +// discovery sections, so the frontend can reuse RequestPosterCard. +type DiscoverBrowseResponse struct { + Kind string `json:"kind"` + Slug string `json:"slug"` + DisplayName string `json:"display_name"` + LogoURL *string `json:"logo_url,omitempty"` + MediaType MediaType `json:"media_type"` + Sort string `json:"sort"` + Page int `json:"page"` + TotalPages int `json:"total_pages"` + Results []MediaResult `json:"results"` +} + +var validBrowseSorts = map[string]string{ + "popularity": "popularity.desc", + "vote_average": "vote_average.desc", + "release_date": "primary_release_date.desc", +} + +const defaultBrowseSort = "popularity" + +// BrowseStudio returns a page of movies from a bundled studio, enriched with +// Silo availability and request state. +func (s *Service) BrowseStudio(ctx context.Context, viewer Viewer, slug, sort string, page int) (*DiscoverBrowseResponse, error) { + if s == nil || s.tmdb == nil { + return nil, fmt.Errorf("request service is not configured") + } + studio, ok := FindStudioBySlug(strings.TrimSpace(slug)) + if !ok { + return nil, ErrNotFound + } + tmdbSort, sortKey, err := normalizeBrowseSort(sort, "movie") + if err != nil { + return nil, err + } + tmdbPage, err := s.tmdb.DiscoverPage(ctx, "movie", tmdb.DiscoverParams{ + SortBy: tmdbSort, + WithCompanies: []int{studio.TMDBID}, + VoteCountGte: voteCountFloorForSort(sortKey), + }, page) + if err != nil { + return nil, err + } + enriched, err := s.enrichPage(ctx, viewer, tmdbPage) + if err != nil { + return nil, err + } + return &DiscoverBrowseResponse{ + Kind: "studio", + Slug: studio.Slug, + DisplayName: studio.DisplayName, + LogoURL: duotoneLogoURL(studio.LogoPath), + MediaType: MediaTypeMovie, + Sort: sortKey, + Page: enriched.Page, + TotalPages: enriched.TotalPages, + Results: enriched.Results, + }, nil +} + +// BrowseNetwork returns a page of series from a bundled TV network. +func (s *Service) BrowseNetwork(ctx context.Context, viewer Viewer, slug, sort string, page int) (*DiscoverBrowseResponse, error) { + if s == nil || s.tmdb == nil { + return nil, fmt.Errorf("request service is not configured") + } + network, ok := FindNetworkBySlug(strings.TrimSpace(slug)) + if !ok { + return nil, ErrNotFound + } + tmdbSort, sortKey, err := normalizeBrowseSort(sort, "tv") + if err != nil { + return nil, err + } + tmdbPage, err := s.tmdb.DiscoverPage(ctx, "tv", tmdb.DiscoverParams{ + SortBy: tmdbSort, + WithNetworks: []int{network.TMDBID}, + VoteCountGte: voteCountFloorForSort(sortKey), + }, page) + if err != nil { + return nil, err + } + enriched, err := s.enrichPage(ctx, viewer, tmdbPage) + if err != nil { + return nil, err + } + return &DiscoverBrowseResponse{ + Kind: "network", + Slug: network.Slug, + DisplayName: network.DisplayName, + LogoURL: duotoneLogoURL(network.LogoPath), + MediaType: MediaTypeSeries, + Sort: sortKey, + Page: enriched.Page, + TotalPages: enriched.TotalPages, + Results: enriched.Results, + }, nil +} + +// BrowseGenre returns a page of movies or series from a bundled genre. +func (s *Service) BrowseGenre(ctx context.Context, viewer Viewer, slug string, rawMediaType MediaType, sort string, page int) (*DiscoverBrowseResponse, error) { + if s == nil || s.tmdb == nil { + return nil, fmt.Errorf("request service is not configured") + } + genre, ok := FindGenreBySlug(strings.TrimSpace(slug)) + if !ok { + return nil, ErrNotFound + } + mediaType, err := normalizeMediaType(rawMediaType) + if err != nil { + return nil, fmt.Errorf("%w: media_type is required for genre browse", ErrInvalidInput) + } + + var ( + tmdbMediaType string + genreID int + ) + switch mediaType { + case MediaTypeMovie: + tmdbMediaType = "movie" + genreID = genre.MovieID + case MediaTypeSeries: + tmdbMediaType = "tv" + genreID = genre.SeriesID + } + if genreID == 0 { + return nil, fmt.Errorf("%w: %s has no %s equivalent", ErrInvalidInput, slug, mediaType) + } + + tmdbSort, sortKey, err := normalizeBrowseSort(sort, tmdbMediaType) + if err != nil { + return nil, err + } + tmdbPage, err := s.tmdb.DiscoverPage(ctx, tmdbMediaType, tmdb.DiscoverParams{ + SortBy: tmdbSort, + WithGenres: []int{genreID}, + VoteCountGte: voteCountFloorForSort(sortKey), + }, page) + if err != nil { + return nil, err + } + enriched, err := s.enrichPage(ctx, viewer, tmdbPage) + if err != nil { + return nil, err + } + return &DiscoverBrowseResponse{ + Kind: "genre", + Slug: genre.Slug, + DisplayName: genre.DisplayName, + MediaType: mediaType, + Sort: sortKey, + Page: enriched.Page, + TotalPages: enriched.TotalPages, + Results: enriched.Results, + }, nil +} + +func normalizeBrowseSort(sort, tmdbMediaType string) (string, string, error) { + sort = strings.TrimSpace(sort) + if sort == "" { + sort = defaultBrowseSort + } + tmdbSort, ok := validBrowseSorts[sort] + if !ok { + return "", "", fmt.Errorf("%w: unknown sort %q", ErrInvalidInput, sort) + } + if sort == "release_date" && tmdbMediaType == "tv" { + tmdbSort = "first_air_date.desc" + } + return tmdbSort, sort, nil +} + +func voteCountFloorForSort(sortKey string) int { + if sortKey == "vote_average" { + return 100 + } + return 0 +} diff --git a/internal/requests/discover_bundle.go b/internal/requests/discover_bundle.go new file mode 100644 index 00000000..5076c391 --- /dev/null +++ b/internal/requests/discover_bundle.go @@ -0,0 +1,108 @@ +package requests + +// BundledStudio is a curated movie studio surfaced in the request discover +// section. LogoPath is a TMDB image file path that is rendered through the +// duotone filter for uniform white-on-gray presentation. +type BundledStudio struct { + TMDBID int + Slug string + DisplayName string + LogoPath string +} + +// BundledNetwork is a curated TV network surfaced in the request discover +// section. LogoPath is a TMDB image file path rendered through the duotone +// filter. +type BundledNetwork struct { + TMDBID int + Slug string + DisplayName string + LogoPath string +} + +// BundledGenre is a curated genre. MovieID is the TMDB movie genre ID; +// SeriesID is the TMDB tv genre ID, or 0 when no TV equivalent exists. +type BundledGenre struct { + Slug string + DisplayName string + GradientFrom string + GradientTo string + MovieID int + SeriesID int +} + +// BundledStudios is the compile-time list of studios shown in the Studios +// carousel. TMDB IDs and logo paths follow the curated set used by Overseerr +// so logos resolve reliably through the duotone filter. +var BundledStudios = []BundledStudio{ + {TMDBID: 2, Slug: "disney", DisplayName: "Disney", LogoPath: "/wdrCwmRnLFJhEoH8GSfymY85KHT.png"}, + {TMDBID: 3, Slug: "pixar", DisplayName: "Pixar", LogoPath: "/1TjvGVDMYsj6JBxOAkUHpPEwLf7.png"}, + {TMDBID: 420, Slug: "marvel-studios", DisplayName: "Marvel Studios", LogoPath: "/hUzeosd33nzE5MCNsZxCGEKTXaQ.png"}, + {TMDBID: 174, Slug: "warner-bros-pictures", DisplayName: "Warner Bros. Pictures", LogoPath: "/ky0xOc5OrhzkZ1N6KyUxacfQsCk.png"}, + {TMDBID: 33, Slug: "universal-pictures", DisplayName: "Universal Pictures", LogoPath: "/8lvHyhjr8oUKOOy2dKXoALWKdp0.png"}, + {TMDBID: 4, Slug: "paramount-pictures", DisplayName: "Paramount Pictures", LogoPath: "/fycMZt242LVjagMByZOLUGbCvv3.png"}, + {TMDBID: 34, Slug: "sony-pictures", DisplayName: "Sony Pictures", LogoPath: "/GagSvqWlyPdkFHMfQ3pNq6ix9P.png"}, + {TMDBID: 127928, Slug: "20th-century-studios", DisplayName: "20th Century Studios", LogoPath: "/h0rjX5vjW5r8yEnUBStFarjcLT4.png"}, + {TMDBID: 521, Slug: "dreamworks", DisplayName: "DreamWorks", LogoPath: "/kP7t6RwGz2AvvTkvnI1uteEwHet.png"}, + {TMDBID: 41077, Slug: "a24", DisplayName: "A24", LogoPath: "/1ZXsGaFPgrgS6ZZGS37AqD5uU12.png"}, +} + +// BundledNetworks is the compile-time list of networks shown in the Networks +// carousel. +var BundledNetworks = []BundledNetwork{ + {TMDBID: 213, Slug: "netflix", DisplayName: "Netflix", LogoPath: "/wwemzKWzjKYJFfCeiB57q3r4Bcm.png"}, + {TMDBID: 2739, Slug: "disney-plus", DisplayName: "Disney+", LogoPath: "/gJ8VX6JSu3ciXHuC2dDGAo2lvwM.png"}, + {TMDBID: 1024, Slug: "prime-video", DisplayName: "Prime Video", LogoPath: "/ifhbNuuVnlwYy5oXA5VIb2YR8AZ.png"}, + {TMDBID: 2552, Slug: "apple-tv-plus", DisplayName: "Apple TV+", LogoPath: "/4KAy34EHvRM25Ih8wb82AuGU7zJ.png"}, + {TMDBID: 453, Slug: "hulu", DisplayName: "Hulu", LogoPath: "/pqUTCleNUiTLAVlelGxUgWn1ELh.png"}, + {TMDBID: 49, Slug: "hbo", DisplayName: "HBO", LogoPath: "/tuomPhY2UtuPTqqFnKMVHvSb724.png"}, + {TMDBID: 4330, Slug: "paramount-plus", DisplayName: "Paramount+", LogoPath: "/fi83B1oztoS47xxcemFdPMhIzK.png"}, + {TMDBID: 174, Slug: "amc", DisplayName: "AMC", LogoPath: "/pmvRmATOCaDykE6JrVoeYxlFHw3.png"}, + {TMDBID: 4, Slug: "bbc-one", DisplayName: "BBC One", LogoPath: "/mVn7xESaTNmjBUyUtGNvDQd3CT1.png"}, + {TMDBID: 3353, Slug: "peacock", DisplayName: "Peacock", LogoPath: "/gIAcGTjKKr0KOHL5s4O36roJ8p7.png"}, +} + +// BundledGenres is the compile-time list of genres shown in the Genres +// carousel. SeriesID = 0 means the genre has no direct TV equivalent and +// the browse page hides the Series tab. +var BundledGenres = []BundledGenre{ + {Slug: "action", DisplayName: "Action", GradientFrom: "#dc2626", GradientTo: "#7f1d1d", MovieID: 28, SeriesID: 10759}, + {Slug: "comedy", DisplayName: "Comedy", GradientFrom: "#fbbf24", GradientTo: "#b45309", MovieID: 35, SeriesID: 35}, + {Slug: "drama", DisplayName: "Drama", GradientFrom: "#64748b", GradientTo: "#1e293b", MovieID: 18, SeriesID: 18}, + {Slug: "sci-fi", DisplayName: "Sci-Fi", GradientFrom: "#7c3aed", GradientTo: "#312e81", MovieID: 878, SeriesID: 10765}, + {Slug: "horror", DisplayName: "Horror", GradientFrom: "#7f1d1d", GradientTo: "#1f2937", MovieID: 27, SeriesID: 0}, + {Slug: "romance", DisplayName: "Romance", GradientFrom: "#ec4899", GradientTo: "#831843", MovieID: 10749, SeriesID: 0}, + {Slug: "animation", DisplayName: "Animation", GradientFrom: "#06b6d4", GradientTo: "#155e75", MovieID: 16, SeriesID: 16}, + {Slug: "documentary", DisplayName: "Documentary", GradientFrom: "#475569", GradientTo: "#0f172a", MovieID: 99, SeriesID: 99}, +} + +// FindStudioBySlug looks up a bundled studio by slug. Returns (zero, false) +// if not found. +func FindStudioBySlug(slug string) (BundledStudio, bool) { + for _, s := range BundledStudios { + if s.Slug == slug { + return s, true + } + } + return BundledStudio{}, false +} + +// FindNetworkBySlug looks up a bundled network by slug. +func FindNetworkBySlug(slug string) (BundledNetwork, bool) { + for _, n := range BundledNetworks { + if n.Slug == slug { + return n, true + } + } + return BundledNetwork{}, false +} + +// FindGenreBySlug looks up a bundled genre by slug. +func FindGenreBySlug(slug string) (BundledGenre, bool) { + for _, g := range BundledGenres { + if g.Slug == slug { + return g, true + } + } + return BundledGenre{}, false +} diff --git a/internal/requests/discover_bundle_test.go b/internal/requests/discover_bundle_test.go new file mode 100644 index 00000000..f2a1b967 --- /dev/null +++ b/internal/requests/discover_bundle_test.go @@ -0,0 +1,145 @@ +package requests + +import ( + "strings" + "testing" +) + +func TestBundleHasExpectedCounts(t *testing.T) { + if len(BundledStudios) != 10 { + t.Errorf("BundledStudios = %d, want 10", len(BundledStudios)) + } + if len(BundledNetworks) != 10 { + t.Errorf("BundledNetworks = %d, want 10", len(BundledNetworks)) + } + if len(BundledGenres) != 8 { + t.Errorf("BundledGenres = %d, want 8", len(BundledGenres)) + } +} + +func TestBundleStudiosHaveRequiredFields(t *testing.T) { + for _, s := range BundledStudios { + if s.TMDBID <= 0 { + t.Errorf("studio %q missing TMDBID", s.Slug) + } + if strings.TrimSpace(s.Slug) == "" { + t.Errorf("studio %+v missing Slug", s) + } + if strings.TrimSpace(s.DisplayName) == "" { + t.Errorf("studio %q missing DisplayName", s.Slug) + } + if !strings.HasPrefix(s.LogoPath, "/") || !strings.HasSuffix(s.LogoPath, ".png") { + t.Errorf("studio %q LogoPath must be a TMDB file path (/...png), got %q", s.Slug, s.LogoPath) + } + } +} + +func TestBundleNetworksHaveRequiredFields(t *testing.T) { + for _, n := range BundledNetworks { + if n.TMDBID <= 0 { + t.Errorf("network %q missing TMDBID", n.Slug) + } + if strings.TrimSpace(n.Slug) == "" { + t.Errorf("network %+v missing Slug", n) + } + if strings.TrimSpace(n.DisplayName) == "" { + t.Errorf("network %q missing DisplayName", n.Slug) + } + if !strings.HasPrefix(n.LogoPath, "/") || !strings.HasSuffix(n.LogoPath, ".png") { + t.Errorf("network %q LogoPath must be a TMDB file path (/...png), got %q", n.Slug, n.LogoPath) + } + } +} + +func TestBundleGenresHaveRequiredFields(t *testing.T) { + for _, g := range BundledGenres { + if strings.TrimSpace(g.Slug) == "" { + t.Errorf("genre %+v missing Slug", g) + } + if strings.TrimSpace(g.DisplayName) == "" { + t.Errorf("genre %q missing DisplayName", g.Slug) + } + if g.MovieID <= 0 { + t.Errorf("genre %q must have MovieID > 0 in v1", g.Slug) + } + if !strings.HasPrefix(g.GradientFrom, "#") || !strings.HasPrefix(g.GradientTo, "#") { + t.Errorf("genre %q gradient must use # hex, got from=%q to=%q", g.Slug, g.GradientFrom, g.GradientTo) + } + } +} + +func TestBundleSlugsAreUniqueWithinKind(t *testing.T) { + seen := map[string]string{} + for _, s := range BundledStudios { + key := "studio:" + s.Slug + if prior, ok := seen[key]; ok { + t.Errorf("duplicate studio slug %q (also %q)", s.Slug, prior) + } + seen[key] = s.DisplayName + } + for _, n := range BundledNetworks { + key := "network:" + n.Slug + if prior, ok := seen[key]; ok { + t.Errorf("duplicate network slug %q (also %q)", n.Slug, prior) + } + seen[key] = n.DisplayName + } + for _, g := range BundledGenres { + key := "genre:" + g.Slug + if prior, ok := seen[key]; ok { + t.Errorf("duplicate genre slug %q (also %q)", g.Slug, prior) + } + seen[key] = g.DisplayName + } +} + +func TestFindStudioBySlug(t *testing.T) { + got, ok := FindStudioBySlug("marvel-studios") + if !ok { + t.Fatal("expected marvel-studios to exist") + } + if got.DisplayName != "Marvel Studios" { + t.Errorf("display = %q, want Marvel Studios", got.DisplayName) + } + + if _, ok := FindStudioBySlug("not-a-real-studio"); ok { + t.Error("expected unknown slug to return false") + } +} + +func TestFindNetworkBySlug(t *testing.T) { + got, ok := FindNetworkBySlug("netflix") + if !ok { + t.Fatal("expected netflix to exist") + } + if got.DisplayName != "Netflix" { + t.Errorf("display = %q, want Netflix", got.DisplayName) + } +} + +func TestFindGenreBySlug(t *testing.T) { + got, ok := FindGenreBySlug("action") + if !ok { + t.Fatal("expected action to exist") + } + if got.MovieID != 28 { + t.Errorf("movie id = %d, want 28", got.MovieID) + } +} + +func TestGenresWithoutTVEquivalentHaveZeroSeriesID(t *testing.T) { + horror, ok := FindGenreBySlug("horror") + if !ok { + t.Fatal("expected horror to exist") + } + if horror.SeriesID != 0 { + t.Errorf("horror.SeriesID = %d, want 0", horror.SeriesID) + } + romance, ok := FindGenreBySlug("romance") + if !ok { + t.Fatal("expected romance to exist") + } + if romance.SeriesID != 0 { + t.Errorf("romance.SeriesID = %d, want 0", romance.SeriesID) + } +} diff --git a/internal/requests/errors.go b/internal/requests/errors.go new file mode 100644 index 00000000..84998d95 --- /dev/null +++ b/internal/requests/errors.go @@ -0,0 +1,30 @@ +package requests + +import "errors" + +var ( + ErrInvalidMediaType = errors.New("invalid media type") + ErrInvalidInput = errors.New("invalid request input") + ErrRequestsDisabled = errors.New("requests are disabled") + ErrUserBlocked = errors.New("user is blocked from requesting") + ErrQuotaExceeded = errors.New("request quota exceeded") + ErrAlreadyAvailable = errors.New("media is already available") + ErrAlreadyRequested = errors.New("media is already requested") + ErrNotFound = errors.New("request not found") + ErrForbidden = errors.New("request forbidden") + ErrInvalidState = errors.New("invalid request state") +) + +type QuotaError struct { + Used int + Limit int + WindowDays int +} + +func (e QuotaError) Error() string { + return ErrQuotaExceeded.Error() +} + +func (e QuotaError) Unwrap() error { + return ErrQuotaExceeded +} diff --git a/internal/requests/presence.go b/internal/requests/presence.go new file mode 100644 index 00000000..ad1224e0 --- /dev/null +++ b/internal/requests/presence.go @@ -0,0 +1,121 @@ +package requests + +import ( + "context" + "log/slog" + "strconv" + "strings" + + "github.com/Silo-Server/silo-server/internal/catalog" +) + +type PresenceCandidate struct { + TMDBID int + TVDBID *int + IMDbID string +} + +type PresenceMatch struct { + Available bool + ContentID string + MatchedProvider string +} + +type PresenceResolver interface { + Lookup(ctx context.Context, mediaType MediaType, candidates []PresenceCandidate) (map[int]PresenceMatch, error) +} + +type presenceItemLookup interface { + LookupExternalIDs(ctx context.Context, mediaType string, candidates []catalog.ExternalIDLookupCandidate) ([]catalog.ExternalIDMatchRow, error) +} + +type tmdbBackfiller interface { + AttachTMDBID(ctx context.Context, contentID, itemType string, tmdbID int) error +} + +type CatalogPresence struct { + items presenceItemLookup + tmdbBackfill tmdbBackfiller +} + +func NewCatalogPresence(items *catalog.ItemRepository, providerIDs ...*catalog.ProviderIDRepository) *CatalogPresence { + var itemLookup presenceItemLookup + if items != nil { + itemLookup = items + } + var backfill tmdbBackfiller + if len(providerIDs) > 0 && providerIDs[0] != nil { + backfill = providerIDs[0] + } + return &CatalogPresence{items: itemLookup, tmdbBackfill: backfill} +} + +func (p *CatalogPresence) Lookup(ctx context.Context, mediaType MediaType, candidates []PresenceCandidate) (map[int]PresenceMatch, error) { + out := map[int]PresenceMatch{} + if p == nil || p.items == nil || len(candidates) == 0 { + return out, nil + } + + lookupCandidates := make([]catalog.ExternalIDLookupCandidate, 0, len(candidates)) + for _, candidate := range candidates { + if candidate.TMDBID <= 0 { + continue + } + row := catalog.ExternalIDLookupCandidate{ + TMDBID: strconv.Itoa(candidate.TMDBID), + IMDbID: strings.TrimSpace(candidate.IMDbID), + } + if candidate.TVDBID != nil && *candidate.TVDBID > 0 { + row.TVDBID = strconv.Itoa(*candidate.TVDBID) + } + lookupCandidates = append(lookupCandidates, row) + } + if len(lookupCandidates) == 0 { + return out, nil + } + + rows, err := p.items.LookupExternalIDs(ctx, string(mediaType), lookupCandidates) + if err != nil { + return nil, err + } + for _, row := range rows { + id, err := strconv.Atoi(row.QueryTMDBID) + if err != nil || id <= 0 { + continue + } + out[id] = PresenceMatch{ + Available: true, + ContentID: row.MediaID, + MatchedProvider: row.MatchedProvider, + } + if row.MatchedProvider != "tmdb" && p.tmdbBackfill != nil { + if err := p.tmdbBackfill.AttachTMDBID(ctx, row.MediaID, string(mediaType), id); err != nil { + slog.Warn("requests: failed to backfill tmdb id from presence lookup", + "content_id", row.MediaID, + "media_type", mediaType, + "tmdb_id", id, + "matched_provider", row.MatchedProvider, + "error", err) + } + } + } + return out, nil +} + +func (p *CatalogPresence) LookupTMDB(ctx context.Context, mediaType MediaType, tmdbIDs []int) (map[int]bool, error) { + candidates := make([]PresenceCandidate, 0, len(tmdbIDs)) + for _, id := range tmdbIDs { + if id > 0 { + candidates = append(candidates, PresenceCandidate{TMDBID: id}) + } + } + matches, err := p.Lookup(ctx, mediaType, candidates) + if err != nil { + return nil, err + } + out := map[int]bool{} + for id, match := range matches { + out[id] = match.Available + } + return out, nil +} diff --git a/internal/requests/presence_test.go b/internal/requests/presence_test.go new file mode 100644 index 00000000..417daca9 --- /dev/null +++ b/internal/requests/presence_test.go @@ -0,0 +1,94 @@ +package requests + +import ( + "context" + "testing" + + "github.com/Silo-Server/silo-server/internal/catalog" +) + +type fakePresenceLookup struct { + rows []catalog.ExternalIDMatchRow + got []catalog.ExternalIDLookupCandidate +} + +func (f *fakePresenceLookup) LookupExternalIDs(_ context.Context, _ string, candidates []catalog.ExternalIDLookupCandidate) ([]catalog.ExternalIDMatchRow, error) { + f.got = append([]catalog.ExternalIDLookupCandidate(nil), candidates...) + return f.rows, nil +} + +type fakeTMDBBackfiller struct { + contentID string + itemType string + tmdbID int +} + +func (f *fakeTMDBBackfiller) AttachTMDBID(_ context.Context, contentID, itemType string, tmdbID int) error { + f.contentID = contentID + f.itemType = itemType + f.tmdbID = tmdbID + return nil +} + +func TestCatalogPresenceMatchesByTVDBAndBackfillsTMDB(t *testing.T) { + tvdbID := 420105 + lookup := &fakePresenceLookup{rows: []catalog.ExternalIDMatchRow{{ + QueryTMDBID: "201992", + MediaID: "120983767174086659", + MatchedProvider: "tvdb", + LibraryID: "2", + Title: "The Rookie: Feds", + }}} + backfill := &fakeTMDBBackfiller{} + presence := &CatalogPresence{items: lookup, tmdbBackfill: backfill} + + result, err := presence.Lookup(context.Background(), MediaTypeSeries, []PresenceCandidate{{ + TMDBID: 201992, + TVDBID: &tvdbID, + IMDbID: "tt18076310", + }}) + if err != nil { + t.Fatalf("Lookup returned error: %v", err) + } + if !result[201992].Available { + t.Fatalf("available = false, want true") + } + if result[201992].MatchedProvider != "tvdb" { + t.Fatalf("matched provider = %q, want tvdb", result[201992].MatchedProvider) + } + if backfill.contentID != "120983767174086659" || backfill.itemType != "series" || backfill.tmdbID != 201992 { + t.Fatalf("backfill = %+v, want content 120983767174086659 series tmdb 201992", backfill) + } +} + +func TestCatalogPresenceKeepsLookupTMDBCompatibility(t *testing.T) { + lookup := &fakePresenceLookup{rows: []catalog.ExternalIDMatchRow{{ + QueryTMDBID: "550", + MediaID: "movie-1", + MatchedProvider: "tmdb", + LibraryID: "1", + Title: "Fight Club", + }}} + presence := &CatalogPresence{items: lookup} + + result, err := presence.LookupTMDB(context.Background(), MediaTypeMovie, []int{550}) + if err != nil { + t.Fatalf("LookupTMDB returned error: %v", err) + } + if !result[550] { + t.Fatalf("result[550] = false, want true") + } + if len(lookup.got) != 1 || lookup.got[0].TMDBID != "550" { + t.Fatalf("lookup candidates = %+v, want tmdb candidate", lookup.got) + } +} + +func TestNewCatalogPresenceIgnoresNilRepositories(t *testing.T) { + presence := NewCatalogPresence(nil, nil) + if presence.items != nil { + t.Fatalf("items = %#v, want nil", presence.items) + } + if presence.tmdbBackfill != nil { + t.Fatalf("tmdbBackfill = %#v, want nil", presence.tmdbBackfill) + } +} diff --git a/internal/requests/radarr/client.go b/internal/requests/radarr/client.go new file mode 100644 index 00000000..7f6f7624 --- /dev/null +++ b/internal/requests/radarr/client.go @@ -0,0 +1,176 @@ +package radarr + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + + mediarequests "github.com/Silo-Server/silo-server/internal/requests" + "github.com/Silo-Server/silo-server/internal/requests/arrclient" +) + +type Client struct { + httpClient *http.Client +} + +type movieResource struct { + ID int `json:"id,omitempty"` + Title string `json:"title,omitempty"` + TMDBID int `json:"tmdbId,omitempty"` + Year int `json:"year,omitempty"` + TitleSlug string `json:"titleSlug,omitempty"` + QualityProfileID int `json:"qualityProfileId,omitempty"` + RootFolderPath string `json:"rootFolderPath,omitempty"` + Monitored bool `json:"monitored"` + MinimumAvailability string `json:"minimumAvailability,omitempty"` + Tags []int `json:"tags,omitempty"` + AddOptions addMovieOptions `json:"addOptions,omitempty"` +} + +type addMovieOptions struct { + SearchForMovie bool `json:"searchForMovie"` + Monitor string `json:"monitor,omitempty"` +} + +func NewClient(httpClient *http.Client) *Client { + return &Client{httpClient: httpClient} +} + +func (c *Client) ListMovieIntegrationOptions(ctx context.Context, integration mediarequests.Integration) (*mediarequests.IntegrationOptions, error) { + client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient) + rootFolders, err := arrclient.ListRootFolders(ctx, client) + if err != nil { + return nil, err + } + qualityProfiles, err := arrclient.ListQualityProfiles(ctx, client) + if err != nil { + return nil, err + } + tags, err := arrclient.ListTags(ctx, client) + if err != nil { + return nil, err + } + return &mediarequests.IntegrationOptions{ + Kind: "radarr", + RootFolders: rootFolders, + QualityProfiles: qualityProfiles, + Tags: tags, + }, nil +} + +func (c *Client) SubmitMovie(ctx context.Context, req mediarequests.Request, integration mediarequests.Integration) (mediarequests.FulfillmentResult, error) { + if req.MediaType != mediarequests.MediaTypeMovie { + return mediarequests.FulfillmentResult{}, fmt.Errorf("radarr: request is not a movie") + } + if integration.QualityProfileID == nil { + return mediarequests.FulfillmentResult{}, fmt.Errorf("radarr: quality profile is required") + } + + client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient) + movie, err := c.lookupMovie(ctx, client, req.TMDBID) + if err != nil { + return mediarequests.FulfillmentResult{}, err + } + movie.RootFolderPath = integration.RootFolder + movie.QualityProfileID = *integration.QualityProfileID + movie.Monitored = arrclient.BoolOption(integration.Options, "monitored", true) + movie.MinimumAvailability = arrclient.StringOption(integration.Options, "minimum_availability", "released") + movie.Tags = integration.Tags + movie.AddOptions = addMovieOptions{ + SearchForMovie: arrclient.BoolOption( + integration.Options, + "search_for_movie", + arrclient.BoolOption(integration.Options, "search_on_add", true), + ), + Monitor: arrclient.StringOption(integration.Options, "monitor", "movieOnly"), + } + + var created movieResource + if err := client.PostJSON(ctx, "/api/v3/movie", movie, &created); err != nil { + if !arrclient.IsEmptyOrTruncatedDecodeError(err) { + return mediarequests.FulfillmentResult{}, err + } + // POST accepted but Radarr returned an empty body. Recover the + // new movie's Radarr ID by listing movies filtered by TMDB ID; + // without the ID the reconcile loop cannot advance the request. + if found, lookErr := c.findMovieByTMDBID(ctx, client, req.TMDBID); lookErr == nil && found.ID > 0 { + return resultFromMovie(found), nil + } + return arrclient.AcceptedWithoutResponse("radarr"), nil + } + return resultFromMovie(created), nil +} + +func (c *Client) findMovieByTMDBID(ctx context.Context, client *arrclient.Client, tmdbID int) (movieResource, error) { + values := url.Values{} + values.Set("tmdbId", strconv.Itoa(tmdbID)) + var matches []movieResource + if err := client.GetJSON(ctx, "/api/v3/movie?"+values.Encode(), &matches); err != nil { + return movieResource{}, err + } + for _, m := range matches { + if m.ID > 0 { + return m, nil + } + } + return movieResource{}, fmt.Errorf("radarr: movie not found after add for tmdb_id %d", tmdbID) +} + +func (c *Client) CheckMovieStatus(ctx context.Context, req mediarequests.Request, integration mediarequests.Integration) (mediarequests.FulfillmentStatus, error) { + client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient) + movieID, _ := strconv.Atoi(req.ExternalID) + if movieID <= 0 { + return mediarequests.FulfillmentStatus{ + Status: mediarequests.StatusQueued, + IntegrationKind: "radarr", + ExternalStatus: "external_id_unavailable", + }, nil + } + + queues, err := c.queueDetails(ctx, client, movieID) + if err != nil { + return mediarequests.FulfillmentStatus{}, err + } + evaluation := arrclient.EvaluateQueue(queues) + return arrclient.StatusFromQueueEvaluation("radarr", movieID, evaluation), nil +} + +func (c *Client) lookupMovie(ctx context.Context, client *arrclient.Client, tmdbID int) (movieResource, error) { + values := url.Values{} + values.Set("tmdbId", strconv.Itoa(tmdbID)) + // Radarr's /api/v3/movie/lookup/tmdb returns a single MovieResource, unlike + // /api/v3/movie/lookup which returns an array. Missing IDs return non-2xx + // (handled as HTTPError upstream), so a successful response is the movie. + var movie movieResource + if err := client.GetJSON(ctx, "/api/v3/movie/lookup/tmdb?"+values.Encode(), &movie); err != nil { + return movieResource{}, err + } + if movie.TMDBID == 0 { + movie.TMDBID = tmdbID + } + return movie, nil +} + +func (c *Client) queueDetails(ctx context.Context, client *arrclient.Client, movieID int) ([]arrclient.QueueResource, error) { + values := url.Values{} + values.Set("movieId", strconv.Itoa(movieID)) + var queues []arrclient.QueueResource + if err := client.GetJSON(ctx, "/api/v3/queue/details?"+values.Encode(), &queues); err != nil { + return nil, err + } + return queues, nil +} + +func resultFromMovie(movie movieResource) mediarequests.FulfillmentResult { + externalID := "" + if movie.ID > 0 { + externalID = strconv.Itoa(movie.ID) + } + return mediarequests.FulfillmentResult{ + IntegrationKind: "radarr", + ExternalID: externalID, + ExternalStatus: "queued", + } +} diff --git a/internal/requests/radarr/client_test.go b/internal/requests/radarr/client_test.go new file mode 100644 index 00000000..63e11d0b --- /dev/null +++ b/internal/requests/radarr/client_test.go @@ -0,0 +1,236 @@ +package radarr + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + mediarequests "github.com/Silo-Server/silo-server/internal/requests" +) + +func TestSubmitMovieAddsLookupResult(t *testing.T) { + qualityProfileID := 7 + var posted movieResource + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Api-Key"); got != "radarr-key" { + t.Fatalf("X-Api-Key = %q, want radarr-key", got) + } + switch r.URL.Path { + case "/api/v3/movie": + if r.Method == http.MethodGet { + if got := r.URL.Query().Get("tmdbId"); got != "550" { + t.Fatalf("tmdbId = %q, want 550", got) + } + w.Write([]byte(`[]`)) + return + } + if r.Method == http.MethodPost { + if err := json.NewDecoder(r.Body).Decode(&posted); err != nil { + t.Fatalf("decode posted movie: %v", err) + } + w.Write([]byte(`{"id":42,"tmdbId":550}`)) + return + } + case "/api/v3/movie/lookup/tmdb": + if got := r.URL.Query().Get("tmdbId"); got != "550" { + t.Fatalf("lookup tmdbId = %q, want 550", got) + } + w.Write([]byte(`{"title":"Fight Club","tmdbId":550,"titleSlug":"fight-club"}`)) + return + } + t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) + })) + defer server.Close() + + client := NewClient(server.Client()) + result, err := client.SubmitMovie(context.Background(), mediarequests.Request{ + MediaType: mediarequests.MediaTypeMovie, + TMDBID: 550, + Title: "Fight Club", + }, mediarequests.Integration{ + Kind: "radarr", + BaseURL: server.URL, + APIKeyRef: "radarr-key", + RootFolder: "/movies", + QualityProfileID: &qualityProfileID, + Options: map[string]any{ + "search_on_add": false, + }, + }) + if err != nil { + t.Fatalf("SubmitMovie returned error: %v", err) + } + if result.ExternalID != "42" || result.IntegrationKind != "radarr" { + t.Fatalf("result = %+v, want radarr external id 42", result) + } + if posted.RootFolderPath != "/movies" || posted.QualityProfileID != qualityProfileID { + t.Fatalf("posted movie = %+v, missing root folder/quality profile", posted) + } + if posted.AddOptions.SearchForMovie { + t.Fatalf("searchForMovie = true, want false") + } +} + +func TestSubmitMovieRecoversFromEmptyAddResponse(t *testing.T) { + qualityProfileID := 7 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v3/movie/lookup/tmdb": + w.Write([]byte(`{"title":"Fight Club","tmdbId":550,"titleSlug":"fight-club"}`)) + case "/api/v3/movie": + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusCreated) + return + } + if r.Method == http.MethodGet { + if got := r.URL.Query().Get("tmdbId"); got != "550" { + t.Fatalf("tmdbId = %q, want 550", got) + } + w.Write([]byte(`[{"id":99,"tmdbId":550,"title":"Fight Club"}]`)) + return + } + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) + } + })) + defer server.Close() + + client := NewClient(server.Client()) + result, err := client.SubmitMovie(context.Background(), mediarequests.Request{ + MediaType: mediarequests.MediaTypeMovie, + TMDBID: 550, + Title: "Fight Club", + }, mediarequests.Integration{ + Kind: "radarr", + BaseURL: server.URL, + APIKeyRef: "radarr-key", + RootFolder: "/movies", + QualityProfileID: &qualityProfileID, + }) + if err != nil { + t.Fatalf("SubmitMovie returned error: %v", err) + } + if result.ExternalID != "99" { + t.Fatalf("ExternalID = %q, want 99 (recovered after empty 201)", result.ExternalID) + } + if result.ExternalStatus != "queued" { + t.Fatalf("ExternalStatus = %q, want queued", result.ExternalStatus) + } +} + +func TestSubmitMovieFallsBackWhenEmptyResponseAndLookupFails(t *testing.T) { + qualityProfileID := 7 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v3/movie/lookup/tmdb": + w.Write([]byte(`{"title":"Fight Club","tmdbId":550,"titleSlug":"fight-club"}`)) + case "/api/v3/movie": + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusCreated) + return + } + if r.Method == http.MethodGet { + w.Write([]byte(`[]`)) + return + } + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) + } + })) + defer server.Close() + + client := NewClient(server.Client()) + result, err := client.SubmitMovie(context.Background(), mediarequests.Request{ + MediaType: mediarequests.MediaTypeMovie, + TMDBID: 550, + Title: "Fight Club", + }, mediarequests.Integration{ + Kind: "radarr", + BaseURL: server.URL, + APIKeyRef: "radarr-key", + RootFolder: "/movies", + QualityProfileID: &qualityProfileID, + }) + if err != nil { + t.Fatalf("SubmitMovie returned error: %v", err) + } + if result.ExternalStatus != "accepted_without_response" { + t.Fatalf("ExternalStatus = %q, want accepted_without_response", result.ExternalStatus) + } +} + +func TestCheckMovieStatusReadsQueueDetails(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Api-Key"); got != "radarr-key" { + t.Fatalf("X-Api-Key = %q, want radarr-key", got) + } + if r.URL.Path != "/api/v3/queue/details" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + if got := r.URL.Query().Get("movieId"); got != "42" { + t.Fatalf("movieId = %q, want 42", got) + } + w.Write([]byte(`[{"movieId":42,"status":"downloading","trackedDownloadState":"downloading"}]`)) + })) + defer server.Close() + + client := NewClient(server.Client()) + status, err := client.CheckMovieStatus(context.Background(), mediarequests.Request{ + MediaType: mediarequests.MediaTypeMovie, + TMDBID: 550, + ExternalID: "42", + }, mediarequests.Integration{ + Kind: "radarr", + BaseURL: server.URL, + APIKeyRef: "radarr-key", + }) + if err != nil { + t.Fatalf("CheckMovieStatus returned error: %v", err) + } + if status.Status != mediarequests.StatusDownloading || status.ExternalStatus != "downloading/downloading" { + t.Fatalf("status = %+v, want downloading", status) + } +} + +func TestListMovieIntegrationOptionsLoadsChoices(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Api-Key"); got != "radarr-key" { + t.Fatalf("X-Api-Key = %q, want radarr-key", got) + } + switch r.URL.Path { + case "/api/v3/rootfolder": + w.Write([]byte(`[{"path":"/movies","freeSpace":123,"totalSpace":456,"accessible":true}]`)) + case "/api/v3/qualityprofile": + w.Write([]byte(`[{"id":7,"name":"HD-1080p"}]`)) + case "/api/v3/tag": + w.Write([]byte(`[{"id":2,"label":"requests"}]`)) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + + client := NewClient(server.Client()) + options, err := client.ListMovieIntegrationOptions(context.Background(), mediarequests.Integration{ + Kind: "radarr", + BaseURL: server.URL, + APIKeyRef: "radarr-key", + }) + if err != nil { + t.Fatalf("ListMovieIntegrationOptions returned error: %v", err) + } + if options.Kind != "radarr" { + t.Fatalf("kind = %q, want radarr", options.Kind) + } + if len(options.RootFolders) != 1 || options.RootFolders[0].Path != "/movies" { + t.Fatalf("root folders = %+v, want /movies", options.RootFolders) + } + if len(options.QualityProfiles) != 1 || options.QualityProfiles[0].ID != 7 { + t.Fatalf("quality profiles = %+v, want id 7", options.QualityProfiles) + } + if len(options.Tags) != 1 || options.Tags[0].ID != 2 { + t.Fatalf("tags = %+v, want id 2", options.Tags) + } +} diff --git a/internal/requests/repository.go b/internal/requests/repository.go new file mode 100644 index 00000000..24ce7e54 --- /dev/null +++ b/internal/requests/repository.go @@ -0,0 +1,755 @@ +package requests + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Repository struct { + pool *pgxpool.Pool +} + +func NewRepository(pool *pgxpool.Pool) *Repository { + return &Repository{pool: pool} +} + +func (r *Repository) GetSettings(ctx context.Context) (Settings, error) { + var s Settings + err := r.pool.QueryRow(ctx, ` + SELECT requests_enabled, global_max_requests, global_window_days, + global_auto_approval_enabled, updated_at + FROM request_settings + WHERE id = true + `).Scan(&s.RequestsEnabled, &s.GlobalMaxRequests, &s.GlobalWindowDays, &s.GlobalAutoApprovalEnabled, &s.UpdatedAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return Settings{ + RequestsEnabled: false, + GlobalMaxRequests: 5, + GlobalWindowDays: 7, + GlobalAutoApprovalEnabled: false, + }, nil + } + return Settings{}, fmt.Errorf("get request settings: %w", err) + } + return s, nil +} + +func (r *Repository) UpdateSettings(ctx context.Context, settings Settings) (Settings, error) { + if settings.GlobalWindowDays <= 0 { + settings.GlobalWindowDays = 7 + } + if settings.GlobalMaxRequests < 0 { + settings.GlobalMaxRequests = 0 + } + + var s Settings + err := r.pool.QueryRow(ctx, ` + INSERT INTO request_settings ( + id, requests_enabled, global_max_requests, global_window_days, + global_auto_approval_enabled, updated_at + ) + VALUES (true, $1, $2, $3, $4, now()) + ON CONFLICT (id) DO UPDATE SET + requests_enabled = EXCLUDED.requests_enabled, + global_max_requests = EXCLUDED.global_max_requests, + global_window_days = EXCLUDED.global_window_days, + global_auto_approval_enabled = EXCLUDED.global_auto_approval_enabled, + updated_at = now() + RETURNING requests_enabled, global_max_requests, global_window_days, + global_auto_approval_enabled, updated_at + `, settings.RequestsEnabled, settings.GlobalMaxRequests, settings.GlobalWindowDays, settings.GlobalAutoApprovalEnabled). + Scan(&s.RequestsEnabled, &s.GlobalMaxRequests, &s.GlobalWindowDays, &s.GlobalAutoApprovalEnabled, &s.UpdatedAt) + if err != nil { + return Settings{}, fmt.Errorf("update request settings: %w", err) + } + return s, nil +} + +func (r *Repository) GetUserLimit(ctx context.Context, userID int) (*UserLimit, error) { + var row UserLimit + var max, window sql.NullInt64 + err := r.pool.QueryRow(ctx, ` + SELECT user_id, limit_mode, max_requests, window_days, approval_mode, updated_at + FROM request_user_limits + WHERE user_id = $1 + `, userID).Scan(&row.UserID, &row.LimitMode, &max, &window, &row.ApprovalMode, &row.UpdatedAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get request user limit: %w", err) + } + if max.Valid { + v := int(max.Int64) + row.MaxRequests = &v + } + if window.Valid { + v := int(window.Int64) + row.WindowDays = &v + } + return &row, nil +} + +func (r *Repository) UpsertUserLimit(ctx context.Context, limit UserLimit) (*UserLimit, error) { + var max, window any + if limit.MaxRequests != nil { + max = *limit.MaxRequests + } + if limit.WindowDays != nil { + window = *limit.WindowDays + } + var row UserLimit + var scannedMax, scannedWindow sql.NullInt64 + err := r.pool.QueryRow(ctx, ` + INSERT INTO request_user_limits ( + user_id, limit_mode, max_requests, window_days, approval_mode, updated_at + ) + VALUES ($1, $2, $3, $4, $5, now()) + ON CONFLICT (user_id) DO UPDATE SET + limit_mode = EXCLUDED.limit_mode, + max_requests = EXCLUDED.max_requests, + window_days = EXCLUDED.window_days, + approval_mode = EXCLUDED.approval_mode, + updated_at = now() + RETURNING user_id, limit_mode, max_requests, window_days, approval_mode, updated_at + `, limit.UserID, limit.LimitMode, max, window, limit.ApprovalMode). + Scan(&row.UserID, &row.LimitMode, &scannedMax, &scannedWindow, &row.ApprovalMode, &row.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("upsert request user limit: %w", err) + } + if scannedMax.Valid { + v := int(scannedMax.Int64) + row.MaxRequests = &v + } + if scannedWindow.Valid { + v := int(scannedWindow.Int64) + row.WindowDays = &v + } + return &row, nil +} + +func (r *Repository) CountUserRequestsSince(ctx context.Context, userID int, since time.Time) (int, error) { + var count int + if err := r.pool.QueryRow(ctx, ` + SELECT COUNT(*) + FROM media_requests + WHERE requested_by_user_id = $1 + AND created_at >= $2 + `, userID, since).Scan(&count); err != nil { + return 0, fmt.Errorf("count user requests: %w", err) + } + return count, nil +} + +func (r *Repository) ListActiveByTMDB(ctx context.Context, mediaType MediaType, tmdbIDs []int) (map[int]*Request, error) { + if len(tmdbIDs) == 0 { + return map[int]*Request{}, nil + } + rows, err := r.pool.Query(ctx, requestSelectSQL()+` + WHERE media_type = $1 + AND provider = 'tmdb' + AND tmdb_id = ANY($2) + AND outcome = 'active' + AND status <> 'completed' + `, mediaType, tmdbIDs) + if err != nil { + return nil, fmt.Errorf("list active requests by tmdb: %w", err) + } + defer rows.Close() + + out := map[int]*Request{} + for rows.Next() { + req, err := scanRequest(rows) + if err != nil { + return nil, err + } + out[req.TMDBID] = req + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate active requests by tmdb: %w", err) + } + return out, nil +} + +func (r *Repository) DeleteFailedByTMDB(ctx context.Context, mediaType MediaType, tmdbID int) (int, error) { + if tmdbID <= 0 { + return 0, nil + } + tag, err := r.pool.Exec(ctx, ` + DELETE FROM media_requests + WHERE media_type = $1 + AND provider = 'tmdb' + AND tmdb_id = $2 + AND outcome = 'failed' + `, mediaType, tmdbID) + if err != nil { + return 0, fmt.Errorf("delete failed requests by tmdb: %w", err) + } + return int(tag.RowsAffected()), nil +} + +// quotaLockNamespace partitions advisory locks so request-quota locks do not +// collide with advisory locks held elsewhere in the database. The value is +// arbitrary; what matters is that it is stable. +const quotaLockNamespace = 139 + +func (r *Repository) CreateRequest(ctx context.Context, input CreateRequestRecord) (*Request, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin create request transaction: %w", err) + } + defer tx.Rollback(ctx) + + if input.Quota != nil { + if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1::int4, $2::int4)`, + quotaLockNamespace, input.Quota.UserID); err != nil { + return nil, fmt.Errorf("acquire request quota lock: %w", err) + } + var count int + if err := tx.QueryRow(ctx, ` + SELECT COUNT(*) + FROM media_requests + WHERE requested_by_user_id = $1 + AND created_at >= $2 + `, input.Quota.UserID, input.Quota.WindowStart).Scan(&count); err != nil { + return nil, fmt.Errorf("count requests for quota: %w", err) + } + if count >= input.Quota.MaxRequests { + return nil, ErrQuotaExceeded + } + } + + now := input.Now + if now.IsZero() { + now = time.Now().UTC() + } + status := input.Status + if status == "" { + status = StatusPending + } + outcome := input.Outcome + if outcome == "" { + outcome = OutcomeActive + } + + var approvedAt any + if status != StatusPending { + approvedAt = now + } + + req, err := r.insertRequest(ctx, tx, input, status, outcome, now, approvedAt) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return nil, ErrAlreadyRequested + } + return nil, err + } + if err := r.recordEvent(ctx, tx, req.ID, "created", input.Requester, ""); err != nil { + return nil, err + } + if status == StatusApproved { + if err := r.recordEvent(ctx, tx, req.ID, "approved", input.Requester, "auto approved"); err != nil { + return nil, err + } + } + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit create request transaction: %w", err) + } + return req, nil +} + +type requestExecutor interface { + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row + Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) +} + +func (r *Repository) insertRequest( + ctx context.Context, + exec requestExecutor, + input CreateRequestRecord, + status Status, + outcome Outcome, + now time.Time, + approvedAt any, +) (*Request, error) { + var tvdbID any + if input.Input.TVDBID != nil { + tvdbID = *input.Input.TVDBID + } + var year any + if input.Input.Year != nil { + year = *input.Input.Year + } + row := exec.QueryRow(ctx, ` + INSERT INTO media_requests ( + id, provider, media_type, tmdb_id, tvdb_id, imdb_id, title, year, + overview, poster_path, backdrop_path, status, outcome, + requested_by_user_id, requested_by_profile_id, created_at, updated_at, approved_at + ) + VALUES ( + $1, 'tmdb', $2, $3, $4, $5, $6, $7, + $8, $9, $10, $11, $12, + $13, $14, $15, $15, $16 + ) + RETURNING `+requestColumns(), input.ID, input.Input.MediaType, input.Input.TMDBID, tvdbID, + strings.TrimSpace(input.Input.IMDbID), strings.TrimSpace(input.Input.Title), year, + strings.TrimSpace(input.Input.Overview), strings.TrimSpace(input.Input.PosterPath), + strings.TrimSpace(input.Input.BackdropPath), status, outcome, + input.Requester.UserID, input.Requester.ProfileID, now, approvedAt) + req, err := scanRequest(row) + if err != nil { + return nil, fmt.Errorf("insert request: %w", err) + } + return req, nil +} + +func (r *Repository) GetRequest(ctx context.Context, id string) (*Request, error) { + req, err := scanRequest(r.pool.QueryRow(ctx, requestSelectSQL()+` + WHERE id = $1 + `, id)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return req, nil +} + +func (r *Repository) ListReconciliationCandidates(ctx context.Context, limit int) ([]*Request, error) { + if limit <= 0 || limit > 500 { + limit = 100 + } + rows, err := r.pool.Query(ctx, requestSelectSQL()+` + WHERE outcome = 'active' + AND status IN ('approved', 'queued', 'downloading') + ORDER BY updated_at ASC + LIMIT $1 + `, limit) + if err != nil { + return nil, fmt.Errorf("list request reconciliation candidates: %w", err) + } + defer rows.Close() + + var out []*Request + for rows.Next() { + req, err := scanRequest(rows) + if err != nil { + return nil, err + } + out = append(out, req) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate request reconciliation candidates: %w", err) + } + return out, nil +} + +func (r *Repository) ListMine(ctx context.Context, userID int, filter ListFilter) ([]*Request, error) { + sqlText, args := buildRequestListSQL("requested_by_user_id = $1", []any{userID}, filter) + return r.listRequests(ctx, sqlText, args) +} + +func (r *Repository) ListAdmin(ctx context.Context, filter ListFilter) ([]*Request, error) { + sqlText, args := buildRequestListSQL("true", nil, filter) + return r.listRequests(ctx, sqlText, args) +} + +func (r *Repository) listRequests(ctx context.Context, sqlText string, args []any) ([]*Request, error) { + rows, err := r.pool.Query(ctx, sqlText, args...) + if err != nil { + return nil, fmt.Errorf("list requests: %w", err) + } + defer rows.Close() + var out []*Request + for rows.Next() { + req, err := scanRequest(rows) + if err != nil { + return nil, err + } + out = append(out, req) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate requests: %w", err) + } + return out, nil +} + +func (r *Repository) SetStatus(ctx context.Context, id string, status Status, actor Viewer) (*Request, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin request status transaction: %w", err) + } + defer tx.Rollback(ctx) + + req, err := scanRequest(tx.QueryRow(ctx, ` + UPDATE media_requests + SET status = $2, + updated_at = now(), + approved_at = CASE WHEN $2 = 'approved' AND approved_at IS NULL THEN now() ELSE approved_at END, + completed_at = CASE WHEN $2 = 'completed' AND completed_at IS NULL THEN now() ELSE completed_at END + WHERE id = $1 + RETURNING `+requestColumns(), id, status)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("set request status: %w", err) + } + if err := r.recordEvent(ctx, tx, id, "status_"+string(status), actor, ""); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit request status transaction: %w", err) + } + return req, nil +} + +func (r *Repository) MarkQueued(ctx context.Context, id string, update QueueUpdate, actor Viewer) (*Request, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin request queue transaction: %w", err) + } + defer tx.Rollback(ctx) + + externalStatus := strings.TrimSpace(update.ExternalStatus) + if externalStatus == "" { + externalStatus = "queued" + } + req, err := scanRequest(tx.QueryRow(ctx, ` + UPDATE media_requests + SET status = 'queued', + outcome = 'active', + integration_kind = $2, + external_id = $3, + external_status = $4, + last_error = '', + updated_at = now(), + approved_at = CASE WHEN approved_at IS NULL THEN now() ELSE approved_at END + WHERE id = $1 + RETURNING `+requestColumns(), id, + strings.TrimSpace(update.IntegrationKind), + strings.TrimSpace(update.ExternalID), + externalStatus, + )) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("mark request queued: %w", err) + } + if err := r.recordEvent(ctx, tx, id, "status_queued", actor, externalStatus); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit request queue transaction: %w", err) + } + return req, nil +} + +func (r *Repository) SetOutcome(ctx context.Context, id string, outcome Outcome, actor Viewer, message string) (*Request, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin request outcome transaction: %w", err) + } + defer tx.Rollback(ctx) + + req, err := scanRequest(tx.QueryRow(ctx, ` + UPDATE media_requests + SET outcome = $2, + last_error = CASE + WHEN $2 = 'failed' THEN $3 + WHEN $2 = 'active' THEN '' + ELSE last_error + END, + updated_at = now() + WHERE id = $1 + RETURNING `+requestColumns(), id, outcome, strings.TrimSpace(message))) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("set request outcome: %w", err) + } + if err := r.recordEvent(ctx, tx, id, "outcome_"+string(outcome), actor, message); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit request outcome transaction: %w", err) + } + return req, nil +} + +func (r *Repository) ListIntegrations(ctx context.Context) ([]Integration, error) { + rows, err := r.pool.Query(ctx, ` + SELECT kind, enabled, base_url, api_key_ref, root_folder, quality_profile_id, + tags, options, last_check_at, last_check_status, last_check_error, updated_at + FROM request_integrations + ORDER BY kind + `) + if err != nil { + return nil, fmt.Errorf("list request integrations: %w", err) + } + defer rows.Close() + + var out []Integration + for rows.Next() { + integration, err := scanIntegration(rows) + if err != nil { + return nil, err + } + out = append(out, integration) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate request integrations: %w", err) + } + return out, nil +} + +func (r *Repository) UpsertIntegration(ctx context.Context, integration Integration) (*Integration, error) { + out, err := r.upsertIntegration(ctx, r.pool, integration) + if err != nil { + return nil, fmt.Errorf("upsert request integration: %w", err) + } + return out, nil +} + +func (r *Repository) UpsertIntegrations(ctx context.Context, integrations []Integration) ([]Integration, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin request integrations transaction: %w", err) + } + defer tx.Rollback(ctx) + + out := make([]Integration, 0, len(integrations)) + for _, integration := range integrations { + updated, err := r.upsertIntegration(ctx, tx, integration) + if err != nil { + return nil, err + } + out = append(out, *updated) + } + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit request integrations transaction: %w", err) + } + return out, nil +} + +func (r *Repository) upsertIntegration(ctx context.Context, exec requestExecutor, integration Integration) (*Integration, error) { + if integration.Options == nil { + integration.Options = map[string]any{} + } + options, err := json.Marshal(integration.Options) + if err != nil { + return nil, fmt.Errorf("marshal request integration options: %w", err) + } + tags := int32Slice(integration.Tags) + row := exec.QueryRow(ctx, ` + INSERT INTO request_integrations ( + kind, enabled, base_url, api_key_ref, root_folder, quality_profile_id, + tags, options, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now()) + ON CONFLICT (kind) DO UPDATE SET + enabled = EXCLUDED.enabled, + base_url = EXCLUDED.base_url, + api_key_ref = CASE + WHEN EXCLUDED.api_key_ref = '' THEN request_integrations.api_key_ref + ELSE EXCLUDED.api_key_ref + END, + root_folder = EXCLUDED.root_folder, + quality_profile_id = EXCLUDED.quality_profile_id, + tags = EXCLUDED.tags, + options = EXCLUDED.options, + updated_at = now() + RETURNING kind, enabled, base_url, api_key_ref, root_folder, quality_profile_id, + tags, options, last_check_at, last_check_status, last_check_error, updated_at + `, integration.Kind, integration.Enabled, strings.TrimSpace(integration.BaseURL), + strings.TrimSpace(integration.APIKeyRef), strings.TrimSpace(integration.RootFolder), + integration.QualityProfileID, tags, options) + out, err := scanIntegration(row) + if err != nil { + return nil, err + } + return &out, nil +} + +func (r *Repository) recordEvent(ctx context.Context, exec requestExecutor, requestID, eventType string, actor Viewer, message string) error { + var actorUserID any + if actor.UserID > 0 { + actorUserID = actor.UserID + } + _, err := exec.Exec(ctx, ` + INSERT INTO media_request_events ( + request_id, event_type, actor_user_id, actor_profile_id, message + ) + VALUES ($1, $2, $3, $4, $5) + `, requestID, eventType, actorUserID, actor.ProfileID, strings.TrimSpace(message)) + if err != nil { + return fmt.Errorf("record request event: %w", err) + } + return nil +} + +func buildRequestListSQL(baseCondition string, baseArgs []any, filter ListFilter) (string, []any) { + args := append([]any(nil), baseArgs...) + conditions := []string{baseCondition} + if filter.Status != "" { + args = append(args, filter.Status) + conditions = append(conditions, "status = $"+strconv.Itoa(len(args))) + } + if filter.Outcome != "" { + args = append(args, filter.Outcome) + conditions = append(conditions, "outcome = $"+strconv.Itoa(len(args))) + } + limit := filter.Limit + if limit <= 0 || limit > 100 { + limit = 50 + } + offset := filter.Offset + if offset < 0 { + offset = 0 + } + args = append(args, limit, offset) + return requestSelectSQL() + ` + WHERE ` + strings.Join(conditions, " AND ") + ` + ORDER BY created_at DESC + LIMIT $` + strconv.Itoa(len(args)-1) + ` OFFSET $` + strconv.Itoa(len(args)), args +} + +func requestSelectSQL() string { + return "SELECT " + requestColumns() + " FROM media_requests " +} + +func requestColumns() string { + return `id, provider, media_type, tmdb_id, tvdb_id, imdb_id, title, year, + overview, poster_path, backdrop_path, status, outcome, + requested_by_user_id, requested_by_profile_id, integration_kind, + external_id, external_status, last_error, created_at, updated_at, + approved_at, completed_at` +} + +type requestScanner interface { + Scan(dest ...any) error +} + +func scanRequest(row requestScanner) (*Request, error) { + var req Request + var tvdbID, year sql.NullInt64 + var approvedAt, completedAt sql.NullTime + if err := row.Scan( + &req.ID, + &req.Provider, + &req.MediaType, + &req.TMDBID, + &tvdbID, + &req.IMDbID, + &req.Title, + &year, + &req.Overview, + &req.PosterPath, + &req.BackdropPath, + &req.Status, + &req.Outcome, + &req.RequestedByUserID, + &req.RequestedByProfileID, + &req.IntegrationKind, + &req.ExternalID, + &req.ExternalStatus, + &req.LastError, + &req.CreatedAt, + &req.UpdatedAt, + &approvedAt, + &completedAt, + ); err != nil { + return nil, err + } + if tvdbID.Valid { + v := int(tvdbID.Int64) + req.TVDBID = &v + } + if year.Valid { + v := int(year.Int64) + req.Year = &v + } + if approvedAt.Valid { + req.ApprovedAt = &approvedAt.Time + } + if completedAt.Valid { + req.CompletedAt = &completedAt.Time + } + return &req, nil +} + +type integrationScanner interface { + Scan(dest ...any) error +} + +func scanIntegration(row integrationScanner) (Integration, error) { + var integration Integration + var quality sql.NullInt64 + var tags []int32 + var optionsRaw []byte + var lastCheckAt sql.NullTime + if err := row.Scan( + &integration.Kind, + &integration.Enabled, + &integration.BaseURL, + &integration.APIKeyRef, + &integration.RootFolder, + &quality, + &tags, + &optionsRaw, + &lastCheckAt, + &integration.LastCheckStatus, + &integration.LastCheckError, + &integration.UpdatedAt, + ); err != nil { + return Integration{}, err + } + if quality.Valid { + v := int(quality.Int64) + integration.QualityProfileID = &v + } + integration.Tags = intsFromInt32(tags) + if len(optionsRaw) > 0 { + if err := json.Unmarshal(optionsRaw, &integration.Options); err != nil { + return Integration{}, fmt.Errorf("unmarshal request integration options for %s: %w", integration.Kind, err) + } + } + if integration.Options == nil { + integration.Options = map[string]any{} + } + if lastCheckAt.Valid { + integration.LastCheckAt = &lastCheckAt.Time + } + return integration, nil +} + +func int32Slice(values []int) []int32 { + out := make([]int32, 0, len(values)) + for _, value := range values { + out = append(out, int32(value)) + } + return out +} + +func intsFromInt32(values []int32) []int { + out := make([]int, 0, len(values)) + for _, value := range values { + out = append(out, int(value)) + } + return out +} diff --git a/internal/requests/service.go b/internal/requests/service.go new file mode 100644 index 00000000..b6456210 --- /dev/null +++ b/internal/requests/service.go @@ -0,0 +1,1364 @@ +package requests + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/Silo-Server/silo-server/internal/idgen" + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" + "golang.org/x/sync/errgroup" +) + +type TMDBClient interface { + SearchMedia(ctx context.Context, mediaType, query string, page int) (*tmdb.MediaPage, error) + DiscoverSection(ctx context.Context, section string, page int) (*tmdb.MediaPage, error) + GetMediaDetail(ctx context.Context, mediaType string, id int) (*tmdb.MediaDetail, error) + DiscoverPage(ctx context.Context, mediaType string, params tmdb.DiscoverParams, page int) (*tmdb.MediaPage, error) +} + +type TMDBExternalIDClient interface { + GetExternalIDs(ctx context.Context, mediaType string, id int) (*tmdb.ExternalIDs, error) +} + +const externalIDHydrationConcurrency = 4 + +type SecretResolver interface { + Get(ctx context.Context, key string) (string, error) +} + +type MovieFulfillmentAdapter interface { + SubmitMovie(ctx context.Context, req Request, integration Integration) (FulfillmentResult, error) +} + +type SeriesFulfillmentAdapter interface { + SubmitSeries(ctx context.Context, req Request, integration Integration) (FulfillmentResult, error) +} + +type MovieStatusAdapter interface { + CheckMovieStatus(ctx context.Context, req Request, integration Integration) (FulfillmentStatus, error) +} + +type SeriesStatusAdapter interface { + CheckSeriesStatus(ctx context.Context, req Request, integration Integration) (FulfillmentStatus, error) +} + +type MovieIntegrationOptionsAdapter interface { + ListMovieIntegrationOptions(ctx context.Context, integration Integration) (*IntegrationOptions, error) +} + +type SeriesIntegrationOptionsAdapter interface { + ListSeriesIntegrationOptions(ctx context.Context, integration Integration) (*IntegrationOptions, error) +} + +type Service struct { + store Store + tmdb TMDBClient + presence PresenceResolver + secrets SecretResolver + movieAdapter MovieFulfillmentAdapter + seriesAdapter SeriesFulfillmentAdapter + Now func() time.Time +} + +type DiscoverySection struct { + Key string `json:"key"` + Title string `json:"title"` + Page int `json:"page"` + TotalPages int `json:"total_pages"` + TotalResults int `json:"total_results"` + Results []MediaResult `json:"results"` +} + +func NewService(store Store, tmdbClient TMDBClient, presence PresenceResolver) *Service { + return &Service{ + store: store, + tmdb: tmdbClient, + presence: presence, + Now: func() time.Time { return time.Now().UTC() }, + } +} + +func (s *Service) SetSecretResolver(resolver SecretResolver) { + s.secrets = resolver +} + +func (s *Service) SetFulfillmentAdapters(movie MovieFulfillmentAdapter, series SeriesFulfillmentAdapter) { + s.movieAdapter = movie + s.seriesAdapter = series +} + +func (s *Service) Search(ctx context.Context, viewer Viewer, query string, mediaType MediaType, page int) (*MediaPage, error) { + if s == nil || s.store == nil || s.tmdb == nil { + return nil, fmt.Errorf("request service is not configured") + } + mediaType, err := normalizeSearchMediaType(mediaType) + if err != nil { + return nil, err + } + query = strings.TrimSpace(query) + if query == "" { + return nil, fmt.Errorf("%w: query is required", ErrInvalidInput) + } + raw, err := s.tmdb.SearchMedia(ctx, string(mediaType), query, page) + if err != nil { + return nil, err + } + return s.enrichPage(ctx, viewer, raw) +} + +func (s *Service) Discover(ctx context.Context, viewer Viewer, section string, page int) (*DiscoverySection, error) { + if s == nil || s.store == nil || s.tmdb == nil { + return nil, fmt.Errorf("request service is not configured") + } + section = strings.TrimSpace(section) + if _, ok := discoverySectionTitles[section]; !ok { + return nil, fmt.Errorf("%w: invalid discovery section", ErrInvalidInput) + } + raw, err := s.tmdb.DiscoverSection(ctx, section, page) + if err != nil { + return nil, err + } + enriched, err := s.enrichPage(ctx, viewer, raw) + if err != nil { + return nil, err + } + return &DiscoverySection{ + Key: section, + Title: discoverySectionTitles[section], + Page: enriched.Page, + TotalPages: enriched.TotalPages, + TotalResults: enriched.TotalResults, + Results: enriched.Results, + }, nil +} + +func (s *Service) DiscoverAll(ctx context.Context, viewer Viewer) ([]DiscoverySection, error) { + sections := make([]DiscoverySection, len(discoverySectionOrder)) + group, gctx := errgroup.WithContext(ctx) + group.SetLimit(externalIDHydrationConcurrency) + for i, key := range discoverySectionOrder { + i, key := i, key + group.Go(func() error { + section, err := s.Discover(gctx, viewer, key, 1) + if err != nil { + return err + } + sections[i] = *section + return nil + }) + } + if err := group.Wait(); err != nil { + return nil, err + } + return sections, nil +} + +// GetDetail fetches a TMDB detail payload and overlays the same availability / +// request-state signals used by search and discovery. Recommendations carry +// their own per-item state so the detail page can render them as request cards. +func (s *Service) GetDetail(ctx context.Context, viewer Viewer, mediaType MediaType, tmdbID int) (*MediaDetail, error) { + if s == nil || s.store == nil || s.tmdb == nil { + return nil, fmt.Errorf("request service is not configured") + } + mediaType, err := normalizeMediaType(mediaType) + if err != nil { + return nil, err + } + if tmdbID <= 0 { + return nil, fmt.Errorf("%w: tmdb id is required", ErrInvalidInput) + } + + raw, err := s.tmdb.GetMediaDetail(ctx, string(mediaType), tmdbID) + if err != nil { + return nil, err + } + if raw == nil { + return nil, ErrNotFound + } + + policy, err := s.EffectivePolicy(ctx, viewer.UserID) + if err != nil { + return nil, err + } + + primaryAvailable, err := s.lookupAvailable(ctx, mediaType, []int{raw.ID}) + if err != nil { + return nil, err + } + primaryRequests, err := s.store.ListActiveByTMDB(ctx, mediaType, []int{raw.ID}) + if err != nil { + return nil, err + } + + detail := &MediaDetail{ + MediaType: mediaType, + TMDBID: raw.ID, + IMDbID: raw.IMDbID, + Title: raw.Title, + OriginalTitle: raw.OriginalTitle, + Tagline: raw.Tagline, + Overview: raw.Overview, + PosterPath: raw.PosterPath, + BackdropPath: raw.BackdropPath, + ReleaseDate: raw.ReleaseDate, + Year: raw.Year, + Runtime: raw.Runtime, + Genres: raw.Genres, + VoteAverage: raw.VoteAverage, + VoteCount: raw.VoteCount, + Status: raw.Status, + Homepage: raw.Homepage, + ContentRating: raw.ContentRating, + ProductionCompanies: raw.ProductionCompanies, + NumberOfSeasons: raw.NumberOfSeasons, + NumberOfEpisodes: raw.NumberOfEpisodes, + FirstAirDate: raw.FirstAirDate, + LastAirDate: raw.LastAirDate, + Networks: raw.Networks, + Director: raw.Director, + Creators: raw.Creators, + Availability: availabilityValue(primaryAvailable[raw.ID]), + Request: requestStateFor(viewer, policy, primaryAvailable[raw.ID], primaryRequests[raw.ID]), + } + if raw.TVDBID > 0 { + tvdb := raw.TVDBID + detail.TVDBID = &tvdb + } + if len(raw.Cast) > 0 { + detail.Cast = make([]MediaCastMember, 0, len(raw.Cast)) + for _, member := range raw.Cast { + detail.Cast = append(detail.Cast, MediaCastMember{ + Name: member.Name, + Character: member.Character, + ProfilePath: member.ProfilePath, + Order: member.Order, + }) + } + } + + if len(raw.Recommendations) > 0 { + recPage := &tmdb.MediaPage{Results: raw.Recommendations} + enriched, err := s.enrichPage(ctx, viewer, recPage) + if err != nil { + return nil, err + } + detail.Recommendations = enriched.Results + } + + return detail, nil +} + +func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input CreateRequestInput) (*Request, error) { + if err := validateViewer(viewer); err != nil { + return nil, err + } + normalized, err := normalizeCreateInput(input) + if err != nil { + return nil, err + } + s.enrichExternalIDs(ctx, &normalized) + + matches, err := s.lookupPresence(ctx, normalized.MediaType, []PresenceCandidate{createPresenceCandidate(normalized)}) + if err != nil { + return nil, err + } + if matches[normalized.TMDBID].Available { + return nil, ErrAlreadyAvailable + } + + active, err := s.store.ListActiveByTMDB(ctx, normalized.MediaType, []int{normalized.TMDBID}) + if err != nil { + return nil, err + } + if active[normalized.TMDBID] != nil { + return nil, ErrAlreadyRequested + } + + // Re-requesting media that previously failed (e.g., transient integration + // error) should not leave stale failed rows behind in user/admin lists. + if _, err := s.store.DeleteFailedByTMDB(ctx, normalized.MediaType, normalized.TMDBID); err != nil { + return nil, err + } + + policy, err := s.EffectivePolicy(ctx, viewer.UserID) + if err != nil { + return nil, err + } + if err := validateCreatePolicy(policy); err != nil { + return nil, err + } + + id, err := idgen.NextID() + if err != nil { + return nil, err + } + status := StatusPending + if policy.AutoApprove { + configured, err := s.integrationConfigured(ctx, normalized.MediaType) + if err == nil && configured { + status = StatusApproved + } + } + record := CreateRequestRecord{ + ID: id, + Input: normalized, + Status: status, + Outcome: OutcomeActive, + Requester: viewer, + Now: s.now(), + } + if !policy.Unlimited { + record.Quota = &QuotaCheck{ + UserID: viewer.UserID, + WindowStart: policy.WindowStart, + MaxRequests: policy.MaxRequests, + } + } + req, err := s.store.CreateRequest(ctx, record) + if err != nil { + if errors.Is(err, ErrAlreadyRequested) { + return nil, ErrAlreadyRequested + } + if errors.Is(err, ErrQuotaExceeded) { + return nil, QuotaError{ + Used: policy.MaxRequests, + Limit: policy.MaxRequests, + WindowDays: policy.WindowDays, + } + } + return nil, err + } + if req.Status == StatusApproved { + return s.submitApprovedRequest(ctx, *req, viewer) + } + return req, nil +} + +func (s *Service) ListMine(ctx context.Context, viewer Viewer, filter ListFilter) ([]*Request, error) { + if viewer.UserID == 0 { + return nil, ErrForbidden + } + return s.store.ListMine(ctx, viewer.UserID, normalizeListFilter(filter)) +} + +func (s *Service) ListAdmin(ctx context.Context, viewer Viewer, filter ListFilter) ([]*Request, error) { + if !viewer.IsAdmin { + return nil, ErrForbidden + } + return s.store.ListAdmin(ctx, normalizeListFilter(filter)) +} + +func (s *Service) GetRequest(ctx context.Context, viewer Viewer, id string) (*Request, error) { + req, err := s.store.GetRequest(ctx, strings.TrimSpace(id)) + if err != nil { + return nil, err + } + if !viewer.IsAdmin && req.RequestedByUserID != viewer.UserID { + return nil, ErrForbidden + } + return req, nil +} + +func (s *Service) Approve(ctx context.Context, viewer Viewer, id string) (*Request, error) { + if !viewer.IsAdmin { + return nil, ErrForbidden + } + req, err := s.store.GetRequest(ctx, strings.TrimSpace(id)) + if err != nil { + return nil, err + } + if req.Outcome != OutcomeActive || req.Status != StatusPending { + return nil, ErrInvalidState + } + approved, err := s.store.SetStatus(ctx, req.ID, StatusApproved, viewer) + if err != nil { + return nil, err + } + return s.submitApprovedRequest(ctx, *approved, viewer) +} + +func (s *Service) Decline(ctx context.Context, viewer Viewer, id, reason string) (*Request, error) { + if !viewer.IsAdmin { + return nil, ErrForbidden + } + req, err := s.store.GetRequest(ctx, strings.TrimSpace(id)) + if err != nil { + return nil, err + } + // Approved requests are pending submission by the reconciler; declining + // while submission may be in flight risks a divergent external state. + if req.Outcome != OutcomeActive || + req.Status == StatusApproved || + req.Status == StatusCompleted || + req.Status == StatusQueued || + req.Status == StatusDownloading || + strings.TrimSpace(req.ExternalID) != "" || + strings.TrimSpace(req.IntegrationKind) != "" { + return nil, ErrInvalidState + } + return s.store.SetOutcome(ctx, req.ID, OutcomeDeclined, viewer, reason) +} + +// Cancel withdraws a request that has not yet been submitted to a downstream +// integration. Owners can cancel their own pending requests; admins can cancel +// any active request that has not entered the fulfillment pipeline. Requests +// already approved, queued, downloading, or completed cannot be cancelled — +// callers should decline (admin) or wait for completion in those cases. +func (s *Service) Cancel(ctx context.Context, viewer Viewer, id, reason string) (*Request, error) { + if viewer.UserID == 0 { + return nil, ErrForbidden + } + req, err := s.store.GetRequest(ctx, strings.TrimSpace(id)) + if err != nil { + return nil, err + } + if !viewer.IsAdmin && req.RequestedByUserID != viewer.UserID { + return nil, ErrForbidden + } + if req.Outcome != OutcomeActive || + req.Status == StatusApproved || + req.Status == StatusCompleted || + req.Status == StatusQueued || + req.Status == StatusDownloading || + strings.TrimSpace(req.ExternalID) != "" || + strings.TrimSpace(req.IntegrationKind) != "" { + return nil, ErrInvalidState + } + return s.store.SetOutcome(ctx, req.ID, OutcomeCancelled, viewer, reason) +} + +func (s *Service) Retry(ctx context.Context, viewer Viewer, id string) (*Request, error) { + if !viewer.IsAdmin { + return nil, ErrForbidden + } + req, err := s.store.GetRequest(ctx, strings.TrimSpace(id)) + if err != nil { + return nil, err + } + if req.Outcome != OutcomeFailed { + return nil, ErrInvalidState + } + active, err := s.store.SetOutcome(ctx, req.ID, OutcomeActive, viewer, "retry requested") + if err != nil { + return nil, err + } + if active.Status == StatusApproved || active.Status == StatusQueued || active.Status == StatusDownloading { + retry := *active + retry.Status = StatusApproved + return s.submitApprovedRequest(ctx, retry, viewer) + } + return active, nil +} + +func (s *Service) ReconcileRequests(ctx context.Context, limit int) (ReconcileResult, error) { + if s == nil || s.store == nil { + return ReconcileResult{}, fmt.Errorf("request service is not configured") + } + if limit <= 0 || limit > 500 { + limit = 100 + } + candidates, err := s.store.ListReconciliationCandidates(ctx, limit) + if err != nil { + return ReconcileResult{}, err + } + result := ReconcileResult{Checked: len(candidates)} + for _, req := range candidates { + if err := ctx.Err(); err != nil { + return result, err + } + change, err := s.reconcileRequest(ctx, *req) + if err != nil { + slog.WarnContext(ctx, "request reconcile failed", + "request_id", req.ID, + "media_type", req.MediaType, + "tmdb_id", req.TMDBID, + "status", req.Status, + "integration_kind", req.IntegrationKind, + "err", err, + ) + result.Errors++ + continue + } + switch change { + case reconcileSubmitted: + result.Submitted++ + case reconcileDownloading: + result.Downloading++ + case reconcileCompleted: + result.Completed++ + case reconcileFailed: + result.Failed++ + case reconcileSkipped: + result.Skipped++ + } + } + return result, nil +} + +func (s *Service) GetSettings(ctx context.Context, viewer Viewer) (Settings, error) { + if !viewer.IsAdmin { + return Settings{}, ErrForbidden + } + return s.store.GetSettings(ctx) +} + +func (s *Service) UpdateSettings(ctx context.Context, viewer Viewer, settings Settings) (Settings, error) { + if !viewer.IsAdmin { + return Settings{}, ErrForbidden + } + if settings.GlobalMaxRequests < 0 || settings.GlobalWindowDays <= 0 { + return Settings{}, fmt.Errorf("%w: invalid request settings", ErrInvalidInput) + } + return s.store.UpdateSettings(ctx, settings) +} + +func (s *Service) GetUserLimit(ctx context.Context, viewer Viewer, userID int) (*UserLimit, error) { + if !viewer.IsAdmin { + return nil, ErrForbidden + } + if userID <= 0 { + return nil, fmt.Errorf("%w: invalid user id", ErrInvalidInput) + } + limit, err := s.store.GetUserLimit(ctx, userID) + if err != nil { + return nil, err + } + if limit != nil { + return limit, nil + } + return &UserLimit{ + UserID: userID, + LimitMode: LimitModeInherit, + ApprovalMode: ApprovalModeInherit, + }, nil +} + +func (s *Service) UpsertUserLimit(ctx context.Context, viewer Viewer, limit UserLimit) (*UserLimit, error) { + if !viewer.IsAdmin { + return nil, ErrForbidden + } + normalized, err := normalizeUserLimit(limit) + if err != nil { + return nil, err + } + return s.store.UpsertUserLimit(ctx, normalized) +} + +func (s *Service) ListIntegrations(ctx context.Context, viewer Viewer) ([]Integration, error) { + if !viewer.IsAdmin { + return nil, ErrForbidden + } + return s.store.ListIntegrations(ctx) +} + +func (s *Service) UpsertIntegration(ctx context.Context, viewer Viewer, integration Integration) (*Integration, error) { + if !viewer.IsAdmin { + return nil, ErrForbidden + } + normalized, err := normalizeIntegration(integration) + if err != nil { + return nil, err + } + return s.store.UpsertIntegration(ctx, normalized) +} + +func (s *Service) UpsertIntegrations(ctx context.Context, viewer Viewer, integrations []Integration) ([]Integration, error) { + if !viewer.IsAdmin { + return nil, ErrForbidden + } + normalized := make([]Integration, 0, len(integrations)) + for _, integration := range integrations { + item, err := normalizeIntegration(integration) + if err != nil { + return nil, err + } + normalized = append(normalized, item) + } + return s.store.UpsertIntegrations(ctx, normalized) +} + +func (s *Service) LoadIntegrationOptions(ctx context.Context, viewer Viewer, integration Integration) (*IntegrationOptions, error) { + if !viewer.IsAdmin { + return nil, ErrForbidden + } + normalized, err := normalizeIntegrationConnection(integration) + if err != nil { + return nil, err + } + if err := s.applyStoredIntegrationCredentials(ctx, &normalized); err != nil { + return nil, err + } + if strings.TrimSpace(normalized.BaseURL) == "" { + return nil, fmt.Errorf("%w: base_url is required", ErrInvalidInput) + } + if strings.TrimSpace(normalized.APIKeyRef) == "" { + return nil, fmt.Errorf("%w: api_key_ref is required", ErrInvalidInput) + } + resolved := normalized + apiKey, err := s.resolveAPIKey(ctx, resolved) + if err != nil { + return nil, err + } + resolved.APIKeyRef = apiKey + + switch resolved.Kind { + case "radarr": + adapter, ok := s.movieAdapter.(MovieIntegrationOptionsAdapter) + if !ok { + return nil, fmt.Errorf("request radarr integration options are not configured") + } + return adapter.ListMovieIntegrationOptions(ctx, resolved) + case "sonarr": + adapter, ok := s.seriesAdapter.(SeriesIntegrationOptionsAdapter) + if !ok { + return nil, fmt.Errorf("request sonarr integration options are not configured") + } + return adapter.ListSeriesIntegrationOptions(ctx, resolved) + default: + return nil, fmt.Errorf("%w: invalid integration kind", ErrInvalidInput) + } +} + +func (s *Service) EffectivePolicy(ctx context.Context, userID int) (EffectivePolicy, error) { + settings, err := s.store.GetSettings(ctx) + if err != nil { + return EffectivePolicy{}, err + } + limit, err := s.store.GetUserLimit(ctx, userID) + if err != nil { + return EffectivePolicy{}, err + } + + policy := EffectivePolicy{ + RequestsEnabled: settings.RequestsEnabled, + MaxRequests: settings.GlobalMaxRequests, + WindowDays: settings.GlobalWindowDays, + AutoApprove: settings.GlobalAutoApprovalEnabled, + } + if policy.WindowDays <= 0 { + policy.WindowDays = 7 + } + if limit != nil { + switch limit.LimitMode { + case LimitModeBlocked: + policy.Blocked = true + case LimitModeUnlimited: + policy.Unlimited = true + case LimitModeCustom: + if limit.MaxRequests != nil { + policy.MaxRequests = *limit.MaxRequests + } + if limit.WindowDays != nil && *limit.WindowDays > 0 { + policy.WindowDays = *limit.WindowDays + } + } + switch limit.ApprovalMode { + case ApprovalModeBlocked: + policy.Blocked = true + case ApprovalModeManual: + policy.AutoApprove = false + case ApprovalModeAuto: + policy.AutoApprove = true + } + } + + policy.WindowStart = s.now().AddDate(0, 0, -policy.WindowDays) + if !policy.Unlimited { + used, err := s.store.CountUserRequestsSince(ctx, userID, policy.WindowStart) + if err != nil { + return EffectivePolicy{}, err + } + policy.Used = used + policy.Remaining = policy.MaxRequests - used + if policy.Remaining < 0 { + policy.Remaining = 0 + } + } + return policy, nil +} + +func (s *Service) enrichPage(ctx context.Context, viewer Viewer, raw *tmdb.MediaPage) (*MediaPage, error) { + if raw == nil { + return &MediaPage{Results: []MediaResult{}}, nil + } + policy, err := s.EffectivePolicy(ctx, viewer.UserID) + if err != nil { + return nil, err + } + + idsByType := map[MediaType][]int{} + for _, item := range raw.Results { + mediaType, err := normalizeMediaType(MediaType(item.MediaType)) + if err != nil || item.ID <= 0 { + continue + } + idsByType[mediaType] = append(idsByType[mediaType], item.ID) + } + + available := map[MediaType]map[int]bool{} + active := map[MediaType]map[int]*Request{} + for mediaType, ids := range idsByType { + presence, err := s.lookupAvailable(ctx, mediaType, ids) + if err != nil { + return nil, err + } + available[mediaType] = presence + requests, err := s.store.ListActiveByTMDB(ctx, mediaType, ids) + if err != nil { + return nil, err + } + active[mediaType] = requests + } + + out := &MediaPage{ + Page: raw.Page, + TotalPages: raw.TotalPages, + TotalResults: raw.TotalResults, + Results: make([]MediaResult, 0, len(raw.Results)), + } + for _, item := range raw.Results { + mediaType, err := normalizeMediaType(MediaType(item.MediaType)) + if err != nil || item.ID <= 0 { + continue + } + isAvailable := available[mediaType][item.ID] + activeRequest := active[mediaType][item.ID] + out.Results = append(out.Results, MediaResult{ + MediaType: mediaType, + TMDBID: item.ID, + Title: item.Title, + Year: item.Year, + Overview: item.Overview, + PosterPath: item.PosterPath, + BackdropPath: item.BackdropPath, + ReleaseDate: item.ReleaseDate, + Popularity: item.Popularity, + VoteAverage: item.VoteAverage, + Availability: availabilityValue(isAvailable), + Request: requestStateFor(viewer, policy, isAvailable, activeRequest), + }) + } + return out, nil +} + +func (s *Service) lookupPresence(ctx context.Context, mediaType MediaType, candidates []PresenceCandidate) (map[int]PresenceMatch, error) { + if s.presence == nil { + return map[int]PresenceMatch{}, nil + } + return s.presence.Lookup(ctx, mediaType, candidates) +} + +func availabilityBoolMap(matches map[int]PresenceMatch) map[int]bool { + out := map[int]bool{} + for id, match := range matches { + out[id] = match.Available + } + return out +} + +func requestPresenceCandidate(req Request) PresenceCandidate { + candidate := PresenceCandidate{ + TMDBID: req.TMDBID, + IMDbID: strings.TrimSpace(req.IMDbID), + } + if req.TVDBID != nil && *req.TVDBID > 0 { + tvdbID := *req.TVDBID + candidate.TVDBID = &tvdbID + } + return candidate +} + +func createPresenceCandidate(input CreateRequestInput) PresenceCandidate { + candidate := PresenceCandidate{ + TMDBID: input.TMDBID, + IMDbID: strings.TrimSpace(input.IMDbID), + } + if input.TVDBID != nil && *input.TVDBID > 0 { + tvdbID := *input.TVDBID + candidate.TVDBID = &tvdbID + } + return candidate +} + +func (s *Service) hydratePresenceCandidate(ctx context.Context, mediaType MediaType, candidate PresenceCandidate) PresenceCandidate { + if candidate.TMDBID <= 0 { + return candidate + } + client, ok := s.tmdb.(TMDBExternalIDClient) + if !ok { + return candidate + } + externalIDs, err := client.GetExternalIDs(ctx, tmdbMediaType(mediaType), candidate.TMDBID) + if err != nil || externalIDs == nil { + return candidate + } + if candidate.IMDbID == "" { + candidate.IMDbID = strings.TrimSpace(externalIDs.IMDbID) + } + if candidate.TVDBID == nil && externalIDs.TVDBID > 0 { + tvdbID := externalIDs.TVDBID + candidate.TVDBID = &tvdbID + } + return candidate +} + +func (s *Service) hydratePresenceCandidates(ctx context.Context, mediaType MediaType, candidates []PresenceCandidate) []PresenceCandidate { + if len(candidates) == 0 { + return candidates + } + if _, ok := s.tmdb.(TMDBExternalIDClient); !ok { + return candidates + } + + hydrated := append([]PresenceCandidate(nil), candidates...) + if externalIDHydrationConcurrency <= 1 { + for i := range hydrated { + if ctx.Err() != nil { + return hydrated + } + hydrated[i] = s.hydratePresenceCandidate(ctx, mediaType, hydrated[i]) + } + return hydrated + } + + group, groupCtx := errgroup.WithContext(ctx) + group.SetLimit(externalIDHydrationConcurrency) + for i := range hydrated { + if groupCtx.Err() != nil { + break + } + i := i + group.Go(func() error { + if err := groupCtx.Err(); err != nil { + return err + } + hydrated[i] = s.hydratePresenceCandidate(groupCtx, mediaType, hydrated[i]) + return nil + }) + } + _ = group.Wait() + return hydrated +} + +func tmdbMediaType(mediaType MediaType) string { + if mediaType == MediaTypeSeries { + return "tv" + } + return "movie" +} + +func (s *Service) lookupAvailable(ctx context.Context, mediaType MediaType, ids []int) (map[int]bool, error) { + if s.presence == nil { + return map[int]bool{}, nil + } + candidates := make([]PresenceCandidate, 0, len(ids)) + for _, id := range ids { + if id > 0 { + candidates = append(candidates, PresenceCandidate{TMDBID: id}) + } + } + candidates = s.hydratePresenceCandidates(ctx, mediaType, candidates) + matches, err := s.lookupPresence(ctx, mediaType, candidates) + if err != nil { + return nil, err + } + return availabilityBoolMap(matches), nil +} + +func (s *Service) enrichExternalIDs(ctx context.Context, input *CreateRequestInput) { + if input == nil { + return + } + client, ok := s.tmdb.(TMDBExternalIDClient) + if !ok { + return + } + externalIDs, err := client.GetExternalIDs(ctx, tmdbMediaType(input.MediaType), input.TMDBID) + if err != nil || externalIDs == nil { + return + } + if input.IMDbID == "" { + input.IMDbID = strings.TrimSpace(externalIDs.IMDbID) + } + if input.TVDBID == nil && externalIDs.TVDBID > 0 { + tvdbID := externalIDs.TVDBID + input.TVDBID = &tvdbID + } +} + +func (s *Service) integrationConfigured(ctx context.Context, mediaType MediaType) (bool, error) { + integration, err := s.integrationForMediaType(ctx, mediaType) + if err != nil { + return false, err + } + if integration == nil || !integrationIsConfigured(*integration) { + return false, nil + } + apiKey, err := s.resolveAPIKey(ctx, *integration) + if err != nil { + return false, err + } + return apiKey != "", nil +} + +func (s *Service) integrationForMediaType(ctx context.Context, mediaType MediaType) (*Integration, error) { + want := integrationKindForMediaType(mediaType) + integrations, err := s.store.ListIntegrations(ctx) + if err != nil { + return nil, err + } + for _, integration := range integrations { + if integration.Kind == want { + integration := integration + return &integration, nil + } + } + return nil, nil +} + +func (s *Service) submitApprovedRequest(ctx context.Context, req Request, actor Viewer) (*Request, error) { + if req.Outcome != OutcomeActive || req.Status != StatusApproved { + return &req, nil + } + + integration, err := s.integrationForMediaType(ctx, req.MediaType) + if err != nil { + return nil, err + } + if integration == nil || !integrationIsConfigured(*integration) { + return &req, nil + } + + resolved := *integration + apiKey, err := s.resolveAPIKey(ctx, resolved) + if err != nil { + return s.markSubmissionFailed(ctx, req.ID, actor, err) + } + if apiKey == "" { + return &req, nil + } + resolved.APIKeyRef = apiKey + + var result FulfillmentResult + switch req.MediaType { + case MediaTypeMovie: + if s.movieAdapter == nil { + return &req, nil + } + result, err = s.movieAdapter.SubmitMovie(ctx, req, resolved) + case MediaTypeSeries: + if s.seriesAdapter == nil { + return &req, nil + } + result, err = s.seriesAdapter.SubmitSeries(ctx, req, resolved) + default: + return &req, nil + } + if err != nil { + return s.markSubmissionFailed(ctx, req.ID, actor, err) + } + if result.IntegrationKind == "" { + result.IntegrationKind = resolved.Kind + } + return s.store.MarkQueued(ctx, req.ID, QueueUpdate{ + IntegrationKind: result.IntegrationKind, + ExternalID: result.ExternalID, + ExternalStatus: result.ExternalStatus, + }, actor) +} + +func (s *Service) markSubmissionFailed(ctx context.Context, requestID string, actor Viewer, submitErr error) (*Request, error) { + failed, err := s.store.SetOutcome(ctx, requestID, OutcomeFailed, actor, submitErr.Error()) + if err != nil { + return nil, fmt.Errorf("submit request failed: %w; mark failed: %v", submitErr, err) + } + return failed, nil +} + +type reconcileChange string + +const ( + reconcileUnchanged reconcileChange = "unchanged" + reconcileSkipped reconcileChange = "skipped" + reconcileSubmitted reconcileChange = "submitted" + reconcileDownloading reconcileChange = "downloading" + reconcileCompleted reconcileChange = "completed" + reconcileFailed reconcileChange = "failed" +) + +func (s *Service) reconcileRequest(ctx context.Context, req Request) (reconcileChange, error) { + completed, err := s.requestAvailable(ctx, req) + if err != nil { + return reconcileUnchanged, err + } + if completed { + if req.Status == StatusCompleted { + return reconcileUnchanged, nil + } + if _, err := s.store.SetStatus(ctx, req.ID, StatusCompleted, Viewer{}); err != nil { + return reconcileUnchanged, err + } + return reconcileCompleted, nil + } + + if req.Status == StatusApproved { + updated, err := s.submitApprovedRequest(ctx, req, Viewer{}) + if err != nil { + return reconcileUnchanged, err + } + switch { + case updated.Outcome == OutcomeFailed: + return reconcileFailed, nil + case updated.Status == StatusQueued: + return reconcileSubmitted, nil + default: + return reconcileSkipped, nil + } + } + + status, err := s.checkFulfillmentStatus(ctx, req) + if err != nil { + return reconcileUnchanged, err + } + if status.Status == "" && status.Outcome == "" { + return reconcileSkipped, nil + } + if status.Outcome == OutcomeFailed { + message := strings.TrimSpace(status.Message) + if message == "" { + message = strings.TrimSpace(status.ExternalStatus) + } + if message == "" { + message = "external fulfillment failed" + } + if _, err := s.store.SetOutcome(ctx, req.ID, OutcomeFailed, Viewer{}, message); err != nil { + return reconcileUnchanged, err + } + return reconcileFailed, nil + } + if status.Status == StatusDownloading && req.Status != StatusDownloading { + if _, err := s.store.SetStatus(ctx, req.ID, StatusDownloading, Viewer{}); err != nil { + return reconcileUnchanged, err + } + return reconcileDownloading, nil + } + return reconcileUnchanged, nil +} + +func (s *Service) requestAvailable(ctx context.Context, req Request) (bool, error) { + matches, err := s.lookupPresence(ctx, req.MediaType, []PresenceCandidate{requestPresenceCandidate(req)}) + if err != nil { + return false, err + } + return matches[req.TMDBID].Available, nil +} + +func (s *Service) checkFulfillmentStatus(ctx context.Context, req Request) (FulfillmentStatus, error) { + integration, err := s.integrationForMediaType(ctx, req.MediaType) + if err != nil { + return FulfillmentStatus{}, err + } + if integration == nil || !integrationIsConfigured(*integration) { + return FulfillmentStatus{}, nil + } + resolved := *integration + apiKey, err := s.resolveAPIKey(ctx, resolved) + if err != nil { + return FulfillmentStatus{}, err + } + if apiKey == "" { + return FulfillmentStatus{}, nil + } + resolved.APIKeyRef = apiKey + + switch req.MediaType { + case MediaTypeMovie: + checker, ok := s.movieAdapter.(MovieStatusAdapter) + if !ok { + return FulfillmentStatus{}, nil + } + return checker.CheckMovieStatus(ctx, req, resolved) + case MediaTypeSeries: + checker, ok := s.seriesAdapter.(SeriesStatusAdapter) + if !ok { + return FulfillmentStatus{}, nil + } + return checker.CheckSeriesStatus(ctx, req, resolved) + default: + return FulfillmentStatus{}, nil + } +} + +func (s *Service) resolveAPIKey(ctx context.Context, integration Integration) (string, error) { + value := strings.TrimSpace(integration.APIKeyRef) + if value == "" || s.secrets == nil { + return value, nil + } + resolved, err := s.secrets.Get(ctx, value) + if err != nil { + return "", err + } + resolved = strings.TrimSpace(resolved) + if resolved == "" { + return value, nil + } + return resolved, nil +} + +func integrationKindForMediaType(mediaType MediaType) string { + if mediaType == MediaTypeSeries { + return "sonarr" + } + return "radarr" +} + +func integrationIsConfigured(integration Integration) bool { + return integration.Enabled && + strings.TrimSpace(integration.BaseURL) != "" && + strings.TrimSpace(integration.APIKeyRef) != "" && + strings.TrimSpace(integration.RootFolder) != "" && + integration.QualityProfileID != nil +} + +func (s *Service) now() time.Time { + if s.Now != nil { + return s.Now() + } + return time.Now().UTC() +} + +func requestStateFor(viewer Viewer, policy EffectivePolicy, available bool, req *Request) RequestState { + if req != nil { + state := RequestState{ + Status: req.Status, + Requestable: false, + Reason: "already_requested", + } + if viewer.IsAdmin || req.RequestedByUserID == viewer.UserID { + state.RequestID = req.ID + } + return state + } + switch { + case available: + return RequestState{Requestable: false, Reason: "already_available"} + case !policy.RequestsEnabled: + return RequestState{Requestable: false, Reason: "requests_disabled"} + case policy.Blocked: + return RequestState{Requestable: false, Reason: "blocked"} + case !policy.Unlimited && policy.Used >= policy.MaxRequests: + return RequestState{Requestable: false, Reason: "quota_exceeded"} + default: + return RequestState{Requestable: true} + } +} + +func validateCreatePolicy(policy EffectivePolicy) error { + switch { + case !policy.RequestsEnabled: + return ErrRequestsDisabled + case policy.Blocked: + return ErrUserBlocked + case !policy.Unlimited && policy.Used >= policy.MaxRequests: + return QuotaError{Used: policy.Used, Limit: policy.MaxRequests, WindowDays: policy.WindowDays} + default: + return nil + } +} + +func validateViewer(viewer Viewer) error { + if viewer.UserID == 0 { + return ErrForbidden + } + if strings.TrimSpace(viewer.ProfileID) == "" { + return fmt.Errorf("%w: profile is required", ErrInvalidInput) + } + return nil +} + +func normalizeCreateInput(input CreateRequestInput) (CreateRequestInput, error) { + mediaType, err := normalizeMediaType(input.MediaType) + if err != nil { + return CreateRequestInput{}, err + } + input.MediaType = mediaType + input.Title = strings.TrimSpace(input.Title) + input.IMDbID = strings.TrimSpace(input.IMDbID) + input.Overview = strings.TrimSpace(input.Overview) + input.PosterPath = strings.TrimSpace(input.PosterPath) + input.BackdropPath = strings.TrimSpace(input.BackdropPath) + if input.TMDBID <= 0 { + return CreateRequestInput{}, fmt.Errorf("%w: tmdb_id is required", ErrInvalidInput) + } + if input.Title == "" { + return CreateRequestInput{}, fmt.Errorf("%w: title is required", ErrInvalidInput) + } + return input, nil +} + +func normalizeUserLimit(limit UserLimit) (UserLimit, error) { + if limit.UserID <= 0 { + return UserLimit{}, fmt.Errorf("%w: invalid user id", ErrInvalidInput) + } + switch limit.LimitMode { + case "", LimitModeInherit: + limit.LimitMode = LimitModeInherit + limit.MaxRequests = nil + limit.WindowDays = nil + case LimitModeCustom: + if limit.MaxRequests == nil || limit.WindowDays == nil || *limit.MaxRequests < 0 || *limit.WindowDays <= 0 { + return UserLimit{}, fmt.Errorf("%w: custom limits require max_requests >= 0 and window_days > 0", ErrInvalidInput) + } + case LimitModeUnlimited: + limit.MaxRequests = nil + limit.WindowDays = nil + case LimitModeBlocked: + limit.MaxRequests = nil + limit.WindowDays = nil + default: + return UserLimit{}, fmt.Errorf("%w: invalid limit mode", ErrInvalidInput) + } + switch limit.ApprovalMode { + case "", ApprovalModeInherit: + limit.ApprovalMode = ApprovalModeInherit + case ApprovalModeManual, ApprovalModeAuto, ApprovalModeBlocked: + default: + return UserLimit{}, fmt.Errorf("%w: invalid approval mode", ErrInvalidInput) + } + return limit, nil +} + +func normalizeIntegration(integration Integration) (Integration, error) { + var err error + integration, err = normalizeIntegrationConnection(integration) + if err != nil { + return Integration{}, err + } + integration.RootFolder = strings.TrimSpace(integration.RootFolder) + if integration.QualityProfileID != nil && *integration.QualityProfileID <= 0 { + return Integration{}, fmt.Errorf("%w: quality_profile_id must be positive", ErrInvalidInput) + } + filteredTags := make([]int, 0, len(integration.Tags)) + for _, tag := range integration.Tags { + if tag > 0 { + filteredTags = append(filteredTags, tag) + } + } + integration.Tags = filteredTags + if integration.Options == nil { + integration.Options = map[string]any{} + } + return integration, nil +} + +func normalizeIntegrationConnection(integration Integration) (Integration, error) { + integration.Kind = strings.ToLower(strings.TrimSpace(integration.Kind)) + switch integration.Kind { + case "radarr", "sonarr": + default: + return Integration{}, fmt.Errorf("%w: invalid integration kind", ErrInvalidInput) + } + integration.BaseURL = strings.TrimRight(strings.TrimSpace(integration.BaseURL), "/") + integration.APIKeyRef = strings.TrimSpace(integration.APIKeyRef) + if integration.Options == nil { + integration.Options = map[string]any{} + } + return integration, nil +} + +func (s *Service) applyStoredIntegrationCredentials(ctx context.Context, integration *Integration) error { + if integration == nil || s.store == nil { + return nil + } + if strings.TrimSpace(integration.BaseURL) != "" && strings.TrimSpace(integration.APIKeyRef) != "" { + return nil + } + stored, err := s.store.ListIntegrations(ctx) + if err != nil { + return err + } + for _, candidate := range stored { + if candidate.Kind != integration.Kind { + continue + } + if strings.TrimSpace(integration.BaseURL) == "" { + integration.BaseURL = candidate.BaseURL + } + if strings.TrimSpace(integration.APIKeyRef) == "" { + integration.APIKeyRef = candidate.APIKeyRef + } + return nil + } + return nil +} + +func normalizeMediaType(mediaType MediaType) (MediaType, error) { + switch MediaType(strings.ToLower(strings.TrimSpace(string(mediaType)))) { + case MediaTypeMovie: + return MediaTypeMovie, nil + case MediaTypeSeries, "tv": + return MediaTypeSeries, nil + default: + return "", ErrInvalidMediaType + } +} + +func normalizeSearchMediaType(mediaType MediaType) (MediaType, error) { + switch MediaType(strings.ToLower(strings.TrimSpace(string(mediaType)))) { + case "", MediaTypeAll: + return MediaTypeAll, nil + case MediaTypeMovie: + return MediaTypeMovie, nil + case MediaTypeSeries, "tv": + return MediaTypeSeries, nil + default: + return "", ErrInvalidMediaType + } +} + +const ( + defaultRequestListLimit = 50 + maxRequestListLimit = 100 +) + +func normalizeListFilter(filter ListFilter) ListFilter { + if filter.Limit <= 0 { + filter.Limit = defaultRequestListLimit + } + if filter.Limit > maxRequestListLimit { + filter.Limit = maxRequestListLimit + } + if filter.Offset < 0 { + filter.Offset = 0 + } + return filter +} + +func availabilityValue(available bool) Availability { + if available { + return AvailabilityAvailable + } + return AvailabilityMissing +} + +var discoverySectionOrder = []string{ + "trending_movies", + "trending_series", + "popular_movies", + "popular_series", + "upcoming_movies", + "on_air_series", +} + +var discoverySectionTitles = map[string]string{ + "trending_movies": "Trending Movies", + "trending_series": "Trending Series", + "popular_movies": "Popular Movies", + "popular_series": "Popular Series", + "upcoming_movies": "Upcoming Movies", + "on_air_series": "On Air Series", +} diff --git a/internal/requests/service_test.go b/internal/requests/service_test.go new file mode 100644 index 00000000..4f0689de --- /dev/null +++ b/internal/requests/service_test.go @@ -0,0 +1,1382 @@ +package requests + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" +) + +func TestCreateRequestQuotaExceeded(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + store.settings.GlobalMaxRequests = 1 + store.count = 1 + service := newTestService(store) + + _, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ + MediaType: MediaTypeMovie, + TMDBID: 550, + Title: "Fight Club", + }) + if err == nil { + t.Fatal("expected quota error") + } + var quota QuotaError + if !errors.As(err, "a) { + t.Fatalf("error = %v, want QuotaError", err) + } + if len(store.created) != 0 { + t.Fatalf("created requests = %d, want 0", len(store.created)) + } +} + +func TestNormalizeListFilterCapsLimit(t *testing.T) { + cases := []struct { + name string + in ListFilter + wantLim int + wantOff int + }{ + {"zero defaults", ListFilter{}, defaultRequestListLimit, 0}, + {"negative defaults", ListFilter{Limit: -10, Offset: -5}, defaultRequestListLimit, 0}, + {"under cap preserved", ListFilter{Limit: 75, Offset: 10}, 75, 10}, + {"at cap preserved", ListFilter{Limit: maxRequestListLimit, Offset: 0}, maxRequestListLimit, 0}, + {"over cap clamped", ListFilter{Limit: 1_000_000}, maxRequestListLimit, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := normalizeListFilter(tc.in) + if got.Limit != tc.wantLim { + t.Errorf("limit = %d, want %d", got.Limit, tc.wantLim) + } + if got.Offset != tc.wantOff { + t.Errorf("offset = %d, want %d", got.Offset, tc.wantOff) + } + }) + } +} + +func TestCreateRequestConcurrentSubmissionsRespectQuota(t *testing.T) { + const ( + maxRequests = 5 + goroutines = 20 + ) + store := newFakeStore() + store.settings.RequestsEnabled = true + store.settings.GlobalMaxRequests = maxRequests + service := newTestService(store) + + var ( + wg sync.WaitGroup + successMu sync.Mutex + successes int + quotaFails int + ) + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(tmdbID int) { + defer wg.Done() + _, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ + MediaType: MediaTypeMovie, + TMDBID: tmdbID, + Title: "Title", + }) + successMu.Lock() + defer successMu.Unlock() + if err == nil { + successes++ + return + } + var quota QuotaError + if errors.As(err, "a) { + quotaFails++ + } + }(1000 + i) + } + wg.Wait() + + if successes != maxRequests { + t.Fatalf("successful creations = %d, want %d", successes, maxRequests) + } + if successes+quotaFails != goroutines { + t.Fatalf("non-quota errors: successes=%d quotaFails=%d total=%d", successes, quotaFails, goroutines) + } + if len(store.created) != maxRequests { + t.Fatalf("stored creations = %d, want %d", len(store.created), maxRequests) + } +} + +func TestCreateRequestActiveDuplicateBlocks(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + store.count = 100 + store.active[MediaTypeMovie][550] = &Request{ + ID: "req-existing", + MediaType: MediaTypeMovie, + TMDBID: 550, + Status: StatusQueued, + Outcome: OutcomeActive, + } + service := newTestService(store) + + _, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ + MediaType: MediaTypeMovie, + TMDBID: 550, + Title: "Fight Club", + }) + if !errors.Is(err, ErrAlreadyRequested) { + t.Fatalf("error = %v, want ErrAlreadyRequested", err) + } + if len(store.created) != 0 { + t.Fatalf("created requests = %d, want 0", len(store.created)) + } +} + +func TestCreateRequestAutoApprovalRequiresConfiguredIntegration(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + store.settings.GlobalAutoApprovalEnabled = true + service := newTestService(store) + + req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ + MediaType: MediaTypeMovie, + TMDBID: 550, + Title: "Fight Club", + }) + if err != nil { + t.Fatalf("CreateRequest returned error: %v", err) + } + if req.Status != StatusPending { + t.Fatalf("status = %q, want pending", req.Status) + } +} + +func TestCreateRequestAutoApprovesWithConfiguredIntegration(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + store.settings.GlobalAutoApprovalEnabled = true + qualityProfileID := 1 + store.integrations = []Integration{{ + Kind: "radarr", + Enabled: true, + BaseURL: "http://radarr.local", + APIKeyRef: "request.radarr.api_key", + RootFolder: "/movies", + QualityProfileID: &qualityProfileID, + }} + service := newTestService(store) + + req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ + MediaType: MediaTypeMovie, + TMDBID: 550, + Title: "Fight Club", + }) + if err != nil { + t.Fatalf("CreateRequest returned error: %v", err) + } + if req.Status != StatusApproved { + t.Fatalf("status = %q, want approved", req.Status) + } +} + +func TestCreateRequestAutoApprovalFallsBackToPendingOnIntegrationCheckError(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + store.settings.GlobalAutoApprovalEnabled = true + qualityProfileID := 1 + store.integrations = []Integration{{ + Kind: "radarr", + Enabled: true, + BaseURL: "http://radarr.local", + APIKeyRef: "requests.radarr.api_key", + RootFolder: "/movies", + QualityProfileID: &qualityProfileID, + }} + service := newTestService(store) + service.SetSecretResolver(fakeSecretError{err: errors.New("secret lookup unavailable")}) + + req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ + MediaType: MediaTypeMovie, + TMDBID: 550, + Title: "Fight Club", + }) + if err != nil { + t.Fatalf("CreateRequest returned error: %v", err) + } + if req.Status != StatusPending { + t.Fatalf("status = %q, want pending", req.Status) + } +} + +func TestCreateRequestAutoApprovalSubmitsMovie(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + store.settings.GlobalAutoApprovalEnabled = true + qualityProfileID := 1 + store.integrations = []Integration{{ + Kind: "radarr", + Enabled: true, + BaseURL: "http://radarr.local", + APIKeyRef: "requests.radarr.api_key", + RootFolder: "/movies", + QualityProfileID: &qualityProfileID, + }} + adapter := &fakeMovieAdapter{result: FulfillmentResult{ + IntegrationKind: "radarr", + ExternalID: "123", + ExternalStatus: "queued", + }} + service := newTestService(store) + service.SetSecretResolver(fakeSecrets{"requests.radarr.api_key": "radarr-key"}) + service.SetFulfillmentAdapters(adapter, nil) + + req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ + MediaType: MediaTypeMovie, + TMDBID: 550, + Title: "Fight Club", + }) + if err != nil { + t.Fatalf("CreateRequest returned error: %v", err) + } + if req.Status != StatusQueued || req.IntegrationKind != "radarr" || req.ExternalID != "123" { + t.Fatalf("request = %+v, want queued radarr external id", req) + } + if adapter.calls != 1 { + t.Fatalf("adapter calls = %d, want 1", adapter.calls) + } + if got := adapter.gotIntegration.APIKeyRef; got != "radarr-key" { + t.Fatalf("adapter api key = %q, want resolved key", got) + } +} + +func TestCreateRequestSubmissionFailureMarksFailed(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + store.settings.GlobalAutoApprovalEnabled = true + qualityProfileID := 1 + store.integrations = []Integration{{ + Kind: "radarr", + Enabled: true, + BaseURL: "http://radarr.local", + APIKeyRef: "radarr-key", + RootFolder: "/movies", + QualityProfileID: &qualityProfileID, + }} + adapter := &fakeMovieAdapter{err: errors.New("radarr unavailable")} + service := newTestService(store) + service.SetFulfillmentAdapters(adapter, nil) + + req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ + MediaType: MediaTypeMovie, + TMDBID: 550, + Title: "Fight Club", + }) + if err != nil { + t.Fatalf("CreateRequest returned error: %v", err) + } + if req.Outcome != OutcomeFailed || req.LastError != "radarr unavailable" { + t.Fatalf("request = %+v, want failed outcome with adapter error", req) + } +} + +func TestCreateRequestEnrichesSeriesTVDBID(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + tmdbClient := &fakeTMDBClient{externalIDs: &tmdb.ExternalIDs{TVDBID: 12345}} + service := newTestServiceWithTMDB(store, tmdbClient) + + _, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ + MediaType: MediaTypeSeries, + TMDBID: 1399, + Title: "Game of Thrones", + }) + if err != nil { + t.Fatalf("CreateRequest returned error: %v", err) + } + if len(store.created) != 1 { + t.Fatalf("created requests = %d, want 1", len(store.created)) + } + if store.created[0].Input.TVDBID == nil || *store.created[0].Input.TVDBID != 12345 { + t.Fatalf("tvdb_id = %v, want 12345", store.created[0].Input.TVDBID) + } +} + +func TestCreateRequestBlocksWhenHydratedTVDBIDIsAvailable(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + tmdbClient := &fakeTMDBClient{externalIDs: &tmdb.ExternalIDs{TVDBID: 420105, IMDbID: "tt18076310"}} + presence := &fakePresence{byTVDB: map[MediaType]map[int]int{ + MediaTypeSeries: {420105: 201992}, + }} + service := NewService(store, tmdbClient, presence) + + _, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ + MediaType: MediaTypeSeries, + TMDBID: 201992, + Title: "The Rookie: Feds", + }) + if !errors.Is(err, ErrAlreadyAvailable) { + t.Fatalf("err = %v, want ErrAlreadyAvailable", err) + } + if len(store.created) != 0 { + t.Fatalf("created requests = %d, want 0", len(store.created)) + } +} + +func TestCreateRequestNoActiveDuplicateCreatesRequest(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + service := newTestService(store) + + req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ + MediaType: MediaTypeMovie, + TMDBID: 550, + Title: "Fight Club", + }) + if err != nil { + t.Fatalf("CreateRequest returned error: %v", err) + } + if req.Status != StatusPending { + t.Fatalf("status = %q, want pending", req.Status) + } + if len(store.created) != 1 { + t.Fatalf("created requests = %d, want 1", len(store.created)) + } +} + +func TestCreateRequestClearsPriorFailedRequest(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + store.requests["req-prior-failed"] = &Request{ + ID: "req-prior-failed", + MediaType: MediaTypeMovie, + TMDBID: 550, + Outcome: OutcomeFailed, + Status: StatusApproved, + LastError: "arr: decode response: json: cannot unmarshal object into Go value of type []radarr.movieResource", + } + store.requests["req-other-media-failed"] = &Request{ + ID: "req-other-media-failed", + MediaType: MediaTypeMovie, + TMDBID: 999, + Outcome: OutcomeFailed, + } + service := newTestService(store) + + _, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ + MediaType: MediaTypeMovie, + TMDBID: 550, + Title: "Fight Club", + }) + if err != nil { + t.Fatalf("CreateRequest returned error: %v", err) + } + if _, ok := store.requests["req-prior-failed"]; ok { + t.Fatal("prior failed request was not cleared") + } + if _, ok := store.requests["req-other-media-failed"]; !ok { + t.Fatal("failed request for different media should not be cleared") + } +} + +func TestSearchMarksSeriesAvailableByHydratedTVDBID(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + tmdbClient := &fakeTMDBClient{ + page: &tmdb.MediaPage{ + Page: 1, + Results: []tmdb.MediaResult{{ + ID: 201992, + MediaType: "series", + Title: "The Rookie: Feds", + Year: 2022, + }}, + }, + externalIDsByID: map[int]*tmdb.ExternalIDs{ + 201992: {TVDBID: 420105, IMDbID: "tt18076310"}, + }, + } + presence := &fakePresence{byTVDB: map[MediaType]map[int]int{ + MediaTypeSeries: {420105: 201992}, + }} + service := NewService(store, tmdbClient, presence) + + page, err := service.Search(context.Background(), testViewer(1), "rookie feds", MediaTypeSeries, 1) + if err != nil { + t.Fatalf("Search returned error: %v", err) + } + if got := page.Results[0].Availability; got != AvailabilityAvailable { + t.Fatalf("availability = %q, want available", got) + } + if page.Results[0].Request.Reason != "already_available" { + t.Fatalf("request reason = %q, want already_available", page.Results[0].Request.Reason) + } + if len(presence.got) != 1 || presence.got[0].TVDBID == nil || *presence.got[0].TVDBID != 420105 { + t.Fatalf("presence candidates = %+v, want hydrated tvdb id", presence.got) + } +} + +func TestSearchWithNilPresenceDoesNotHydrateExternalIDs(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + tmdbClient := &fakeTMDBClient{page: &tmdb.MediaPage{Results: []tmdb.MediaResult{{ + ID: 201992, + MediaType: "series", + Title: "The Rookie: Feds", + }}}} + service := NewService(store, tmdbClient, nil) + + _, err := service.Search(context.Background(), testViewer(1), "rookie feds", MediaTypeSeries, 1) + if err != nil { + t.Fatalf("Search returned error: %v", err) + } + if len(tmdbClient.externalIDCalls) != 0 { + t.Fatalf("external ID calls = %v, want none", tmdbClient.externalIDCalls) + } +} + +func TestSearchHydratesMultipleResultsBeforePresenceLookup(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + tmdbClient := &fakeTMDBClient{ + page: &tmdb.MediaPage{Results: []tmdb.MediaResult{ + {ID: 201992, MediaType: "series", Title: "The Rookie: Feds"}, + {ID: 1399, MediaType: "series", Title: "Game of Thrones"}, + }}, + externalIDsByID: map[int]*tmdb.ExternalIDs{ + 201992: {TVDBID: 420105, IMDbID: "tt18076310"}, + 1399: {TVDBID: 121361, IMDbID: "tt0944947"}, + }, + } + presence := &fakePresence{} + service := NewService(store, tmdbClient, presence) + + _, err := service.Search(context.Background(), testViewer(1), "series", MediaTypeSeries, 1) + if err != nil { + t.Fatalf("Search returned error: %v", err) + } + if len(presence.got) != 2 { + t.Fatalf("presence candidates = %d, want 2", len(presence.got)) + } + got := map[int]int{} + for _, candidate := range presence.got { + if candidate.TVDBID != nil { + got[candidate.TMDBID] = *candidate.TVDBID + } + } + if got[201992] != 420105 || got[1399] != 121361 { + t.Fatalf("hydrated tvdb ids = %+v", got) + } +} + +func TestSearchEnrichmentHidesOtherRequesterID(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + store.active[MediaTypeMovie][550] = &Request{ + ID: "req-existing", + MediaType: MediaTypeMovie, + TMDBID: 550, + Status: StatusQueued, + Outcome: OutcomeActive, + RequestedByUserID: 2, + } + tmdbClient := &fakeTMDBClient{page: &tmdb.MediaPage{ + Page: 1, + TotalPages: 1, + TotalResults: 1, + Results: []tmdb.MediaResult{{ + ID: 550, + MediaType: "movie", + Title: "Fight Club", + Year: 1999, + }}, + }} + service := newTestServiceWithTMDB(store, tmdbClient) + + result, err := service.Search(context.Background(), testViewer(1), "fight", MediaTypeMovie, 1) + if err != nil { + t.Fatalf("Search returned error: %v", err) + } + if len(result.Results) != 1 { + t.Fatalf("results = %d, want 1", len(result.Results)) + } + state := result.Results[0].Request + if state.Status != StatusQueued || state.Requestable { + t.Fatalf("state = %+v, want queued non-requestable", state) + } + if state.RequestID != "" { + t.Fatalf("request id leaked as %q", state.RequestID) + } +} + +func TestSearchEnrichmentShowsOwnRequestID(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + store.active[MediaTypeMovie][550] = &Request{ + ID: "req-existing", + MediaType: MediaTypeMovie, + TMDBID: 550, + Status: StatusQueued, + Outcome: OutcomeActive, + RequestedByUserID: 2, + } + tmdbClient := &fakeTMDBClient{page: &tmdb.MediaPage{ + Page: 1, + TotalPages: 1, + TotalResults: 1, + Results: []tmdb.MediaResult{{ + ID: 550, + MediaType: "movie", + Title: "Fight Club", + }}, + }} + service := newTestServiceWithTMDB(store, tmdbClient) + + result, err := service.Search(context.Background(), testViewer(2), "fight", MediaTypeMovie, 1) + if err != nil { + t.Fatalf("Search returned error: %v", err) + } + if got := result.Results[0].Request.RequestID; got != "req-existing" { + t.Fatalf("request id = %q, want req-existing", got) + } +} + +func TestSearchWithoutMediaTypeSearchesMoviesAndSeries(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + store.active[MediaTypeSeries][1399] = &Request{ + ID: "req-series", + MediaType: MediaTypeSeries, + TMDBID: 1399, + Status: StatusQueued, + Outcome: OutcomeActive, + RequestedByUserID: 1, + } + tmdbClient := &fakeTMDBClient{page: &tmdb.MediaPage{ + Page: 1, + TotalPages: 1, + TotalResults: 2, + Results: []tmdb.MediaResult{ + { + ID: 550, + MediaType: "movie", + Title: "Fight Club", + }, + { + ID: 1399, + MediaType: "series", + Title: "Fight Club: The Series", + }, + }, + }} + service := newTestServiceWithTMDB(store, tmdbClient) + + result, err := service.Search(context.Background(), testViewer(1), "fight", "", 1) + if err != nil { + t.Fatalf("Search returned error: %v", err) + } + if tmdbClient.searchMediaType != "all" { + t.Fatalf("search media type = %q, want all", tmdbClient.searchMediaType) + } + if len(result.Results) != 2 { + t.Fatalf("results = %d, want 2", len(result.Results)) + } + if result.Results[0].MediaType != MediaTypeMovie { + t.Fatalf("results[0].MediaType = %q, want movie", result.Results[0].MediaType) + } + if result.Results[1].MediaType != MediaTypeSeries { + t.Fatalf("results[1].MediaType = %q, want series", result.Results[1].MediaType) + } + if result.Results[1].Request.RequestID != "req-series" { + t.Fatalf("series request id = %q, want req-series", result.Results[1].Request.RequestID) + } +} + +func TestReconcileRequestsCompletesFromCatalogPresence(t *testing.T) { + store := newFakeStore() + store.candidates = []*Request{{ + ID: "req-1", + MediaType: MediaTypeMovie, + TMDBID: 550, + Status: StatusQueued, + Outcome: OutcomeActive, + }} + service := NewService(store, &fakeTMDBClient{}, &fakePresence{available: map[MediaType]map[int]bool{ + MediaTypeMovie: {550: true}, + }}) + + result, err := service.ReconcileRequests(context.Background(), 100) + if err != nil { + t.Fatalf("ReconcileRequests returned error: %v", err) + } + if result.Completed != 1 || len(store.statusUpdates) != 1 || store.statusUpdates[0] != StatusCompleted { + t.Fatalf("result = %+v statusUpdates = %+v, want one completed update", result, store.statusUpdates) + } +} + +func TestReconcileRequestsCompletesByStoredTVDBID(t *testing.T) { + store := newFakeStore() + tvdbID := 420105 + store.candidates = []*Request{{ + ID: "req-1", + MediaType: MediaTypeSeries, + TMDBID: 201992, + TVDBID: &tvdbID, + Status: StatusQueued, + Outcome: OutcomeActive, + }} + presence := &fakePresence{byTVDB: map[MediaType]map[int]int{ + MediaTypeSeries: {420105: 201992}, + }} + service := NewService(store, &fakeTMDBClient{}, presence) + + result, err := service.ReconcileRequests(context.Background(), 100) + if err != nil { + t.Fatalf("ReconcileRequests returned error: %v", err) + } + if result.Completed != 1 { + t.Fatalf("completed = %d, want 1", result.Completed) + } +} + +func TestReconcileRequestsMarksDownloadingFromAdapter(t *testing.T) { + store := newFakeStore() + qualityProfileID := 1 + store.integrations = []Integration{{ + Kind: "radarr", + Enabled: true, + BaseURL: "http://radarr.local", + APIKeyRef: "radarr-key", + RootFolder: "/movies", + QualityProfileID: &qualityProfileID, + }} + store.candidates = []*Request{{ + ID: "req-1", + MediaType: MediaTypeMovie, + TMDBID: 550, + Status: StatusQueued, + Outcome: OutcomeActive, + ExternalID: "123", + }} + adapter := &fakeMovieAdapter{status: FulfillmentStatus{ + Status: StatusDownloading, + IntegrationKind: "radarr", + ExternalID: "123", + ExternalStatus: "downloading", + }} + service := newTestService(store) + service.SetFulfillmentAdapters(adapter, nil) + + result, err := service.ReconcileRequests(context.Background(), 100) + if err != nil { + t.Fatalf("ReconcileRequests returned error: %v", err) + } + if result.Downloading != 1 || len(store.statusUpdates) != 1 || store.statusUpdates[0] != StatusDownloading { + t.Fatalf("result = %+v statusUpdates = %+v, want one downloading update", result, store.statusUpdates) + } + if adapter.statusCalls != 1 { + t.Fatalf("status adapter calls = %d, want 1", adapter.statusCalls) + } +} + +func TestCancelOwnerCanWithdrawPendingRequest(t *testing.T) { + store := newFakeStore() + store.requests["req-mine"] = &Request{ + ID: "req-mine", + MediaType: MediaTypeMovie, + TMDBID: 550, + Status: StatusPending, + Outcome: OutcomeActive, + RequestedByUserID: 7, + } + service := newTestService(store) + + req, err := service.Cancel(context.Background(), Viewer{UserID: 7, ProfileID: "profile-1"}, "req-mine", "no longer want") + if err != nil { + t.Fatalf("Cancel returned error: %v", err) + } + if req.Outcome != OutcomeCancelled { + t.Fatalf("Outcome = %q, want cancelled", req.Outcome) + } +} + +func TestCancelNonOwnerForbidden(t *testing.T) { + store := newFakeStore() + store.requests["req-someone-else"] = &Request{ + ID: "req-someone-else", + MediaType: MediaTypeMovie, + TMDBID: 550, + Status: StatusPending, + Outcome: OutcomeActive, + RequestedByUserID: 7, + } + service := newTestService(store) + + _, err := service.Cancel(context.Background(), Viewer{UserID: 8, ProfileID: "profile-2"}, "req-someone-else", "") + if !errors.Is(err, ErrForbidden) { + t.Fatalf("err = %v, want ErrForbidden", err) + } +} + +func TestCancelAdminCanCancelAnyPending(t *testing.T) { + store := newFakeStore() + store.requests["req-other"] = &Request{ + ID: "req-other", + MediaType: MediaTypeMovie, + TMDBID: 550, + Status: StatusPending, + Outcome: OutcomeActive, + RequestedByUserID: 7, + } + service := newTestService(store) + + _, err := service.Cancel(context.Background(), Viewer{UserID: 99, IsAdmin: true}, "req-other", "house cleaning") + if err != nil { + t.Fatalf("admin Cancel returned error: %v", err) + } +} + +func TestCancelRejectsRequestsAlreadyInFulfillment(t *testing.T) { + cases := []struct { + name string + req Request + }{ + {"approved", Request{Status: StatusApproved, Outcome: OutcomeActive}}, + {"queued", Request{Status: StatusQueued, Outcome: OutcomeActive, IntegrationKind: "radarr", ExternalID: "42"}}, + {"downloading", Request{Status: StatusDownloading, Outcome: OutcomeActive, IntegrationKind: "radarr", ExternalID: "42"}}, + {"completed", Request{Status: StatusCompleted, Outcome: OutcomeActive}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + store := newFakeStore() + tc.req.ID = "req-x" + tc.req.RequestedByUserID = 7 + store.requests["req-x"] = &tc.req + service := newTestService(store) + + _, err := service.Cancel(context.Background(), Viewer{UserID: 7, ProfileID: "profile-1"}, "req-x", "") + if !errors.Is(err, ErrInvalidState) { + t.Fatalf("err = %v, want ErrInvalidState for %s", err, tc.name) + } + }) + } +} + +func TestDeclineRejectsApprovedRequests(t *testing.T) { + store := newFakeStore() + store.requests["req-approved"] = &Request{ + ID: "req-approved", + MediaType: MediaTypeMovie, + TMDBID: 550, + Status: StatusApproved, + Outcome: OutcomeActive, + } + service := newTestService(store) + + _, err := service.Decline(context.Background(), Viewer{UserID: 1, IsAdmin: true}, "req-approved", "changed mind") + if !errors.Is(err, ErrInvalidState) { + t.Fatalf("err = %v, want ErrInvalidState (approved is owned by the reconciler)", err) + } +} + +func TestDeclineRejectsQueuedRequests(t *testing.T) { + store := newFakeStore() + store.requests["req-1"] = &Request{ + ID: "req-1", + MediaType: MediaTypeMovie, + TMDBID: 550, + Status: StatusQueued, + Outcome: OutcomeActive, + IntegrationKind: "radarr", + ExternalID: "42", + } + service := newTestService(store) + + _, err := service.Decline(context.Background(), Viewer{UserID: 1, IsAdmin: true}, "req-1", "not needed") + if !errors.Is(err, ErrInvalidState) { + t.Fatalf("err = %v, want ErrInvalidState", err) + } +} + +func TestRetryResubmitsFailedQueuedRequest(t *testing.T) { + store := newFakeStore() + qualityProfileID := 1 + store.integrations = []Integration{{ + Kind: "radarr", + Enabled: true, + BaseURL: "http://radarr.local", + APIKeyRef: "radarr-key", + RootFolder: "/movies", + QualityProfileID: &qualityProfileID, + }} + store.requests["req-1"] = &Request{ + ID: "req-1", + MediaType: MediaTypeMovie, + TMDBID: 550, + Status: StatusQueued, + Outcome: OutcomeFailed, + } + adapter := &fakeMovieAdapter{result: FulfillmentResult{ + IntegrationKind: "radarr", + ExternalID: "99", + ExternalStatus: "queued", + }} + service := newTestService(store) + service.SetFulfillmentAdapters(adapter, nil) + + req, err := service.Retry(context.Background(), Viewer{UserID: 1, IsAdmin: true}, "req-1") + if err != nil { + t.Fatalf("Retry returned error: %v", err) + } + if adapter.calls != 1 { + t.Fatalf("adapter calls = %d, want 1", adapter.calls) + } + if adapter.gotReq.Status != StatusApproved { + t.Fatalf("submitted status = %q, want approved", adapter.gotReq.Status) + } + if req.Status != StatusQueued || req.ExternalID != "99" { + t.Fatalf("request = %+v, want re-queued with external id 99", req) + } +} + +func newTestService(store *fakeStore) *Service { + return newTestServiceWithTMDB(store, &fakeTMDBClient{}) +} + +func newTestServiceWithTMDB(store *fakeStore, tmdbClient *fakeTMDBClient) *Service { + service := NewService(store, tmdbClient, &fakePresence{}) + service.Now = func() time.Time { return time.Date(2026, 5, 24, 12, 0, 0, 0, time.UTC) } + return service +} + +func testViewer(userID int) Viewer { + return Viewer{UserID: userID, ProfileID: "profile-1"} +} + +type fakeStore struct { + mu sync.Mutex + settings Settings + limit *UserLimit + count int + active map[MediaType]map[int]*Request + created []CreateRequestRecord + integrations []Integration + queued []QueueUpdate + candidates []*Request + statusUpdates []Status + requests map[string]*Request +} + +func newFakeStore() *fakeStore { + return &fakeStore{ + settings: Settings{ + GlobalMaxRequests: 5, + GlobalWindowDays: 7, + }, + active: map[MediaType]map[int]*Request{ + MediaTypeMovie: {}, + MediaTypeSeries: {}, + }, + requests: map[string]*Request{}, + } +} + +func (f *fakeStore) GetSettings(context.Context) (Settings, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.settings, nil +} + +func (f *fakeStore) UpdateSettings(_ context.Context, settings Settings) (Settings, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.settings = settings + return settings, nil +} + +func (f *fakeStore) GetUserLimit(context.Context, int) (*UserLimit, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.limit, nil +} + +func (f *fakeStore) UpsertUserLimit(_ context.Context, limit UserLimit) (*UserLimit, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.limit = &limit + return &limit, nil +} + +func (f *fakeStore) CountUserRequestsSince(_ context.Context, userID int, since time.Time) (int, error) { + f.mu.Lock() + defer f.mu.Unlock() + used := f.count + for _, prior := range f.created { + if prior.Requester.UserID != userID { + continue + } + if prior.Now.Before(since) { + continue + } + used++ + } + return used, nil +} + +func (f *fakeStore) ListActiveByTMDB(_ context.Context, mediaType MediaType, ids []int) (map[int]*Request, error) { + f.mu.Lock() + defer f.mu.Unlock() + out := map[int]*Request{} + for _, id := range ids { + if req := f.active[mediaType][id]; req != nil { + out[id] = req + } + } + return out, nil +} + +func (f *fakeStore) DeleteFailedByTMDB(_ context.Context, mediaType MediaType, tmdbID int) (int, error) { + f.mu.Lock() + defer f.mu.Unlock() + deleted := 0 + for id, req := range f.requests { + if req.MediaType == mediaType && req.TMDBID == tmdbID && req.Outcome == OutcomeFailed { + delete(f.requests, id) + deleted++ + } + } + return deleted, nil +} + +func (f *fakeStore) CreateRequest(_ context.Context, input CreateRequestRecord) (*Request, error) { + f.mu.Lock() + defer f.mu.Unlock() + if input.Quota != nil { + used := f.count + for _, prior := range f.created { + if prior.Requester.UserID != input.Quota.UserID { + continue + } + if prior.Now.Before(input.Quota.WindowStart) { + continue + } + used++ + } + if used >= input.Quota.MaxRequests { + return nil, ErrQuotaExceeded + } + } + f.created = append(f.created, input) + return &Request{ + ID: input.ID, + Provider: "tmdb", + MediaType: input.Input.MediaType, + TMDBID: input.Input.TMDBID, + TVDBID: input.Input.TVDBID, + IMDbID: input.Input.IMDbID, + Title: input.Input.Title, + Status: input.Status, + Outcome: input.Outcome, + RequestedByUserID: input.Requester.UserID, + RequestedByProfileID: input.Requester.ProfileID, + CreatedAt: input.Now, + UpdatedAt: input.Now, + }, nil +} + +func (f *fakeStore) GetRequest(_ context.Context, id string) (*Request, error) { + f.mu.Lock() + defer f.mu.Unlock() + req := f.requests[strings.TrimSpace(id)] + if req == nil { + return nil, ErrNotFound + } + copy := *req + return ©, nil +} + +func (f *fakeStore) ListReconciliationCandidates(context.Context, int) ([]*Request, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.candidates, nil +} + +func (f *fakeStore) ListMine(context.Context, int, ListFilter) ([]*Request, error) { + return nil, nil +} + +func (f *fakeStore) ListAdmin(context.Context, ListFilter) ([]*Request, error) { + return nil, nil +} + +func (f *fakeStore) SetStatus(_ context.Context, id string, status Status, _ Viewer) (*Request, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.statusUpdates = append(f.statusUpdates, status) + req := f.requests[id] + if req == nil { + req = &Request{ID: id, Outcome: OutcomeActive} + f.requests[id] = req + } + req.Status = status + copy := *req + return ©, nil +} + +func (f *fakeStore) MarkQueued(_ context.Context, id string, update QueueUpdate, _ Viewer) (*Request, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.queued = append(f.queued, update) + req := f.requests[id] + if req == nil { + req = &Request{ID: id} + f.requests[id] = req + } + req.Status = StatusQueued + req.Outcome = OutcomeActive + req.IntegrationKind = update.IntegrationKind + req.ExternalID = update.ExternalID + req.ExternalStatus = update.ExternalStatus + copy := *req + return ©, nil +} + +func (f *fakeStore) SetOutcome(_ context.Context, id string, outcome Outcome, _ Viewer, message string) (*Request, error) { + f.mu.Lock() + defer f.mu.Unlock() + req := f.requests[id] + if req == nil { + req = &Request{ID: id} + f.requests[id] = req + } + req.Outcome = outcome + req.LastError = message + copy := *req + return ©, nil +} + +func (f *fakeStore) ListIntegrations(context.Context) ([]Integration, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.integrations, nil +} + +func (f *fakeStore) UpsertIntegration(context.Context, Integration) (*Integration, error) { + return nil, nil +} + +func (f *fakeStore) UpsertIntegrations(_ context.Context, integrations []Integration) ([]Integration, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.integrations = append([]Integration(nil), integrations...) + return append([]Integration(nil), integrations...), nil +} + +func TestListStudiosReturnsBundleWithDuotoneLogos(t *testing.T) { + service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{}) + + studios, err := service.ListStudios(context.Background(), testViewer(1)) + if err != nil { + t.Fatalf("ListStudios: %v", err) + } + if len(studios) != len(BundledStudios) { + t.Fatalf("len = %d, want %d", len(studios), len(BundledStudios)) + } + + for _, s := range studios { + if s.LogoURL == nil || *s.LogoURL == "" { + t.Errorf("studio %q missing logo URL", s.Slug) + continue + } + if !strings.Contains(*s.LogoURL, "filter(duotone,ffffff,bababa)") { + t.Errorf("studio %q logo URL missing duotone filter: %s", s.Slug, *s.LogoURL) + } + } +} + +func TestListNetworksReturnsBundleWithDuotoneLogos(t *testing.T) { + service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{}) + + networks, err := service.ListNetworks(context.Background(), testViewer(1)) + if err != nil { + t.Fatalf("ListNetworks: %v", err) + } + if len(networks) != len(BundledNetworks) { + t.Fatalf("len = %d, want %d", len(networks), len(BundledNetworks)) + } + for _, n := range networks { + if n.LogoURL == nil || *n.LogoURL == "" { + t.Errorf("network %q missing logo URL", n.Slug) + continue + } + if !strings.Contains(*n.LogoURL, "filter(duotone,ffffff,bababa)") { + t.Errorf("network %q logo URL missing duotone filter: %s", n.Slug, *n.LogoURL) + } + } +} + +func TestListGenresReturnsBundleWithSeriesSupportFlag(t *testing.T) { + service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{}) + + genres, err := service.ListGenres(context.Background(), testViewer(1)) + if err != nil { + t.Fatalf("ListGenres: %v", err) + } + if len(genres) != len(BundledGenres) { + t.Fatalf("len = %d, want %d", len(genres), len(BundledGenres)) + } + for _, g := range genres { + switch g.Slug { + case "action", "comedy", "drama", "sci-fi", "animation", "documentary": + if !g.SeriesSupported { + t.Errorf("%s should support series", g.Slug) + } + case "horror", "romance": + if g.SeriesSupported { + t.Errorf("%s should not support series", g.Slug) + } + } + if g.GradientFrom == "" || g.GradientTo == "" { + t.Errorf("%s missing gradient", g.Slug) + } + if g.LogoURL != nil { + t.Errorf("%s should not have a logo URL", g.Slug) + } + } +} + +func TestBrowseStudioReturnsEnrichedMovies(t *testing.T) { + tmdbClient := &fakeTMDBClient{discoverPage: &tmdb.MediaPage{ + Page: 1, + TotalPages: 2, + TotalResults: 20, + Results: []tmdb.MediaResult{ + {ID: 24428, MediaType: "movie", Title: "The Avengers", Year: 2012, Popularity: 100.5}, + }, + }} + service := newTestServiceWithTMDB(newFakeStore(), tmdbClient) + + resp, err := service.BrowseStudio(context.Background(), testViewer(1), "marvel-studios", "popularity", 1) + if err != nil { + t.Fatalf("BrowseStudio: %v", err) + } + if resp.Kind != "studio" || resp.Slug != "marvel-studios" || resp.MediaType != MediaTypeMovie { + t.Errorf("resp = %+v", resp) + } + if resp.Page != 1 || resp.TotalPages != 2 { + t.Errorf("pagination = %d/%d", resp.Page, resp.TotalPages) + } + if len(resp.Results) != 1 || resp.Results[0].TMDBID != 24428 { + t.Errorf("results = %+v", resp.Results) + } + if resp.Results[0].Availability == "" { + t.Error("availability should be enriched") + } +} + +func TestBrowseStudioUnknownSlugReturnsNotFound(t *testing.T) { + service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{}) + _, err := service.BrowseStudio(context.Background(), testViewer(1), "not-a-studio", "popularity", 1) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } +} + +func TestBrowseStudioRejectsBadSort(t *testing.T) { + service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{}) + _, err := service.BrowseStudio(context.Background(), testViewer(1), "marvel-studios", "made-up-sort", 1) + if !errors.Is(err, ErrInvalidInput) { + t.Fatalf("err = %v, want ErrInvalidInput", err) + } +} + +func TestBrowseStudioDefaultsBlankSortToPopularity(t *testing.T) { + tmdbClient := &fakeTMDBClient{discoverPage: &tmdb.MediaPage{Results: []tmdb.MediaResult{}}} + service := newTestServiceWithTMDB(newFakeStore(), tmdbClient) + + resp, err := service.BrowseStudio(context.Background(), testViewer(1), "marvel-studios", "", 1) + if err != nil { + t.Fatalf("BrowseStudio: %v", err) + } + if resp.Sort != "popularity" { + t.Errorf("sort = %q, want popularity (default)", resp.Sort) + } +} + +func TestBrowseNetworkReturnsSeries(t *testing.T) { + tmdbClient := &fakeTMDBClient{discoverPage: &tmdb.MediaPage{ + Page: 1, TotalPages: 1, TotalResults: 1, + Results: []tmdb.MediaResult{ + {ID: 1399, MediaType: "series", Title: "Game of Thrones", Year: 2011}, + }, + }} + service := newTestServiceWithTMDB(newFakeStore(), tmdbClient) + + resp, err := service.BrowseNetwork(context.Background(), testViewer(1), "netflix", "popularity", 1) + if err != nil { + t.Fatalf("BrowseNetwork: %v", err) + } + if resp.MediaType != MediaTypeSeries { + t.Errorf("media_type = %q, want series", resp.MediaType) + } +} + +func TestBrowseGenreRequiresMediaType(t *testing.T) { + service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{}) + _, err := service.BrowseGenre(context.Background(), testViewer(1), "action", "", "popularity", 1) + if !errors.Is(err, ErrInvalidInput) { + t.Fatalf("err = %v, want ErrInvalidInput", err) + } +} + +func TestBrowseGenreSeriesRejectedWhenUnsupported(t *testing.T) { + service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{}) + _, err := service.BrowseGenre(context.Background(), testViewer(1), "horror", "series", "popularity", 1) + if !errors.Is(err, ErrInvalidInput) { + t.Fatalf("err = %v, want ErrInvalidInput for horror+series", err) + } +} + +func TestBrowseGenreMovieReturnsResults(t *testing.T) { + tmdbClient := &fakeTMDBClient{discoverPage: &tmdb.MediaPage{ + Results: []tmdb.MediaResult{{ID: 1, MediaType: "movie", Title: "Movie"}}, + }} + service := newTestServiceWithTMDB(newFakeStore(), tmdbClient) + + resp, err := service.BrowseGenre(context.Background(), testViewer(1), "action", "movie", "popularity", 1) + if err != nil { + t.Fatalf("BrowseGenre: %v", err) + } + if resp.Kind != "genre" || resp.Slug != "action" || resp.MediaType != MediaTypeMovie { + t.Errorf("resp = %+v", resp) + } +} + +type fakePresence struct { + mu sync.Mutex + available map[MediaType]map[int]bool + byTVDB map[MediaType]map[int]int + got []PresenceCandidate +} + +func (f *fakePresence) Lookup(_ context.Context, mediaType MediaType, candidates []PresenceCandidate) (map[int]PresenceMatch, error) { + f.mu.Lock() + defer f.mu.Unlock() + out := map[int]PresenceMatch{} + f.got = append(f.got, candidates...) + for _, candidate := range candidates { + if f.available != nil && f.available[mediaType][candidate.TMDBID] { + out[candidate.TMDBID] = PresenceMatch{Available: true, MatchedProvider: "tmdb"} + continue + } + if candidate.TVDBID != nil && f.byTVDB != nil { + if tmdbID, ok := f.byTVDB[mediaType][*candidate.TVDBID]; ok && tmdbID == candidate.TMDBID { + out[candidate.TMDBID] = PresenceMatch{Available: true, MatchedProvider: "tvdb"} + } + } + } + return out, nil +} + +func (f *fakePresence) LookupTMDB(_ context.Context, mediaType MediaType, ids []int) (map[int]bool, error) { + out := map[int]bool{} + for _, id := range ids { + matches, err := f.Lookup(context.Background(), mediaType, []PresenceCandidate{{TMDBID: id}}) + if err != nil { + return nil, err + } + if matches[id].Available { + out[id] = true + } + } + return out, nil +} + +type fakeTMDBClient struct { + mu sync.Mutex + page *tmdb.MediaPage + externalIDs *tmdb.ExternalIDs + externalIDsByID map[int]*tmdb.ExternalIDs + externalIDCalls []int + detail *tmdb.MediaDetail + discoverPage *tmdb.MediaPage + discoverErr error + searchMediaType string +} + +func (f *fakeTMDBClient) SearchMedia(_ context.Context, mediaType, _ string, _ int) (*tmdb.MediaPage, error) { + f.searchMediaType = mediaType + return f.page, nil +} + +func (f *fakeTMDBClient) DiscoverSection(context.Context, string, int) (*tmdb.MediaPage, error) { + return f.page, nil +} + +func (f *fakeTMDBClient) DiscoverPage(context.Context, string, tmdb.DiscoverParams, int) (*tmdb.MediaPage, error) { + if f.discoverErr != nil { + return nil, f.discoverErr + } + if f.discoverPage != nil { + return f.discoverPage, nil + } + return &tmdb.MediaPage{Results: []tmdb.MediaResult{}}, nil +} + +func (f *fakeTMDBClient) GetExternalIDs(_ context.Context, _ string, id int) (*tmdb.ExternalIDs, error) { + f.mu.Lock() + f.externalIDCalls = append(f.externalIDCalls, id) + f.mu.Unlock() + if f.externalIDsByID != nil { + return f.externalIDsByID[id], nil + } + return f.externalIDs, nil +} + +func (f *fakeTMDBClient) GetMediaDetail(context.Context, string, int) (*tmdb.MediaDetail, error) { + return f.detail, nil +} + +type fakeMovieAdapter struct { + result FulfillmentResult + status FulfillmentStatus + err error + statusErr error + calls int + statusCalls int + gotReq Request + gotIntegration Integration +} + +func (f *fakeMovieAdapter) SubmitMovie(_ context.Context, req Request, integration Integration) (FulfillmentResult, error) { + f.calls++ + f.gotReq = req + f.gotIntegration = integration + return f.result, f.err +} + +func (f *fakeMovieAdapter) CheckMovieStatus(_ context.Context, req Request, integration Integration) (FulfillmentStatus, error) { + f.statusCalls++ + f.gotReq = req + f.gotIntegration = integration + return f.status, f.statusErr +} + +type fakeSecrets map[string]string + +func (f fakeSecrets) Get(_ context.Context, key string) (string, error) { + return f[key], nil +} + +type fakeSecretError struct { + err error +} + +func (f fakeSecretError) Get(context.Context, string) (string, error) { + return "", f.err +} diff --git a/internal/requests/sonarr/client.go b/internal/requests/sonarr/client.go new file mode 100644 index 00000000..af5611b8 --- /dev/null +++ b/internal/requests/sonarr/client.go @@ -0,0 +1,183 @@ +package sonarr + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + + mediarequests "github.com/Silo-Server/silo-server/internal/requests" + "github.com/Silo-Server/silo-server/internal/requests/arrclient" +) + +type Client struct { + httpClient *http.Client +} + +type seriesResource struct { + ID int `json:"id,omitempty"` + Title string `json:"title,omitempty"` + TVDBID int `json:"tvdbId,omitempty"` + TMDBID int `json:"tmdbId,omitempty"` + TitleSlug string `json:"titleSlug,omitempty"` + QualityProfileID int `json:"qualityProfileId,omitempty"` + RootFolderPath string `json:"rootFolderPath,omitempty"` + SeasonFolder bool `json:"seasonFolder"` + Monitored bool `json:"monitored"` + SeriesType string `json:"seriesType,omitempty"` + Tags []int `json:"tags,omitempty"` + AddOptions addSeriesOptions `json:"addOptions,omitempty"` +} + +type addSeriesOptions struct { + Monitor string `json:"monitor,omitempty"` + SearchForMissingEpisodes bool `json:"searchForMissingEpisodes"` + SearchForCutoffUnmetEpisodes bool `json:"searchForCutoffUnmetEpisodes,omitempty"` +} + +func NewClient(httpClient *http.Client) *Client { + return &Client{httpClient: httpClient} +} + +func (c *Client) ListSeriesIntegrationOptions(ctx context.Context, integration mediarequests.Integration) (*mediarequests.IntegrationOptions, error) { + client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient) + rootFolders, err := arrclient.ListRootFolders(ctx, client) + if err != nil { + return nil, err + } + qualityProfiles, err := arrclient.ListQualityProfiles(ctx, client) + if err != nil { + return nil, err + } + tags, err := arrclient.ListTags(ctx, client) + if err != nil { + return nil, err + } + return &mediarequests.IntegrationOptions{ + Kind: "sonarr", + RootFolders: rootFolders, + QualityProfiles: qualityProfiles, + Tags: tags, + }, nil +} + +func (c *Client) SubmitSeries(ctx context.Context, req mediarequests.Request, integration mediarequests.Integration) (mediarequests.FulfillmentResult, error) { + if req.MediaType != mediarequests.MediaTypeSeries { + return mediarequests.FulfillmentResult{}, fmt.Errorf("sonarr: request is not a series") + } + if integration.QualityProfileID == nil { + return mediarequests.FulfillmentResult{}, fmt.Errorf("sonarr: quality profile is required") + } + if req.TVDBID == nil || *req.TVDBID <= 0 { + return mediarequests.FulfillmentResult{}, fmt.Errorf("sonarr: tvdb_id is required") + } + + client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient) + series, err := c.lookupSeries(ctx, client, *req.TVDBID) + if err != nil { + return mediarequests.FulfillmentResult{}, err + } + series.RootFolderPath = integration.RootFolder + series.QualityProfileID = *integration.QualityProfileID + series.SeasonFolder = arrclient.BoolOption(integration.Options, "season_folder", true) + series.Monitored = arrclient.BoolOption(integration.Options, "monitored", true) + series.SeriesType = arrclient.StringOption(integration.Options, "series_type", "standard") + series.Tags = integration.Tags + series.AddOptions = addSeriesOptions{ + Monitor: arrclient.StringOption(integration.Options, "monitor", "all"), + SearchForMissingEpisodes: arrclient.BoolOption( + integration.Options, + "search_for_missing_episodes", + arrclient.BoolOption(integration.Options, "search_on_add", true), + ), + SearchForCutoffUnmetEpisodes: arrclient.BoolOption(integration.Options, "search_for_cutoff_unmet", false), + } + + var created seriesResource + if err := client.PostJSON(ctx, "/api/v3/series", series, &created); err != nil { + if !arrclient.IsEmptyOrTruncatedDecodeError(err) { + return mediarequests.FulfillmentResult{}, err + } + // POST accepted but Sonarr returned an empty body. Recover the + // new series' Sonarr ID by listing series filtered by TVDB ID; + // without the ID the reconcile loop cannot advance the request. + if found, lookErr := c.findSeriesByTVDBID(ctx, client, *req.TVDBID); lookErr == nil && found.ID > 0 { + return resultFromSeries(found), nil + } + return arrclient.AcceptedWithoutResponse("sonarr"), nil + } + return resultFromSeries(created), nil +} + +func (c *Client) findSeriesByTVDBID(ctx context.Context, client *arrclient.Client, tvdbID int) (seriesResource, error) { + values := url.Values{} + values.Set("tvdbId", strconv.Itoa(tvdbID)) + var matches []seriesResource + if err := client.GetJSON(ctx, "/api/v3/series?"+values.Encode(), &matches); err != nil { + return seriesResource{}, err + } + for _, s := range matches { + if s.ID > 0 && s.TVDBID == tvdbID { + return s, nil + } + } + return seriesResource{}, fmt.Errorf("sonarr: series not found after add for tvdb_id %d", tvdbID) +} + +func (c *Client) CheckSeriesStatus(ctx context.Context, req mediarequests.Request, integration mediarequests.Integration) (mediarequests.FulfillmentStatus, error) { + client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient) + seriesID, _ := strconv.Atoi(req.ExternalID) + if seriesID <= 0 { + return mediarequests.FulfillmentStatus{ + Status: mediarequests.StatusQueued, + IntegrationKind: "sonarr", + ExternalStatus: "external_id_unavailable", + }, nil + } + + queues, err := c.queueDetails(ctx, client, seriesID) + if err != nil { + return mediarequests.FulfillmentStatus{}, err + } + evaluation := arrclient.EvaluateQueue(queues) + return arrclient.StatusFromQueueEvaluation("sonarr", seriesID, evaluation), nil +} + +func (c *Client) lookupSeries(ctx context.Context, client *arrclient.Client, tvdbID int) (seriesResource, error) { + values := url.Values{} + values.Set("term", "tvdb:"+strconv.Itoa(tvdbID)) + var matches []seriesResource + if err := client.GetJSON(ctx, "/api/v3/series/lookup?"+values.Encode(), &matches); err != nil { + return seriesResource{}, err + } + for _, match := range matches { + if match.TVDBID == tvdbID { + return match, nil + } + } + return seriesResource{}, fmt.Errorf("sonarr: no series found for tvdb_id %d", tvdbID) +} + +func (c *Client) queueDetails(ctx context.Context, client *arrclient.Client, seriesID int) ([]arrclient.QueueResource, error) { + values := url.Values{} + values.Set("seriesId", strconv.Itoa(seriesID)) + var queues []arrclient.QueueResource + if err := client.GetJSON(ctx, "/api/v3/queue/details?"+values.Encode(), &queues); err != nil { + return nil, err + } + return queues, nil +} + +func resultFromSeries(series seriesResource) mediarequests.FulfillmentResult { + externalID := "" + if series.ID > 0 { + externalID = strconv.Itoa(series.ID) + } + return mediarequests.FulfillmentResult{ + IntegrationKind: "sonarr", + ExternalID: externalID, + ExternalStatus: "queued", + } +} + diff --git a/internal/requests/sonarr/client_test.go b/internal/requests/sonarr/client_test.go new file mode 100644 index 00000000..07ac24ad --- /dev/null +++ b/internal/requests/sonarr/client_test.go @@ -0,0 +1,228 @@ +package sonarr + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + mediarequests "github.com/Silo-Server/silo-server/internal/requests" +) + +func TestSubmitSeriesAddsLookupResult(t *testing.T) { + qualityProfileID := 3 + tvdbID := 121361 + var posted seriesResource + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Api-Key"); got != "sonarr-key" { + t.Fatalf("X-Api-Key = %q, want sonarr-key", got) + } + switch r.URL.Path { + case "/api/v3/series": + if r.Method == http.MethodGet { + if got := r.URL.Query().Get("tvdbId"); got != "121361" { + t.Fatalf("tvdbId = %q, want 121361", got) + } + w.Write([]byte(`[]`)) + return + } + if r.Method == http.MethodPost { + if err := json.NewDecoder(r.Body).Decode(&posted); err != nil { + t.Fatalf("decode posted series: %v", err) + } + w.Write([]byte(`{"id":24,"tvdbId":121361}`)) + return + } + case "/api/v3/series/lookup": + if got := r.URL.Query().Get("term"); got != "tvdb:121361" { + t.Fatalf("lookup term = %q, want tvdb:121361", got) + } + w.Write([]byte(`[{"title":"Game of Thrones","tvdbId":121361,"titleSlug":"game-of-thrones"}]`)) + return + } + t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) + })) + defer server.Close() + + client := NewClient(server.Client()) + result, err := client.SubmitSeries(context.Background(), mediarequests.Request{ + MediaType: mediarequests.MediaTypeSeries, + TMDBID: 1399, + TVDBID: &tvdbID, + Title: "Game of Thrones", + }, mediarequests.Integration{ + Kind: "sonarr", + BaseURL: server.URL, + APIKeyRef: "sonarr-key", + RootFolder: "/series", + QualityProfileID: &qualityProfileID, + Options: map[string]any{ + "monitor": "future", + }, + }) + if err != nil { + t.Fatalf("SubmitSeries returned error: %v", err) + } + if result.ExternalID != "24" || result.IntegrationKind != "sonarr" { + t.Fatalf("result = %+v, want sonarr external id 24", result) + } + if posted.RootFolderPath != "/series" || posted.QualityProfileID != qualityProfileID { + t.Fatalf("posted series = %+v, missing root folder/quality profile", posted) + } + if posted.AddOptions.Monitor != "future" { + t.Fatalf("monitor = %q, want future", posted.AddOptions.Monitor) + } +} + +func TestSubmitSeriesRecoversFromEmptyAddResponse(t *testing.T) { + qualityProfileID := 3 + tvdbID := 121361 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v3/series/lookup": + w.Write([]byte(`[{"title":"Game of Thrones","tvdbId":121361,"titleSlug":"game-of-thrones"}]`)) + case "/api/v3/series": + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusCreated) + return + } + if r.Method == http.MethodGet { + if got := r.URL.Query().Get("tvdbId"); got != "121361" { + t.Fatalf("tvdbId = %q, want 121361", got) + } + w.Write([]byte(`[{"id":77,"tvdbId":121361,"title":"Game of Thrones"}]`)) + return + } + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) + } + })) + defer server.Close() + + client := NewClient(server.Client()) + result, err := client.SubmitSeries(context.Background(), mediarequests.Request{ + MediaType: mediarequests.MediaTypeSeries, + TMDBID: 1399, + TVDBID: &tvdbID, + Title: "Game of Thrones", + }, mediarequests.Integration{ + Kind: "sonarr", + BaseURL: server.URL, + APIKeyRef: "sonarr-key", + RootFolder: "/series", + QualityProfileID: &qualityProfileID, + }) + if err != nil { + t.Fatalf("SubmitSeries returned error: %v", err) + } + if result.ExternalID != "77" { + t.Fatalf("ExternalID = %q, want 77 (recovered after empty 201)", result.ExternalID) + } + if result.ExternalStatus != "queued" { + t.Fatalf("ExternalStatus = %q, want queued", result.ExternalStatus) + } +} + +func TestSubmitSeriesRejectsNonExactTVDBLookupMatch(t *testing.T) { + qualityProfileID := 3 + tvdbID := 121361 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v3/series/lookup" { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) + } + w.Write([]byte(`[{"title":"Wrong Show","tvdbId":999,"titleSlug":"wrong-show"}]`)) + })) + defer server.Close() + + client := NewClient(server.Client()) + _, err := client.SubmitSeries(context.Background(), mediarequests.Request{ + MediaType: mediarequests.MediaTypeSeries, + TMDBID: 1399, + TVDBID: &tvdbID, + Title: "Game of Thrones", + }, mediarequests.Integration{ + Kind: "sonarr", + BaseURL: server.URL, + APIKeyRef: "sonarr-key", + RootFolder: "/series", + QualityProfileID: &qualityProfileID, + }) + if err == nil { + t.Fatal("expected error for non-exact TVDB lookup match") + } +} + +func TestCheckSeriesStatusReadsQueueDetails(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Api-Key"); got != "sonarr-key" { + t.Fatalf("X-Api-Key = %q, want sonarr-key", got) + } + if r.URL.Path != "/api/v3/queue/details" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + if got := r.URL.Query().Get("seriesId"); got != "24" { + t.Fatalf("seriesId = %q, want 24", got) + } + w.Write([]byte(`[{"seriesId":24,"status":"downloading","trackedDownloadState":"importing"}]`)) + })) + defer server.Close() + + client := NewClient(server.Client()) + status, err := client.CheckSeriesStatus(context.Background(), mediarequests.Request{ + MediaType: mediarequests.MediaTypeSeries, + TMDBID: 1399, + ExternalID: "24", + }, mediarequests.Integration{ + Kind: "sonarr", + BaseURL: server.URL, + APIKeyRef: "sonarr-key", + }) + if err != nil { + t.Fatalf("CheckSeriesStatus returned error: %v", err) + } + if status.Status != mediarequests.StatusDownloading || status.ExternalStatus != "downloading/importing" { + t.Fatalf("status = %+v, want downloading", status) + } +} + +func TestListSeriesIntegrationOptionsLoadsChoices(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Api-Key"); got != "sonarr-key" { + t.Fatalf("X-Api-Key = %q, want sonarr-key", got) + } + switch r.URL.Path { + case "/api/v3/rootfolder": + w.Write([]byte(`[{"path":"/series","freeSpace":123,"totalSpace":456,"accessible":true}]`)) + case "/api/v3/qualityprofile": + w.Write([]byte(`[{"id":3,"name":"HD-1080p"}]`)) + case "/api/v3/tag": + w.Write([]byte(`[{"id":4,"label":"requests"}]`)) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + + client := NewClient(server.Client()) + options, err := client.ListSeriesIntegrationOptions(context.Background(), mediarequests.Integration{ + Kind: "sonarr", + BaseURL: server.URL, + APIKeyRef: "sonarr-key", + }) + if err != nil { + t.Fatalf("ListSeriesIntegrationOptions returned error: %v", err) + } + if options.Kind != "sonarr" { + t.Fatalf("kind = %q, want sonarr", options.Kind) + } + if len(options.RootFolders) != 1 || options.RootFolders[0].Path != "/series" { + t.Fatalf("root folders = %+v, want /series", options.RootFolders) + } + if len(options.QualityProfiles) != 1 || options.QualityProfiles[0].ID != 3 { + t.Fatalf("quality profiles = %+v, want id 3", options.QualityProfiles) + } + if len(options.Tags) != 1 || options.Tags[0].ID != 4 { + t.Fatalf("tags = %+v, want id 4", options.Tags) + } +} diff --git a/internal/requests/store.go b/internal/requests/store.go new file mode 100644 index 00000000..91637b4e --- /dev/null +++ b/internal/requests/store.go @@ -0,0 +1,49 @@ +package requests + +import ( + "context" + "time" +) + +type Store interface { + GetSettings(ctx context.Context) (Settings, error) + UpdateSettings(ctx context.Context, settings Settings) (Settings, error) + GetUserLimit(ctx context.Context, userID int) (*UserLimit, error) + UpsertUserLimit(ctx context.Context, limit UserLimit) (*UserLimit, error) + CountUserRequestsSince(ctx context.Context, userID int, since time.Time) (int, error) + ListActiveByTMDB(ctx context.Context, mediaType MediaType, tmdbIDs []int) (map[int]*Request, error) + // DeleteFailedByTMDB removes prior failed requests for a given media so a + // re-request does not leave behind stale rows in user/admin lists. + DeleteFailedByTMDB(ctx context.Context, mediaType MediaType, tmdbID int) (int, error) + CreateRequest(ctx context.Context, input CreateRequestRecord) (*Request, error) + GetRequest(ctx context.Context, id string) (*Request, error) + ListReconciliationCandidates(ctx context.Context, limit int) ([]*Request, error) + ListMine(ctx context.Context, userID int, filter ListFilter) ([]*Request, error) + ListAdmin(ctx context.Context, filter ListFilter) ([]*Request, error) + SetStatus(ctx context.Context, id string, status Status, actor Viewer) (*Request, error) + MarkQueued(ctx context.Context, id string, update QueueUpdate, actor Viewer) (*Request, error) + SetOutcome(ctx context.Context, id string, outcome Outcome, actor Viewer, message string) (*Request, error) + ListIntegrations(ctx context.Context) ([]Integration, error) + UpsertIntegration(ctx context.Context, integration Integration) (*Integration, error) + UpsertIntegrations(ctx context.Context, integrations []Integration) ([]Integration, error) +} + +type CreateRequestRecord struct { + ID string + Input CreateRequestInput + Status Status + Outcome Outcome + Requester Viewer + Now time.Time + // Quota, when non-nil, instructs the store to atomically verify the + // requester is below their per-user limit before inserting. The check + // runs inside the same transaction as the insert with a per-user + // advisory lock so concurrent submissions cannot both exceed the limit. + Quota *QuotaCheck +} + +type QuotaCheck struct { + UserID int + WindowStart time.Time + MaxRequests int +} diff --git a/internal/requests/types.go b/internal/requests/types.go new file mode 100644 index 00000000..b7331821 --- /dev/null +++ b/internal/requests/types.go @@ -0,0 +1,287 @@ +package requests + +import ( + "time" +) + +type MediaType string + +const ( + MediaTypeMovie MediaType = "movie" + MediaTypeSeries MediaType = "series" + MediaTypeAll MediaType = "all" +) + +type Status string + +const ( + StatusPending Status = "pending" + StatusApproved Status = "approved" + StatusQueued Status = "queued" + StatusDownloading Status = "downloading" + StatusCompleted Status = "completed" +) + +type Outcome string + +const ( + OutcomeActive Outcome = "active" + OutcomeDeclined Outcome = "declined" + OutcomeCancelled Outcome = "cancelled" + OutcomeFailed Outcome = "failed" +) + +type Availability string + +const ( + AvailabilityMissing Availability = "missing" + AvailabilityAvailable Availability = "available" +) + +type LimitMode string + +const ( + LimitModeInherit LimitMode = "inherit" + LimitModeCustom LimitMode = "custom" + LimitModeUnlimited LimitMode = "unlimited" + LimitModeBlocked LimitMode = "blocked" +) + +type ApprovalMode string + +const ( + ApprovalModeInherit ApprovalMode = "inherit" + ApprovalModeManual ApprovalMode = "manual" + ApprovalModeAuto ApprovalMode = "auto" + ApprovalModeBlocked ApprovalMode = "blocked" +) + +type Viewer struct { + UserID int + ProfileID string + IsAdmin bool +} + +type Settings struct { + RequestsEnabled bool `json:"requests_enabled"` + GlobalMaxRequests int `json:"global_max_requests"` + GlobalWindowDays int `json:"global_window_days"` + GlobalAutoApprovalEnabled bool `json:"global_auto_approval_enabled"` + UpdatedAt time.Time `json:"updated_at"` +} + +type UserLimit struct { + UserID int `json:"user_id"` + LimitMode LimitMode `json:"limit_mode"` + MaxRequests *int `json:"max_requests,omitempty"` + WindowDays *int `json:"window_days,omitempty"` + ApprovalMode ApprovalMode `json:"approval_mode"` + UpdatedAt time.Time `json:"updated_at"` +} + +type EffectivePolicy struct { + RequestsEnabled bool + MaxRequests int + WindowDays int + Unlimited bool + Blocked bool + AutoApprove bool + Used int + Remaining int + WindowStart time.Time +} + +type Request struct { + ID string `json:"id"` + Provider string `json:"provider"` + MediaType MediaType `json:"media_type"` + TMDBID int `json:"tmdb_id"` + TVDBID *int `json:"tvdb_id,omitempty"` + IMDbID string `json:"imdb_id,omitempty"` + Title string `json:"title"` + Year *int `json:"year,omitempty"` + Overview string `json:"overview,omitempty"` + PosterPath string `json:"poster_path,omitempty"` + BackdropPath string `json:"backdrop_path,omitempty"` + Status Status `json:"status"` + Outcome Outcome `json:"outcome"` + RequestedByUserID int `json:"requested_by_user_id,omitempty"` + RequestedByProfileID string `json:"requested_by_profile_id,omitempty"` + IntegrationKind string `json:"integration_kind,omitempty"` + ExternalID string `json:"external_id,omitempty"` + ExternalStatus string `json:"external_status,omitempty"` + LastError string `json:"last_error,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ApprovedAt *time.Time `json:"approved_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` +} + +type RequestEvent struct { + ID int64 `json:"id"` + RequestID string `json:"request_id"` + EventType string `json:"event_type"` + ActorUserID *int `json:"actor_user_id,omitempty"` + ActorProfileID string `json:"actor_profile_id,omitempty"` + Message string `json:"message,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type RequestState struct { + Status Status `json:"status,omitempty"` + Requestable bool `json:"requestable"` + Reason string `json:"reason,omitempty"` + RequestID string `json:"request_id,omitempty"` +} + +type MediaResult struct { + MediaType MediaType `json:"media_type"` + TMDBID int `json:"tmdb_id"` + Title string `json:"title"` + Year int `json:"year,omitempty"` + Overview string `json:"overview,omitempty"` + PosterPath string `json:"poster_path,omitempty"` + BackdropPath string `json:"backdrop_path,omitempty"` + ReleaseDate string `json:"release_date,omitempty"` + Popularity float64 `json:"popularity,omitempty"` + VoteAverage float64 `json:"vote_average,omitempty"` + Availability Availability `json:"availability"` + Request RequestState `json:"request"` +} + +type MediaPage struct { + Page int `json:"page"` + TotalPages int `json:"total_pages"` + TotalResults int `json:"total_results"` + Results []MediaResult `json:"results"` +} + +type MediaCastMember struct { + Name string `json:"name"` + Character string `json:"character,omitempty"` + ProfilePath string `json:"profile_path,omitempty"` + Order int `json:"order"` +} + +type MediaDetail struct { + MediaType MediaType `json:"media_type"` + TMDBID int `json:"tmdb_id"` + IMDbID string `json:"imdb_id,omitempty"` + TVDBID *int `json:"tvdb_id,omitempty"` + Title string `json:"title"` + OriginalTitle string `json:"original_title,omitempty"` + Tagline string `json:"tagline,omitempty"` + Overview string `json:"overview,omitempty"` + PosterPath string `json:"poster_path,omitempty"` + BackdropPath string `json:"backdrop_path,omitempty"` + ReleaseDate string `json:"release_date,omitempty"` + Year int `json:"year,omitempty"` + Runtime int `json:"runtime,omitempty"` + Genres []string `json:"genres,omitempty"` + VoteAverage float64 `json:"vote_average,omitempty"` + VoteCount int `json:"vote_count,omitempty"` + Status string `json:"status,omitempty"` + Homepage string `json:"homepage,omitempty"` + ContentRating string `json:"content_rating,omitempty"` + ProductionCompanies []string `json:"production_companies,omitempty"` + NumberOfSeasons int `json:"number_of_seasons,omitempty"` + NumberOfEpisodes int `json:"number_of_episodes,omitempty"` + FirstAirDate string `json:"first_air_date,omitempty"` + LastAirDate string `json:"last_air_date,omitempty"` + Networks []string `json:"networks,omitempty"` + Cast []MediaCastMember `json:"cast,omitempty"` + Director string `json:"director,omitempty"` + Creators []string `json:"creators,omitempty"` + Recommendations []MediaResult `json:"recommendations,omitempty"` + Availability Availability `json:"availability"` + Request RequestState `json:"request"` +} + +type CreateRequestInput struct { + MediaType MediaType `json:"media_type"` + TMDBID int `json:"tmdb_id"` + TVDBID *int `json:"tvdb_id,omitempty"` + IMDbID string `json:"imdb_id,omitempty"` + Title string `json:"title"` + Year *int `json:"year,omitempty"` + Overview string `json:"overview,omitempty"` + PosterPath string `json:"poster_path,omitempty"` + BackdropPath string `json:"backdrop_path,omitempty"` +} + +type ListFilter struct { + Status Status + Outcome Outcome + Limit int + Offset int +} + +type Integration struct { + Kind string `json:"kind"` + Enabled bool `json:"enabled"` + BaseURL string `json:"base_url"` + APIKeyRef string `json:"api_key_ref,omitempty"` + RootFolder string `json:"root_folder"` + QualityProfileID *int `json:"quality_profile_id,omitempty"` + Tags []int `json:"tags"` + Options map[string]any `json:"options"` + LastCheckAt *time.Time `json:"last_check_at,omitempty"` + LastCheckStatus string `json:"last_check_status,omitempty"` + LastCheckError string `json:"last_check_error,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +type IntegrationRootFolder struct { + Path string `json:"path"` + FreeSpace int64 `json:"free_space,omitempty"` + TotalSpace int64 `json:"total_space,omitempty"` + Accessible bool `json:"accessible"` +} + +type IntegrationQualityProfile struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type IntegrationTag struct { + ID int `json:"id"` + Label string `json:"label"` +} + +type IntegrationOptions struct { + Kind string `json:"kind"` + RootFolders []IntegrationRootFolder `json:"root_folders"` + QualityProfiles []IntegrationQualityProfile `json:"quality_profiles"` + Tags []IntegrationTag `json:"tags"` +} + +type QueueUpdate struct { + IntegrationKind string + ExternalID string + ExternalStatus string +} + +type FulfillmentResult struct { + IntegrationKind string + ExternalID string + ExternalStatus string +} + +type FulfillmentStatus struct { + Status Status + Outcome Outcome + IntegrationKind string + ExternalID string + ExternalStatus string + Message string +} + +type ReconcileResult struct { + Checked int `json:"checked"` + Submitted int `json:"submitted"` + Downloading int `json:"downloading"` + Completed int `json:"completed"` + Failed int `json:"failed"` + Skipped int `json:"skipped"` + Errors int `json:"errors"` +} diff --git a/internal/taskmanager/tasks/reconcile_requests.go b/internal/taskmanager/tasks/reconcile_requests.go new file mode 100644 index 00000000..80a7e37f --- /dev/null +++ b/internal/taskmanager/tasks/reconcile_requests.go @@ -0,0 +1,59 @@ +package tasks + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/Silo-Server/silo-server/internal/requests" + "github.com/Silo-Server/silo-server/internal/taskmanager" +) + +type RequestReconciler interface { + ReconcileRequests(ctx context.Context, limit int) (requests.ReconcileResult, error) +} + +type ReconcileRequestsTask struct { + reconciler RequestReconciler + limit int +} + +func NewReconcileRequestsTask(reconciler RequestReconciler, limit int) *ReconcileRequestsTask { + if limit <= 0 { + limit = 100 + } + return &ReconcileRequestsTask{reconciler: reconciler, limit: limit} +} + +func (t *ReconcileRequestsTask) Key() string { return "reconcile_requests" } +func (t *ReconcileRequestsTask) Name() string { return "Reconcile Requests" } +func (t *ReconcileRequestsTask) Description() string { + return "Checks approved and active media requests against Radarr, Sonarr, and the Silo catalog" +} +func (t *ReconcileRequestsTask) Category() taskmanager.TaskCategory { + return taskmanager.TaskCategoryLibrary +} +func (t *ReconcileRequestsTask) IsHidden() bool { return false } + +func (t *ReconcileRequestsTask) DefaultTriggers() []taskmanager.TriggerConfig { + return []taskmanager.TriggerConfig{ + {Type: taskmanager.TriggerTypeInterval, IntervalMs: 5 * 60 * 1000}, + } +} + +func (t *ReconcileRequestsTask) Execute(ctx context.Context, progress taskmanager.ProgressReporter) error { + progress.Report(0, "Reconciling media requests") + if t.reconciler == nil { + progress.Report(100, "Request reconciliation unavailable") + return nil + } + result, err := t.reconciler.ReconcileRequests(ctx, t.limit) + if err != nil { + return fmt.Errorf("reconcile media requests: %w", err) + } + if data, err := json.Marshal(result); err == nil { + progress.SetResultData(data) + } + progress.Report(100, "Request reconciliation complete") + return nil +} diff --git a/migrations/139_media_requests.down.sql b/migrations/139_media_requests.down.sql new file mode 100644 index 00000000..de261138 --- /dev/null +++ b/migrations/139_media_requests.down.sql @@ -0,0 +1,10 @@ +DROP TABLE IF EXISTS public.media_request_events; +DROP INDEX IF EXISTS public.idx_media_requests_outcome_created; +DROP INDEX IF EXISTS public.idx_media_requests_status_created; +DROP INDEX IF EXISTS public.idx_media_requests_profile_created; +DROP INDEX IF EXISTS public.idx_media_requests_user_created; +DROP INDEX IF EXISTS public.idx_media_requests_active_tmdb; +DROP TABLE IF EXISTS public.media_requests; +DROP TABLE IF EXISTS public.request_integrations; +DROP TABLE IF EXISTS public.request_user_limits; +DROP TABLE IF EXISTS public.request_settings; diff --git a/migrations/139_media_requests.up.sql b/migrations/139_media_requests.up.sql new file mode 100644 index 00000000..fa3a130b --- /dev/null +++ b/migrations/139_media_requests.up.sql @@ -0,0 +1,114 @@ +CREATE TABLE IF NOT EXISTS public.request_settings ( + id boolean PRIMARY KEY DEFAULT true, + requests_enabled boolean NOT NULL DEFAULT false, + global_max_requests integer NOT NULL DEFAULT 5, + global_window_days integer NOT NULL DEFAULT 7, + global_auto_approval_enabled boolean NOT NULL DEFAULT false, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT request_settings_singleton CHECK (id), + CONSTRAINT request_settings_global_max_nonnegative CHECK (global_max_requests >= 0), + CONSTRAINT request_settings_global_window_positive CHECK (global_window_days > 0) +); + +INSERT INTO public.request_settings (id) +VALUES (true) +ON CONFLICT (id) DO NOTHING; + +CREATE TABLE IF NOT EXISTS public.request_user_limits ( + user_id integer PRIMARY KEY REFERENCES public.users(id) ON DELETE CASCADE, + limit_mode text NOT NULL DEFAULT 'inherit', + max_requests integer, + window_days integer, + approval_mode text NOT NULL DEFAULT 'inherit', + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT request_user_limits_limit_mode_check + CHECK (limit_mode IN ('inherit', 'custom', 'unlimited', 'blocked')), + CONSTRAINT request_user_limits_approval_mode_check + CHECK (approval_mode IN ('inherit', 'manual', 'auto', 'blocked')), + CONSTRAINT request_user_limits_max_nonnegative + CHECK (max_requests IS NULL OR max_requests >= 0), + CONSTRAINT request_user_limits_window_positive + CHECK (window_days IS NULL OR window_days > 0) +); + +CREATE TABLE IF NOT EXISTS public.request_integrations ( + kind text PRIMARY KEY, + enabled boolean NOT NULL DEFAULT false, + base_url text NOT NULL DEFAULT '', + api_key_ref text NOT NULL DEFAULT '', + root_folder text NOT NULL DEFAULT '', + quality_profile_id integer, + tags integer[] NOT NULL DEFAULT '{}', + options jsonb NOT NULL DEFAULT '{}'::jsonb, + last_check_at timestamp with time zone, + last_check_status text NOT NULL DEFAULT '', + last_check_error text NOT NULL DEFAULT '', + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT request_integrations_kind_check CHECK (kind IN ('radarr', 'sonarr')) +); + +CREATE TABLE IF NOT EXISTS public.media_requests ( + id text PRIMARY KEY, + provider text NOT NULL DEFAULT 'tmdb', + media_type text NOT NULL, + tmdb_id integer NOT NULL, + tvdb_id integer, + imdb_id text NOT NULL DEFAULT '', + title text NOT NULL, + year integer, + overview text NOT NULL DEFAULT '', + poster_path text NOT NULL DEFAULT '', + backdrop_path text NOT NULL DEFAULT '', + status text NOT NULL, + outcome text NOT NULL DEFAULT 'active', + requested_by_user_id integer NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + requested_by_profile_id text NOT NULL DEFAULT '', + integration_kind text NOT NULL DEFAULT '', + external_id text NOT NULL DEFAULT '', + external_status text NOT NULL DEFAULT '', + last_error text NOT NULL DEFAULT '', + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + approved_at timestamp with time zone, + completed_at timestamp with time zone, + CONSTRAINT media_requests_provider_check CHECK (provider IN ('tmdb')), + CONSTRAINT media_requests_media_type_check CHECK (media_type IN ('movie', 'series')), + CONSTRAINT media_requests_status_check + CHECK (status IN ('pending', 'approved', 'queued', 'downloading', 'completed')), + CONSTRAINT media_requests_outcome_check + CHECK (outcome IN ('active', 'declined', 'cancelled', 'failed')), + CONSTRAINT media_requests_tmdb_positive CHECK (tmdb_id > 0) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_media_requests_active_tmdb + ON public.media_requests (media_type, provider, tmdb_id) + WHERE outcome = 'active' AND status <> 'completed'; + +CREATE INDEX IF NOT EXISTS idx_media_requests_user_created + ON public.media_requests (requested_by_user_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_media_requests_profile_created + ON public.media_requests (requested_by_profile_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_media_requests_status_created + ON public.media_requests (status, created_at DESC) + WHERE outcome = 'active'; + +CREATE INDEX IF NOT EXISTS idx_media_requests_outcome_created + ON public.media_requests (outcome, created_at DESC); + +CREATE TABLE IF NOT EXISTS public.media_request_events ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + request_id text NOT NULL REFERENCES public.media_requests(id) ON DELETE CASCADE, + event_type text NOT NULL, + actor_user_id integer REFERENCES public.users(id) ON DELETE SET NULL, + actor_profile_id text NOT NULL DEFAULT '', + message text NOT NULL DEFAULT '', + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_media_request_events_request_created + ON public.media_request_events (request_id, created_at DESC); diff --git a/scripts/check-local-path-leaks.sh b/scripts/check-local-path-leaks.sh new file mode 100755 index 00000000..f4b57808 --- /dev/null +++ b/scripts/check-local-path-leaks.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + printf 'usage: %s [--cached]\n' "${0##*/}" >&2 +} + +cached=0 +case "${1:-}" in + "") + ;; + --cached) + cached=1 + ;; + -h|--help) + usage + exit 0 + ;; + *) + usage + exit 2 + ;; +esac + +repo_root=$(git rev-parse --show-toplevel) +cd "$repo_root" + +failed=0 +t3_worktree_dir='\.t3'/'worktrees' +t3_worktree_id_prefix='t3''code-' + +check_pattern() { + local label=$1 + local pattern=$2 + shift 2 + + local matches + if [[ "$cached" -eq 1 ]]; then + matches=$(git grep --cached -n -I -E "$pattern" -- "$@" 2>/dev/null) || return 0 + else + matches=$(git grep -n -I -E "$pattern" -- "$@" 2>/dev/null) || return 0 + fi + + if [[ -n "$matches" ]]; then + printf '%s\n' "local path leak check failed: $label" >&2 + printf '%s\n\n' "$matches" >&2 + failed=1 + fi +} + +check_pattern \ + "T3 worktree path or generated worktree id" \ + "(${t3_worktree_dir}|/Users/[^[:space:]]*/${t3_worktree_dir}/|${t3_worktree_id_prefix}[0-9a-f]{8})" \ + docs/superpowers/specs docs/superpowers/plans + +check_pattern \ + "absolute local filesystem path in generated superpowers docs" \ + '(/Users/[^[:space:]]+|/home/[^[:space:]]+|/Volumes/[^[:space:]]+|/var/folders/[^[:space:]]+|/private/tmp/[^[:space:]]+|[A-Za-z]:\\Users\\[^[:space:]]+)' \ + docs/superpowers/specs docs/superpowers/plans + +if [[ "$failed" -ne 0 ]]; then + printf '%s\n' "Remove local machine paths from committed content. Use repository-relative paths instead." >&2 + exit 1 +fi diff --git a/web/src/App.tsx b/web/src/App.tsx index 24eb78c6..d83574a9 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -32,10 +32,14 @@ import ItemDetail from "@/pages/ItemDetail/index"; import PersonDetail from "@/pages/PersonDetail"; import Collections from "@/pages/Collections"; import CollectionEditor from "@/pages/CollectionEditor"; +import Requests from "@/pages/Requests"; +import RequestBrowse from "@/pages/RequestBrowse"; +import RequestDetail from "@/pages/RequestDetail"; import AdminDashboard from "@/pages/AdminDashboard"; import AdminActivity from "@/pages/AdminActivity"; import AdminLogs from "@/pages/AdminLogs"; import AdminUsers from "@/pages/AdminUsers"; +import AdminRequests from "@/pages/AdminRequests"; import AdminDevices from "@/pages/AdminDevices"; import AdminLibraries from "@/pages/AdminLibraries"; import AdminSettingsLayout from "@/pages/admin-settings/AdminSettingsLayout"; @@ -216,6 +220,7 @@ function QueryCacheManager() { qc.removeQueries({ queryKey: ["progress"] }); qc.removeQueries({ queryKey: ["sections"] }); qc.removeQueries({ queryKey: ["calendar"] }); + qc.removeQueries({ queryKey: ["requests"] }); // Recommendation rows include per-profile user_state (is_favorite, etc.); // the taste-seed picker depends on this for pre-selection. qc.removeQueries({ queryKey: ["recommendations"] }); @@ -350,6 +355,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -443,6 +449,20 @@ function AppRoutes() { path="/collections/:id" element={} /> + } /> + } /> + } + /> + } + /> + } + /> } /> ; + last_check_at?: string | null; + last_check_status?: string; + last_check_error?: string; + updated_at?: string; +} + +export interface RequestIntegrationRootFolder { + path: string; + free_space?: number; + total_space?: number; + accessible: boolean; +} + +export interface RequestIntegrationQualityProfile { + id: number; + name: string; +} + +export interface RequestIntegrationTag { + id: number; + label: string; +} + +export interface RequestIntegrationOptions { + kind: string; + root_folders: RequestIntegrationRootFolder[]; + quality_profiles: RequestIntegrationQualityProfile[]; + tags: RequestIntegrationTag[]; +} + +export interface LoadRequestIntegrationOptionsRequest { + base_url: string; + api_key_ref?: string; +} + +export interface RequestIntegrationsResponse { + integrations: RequestIntegration[]; +} + +export interface RequestListParams { + status?: MediaRequestStatus | "all"; + outcome?: MediaRequestOutcome | "all"; + limit?: number; + offset?: number; +} + // Admin export interface AdminUser { id: number; diff --git a/web/src/app.css b/web/src/app.css index 70de7155..9776b429 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -1490,6 +1490,28 @@ } } + /* Indeterminate progress shimmer used by in-flight request cards. + Renders as a thin sliding band over the bottom edge of the poster. */ + .downloading-shimmer { + width: 40%; + animation: downloading-shimmer 1.6s ease-in-out infinite; + } + @keyframes downloading-shimmer { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(350%); + } + } + @media (prefers-reduced-motion: reduce) { + .downloading-shimmer { + animation: none; + width: 100%; + opacity: 0.6; + } + } + /* Pill-style tab bar used inside the marquee header. */ .marquee-tab-bar { display: inline-flex; diff --git a/web/src/components/AdminSidebar.tsx b/web/src/components/AdminSidebar.tsx index f4e97f07..62559f3d 100644 --- a/web/src/components/AdminSidebar.tsx +++ b/web/src/components/AdminSidebar.tsx @@ -19,6 +19,7 @@ import { ScrollText, Blocks, Puzzle, + Send, } from "lucide-react"; import type { ReactNode } from "react"; import { SiloBrand } from "@/components/SiloBrand"; @@ -103,6 +104,11 @@ export default function AdminSidebar({ onNavigate }: AdminSidebarProps) { icon: , href: "/admin/collections", }, + { + label: "Requests", + icon: , + href: "/admin/requests", + }, { label: "Sections", icon: , diff --git a/web/src/components/AppSidebar.tsx b/web/src/components/AppSidebar.tsx index aeac591c..59c4ab13 100644 --- a/web/src/components/AppSidebar.tsx +++ b/web/src/components/AppSidebar.tsx @@ -50,6 +50,7 @@ import { PinOff, LayoutGrid, Puzzle, + Send, } from "lucide-react"; import { useTheme } from "@/hooks/useTheme"; import { CURATED_THEME_IDS, THEMES } from "@/lib/themes"; @@ -494,6 +495,23 @@ export default function AppSidebar({ onNavigate, collapsed = false }: AppSidebar Recommendations +
  • + + {isActive("/requests") && ( + + )} + + Requests + +
  • + + {card.display_name} + + + ); + } + + return ( + + ); +} diff --git a/web/src/components/BrandCarousel.tsx b/web/src/components/BrandCarousel.tsx new file mode 100644 index 00000000..17b2b0e1 --- /dev/null +++ b/web/src/components/BrandCarousel.tsx @@ -0,0 +1,106 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; +import type { DiscoverBrandCard, DiscoverBrowseKind } from "@/api/types"; +import BrandCard from "@/components/BrandCard"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useCarouselEmbla } from "@/hooks/useCarouselEmbla"; + +interface BrandCarouselProps { + kind: DiscoverBrowseKind; + title: string; + cards: DiscoverBrandCard[] | undefined; + isLoading: boolean; + isError: boolean; + onRetry?: () => void; +} + +export default function BrandCarousel({ + kind, + title, + cards, + isLoading, + isError, + onRetry, +}: BrandCarouselProps) { + const { emblaRef, canScrollPrev, canScrollNext, scrollPrev, scrollNext } = useCarouselEmbla(); + + const slides = isLoading + ? Array.from({ length: 8 }).map((_, idx) => ( + + )) + : (cards ?? []).map((card) => ); + + return ( +
    +
    +

    {title}

    + {isError && onRetry ? ( + + ) : null} +
    + + {isError && !isLoading ? ( +
    + Could not load {title.toLowerCase()}. +
    + ) : ( +
    + {canScrollPrev && ( +
    + )} + {canScrollPrev && ( + + )} + +
    { + if (e.key === "ArrowLeft") { + scrollPrev(); + } else if (e.key === "ArrowRight") { + scrollNext(); + } + }} + > +
      + {slides.map((slide, index) => ( +
    • + {slide} +
    • + ))} +
    +
    + + {canScrollNext && ( + + )} +
    + )} +
    + ); +} diff --git a/web/src/components/CastCarousel.tsx b/web/src/components/CastCarousel.tsx index 7b17d094..d720fd64 100644 --- a/web/src/components/CastCarousel.tsx +++ b/web/src/components/CastCarousel.tsx @@ -4,13 +4,20 @@ import type { CastMember } from "@/api/types"; import { useCarouselEmbla } from "@/hooks/useCarouselEmbla"; import { buildPersonCatalogHref } from "@/pages/catalogSearchParams"; import { getInitials } from "@/lib/text"; +import { cn } from "@/lib/utils"; interface CastCarouselProps { cast: CastMember[]; limit?: number; + /** + * When true, the carousel adds MediaCarousel-style edge padding so it can sit + * at the top level of a page (outside `.page-shell`) and align with other + * full-bleed rows. + */ + fullBleed?: boolean; } -export default function CastCarousel({ cast, limit = 20 }: CastCarouselProps) { +export default function CastCarousel({ cast, limit = 20, fullBleed = false }: CastCarouselProps) { const { emblaRef, canScrollPrev, canScrollNext, scrollPrev, scrollNext } = useCarouselEmbla(); if (cast.length === 0) return null; @@ -26,46 +33,38 @@ export default function CastCarousel({ cast, limit = 20 }: CastCarouselProps) { )} -
    -
      - {visible.map((member) => ( -
    • - -
      - {member.photo_url ? ( - {member.name} - ) : ( -
      - {getInitials(member.name)} -
      - )} -
      -
      -
      - {member.name} -
      -
      - {member.character} -
      -
      -
      -
    • - ))} +
      +
        + {visible.map((member) => { + const href = member.person_id ? buildPersonCatalogHref(member.person_id) : null; + return ( +
      • + +
      • + ); + })}
      @@ -73,7 +72,10 @@ export default function CastCarousel({ cast, limit = 20 }: CastCarouselProps) {
    ); } + +function CastCard({ member, href }: { member: CastMember; href: string | null }) { + const inner = ( + <> +
    + {member.photo_url ? ( + {member.name} + ) : ( +
    + {getInitials(member.name)} +
    + )} +
    +
    +
    {member.name}
    + {member.character ? ( +
    {member.character}
    + ) : null} +
    + + ); + + if (href) { + return ( + + {inner} + + ); + } + return
    {inner}
    ; +} diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index b007dd2d..19357593 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -42,10 +42,12 @@ export default function Layout({ children }: LayoutProps) { })(); const isRecommendationsRoute = location.pathname === "/recommendations"; const isCalendarRoute = location.pathname === "/calendar"; + const isRequestDetailRoute = /^\/requests\/(movie|series)\//.test(location.pathname); const needsNoPadding = isHomePath || isLibraryRoute || isItemRoute || + isRequestDetailRoute || isSearchLandingRoute || isRecommendationsRoute || isCalendarRoute; diff --git a/web/src/components/RequestPosterCard.tsx b/web/src/components/RequestPosterCard.tsx new file mode 100644 index 00000000..f2599c97 --- /dev/null +++ b/web/src/components/RequestPosterCard.tsx @@ -0,0 +1,370 @@ +import { Link } from "react-router"; +import { Check, Film, Loader2, Plus, Tv } from "lucide-react"; +import type { MediaRequest, RequestMediaResult } from "@/api/types"; +import { cn } from "@/lib/utils"; +import { formatRequestReason, formatRequestStatus, tmdbImageURL } from "@/lib/mediaRequests"; + +const POSTER_WIDTH = "w-[148px] sm:w-[164px] lg:w-[184px]"; + +type DiscoverProps = { + variant: "discover"; + item: RequestMediaResult; + isSubmitting: boolean; + onRequest: () => void; + /** When true, fills the parent (use inside grids). Default: fixed carousel width. */ + fluid?: boolean; +}; + +type MineProps = { + variant: "mine"; + request: MediaRequest; + fluid?: boolean; +}; + +export type RequestPosterCardProps = DiscoverProps | MineProps; + +export default function RequestPosterCard(props: RequestPosterCardProps) { + if (props.variant === "mine") { + return ; + } + return ( + + ); +} + +function DiscoverCard({ + item, + isSubmitting, + onRequest, + fluid, +}: { + item: RequestMediaResult; + isSubmitting: boolean; + onRequest: () => void; + fluid?: boolean; +}) { + const poster = tmdbImageURL(item.poster_path); + const requestable = item.request.requestable; + const statusLabel = item.request.status ? formatRequestStatus(item.request.status) : null; + const reasonLabel = + !requestable && !item.request.status ? formatRequestReason(item.request.reason) : null; + const availableInLibrary = item.availability === "available" && !item.request.status; + + const ribbon: { kind: RibbonKind; label: string } | null = statusLabel + ? { kind: (item.request.status as RibbonKind) ?? "pending", label: statusLabel } + : availableInLibrary + ? { kind: "completed", label: "In library" } + : reasonLabel + ? { kind: "blocked", label: reasonLabel } + : null; + + return ( +
    + + + {ribbon && } + + + + + + {requestable && ( +
    + +
    + )} +
    + ); +} + +function MineCard({ request, fluid }: { request: MediaRequest; fluid?: boolean }) { + const poster = tmdbImageURL(request.poster_path); + const isDownloading = request.status === "downloading"; + const isCompleted = request.status === "completed"; + const isFailed = + request.outcome === "failed" || + request.outcome === "declined" || + request.outcome === "cancelled"; + + const kind: RibbonKind = isFailed ? "blocked" : (request.status as RibbonKind); + const label = isFailed ? formatOutcome(request.outcome) : formatRequestStatus(request.status); + + return ( + + + + + {isDownloading && ( +
    +
    +
    + )} + {isCompleted && ( +
    + + + Ready to watch + +
    + )} + + + + + {request.last_error ? ( +

    + {request.last_error} +

    + ) : null} + + ); +} + +const ACCENT_BAR: Record = { + pending: "bg-amber-300/70", + approved: "bg-emerald-300/70", + queued: "bg-sky-300/70", + downloading: "bg-sky-300/70", + completed: "bg-emerald-300/70", + blocked: "bg-zinc-400/40", +}; + +function PosterFrame({ + poster, + title, + mediaType, + dim, + accent, + children, +}: { + poster: string | null; + title: string; + mediaType: "movie" | "series"; + dim?: boolean; + accent?: RibbonKind | null; + children?: React.ReactNode; +}) { + return ( +
    + {poster ? ( + {title + ) : ( + + )} + {/* subtle bottom vignette for legibility behind ribbons / hover overlays */} +
    + {/* thin status accent bar, hugs bottom edge */} + {accent && ( +
    + )} + {children} +
    + ); +} + +function PosterFallback({ + title, + mediaType, + dim, +}: { + title: string; + mediaType: "movie" | "series"; + dim?: boolean; +}) { + const hue = stringHue(title); + const Icon = mediaType === "series" ? Tv : Film; + return ( +
    +
    + +
    +
    +
    +
    + + {mediaType === "series" ? "Series" : "Motion picture"} + +

    + {title} +

    +
    +
    + ); +} + +function stringHue(input: string): number { + let hash = 0; + for (let i = 0; i < input.length; i++) { + hash = (Math.imul(hash, 31) + input.charCodeAt(i)) | 0; + } + return Math.abs(hash) % 360; +} + +function CardMeta({ + title, + year, + rating, + mediaType, +}: { + title: string; + year?: number; + rating?: number; + mediaType?: "movie" | "series"; +}) { + const Icon = mediaType === "series" ? Tv : Film; + const hasMeta = mediaType || year !== undefined || rating !== undefined; + return ( +
    +

    + {title} +

    + {hasMeta && ( +
    + {mediaType && ( + + )} + {year ? {year} : null} + {(year || mediaType) && rating ? ( + + · + + ) : null} + {rating ? ( + + {rating.toFixed(1)} + + ) : null} +
    + )} +
    + ); +} + +type RibbonKind = "pending" | "approved" | "queued" | "downloading" | "completed" | "blocked"; + +const RIBBON_STYLES: Record = { + pending: + "bg-amber-950/75 text-amber-100 ring-amber-400/30 [&_.dot]:bg-amber-300 [&_.dot]:animate-pulse", + approved: "bg-emerald-950/75 text-emerald-100 ring-emerald-400/30 [&_.dot]:bg-emerald-300", + queued: "bg-sky-950/75 text-sky-100 ring-sky-400/30 [&_.dot]:bg-sky-300 [&_.dot]:animate-pulse", + downloading: + "bg-sky-950/80 text-sky-100 ring-sky-400/35 [&_.dot]:bg-sky-300 [&_.dot]:animate-pulse", + completed: "bg-emerald-950/80 text-emerald-100 ring-emerald-400/30 [&_.dot]:bg-emerald-300", + blocked: "bg-zinc-900/80 text-zinc-200 ring-white/10 [&_.dot]:bg-zinc-400", +}; + +function StatusRibbon({ status, label }: { status: string; label: string }) { + const kind = (RIBBON_STYLES[status as RibbonKind] ? status : "blocked") as RibbonKind; + return ( + + + {label} + + ); +} + +function formatOutcome(outcome: MediaRequest["outcome"]): string { + switch (outcome) { + case "declined": + return "Declined"; + case "cancelled": + return "Cancelled"; + case "failed": + return "Failed"; + default: + return "Active"; + } +} diff --git a/web/src/hooks/queries/keys.ts b/web/src/hooks/queries/keys.ts index e386695f..c857ae6a 100644 --- a/web/src/hooks/queries/keys.ts +++ b/web/src/hooks/queries/keys.ts @@ -116,6 +116,27 @@ export const collectionKeys = { mdblistTop: () => ["collections", "mdblist", "top"] as const, }; +export const requestKeys = { + all: ["requests"] as const, + discovery: () => ["requests", "discovery"] as const, + discoverySection: (section: string, page: number) => + ["requests", "discovery", section, page] as const, + discoverStudios: () => ["requests", "discover", "studios"] as const, + discoverNetworks: () => ["requests", "discover", "networks"] as const, + discoverGenres: () => ["requests", "discover", "genres"] as const, + discoverBrowse: ( + kind: "studio" | "network" | "genre", + slug: string, + mediaType: string | undefined, + 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, + detail: (mediaType: string, tmdbID: number) => ["requests", "detail", mediaType, tmdbID] as const, + mine: (params: Record) => ["requests", "mine", params] as const, +}; + export const libraryCollectionKeys = { all: ["libraryCollections"] as const, list: (libraryId: number) => ["libraryCollections", "list", libraryId] as const, @@ -317,6 +338,11 @@ export const adminKeys = { stats: () => ["admin", "stats"] as const, sessions: () => ["admin", "sessions"] as const, serverSettings: () => ["admin", "serverSettings"] as const, + requestsRoot: () => ["admin", "requests"] as const, + requests: (params: Record) => ["admin", "requests", params] as const, + requestSettings: () => ["admin", "requests", "settings"] as const, + requestIntegrations: () => ["admin", "requests", "integrations"] as const, + requestUserLimit: (userId: number) => ["admin", "requests", "users", userId, "limit"] as const, recommendationsStatus: () => ["admin", "recommendationsStatus"] as const, inviteCodes: () => ["admin", "inviteCodes"] as const, apiKeys: () => ["admin", "apiKeys"] as const, diff --git a/web/src/hooks/queries/useRequests.ts b/web/src/hooks/queries/useRequests.ts new file mode 100644 index 00000000..dc85e2ba --- /dev/null +++ b/web/src/hooks/queries/useRequests.ts @@ -0,0 +1,352 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { api } from "@/api/client"; +import type { + CreateMediaRequestInput, + DiscoverBrowseKind, + DiscoverBrowseResponse, + DiscoverGenresResponse, + DiscoverNetworksResponse, + DiscoverStudiosResponse, + LoadRequestIntegrationOptionsRequest, + MediaRequest, + MediaRequestsListResponse, + RequestDiscoveryResponse, + RequestDiscoverySection, + RequestIntegration, + RequestIntegrationOptions, + RequestIntegrationsResponse, + RequestListParams, + RequestMediaDetail, + RequestMediaPage, + RequestSearchMediaType, + RequestMediaType, + RequestSettings, + RequestUserLimit, +} from "@/api/types"; +import { adminKeys, requestKeys } from "./keys"; + +const REQUESTS_STALE_TIME = 30_000; +const DISCOVER_BRAND_STALE_TIME = 24 * 60 * 60 * 1000; +const BROWSE_STALE_TIME = 60 * 1000; + +function listParamsKey(params: RequestListParams) { + return { + status: params.status ?? "all", + outcome: params.outcome ?? "all", + limit: params.limit ?? 50, + offset: params.offset ?? 0, + }; +} + +function buildListQuery(params: RequestListParams = {}) { + const query = new URLSearchParams(); + if (params.status && params.status !== "all") query.set("status", params.status); + if (params.outcome && params.outcome !== "all") query.set("outcome", params.outcome); + if (params.limit != null && params.limit > 0) query.set("limit", String(params.limit)); + if (params.offset != null && params.offset > 0) query.set("offset", String(params.offset)); + const encoded = query.toString(); + return encoded ? `?${encoded}` : ""; +} + +function invalidateRequestSurfaces(queryClient: ReturnType) { + queryClient.invalidateQueries({ queryKey: requestKeys.all }); + queryClient.invalidateQueries({ queryKey: adminKeys.requestsRoot() }); +} + +export function useRequestDiscovery() { + return useQuery({ + queryKey: requestKeys.discovery(), + queryFn: () => + api("/requests/discover").then((data) => data.sections ?? []), + staleTime: REQUESTS_STALE_TIME, + }); +} + +export function useRequestDiscoverySection(section: string, page = 1) { + return useQuery({ + queryKey: requestKeys.discoverySection(section, page), + queryFn: () => + api( + `/requests/discover/${encodeURIComponent(section)}?page=${page}`, + ), + enabled: section.trim().length > 0, + staleTime: REQUESTS_STALE_TIME, + }); +} + +export function useDiscoverStudios() { + return useQuery({ + queryKey: requestKeys.discoverStudios(), + queryFn: () => + api("/requests/discover/studios").then((data) => data.studios ?? []), + staleTime: DISCOVER_BRAND_STALE_TIME, + }); +} + +export function useDiscoverNetworks() { + return useQuery({ + queryKey: requestKeys.discoverNetworks(), + queryFn: () => + api("/requests/discover/networks").then( + (data) => data.networks ?? [], + ), + staleTime: DISCOVER_BRAND_STALE_TIME, + }); +} + +export function useDiscoverGenres() { + return useQuery({ + queryKey: requestKeys.discoverGenres(), + queryFn: () => + api("/requests/discover/genres").then((data) => data.genres ?? []), + staleTime: DISCOVER_BRAND_STALE_TIME, + }); +} + +export interface UseRequestBrowseArgs { + kind: DiscoverBrowseKind; + slug: string; + mediaType?: RequestMediaType; + sort: "popularity" | "vote_average" | "release_date"; + page: number; +} + +export function useRequestBrowse({ kind, slug, mediaType, sort, page }: UseRequestBrowseArgs) { + return useQuery({ + queryKey: requestKeys.discoverBrowse(kind, slug, mediaType, sort, page), + queryFn: () => { + const params = new URLSearchParams({ sort, page: String(page) }); + if (mediaType) params.set("media_type", mediaType); + return api( + `/requests/discover/browse/${kind}/${encodeURIComponent(slug)}?${params}`, + ); + }, + enabled: slug.trim().length > 0 && (kind !== "genre" || Boolean(mediaType)), + staleTime: BROWSE_STALE_TIME, + }); +} + +export function useRequestMediaDetail(mediaType: RequestMediaType, tmdbID: number) { + return useQuery({ + queryKey: requestKeys.detail(mediaType, tmdbID), + queryFn: () => + api( + `/requests/detail/${encodeURIComponent(mediaType)}/${encodeURIComponent(String(tmdbID))}`, + ), + enabled: tmdbID > 0, + staleTime: REQUESTS_STALE_TIME, + }); +} + +export function useRequestSearch(mediaType: RequestSearchMediaType, query: string, page = 1) { + const normalizedQuery = query.trim(); + return useQuery({ + queryKey: requestKeys.search(mediaType, normalizedQuery, page), + queryFn: () => { + const params = new URLSearchParams({ + q: normalizedQuery, + media_type: mediaType, + page: String(page), + }); + return api(`/requests/search?${params}`); + }, + enabled: normalizedQuery.length > 1, + staleTime: REQUESTS_STALE_TIME, + }); +} + +export function useCreateMediaRequest() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (body: CreateMediaRequestInput) => + api("/requests/", { + method: "POST", + body: JSON.stringify(body), + }), + onSuccess: () => { + toast.success("Request submitted"); + invalidateRequestSurfaces(queryClient); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to submit request"); + }, + }); +} + +export function useMyMediaRequests(params: RequestListParams = {}) { + const key = listParamsKey(params); + return useQuery({ + queryKey: requestKeys.mine(key), + queryFn: () => + api(`/requests/mine${buildListQuery(params)}`).then( + (data) => data.requests ?? [], + ), + staleTime: REQUESTS_STALE_TIME, + }); +} + +export function useAdminMediaRequests(params: RequestListParams = {}) { + const key = listParamsKey(params); + return useQuery({ + queryKey: adminKeys.requests(key), + queryFn: () => + api(`/admin/requests${buildListQuery(params)}`).then( + (data) => data.requests ?? [], + ), + staleTime: 10_000, + }); +} + +export function useApproveMediaRequest() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + api(`/admin/requests/${encodeURIComponent(id)}/approve`, { + method: "POST", + }), + onSuccess: () => { + toast.success("Request approved"); + invalidateRequestSurfaces(queryClient); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to approve request"); + }, + }); +} + +export function useDeclineMediaRequest() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, reason }: { id: string; reason?: string }) => + api(`/admin/requests/${encodeURIComponent(id)}/decline`, { + method: "POST", + body: JSON.stringify({ reason }), + }), + onSuccess: () => { + toast.success("Request declined"); + invalidateRequestSurfaces(queryClient); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to decline request"); + }, + }); +} + +export function useRetryMediaRequest() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + api(`/admin/requests/${encodeURIComponent(id)}/retry`, { + method: "POST", + }), + onSuccess: () => { + toast.success("Request queued for retry"); + invalidateRequestSurfaces(queryClient); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to retry request"); + }, + }); +} + +export function useRequestSettings() { + return useQuery({ + queryKey: adminKeys.requestSettings(), + queryFn: () => api("/admin/request-settings"), + staleTime: REQUESTS_STALE_TIME, + }); +} + +export function useUpdateRequestSettings() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (body: RequestSettings) => + api("/admin/request-settings", { + method: "PUT", + body: JSON.stringify(body), + }), + onSuccess: () => { + toast.success("Request settings saved"); + queryClient.invalidateQueries({ queryKey: adminKeys.requestSettings() }); + invalidateRequestSurfaces(queryClient); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to save request settings"); + }, + }); +} + +export function useRequestIntegrations() { + return useQuery({ + queryKey: adminKeys.requestIntegrations(), + queryFn: () => + api("/admin/request-integrations").then( + (data) => data.integrations ?? [], + ), + staleTime: REQUESTS_STALE_TIME, + }); +} + +export function useUpdateRequestIntegrations() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (integrations: RequestIntegration[]) => + api("/admin/request-integrations", { + method: "PUT", + body: JSON.stringify({ integrations }), + }), + onSuccess: () => { + toast.success("Integrations saved"); + queryClient.invalidateQueries({ queryKey: adminKeys.requestIntegrations() }); + invalidateRequestSurfaces(queryClient); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to save integrations"); + }, + }); +} + +export function useLoadRequestIntegrationOptions() { + return useMutation({ + mutationFn: ({ kind, body }: { kind: string; body: LoadRequestIntegrationOptionsRequest }) => + api( + `/admin/request-integrations/${encodeURIComponent(kind)}/options`, + { + method: "POST", + body: JSON.stringify(body), + }, + ), + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to load integration settings"); + }, + }); +} + +export function useRequestUserLimit(userId?: number) { + return useQuery({ + queryKey: adminKeys.requestUserLimit(userId ?? 0), + queryFn: () => api(`/admin/request-users/${userId}/limit`), + enabled: Boolean(userId && userId > 0), + staleTime: REQUESTS_STALE_TIME, + }); +} + +export function useUpdateRequestUserLimit() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ userId, body }: { userId: number; body: RequestUserLimit }) => + api(`/admin/request-users/${userId}/limit`, { + method: "PUT", + body: JSON.stringify(body), + }), + onSuccess: (_data, variables) => { + toast.success("User request limit saved"); + queryClient.invalidateQueries({ queryKey: adminKeys.requestUserLimit(variables.userId) }); + invalidateRequestSurfaces(queryClient); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to save user limit"); + }, + }); +} diff --git a/web/src/lib/documentTitle.ts b/web/src/lib/documentTitle.ts index aff3a67b..93e68a36 100644 --- a/web/src/lib/documentTitle.ts +++ b/web/src/lib/documentTitle.ts @@ -29,6 +29,7 @@ const ADMIN_TITLES: Record = { maintenance: "Admin Maintenance", nodes: "Admin Nodes", recommendations: "Admin Recommendations", + requests: "Admin Requests", sections: "Admin Sections", settings: "Admin Settings", tasks: "Admin Tasks", diff --git a/web/src/lib/mediaRequests.ts b/web/src/lib/mediaRequests.ts new file mode 100644 index 00000000..3dd4d256 --- /dev/null +++ b/web/src/lib/mediaRequests.ts @@ -0,0 +1,129 @@ +import type { + CreateMediaRequestInput, + MediaRequest, + MediaRequestOutcome, + MediaRequestStatus, + RequestMediaResult, + RequestMediaType, +} from "@/api/types"; + +export const REQUEST_STATUSES: Array = [ + "all", + "pending", + "approved", + "queued", + "downloading", + "completed", +]; + +export const REQUEST_OUTCOMES: Array = [ + "all", + "active", + "declined", + "cancelled", + "failed", +]; + +type BadgeVariant = "default" | "secondary" | "destructive" | "outline"; + +export function formatMediaType(mediaType: RequestMediaType): string { + return mediaType === "series" ? "Series" : "Movie"; +} + +export function formatRequestStatus(status?: MediaRequestStatus): string { + switch (status) { + case "pending": + return "Pending"; + case "approved": + return "Approved"; + case "queued": + return "Queued"; + case "downloading": + return "Downloading"; + case "completed": + return "Completed"; + default: + return "Requested"; + } +} + +export function requestStatusBadgeVariant(status?: MediaRequestStatus): BadgeVariant { + switch (status) { + case "completed": + return "default"; + case "pending": + return "outline"; + default: + return "secondary"; + } +} + +export function formatRequestOutcome(outcome?: MediaRequestOutcome): string { + switch (outcome) { + case "active": + return "Active"; + case "declined": + return "Declined"; + case "cancelled": + return "Cancelled"; + case "failed": + return "Failed"; + default: + return "Active"; + } +} + +export function requestOutcomeBadgeVariant(outcome?: MediaRequestOutcome): BadgeVariant { + switch (outcome) { + case "failed": + case "declined": + case "cancelled": + return "destructive"; + case "active": + return "secondary"; + default: + return "outline"; + } +} + +export function formatRequestReason(reason?: string): string { + switch (reason) { + case "already_requested": + return "Already requested"; + case "already_available": + return "Available"; + case "requests_disabled": + return "Requests disabled"; + case "blocked": + return "Blocked"; + case "quota_exceeded": + return "Limit reached"; + default: + return "Unavailable"; + } +} + +export function tmdbImageURL(path?: string, size = "w342"): string | null { + if (!path) return null; + return `https://image.tmdb.org/t/p/${size}${path}`; +} + +export function requestInputFromMediaResult(item: RequestMediaResult): CreateMediaRequestInput { + return { + media_type: item.media_type, + tmdb_id: item.tmdb_id, + title: item.title, + year: item.year || undefined, + overview: item.overview || undefined, + poster_path: item.poster_path || undefined, + backdrop_path: item.backdrop_path || undefined, + }; +} + +export function formatRequestDate(request: Pick): string { + return new Date(request.created_at).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); +} diff --git a/web/src/pages/AdminRequests.tsx b/web/src/pages/AdminRequests.tsx new file mode 100644 index 00000000..e7c339a6 --- /dev/null +++ b/web/src/pages/AdminRequests.tsx @@ -0,0 +1,1125 @@ +import { useMemo, useState } from "react"; +import type { ReactNode } from "react"; +import { Link, useSearchParams } from "react-router"; +import { Check, Plug, RefreshCw, Save, Settings2, SlidersHorizontal, X } from "lucide-react"; +import type { + MediaRequest, + MediaRequestOutcome, + MediaRequestStatus, + RequestApprovalMode, + RequestIntegration, + RequestIntegrationOptions, + RequestLimitMode, + RequestSettings, + RequestUserLimit, +} from "@/api/types"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useAdminUsers } from "@/hooks/queries/admin/users"; +import { + useAdminMediaRequests, + useApproveMediaRequest, + useDeclineMediaRequest, + useLoadRequestIntegrationOptions, + useRequestIntegrations, + useRequestSettings, + useRequestUserLimit, + useRetryMediaRequest, + useUpdateRequestIntegrations, + useUpdateRequestSettings, + useUpdateRequestUserLimit, +} from "@/hooks/queries/useRequests"; +import { + formatMediaType, + formatRequestDate, + formatRequestOutcome, + formatRequestStatus, + requestOutcomeBadgeVariant, + requestStatusBadgeVariant, + REQUEST_OUTCOMES, + REQUEST_STATUSES, +} from "@/lib/mediaRequests"; + +type StatusFilter = MediaRequestStatus | "all"; +type OutcomeFilter = MediaRequestOutcome | "all"; + +const ADMIN_REQUEST_TABS = ["queue", "settings", "integrations", "overrides"] as const; +type AdminRequestTab = (typeof ADMIN_REQUEST_TABS)[number]; + +function normalizeAdminRequestTab(value: string | null): AdminRequestTab { + return ADMIN_REQUEST_TABS.includes(value as AdminRequestTab) + ? (value as AdminRequestTab) + : "queue"; +} + +export default function AdminRequests() { + const [searchParams, setSearchParams] = useSearchParams(); + const activeTab = normalizeAdminRequestTab(searchParams.get("tab")); + + function setActiveTab(value: string) { + const nextTab = normalizeAdminRequestTab(value); + const next = new URLSearchParams(searchParams); + + if (nextTab === "queue") { + next.delete("tab"); + } else { + next.set("tab", nextTab); + } + + setSearchParams(next, { replace: true }); + } + + return ( +
    +
    +
    +

    + Requests +

    +

    + Review media requests, set limits, and manage Radarr or Sonarr routing. +

    +
    +
    + + + + Queue + Settings + Integrations + User Overrides + + + + + + + + + + + + + + +
    + ); +} + +function RequestQueueTab() { + const [status, setStatus] = useState("all"); + const [outcome, setOutcome] = useState("all"); + const requests = useAdminMediaRequests({ status, outcome, limit: 100 }); + const users = useAdminUsers(); + const approve = useApproveMediaRequest(); + const decline = useDeclineMediaRequest(); + const retry = useRetryMediaRequest(); + const [declineTarget, setDeclineTarget] = useState(null); + const [declineReason, setDeclineReason] = useState(""); + const usernamesByID = useMemo(() => { + return new Map((users.data ?? []).map((user) => [user.id, user.username])); + }, [users.data]); + + function handleDecline(request: MediaRequest) { + setDeclineTarget(request); + setDeclineReason(""); + } + + function confirmDecline() { + if (!declineTarget) return; + decline.mutate({ id: declineTarget.id, reason: declineReason }); + setDeclineTarget(null); + setDeclineReason(""); + } + + return ( +
    +
    + + + +
    + + {requests.isLoading ? ( + + ) : requests.isError ? ( + + ) : ( +
    + + + + Title + Requested + Status + Outcome + Integration + Actions + + + + {(requests.data ?? []).length === 0 ? ( + + + No requests match the current filters. + + + ) : ( + requests.data?.map((request) => ( + approve.mutate(request.id)} + onDecline={() => handleDecline(request)} + onRetry={() => retry.mutate(request.id)} + /> + )) + )} + +
    +
    + )} + { + if (!open) { + setDeclineTarget(null); + setDeclineReason(""); + } + }} + > + + + Decline request + + {declineTarget + ? `"${declineTarget.title}" will be marked declined. Add an optional note for the requester.` + : null} + + + +