From 246c9da6abf2fdb7add85fafd84651b475c01290 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 13:58:12 -0400 Subject: [PATCH 01/53] feat(requests): add media request system with Radarr/Sonarr fulfillment - Add request domain, repository, service, and reconcile task - Add Radarr/Sonarr fulfillment adapters and TMDB discovery - Expose user and admin request APIs with quota and approval rules - Add web UI for browsing, requesting, and admin queue management - Migration 139 introduces media_requests and related tables --- cmd/silo/main.go | 11 + .../plans/request-system-implementation.md | 218 ++++ docs/superpowers/specs/request-system.md | 417 +++++++ internal/api/handlers/admin.go | 2 + internal/api/handlers/requests.go | 464 ++++++++ internal/api/router.go | 43 + internal/metadata/tmdb/client.go | 150 +++ internal/metadata/tmdb/client_test.go | 97 ++ internal/metadata/tmdb/types.go | 46 + internal/requests/arrclient/client.go | 98 ++ internal/requests/arrclient/options.go | 34 + internal/requests/arrclient/queue.go | 77 ++ internal/requests/errors.go | 30 + internal/requests/presence.go | 45 + internal/requests/radarr/client.go | 255 ++++ internal/requests/radarr/client_test.go | 148 +++ internal/requests/repository.go | 683 +++++++++++ internal/requests/service.go | 1041 +++++++++++++++++ internal/requests/service_test.go | 563 +++++++++ internal/requests/sonarr/client.go | 270 +++++ internal/requests/sonarr/client_test.go | 150 +++ internal/requests/store.go | 34 + internal/requests/types.go | 245 ++++ .../taskmanager/tasks/reconcile_requests.go | 59 + migrations/139_media_requests.down.sql | 10 + migrations/139_media_requests.up.sql | 114 ++ web/src/App.tsx | 5 + web/src/api/types.ts | 161 +++ web/src/app.css | 22 + web/src/components/AdminSidebar.tsx | 6 + web/src/components/AppSidebar.tsx | 18 + web/src/components/RequestPosterCard.tsx | 258 ++++ web/src/hooks/queries/keys.ts | 15 + web/src/hooks/queries/requests.ts | 279 +++++ web/src/lib/documentTitle.ts | 1 + web/src/lib/mediaRequests.ts | 129 ++ web/src/pages/AdminRequests.tsx | 1039 ++++++++++++++++ web/src/pages/Requests.tsx | 568 +++++++++ 38 files changed, 7805 insertions(+) create mode 100644 docs/superpowers/plans/request-system-implementation.md create mode 100644 docs/superpowers/specs/request-system.md create mode 100644 internal/api/handlers/requests.go create mode 100644 internal/requests/arrclient/client.go create mode 100644 internal/requests/arrclient/options.go create mode 100644 internal/requests/arrclient/queue.go create mode 100644 internal/requests/errors.go create mode 100644 internal/requests/presence.go create mode 100644 internal/requests/radarr/client.go create mode 100644 internal/requests/radarr/client_test.go create mode 100644 internal/requests/repository.go create mode 100644 internal/requests/service.go create mode 100644 internal/requests/service_test.go create mode 100644 internal/requests/sonarr/client.go create mode 100644 internal/requests/sonarr/client_test.go create mode 100644 internal/requests/store.go create mode 100644 internal/requests/types.go create mode 100644 internal/taskmanager/tasks/reconcile_requests.go create mode 100644 migrations/139_media_requests.down.sql create mode 100644 migrations/139_media_requests.up.sql create mode 100644 web/src/components/RequestPosterCard.tsx create mode 100644 web/src/hooks/queries/requests.ts create mode 100644 web/src/lib/mediaRequests.ts create mode 100644 web/src/pages/AdminRequests.tsx create mode 100644 web/src/pages/Requests.tsx diff --git a/cmd/silo/main.go b/cmd/silo/main.go index 487745f7..3fba3a15 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,14 @@ func main() { if watchProviderService != nil { taskMgr.Register(tasks.NewSyncWatchProvidersTask(watchProviderService)) } + requestReconcileSvc := mediarequests.NewService( + mediarequests.NewRepository(deps.DB), + nil, + mediarequests.NewCatalogPresence(catalog.NewItemRepository(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/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/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/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..51152e63 --- /dev/null +++ b/internal/api/handlers/requests.go @@ -0,0 +1,464 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "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) + 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) +} + +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) 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, false) + 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 { + _ = json.NewDecoder(r.Body).Decode(&body) + } + 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) 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 := make([]mediarequests.Integration, 0, len(body.Integrations)) + for _, integration := range body.Integrations { + result, err := h.service.UpsertIntegration(r.Context(), viewer, integration) + if err != nil { + writeRequestServiceError(w, err) + return + } + updated = append(updated, *result) + } + 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/router.go b/internal/api/router.go index 6b73b108..75e2ffad 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), + ) + 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,18 @@ 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/{section}", requestHandler.HandleDiscoverSection) + r.Post("/", requestHandler.HandleCreate) + r.Get("/mine", requestHandler.HandleListMine) + r.Get("/{id}", requestHandler.HandleGet) + }) + } + // Settings routes (user-scoped, no profile required). if settingsHandler != nil { r.Route("/settings", func(r chi.Router) { @@ -1869,6 +1898,20 @@ 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}/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/metadata/tmdb/client.go b/internal/metadata/tmdb/client.go index 8a871929..614267e5 100644 --- a/internal/metadata/tmdb/client.go +++ b/internal/metadata/tmdb/client.go @@ -133,6 +133,156 @@ func retryAfterOrDefault(resp *http.Response, attempt int) time.Duration { return time.Duration(1<= 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 fmt.Errorf("arr: decode response: %w", 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..50251d35 --- /dev/null +++ b/internal/requests/arrclient/queue.go @@ -0,0 +1,77 @@ +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 + 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" { + return QueueEvaluation{State: QueueStateDownloading, ExternalStatus: externalStatus} + } + queued = true + } + 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/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..9cdc5ab0 --- /dev/null +++ b/internal/requests/presence.go @@ -0,0 +1,45 @@ +package requests + +import ( + "context" + "strconv" + + "github.com/Silo-Server/silo-server/internal/catalog" +) + +type PresenceResolver interface { + LookupTMDB(ctx context.Context, mediaType MediaType, tmdbIDs []int) (map[int]bool, error) +} + +type CatalogPresence struct { + items *catalog.ItemRepository +} + +func NewCatalogPresence(items *catalog.ItemRepository) *CatalogPresence { + return &CatalogPresence{items: items} +} + +func (p *CatalogPresence) LookupTMDB(ctx context.Context, mediaType MediaType, tmdbIDs []int) (map[int]bool, error) { + out := map[int]bool{} + if p == nil || p.items == nil || len(tmdbIDs) == 0 { + return out, nil + } + ids := make([]string, 0, len(tmdbIDs)) + for _, id := range tmdbIDs { + if id > 0 { + ids = append(ids, strconv.Itoa(id)) + } + } + internalType := string(mediaType) + rows, err := p.items.LookupTMDBIDs(ctx, internalType, ids) + if err != nil { + return nil, err + } + for _, row := range rows { + id, err := strconv.Atoi(row.TMDBID) + if err == nil { + out[id] = true + } + } + return out, nil +} diff --git a/internal/requests/radarr/client.go b/internal/requests/radarr/client.go new file mode 100644 index 00000000..760e6105 --- /dev/null +++ b/internal/requests/radarr/client.go @@ -0,0 +1,255 @@ +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"` +} + +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 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 := c.rootFolders(ctx, client) + if err != nil { + return nil, err + } + qualityProfiles, err := c.qualityProfiles(ctx, client) + if err != nil { + return nil, err + } + tags, err := c.tags(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) + existing, err := c.lookupExisting(ctx, client, req.TMDBID) + if err != nil { + return mediarequests.FulfillmentResult{}, err + } + if existing != nil { + return resultFromMovie(*existing), nil + } + + 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 { + return mediarequests.FulfillmentResult{}, err + } + return resultFromMovie(created), nil +} + +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 { + existing, err := c.lookupExisting(ctx, client, req.TMDBID) + if err != nil { + return mediarequests.FulfillmentStatus{}, err + } + if existing == nil { + return mediarequests.FulfillmentStatus{ + Status: mediarequests.StatusQueued, + IntegrationKind: "radarr", + ExternalStatus: "missing_from_radarr", + }, nil + } + movieID = existing.ID + } + + queues, err := c.queueDetails(ctx, client, movieID) + if err != nil { + return mediarequests.FulfillmentStatus{}, err + } + evaluation := arrclient.EvaluateQueue(queues) + return statusFromQueueEvaluation("radarr", movieID, evaluation), nil +} + +func (c *Client) lookupExisting(ctx context.Context, client *arrclient.Client, tmdbID int) (*movieResource, error) { + path := "/api/v3/movie?tmdbId=" + strconv.Itoa(tmdbID) + var movies []movieResource + if err := client.GetJSON(ctx, path, &movies); err != nil { + return nil, err + } + if len(movies) == 0 { + return nil, nil + } + return &movies[0], nil +} + +func (c *Client) lookupMovie(ctx context.Context, client *arrclient.Client, tmdbID int) (movieResource, error) { + values := url.Values{} + values.Set("tmdbId", strconv.Itoa(tmdbID)) + 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 (c *Client) rootFolders(ctx context.Context, client *arrclient.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 (c *Client) qualityProfiles(ctx context.Context, client *arrclient.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 (c *Client) tags(ctx context.Context, client *arrclient.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 +} + +func resultFromMovie(movie movieResource) mediarequests.FulfillmentResult { + return mediarequests.FulfillmentResult{ + IntegrationKind: "radarr", + ExternalID: strconv.Itoa(movie.ID), + ExternalStatus: "queued", + } +} + +func statusFromQueueEvaluation(kind string, externalID int, evaluation arrclient.QueueEvaluation) mediarequests.FulfillmentStatus { + status := mediarequests.StatusQueued + outcome := mediarequests.Outcome("") + if evaluation.State == arrclient.QueueStateDownloading { + status = mediarequests.StatusDownloading + } + if evaluation.State == arrclient.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/radarr/client_test.go b/internal/requests/radarr/client_test.go new file mode 100644 index 00000000..389c398d --- /dev/null +++ b/internal/requests/radarr/client_test.go @@ -0,0 +1,148 @@ +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 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..441bbb1f --- /dev/null +++ b/internal/requests/repository.go @@ -0,0 +1,683 @@ +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) 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) + + 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) { + 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 := r.pool.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, fmt.Errorf("upsert request integration: %w", 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 { + _ = json.Unmarshal(optionsRaw, &integration.Options) + } + 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..a4280f62 --- /dev/null +++ b/internal/requests/service.go @@ -0,0 +1,1041 @@ +package requests + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/Silo-Server/silo-server/internal/idgen" + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" +) + +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) +} + +type TMDBExternalIDClient interface { + GetExternalIDs(ctx context.Context, mediaType string, id int) (*tmdb.ExternalIDs, error) +} + +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 := normalizeMediaType(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, 0, len(discoverySectionOrder)) + for _, key := range discoverySectionOrder { + section, err := s.Discover(ctx, viewer, key, 1) + if err != nil { + return nil, err + } + sections = append(sections, *section) + } + return sections, 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) + + available, err := s.lookupAvailable(ctx, normalized.MediaType, []int{normalized.TMDBID}) + if err != nil { + return nil, err + } + if available[normalized.TMDBID] { + 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 + } + + 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 { + return nil, err + } + if configured { + status = StatusApproved + } + } + req, err := s.store.CreateRequest(ctx, CreateRequestRecord{ + ID: id, + Input: normalized, + Status: status, + Outcome: OutcomeActive, + Requester: viewer, + Now: s.now(), + }) + if err != nil { + if errors.Is(err, ErrAlreadyRequested) { + return nil, ErrAlreadyRequested + } + 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 + } + if req.Outcome != OutcomeActive || req.Status == StatusCompleted { + return nil, ErrInvalidState + } + return s.store.SetOutcome(ctx, req.ID, OutcomeDeclined, 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 { + return s.submitApprovedRequest(ctx, *active, 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 { + 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) 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) lookupAvailable(ctx context.Context, mediaType MediaType, ids []int) (map[int]bool, error) { + if s.presence == nil { + return map[int]bool{}, nil + } + return s.presence.LookupTMDB(ctx, mediaType, ids) +} + +func (s *Service) enrichExternalIDs(ctx context.Context, input *CreateRequestInput) { + if input == nil { + return + } + client, ok := s.tmdb.(TMDBExternalIDClient) + if !ok { + return + } + mediaType := "movie" + if input.MediaType == MediaTypeSeries { + mediaType = "tv" + } + externalIDs, err := client.GetExternalIDs(ctx, 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) { + available, err := s.lookupAvailable(ctx, req.MediaType, []int{req.TMDBID}) + if err != nil { + return false, err + } + return available[req.TMDBID], 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 := integration.Tags[:0] + 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 normalizeListFilter(filter ListFilter) ListFilter { + if filter.Limit <= 0 || filter.Limit > 100 { + filter.Limit = 50 + } + 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..63b864c9 --- /dev/null +++ b/internal/requests/service_test.go @@ -0,0 +1,563 @@ +package requests + +import ( + "context" + "errors" + "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 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 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 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 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 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 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 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 { + settings Settings + limit *UserLimit + count int + active map[MediaType]map[int]*Request + created []CreateRequestRecord + integrations []Integration + queued []QueueUpdate + candidates []*Request + statusUpdates []Status +} + +func newFakeStore() *fakeStore { + return &fakeStore{ + settings: Settings{ + GlobalMaxRequests: 5, + GlobalWindowDays: 7, + }, + active: map[MediaType]map[int]*Request{ + MediaTypeMovie: {}, + MediaTypeSeries: {}, + }, + } +} + +func (f *fakeStore) GetSettings(context.Context) (Settings, error) { + return f.settings, nil +} + +func (f *fakeStore) UpdateSettings(_ context.Context, settings Settings) (Settings, error) { + f.settings = settings + return settings, nil +} + +func (f *fakeStore) GetUserLimit(context.Context, int) (*UserLimit, error) { + return f.limit, nil +} + +func (f *fakeStore) UpsertUserLimit(_ context.Context, limit UserLimit) (*UserLimit, error) { + f.limit = &limit + return &limit, nil +} + +func (f *fakeStore) CountUserRequestsSince(context.Context, int, time.Time) (int, error) { + return f.count, nil +} + +func (f *fakeStore) ListActiveByTMDB(_ context.Context, mediaType MediaType, ids []int) (map[int]*Request, error) { + 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) CreateRequest(_ context.Context, input CreateRequestRecord) (*Request, error) { + 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, string) (*Request, error) { + return nil, ErrNotFound +} + +func (f *fakeStore) ListReconciliationCandidates(context.Context, int) ([]*Request, error) { + 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.statusUpdates = append(f.statusUpdates, status) + return &Request{ + ID: id, + Status: status, + Outcome: OutcomeActive, + }, nil +} + +func (f *fakeStore) MarkQueued(_ context.Context, id string, update QueueUpdate, _ Viewer) (*Request, error) { + f.queued = append(f.queued, update) + return &Request{ + ID: id, + Status: StatusQueued, + Outcome: OutcomeActive, + IntegrationKind: update.IntegrationKind, + ExternalID: update.ExternalID, + ExternalStatus: update.ExternalStatus, + }, nil +} + +func (f *fakeStore) SetOutcome(_ context.Context, id string, outcome Outcome, _ Viewer, message string) (*Request, error) { + return &Request{ + ID: id, + Outcome: outcome, + LastError: message, + }, nil +} + +func (f *fakeStore) ListIntegrations(context.Context) ([]Integration, error) { + return f.integrations, nil +} + +func (f *fakeStore) UpsertIntegration(context.Context, Integration) (*Integration, error) { + return nil, nil +} + +type fakePresence struct { + available map[MediaType]map[int]bool +} + +func (f *fakePresence) LookupTMDB(_ context.Context, mediaType MediaType, ids []int) (map[int]bool, error) { + out := map[int]bool{} + if f.available == nil { + return out, nil + } + for _, id := range ids { + if f.available[mediaType][id] { + out[id] = true + } + } + return out, nil +} + +type fakeTMDBClient struct { + page *tmdb.MediaPage + externalIDs *tmdb.ExternalIDs +} + +func (f *fakeTMDBClient) SearchMedia(context.Context, string, string, int) (*tmdb.MediaPage, error) { + return f.page, nil +} + +func (f *fakeTMDBClient) DiscoverSection(context.Context, string, int) (*tmdb.MediaPage, error) { + return f.page, nil +} + +func (f *fakeTMDBClient) GetExternalIDs(context.Context, string, int) (*tmdb.ExternalIDs, error) { + return f.externalIDs, 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 +} diff --git a/internal/requests/sonarr/client.go b/internal/requests/sonarr/client.go new file mode 100644 index 00000000..d02adf5d --- /dev/null +++ b/internal/requests/sonarr/client.go @@ -0,0 +1,270 @@ +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"` +} + +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 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 := c.rootFolders(ctx, client) + if err != nil { + return nil, err + } + qualityProfiles, err := c.qualityProfiles(ctx, client) + if err != nil { + return nil, err + } + tags, err := c.tags(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) + existing, err := c.lookupExisting(ctx, client, *req.TVDBID) + if err != nil { + return mediarequests.FulfillmentResult{}, err + } + if existing != nil { + return resultFromSeries(*existing), nil + } + + 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 { + return mediarequests.FulfillmentResult{}, err + } + return resultFromSeries(created), nil +} + +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 { + if req.TVDBID == nil || *req.TVDBID <= 0 { + return mediarequests.FulfillmentStatus{}, fmt.Errorf("sonarr: tvdb_id is required") + } + existing, err := c.lookupExisting(ctx, client, *req.TVDBID) + if err != nil { + return mediarequests.FulfillmentStatus{}, err + } + if existing == nil { + return mediarequests.FulfillmentStatus{ + Status: mediarequests.StatusQueued, + IntegrationKind: "sonarr", + ExternalStatus: "missing_from_sonarr", + }, nil + } + seriesID = existing.ID + } + + queues, err := c.queueDetails(ctx, client, seriesID) + if err != nil { + return mediarequests.FulfillmentStatus{}, err + } + evaluation := arrclient.EvaluateQueue(queues) + return statusFromQueueEvaluation("sonarr", seriesID, evaluation), nil +} + +func (c *Client) lookupExisting(ctx context.Context, client *arrclient.Client, tvdbID int) (*seriesResource, error) { + path := "/api/v3/series?tvdbId=" + strconv.Itoa(tvdbID) + var series []seriesResource + if err := client.GetJSON(ctx, path, &series); err != nil { + return nil, err + } + if len(series) == 0 { + return nil, nil + } + return &series[0], 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 + } + } + if len(matches) > 0 { + return matches[0], 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 (c *Client) rootFolders(ctx context.Context, client *arrclient.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 (c *Client) qualityProfiles(ctx context.Context, client *arrclient.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 (c *Client) tags(ctx context.Context, client *arrclient.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 +} + +func resultFromSeries(series seriesResource) mediarequests.FulfillmentResult { + return mediarequests.FulfillmentResult{ + IntegrationKind: "sonarr", + ExternalID: strconv.Itoa(series.ID), + ExternalStatus: "queued", + } +} + +func statusFromQueueEvaluation(kind string, externalID int, evaluation arrclient.QueueEvaluation) mediarequests.FulfillmentStatus { + status := mediarequests.StatusQueued + outcome := mediarequests.Outcome("") + if evaluation.State == arrclient.QueueStateDownloading { + status = mediarequests.StatusDownloading + } + if evaluation.State == arrclient.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/sonarr/client_test.go b/internal/requests/sonarr/client_test.go new file mode 100644 index 00000000..e1a077b9 --- /dev/null +++ b/internal/requests/sonarr/client_test.go @@ -0,0 +1,150 @@ +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 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..205f2b6b --- /dev/null +++ b/internal/requests/store.go @@ -0,0 +1,34 @@ +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) + 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) +} + +type CreateRequestRecord struct { + ID string + Input CreateRequestInput + Status Status + Outcome Outcome + Requester Viewer + Now time.Time +} diff --git a/internal/requests/types.go b/internal/requests/types.go new file mode 100644 index 00000000..92c70375 --- /dev/null +++ b/internal/requests/types.go @@ -0,0 +1,245 @@ +package requests + +import ( + "time" +) + +type MediaType string + +const ( + MediaTypeMovie MediaType = "movie" + MediaTypeSeries MediaType = "series" +) + +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 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/web/src/App.tsx b/web/src/App.tsx index 24eb78c6..4cfd6df7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -32,10 +32,12 @@ 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 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 +218,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 +353,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -443,6 +447,7 @@ 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..928cab98 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"; @@ -586,6 +587,23 @@ export default function AppSidebar({ onNavigate, collapsed = false }: AppSidebar Collections +
  • + + {isActive("/requests") && ( + + )} + + Requests + +
  • void; +}; + +type MineProps = { + variant: "mine"; + request: MediaRequest; +}; + +export type RequestPosterCardProps = DiscoverProps | MineProps; + +export default function RequestPosterCard(props: RequestPosterCardProps) { + if (props.variant === "mine") { + return ; + } + return ( + + ); +} + +function DiscoverCard({ + item, + isSubmitting, + onRequest, +}: { + item: RequestMediaResult; + isSubmitting: boolean; + onRequest: () => void; +}) { + 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; + + return ( +
    + + + + {/* Status ribbon for already-requested or in-library items */} + {statusLabel ? ( + + ) : availableInLibrary ? ( + + ) : reasonLabel ? ( + + ) : null} + + {/* Hover Request overlay — only for requestable items */} + {requestable && ( +
    + +
    + )} +
    + + +
    + ); +} + +function MineCard({ request }: { request: MediaRequest }) { + 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"; + + return ( +
    + + + + + {/* Animated indicator at the bottom of the poster */} + {isDownloading && ( +
    +
    +
    + )} + {isCompleted && ( +
    + + + Ready to watch + +
    + )} + + + + + {request.last_error ? ( +

    + {request.last_error} +

    + ) : null} +
    + ); +} + +function PosterFrame({ + poster, + title, + mediaType, + dim, + children, +}: { + poster: string | null; + title: string; + mediaType: "movie" | "series"; + dim?: boolean; + children?: React.ReactNode; +}) { + return ( +
    + {poster ? ( + + ) : ( +
    + {mediaType === "series" ? : } + {title} +
    + )} + {/* subtle bottom vignette for legibility behind badges */} +
    + {children} +
    + ); +} + +function TypeBadge({ mediaType }: { mediaType: "movie" | "series" }) { + return ( + + {mediaType === "series" ? "Series" : "Movie"} + + ); +} + +function CardMeta({ title, year, rating }: { title: string; year?: number; rating?: number }) { + return ( +
    +

    + {title} +

    +
    + {year ? {year} : null} + {year && rating ? · : null} + {rating ? ( + + {rating.toFixed(1)} + + ) : null} +
    +
    + ); +} + +type RibbonKind = "pending" | "approved" | "queued" | "downloading" | "completed" | "blocked"; + +const RIBBON_STYLES: Record = { + pending: + "bg-amber-500/15 text-amber-100 ring-amber-400/40 [&_.dot]:bg-amber-300 [&_.dot]:animate-pulse", + approved: "bg-emerald-500/15 text-emerald-100 ring-emerald-400/40 [&_.dot]:bg-emerald-300", + queued: "bg-sky-500/15 text-sky-100 ring-sky-400/40 [&_.dot]:bg-sky-300 [&_.dot]:animate-pulse", + downloading: + "bg-sky-500/20 text-sky-100 ring-sky-400/50 [&_.dot]:bg-sky-300 [&_.dot]:animate-pulse", + completed: "bg-emerald-500/20 text-emerald-100 ring-emerald-400/40 [&_.dot]:bg-emerald-300", + blocked: "bg-zinc-700/60 text-zinc-200 ring-zinc-500/40 [&_.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..53e18432 100644 --- a/web/src/hooks/queries/keys.ts +++ b/web/src/hooks/queries/keys.ts @@ -116,6 +116,16 @@ 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, + search: (mediaType: string, query: string, page: number) => + ["requests", "search", mediaType, query, page] 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 +327,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/requests.ts b/web/src/hooks/queries/requests.ts new file mode 100644 index 00000000..bf5ece6f --- /dev/null +++ b/web/src/hooks/queries/requests.ts @@ -0,0 +1,279 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { api } from "@/api/client"; +import type { + CreateMediaRequestInput, + LoadRequestIntegrationOptionsRequest, + MediaRequest, + MediaRequestsListResponse, + RequestDiscoveryResponse, + RequestDiscoverySection, + RequestIntegration, + RequestIntegrationOptions, + RequestIntegrationsResponse, + RequestListParams, + RequestMediaPage, + RequestMediaType, + RequestSettings, + RequestUserLimit, +} from "@/api/types"; +import { adminKeys, requestKeys } from "./keys"; + +const REQUESTS_STALE_TIME = 30_000; + +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) query.set("limit", String(params.limit)); + if (params.offset) 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 useRequestSearch(mediaType: RequestMediaType, 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..d1b89e5d --- /dev/null +++ b/web/src/pages/AdminRequests.tsx @@ -0,0 +1,1039 @@ +import { useMemo, useState } from "react"; +import type { ReactNode } from "react"; +import { 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 { 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/requests"; +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 approve = useApproveMediaRequest(); + const decline = useDeclineMediaRequest(); + const retry = useRetryMediaRequest(); + + function handleDecline(request: MediaRequest) { + const reason = window.prompt(`Decline "${request.title}"?`, ""); + if (reason === null) return; + decline.mutate({ id: request.id, reason }); + } + + 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)} + /> + )) + )} + +
    +
    + )} +
    + ); +} + +function RequestQueueRow({ + request, + approving, + declining, + retrying, + onApprove, + onDecline, + onRetry, +}: { + request: MediaRequest; + approving: boolean; + declining: boolean; + retrying: boolean; + onApprove: () => void; + onDecline: () => void; + onRetry: () => void; +}) { + const canApprove = request.status === "pending" && request.outcome === "active"; + const canDecline = request.status !== "completed" && request.outcome === "active"; + const canRetry = request.outcome === "failed"; + + return ( + + +
    +
    + {request.title} + {formatMediaType(request.media_type)} +
    +
    + {request.year ? {request.year} : null} + TMDB {request.tmdb_id} + {request.requested_by_user_id ? User {request.requested_by_user_id} : null} +
    + {request.last_error ? ( +

    {request.last_error}

    + ) : null} +
    +
    + {formatRequestDate(request)} + + + {formatRequestStatus(request.status)} + + + + + {formatRequestOutcome(request.outcome)} + + + + {request.integration_kind || "Not submitted"} + {request.external_status ? {request.external_status} : null} + + +
    + + + +
    +
    +
    + ); +} + +type SettingsFormState = { + requests_enabled: boolean; + global_max_requests: string; + global_window_days: string; + global_auto_approval_enabled: boolean; + updated_at: string; +}; + +function RequestSettingsTab() { + const settings = useRequestSettings(); + + if (settings.isLoading) return ; + if (settings.isError) { + return ; + } + if (!settings.data) { + return ; + } + + return ; +} + +function RequestSettingsForm({ settings }: { settings: RequestSettings }) { + const updateSettings = useUpdateRequestSettings(); + const [form, setForm] = useState(() => ({ + requests_enabled: settings.requests_enabled, + global_max_requests: String(settings.global_max_requests), + global_window_days: String(settings.global_window_days), + global_auto_approval_enabled: settings.global_auto_approval_enabled, + updated_at: settings.updated_at, + })); + + function saveSettings() { + const payload: RequestSettings = { + requests_enabled: form.requests_enabled, + global_max_requests: Math.max(0, Number(form.global_max_requests) || 0), + global_window_days: Math.max(1, Number(form.global_window_days) || 1), + global_auto_approval_enabled: form.global_auto_approval_enabled, + updated_at: form.updated_at, + }; + updateSettings.mutate(payload); + } + + return ( +
    +
    + +

    Global Settings

    +
    + +
    + + setForm((current) => ({ ...current, requests_enabled: checked })) + } + /> + + setForm((current) => ({ ...current, global_auto_approval_enabled: checked })) + } + /> + + + setForm((current) => ({ ...current, global_max_requests: event.target.value })) + } + /> + + + + setForm((current) => ({ ...current, global_window_days: event.target.value })) + } + /> + +
    + + +
    + ); +} + +type IntegrationFormState = { + kind: "radarr" | "sonarr"; + enabled: boolean; + base_url: string; + api_key_ref: string; + root_folder: string; + quality_profile_id: string; + tags: string; + search_on_add: boolean; + minimum_availability: string; + series_type: string; + season_folder: boolean; + has_api_key: boolean; +}; + +const INTEGRATION_KINDS: Array<"radarr" | "sonarr"> = ["radarr", "sonarr"]; + +const MINIMUM_AVAILABILITY_OPTIONS = [ + { value: "announced", label: "Announced" }, + { value: "inCinemas", label: "In Cinemas" }, + { value: "released", label: "Released" }, + { value: "preDB", label: "PreDB" }, +] as const; + +function RequestIntegrationsTab() { + const integrations = useRequestIntegrations(); + + if (integrations.isLoading) return ; + if (integrations.isError) { + return ( + + ); + } + + return ( + + ); +} + +function RequestIntegrationsForm({ integrations }: { integrations: RequestIntegration[] }) { + const updateIntegrations = useUpdateRequestIntegrations(); + const [forms, setForms] = useState(() => + INTEGRATION_KINDS.map((kind) => + integrationToForm( + kind, + integrations.find((integration) => integration.kind === kind), + ), + ), + ); + + function updateForm(kind: "radarr" | "sonarr", patch: Partial) { + setForms((current) => + current.map((form) => (form.kind === kind ? { ...form, ...patch } : form)), + ); + } + + function saveIntegrations() { + updateIntegrations.mutate(forms.map(formToIntegration)); + } + + return ( +
    +
    + {forms.map((form) => ( + updateForm(form.kind, patch)} + /> + ))} +
    + +
    + ); +} + +function integrationsFormKey(integrations: RequestIntegration[]): string { + if (integrations.length === 0) return "empty"; + return integrations + .map( + (integration) => + `${integration.kind}:${integration.updated_at ?? ""}:${integration.enabled}:${integration.has_api_key}`, + ) + .join("|"); +} + +function IntegrationEditor({ + form, + onChange, +}: { + form: IntegrationFormState; + onChange: (patch: Partial) => void; +}) { + const title = form.kind === "radarr" ? "Radarr" : "Sonarr"; + const loadOptions = useLoadRequestIntegrationOptions(); + const [options, setOptions] = useState(null); + const rootFolders = rootFolderChoices(options, form.root_folder); + const qualityProfiles = qualityProfileChoices(options, form.quality_profile_id); + const tags = options?.tags ?? []; + const selectedTags = parseTags(form.tags); + const canLoadOptions = + form.base_url.trim().length > 0 && Boolean(form.api_key_ref.trim() || form.has_api_key); + + async function handleLoadOptions() { + const loaded = await loadOptions.mutateAsync({ + kind: form.kind, + body: { + base_url: form.base_url, + api_key_ref: form.api_key_ref.trim() || undefined, + }, + }); + setOptions(loaded); + + const patch: Partial = {}; + if (!form.root_folder && loaded.root_folders[0]?.path) { + patch.root_folder = loaded.root_folders[0].path; + } + if (!form.quality_profile_id && loaded.quality_profiles[0]?.id) { + patch.quality_profile_id = String(loaded.quality_profiles[0].id); + } + if (Object.keys(patch).length > 0) { + onChange(patch); + } + } + + return ( +
    +
    +
    + +

    {title}

    + {form.has_api_key ? Key saved : null} +
    +
    + + onChange({ enabled })} /> +
    +
    + +
    + + onChange({ base_url: event.target.value })} + placeholder="http://localhost:7878" + /> + + + onChange({ api_key_ref: event.target.value })} + placeholder={form.has_api_key ? "Leave blank to keep saved key" : "API key"} + /> + + + {rootFolders.length > 0 ? ( + + ) : ( + onChange({ root_folder: event.target.value })} + placeholder="/media" + /> + )} + + + {qualityProfiles.length > 0 ? ( + + ) : ( + onChange({ quality_profile_id: event.target.value })} + /> + )} + + + onChange({ tags: event.target.value })} + placeholder="1, 2" + /> + {tags.length > 0 ? ( +
    + {tags.map((tag) => { + const selected = selectedTags.includes(tag.id); + return ( + + ); + })} +
    + ) : null} +
    +
    + +
    + onChange({ search_on_add })} + /> + {form.kind === "sonarr" ? ( + <> + + onChange({ series_type: event.target.value })} + /> + + onChange({ season_folder })} + /> + + ) : ( + + + + )} +
    + + {options ? ( +

    + Loaded {options.root_folders.length} root folder + {options.root_folders.length === 1 ? "" : "s"}, {options.quality_profiles.length} quality + profile{options.quality_profiles.length === 1 ? "" : "s"}, and {options.tags.length} tag + {options.tags.length === 1 ? "" : "s"}. +

    + ) : null} +
    + ); +} + +function integrationToForm( + kind: "radarr" | "sonarr", + integration?: RequestIntegration, +): IntegrationFormState { + const options = integration?.options ?? {}; + return { + kind, + enabled: integration?.enabled ?? false, + base_url: integration?.base_url ?? "", + api_key_ref: "", + root_folder: integration?.root_folder ?? "", + quality_profile_id: integration?.quality_profile_id + ? String(integration.quality_profile_id) + : "", + tags: integration?.tags?.join(", ") ?? "", + search_on_add: boolOption(options, "search_on_add", true), + minimum_availability: stringOption(options, "minimum_availability", "released"), + series_type: stringOption(options, "series_type", "standard"), + season_folder: boolOption(options, "season_folder", true), + has_api_key: integration?.has_api_key ?? false, + }; +} + +function formToIntegration(form: IntegrationFormState): RequestIntegration { + const qualityProfileID = Number(form.quality_profile_id); + const options: Record = { + search_on_add: form.search_on_add, + }; + if (form.kind === "sonarr") { + options.series_type = form.series_type.trim() || "standard"; + options.season_folder = form.season_folder; + } else { + options.minimum_availability = form.minimum_availability.trim() || "released"; + } + + return { + kind: form.kind, + enabled: form.enabled, + base_url: form.base_url.trim(), + api_key_ref: form.api_key_ref.trim() || undefined, + root_folder: form.root_folder.trim(), + quality_profile_id: + Number.isFinite(qualityProfileID) && qualityProfileID > 0 ? qualityProfileID : undefined, + tags: parseTags(form.tags), + options, + }; +} + +function rootFolderChoices(options: RequestIntegrationOptions | null, currentPath: string) { + const folders = options?.root_folders ?? []; + if (!currentPath || folders.some((folder) => folder.path === currentPath)) { + return folders; + } + return [{ path: currentPath, accessible: true }, ...folders]; +} + +function qualityProfileChoices(options: RequestIntegrationOptions | null, currentID: string) { + const profiles = options?.quality_profiles ?? []; + const id = Number(currentID); + if (!Number.isInteger(id) || id <= 0 || profiles.some((profile) => profile.id === id)) { + return profiles; + } + return [{ id, name: "Current profile" }, ...profiles]; +} + +function minimumAvailabilityChoices(currentValue: string) { + if ( + !currentValue || + MINIMUM_AVAILABILITY_OPTIONS.some((option) => option.value === currentValue) + ) { + return MINIMUM_AVAILABILITY_OPTIONS; + } + return [ + { value: currentValue, label: `Current value (${currentValue})` }, + ...MINIMUM_AVAILABILITY_OPTIONS, + ]; +} + +function rootFolderLabel(folder: RequestIntegrationOptions["root_folders"][number]): string { + const freeSpace = folder.free_space ? `, ${formatBytes(folder.free_space)} free` : ""; + const access = folder.accessible ? "" : ", inaccessible"; + return `${folder.path}${freeSpace}${access}`; +} + +function formatBytes(value: number): string { + if (!Number.isFinite(value) || value <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB", "PB"]; + let size = value; + let unit = 0; + while (size >= 1024 && unit < units.length - 1) { + size /= 1024; + unit += 1; + } + return `${size >= 10 || unit === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unit]}`; +} + +function toggleTag(current: string, tagID: number): string { + const tags = parseTags(current); + const next = tags.includes(tagID) ? tags.filter((tag) => tag !== tagID) : [...tags, tagID]; + return next.join(", "); +} + +type UserLimitFormState = { + limit_mode: RequestLimitMode; + max_requests: string; + window_days: string; + approval_mode: RequestApprovalMode; +}; + +function UserOverridesTab() { + const users = useAdminUsers(); + const [selectedUserID, setSelectedUserID] = useState(); + const effectiveUserID = selectedUserID ?? users.data?.[0]?.id; + const limit = useRequestUserLimit(effectiveUserID); + + const selectedUser = useMemo( + () => users.data?.find((user) => user.id === effectiveUserID), + [effectiveUserID, users.data], + ); + + if (users.isLoading) return ; + if (users.isError) { + return ; + } + + return ( +
    +
    + +

    User Overrides

    +
    + + + + + + {limit.isLoading ? ( + + ) : limit.isError || !limit.data || !effectiveUserID ? ( + + ) : ( + + )} +
    + ); +} + +function UserLimitEditor({ + userID, + limit, + userAvailable, +}: { + userID: number; + limit: RequestUserLimit; + userAvailable: boolean; +}) { + const updateLimit = useUpdateRequestUserLimit(); + const [form, setForm] = useState(() => ({ + limit_mode: limit.limit_mode, + max_requests: limit.max_requests == null ? "" : String(limit.max_requests), + window_days: limit.window_days == null ? "" : String(limit.window_days), + approval_mode: limit.approval_mode, + })); + + function saveLimit() { + const custom = form.limit_mode === "custom"; + const payload: RequestUserLimit = { + user_id: userID, + limit_mode: form.limit_mode, + approval_mode: form.approval_mode, + max_requests: custom ? Math.max(0, Number(form.max_requests) || 0) : undefined, + window_days: custom ? Math.max(1, Number(form.window_days) || 1) : undefined, + }; + updateLimit.mutate({ userId: userID, body: payload }); + } + + return ( + <> +
    + + + + + + + {form.limit_mode === "custom" ? ( + <> + + + setForm((current) => ({ ...current, max_requests: event.target.value })) + } + /> + + + + setForm((current) => ({ ...current, window_days: event.target.value })) + } + /> + + + ) : null} +
    + + + + ); +} + +function userLimitFormKey(limit: RequestUserLimit): string { + return `${limit.user_id}:${limit.updated_at ?? ""}:${limit.limit_mode}:${limit.approval_mode}`; +} + +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( +
    + + {children} +
    + ); +} + +function SwitchField({ + label, + checked, + onCheckedChange, +}: { + label: string; + checked: boolean; + onCheckedChange: (checked: boolean) => void; +}) { + return ( +
    + + +
    + ); +} + +function boolOption(options: Record, key: string, fallback: boolean): boolean { + return typeof options[key] === "boolean" ? Boolean(options[key]) : fallback; +} + +function stringOption(options: Record, key: string, fallback: string): string { + return typeof options[key] === "string" ? String(options[key]) : fallback; +} + +function parseTags(value: string): number[] { + return value + .split(",") + .map((part) => Number(part.trim())) + .filter((tag) => Number.isInteger(tag) && tag > 0); +} + +function RowsSkeleton() { + return ( +
    + {Array.from({ length: 5 }).map((_, index) => ( + + ))} +
    + ); +} + +function EmptyPanel({ title, detail }: { title: string; detail: string }) { + return ( +
    +

    {title}

    +

    {detail}

    +
    + ); +} diff --git a/web/src/pages/Requests.tsx b/web/src/pages/Requests.tsx new file mode 100644 index 00000000..d3db7de1 --- /dev/null +++ b/web/src/pages/Requests.tsx @@ -0,0 +1,568 @@ +import { useMemo, useState } from "react"; +import type { FormEvent } from "react"; +import { Search, Sparkles, X } from "lucide-react"; +import MediaCarousel from "@/components/MediaCarousel"; +import RequestPosterCard from "@/components/RequestPosterCard"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import type { MediaRequest, RequestDiscoverySection, RequestMediaResult } from "@/api/types"; +import { + useCreateMediaRequest, + useMyMediaRequests, + useRequestDiscovery, + useRequestSearch, +} from "@/hooks/queries/requests"; +import { useDocumentTitle } from "@/hooks/useDocumentTitle"; +import { cn } from "@/lib/utils"; +import { requestInputFromMediaResult } from "@/lib/mediaRequests"; + +type MineBucketKey = "motion" | "completed" | "issues"; + +const MINE_BUCKET_META: Record = + { + motion: { + title: "In motion", + eyebrow: "On their way", + accent: "text-amber-200/90", + }, + completed: { + title: "Landed in your library", + eyebrow: "Ready to watch", + accent: "text-emerald-200/90", + }, + issues: { + title: "Needs attention", + eyebrow: "Hit a snag", + accent: "text-red-200/90", + }, + }; + +export default function Requests() { + useDocumentTitle("Requests"); + + const [mediaType, setMediaType] = useState<"movie" | "series">("movie"); + const [searchInput, setSearchInput] = useState(""); + const [submittedQuery, setSubmittedQuery] = useState(""); + const [searchPage, setSearchPage] = useState(1); + + const discovery = useRequestDiscovery(); + const search = useRequestSearch(mediaType, submittedQuery, searchPage); + const mine = useMyMediaRequests({ limit: 100 }); + const createRequest = useCreateMediaRequest(); + + const isSearching = submittedQuery.length > 0; + + function handleSearch(event: FormEvent) { + event.preventDefault(); + setSubmittedQuery(searchInput.trim()); + setSearchPage(1); + } + + function clearSearch() { + setSearchInput(""); + setSubmittedQuery(""); + setSearchPage(1); + } + + function submitRequest(item: RequestMediaResult) { + createRequest.mutate(requestInputFromMediaResult(item)); + } + + const buckets = useMemo(() => groupMineRequests(mine.data ?? []), [mine.data]); + const mineCounts = useMemo(() => countMineStatuses(mine.data ?? []), [mine.data]); + const totalMine = (mine.data ?? []).length; + + return ( +
    + + + + + + + + Discover + + + Yours + {totalMine > 0 && ( + + {totalMine} + + )} + + + + + {isSearching ? ( + + ) : discovery.isLoading ? ( + + ) : discovery.isError ? ( + + ) : ( +
    + {(discovery.data ?? []).map((section) => ( + + ))} +
    + )} +
    + + + {mine.isLoading ? ( + + ) : mine.isError ? ( + + ) : totalMine === 0 ? ( + + ) : ( + <> + +
    + {(Object.keys(MINE_BUCKET_META) as MineBucketKey[]).map((key) => { + const items = buckets[key]; + if (items.length === 0) return null; + return ; + })} +
    + + )} +
    +
    +
    + ); +} + +function PageHeader() { + return ( +
    +
    + + + Your wishlist + +
    +

    + Find something worth waiting for. +

    +

    + Browse what's trending, search the full TMDB catalog, and watch your requests move from{" "} + pending to{" "} + ready. +

    +
    + ); +} + +function SearchBar({ + mediaType, + onMediaTypeChange, + searchInput, + onSearchInputChange, + onSubmit, + onClear, + isSearching, +}: { + mediaType: "movie" | "series"; + onMediaTypeChange: (value: "movie" | "series") => void; + searchInput: string; + onSearchInputChange: (value: string) => void; + onSubmit: (event: FormEvent) => void; + onClear: () => void; + isSearching: boolean; +}) { + return ( +
    + +
    + + onSearchInputChange(event.target.value)} + placeholder="Search TMDB by title…" + className="border-border/60 bg-background/40 h-10 rounded-xl pr-10 pl-10 text-sm" + /> + {(searchInput || isSearching) && ( + + )} +
    + +
    + ); +} + +function DiscoverySectionRow({ + section, + pendingTMDBID, + isSubmitting, + onRequest, +}: { + section: RequestDiscoverySection; + pendingTMDBID?: number; + isSubmitting: boolean; + onRequest: (item: RequestMediaResult) => void; +}) { + const eyebrow = sectionEyebrow(section.key); + return ( +
    +
    + + {eyebrow} + +
    + + {section.results.map((item) => ( + onRequest(item)} + /> + ))} + +
    + ); +} + +function MineBucketRow({ bucket, requests }: { bucket: MineBucketKey; requests: MediaRequest[] }) { + const meta = MINE_BUCKET_META[bucket]; + return ( +
    +
    + + {meta.eyebrow} + +
    + + {requests.map((request) => ( + + ))} + +
    + ); +} + +function SearchResultsView({ + query, + mediaType, + page, + onPageChange, + isLoading, + isError, + totalPages, + results, + pendingTMDBID, + isSubmitting, + onRequest, +}: { + query: string; + mediaType: "movie" | "series"; + page: number; + onPageChange: (page: number) => void; + isLoading: boolean; + isError: boolean; + totalPages: number; + results: RequestMediaResult[]; + pendingTMDBID?: number; + isSubmitting: boolean; + onRequest: (item: RequestMediaResult) => void; +}) { + return ( +
    +
    + + Search · {mediaType === "series" ? "Series" : "Movies"} + +
    +
    +

    + Results for "{query}" +

    +
    + + {isError ? ( + + ) : isLoading ? ( + + ) : results.length === 0 ? ( + + ) : ( + <> +
    + {results.map((item) => ( + onRequest(item)} + /> + ))} +
    + + {totalPages > 1 && ( +
    + + + Page {page} of {totalPages} + + +
    + )} + + )} +
    + ); +} + +function MineSummary({ + counts, +}: { + counts: { pending: number; inFlight: number; completed: number; issues: number }; +}) { + const chips: Array<{ label: string; value: number; tone: string }> = []; + if (counts.pending > 0) + chips.push({ + label: "Pending review", + value: counts.pending, + tone: "bg-amber-500/15 text-amber-100 ring-amber-400/40", + }); + if (counts.inFlight > 0) + chips.push({ + label: "In motion", + value: counts.inFlight, + tone: "bg-sky-500/15 text-sky-100 ring-sky-400/40", + }); + if (counts.completed > 0) + chips.push({ + label: "Ready to watch", + value: counts.completed, + tone: "bg-emerald-500/15 text-emerald-100 ring-emerald-400/40", + }); + if (counts.issues > 0) + chips.push({ + label: "Need attention", + value: counts.issues, + tone: "bg-red-500/15 text-red-100 ring-red-400/40", + }); + + if (chips.length === 0) return null; + + return ( +
    + {chips.map((chip) => ( + + {chip.value} + {chip.label} + + ))} +
    + ); +} + +function EmptyMineState() { + return ( +
    + +

    Your wishlist is empty.

    +

    + Browse the Discover tab or search above. The moment you request something, it'll show up + here with live status. +

    +
    + ); +} + +function EmptyPanel({ title, detail }: { title: string; detail: string }) { + return ( +
    +

    {title}

    +

    {detail}

    +
    + ); +} + +function DiscoveryCarouselSkeleton() { + return ( +
    + {Array.from({ length: 3 }).map((_, sectionIndex) => ( +
    +
    + + +
    +
    + {Array.from({ length: 7 }).map((_, i) => ( +
    + + + +
    + ))} +
    +
    + ))} +
    + ); +} + +function SearchGridSkeleton() { + return ( +
    + {Array.from({ length: 14 }).map((_, i) => ( +
    + + + +
    + ))} +
    + ); +} + +function sectionEyebrow(key: string): string { + if (key.startsWith("trending")) return "Trending now"; + if (key.startsWith("popular")) return "Crowd favorites"; + return "Discover"; +} + +function groupMineRequests(requests: MediaRequest[]) { + const buckets: Record = { + motion: [], + completed: [], + issues: [], + }; + for (const request of requests) { + if ( + request.outcome === "declined" || + request.outcome === "cancelled" || + request.outcome === "failed" + ) { + buckets.issues.push(request); + } else if (request.status === "completed") { + buckets.completed.push(request); + } else { + buckets.motion.push(request); + } + } + return buckets; +} + +function countMineStatuses(requests: MediaRequest[]) { + let pending = 0; + let inFlight = 0; + let completed = 0; + let issues = 0; + for (const request of requests) { + if ( + request.outcome === "declined" || + request.outcome === "cancelled" || + request.outcome === "failed" + ) { + issues += 1; + } else if (request.status === "completed") { + completed += 1; + } else if (request.status === "pending") { + pending += 1; + } else { + inFlight += 1; + } + } + return { pending, inFlight, completed, issues }; +} From 5b8aa9f34c407e42bdaebc46021670ddfadcabbd Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 17:03:29 -0400 Subject: [PATCH 02/53] feat(requests): add media detail page with TMDB metadata - Add GetMediaDetail TMDB client returning normalized detail with cast, crew, recommendations, and certifications - Add /api/requests/detail/{media_type}/{tmdb_id} endpoint overlaying availability and request state - Add RequestDetail page and link poster cards to it - Treat empty/truncated Radarr/Sonarr POST responses as accepted; drop pre-submit existence lookups --- internal/api/handlers/requests.go | 20 + internal/api/router.go | 1 + internal/metadata/tmdb/client.go | 254 ++++++++++++ internal/metadata/tmdb/types.go | 169 ++++++++ internal/requests/arrclient/client.go | 24 +- internal/requests/radarr/client.go | 53 +-- internal/requests/service.go | 96 +++++ internal/requests/service_test.go | 5 + internal/requests/sonarr/client.go | 56 +-- internal/requests/types.go | 41 ++ web/src/App.tsx | 2 + web/src/api/types.ts | 41 ++ web/src/components/RequestPosterCard.tsx | 22 +- web/src/hooks/queries/keys.ts | 1 + web/src/hooks/queries/requests.ts | 13 + web/src/pages/AdminRequests.tsx | 28 +- web/src/pages/RequestDetail.tsx | 469 +++++++++++++++++++++++ web/src/pages/Requests.tsx | 52 +-- 18 files changed, 1246 insertions(+), 101 deletions(-) create mode 100644 web/src/pages/RequestDetail.tsx diff --git a/internal/api/handlers/requests.go b/internal/api/handlers/requests.go index 51152e63..da6d0729 100644 --- a/internal/api/handlers/requests.go +++ b/internal/api/handlers/requests.go @@ -19,6 +19,7 @@ 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) @@ -98,6 +99,25 @@ func (h *RequestsHandler) HandleDiscoverSection(w http.ResponseWriter, r *http.R writeJSON(w, http.StatusOK, section) } +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 { diff --git a/internal/api/router.go b/internal/api/router.go index 75e2ffad..d39634b0 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1397,6 +1397,7 @@ func NewRouter(deps Dependencies) 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) diff --git a/internal/metadata/tmdb/client.go b/internal/metadata/tmdb/client.go index 614267e5..c5a02d7a 100644 --- a/internal/metadata/tmdb/client.go +++ b/internal/metadata/tmdb/client.go @@ -614,6 +614,260 @@ 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) + for i := 1; i < len(sorted); i++ { + for j := i; j > 0 && sorted[j].Order < sorted[j-1].Order; j-- { + sorted[j], sorted[j-1] = sorted[j-1], sorted[j] + } + } + const maxCast = 24 + if len(sorted) > maxCast { + sorted = sorted[:maxCast] + } + out := make([]MediaCastMember, 0, len(sorted)) + for _, member := range sorted { + out = append(out, MediaCastMember{ + Name: strings.TrimSpace(member.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. func (c *Client) GetExternalIDs(ctx context.Context, mediaType string, id int) (*ExternalIDs, error) { var path string diff --git a/internal/metadata/tmdb/types.go b/internal/metadata/tmdb/types.go index efedb394..4a3e462f 100644 --- a/internal/metadata/tmdb/types.go +++ b/internal/metadata/tmdb/types.go @@ -149,3 +149,172 @@ 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 index c2832569..e5d3a135 100644 --- a/internal/requests/arrclient/client.go +++ b/internal/requests/arrclient/client.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -24,6 +25,11 @@ type HTTPError struct { 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) @@ -31,6 +37,22 @@ func (e HTTPError) Error() string { 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} @@ -92,7 +114,7 @@ func (c *Client) DoJSON(ctx context.Context, method, path string, body, dest any return nil } if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(dest); err != nil { - return fmt.Errorf("arr: decode response: %w", err) + return DecodeError{StatusCode: resp.StatusCode, Err: err} } return nil } diff --git a/internal/requests/radarr/client.go b/internal/requests/radarr/client.go index 760e6105..fce76ed5 100644 --- a/internal/requests/radarr/client.go +++ b/internal/requests/radarr/client.go @@ -86,14 +86,6 @@ func (c *Client) SubmitMovie(ctx context.Context, req mediarequests.Request, int } client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient) - existing, err := c.lookupExisting(ctx, client, req.TMDBID) - if err != nil { - return mediarequests.FulfillmentResult{}, err - } - if existing != nil { - return resultFromMovie(*existing), nil - } - movie, err := c.lookupMovie(ctx, client, req.TMDBID) if err != nil { return mediarequests.FulfillmentResult{}, err @@ -114,6 +106,9 @@ func (c *Client) SubmitMovie(ctx context.Context, req mediarequests.Request, int var created movieResource if err := client.PostJSON(ctx, "/api/v3/movie", movie, &created); err != nil { + if arrclient.IsEmptyOrTruncatedDecodeError(err) { + return acceptedWithoutResponse("radarr"), nil + } return mediarequests.FulfillmentResult{}, err } return resultFromMovie(created), nil @@ -123,18 +118,11 @@ func (c *Client) CheckMovieStatus(ctx context.Context, req mediarequests.Request client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient) movieID, _ := strconv.Atoi(req.ExternalID) if movieID <= 0 { - existing, err := c.lookupExisting(ctx, client, req.TMDBID) - if err != nil { - return mediarequests.FulfillmentStatus{}, err - } - if existing == nil { - return mediarequests.FulfillmentStatus{ - Status: mediarequests.StatusQueued, - IntegrationKind: "radarr", - ExternalStatus: "missing_from_radarr", - }, nil - } - movieID = existing.ID + return mediarequests.FulfillmentStatus{ + Status: mediarequests.StatusQueued, + IntegrationKind: "radarr", + ExternalStatus: "external_id_unavailable", + }, nil } queues, err := c.queueDetails(ctx, client, movieID) @@ -145,18 +133,6 @@ func (c *Client) CheckMovieStatus(ctx context.Context, req mediarequests.Request return statusFromQueueEvaluation("radarr", movieID, evaluation), nil } -func (c *Client) lookupExisting(ctx context.Context, client *arrclient.Client, tmdbID int) (*movieResource, error) { - path := "/api/v3/movie?tmdbId=" + strconv.Itoa(tmdbID) - var movies []movieResource - if err := client.GetJSON(ctx, path, &movies); err != nil { - return nil, err - } - if len(movies) == 0 { - return nil, nil - } - return &movies[0], nil -} - func (c *Client) lookupMovie(ctx context.Context, client *arrclient.Client, tmdbID int) (movieResource, error) { values := url.Values{} values.Set("tmdbId", strconv.Itoa(tmdbID)) @@ -228,13 +204,24 @@ func (c *Client) tags(ctx context.Context, client *arrclient.Client) ([]mediareq } func resultFromMovie(movie movieResource) mediarequests.FulfillmentResult { + externalID := "" + if movie.ID > 0 { + externalID = strconv.Itoa(movie.ID) + } return mediarequests.FulfillmentResult{ IntegrationKind: "radarr", - ExternalID: strconv.Itoa(movie.ID), + ExternalID: externalID, ExternalStatus: "queued", } } +func acceptedWithoutResponse(kind string) mediarequests.FulfillmentResult { + return mediarequests.FulfillmentResult{ + IntegrationKind: kind, + ExternalStatus: "accepted_without_response", + } +} + func statusFromQueueEvaluation(kind string, externalID int, evaluation arrclient.QueueEvaluation) mediarequests.FulfillmentStatus { status := mediarequests.StatusQueued outcome := mediarequests.Outcome("") diff --git a/internal/requests/service.go b/internal/requests/service.go index a4280f62..473aa272 100644 --- a/internal/requests/service.go +++ b/internal/requests/service.go @@ -14,6 +14,7 @@ import ( 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) } type TMDBExternalIDClient interface { @@ -142,6 +143,101 @@ func (s *Service) DiscoverAll(ctx context.Context, viewer Viewer) ([]DiscoverySe 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 diff --git a/internal/requests/service_test.go b/internal/requests/service_test.go index 63b864c9..1f6967b6 100644 --- a/internal/requests/service_test.go +++ b/internal/requests/service_test.go @@ -517,6 +517,7 @@ func (f *fakePresence) LookupTMDB(_ context.Context, mediaType MediaType, ids [] type fakeTMDBClient struct { page *tmdb.MediaPage externalIDs *tmdb.ExternalIDs + detail *tmdb.MediaDetail } func (f *fakeTMDBClient) SearchMedia(context.Context, string, string, int) (*tmdb.MediaPage, error) { @@ -531,6 +532,10 @@ func (f *fakeTMDBClient) GetExternalIDs(context.Context, string, int) (*tmdb.Ext 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 diff --git a/internal/requests/sonarr/client.go b/internal/requests/sonarr/client.go index d02adf5d..476210b1 100644 --- a/internal/requests/sonarr/client.go +++ b/internal/requests/sonarr/client.go @@ -91,14 +91,6 @@ func (c *Client) SubmitSeries(ctx context.Context, req mediarequests.Request, in } client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient) - existing, err := c.lookupExisting(ctx, client, *req.TVDBID) - if err != nil { - return mediarequests.FulfillmentResult{}, err - } - if existing != nil { - return resultFromSeries(*existing), nil - } - series, err := c.lookupSeries(ctx, client, *req.TVDBID) if err != nil { return mediarequests.FulfillmentResult{}, err @@ -121,6 +113,9 @@ func (c *Client) SubmitSeries(ctx context.Context, req mediarequests.Request, in var created seriesResource if err := client.PostJSON(ctx, "/api/v3/series", series, &created); err != nil { + if arrclient.IsEmptyOrTruncatedDecodeError(err) { + return acceptedWithoutResponse("sonarr"), nil + } return mediarequests.FulfillmentResult{}, err } return resultFromSeries(created), nil @@ -130,21 +125,11 @@ func (c *Client) CheckSeriesStatus(ctx context.Context, req mediarequests.Reques client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient) seriesID, _ := strconv.Atoi(req.ExternalID) if seriesID <= 0 { - if req.TVDBID == nil || *req.TVDBID <= 0 { - return mediarequests.FulfillmentStatus{}, fmt.Errorf("sonarr: tvdb_id is required") - } - existing, err := c.lookupExisting(ctx, client, *req.TVDBID) - if err != nil { - return mediarequests.FulfillmentStatus{}, err - } - if existing == nil { - return mediarequests.FulfillmentStatus{ - Status: mediarequests.StatusQueued, - IntegrationKind: "sonarr", - ExternalStatus: "missing_from_sonarr", - }, nil - } - seriesID = existing.ID + return mediarequests.FulfillmentStatus{ + Status: mediarequests.StatusQueued, + IntegrationKind: "sonarr", + ExternalStatus: "external_id_unavailable", + }, nil } queues, err := c.queueDetails(ctx, client, seriesID) @@ -155,18 +140,6 @@ func (c *Client) CheckSeriesStatus(ctx context.Context, req mediarequests.Reques return statusFromQueueEvaluation("sonarr", seriesID, evaluation), nil } -func (c *Client) lookupExisting(ctx context.Context, client *arrclient.Client, tvdbID int) (*seriesResource, error) { - path := "/api/v3/series?tvdbId=" + strconv.Itoa(tvdbID) - var series []seriesResource - if err := client.GetJSON(ctx, path, &series); err != nil { - return nil, err - } - if len(series) == 0 { - return nil, nil - } - return &series[0], 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)) @@ -243,13 +216,24 @@ func (c *Client) tags(ctx context.Context, client *arrclient.Client) ([]mediareq } func resultFromSeries(series seriesResource) mediarequests.FulfillmentResult { + externalID := "" + if series.ID > 0 { + externalID = strconv.Itoa(series.ID) + } return mediarequests.FulfillmentResult{ IntegrationKind: "sonarr", - ExternalID: strconv.Itoa(series.ID), + ExternalID: externalID, ExternalStatus: "queued", } } +func acceptedWithoutResponse(kind string) mediarequests.FulfillmentResult { + return mediarequests.FulfillmentResult{ + IntegrationKind: kind, + ExternalStatus: "accepted_without_response", + } +} + func statusFromQueueEvaluation(kind string, externalID int, evaluation arrclient.QueueEvaluation) mediarequests.FulfillmentStatus { status := mediarequests.StatusQueued outcome := mediarequests.Outcome("") diff --git a/internal/requests/types.go b/internal/requests/types.go index 92c70375..488f8603 100644 --- a/internal/requests/types.go +++ b/internal/requests/types.go @@ -155,6 +155,47 @@ type MediaPage struct { 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"` diff --git a/web/src/App.tsx b/web/src/App.tsx index 4cfd6df7..583a2910 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -33,6 +33,7 @@ import PersonDetail from "@/pages/PersonDetail"; import Collections from "@/pages/Collections"; import CollectionEditor from "@/pages/CollectionEditor"; import Requests from "@/pages/Requests"; +import RequestDetail from "@/pages/RequestDetail"; import AdminDashboard from "@/pages/AdminDashboard"; import AdminActivity from "@/pages/AdminActivity"; import AdminLogs from "@/pages/AdminLogs"; @@ -448,6 +449,7 @@ function AppRoutes() { element={} /> } /> + } /> } /> + { e.preventDefault(); + e.stopPropagation(); onRequest(); }} className="pointer-events-auto inline-flex items-center gap-1.5 rounded-full bg-white px-3.5 py-1.5 text-[12px] font-semibold tracking-wide text-black shadow-lg shadow-black/40 transition-all hover:scale-[1.03] active:scale-[0.97] disabled:opacity-70" @@ -92,7 +100,7 @@ function DiscoverCard({ - + ); } @@ -106,7 +114,13 @@ function MineCard({ request }: { request: MediaRequest }) { request.outcome === "cancelled"; return ( -
    + ) : null} -
    + ); } diff --git a/web/src/hooks/queries/keys.ts b/web/src/hooks/queries/keys.ts index 53e18432..ad7325f0 100644 --- a/web/src/hooks/queries/keys.ts +++ b/web/src/hooks/queries/keys.ts @@ -123,6 +123,7 @@ export const requestKeys = { ["requests", "discovery", section, 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, }; diff --git a/web/src/hooks/queries/requests.ts b/web/src/hooks/queries/requests.ts index bf5ece6f..eab55ed0 100644 --- a/web/src/hooks/queries/requests.ts +++ b/web/src/hooks/queries/requests.ts @@ -12,6 +12,7 @@ import type { RequestIntegrationOptions, RequestIntegrationsResponse, RequestListParams, + RequestMediaDetail, RequestMediaPage, RequestMediaType, RequestSettings, @@ -66,6 +67,18 @@ export function useRequestDiscoverySection(section: string, page = 1) { }); } +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: RequestMediaType, query: string, page = 1) { const normalizedQuery = query.trim(); return useQuery({ diff --git a/web/src/pages/AdminRequests.tsx b/web/src/pages/AdminRequests.tsx index d1b89e5d..044b8c86 100644 --- a/web/src/pages/AdminRequests.tsx +++ b/web/src/pages/AdminRequests.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import type { ReactNode } from "react"; -import { useSearchParams } from "react-router"; +import { Link, useSearchParams } from "react-router"; import { Check, Plug, RefreshCw, Save, Settings2, SlidersHorizontal, X } from "lucide-react"; import type { MediaRequest, @@ -130,9 +130,13 @@ 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 usernamesByID = useMemo(() => { + return new Map((users.data ?? []).map((user) => [user.id, user.username])); + }, [users.data]); function handleDecline(request: MediaRequest) { const reason = window.prompt(`Decline "${request.title}"?`, ""); @@ -207,6 +211,11 @@ function RequestQueueTab() {
    - {request.title} + + {request.title} + {formatMediaType(request.media_type)}
    {request.year ? {request.year} : null} TMDB {request.tmdb_id} - {request.requested_by_user_id ? User {request.requested_by_user_id} : null} + {request.requested_by_user_id ? ( + + {requesterLabel} + + ) : null}
    {request.last_error ? (

    {request.last_error}

    diff --git a/web/src/pages/RequestDetail.tsx b/web/src/pages/RequestDetail.tsx new file mode 100644 index 00000000..f36a510b --- /dev/null +++ b/web/src/pages/RequestDetail.tsx @@ -0,0 +1,469 @@ +import { useMemo } from "react"; +import { useNavigate, useParams } from "react-router"; +import { ArrowLeft, Check, Clock, Loader2, Plus, Star } from "lucide-react"; +import MediaCarousel from "@/components/MediaCarousel"; +import RequestPosterCard from "@/components/RequestPosterCard"; +import DetailHero from "@/pages/ItemDetail/DetailHero"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { RequestMediaCastMember, RequestMediaDetail, RequestMediaResult } from "@/api/types"; +import { useCreateMediaRequest, useRequestMediaDetail } from "@/hooks/queries/requests"; +import { useDocumentTitle } from "@/hooks/useDocumentTitle"; +import { cn } from "@/lib/utils"; +import { getInitials } from "@/lib/text"; +import { formatRequestReason, formatRequestStatus, tmdbImageURL } from "@/lib/mediaRequests"; + +export default function RequestDetail() { + const navigate = useNavigate(); + const params = useParams<{ mediaType: string; tmdbId: string }>(); + const mediaType = (params.mediaType === "series" ? "series" : "movie") as "movie" | "series"; + const tmdbID = Number(params.tmdbId) || 0; + + const detail = useRequestMediaDetail(mediaType, tmdbID); + const createRequest = useCreateMediaRequest(); + + useDocumentTitle(detail.data?.title ?? "Request"); + + if (detail.isLoading) { + return ; + } + + if (detail.isError || !detail.data) { + return ( +
    +

    Couldn't load this title.

    +

    + The TMDB record may be temporarily unavailable. +

    +
    + +
    +
    + ); + } + + const item = detail.data; + const backdropUrl = tmdbImageURL(item.backdrop_path, "original") ?? undefined; + const posterUrl = tmdbImageURL(item.poster_path, "w500") ?? undefined; + const studioLabel = pickStudioLabel(item); + + return ( +
    + } + studioLabel={studioLabel} + backdropUrl={backdropUrl} + posterUrl={posterUrl} + tagline={item.tagline || undefined} + metadata={} + scoreRow={} + crewLine={} + overview={item.overview} + actions={ + + createRequest.mutate({ + 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, + }) + } + onBack={() => navigate(-1)} + /> + } + /> + +
    + {item.cast && item.cast.length > 0 && ( +
    +

    Cast

    + +
    + )} + + {item.recommendations && item.recommendations.length > 0 && ( + + createRequest.mutate({ + media_type: rec.media_type, + tmdb_id: rec.tmdb_id, + title: rec.title, + year: rec.year || undefined, + overview: rec.overview || undefined, + poster_path: rec.poster_path || undefined, + backdrop_path: rec.backdrop_path || undefined, + }) + } + /> + )} +
    +
    + ); +} + +function RequestContext({ mediaType }: { mediaType: "movie" | "series" }) { + return ( + + Request · {mediaType === "series" ? "Series" : "Movie"} + + ); +} + +function MetaPills({ item }: { item: RequestMediaDetail }) { + const pills: string[] = []; + if (item.year) pills.push(String(item.year)); + if (item.content_rating) pills.push(item.content_rating); + if (item.media_type === "movie" && item.runtime) pills.push(formatDuration(item.runtime)); + if (item.media_type === "series" && item.number_of_seasons) + pills.push(`${item.number_of_seasons} Season${item.number_of_seasons === 1 ? "" : "s"}`); + if (item.media_type === "series" && item.status) pills.push(item.status); + + return ( +
    + {pills.map((pill) => ( + + {pill} + + ))} + {(item.genres ?? []).slice(0, 4).map((genre) => ( + + {genre} + + ))} +
    + ); +} + +function RequestScoreRow({ item }: { item: RequestMediaDetail }) { + if (!item.vote_average) return null; + return ( +
    + + + {item.vote_average.toFixed(1)} + TMDB + + {item.vote_count ? ( + {formatVoteCount(item.vote_count)} votes + ) : null} +
    + ); +} + +function RequestCrewLine({ item }: { item: RequestMediaDetail }) { + const parts: { label: string; value: string }[] = []; + if (item.director) parts.push({ label: "Director", value: item.director }); + if (item.creators && item.creators.length > 0) + parts.push({ label: "Created by", value: item.creators.join(", ") }); + if (item.networks && item.networks.length > 0) + parts.push({ label: "Network", value: item.networks.join(", ") }); + + if (parts.length === 0) return null; + + return ( +
    + {parts.map((part) => ( + + {part.label}: + {part.value} + + ))} +
    + ); +} + +function RequestActions({ + item, + isSubmitting, + onRequest, + onBack, +}: { + item: RequestMediaDetail; + isSubmitting: boolean; + onRequest: () => void; + onBack: () => void; +}) { + const requestable = item.request.requestable; + const statusLabel = item.request.status ? formatRequestStatus(item.request.status) : null; + const reasonLabel = + !requestable && !item.request.status ? formatRequestReason(item.request.reason) : null; + const availableInLibrary = item.availability === "available" && !item.request.status; + + return ( +
    + + + {requestable ? ( + + ) : availableInLibrary ? ( + } + label="Already in your library" + /> + ) : statusLabel ? ( + } + label={statusLabel} + /> + ) : ( + } + label={reasonLabel ?? "Unavailable"} + /> + )} + + {item.imdb_id ? ( + + IMDb + + ) : null} + + TMDB + +
    + ); +} + +const STATUS_TONES: Record<"amber" | "sky" | "emerald" | "zinc", string> = { + amber: "bg-amber-500/15 text-amber-100 ring-amber-400/40", + sky: "bg-sky-500/15 text-sky-100 ring-sky-400/40", + emerald: "bg-emerald-500/15 text-emerald-100 ring-emerald-400/40", + zinc: "bg-zinc-700/60 text-zinc-200 ring-zinc-500/40", +}; + +function StatusBlock({ + tone, + icon, + label, +}: { + tone: "amber" | "sky" | "emerald" | "zinc"; + icon: React.ReactNode; + label: string; +}) { + return ( + + {icon} + {label} + + ); +} + +function statusToneForStatus(status: string): "amber" | "sky" | "emerald" | "zinc" { + switch (status) { + case "pending": + return "amber"; + case "approved": + case "completed": + return "emerald"; + case "queued": + case "downloading": + return "sky"; + default: + return "zinc"; + } +} + +function RequestCastRow({ cast }: { cast: RequestMediaCastMember[] }) { + const sorted = useMemo( + () => + cast + .slice() + .sort((a, b) => a.order - b.order) + .slice(0, 24), + [cast], + ); + return ( +
    +
      + {sorted.map((member, index) => { + const photo = tmdbImageURL(member.profile_path, "w185"); + return ( +
    • +
      + {photo ? ( + {member.name} + ) : ( +
      + {getInitials(member.name)} +
      + )} +
      +
      +
      + {member.name} +
      + {member.character ? ( +
      + {member.character} +
      + ) : null} +
      +
    • + ); + })} +
    +
    + ); +} + +function RecommendationsRow({ + recommendations, + pendingTMDBID, + isSubmitting, + onRequest, +}: { + recommendations: RequestMediaResult[]; + pendingTMDBID?: number; + isSubmitting: boolean; + onRequest: (item: RequestMediaResult) => void; +}) { + return ( +
    +
    + + More like this + +
    + + {recommendations.map((item) => ( + onRequest(item)} + /> + ))} + +
    + ); +} + +function pickStudioLabel(item: RequestMediaDetail): string | undefined { + if (item.media_type === "series" && item.networks && item.networks.length > 0) { + return item.networks[0]; + } + if (item.production_companies && item.production_companies.length > 0) { + return item.production_companies[0]; + } + return undefined; +} + +function formatDuration(minutes: number): string { + if (minutes <= 0) return ""; + const h = Math.floor(minutes / 60); + const m = minutes % 60; + if (h <= 0) return `${m}m`; + return m === 0 ? `${h}h` : `${h}h ${m}m`; +} + +function formatVoteCount(count: number): string { + if (count >= 1000) return `${(count / 1000).toFixed(1)}k`; + return String(count); +} + +function RequestDetailSkeleton() { + return ( +
    +
    +
    +
    +
    +
    + +
    + + + + + + +
    + + +
    +
    +
    +
    +
    +
    +
    + +
    + {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
    +
    +
    +
    + ); +} diff --git a/web/src/pages/Requests.tsx b/web/src/pages/Requests.tsx index d3db7de1..42095945 100644 --- a/web/src/pages/Requests.tsx +++ b/web/src/pages/Requests.tsx @@ -82,33 +82,37 @@ export default function Requests() { const totalMine = (mine.data ?? []).length; return ( -
    - +
    +
    + - + +
    - - - Discover - - - Yours - {totalMine > 0 && ( - - {totalMine} - - )} - - +
    + + + Discover + + + Yours + {totalMine > 0 && ( + + {totalMine} + + )} + + +
    {isSearching ? ( From 06de70fe71f6a77eac63bb46d2f2dde0346f0546 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 17:13:43 -0400 Subject: [PATCH 03/53] feat(requests): add status guide to my requests view - Explain request progress and needs-attention statuses inline above the buckets --- web/src/pages/Requests.tsx | 147 ++++++++++++++++++++++++++++++++++++- 1 file changed, 145 insertions(+), 2 deletions(-) diff --git a/web/src/pages/Requests.tsx b/web/src/pages/Requests.tsx index 42095945..d7d1d050 100644 --- a/web/src/pages/Requests.tsx +++ b/web/src/pages/Requests.tsx @@ -14,7 +14,13 @@ import { } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import type { MediaRequest, RequestDiscoverySection, RequestMediaResult } from "@/api/types"; +import type { + MediaRequest, + MediaRequestOutcome, + MediaRequestStatus, + RequestDiscoverySection, + RequestMediaResult, +} from "@/api/types"; import { useCreateMediaRequest, useMyMediaRequests, @@ -23,9 +29,13 @@ import { } from "@/hooks/queries/requests"; import { useDocumentTitle } from "@/hooks/useDocumentTitle"; import { cn } from "@/lib/utils"; -import { requestInputFromMediaResult } from "@/lib/mediaRequests"; +import { formatRequestStatus, requestInputFromMediaResult } from "@/lib/mediaRequests"; type MineBucketKey = "motion" | "completed" | "issues"; +type StatusGuideItem = { + description: string; + tone: string; +}; const MINE_BUCKET_META: Record = { @@ -46,6 +56,61 @@ const MINE_BUCKET_META: Record = [ + { + status: "pending", + description: "Waiting for an admin to approve the request.", + tone: "bg-amber-500/15 text-amber-100 ring-amber-400/40", + }, + { + status: "approved", + description: "Approved, but not yet sent to the download automation.", + tone: "bg-emerald-500/15 text-emerald-100 ring-emerald-400/40", + }, + { + status: "queued", + description: "Sent to the request automation and waiting for download/import activity.", + tone: "bg-sky-500/15 text-sky-100 ring-sky-400/40", + }, + { + status: "downloading", + description: "Downloading or importing now.", + tone: "bg-sky-500/20 text-sky-100 ring-sky-400/50", + }, + { + status: "completed", + description: "In your Silo library and ready to watch.", + tone: "bg-emerald-500/20 text-emerald-100 ring-emerald-400/40", + }, +]; + +const REQUEST_ISSUE_GUIDE: Array< + StatusGuideItem & { + outcome: Extract; + label: string; + } +> = [ + { + outcome: "declined", + label: "Declined", + description: "An admin declined the request.", + tone: "bg-zinc-700/60 text-zinc-200 ring-zinc-500/40", + }, + { + outcome: "cancelled", + label: "Cancelled", + description: "The request was cancelled before completion.", + tone: "bg-zinc-700/60 text-zinc-200 ring-zinc-500/40", + }, + { + outcome: "failed", + label: "Failed", + description: + "Silo or the external request automation hit an error. If details are available, they appear on the request card.", + tone: "bg-red-500/15 text-red-100 ring-red-400/40", + }, +]; + export default function Requests() { useDocumentTitle("Requests"); @@ -164,6 +229,7 @@ export default function Requests() { ) : ( <> +
    {(Object.keys(MINE_BUCKET_META) as MineBucketKey[]).map((key) => { const items = buckets[key]; @@ -179,6 +245,83 @@ export default function Requests() { ); } +function RequestStatusGuide() { + return ( +
    +
    +
    +
    +

    + Status guide +

    +

    + Statuses update automatically as Silo checks the library and connected request + integrations. +

    +
    +
    + + {REQUEST_PROGRESS_GUIDE.map((item) => ( + + ))} + + + {REQUEST_ISSUE_GUIDE.map((item) => ( + + ))} + +
    +
    +
    +
    + ); +} + +function StatusGuideGroup({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
    +

    {title}

    +
    {children}
    +
    + ); +} + +function StatusGuideRow({ + label, + description, + tone, +}: { + label: string; + description: string; + tone: string; +}) { + return ( +
    +
    + + {label} + +
    +
    {description}
    +
    + ); +} + function PageHeader() { return (
    From 1a313f4003dd82793177afbb108f6d16fd69985a Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 18:11:21 -0400 Subject: [PATCH 04/53] docs(requests): add discover studios/networks/genres design spec Captures the v1 design for adding Overseerr-style brand-card discovery (studios, networks, genres) on top of the existing request system. Bundled list of TMDB company/network/genre IDs, logos fetched from TMDB, three new list endpoints + three new browse endpoints, no admin UI, no migrations. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...discover-studios-networks-genres-design.md | 351 ++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-24-discover-studios-networks-genres-design.md 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..4add6ddb --- /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` (currently on branch +`t3code/df875f01`). It must land on top of that branch — it depends on +`internal/requests/`, `internal/metadata/tmdb/`, `Requests.tsx`, and the +existing `/api/v1/requests/discover/*` route prefix. + +## 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 + +``` +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 + +``` +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`) + +``` +/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/requests.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. From 7397fa8141925db9fbc9e877a02470f7a592a363 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 18:31:33 -0400 Subject: [PATCH 05/53] feat(tmdb): add with_companies and with_networks to discover params --- internal/metadata/tmdb/client.go | 6 ++++++ internal/metadata/tmdb/client_test.go | 28 +++++++++++++++++++++++++++ internal/metadata/tmdb/types.go | 2 ++ 3 files changed, 36 insertions(+) diff --git a/internal/metadata/tmdb/client.go b/internal/metadata/tmdb/client.go index c5a02d7a..c3c5aa0f 100644 --- a/internal/metadata/tmdb/client.go +++ b/internal/metadata/tmdb/client.go @@ -520,6 +520,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)) } diff --git a/internal/metadata/tmdb/client_test.go b/internal/metadata/tmdb/client_test.go index 6663a095..f4cfae6f 100644 --- a/internal/metadata/tmdb/client_test.go +++ b/internal/metadata/tmdb/client_test.go @@ -204,6 +204,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"}) diff --git a/internal/metadata/tmdb/types.go b/internal/metadata/tmdb/types.go index 4a3e462f..eefcce92 100644 --- a/internal/metadata/tmdb/types.go +++ b/internal/metadata/tmdb/types.go @@ -65,6 +65,8 @@ type MediaPage struct { type DiscoverParams struct { WithGenres []int WithoutGenres []int + WithCompanies []int + WithNetworks []int SortBy string VoteCountGte int VoteAverageGte float64 From 8a6a8438d0d279c7fe5443fecc6cc4e50bd1ee4d Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 18:32:31 -0400 Subject: [PATCH 06/53] feat(tmdb): add DiscoverPage returning full MediaPage with pagination --- internal/metadata/tmdb/client.go | 36 +++++++ internal/metadata/tmdb/client_test.go | 131 ++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) diff --git a/internal/metadata/tmdb/client.go b/internal/metadata/tmdb/client.go index c3c5aa0f..5e79a55e 100644 --- a/internal/metadata/tmdb/client.go +++ b/internal/metadata/tmdb/client.go @@ -508,6 +508,42 @@ 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 + + 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 { diff --git a/internal/metadata/tmdb/client_test.go b/internal/metadata/tmdb/client_test.go index f4cfae6f..86e91eb6 100644 --- a/internal/metadata/tmdb/client_test.go +++ b/internal/metadata/tmdb/client_test.go @@ -248,6 +248,137 @@ 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" { From 736340583eb6722d971817a75a8e9bb9fb3de446 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 18:33:20 -0400 Subject: [PATCH 07/53] feat(tmdb): add GetCompany and GetNetwork for logo path resolution --- internal/metadata/tmdb/client.go | 26 +++++++++++ internal/metadata/tmdb/client_test.go | 65 +++++++++++++++++++++++++++ internal/metadata/tmdb/types.go | 14 ++++++ 3 files changed, 105 insertions(+) diff --git a/internal/metadata/tmdb/client.go b/internal/metadata/tmdb/client.go index 5e79a55e..edefaeb1 100644 --- a/internal/metadata/tmdb/client.go +++ b/internal/metadata/tmdb/client.go @@ -544,6 +544,32 @@ func (c *Client) DiscoverPage(ctx context.Context, mediaType string, params Disc return normalizeMoviePage(resp), nil } +// 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 +} + // 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 { diff --git a/internal/metadata/tmdb/client_test.go b/internal/metadata/tmdb/client_test.go index 86e91eb6..1990b8d2 100644 --- a/internal/metadata/tmdb/client_test.go +++ b/internal/metadata/tmdb/client_test.go @@ -379,6 +379,71 @@ func TestDiscoverPageDefaultsToPage1(t *testing.T) { } } +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) + } +} + func TestSearchMediaMovie(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/search/movie" { diff --git a/internal/metadata/tmdb/types.go b/internal/metadata/tmdb/types.go index eefcce92..e9ecfc22 100644 --- a/internal/metadata/tmdb/types.go +++ b/internal/metadata/tmdb/types.go @@ -86,6 +86,20 @@ type ExternalIDs struct { TVDBID int `json:"tvdb_id"` } +// 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"` +} + // Collection is the decoded payload of TMDB's /collection/{id} endpoint. // TMDB collections only contain movies (franchises, sagas), so each Part is // implicitly a movie. The Parts slice preserves TMDB's ordering, which is From d99c253ff9ee6795d83c16421dc79ad101b0c195 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 18:34:43 -0400 Subject: [PATCH 08/53] feat(requests): add bundled studio/network/genre registry --- internal/requests/discover_bundle.go | 105 ++++++++++++++++ internal/requests/discover_bundle_test.go | 145 ++++++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 internal/requests/discover_bundle.go create mode 100644 internal/requests/discover_bundle_test.go diff --git a/internal/requests/discover_bundle.go b/internal/requests/discover_bundle.go new file mode 100644 index 00000000..56697147 --- /dev/null +++ b/internal/requests/discover_bundle.go @@ -0,0 +1,105 @@ +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 +} + +// 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. +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 +} diff --git a/internal/requests/discover_bundle_test.go b/internal/requests/discover_bundle_test.go new file mode 100644 index 00000000..e67f401a --- /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.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) + } +} From 7f246aae1a709624663b95877953edbf52e9b274 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 18:35:43 -0400 Subject: [PATCH 09/53] feat(requests): add singleflight logo cache for TMDB company/network logos --- internal/requests/discover_logo_cache.go | 86 +++++++++++++ internal/requests/discover_logo_cache_test.go | 115 ++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 internal/requests/discover_logo_cache.go create mode 100644 internal/requests/discover_logo_cache_test.go diff --git a/internal/requests/discover_logo_cache.go b/internal/requests/discover_logo_cache.go new file mode 100644 index 00000000..39daaef9 --- /dev/null +++ b/internal/requests/discover_logo_cache.go @@ -0,0 +1,86 @@ +package requests + +import ( + "context" + "strconv" + "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 do not +// 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 + } + + value, err, _ := c.group.Do(keyFromID(id), 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:" + strconv.Itoa(id) +} diff --git a/internal/requests/discover_logo_cache_test.go b/internal/requests/discover_logo_cache_test.go new file mode 100644 index 00000000..afad56b5 --- /dev/null +++ b/internal/requests/discover_logo_cache_test.go @@ -0,0 +1,115 @@ +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) + } +} From 562f97575e3484494904563ee6275edf79e7c3fe Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 18:37:46 -0400 Subject: [PATCH 10/53] feat(requests): add ListStudios/ListNetworks/ListGenres with logo cache --- internal/requests/discover_brand.go | 103 +++++++++++++++++++ internal/requests/service.go | 22 ++++- internal/requests/service_test.go | 147 +++++++++++++++++++++++++++- 3 files changed, 268 insertions(+), 4 deletions(-) create mode 100644 internal/requests/discover_brand.go diff --git a/internal/requests/discover_brand.go b/internal/requests/discover_brand.go new file mode 100644 index 00000000..dbc0d814 --- /dev/null +++ b/internal/requests/discover_brand.go @@ -0,0 +1,103 @@ +package requests + +import ( + "context" + "fmt" + "log/slog" +) + +// DiscoverBrandCard is one card on the Studios / Networks / Genres carousels. +// Studios and networks carry a TMDB ID and a brand color plus lazy-fetched logo. +// Genres carry no TMDB ID at this layer and render with a gradient and 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 { + out = append(out, DiscoverBrandCard{ + TMDBID: studio.TMDBID, + Slug: studio.Slug, + DisplayName: studio.DisplayName, + BrandColor: studio.BrandColor, + LogoURL: s.resolveLogoURL(ctx, s.companyLogos, studio.TMDBID, "company", studio.Slug), + }) + } + 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 { + out = append(out, DiscoverBrandCard{ + TMDBID: network.TMDBID, + Slug: network.Slug, + DisplayName: network.DisplayName, + BrandColor: network.BrandColor, + LogoURL: s.resolveLogoURL(ctx, s.networkLogos, network.TMDBID, "network", network.Slug), + }) + } + 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. It returns nil on lookup failure or when TMDB has no logo for the entity. +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. +func tmdbImageURL(path, size string) string { + return "https://image.tmdb.org/t/p/" + size + path +} diff --git a/internal/requests/service.go b/internal/requests/service.go index 473aa272..0aff49a4 100644 --- a/internal/requests/service.go +++ b/internal/requests/service.go @@ -15,6 +15,9 @@ 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) } type TMDBExternalIDClient interface { @@ -56,6 +59,8 @@ type Service struct { secrets SecretResolver movieAdapter MovieFulfillmentAdapter seriesAdapter SeriesFulfillmentAdapter + companyLogos *logoCache + networkLogos *logoCache Now func() time.Time } @@ -69,12 +74,27 @@ type DiscoverySection struct { } func NewService(store Store, tmdbClient TMDBClient, presence PresenceResolver) *Service { - return &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 } func (s *Service) SetSecretResolver(resolver SecretResolver) { diff --git a/internal/requests/service_test.go b/internal/requests/service_test.go index 1f6967b6..89e4b86a 100644 --- a/internal/requests/service_test.go +++ b/internal/requests/service_test.go @@ -497,6 +497,111 @@ func (f *fakeStore) UpsertIntegration(context.Context, Integration) (*Integratio return nil, nil } +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) + } + } +} + type fakePresence struct { available map[MediaType]map[int]bool } @@ -515,9 +620,15 @@ func (f *fakePresence) LookupTMDB(_ context.Context, mediaType MediaType, ids [] } type fakeTMDBClient struct { - page *tmdb.MediaPage - externalIDs *tmdb.ExternalIDs - detail *tmdb.MediaDetail + page *tmdb.MediaPage + externalIDs *tmdb.ExternalIDs + detail *tmdb.MediaDetail + companies map[int]*tmdb.Company + companyErr map[int]error + networks map[int]*tmdb.Network + networkErr map[int]error + discoverPage *tmdb.MediaPage + discoverErr error } func (f *fakeTMDBClient) SearchMedia(context.Context, string, string, int) (*tmdb.MediaPage, error) { @@ -528,6 +639,16 @@ func (f *fakeTMDBClient) DiscoverSection(context.Context, string, int) (*tmdb.Me 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, int) (*tmdb.ExternalIDs, error) { return f.externalIDs, nil } @@ -536,6 +657,26 @@ func (f *fakeTMDBClient) GetMediaDetail(context.Context, string, int) (*tmdb.Med return f.detail, 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 +} + type fakeMovieAdapter struct { result FulfillmentResult status FulfillmentStatus From 216fbbabbe03cea0f59c4a7b59804256bc3457a4 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 18:39:16 -0400 Subject: [PATCH 11/53] feat(requests): add BrowseStudio/BrowseNetwork/BrowseGenre service methods --- internal/requests/discover_brand.go | 186 ++++++++++++++++++++++++++++ internal/requests/service_test.go | 107 ++++++++++++++++ 2 files changed, 293 insertions(+) diff --git a/internal/requests/discover_brand.go b/internal/requests/discover_brand.go index dbc0d814..e605a98f 100644 --- a/internal/requests/discover_brand.go +++ b/internal/requests/discover_brand.go @@ -4,6 +4,9 @@ import ( "context" "fmt" "log/slog" + "strings" + + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" ) // DiscoverBrandCard is one card on the Studios / Networks / Genres carousels. @@ -101,3 +104,186 @@ func (s *Service) resolveLogoURL(ctx context.Context, cache *logoCache, id int, func tmdbImageURL(path, size string) string { return "https://image.tmdb.org/t/p/" + size + path } + +// 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"` +} + +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, + 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) { + 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, + 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. +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/service_test.go b/internal/requests/service_test.go index 89e4b86a..eb283d2c 100644 --- a/internal/requests/service_test.go +++ b/internal/requests/service_test.go @@ -602,6 +602,113 @@ func TestListGenresReturnsBundleWithSeriesSupportFlag(t *testing.T) { } } +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 { available map[MediaType]map[int]bool } From 8789249f44692f803fe7b430733456ac25e98dfe Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 18:41:31 -0400 Subject: [PATCH 12/53] feat(api): add discover brand and browse handlers --- internal/api/handlers/requests.go | 110 ++++++++++++ internal/api/handlers/requests_test.go | 233 +++++++++++++++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 internal/api/handlers/requests_test.go diff --git a/internal/api/handlers/requests.go b/internal/api/handlers/requests.go index da6d0729..497eb9c0 100644 --- a/internal/api/handlers/requests.go +++ b/internal/api/handlers/requests.go @@ -34,6 +34,13 @@ type RequestService interface { 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) } type RequestsHandler struct { @@ -99,6 +106,109 @@ func (h *RequestsHandler) HandleDiscoverSection(w http.ResponseWriter, r *http.R 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 { diff --git a/internal/api/handlers/requests_test.go b/internal/api/handlers/requests_test.go new file mode 100644 index 00000000..ba818cee --- /dev/null +++ b/internal/api/handlers/requests_test.go @@ -0,0 +1,233 @@ +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) 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 +} + +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", 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()) + } +} From 4321cb797cb06b5d0ed4266ea2d82f8958384d9f Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 18:42:02 -0400 Subject: [PATCH 13/53] feat(api): wire discover studios/networks/genres routes --- internal/api/router.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/api/router.go b/internal/api/router.go index d39634b0..b97240f4 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1396,6 +1396,12 @@ func NewRouter(deps Dependencies) 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) From b368977ee84709d540ffe856651e38992253d0b6 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 18:42:45 -0400 Subject: [PATCH 14/53] feat(web): add discover brand and browse response types --- web/src/api/types.ts | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 1060955c..279b8b74 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1469,6 +1469,44 @@ export interface RequestDiscoveryResponse { sections: RequestDiscoverySection[]; } +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[]; +} + export interface CreateMediaRequestInput { media_type: RequestMediaType; tmdb_id: number; From a96f667bb517906572db8157370ab3e377fd6eaa Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 18:43:25 -0400 Subject: [PATCH 15/53] feat(web): add discover brand and browse query hooks --- web/src/hooks/queries/keys.ts | 20 ++++++++++ web/src/hooks/queries/requests.ts | 63 +++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/web/src/hooks/queries/keys.ts b/web/src/hooks/queries/keys.ts index ad7325f0..87d94fcf 100644 --- a/web/src/hooks/queries/keys.ts +++ b/web/src/hooks/queries/keys.ts @@ -121,6 +121,26 @@ export const requestKeys = { 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, diff --git a/web/src/hooks/queries/requests.ts b/web/src/hooks/queries/requests.ts index eab55ed0..b4422946 100644 --- a/web/src/hooks/queries/requests.ts +++ b/web/src/hooks/queries/requests.ts @@ -3,6 +3,11 @@ import { toast } from "sonner"; import { api } from "@/api/client"; import type { CreateMediaRequestInput, + DiscoverBrowseKind, + DiscoverBrowseResponse, + DiscoverGenresResponse, + DiscoverNetworksResponse, + DiscoverStudiosResponse, LoadRequestIntegrationOptionsRequest, MediaRequest, MediaRequestsListResponse, @@ -21,6 +26,8 @@ import type { 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 { @@ -67,6 +74,62 @@ export function useRequestDiscoverySection(section: string, page = 1) { }); } +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), From 510f9832e8e495eb268f13fef4699b5a0877fd0b Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 18:44:04 -0400 Subject: [PATCH 16/53] feat(web): add BrandCard and BrandCarousel components --- web/src/components/BrandCard.tsx | 58 ++++++++++++++++++++++++++++ web/src/components/BrandCarousel.tsx | 53 +++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 web/src/components/BrandCard.tsx create mode 100644 web/src/components/BrandCarousel.tsx diff --git a/web/src/components/BrandCard.tsx b/web/src/components/BrandCard.tsx new file mode 100644 index 00000000..518a2265 --- /dev/null +++ b/web/src/components/BrandCard.tsx @@ -0,0 +1,58 @@ +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(); + const isGenre = kind === "genre"; + const background = isGenre + ? `linear-gradient(135deg, ${card.gradient_from ?? "#475569"}, ${card.gradient_to ?? "#0f172a"})` + : card.brand_color || "#1f2937"; + + 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}`); + return; + } + navigate(base); + } + + return ( + + ); +} diff --git a/web/src/components/BrandCarousel.tsx b/web/src/components/BrandCarousel.tsx new file mode 100644 index 00000000..d0a07100 --- /dev/null +++ b/web/src/components/BrandCarousel.tsx @@ -0,0 +1,53 @@ +import type { DiscoverBrandCard, DiscoverBrowseKind } from "@/api/types"; +import BrandCard from "@/components/BrandCard"; +import { Skeleton } from "@/components/ui/skeleton"; + +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 ? ( + + ) : null} +
    +
    +
    + {isLoading ? ( + Array.from({ length: 8 }).map((_, idx) => ( + + )) + ) : isError ? ( +
    + Could not load {title.toLowerCase()}. +
    + ) : ( + (cards ?? []).map((card) => ) + )} +
    +
    +
    + ); +} From 330fbc261c31fbc0998e07a9d990bcc9482abeb3 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 18:44:56 -0400 Subject: [PATCH 17/53] feat(web): render studios/networks/genres carousels in Requests discover --- web/src/pages/Requests.tsx | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/web/src/pages/Requests.tsx b/web/src/pages/Requests.tsx index d7d1d050..a96b3b14 100644 --- a/web/src/pages/Requests.tsx +++ b/web/src/pages/Requests.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from "react"; import type { FormEvent } from "react"; import { Search, Sparkles, X } from "lucide-react"; +import BrandCarousel from "@/components/BrandCarousel"; import MediaCarousel from "@/components/MediaCarousel"; import RequestPosterCard from "@/components/RequestPosterCard"; import { Button } from "@/components/ui/button"; @@ -23,6 +24,9 @@ import type { } from "@/api/types"; import { useCreateMediaRequest, + useDiscoverGenres, + useDiscoverNetworks, + useDiscoverStudios, useMyMediaRequests, useRequestDiscovery, useRequestSearch, @@ -120,6 +124,9 @@ export default function Requests() { const [searchPage, setSearchPage] = useState(1); const discovery = useRequestDiscovery(); + const studios = useDiscoverStudios(); + const networks = useDiscoverNetworks(); + const genres = useDiscoverGenres(); const search = useRequestSearch(mediaType, submittedQuery, searchPage); const mine = useMyMediaRequests({ limit: 100 }); const createRequest = useCreateMediaRequest(); @@ -212,6 +219,30 @@ export default function Requests() { onRequest={submitRequest} /> ))} + void studios.refetch()} + /> + void networks.refetch()} + /> + void genres.refetch()} + />
    )}
    From a3a7e51e71e4df78163de2306bfc875a61ef13f0 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 18:46:31 -0400 Subject: [PATCH 18/53] feat(web): add RequestBrowse page for studio/network/genre browse --- web/src/pages/RequestBrowse.tsx | 250 ++++++++++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 web/src/pages/RequestBrowse.tsx diff --git a/web/src/pages/RequestBrowse.tsx b/web/src/pages/RequestBrowse.tsx new file mode 100644 index 00000000..e4da8f8f --- /dev/null +++ b/web/src/pages/RequestBrowse.tsx @@ -0,0 +1,250 @@ +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 { cn } from "@/lib/utils"; +import type { + DiscoverBrowseKind, + DiscoverBrowseResponse, + RequestMediaResult, + RequestMediaType, +} from "@/api/types"; + +type BrowseSort = "popularity" | "vote_average" | "release_date"; + +const SORT_OPTIONS: { value: BrowseSort; 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 = normalizeSort(searchParams.get("sort")); + const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1); + const mediaTypeFromQuery = normalizeMediaType(searchParams.get("media_type")); + 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 ?? humanizeSlug(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)); + } + + const totalPages = browse.data?.total_pages ?? 0; + const results = browse.data?.results ?? []; + + if (browse.isError && (browse.error as { status?: number }).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 + + + ) : null} +
    + +
    + {browse.isLoading ? ( + + ) : browse.isError ? ( +

    + Could not load this browse page. Try a different sort or media type. +

    + ) : 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} + + +
    + ) : null} +
    + ); +} + +function BrowseHeaderTile({ + browse, + kind, + fallback, +}: { + browse: DiscoverBrowseResponse | undefined; + kind: DiscoverBrowseKind; + fallback: string; +}) { + if (!browse) { + return
    ; + } + if (kind === "genre") { + return ( +
    + {browse.display_name || fallback} +
    + ); + } + return ( +
    + {browse.logo_url ? ( + {browse.display_name} + ) : ( + + {browse.display_name || fallback} + + )} +
    + ); +} + +function BrowseGridSkeleton() { + return ( +
    + {Array.from({ length: 12 }).map((_, idx) => ( + + ))} +
    + ); +} + +function normalizeSort(value: string | null): BrowseSort { + return SORT_OPTIONS.some((option) => option.value === value) ? (value as BrowseSort) : "popularity"; +} + +function normalizeMediaType(value: string | null): RequestMediaType | undefined { + return value === "movie" || value === "series" ? value : undefined; +} + +function humanizeSlug(slug: string) { + return slug + .split("-") + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} From 9f961914c1fd9d631dca304a7bd6b706c362ade8 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 18:47:08 -0400 Subject: [PATCH 19/53] feat(web): wire studio/network/genre browse routes --- web/src/App.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/web/src/App.tsx b/web/src/App.tsx index 583a2910..d83574a9 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -33,6 +33,7 @@ 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"; @@ -450,6 +451,18 @@ function AppRoutes() { /> } /> } /> + } + /> + } + /> + } + /> } /> Date: Sun, 24 May 2026 18:47:56 -0400 Subject: [PATCH 20/53] fix(web): format discover browse files --- web/src/components/BrandCard.tsx | 2 +- web/src/hooks/queries/keys.ts | 12 +----------- web/src/hooks/queries/requests.ts | 8 ++------ web/src/pages/RequestBrowse.tsx | 17 +++++++++++++---- 4 files changed, 17 insertions(+), 22 deletions(-) diff --git a/web/src/components/BrandCard.tsx b/web/src/components/BrandCard.tsx index 518a2265..8441229f 100644 --- a/web/src/components/BrandCard.tsx +++ b/web/src/components/BrandCard.tsx @@ -37,7 +37,7 @@ export default function BrandCard({ aria-label={card.display_name} className={cn( "group relative flex h-20 w-[140px] flex-none items-center justify-center overflow-hidden rounded-lg shadow-sm", - "ring-1 ring-white/5 transition-colors hover:ring-white/30 focus:outline-none focus:ring-2 focus:ring-white", + "ring-1 ring-white/5 transition-colors hover:ring-white/30 focus:ring-2 focus:ring-white focus:outline-none", )} style={{ background }} > diff --git a/web/src/hooks/queries/keys.ts b/web/src/hooks/queries/keys.ts index 87d94fcf..c857ae6a 100644 --- a/web/src/hooks/queries/keys.ts +++ b/web/src/hooks/queries/keys.ts @@ -130,17 +130,7 @@ export const requestKeys = { mediaType: string | undefined, sort: string, page: number, - ) => - [ - "requests", - "discover", - "browse", - kind, - slug, - mediaType ?? "", - sort, - page, - ] as const, + ) => ["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, diff --git a/web/src/hooks/queries/requests.ts b/web/src/hooks/queries/requests.ts index b4422946..2c2f0f43 100644 --- a/web/src/hooks/queries/requests.ts +++ b/web/src/hooks/queries/requests.ts @@ -78,9 +78,7 @@ export function useDiscoverStudios() { return useQuery({ queryKey: requestKeys.discoverStudios(), queryFn: () => - api("/requests/discover/studios").then( - (data) => data.studios ?? [], - ), + api("/requests/discover/studios").then((data) => data.studios ?? []), staleTime: DISCOVER_BRAND_STALE_TIME, }); } @@ -100,9 +98,7 @@ export function useDiscoverGenres() { return useQuery({ queryKey: requestKeys.discoverGenres(), queryFn: () => - api("/requests/discover/genres").then( - (data) => data.genres ?? [], - ), + api("/requests/discover/genres").then((data) => data.genres ?? []), staleTime: DISCOVER_BRAND_STALE_TIME, }); } diff --git a/web/src/pages/RequestBrowse.tsx b/web/src/pages/RequestBrowse.tsx index e4da8f8f..0249aa12 100644 --- a/web/src/pages/RequestBrowse.tsx +++ b/web/src/pages/RequestBrowse.tsx @@ -42,7 +42,7 @@ export default function RequestBrowse({ kind }: RequestBrowseProps) { const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1); const mediaTypeFromQuery = normalizeMediaType(searchParams.get("media_type")); const mediaType: RequestMediaType | undefined = - kind === "studio" ? "movie" : kind === "network" ? "series" : mediaTypeFromQuery ?? "movie"; + kind === "studio" ? "movie" : kind === "network" ? "series" : (mediaTypeFromQuery ?? "movie"); const browse = useRequestBrowse({ kind, slug, mediaType, sort, page }); const createRequest = useCreateMediaRequest(); @@ -84,7 +84,10 @@ export default function RequestBrowse({ kind }: RequestBrowseProps) {

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

    - + Back to Requests
    @@ -175,7 +178,11 @@ export default function RequestBrowse({ kind }: RequestBrowseProps) { Page {page} of {totalPages} -
    @@ -234,7 +241,9 @@ function BrowseGridSkeleton() { } function normalizeSort(value: string | null): BrowseSort { - return SORT_OPTIONS.some((option) => option.value === value) ? (value as BrowseSort) : "popularity"; + return SORT_OPTIONS.some((option) => option.value === value) + ? (value as BrowseSort) + : "popularity"; } function normalizeMediaType(value: string | null): RequestMediaType | undefined { From 509a6c84ba377add9e3ff1418d226222947260c6 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 19:41:13 -0400 Subject: [PATCH 21/53] feat(requests): add discover studios, networks, and genres Wire curated TMDB-backed studios/networks/genres discovery into the requests service and UI, replacing on-demand logo fetches with fixed duotone logos and adding browse routes plus tests. Co-authored-by: Cursor --- ...-05-24-discover-studios-networks-genres.md | 3246 +++++++++++++++++ internal/api/handlers/requests_test.go | 2 +- internal/metadata/tmdb/client.go | 26 - internal/metadata/tmdb/client_test.go | 65 - internal/metadata/tmdb/types.go | 14 - internal/requests/discover_brand.go | 57 +- internal/requests/discover_bundle.go | 55 +- internal/requests/discover_bundle_test.go | 8 +- internal/requests/discover_logo_cache.go | 86 - internal/requests/discover_logo_cache_test.go | 115 - internal/requests/service.go | 21 +- internal/requests/service_test.go | 95 +- web/src/api/types.ts | 2 - web/src/components/BrandCard.tsx | 37 +- web/src/components/BrandCarousel.tsx | 81 +- web/src/components/RequestPosterCard.tsx | 28 +- web/src/pages/RequestBrowse.tsx | 8 +- 17 files changed, 3429 insertions(+), 517 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-24-discover-studios-networks-genres.md delete mode 100644 internal/requests/discover_logo_cache.go delete mode 100644 internal/requests/discover_logo_cache_test.go 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/internal/api/handlers/requests_test.go b/internal/api/handlers/requests_test.go index ba818cee..c3945d17 100644 --- a/internal/api/handlers/requests_test.go +++ b/internal/api/handlers/requests_test.go @@ -143,7 +143,7 @@ func TestHandleListStudiosReturnsJSON(t *testing.T) { svc := &fakeRequestService{ listStudiosFn: func() ([]mediarequests.DiscoverBrandCard, error) { return []mediarequests.DiscoverBrandCard{ - {TMDBID: 420, Slug: "marvel-studios", DisplayName: "Marvel Studios", BrandColor: "#ed1d24", LogoURL: &logo}, + {TMDBID: 420, Slug: "marvel-studios", DisplayName: "Marvel Studios", LogoURL: &logo}, }, nil }, } diff --git a/internal/metadata/tmdb/client.go b/internal/metadata/tmdb/client.go index edefaeb1..5e79a55e 100644 --- a/internal/metadata/tmdb/client.go +++ b/internal/metadata/tmdb/client.go @@ -544,32 +544,6 @@ func (c *Client) DiscoverPage(ctx context.Context, mediaType string, params Disc return normalizeMoviePage(resp), nil } -// 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 -} - // 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 { diff --git a/internal/metadata/tmdb/client_test.go b/internal/metadata/tmdb/client_test.go index 1990b8d2..86e91eb6 100644 --- a/internal/metadata/tmdb/client_test.go +++ b/internal/metadata/tmdb/client_test.go @@ -379,71 +379,6 @@ func TestDiscoverPageDefaultsToPage1(t *testing.T) { } } -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) - } -} - func TestSearchMediaMovie(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/search/movie" { diff --git a/internal/metadata/tmdb/types.go b/internal/metadata/tmdb/types.go index e9ecfc22..eefcce92 100644 --- a/internal/metadata/tmdb/types.go +++ b/internal/metadata/tmdb/types.go @@ -86,20 +86,6 @@ type ExternalIDs struct { TVDBID int `json:"tvdb_id"` } -// 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"` -} - // Collection is the decoded payload of TMDB's /collection/{id} endpoint. // TMDB collections only contain movies (franchises, sagas), so each Part is // implicitly a movie. The Parts slice preserves TMDB's ordering, which is diff --git a/internal/requests/discover_brand.go b/internal/requests/discover_brand.go index e605a98f..cf95ff09 100644 --- a/internal/requests/discover_brand.go +++ b/internal/requests/discover_brand.go @@ -3,32 +3,27 @@ package requests import ( "context" "fmt" - "log/slog" "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 brand color plus lazy-fetched logo. -// Genres carry no TMDB ID at this layer and render with a gradient and display -// name instead of a logo. +// 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"` - 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 { +// 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)) @@ -37,16 +32,15 @@ func (s *Service) ListStudios(ctx context.Context, _ Viewer) ([]DiscoverBrandCar TMDBID: studio.TMDBID, Slug: studio.Slug, DisplayName: studio.DisplayName, - BrandColor: studio.BrandColor, - LogoURL: s.resolveLogoURL(ctx, s.companyLogos, studio.TMDBID, "company", studio.Slug), + LogoURL: duotoneLogoURL(studio.LogoPath), }) } 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 { +// 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)) @@ -55,8 +49,7 @@ func (s *Service) ListNetworks(ctx context.Context, _ Viewer) ([]DiscoverBrandCa TMDBID: network.TMDBID, Slug: network.Slug, DisplayName: network.DisplayName, - BrandColor: network.BrandColor, - LogoURL: s.resolveLogoURL(ctx, s.networkLogos, network.TMDBID, "network", network.Slug), + LogoURL: duotoneLogoURL(network.LogoPath), }) } return out, nil @@ -82,29 +75,18 @@ func (s *Service) ListGenres(_ context.Context, _ Viewer) ([]DiscoverBrandCard, return out, nil } -// resolveLogoURL fetches the cached logo path and renders it as a TMDB image -// URL. It returns nil on lookup failure or when TMDB has no logo for the entity. -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 - } +// 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 := tmdbImageURL(path, "w300") + url := "https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)" + path return &url } -// tmdbImageURL is the public TMDB image CDN URL for a given file path and size. -func tmdbImageURL(path, size string) string { - return "https://image.tmdb.org/t/p/" + size + path -} - // 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. @@ -112,7 +94,6 @@ 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"` @@ -159,8 +140,7 @@ func (s *Service) BrowseStudio(ctx context.Context, viewer Viewer, slug, sort st Kind: "studio", Slug: studio.Slug, DisplayName: studio.DisplayName, - BrandColor: studio.BrandColor, - LogoURL: s.resolveLogoURL(ctx, s.companyLogos, studio.TMDBID, "company", studio.Slug), + LogoURL: duotoneLogoURL(studio.LogoPath), MediaType: MediaTypeMovie, Sort: sortKey, Page: enriched.Page, @@ -198,8 +178,7 @@ func (s *Service) BrowseNetwork(ctx context.Context, viewer Viewer, slug, sort s Kind: "network", Slug: network.Slug, DisplayName: network.DisplayName, - BrandColor: network.BrandColor, - LogoURL: s.resolveLogoURL(ctx, s.networkLogos, network.TMDBID, "network", network.Slug), + LogoURL: duotoneLogoURL(network.LogoPath), MediaType: MediaTypeSeries, Sort: sortKey, Page: enriched.Page, diff --git a/internal/requests/discover_bundle.go b/internal/requests/discover_bundle.go index 56697147..5076c391 100644 --- a/internal/requests/discover_bundle.go +++ b/internal/requests/discover_bundle.go @@ -1,21 +1,23 @@ 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=. +// 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 - BrandColor string + LogoPath string } // BundledNetwork is a curated TV network surfaced in the request discover -// section. The TMDB ID identifies the network in /discover/tv?with_networks=. +// section. LogoPath is a TMDB image file path rendered through the duotone +// filter. type BundledNetwork struct { TMDBID int Slug string DisplayName string - BrandColor string + LogoPath string } // BundledGenre is a curated genre. MovieID is the TMDB movie genre ID; @@ -30,33 +32,34 @@ type BundledGenre struct { } // BundledStudios is the compile-time list of studios shown in the Studios -// carousel. Order is preservation order; render in this order. +// 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: "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"}, + {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. Order is preservation order. +// carousel. 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"}, + {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 diff --git a/internal/requests/discover_bundle_test.go b/internal/requests/discover_bundle_test.go index e67f401a..f2a1b967 100644 --- a/internal/requests/discover_bundle_test.go +++ b/internal/requests/discover_bundle_test.go @@ -28,8 +28,8 @@ func TestBundleStudiosHaveRequiredFields(t *testing.T) { 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) + 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) } } } @@ -45,8 +45,8 @@ func TestBundleNetworksHaveRequiredFields(t *testing.T) { 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) + 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) } } } diff --git a/internal/requests/discover_logo_cache.go b/internal/requests/discover_logo_cache.go deleted file mode 100644 index 39daaef9..00000000 --- a/internal/requests/discover_logo_cache.go +++ /dev/null @@ -1,86 +0,0 @@ -package requests - -import ( - "context" - "strconv" - "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 do not -// 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 - } - - value, err, _ := c.group.Do(keyFromID(id), 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:" + strconv.Itoa(id) -} diff --git a/internal/requests/discover_logo_cache_test.go b/internal/requests/discover_logo_cache_test.go deleted file mode 100644 index afad56b5..00000000 --- a/internal/requests/discover_logo_cache_test.go +++ /dev/null @@ -1,115 +0,0 @@ -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) - } -} diff --git a/internal/requests/service.go b/internal/requests/service.go index 0aff49a4..e87a6a3b 100644 --- a/internal/requests/service.go +++ b/internal/requests/service.go @@ -16,8 +16,6 @@ type TMDBClient interface { 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) } type TMDBExternalIDClient interface { @@ -59,8 +57,6 @@ type Service struct { secrets SecretResolver movieAdapter MovieFulfillmentAdapter seriesAdapter SeriesFulfillmentAdapter - companyLogos *logoCache - networkLogos *logoCache Now func() time.Time } @@ -74,27 +70,12 @@ type DiscoverySection struct { } func NewService(store Store, tmdbClient TMDBClient, presence PresenceResolver) *Service { - svc := &Service{ + return &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 } func (s *Service) SetSecretResolver(resolver SecretResolver) { diff --git a/internal/requests/service_test.go b/internal/requests/service_test.go index eb283d2c..c6be4ebb 100644 --- a/internal/requests/service_test.go +++ b/internal/requests/service_test.go @@ -3,6 +3,7 @@ package requests import ( "context" "errors" + "strings" "testing" "time" @@ -497,13 +498,8 @@ func (f *fakeStore) UpsertIntegration(context.Context, Integration) (*Integratio return nil, nil } -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) +func TestListStudiosReturnsBundleWithDuotoneLogos(t *testing.T) { + service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{}) studios, err := service.ListStudios(context.Background(), testViewer(1)) if err != nil { @@ -513,55 +509,19 @@ func TestListStudiosReturnsBundleWithLogos(t *testing.T) { 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 + 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) } } - 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) +func TestListNetworksReturnsBundleWithDuotoneLogos(t *testing.T) { + service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{}) networks, err := service.ListNetworks(context.Background(), testViewer(1)) if err != nil { @@ -570,6 +530,15 @@ func TestListNetworksReturnsBundle(t *testing.T) { 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) { @@ -730,10 +699,6 @@ type fakeTMDBClient struct { page *tmdb.MediaPage externalIDs *tmdb.ExternalIDs detail *tmdb.MediaDetail - companies map[int]*tmdb.Company - companyErr map[int]error - networks map[int]*tmdb.Network - networkErr map[int]error discoverPage *tmdb.MediaPage discoverErr error } @@ -764,26 +729,6 @@ func (f *fakeTMDBClient) GetMediaDetail(context.Context, string, int) (*tmdb.Med return f.detail, 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 -} - type fakeMovieAdapter struct { result FulfillmentResult status FulfillmentStatus diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 279b8b74..f674bf68 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1473,7 +1473,6 @@ export interface DiscoverBrandCard { tmdb_id?: number; slug: string; display_name: string; - brand_color?: string; logo_url?: string | null; gradient_from?: string; gradient_to?: string; @@ -1498,7 +1497,6 @@ 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"; diff --git a/web/src/components/BrandCard.tsx b/web/src/components/BrandCard.tsx index 8441229f..b92d3315 100644 --- a/web/src/components/BrandCard.tsx +++ b/web/src/components/BrandCard.tsx @@ -15,9 +15,6 @@ export default function BrandCard({ }: BrandCardProps) { const navigate = useNavigate(); const isGenre = kind === "genre"; - const background = isGenre - ? `linear-gradient(135deg, ${card.gradient_from ?? "#475569"}, ${card.gradient_to ?? "#0f172a"})` - : card.brand_color || "#1f2937"; function handleClick() { const base = `/requests/browse/${kind}/${encodeURIComponent(card.slug)}`; @@ -30,26 +27,48 @@ export default function BrandCard({ navigate(base); } + const baseClasses = + "group relative flex h-28 w-52 flex-none transform-gpu cursor-pointer items-center justify-center overflow-hidden rounded-xl shadow-sm ring-1 transition duration-300 ease-in-out hover:scale-[1.03] focus:scale-[1.03] focus:outline-none sm:h-32 sm:w-64"; + + if (isGenre) { + const background = `linear-gradient(135deg, ${card.gradient_from ?? "#475569"}, ${card.gradient_to ?? "#0f172a"})`; + return ( + + ); + } + return ( ) : null}
    -
    -
    - {isLoading ? ( - Array.from({ length: 8 }).map((_, idx) => ( - - )) - ) : isError ? ( -
    - Could not load {title.toLowerCase()}. -
    - ) : ( - (cards ?? []).map((card) => ) + + {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/RequestPosterCard.tsx b/web/src/components/RequestPosterCard.tsx index ec97de33..7f2ae1d9 100644 --- a/web/src/components/RequestPosterCard.tsx +++ b/web/src/components/RequestPosterCard.tsx @@ -177,18 +177,16 @@ function PosterFrame({ children?: React.ReactNode; }) { return ( -
    +
    {poster ? ( ) : (
    @@ -205,7 +203,7 @@ function PosterFrame({ function TypeBadge({ mediaType }: { mediaType: "movie" | "series" }) { return ( - + {mediaType === "series" ? "Series" : "Movie"} ); @@ -234,13 +232,13 @@ type RibbonKind = "pending" | "approved" | "queued" | "downloading" | "completed const RIBBON_STYLES: Record = { pending: - "bg-amber-500/15 text-amber-100 ring-amber-400/40 [&_.dot]:bg-amber-300 [&_.dot]:animate-pulse", - approved: "bg-emerald-500/15 text-emerald-100 ring-emerald-400/40 [&_.dot]:bg-emerald-300", - queued: "bg-sky-500/15 text-sky-100 ring-sky-400/40 [&_.dot]:bg-sky-300 [&_.dot]:animate-pulse", + "bg-amber-500/85 text-amber-50 ring-amber-300/60 [&_.dot]:bg-amber-200 [&_.dot]:animate-pulse", + approved: "bg-emerald-500/85 text-emerald-50 ring-emerald-300/60 [&_.dot]:bg-emerald-200", + queued: "bg-sky-500/85 text-sky-50 ring-sky-300/60 [&_.dot]:bg-sky-200 [&_.dot]:animate-pulse", downloading: - "bg-sky-500/20 text-sky-100 ring-sky-400/50 [&_.dot]:bg-sky-300 [&_.dot]:animate-pulse", - completed: "bg-emerald-500/20 text-emerald-100 ring-emerald-400/40 [&_.dot]:bg-emerald-300", - blocked: "bg-zinc-700/60 text-zinc-200 ring-zinc-500/40 [&_.dot]:bg-zinc-400", + "bg-sky-500/90 text-sky-50 ring-sky-300/70 [&_.dot]:bg-sky-200 [&_.dot]:animate-pulse", + completed: "bg-emerald-500/90 text-emerald-50 ring-emerald-300/60 [&_.dot]:bg-emerald-200", + blocked: "bg-zinc-800/85 text-zinc-50 ring-zinc-400/60 [&_.dot]:bg-zinc-300", }; function StatusRibbon({ status, label }: { status: string; label: string }) { @@ -248,7 +246,7 @@ function StatusRibbon({ status, label }: { status: string; label: string }) { return ( diff --git a/web/src/pages/RequestBrowse.tsx b/web/src/pages/RequestBrowse.tsx index 0249aa12..5d32e936 100644 --- a/web/src/pages/RequestBrowse.tsx +++ b/web/src/pages/RequestBrowse.tsx @@ -14,7 +14,6 @@ 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 { cn } from "@/lib/utils"; import type { DiscoverBrowseKind, DiscoverBrowseResponse, @@ -211,15 +210,12 @@ function BrowseHeaderTile({ ); } return ( -
    +
    {browse.logo_url ? ( {browse.display_name} ) : ( From dcdf791c3c211ddf7ae4227651d5c7de69206ee4 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 19:58:22 -0400 Subject: [PATCH 22/53] chore: add local path leak pre-commit guard --- .githooks/pre-commit | 4 ++ AGENTS.md | 2 + Makefile | 10 ++++- scripts/check-local-path-leaks.sh | 64 +++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 1 deletion(-) create mode 100755 .githooks/pre-commit create mode 100755 scripts/check-local-path-leaks.sh diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 00000000..ee5b75b9 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +set -eu + +scripts/check-local-path-leaks.sh --cached diff --git a/AGENTS.md b/AGENTS.md index 7c863965..b604f99f 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. diff --git a/Makefile b/Makefile index 4deea2ce..9edb443f 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,14 @@ 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: + 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/scripts/check-local-path-leaks.sh b/scripts/check-local-path-leaks.sh new file mode 100755 index 00000000..af860c68 --- /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})" \ + . + +check_pattern \ + "absolute /Users path in generated superpowers docs" \ + '/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 From f023c4a6f9afff86cf5078704110da9c6a3887b3 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 21:30:02 -0400 Subject: [PATCH 23/53] feat(requests): support combined movie and series search - Add `media_type=all` to request search, backed by TMDB `/search/multi` filtered to movies and series - Default the Requests page filter to All and refresh search results grid styling - Refine RequestPosterCard with status accent bar, richer fallback poster, and fluid grid layout --- .../2026-05-25-request-search-all-design.md | 25 +++ internal/metadata/tmdb/client.go | 56 ++++- internal/metadata/tmdb/client_test.go | 68 ++++++ internal/metadata/tmdb/types.go | 14 ++ internal/requests/service.go | 15 +- internal/requests/service_test.go | 65 +++++- internal/requests/types.go | 1 + web/src/api/types.ts | 1 + web/src/components/RequestPosterCard.tsx | 208 +++++++++++++----- web/src/hooks/queries/requests.ts | 3 +- web/src/pages/Requests.tsx | 99 +++++++-- 11 files changed, 464 insertions(+), 91 deletions(-) create mode 100644 docs/superpowers/specs/2026-05-25-request-search-all-design.md 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/internal/metadata/tmdb/client.go b/internal/metadata/tmdb/client.go index 5e79a55e..9fd3dd95 100644 --- a/internal/metadata/tmdb/client.go +++ b/internal/metadata/tmdb/client.go @@ -133,9 +133,9 @@ func retryAfterOrDefault(resp *http.Response, attempt int) time.Duration { return time.Duration(1< 100 { filter.Limit = 50 diff --git a/internal/requests/service_test.go b/internal/requests/service_test.go index c6be4ebb..5873ace8 100644 --- a/internal/requests/service_test.go +++ b/internal/requests/service_test.go @@ -293,6 +293,57 @@ func TestSearchEnrichmentShowsOwnRequestID(t *testing.T) { } } +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{{ @@ -696,14 +747,16 @@ func (f *fakePresence) LookupTMDB(_ context.Context, mediaType MediaType, ids [] } type fakeTMDBClient struct { - page *tmdb.MediaPage - externalIDs *tmdb.ExternalIDs - detail *tmdb.MediaDetail - discoverPage *tmdb.MediaPage - discoverErr error + page *tmdb.MediaPage + externalIDs *tmdb.ExternalIDs + detail *tmdb.MediaDetail + discoverPage *tmdb.MediaPage + discoverErr error + searchMediaType string } -func (f *fakeTMDBClient) SearchMedia(context.Context, string, string, int) (*tmdb.MediaPage, error) { +func (f *fakeTMDBClient) SearchMedia(_ context.Context, mediaType, _ string, _ int) (*tmdb.MediaPage, error) { + f.searchMediaType = mediaType return f.page, nil } diff --git a/internal/requests/types.go b/internal/requests/types.go index 488f8603..b7331821 100644 --- a/internal/requests/types.go +++ b/internal/requests/types.go @@ -9,6 +9,7 @@ type MediaType string const ( MediaTypeMovie MediaType = "movie" MediaTypeSeries MediaType = "series" + MediaTypeAll MediaType = "all" ) type Status string diff --git a/web/src/api/types.ts b/web/src/api/types.ts index f674bf68..bd464b83 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1384,6 +1384,7 @@ export interface ImportUserCollectionResponse { // Media Requests export type RequestMediaType = "movie" | "series"; +export type RequestSearchMediaType = RequestMediaType | "all"; export type MediaRequestStatus = "pending" | "approved" | "queued" | "downloading" | "completed"; export type MediaRequestOutcome = "active" | "declined" | "cancelled" | "failed"; export type RequestAvailability = "missing" | "available"; diff --git a/web/src/components/RequestPosterCard.tsx b/web/src/components/RequestPosterCard.tsx index 7f2ae1d9..b5df43d6 100644 --- a/web/src/components/RequestPosterCard.tsx +++ b/web/src/components/RequestPosterCard.tsx @@ -11,21 +11,29 @@ type DiscoverProps = { 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 ; } return ( - + ); } @@ -33,10 +41,12 @@ 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; @@ -45,12 +55,20 @@ function DiscoverCard({ !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 && } - {/* Status ribbon for already-requested or in-library items */} - {statusLabel ? ( - - ) : availableInLibrary ? ( - - ) : reasonLabel ? ( - - ) : null} - - {/* Hover Request overlay — only for requestable items */} {requestable && ( -
    +