Merge pull request #7 from Silo-Server/t3code/discover-studios-networks-genres-clean

feat(requests): add media request system
This commit is contained in:
Quick
2026-05-25 10:56:57 -04:00
committed by GitHub
70 changed files with 18750 additions and 92 deletions
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
# Resolve via git toplevel so the hook works from any cwd (e.g., when
# git invokes hooks from within a non-root subdirectory).
"$(git rev-parse --show-toplevel)/scripts/check-local-path-leaks.sh" --cached
+3
View File
@@ -3,6 +3,8 @@
## Project Structure & Module Organization
`cmd/silo` contains the main server entrypoint. Backend code lives in `internal/`, organized by domain (`api`, `catalog`, `metadata`, `playback`, `scanner`, `jellycompat`, etc.); keep new code in the package that owns the behavior instead of creating catch-all helpers. Database changes belong in `migrations/` as paired numbered `.up.sql` and `.down.sql` files. The React frontend lives in `web/src/`, with feature code split across `components/`, `pages/`, `hooks/`, `player/`, and `lib/`. Reference material belongs in `docs/architecture/` or `docs/superpowers/{specs,plans}/`; ad hoc SQL helpers live in `scripts/`.
When creating or editing `docs/superpowers/specs/` or `docs/superpowers/plans/`, never include local absolute filesystem paths or transient worktree IDs. Use repository-relative paths and wording like "Commands assume the repository root is the cwd."
This repository is a VERY EARLY WIP. Proposing sweeping changes that improve long-term maintainability is encouraged.
@@ -56,6 +58,7 @@ Run before opening a merge request:
- `cd web && pnpm run lint`
- `cd web && pnpm run format:check`
- `make verify-local-paths`
For local services, start PostgreSQL and Redis with `docker compose up -d postgres redis`.
+13 -1
View File
@@ -1,4 +1,4 @@
.PHONY: frontend build dev-frontend dev-backend dev-proxy dev-transcode lint clean jellyfin-web-bundle migrate-continuum-check
.PHONY: frontend build dev-frontend dev-backend dev-proxy dev-transcode lint clean jellyfin-web-bundle migrate-continuum-check verify-local-paths install-hooks
GIT_COMMON_DIR := $(strip $(shell git rev-parse --git-common-dir 2>/dev/null))
MAIN_CHECKOUT_ROOT := $(if $(GIT_COMMON_DIR),$(abspath $(GIT_COMMON_DIR)/..))
@@ -43,6 +43,18 @@ lint:
golangci-lint run
cd web && pnpm run lint
# Check committed content for local machine path leaks.
verify-local-paths:
scripts/check-local-path-leaks.sh
# Install repo-local git hooks for this checkout/worktree.
install-hooks:
@existing="$$(git config --local core.hooksPath 2>/dev/null || true)"; \
if [ -n "$$existing" ] && [ "$$existing" != ".githooks" ]; then \
echo "warning: overwriting existing local core.hooksPath ($$existing) with .githooks"; \
fi
git config core.hooksPath .githooks
# Fetch and build the pinned Jellyfin Web bundle
jellyfin-web-bundle:
JELLYFIN_WEB_OUTPUT_DIR=$(JELLYFIN_WEB_OUTPUT_DIR) scripts/fetch-jellyfin-web.sh
+14
View File
@@ -65,6 +65,9 @@ import (
"github.com/Silo-Server/silo-server/internal/proxy"
"github.com/Silo-Server/silo-server/internal/ratelimit"
"github.com/Silo-Server/silo-server/internal/recommendations"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
"github.com/Silo-Server/silo-server/internal/requests/radarr"
"github.com/Silo-Server/silo-server/internal/requests/sonarr"
"github.com/Silo-Server/silo-server/internal/s3client"
"github.com/Silo-Server/silo-server/internal/scanner"
"github.com/Silo-Server/silo-server/internal/scanqueue"
@@ -1264,6 +1267,17 @@ func main() {
if watchProviderService != nil {
taskMgr.Register(tasks.NewSyncWatchProvidersTask(watchProviderService))
}
requestReconcileSvc := mediarequests.NewService(
mediarequests.NewRepository(deps.DB),
nil,
mediarequests.NewCatalogPresence(
catalog.NewItemRepository(deps.DB),
catalog.NewProviderIDRepository(deps.DB),
),
)
requestReconcileSvc.SetSecretResolver(settingsRepo)
requestReconcileSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil))
taskMgr.Register(tasks.NewReconcileRequestsTask(requestReconcileSvc, 100))
reconcileProviderIDRepo := catalog.NewProviderIDRepository(deps.DB)
reconcileEpisodeRepo := catalog.NewEpisodeRepository(deps.DB)
historyResolver := watchstate.NewStableIdentityResolver(nil, reconcileEpisodeRepo, reconcileProviderIDRepo)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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.
@@ -0,0 +1,351 @@
# Discover: Studios, Networks, and Genres
Status: design approved 2026-05-24
## Dependency
This feature extends the media request system spec'd in
`docs/superpowers/specs/request-system.md`. It depends on `internal/requests/`,
`internal/metadata/tmdb/`, `Requests.tsx`, and the existing
`/api/v1/requests/discover/*` route prefix. Commands assume the repository root
is the cwd.
## Goal
Add an Overseerr-style browse-by-brand experience to the request discover
surface. Users land on `/requests`, see existing Trending / Popular / Upcoming /
On Air carousels, and below them three new carousels of brand cards:
- **Studios** — Disney, Pixar, Marvel, etc. Click → movie browse page.
- **Networks** — Netflix, HBO, Apple TV+, etc. Click → series browse page.
- **Genres** — Action, Comedy, Drama, etc. Click → genre browse page with
Movies / Series tabs.
Each brand card is a curated entry tied to a TMDB company, network, or genre
ID. Click-through lands on a paginated, sortable grid of TMDB results enriched
with Silo availability and request state — the same enrichment used by the
existing six discovery sections.
## V1 Scope
- A bundled, compile-time list of ~10 studios, ~10 networks, and ~8 genres.
- Logos fetched from TMDB at runtime (`company.logo_path`,
`network.logo_path`), cached server-side for 24h.
- Genre cards rendered with a gradient + display name (TMDB has no logos for
genres).
- Three new discover list endpoints returning brand cards.
- Three new browse endpoints returning enriched paginated TMDB results.
- Sort by Popularity (default), Vote Average, or Release Date.
- Studios browse: movies only. Networks browse: series only. Genres browse:
Movies / Series tabs, Series tab hidden when the genre has no TV equivalent.
## Non-Goals (v1)
- Admin UI for editing the bundled list — compile-time only.
- Cross-filter on the browse page (e.g., Marvel + Action).
- Infinite scroll — page-based pagination only.
- Studios browse showing series, networks browse showing movies — strict 1:1
media-type mapping.
- Per-user favorites or pinned studios.
- Search-within-browse-page.
- Backdrop variants for genre cards — gradient + text only.
- Internationalized brand display names — English only.
## Architecture
New domain code lives under `internal/requests/` alongside the existing request
system files. No new top-level package, no sub-package — keep the layout flat
to match the rest of `internal/requests/`.
New files:
- `discover_bundle.go` — hardcoded `BundledStudio`, `BundledNetwork`,
`BundledGenre` slices. Slug → entity lookup helpers.
- `discover_logo_cache.go` — in-memory `map[int]string` for TMDB company and
network `logo_path`. Lazy fetch, 24h TTL, singleflight to dedupe concurrent
misses.
- `discover_brand.go``ListStudios`, `ListNetworks`, `ListGenres`,
`BrowseStudio`, `BrowseNetwork`, `BrowseGenre` methods. Reuses
`enrichResults` from the existing service for availability + request state.
Extensions to existing code:
- `internal/metadata/tmdb/client.go`
- Add `WithCompanies []int` and `WithNetworks []int` to `DiscoverParams`.
- Add `GetCompany(ctx, id)` and `GetNetwork(ctx, id)` helpers that return the
canonical entity (we only need `logo_path` and `name` from each).
- `internal/api/handlers/requests.go` — six new handler methods.
- `internal/api/router.go` — wire the six new routes under
`/api/v1/requests/discover/...`.
No new database tables. No migrations. Stateful caches are process-local.
## Data Model
### Go types (constants and request/response shapes)
```go
type BundledStudio struct {
TMDBID int // TMDB company ID
Slug string // URL slug, e.g. "marvel-studios"
DisplayName string
BrandColor string // hex, e.g. "#ed1d24"
}
type BundledNetwork struct {
TMDBID int
Slug string
DisplayName string
BrandColor string
}
type BundledGenre struct {
Slug string
DisplayName string
GradientFrom string // hex
GradientTo string // hex
MovieID int // TMDB movie genre ID, always set in v1
SeriesID int // TMDB tv genre ID, 0 if no TV equivalent
}
type DiscoverBrandCard struct {
TMDBID int `json:"tmdb_id,omitempty"` // omitted for genres
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
BrandColor string `json:"brand_color,omitempty"` // studios/networks
LogoURL *string `json:"logo_url,omitempty"` // studios/networks; null on lookup failure
GradientFrom string `json:"gradient_from,omitempty"` // genres
GradientTo string `json:"gradient_to,omitempty"` // genres
SeriesSupported bool `json:"series_supported,omitempty"` // genres; true iff SeriesID > 0
}
type DiscoverBrowseResponse struct {
Kind string `json:"kind"` // "studio" | "network" | "genre"
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
BrandColor string `json:"brand_color,omitempty"`
LogoURL *string `json:"logo_url,omitempty"`
MediaType MediaType `json:"media_type"`
Sort string `json:"sort"`
Page int `json:"page"`
TotalPages int `json:"total_pages"`
Results []MediaResult `json:"results"` // existing internal/requests.MediaResult type, same as search/discover
}
```
### Bundled v1 contents
Exact TMDB IDs verified during implementation against `/company/{id}` and
`/network/{id}` responses. The lists below are starting defaults; the team can
adjust during plan review.
**Studios (10):** Walt Disney Pictures, Pixar, Marvel Studios, Lucasfilm,
Warner Bros. Pictures, Universal Pictures, Paramount Pictures, Sony Pictures,
20th Century Studios, Studio Ghibli.
**Networks (10):** Netflix, Disney+, Apple TV+, HBO, Hulu, Amazon Prime Video,
Max, Paramount+, BBC, FX.
**Genres (8):**
| Slug | Display | Movie ID | Series ID |
|---|---|---|---|
| `action` | Action | 28 | 10759 *(Action & Adventure)* |
| `comedy` | Comedy | 35 | 35 |
| `drama` | Drama | 18 | 18 |
| `sci-fi` | Sci-Fi | 878 | 10765 *(Sci-Fi & Fantasy)* |
| `horror` | Horror | 27 | 0 *(no TV equivalent)* |
| `romance` | Romance | 10749 | 0 *(no TV equivalent)* |
| `animation` | Animation | 16 | 16 |
| `documentary` | Documentary | 99 | 99 |
Genres with `SeriesID = 0` set `SeriesSupported = false`; the Series tab is
hidden on the browse page.
## API Surface
All routes are profile-scoped, served under the existing
`/api/v1/requests/discover/` prefix, with the same auth middleware as existing
discover routes.
### List endpoints
```http
GET /api/v1/requests/discover/studios
GET /api/v1/requests/discover/networks
GET /api/v1/requests/discover/genres
```
Each returns `{ "studios" | "networks" | "genres": [DiscoverBrandCard, ...] }`
in bundle order. A failed logo lookup yields a card with `logo_url: null`
the response is not failed wholesale.
### Browse endpoints
```http
GET /api/v1/requests/discover/browse/studio/{slug}?page=1&sort=popularity
GET /api/v1/requests/discover/browse/network/{slug}?page=1&sort=popularity
GET /api/v1/requests/discover/browse/genre/{slug}?media_type=movie&page=1&sort=popularity
```
- `sort``popularity` (default), `vote_average`, `release_date`. All three
are descending — most popular / highest rated / most recent first. There is
no ascending variant in v1.
- `popularity` → TMDB `popularity.desc`
- `vote_average` → TMDB `vote_average.desc` with `vote_count.gte=100` to
filter low-vote noise
- `release_date` → TMDB `primary_release_date.desc` (movies) or
`first_air_date.desc` (series)
- `media_type``movie`, `series`.
- **Required** on `genre` browse.
- **Omitted** on `studio` browse (always movie) and `network` browse (always
series).
- Invalid combo (e.g., `media_type=series` for a genre with
`SeriesSupported = false`) → 400.
- `{slug}` is the bundle slug. Unknown slug → 404.
- `page` defaults to 1.
Response: `DiscoverBrowseResponse` — see Go types above. `results` items share
the shape of existing search/discover results and carry the same `availability`
and `request: { status, requestable }` fields.
## Caching
| Resource | Cache | Lifetime |
|---|---|---|
| Studio/network `logo_path` | In-memory `map[int]string`, singleflight on miss | 24h |
| Browse responses (TMDB discover results) | Existing TMDB response cache wrapper | 15 min |
| List responses (`/discover/studios` etc.) | In-memory, keyed by `(kind, locale)` | 24h |
All caches are process-local. Restart clears them and they re-fill lazily.
## Frontend
### New components (`web/src/components/`)
- `BrandCarousel.tsx` — horizontal-scroll carousel for brand cards. Same
scroll behavior as the existing `MediaCarousel`, slot dimensions differ
(~140x80 instead of poster aspect ratio).
- `BrandCard.tsx` — renders one brand card. Props:
`{ kind, slug, displayName, brandColor?, logoUrl?, gradientFrom?, gradientTo?, movieSupported?, seriesSupported? }`.
Studios/networks render with `brandColor` + `<img src={logoUrl}>` (or
centered `displayName` text fallback if `logoUrl` is null). Genres render
with a CSS gradient (`gradientFrom``gradientTo`) and centered display
name. Click → `useNavigate()` to the matching browse route.
### `Requests.tsx` changes
Append three new sections after the existing six discovery carousels:
1. `BrandCarousel` for Studios — backed by `useDiscoverStudios()`.
2. `BrandCarousel` for Networks — backed by `useDiscoverNetworks()`.
3. `BrandCarousel` for Genres — backed by `useDiscoverGenres()`.
Skeleton states reuse the existing `Skeleton` import. If a list endpoint
fails, only that carousel shows an inline retry — the rest of the page
remains.
### New routes (`web/src/App.tsx`)
```text
/requests/browse/studio/:slug
/requests/browse/network/:slug
/requests/browse/genre/:slug
```
All three render `RequestBrowse.tsx`. The page reads `kind` from the route,
`slug` from `useParams`, and `media_type` + `sort` + `page` from query string.
### New page (`web/src/pages/RequestBrowse.tsx`)
Layout:
- Header: Back link, brand card preview (or gradient for genres), display
name, result count, and a sort `Select` dropdown.
- Genre pages only: shadcn `Tabs` for Movies / Series, with the Series tab
hidden if `series_supported = false`. The active tab drives the
`media_type` query param.
- Grid: reuses existing `RequestPosterCard` — no changes to that component
since the browse response item shape mirrors the search response item shape.
- Pagination: page-based with Prev/Next buttons matching the `searchPage`
pattern in `Requests.tsx`.
Empty/error states:
- Unknown slug → "Studio/Network/Genre not found" + link back to `/requests`.
- Empty results → "Nothing matched — try a different sort."
- Network error → existing toast pattern from the request hooks.
### New hooks (`web/src/hooks/queries/useRequests.ts`)
- `useDiscoverStudios()`, `useDiscoverNetworks()`, `useDiscoverGenres()`
24h stale time matching the server cache.
- `useRequestBrowse({ kind, slug, mediaType?, sort, page })` — 60-90s stale
time so request state stays fresh.
### New types (`web/src/api/types.ts`)
- `DiscoverStudio`, `DiscoverNetwork`, `DiscoverGenre`,
`DiscoverBrowseResult`.
### Sidebar
No changes. The existing "Requests" sidebar entry covers the new routes.
## Failure modes and edge cases
- **Logo fetch fails for one entity** — card returned with `logo_url: null`;
rest of the list unaffected. Logged as a warning.
- **Genre has no TV equivalent** — `series_supported: false`; Series tab
hidden; `media_type=series` requests for that genre return 400.
- **Browse returns zero results** — empty state on the page; carousel cards
are not pre-checked for result counts.
- **Pagination past TMDB's page-500 cap** — `total_pages` reflects TMDB's
response; the Next button disables when `page == total_pages`.
- **TMDB rate-limited** — existing `retryAfterOrDefault` backoff in the TMDB
client applies. Sustained rate-limiting returns 503; cached carousels are
unaffected.
- **`requests_enabled = false`** — all new endpoints return 403 like the rest
of `/requests/*`. Sidebar entry is hidden by existing behavior.
## Testing
Backend:
- `discover_bundle_test.go` — slug lookups, unknown slug, integrity (no
duplicate slugs, every entry has required fields).
- `discover_logo_cache_test.go` — TTL expiry, miss returns `nil` path, parallel
access does not double-fetch (singleflight).
- `internal/metadata/tmdb/client_test.go` — extend with `WithCompanies` and
`WithNetworks` query construction cases.
- `internal/requests/service_test.go` — extend with `ListStudios/Networks/
Genres` (bundled list returned with logo lookups stubbed) and
`BrowseStudio/Network/Genre` (enriched TMDB results, verify `availability` +
`request` state).
- `internal/api/handlers/requests_test.go` — six new endpoints. Cover auth,
invalid `media_type` on genre browse, unknown slug → 404, unknown `sort` →
400, `requests_enabled = false` → 403.
Frontend: manual verification via `make dev-frontend` against a backend with
the new endpoints. Existing request system commits did not add frontend unit
tests; this feature follows suit.
## Acceptance criteria
- `/requests` shows Studios, Networks, and Genres carousels below the existing
six discovery sections.
- Clicking a studio card navigates to `/requests/browse/studio/{slug}` and
renders a paginated, sortable grid of movies enriched with availability and
request state.
- Clicking a network card navigates to `/requests/browse/network/{slug}` with
series.
- Clicking a genre card navigates to `/requests/browse/genre/{slug}` with
Movies/Series tabs; the Series tab is hidden when `series_supported = false`.
- Sort options (Popularity, Vote Average, Release Date) work on all browse
pages and update the URL.
- A card with a failed logo lookup falls back to text and does not break the
carousel.
- Existing request creation, status, quota, and Radarr/Sonarr fulfillment flows
are unchanged and still pass their tests.
- `make lint`, `cd web && pnpm run lint`, and `cd web && pnpm run format:check`
all pass.
@@ -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.
+417
View File
@@ -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.
+8
View File
@@ -110,6 +110,14 @@ func (l *PGLibraryRefreshItemLister) ListLibraryItems(ctx context.Context, libra
OR COALESCE(mi.logo_path, '') LIKE '%//logo/%'
OR mi.refresh_failures > 0
OR mi.episode_metadata_incomplete = TRUE
OR (
LOWER(TRIM(COALESCE(mi.status, ''))) = 'matched'
AND COALESCE(mi.tmdb_id, '') = ''
AND (
COALESCE(mi.tvdb_id, '') <> ''
OR COALESCE(mi.imdb_id, '') <> ''
)
)
OR EXISTS (
SELECT 1
FROM stale_media_ids smi
+2
View File
@@ -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,
+618
View File
@@ -0,0 +1,618 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
)
type RequestService interface {
Search(ctx context.Context, viewer mediarequests.Viewer, query string, mediaType mediarequests.MediaType, page int) (*mediarequests.MediaPage, error)
Discover(ctx context.Context, viewer mediarequests.Viewer, section string, page int) (*mediarequests.DiscoverySection, error)
DiscoverAll(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverySection, error)
GetDetail(ctx context.Context, viewer mediarequests.Viewer, mediaType mediarequests.MediaType, tmdbID int) (*mediarequests.MediaDetail, error)
CreateRequest(ctx context.Context, viewer mediarequests.Viewer, input mediarequests.CreateRequestInput) (*mediarequests.Request, error)
ListMine(ctx context.Context, viewer mediarequests.Viewer, filter mediarequests.ListFilter) ([]*mediarequests.Request, error)
ListAdmin(ctx context.Context, viewer mediarequests.Viewer, filter mediarequests.ListFilter) ([]*mediarequests.Request, error)
GetRequest(ctx context.Context, viewer mediarequests.Viewer, id string) (*mediarequests.Request, error)
Approve(ctx context.Context, viewer mediarequests.Viewer, id string) (*mediarequests.Request, error)
Decline(ctx context.Context, viewer mediarequests.Viewer, id, reason string) (*mediarequests.Request, error)
Cancel(ctx context.Context, viewer mediarequests.Viewer, id, reason string) (*mediarequests.Request, error)
Retry(ctx context.Context, viewer mediarequests.Viewer, id string) (*mediarequests.Request, error)
GetSettings(ctx context.Context, viewer mediarequests.Viewer) (mediarequests.Settings, error)
UpdateSettings(ctx context.Context, viewer mediarequests.Viewer, settings mediarequests.Settings) (mediarequests.Settings, error)
GetUserLimit(ctx context.Context, viewer mediarequests.Viewer, userID int) (*mediarequests.UserLimit, error)
UpsertUserLimit(ctx context.Context, viewer mediarequests.Viewer, limit mediarequests.UserLimit) (*mediarequests.UserLimit, error)
ListIntegrations(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.Integration, error)
UpsertIntegration(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error)
UpsertIntegrations(ctx context.Context, viewer mediarequests.Viewer, integrations []mediarequests.Integration) ([]mediarequests.Integration, error)
LoadIntegrationOptions(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.IntegrationOptions, error)
ListStudios(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error)
ListNetworks(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error)
ListGenres(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error)
BrowseStudio(ctx context.Context, viewer mediarequests.Viewer, slug, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error)
BrowseNetwork(ctx context.Context, viewer mediarequests.Viewer, slug, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error)
BrowseGenre(ctx context.Context, viewer mediarequests.Viewer, slug string, mediaType mediarequests.MediaType, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error)
}
type RequestsHandler struct {
service RequestService
}
func NewRequestsHandler(service RequestService) *RequestsHandler {
return &RequestsHandler{service: service}
}
func (h *RequestsHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
page, ok := parsePositiveIntQuery(w, r, "page", 1)
if !ok {
return
}
result, err := h.service.Search(
r.Context(),
viewer,
r.URL.Query().Get("q"),
mediarequests.MediaType(r.URL.Query().Get("media_type")),
page,
)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *RequestsHandler) HandleDiscover(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
sections, err := h.service.DiscoverAll(r.Context(), viewer)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, struct {
Sections []mediarequests.DiscoverySection `json:"sections"`
}{Sections: sections})
}
func (h *RequestsHandler) HandleDiscoverSection(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
page, ok := parsePositiveIntQuery(w, r, "page", 1)
if !ok {
return
}
section, err := h.service.Discover(r.Context(), viewer, chi.URLParam(r, "section"), page)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, section)
}
func (h *RequestsHandler) HandleListStudios(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
studios, err := h.service.ListStudios(r.Context(), viewer)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, struct {
Studios []mediarequests.DiscoverBrandCard `json:"studios"`
}{Studios: studios})
}
func (h *RequestsHandler) HandleListNetworks(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
networks, err := h.service.ListNetworks(r.Context(), viewer)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, struct {
Networks []mediarequests.DiscoverBrandCard `json:"networks"`
}{Networks: networks})
}
func (h *RequestsHandler) HandleListGenres(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
genres, err := h.service.ListGenres(r.Context(), viewer)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, struct {
Genres []mediarequests.DiscoverBrandCard `json:"genres"`
}{Genres: genres})
}
func (h *RequestsHandler) HandleBrowseStudio(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
page, ok := parsePositiveIntQuery(w, r, "page", 1)
if !ok {
return
}
slug := strings.TrimSpace(chi.URLParam(r, "slug"))
sort := strings.TrimSpace(r.URL.Query().Get("sort"))
resp, err := h.service.BrowseStudio(r.Context(), viewer, slug, sort, page)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
func (h *RequestsHandler) HandleBrowseNetwork(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
page, ok := parsePositiveIntQuery(w, r, "page", 1)
if !ok {
return
}
slug := strings.TrimSpace(chi.URLParam(r, "slug"))
sort := strings.TrimSpace(r.URL.Query().Get("sort"))
resp, err := h.service.BrowseNetwork(r.Context(), viewer, slug, sort, page)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
func (h *RequestsHandler) HandleBrowseGenre(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
page, ok := parsePositiveIntQuery(w, r, "page", 1)
if !ok {
return
}
slug := strings.TrimSpace(chi.URLParam(r, "slug"))
sort := strings.TrimSpace(r.URL.Query().Get("sort"))
mediaType := mediarequests.MediaType(strings.TrimSpace(r.URL.Query().Get("media_type")))
resp, err := h.service.BrowseGenre(r.Context(), viewer, slug, mediaType, sort, page)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
func (h *RequestsHandler) HandleGetDetail(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
mediaType := mediarequests.MediaType(strings.TrimSpace(chi.URLParam(r, "media_type")))
tmdbID, err := strconv.Atoi(strings.TrimSpace(chi.URLParam(r, "tmdb_id")))
if err != nil || tmdbID <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid tmdb id")
return
}
detail, err := h.service.GetDetail(r.Context(), viewer, mediaType, tmdbID)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, detail)
}
func (h *RequestsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
var input mediarequests.CreateRequestInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
req, err := h.service.CreateRequest(r.Context(), viewer, input)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, req)
}
func (h *RequestsHandler) HandleListMine(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
requests, err := h.service.ListMine(r.Context(), viewer, parseRequestListFilter(r))
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, struct {
Requests []*mediarequests.Request `json:"requests"`
}{Requests: requests})
}
func (h *RequestsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, true)
if !ok {
return
}
req, err := h.service.GetRequest(r.Context(), viewer, chi.URLParam(r, "id"))
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, req)
}
func (h *RequestsHandler) HandleAdminList(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
requests, err := h.service.ListAdmin(r.Context(), viewer, parseRequestListFilter(r))
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, struct {
Requests []*mediarequests.Request `json:"requests"`
}{Requests: requests})
}
func (h *RequestsHandler) HandleApprove(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
req, err := h.service.Approve(r.Context(), viewer, chi.URLParam(r, "id"))
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, req)
}
func (h *RequestsHandler) HandleDecline(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
var body struct {
Reason string `json:"reason"`
}
if r.Body != nil {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
}
req, err := h.service.Decline(r.Context(), viewer, chi.URLParam(r, "id"), body.Reason)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, req)
}
func (h *RequestsHandler) HandleCancel(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
var body struct {
Reason string `json:"reason"`
}
if r.Body != nil {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
}
req, err := h.service.Cancel(r.Context(), viewer, chi.URLParam(r, "id"), body.Reason)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, req)
}
func (h *RequestsHandler) HandleRetry(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
req, err := h.service.Retry(r.Context(), viewer, chi.URLParam(r, "id"))
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, req)
}
func (h *RequestsHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
settings, err := h.service.GetSettings(r.Context(), viewer)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, settings)
}
func (h *RequestsHandler) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
var settings mediarequests.Settings
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
updated, err := h.service.UpdateSettings(r.Context(), viewer, settings)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, updated)
}
func (h *RequestsHandler) HandleListIntegrations(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
integrations, err := h.service.ListIntegrations(r.Context(), viewer)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, struct {
Integrations []requestIntegrationResponse `json:"integrations"`
}{Integrations: toIntegrationResponses(integrations)})
}
func (h *RequestsHandler) HandleUpdateIntegrations(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
var body struct {
Integrations []mediarequests.Integration `json:"integrations"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
updated, err := h.service.UpsertIntegrations(r.Context(), viewer, body.Integrations)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, struct {
Integrations []requestIntegrationResponse `json:"integrations"`
}{Integrations: toIntegrationResponses(updated)})
}
func (h *RequestsHandler) HandleLoadIntegrationOptions(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
var integration mediarequests.Integration
if r.Body != nil {
if err := json.NewDecoder(r.Body).Decode(&integration); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
}
integration.Kind = chi.URLParam(r, "kind")
options, err := h.service.LoadIntegrationOptions(r.Context(), viewer, integration)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, options)
}
func (h *RequestsHandler) HandleGetUserLimit(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
userID, ok := parsePositivePathInt(w, r, "user_id")
if !ok {
return
}
limit, err := h.service.GetUserLimit(r.Context(), viewer, userID)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, limit)
}
func (h *RequestsHandler) HandleUpdateUserLimit(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
userID, ok := parsePositivePathInt(w, r, "user_id")
if !ok {
return
}
var limit mediarequests.UserLimit
if err := json.NewDecoder(r.Body).Decode(&limit); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
limit.UserID = userID
updated, err := h.service.UpsertUserLimit(r.Context(), viewer, limit)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, updated)
}
func requestViewer(w http.ResponseWriter, r *http.Request, requireProfile bool) (mediarequests.Viewer, bool) {
claims := apimw.GetClaims(r.Context())
if claims == nil || claims.UserID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return mediarequests.Viewer{}, false
}
profileID := strings.TrimSpace(apimw.GetProfileID(r.Context()))
if requireProfile && profileID == "" {
writeError(w, http.StatusBadRequest, "profile_required", "Profile is required")
return mediarequests.Viewer{}, false
}
return mediarequests.Viewer{
UserID: claims.UserID,
ProfileID: profileID,
IsAdmin: claims.Role == "admin",
}, true
}
func parseRequestListFilter(r *http.Request) mediarequests.ListFilter {
q := r.URL.Query()
limit, _ := strconv.Atoi(q.Get("limit"))
offset, _ := strconv.Atoi(q.Get("offset"))
return mediarequests.ListFilter{
Status: mediarequests.Status(strings.TrimSpace(q.Get("status"))),
Outcome: mediarequests.Outcome(strings.TrimSpace(q.Get("outcome"))),
Limit: limit,
Offset: offset,
}
}
func parsePositiveIntQuery(w http.ResponseWriter, r *http.Request, key string, fallback int) (int, bool) {
raw := strings.TrimSpace(r.URL.Query().Get(key))
if raw == "" {
return fallback, true
}
value, err := strconv.Atoi(raw)
if err != nil || value <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid "+key)
return 0, false
}
return value, true
}
func parsePositivePathInt(w http.ResponseWriter, r *http.Request, key string) (int, bool) {
value, err := strconv.Atoi(strings.TrimSpace(chi.URLParam(r, key)))
if err != nil || value <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid "+key)
return 0, false
}
return value, true
}
type requestIntegrationResponse struct {
Kind string `json:"kind"`
Enabled bool `json:"enabled"`
BaseURL string `json:"base_url"`
HasAPIKey bool `json:"has_api_key"`
RootFolder string `json:"root_folder"`
QualityProfileID *int `json:"quality_profile_id,omitempty"`
Tags []int `json:"tags"`
Options map[string]any `json:"options"`
LastCheckAt *time.Time `json:"last_check_at,omitempty"`
LastCheckStatus string `json:"last_check_status,omitempty"`
LastCheckError string `json:"last_check_error,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
func toIntegrationResponses(integrations []mediarequests.Integration) []requestIntegrationResponse {
out := make([]requestIntegrationResponse, 0, len(integrations))
for _, integration := range integrations {
out = append(out, requestIntegrationResponse{
Kind: integration.Kind,
Enabled: integration.Enabled,
BaseURL: integration.BaseURL,
HasAPIKey: strings.TrimSpace(integration.APIKeyRef) != "",
RootFolder: integration.RootFolder,
QualityProfileID: integration.QualityProfileID,
Tags: integration.Tags,
Options: integration.Options,
LastCheckAt: integration.LastCheckAt,
LastCheckStatus: integration.LastCheckStatus,
LastCheckError: integration.LastCheckError,
UpdatedAt: integration.UpdatedAt,
})
}
return out
}
func writeRequestServiceError(w http.ResponseWriter, err error) {
var quota mediarequests.QuotaError
switch {
case errors.As(err, &quota):
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")
}
}
+241
View File
@@ -0,0 +1,241 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
)
type fakeRequestService struct {
listStudiosFn func() ([]mediarequests.DiscoverBrandCard, error)
listNetworksFn func() ([]mediarequests.DiscoverBrandCard, error)
listGenresFn func() ([]mediarequests.DiscoverBrandCard, error)
browseFn func(kind, slug string, mediaType mediarequests.MediaType, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error)
}
func (f *fakeRequestService) ListStudios(context.Context, mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error) {
if f.listStudiosFn != nil {
return f.listStudiosFn()
}
return nil, nil
}
func (f *fakeRequestService) ListNetworks(context.Context, mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error) {
if f.listNetworksFn != nil {
return f.listNetworksFn()
}
return nil, nil
}
func (f *fakeRequestService) ListGenres(context.Context, mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error) {
if f.listGenresFn != nil {
return f.listGenresFn()
}
return nil, nil
}
func (f *fakeRequestService) BrowseStudio(_ context.Context, _ mediarequests.Viewer, slug, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) {
return f.browseFn("studio", slug, mediarequests.MediaTypeMovie, sort, page)
}
func (f *fakeRequestService) BrowseNetwork(_ context.Context, _ mediarequests.Viewer, slug, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) {
return f.browseFn("network", slug, mediarequests.MediaTypeSeries, sort, page)
}
func (f *fakeRequestService) BrowseGenre(_ context.Context, _ mediarequests.Viewer, slug string, mediaType mediarequests.MediaType, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) {
return f.browseFn("genre", slug, mediaType, sort, page)
}
func (f *fakeRequestService) Search(context.Context, mediarequests.Viewer, string, mediarequests.MediaType, int) (*mediarequests.MediaPage, error) {
return nil, nil
}
func (f *fakeRequestService) Discover(context.Context, mediarequests.Viewer, string, int) (*mediarequests.DiscoverySection, error) {
return nil, nil
}
func (f *fakeRequestService) DiscoverAll(context.Context, mediarequests.Viewer) ([]mediarequests.DiscoverySection, error) {
return nil, nil
}
func (f *fakeRequestService) GetDetail(context.Context, mediarequests.Viewer, mediarequests.MediaType, int) (*mediarequests.MediaDetail, error) {
return nil, nil
}
func (f *fakeRequestService) CreateRequest(context.Context, mediarequests.Viewer, mediarequests.CreateRequestInput) (*mediarequests.Request, error) {
return nil, nil
}
func (f *fakeRequestService) ListMine(context.Context, mediarequests.Viewer, mediarequests.ListFilter) ([]*mediarequests.Request, error) {
return nil, nil
}
func (f *fakeRequestService) ListAdmin(context.Context, mediarequests.Viewer, mediarequests.ListFilter) ([]*mediarequests.Request, error) {
return nil, nil
}
func (f *fakeRequestService) GetRequest(context.Context, mediarequests.Viewer, string) (*mediarequests.Request, error) {
return nil, nil
}
func (f *fakeRequestService) Approve(context.Context, mediarequests.Viewer, string) (*mediarequests.Request, error) {
return nil, nil
}
func (f *fakeRequestService) Decline(context.Context, mediarequests.Viewer, string, string) (*mediarequests.Request, error) {
return nil, nil
}
func (f *fakeRequestService) Cancel(context.Context, mediarequests.Viewer, string, string) (*mediarequests.Request, error) {
return nil, nil
}
func (f *fakeRequestService) Retry(context.Context, mediarequests.Viewer, string) (*mediarequests.Request, error) {
return nil, nil
}
func (f *fakeRequestService) GetSettings(context.Context, mediarequests.Viewer) (mediarequests.Settings, error) {
return mediarequests.Settings{}, nil
}
func (f *fakeRequestService) UpdateSettings(context.Context, mediarequests.Viewer, mediarequests.Settings) (mediarequests.Settings, error) {
return mediarequests.Settings{}, nil
}
func (f *fakeRequestService) GetUserLimit(context.Context, mediarequests.Viewer, int) (*mediarequests.UserLimit, error) {
return nil, nil
}
func (f *fakeRequestService) UpsertUserLimit(context.Context, mediarequests.Viewer, mediarequests.UserLimit) (*mediarequests.UserLimit, error) {
return nil, nil
}
func (f *fakeRequestService) ListIntegrations(context.Context, mediarequests.Viewer) ([]mediarequests.Integration, error) {
return nil, nil
}
func (f *fakeRequestService) UpsertIntegration(context.Context, mediarequests.Viewer, mediarequests.Integration) (*mediarequests.Integration, error) {
return nil, nil
}
func (f *fakeRequestService) UpsertIntegrations(context.Context, mediarequests.Viewer, []mediarequests.Integration) ([]mediarequests.Integration, error) {
return nil, nil
}
func (f *fakeRequestService) LoadIntegrationOptions(context.Context, mediarequests.Viewer, mediarequests.Integration) (*mediarequests.IntegrationOptions, error) {
return nil, nil
}
func authedRequest(method, target string) *http.Request {
req := httptest.NewRequest(method, target, nil)
ctx := apimw.SetClaims(req.Context(), &auth.Claims{
UserID: 1,
Role: "user",
TokenType: auth.TokenTypeAccess,
})
ctx = apimw.SetProfileID(ctx, "profile-1")
return req.WithContext(ctx)
}
func TestHandleListStudiosReturnsJSON(t *testing.T) {
logo := "https://image.tmdb.org/t/p/w300/x.png"
svc := &fakeRequestService{
listStudiosFn: func() ([]mediarequests.DiscoverBrandCard, error) {
return []mediarequests.DiscoverBrandCard{
{TMDBID: 420, Slug: "marvel-studios", DisplayName: "Marvel Studios", LogoURL: &logo},
}, nil
},
}
h := NewRequestsHandler(svc)
rec := httptest.NewRecorder()
h.HandleListStudios(rec, authedRequest("GET", "/api/v1/requests/discover/studios"))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var body struct {
Studios []mediarequests.DiscoverBrandCard `json:"studios"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if len(body.Studios) != 1 || body.Studios[0].Slug != "marvel-studios" {
t.Errorf("studios = %+v", body.Studios)
}
}
func TestHandleBrowseStudioRejectsUnknownSort(t *testing.T) {
svc := &fakeRequestService{
browseFn: func(kind, slug string, _ mediarequests.MediaType, sort string, _ int) (*mediarequests.DiscoverBrowseResponse, error) {
return nil, mediarequests.ErrInvalidInput
},
}
h := NewRequestsHandler(svc)
req := authedRequest("GET", "/api/v1/requests/discover/browse/studio/marvel-studios?sort=garbage")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("slug", "marvel-studios")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rec := httptest.NewRecorder()
h.HandleBrowseStudio(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
}
func TestHandleBrowseStudioUnknownSlugReturns404(t *testing.T) {
svc := &fakeRequestService{
browseFn: func(string, string, mediarequests.MediaType, string, int) (*mediarequests.DiscoverBrowseResponse, error) {
return nil, mediarequests.ErrNotFound
},
}
h := NewRequestsHandler(svc)
req := authedRequest("GET", "/api/v1/requests/discover/browse/studio/ghosts")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("slug", "ghosts")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rec := httptest.NewRecorder()
h.HandleBrowseStudio(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
}
func TestHandleBrowseGenreRequiresMediaType(t *testing.T) {
svc := &fakeRequestService{
browseFn: func(_ string, _ string, mt mediarequests.MediaType, _ string, _ int) (*mediarequests.DiscoverBrowseResponse, error) {
if strings.TrimSpace(string(mt)) == "" {
return nil, mediarequests.ErrInvalidInput
}
return &mediarequests.DiscoverBrowseResponse{Kind: "genre"}, nil
},
}
h := NewRequestsHandler(svc)
req := authedRequest("GET", "/api/v1/requests/discover/browse/genre/action")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("slug", "action")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rec := httptest.NewRecorder()
h.HandleBrowseGenre(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
}
+52
View File
@@ -46,6 +46,9 @@ import (
"github.com/Silo-Server/silo-server/internal/plugins"
"github.com/Silo-Server/silo-server/internal/ratelimit"
"github.com/Silo-Server/silo-server/internal/recommendations"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
"github.com/Silo-Server/silo-server/internal/requests/radarr"
"github.com/Silo-Server/silo-server/internal/requests/sonarr"
"github.com/Silo-Server/silo-server/internal/s3client"
"github.com/Silo-Server/silo-server/internal/scanner"
"github.com/Silo-Server/silo-server/internal/scanqueue"
@@ -323,6 +326,7 @@ func NewRouter(deps Dependencies) chi.Router {
var detailSvc *catalog.DetailService
var calendarRepo *catalog.CalendarRepository
var webhookSyncHandler *handlers.WebhookSyncHandler
var requestHandler *handlers.RequestsHandler
if deps.DB != nil {
browseRepo := catalog.NewBrowseRepository(deps.DB)
itemRepo = catalog.NewItemRepository(deps.DB)
@@ -382,6 +386,19 @@ func NewRouter(deps Dependencies) chi.Router {
itemsHandler,
)
tmdbAPIKey := ""
if deps.Config != nil {
tmdbAPIKey = deps.Config.TMDBAPIKey
}
requestSvc := mediarequests.NewService(
mediarequests.NewRepository(deps.DB),
tmdb.NewClient(tmdbAPIKey, 40),
mediarequests.NewCatalogPresence(itemRepo, providerIDRepo),
)
requestSvc.SetSecretResolver(settingsRepo)
requestSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil))
requestHandler = handlers.NewRequestsHandler(requestSvc)
if deps.PersonRepo != nil {
peopleHandler = handlers.NewPeopleHandler(deps.PersonRepo, browseRepo, itemRepo, detailSvc)
peopleHandler.SetItemsHandler(itemsHandler)
@@ -1374,6 +1391,26 @@ func NewRouter(deps Dependencies) chi.Router {
})
}
if requestHandler != nil {
r.Route("/requests", func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Get("/search", requestHandler.HandleSearch)
r.Get("/discover", requestHandler.HandleDiscover)
r.Get("/discover/studios", requestHandler.HandleListStudios)
r.Get("/discover/networks", requestHandler.HandleListNetworks)
r.Get("/discover/genres", requestHandler.HandleListGenres)
r.Get("/discover/browse/studio/{slug}", requestHandler.HandleBrowseStudio)
r.Get("/discover/browse/network/{slug}", requestHandler.HandleBrowseNetwork)
r.Get("/discover/browse/genre/{slug}", requestHandler.HandleBrowseGenre)
r.Get("/discover/{section}", requestHandler.HandleDiscoverSection)
r.Get("/detail/{media_type}/{tmdb_id}", requestHandler.HandleGetDetail)
r.Post("/", requestHandler.HandleCreate)
r.Get("/mine", requestHandler.HandleListMine)
r.Get("/{id}", requestHandler.HandleGet)
r.Post("/{id}/cancel", requestHandler.HandleCancel)
})
}
// Settings routes (user-scoped, no profile required).
if settingsHandler != nil {
r.Route("/settings", func(r chi.Router) {
@@ -1870,6 +1907,21 @@ func NewRouter(deps Dependencies) chi.Router {
r.Put("/api-keys/{id}/tier", apiKeyHandler.HandleAdminUpdateTier)
}
if requestHandler != nil {
r.Get("/requests", requestHandler.HandleAdminList)
r.Post("/requests/{id}/approve", requestHandler.HandleApprove)
r.Post("/requests/{id}/decline", requestHandler.HandleDecline)
r.Post("/requests/{id}/cancel", requestHandler.HandleCancel)
r.Post("/requests/{id}/retry", requestHandler.HandleRetry)
r.Get("/request-settings", requestHandler.HandleGetSettings)
r.Put("/request-settings", requestHandler.HandleUpdateSettings)
r.Get("/request-users/{user_id}/limit", requestHandler.HandleGetUserLimit)
r.Put("/request-users/{user_id}/limit", requestHandler.HandleUpdateUserLimit)
r.Get("/request-integrations", requestHandler.HandleListIntegrations)
r.Put("/request-integrations", requestHandler.HandleUpdateIntegrations)
r.Post("/request-integrations/{kind}/options", requestHandler.HandleLoadIntegrationOptions)
}
if deps.ActivityLogRepo != nil {
adminIPHandler := handlers.NewAdminIPHandler(deps.ActivityLogRepo)
r.Get("/users/{id}/ips", adminIPHandler.HandleGetUserIPs)
+120 -23
View File
@@ -1330,6 +1330,20 @@ type MediaTMDBRow struct {
Title string
}
type ExternalIDLookupCandidate struct {
TMDBID string
TVDBID string
IMDbID string
}
type ExternalIDMatchRow struct {
QueryTMDBID string
MediaID string
MatchedProvider string
LibraryID string
Title string
}
// LookupTMDBIDs returns one row per matching media item that has its tmdb_id
// in the supplied list and is linked to at least one enabled library.
// mediaType is "movie" or "series" (silo's internal naming).
@@ -1341,38 +1355,121 @@ func (r *ItemRepository) LookupTMDBIDs(ctx context.Context, mediaType string, tm
if len(tmdbIDs) == 0 {
return nil, nil
}
// Convert string IDs to int: TMDB IDs are stored as text in media_items
// but the plugin sends them as strings. We do an ANY match directly on
// the text column so no int conversion is required.
rows, err := r.pool.Query(ctx, `
SELECT DISTINCT ON (mi.content_id)
mi.content_id,
COALESCE(mi.tmdb_id, ''),
mil.media_folder_id::text,
mi.title
FROM media_items mi
JOIN media_item_libraries mil ON mil.content_id = mi.content_id
JOIN media_folders mf ON mf.id = mil.media_folder_id
WHERE mi.tmdb_id = ANY($1)
AND mi.type = $2
AND mf.enabled = true
ORDER BY mi.content_id, mil.media_folder_id ASC
`, tmdbIDs, mediaType)
candidates := make([]ExternalIDLookupCandidate, 0, len(tmdbIDs))
for _, id := range tmdbIDs {
if strings.TrimSpace(id) != "" {
candidates = append(candidates, ExternalIDLookupCandidate{TMDBID: id})
}
}
rows, err := r.LookupExternalIDs(ctx, mediaType, candidates)
if err != nil {
return nil, fmt.Errorf("lookup tmdb ids: %w", err)
return nil, err
}
out := make([]MediaTMDBRow, 0, len(rows))
for _, row := range rows {
out = append(out, MediaTMDBRow{
MediaID: row.MediaID,
TMDBID: row.QueryTMDBID,
LibraryID: row.LibraryID,
Title: row.Title,
})
}
return out, nil
}
func lookupExternalIDsSQL() string {
return `
WITH requested(query_tmdb_id, provider, provider_id, ord) AS (
SELECT * FROM unnest($1::text[], $2::text[], $3::text[], $4::int[])
),
direct_matches AS (
SELECT r.query_tmdb_id, mi.content_id, r.provider, mil.media_folder_id::text, mi.title, r.ord,
CASE r.provider WHEN 'tmdb' THEN 0 WHEN 'tvdb' THEN 1 WHEN 'imdb' THEN 2 ELSE 3 END AS provider_rank
FROM requested r
JOIN media_items mi
ON mi.type = $5
AND (
(r.provider = 'tmdb' AND mi.tmdb_id <> '' AND mi.tmdb_id = r.provider_id)
OR (r.provider = 'tvdb' AND mi.tvdb_id <> '' AND mi.tvdb_id = r.provider_id)
OR (r.provider = 'imdb' AND mi.imdb_id <> '' AND mi.imdb_id = r.provider_id)
)
JOIN media_item_libraries mil ON mil.content_id = mi.content_id
JOIN media_folders mf ON mf.id = mil.media_folder_id
WHERE mf.enabled = true
),
provider_matches AS (
SELECT r.query_tmdb_id, mi.content_id, r.provider, mil.media_folder_id::text, mi.title, r.ord,
CASE r.provider WHEN 'tmdb' THEN 0 WHEN 'tvdb' THEN 1 WHEN 'imdb' THEN 2 ELSE 3 END AS provider_rank
FROM requested r
JOIN media_item_provider_ids mip
ON mip.provider = r.provider
AND mip.provider_id = r.provider_id
AND mip.item_type = $5
JOIN media_items mi ON mi.content_id = mip.content_id AND mi.type = $5
JOIN media_item_libraries mil ON mil.content_id = mi.content_id
JOIN media_folders mf ON mf.id = mil.media_folder_id
WHERE mf.enabled = true
)
SELECT DISTINCT ON (query_tmdb_id)
query_tmdb_id, content_id, provider, media_folder_id, title
FROM (
SELECT * FROM direct_matches
UNION ALL
SELECT * FROM provider_matches
) matches
ORDER BY query_tmdb_id, provider_rank ASC, ord ASC, content_id ASC, media_folder_id ASC`
}
func (r *ItemRepository) LookupExternalIDs(
ctx context.Context,
mediaType string,
candidates []ExternalIDLookupCandidate,
) ([]ExternalIDMatchRow, error) {
if len(candidates) == 0 {
return nil, nil
}
queryTMDBIDs := make([]string, 0, len(candidates)*3)
providers := make([]string, 0, len(candidates)*3)
providerIDs := make([]string, 0, len(candidates)*3)
ordinals := make([]int32, 0, len(candidates)*3)
appendID := func(candidate ExternalIDLookupCandidate, provider, providerID string, ordinal int) {
providerID = strings.TrimSpace(providerID)
if providerID == "" {
return
}
queryTMDBIDs = append(queryTMDBIDs, strings.TrimSpace(candidate.TMDBID))
providers = append(providers, provider)
providerIDs = append(providerIDs, providerID)
ordinals = append(ordinals, int32(ordinal))
}
for i, candidate := range candidates {
appendID(candidate, "tmdb", candidate.TMDBID, i)
appendID(candidate, "tvdb", candidate.TVDBID, i)
appendID(candidate, "imdb", candidate.IMDbID, i)
}
if len(providerIDs) == 0 {
return nil, nil
}
rows, err := r.pool.Query(ctx, lookupExternalIDsSQL(), queryTMDBIDs, providers, providerIDs, ordinals, mediaType)
if err != nil {
return nil, fmt.Errorf("lookup external ids: %w", err)
}
defer rows.Close()
var out []MediaTMDBRow
out := make([]ExternalIDMatchRow, 0)
for rows.Next() {
var row MediaTMDBRow
if err := rows.Scan(&row.MediaID, &row.TMDBID, &row.LibraryID, &row.Title); err != nil {
return nil, fmt.Errorf("scanning tmdb lookup row: %w", err)
var row ExternalIDMatchRow
if err := rows.Scan(&row.QueryTMDBID, &row.MediaID, &row.MatchedProvider, &row.LibraryID, &row.Title); err != nil {
return nil, fmt.Errorf("scanning external id lookup row: %w", err)
}
out = append(out, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating tmdb lookup rows: %w", err)
return nil, fmt.Errorf("iterating external id lookup rows: %w", err)
}
return out, nil
}
+31
View File
@@ -191,6 +191,37 @@ func TestItemRepo_GetByExternalIDs_NilSliceStillBindsArg(t *testing.T) {
_ = args
}
func TestLookupExternalIDsSQLChecksProviderTableAndDirectColumns(t *testing.T) {
sql := lookupExternalIDsSQL()
for _, want := range []string{
"FROM requested r",
"JOIN media_item_provider_ids mip",
"mip.provider = r.provider",
"mip.provider_id = r.provider_id",
"mip.item_type = $5",
"mi.type = $5",
"mi.tmdb_id <> '' AND mi.tmdb_id = r.provider_id",
"mi.tvdb_id <> '' AND mi.tvdb_id = r.provider_id",
"mi.imdb_id <> '' AND mi.imdb_id = r.provider_id",
"JOIN media_folders mf ON mf.id = mil.media_folder_id",
"mf.enabled = true",
} {
if !strings.Contains(sql, want) {
t.Fatalf("lookupExternalIDsSQL missing %q:\n%s", want, sql)
}
}
for _, disallowed := range []string{
"COALESCE(mi.tmdb_id, '') = r.provider_id",
"COALESCE(mi.tvdb_id, '') = r.provider_id",
"COALESCE(mi.imdb_id, '') = r.provider_id",
} {
if strings.Contains(sql, disallowed) {
t.Fatalf("lookupExternalIDsSQL should use indexable direct predicate, found %q:\n%s", disallowed, sql)
}
}
}
// TestItemRepo_Search_UsesWindowCount asserts that buildSearchSQL emits a
// single-pass paged SELECT that includes COUNT(*) OVER () so Search no longer
// needs a separate count query before the data fetch (audit 2026-05-01 §3.11).
+97
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"sort"
"strconv"
"strings"
"github.com/jackc/pgx/v5"
@@ -30,6 +31,102 @@ func NewProviderIDRepository(pool *pgxpool.Pool) *ProviderIDRepository {
return &ProviderIDRepository{pool: pool}
}
func (r *ProviderIDRepository) AttachTMDBID(ctx context.Context, contentID, itemType string, tmdbID int) error {
contentID = strings.TrimSpace(contentID)
itemType = strings.TrimSpace(itemType)
if contentID == "" {
return fmt.Errorf("content_id is required")
}
if itemType == "" {
return fmt.Errorf("item_type is required")
}
if tmdbID <= 0 {
return fmt.Errorf("tmdb_id must be positive")
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin attach tmdb transaction: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
tmdbText := strconv.Itoa(tmdbID)
var existingType, existingTMDBID string
if err := tx.QueryRow(ctx, `
SELECT type, COALESCE(tmdb_id, '')
FROM media_items
WHERE content_id = $1
FOR UPDATE
`, contentID).Scan(&existingType, &existingTMDBID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("loading media item for tmdb attach: content not found")
}
return fmt.Errorf("loading media item for tmdb attach: %w", err)
}
if existingType != itemType {
return fmt.Errorf("media item type mismatch: got %q, want %q", existingType, itemType)
}
if existingTMDBID != "" && existingTMDBID != tmdbText {
return fmt.Errorf("media item tmdb id conflict: got %q, want %q", existingTMDBID, tmdbText)
}
var existingProviderTMDBID string
err = tx.QueryRow(ctx, `
SELECT provider_id
FROM media_item_provider_ids
WHERE content_id = $1 AND provider = 'tmdb'
FOR UPDATE
`, contentID).Scan(&existingProviderTMDBID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("loading media item tmdb provider id: %w", err)
}
if existingProviderTMDBID != "" && existingProviderTMDBID != tmdbText {
return fmt.Errorf("media item tmdb provider id conflict: got %q, want %q", existingProviderTMDBID, tmdbText)
}
var existingOwnerContentID string
err = tx.QueryRow(ctx, `
SELECT content_id
FROM media_items
WHERE type = $1
AND tmdb_id = $2
AND content_id <> $3
LIMIT 1
`, itemType, tmdbText, contentID).Scan(&existingOwnerContentID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("checking tmdb id owner: %w", err)
}
if existingOwnerContentID != "" {
return fmt.Errorf("tmdb id %q already belongs to content_id %q", tmdbText, existingOwnerContentID)
}
if _, err := tx.Exec(ctx, `
UPDATE media_items
SET tmdb_id = $1,
updated_at = NOW()
WHERE content_id = $2
AND type = $3
`, tmdbText, contentID, itemType); err != nil {
return fmt.Errorf("updating media item tmdb id: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO media_item_provider_ids (content_id, item_type, provider, provider_id, created_at, updated_at)
VALUES ($1, $2, 'tmdb', $3, NOW(), NOW())
ON CONFLICT (content_id, provider) DO UPDATE
SET item_type = EXCLUDED.item_type,
provider_id = EXCLUDED.provider_id,
updated_at = NOW()
`, contentID, itemType, tmdbText); err != nil {
return fmt.Errorf("upserting media item tmdb provider id: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit attach tmdb transaction: %w", err)
}
return nil
}
const providerIDColumns = `content_id, item_type, provider, provider_id, created_at, updated_at`
var excludedProviderIDs = map[string]struct{}{
+14
View File
@@ -40,3 +40,17 @@ func TestNormalizeDurableProviderIDsOrdersCanonicalKeysFirst(t *testing.T) {
}
}
}
func TestNormalizeDurableProviderIDsKeepsTMDBFirstForBackfill(t *testing.T) {
entries := normalizeDurableProviderIDs(map[string]string{
"tvdb": "420105",
"imdb": "tt18076310",
"tmdb": "201992",
})
if len(entries) != 3 {
t.Fatalf("len(entries) = %d, want 3", len(entries))
}
if entries[0].Provider != "tmdb" || entries[0].ProviderID != "201992" {
t.Fatalf("first entry = (%q, %q), want tmdb/201992", entries[0].Provider, entries[0].ProviderID)
}
}
+40 -2
View File
@@ -25,12 +25,40 @@ type MatchCandidate struct {
AgreementHints []string `json:"agreement_hints"`
}
var canonicalCandidateIDKeys = []string{"tmdb", "tvdb", "imdb"}
func compatibleProviderIDs(left, right map[string]string) bool {
overlap := false
for _, key := range canonicalCandidateIDKeys {
lv := strings.TrimSpace(left[key])
rv := strings.TrimSpace(right[key])
if lv == "" || rv == "" {
continue
}
if lv != rv {
return false
}
overlap = true
}
return overlap
}
func providerIDRichness(ids map[string]string) int {
score := 0
for _, key := range canonicalCandidateIDKeys {
if strings.TrimSpace(ids[key]) != "" {
score++
}
}
return score
}
// normalizedKey returns a stable grouping key from provider IDs.
// Results with identical provider ID fingerprints (the exact set of
// tmdb/tvdb/imdb key=value pairs) are considered the same candidate.
func normalizedKey(ids map[string]string) string {
var parts []string
for _, k := range []string{"tmdb", "tvdb", "imdb"} {
for _, k := range canonicalCandidateIDKeys {
if v, ok := ids[k]; ok && v != "" {
parts = append(parts, k+"="+v)
}
@@ -59,7 +87,16 @@ func NormalizeCandidates(results []SearchResult, contentType string) []MatchCand
buckets := make(map[string]*bucket)
for _, sr := range results {
key := normalizedKey(sr.ProviderIDs)
key := ""
for _, existingKey := range ordered {
if compatibleProviderIDs(buckets[existingKey].candidate.ProviderIDs, sr.ProviderIDs) {
key = existingKey
break
}
}
if key == "" {
key = normalizedKey(sr.ProviderIDs)
}
if key == "" {
// Cannot group by provider IDs; create a synthetic unique key.
key = sr.Provider + ":" + sr.Name + ":" + strings.Repeat("?", len(ordered))
@@ -193,6 +230,7 @@ func scoreMatchCandidate(hints *MatchHints, candidate MatchCandidate) float64 {
if len(candidate.ProviderIDs) > 0 {
score += 5
score += float64(providerIDRichness(candidate.ProviderIDs))
}
return score
@@ -61,6 +61,61 @@ func TestNormalizeCandidates(t *testing.T) {
}
},
},
{
name: "merge compatible candidates with overlapping provider IDs",
results: []SearchResult{
{
Name: "The Rookie: Feds",
Year: 2022,
Provider: "tvdb",
ProviderIDs: map[string]string{"tvdb": "420105", "imdb": "tt18076310"},
},
{
Name: "The Rookie: Feds",
Year: 2022,
Provider: "tmdb",
ProviderIDs: map[string]string{"tmdb": "201992", "tvdb": "420105", "imdb": "tt18076310"},
},
},
content: "series",
wantLen: 1,
check: func(t *testing.T, candidates []MatchCandidate) {
c := candidates[0]
if c.ProviderIDs["tmdb"] != "201992" {
t.Fatalf("tmdb id = %q, want 201992", c.ProviderIDs["tmdb"])
}
if c.ProviderIDs["tvdb"] != "420105" || c.ProviderIDs["imdb"] != "tt18076310" {
t.Fatalf("provider ids = %+v, want tvdb and imdb preserved", c.ProviderIDs)
}
if len(c.Sources) != 2 {
t.Fatalf("sources = %+v, want two providers", c.Sources)
}
},
},
{
name: "do not merge candidates with conflicting overlapping provider IDs",
results: []SearchResult{
{
Name: "Show A",
Year: 2022,
Provider: "tvdb",
ProviderIDs: map[string]string{"tvdb": "420105", "imdb": "tt18076310"},
},
{
Name: "Show B",
Year: 2022,
Provider: "tmdb",
ProviderIDs: map[string]string{"tmdb": "201992", "tvdb": "999999", "imdb": "tt18076310"},
},
},
content: "series",
wantLen: 2,
check: func(t *testing.T, candidates []MatchCandidate) {
if len(candidates) != 2 {
t.Fatalf("len(candidates) = %d, want 2", len(candidates))
}
},
},
{
name: "no recognized provider IDs gets synthetic key and stays separate",
results: []SearchResult{
+20 -4
View File
@@ -8,10 +8,11 @@ import (
)
const (
RefreshDebtReasonEpisodeIncomplete int64 = 1 << iota
RefreshDebtReasonStaleProviderID
RefreshDebtReasonRefreshFailure
RefreshDebtReasonCoreMetadataIncomplete
RefreshDebtReasonEpisodeIncomplete int64 = 1
RefreshDebtReasonStaleProviderID int64 = 2
RefreshDebtReasonRefreshFailure int64 = 4
RefreshDebtReasonCoreMetadataIncomplete int64 = 8
RefreshDebtReasonProviderIDIncomplete int64 = 16
)
const (
@@ -43,6 +44,8 @@ func refreshDebtPriority(reasonMask int64) int {
return 300
case hasRefreshDebtReason(reasonMask, RefreshDebtReasonStaleProviderID):
return 250
case hasRefreshDebtReason(reasonMask, RefreshDebtReasonProviderIDIncomplete):
return 240
case hasRefreshDebtReason(reasonMask, RefreshDebtReasonRefreshFailure):
return 200
case hasRefreshDebtReason(reasonMask, RefreshDebtReasonCoreMetadataIncomplete):
@@ -95,12 +98,25 @@ func refreshDebtReasonsForItem(item *models.MediaItem) int64 {
if hasCoreMetadataRefreshDebt(item) {
reasonMask |= RefreshDebtReasonCoreMetadataIncomplete
}
if hasProviderIDRefreshDebt(item) {
reasonMask |= RefreshDebtReasonProviderIDIncomplete
}
if item.RefreshFailures > 0 && strings.EqualFold(strings.TrimSpace(item.Status), "matched") {
reasonMask |= RefreshDebtReasonRefreshFailure
}
return reasonMask
}
func hasProviderIDRefreshDebt(item *models.MediaItem) bool {
if item == nil || !strings.EqualFold(strings.TrimSpace(item.Status), "matched") {
return false
}
if strings.TrimSpace(item.TmdbID) != "" {
return false
}
return strings.TrimSpace(item.TvdbID) != "" || strings.TrimSpace(item.ImdbID) != ""
}
func hasCoreMetadataRefreshDebt(item *models.MediaItem) bool {
if item == nil || !strings.EqualFold(strings.TrimSpace(item.Status), "matched") {
return false
+1
View File
@@ -448,6 +448,7 @@ func (r *RefreshDebtRepository) GetMetrics(ctx context.Context, sampleLimit int)
}{
{reason: "episode_incomplete", mask: RefreshDebtReasonEpisodeIncomplete},
{reason: "stale_provider_id", mask: RefreshDebtReasonStaleProviderID},
{reason: "provider_id_incomplete", mask: RefreshDebtReasonProviderIDIncomplete},
{reason: "refresh_failure", mask: RefreshDebtReasonRefreshFailure},
{reason: "core_metadata_incomplete", mask: RefreshDebtReasonCoreMetadataIncomplete},
}
+60
View File
@@ -39,6 +39,66 @@ func TestRefreshDebtReasonsForItemSkipsUnmatchedFailureOnly(t *testing.T) {
}
}
func TestRefreshDebtReasonsForItemFlagsMissingTMDBWithOtherProviderIDs(t *testing.T) {
item := &models.MediaItem{
Type: "series",
Status: "matched",
TvdbID: "420105",
ImdbID: "tt18076310",
TmdbID: "",
}
mask := refreshDebtReasonsForItem(item)
if !hasRefreshDebtReason(mask, RefreshDebtReasonProviderIDIncomplete) {
t.Fatalf("reason mask = %d, want provider id incomplete", mask)
}
}
func TestRefreshDebtReasonsForItemDoesNotFlagProviderIDIncompleteWithoutAlternateIDs(t *testing.T) {
item := &models.MediaItem{
Type: "series",
Status: "matched",
TmdbID: "",
}
mask := refreshDebtReasonsForItem(item)
if hasRefreshDebtReason(mask, RefreshDebtReasonProviderIDIncomplete) {
t.Fatalf("reason mask = %d, did not want provider id incomplete", mask)
}
}
func TestRefreshDebtReasonMaskValuesAreStable(t *testing.T) {
tests := map[string]int64{
"episode_incomplete": 1,
"stale_provider_id": 2,
"refresh_failure": 4,
"core_metadata_incomplete": 8,
"provider_id_incomplete": 16,
}
got := map[string]int64{
"episode_incomplete": RefreshDebtReasonEpisodeIncomplete,
"stale_provider_id": RefreshDebtReasonStaleProviderID,
"refresh_failure": RefreshDebtReasonRefreshFailure,
"core_metadata_incomplete": RefreshDebtReasonCoreMetadataIncomplete,
"provider_id_incomplete": RefreshDebtReasonProviderIDIncomplete,
}
for name, want := range tests {
if got[name] != want {
t.Fatalf("%s mask = %d, want %d", name, got[name], want)
}
}
}
func TestRefreshDebtPriorityProviderIDIncomplete(t *testing.T) {
if got := refreshDebtPriority(RefreshDebtReasonProviderIDIncomplete); got != 240 {
t.Fatalf("provider id incomplete priority = %d, want 240", got)
}
combined := RefreshDebtReasonProviderIDIncomplete | RefreshDebtReasonStaleProviderID
if got := refreshDebtPriority(combined); got != 250 {
t.Fatalf("combined stale/provider priority = %d, want stale priority 250", got)
}
}
func TestNextRefreshDelayEpisodeSchedule(t *testing.T) {
reasonMask := RefreshDebtReasonEpisodeIncomplete
cases := []struct {
+5 -1
View File
@@ -1985,7 +1985,11 @@ func (s *MetadataService) syncRefreshDebtFailure(ctx context.Context, contentID
return err
}
reasonMask |= staleReason
if strings.EqualFold(strings.TrimSpace(item.Status), "matched") {
if strings.EqualFold(strings.TrimSpace(item.Status), "matched") &&
!hasRefreshDebtReason(reasonMask, RefreshDebtReasonProviderIDIncomplete) {
// Items missing provider IDs fail for that reason, not because the
// refresh itself errored. Tagging them with RefreshFailure muddies
// the metrics — the priority logic already prefers ProviderIDIncomplete.
reasonMask |= RefreshDebtReasonRefreshFailure
}
if reasonMask == 0 {
+666 -15
View File
@@ -7,40 +7,55 @@ import (
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/cache"
"golang.org/x/sync/singleflight"
"golang.org/x/time/rate"
)
const (
defaultBaseURL = "https://api.themoviedb.org/3"
defaultAPIKey = "4ef0d7355d9ffb5151e987764708ce96"
projectAPIKey = "4ef0d7355d9ffb5151e987764708ce96"
maxRetries = 3
maxResponseBody = 1 << 20 // 1 MB
maxCollectionPresetResults = 500
defaultResponseCacheTTL = 2 * time.Hour
)
// Client is an HTTP client for the TMDB collection preset API surface.
type Client struct {
httpClient *http.Client
apiKey string
baseURL string
limiter *rate.Limiter
httpClient *http.Client
apiKey string
baseURL string
limiter *rate.Limiter
discoverSectionCache *cache.TTLCache[*MediaPage]
discoverPageCache *cache.TTLCache[*MediaPage]
externalIDCache *cache.TTLCache[*ExternalIDs]
cacheGroup singleflight.Group
responseCacheTTL time.Duration
}
// NewClient creates a TMDB API client with the given API key and rate limit
// (requests per second). If apiKey is empty the built-in project key is used.
// (requests per second). If apiKey is empty, Silo's public project API key is
// used.
func NewClient(apiKey string, rateLimit int) *Client {
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
apiKey = defaultAPIKey
apiKey = projectAPIKey
}
return &Client{
httpClient: &http.Client{Timeout: 30 * time.Second},
apiKey: apiKey,
baseURL: defaultBaseURL,
limiter: rate.NewLimiter(rate.Limit(rateLimit), rateLimit),
httpClient: &http.Client{Timeout: 30 * time.Second},
apiKey: apiKey,
baseURL: defaultBaseURL,
limiter: rate.NewLimiter(rate.Limit(rateLimit), rateLimit),
discoverSectionCache: cache.NewTTLCache[*MediaPage](),
discoverPageCache: cache.NewTTLCache[*MediaPage](),
externalIDCache: cache.NewTTLCache[*ExternalIDs](),
responseCacheTTL: defaultResponseCacheTTL,
}
}
@@ -49,6 +64,22 @@ func (c *Client) SetBaseURL(url string) {
c.baseURL = url
}
// Close releases background cache sweepers owned by the client.
func (c *Client) Close() {
if c == nil {
return
}
if c.discoverSectionCache != nil {
c.discoverSectionCache.Close()
}
if c.discoverPageCache != nil {
c.discoverPageCache.Close()
}
if c.externalIDCache != nil {
c.externalIDCache.Close()
}
}
// doGet executes a GET request against the TMDB API with rate limiting,
// exponential backoff on 5xx/429, and JSON decoding into dest.
func (c *Client) doGet(ctx context.Context, path string, dest any) error {
@@ -133,6 +164,250 @@ func retryAfterOrDefault(resp *http.Response, attempt int) time.Duration {
return time.Duration(1<<attempt) * time.Second
}
// SearchMedia searches TMDB directly for movies, TV series, or both. mediaType
// accepts Silo-facing "movie", "series", or "all" values, plus TMDB-facing
// "tv" for callers already working at the provider boundary.
func (c *Client) SearchMedia(ctx context.Context, mediaType, query string, page int) (*MediaPage, error) {
query = strings.TrimSpace(query)
if query == "" {
return nil, fmt.Errorf("tmdb: search query is required")
}
if page <= 0 {
page = 1
}
switch mediaType {
case "movie":
values := url.Values{}
values.Set("query", query)
values.Set("include_adult", "false")
values.Set("page", strconv.Itoa(page))
var resp paginatedResponse[mediaMovieResponse]
if err := c.doGet(ctx, "/search/movie?"+values.Encode(), &resp); err != nil {
return nil, err
}
return normalizeMoviePage(resp), nil
case "series", "tv":
values := url.Values{}
values.Set("query", query)
values.Set("include_adult", "false")
values.Set("page", strconv.Itoa(page))
var resp paginatedResponse[mediaTVResponse]
if err := c.doGet(ctx, "/search/tv?"+values.Encode(), &resp); err != nil {
return nil, err
}
return normalizeTVPage(resp), nil
case "all":
values := url.Values{}
values.Set("query", query)
values.Set("include_adult", "false")
values.Set("page", strconv.Itoa(page))
var resp paginatedResponse[mediaMultiSearchResponse]
if err := c.doGet(ctx, "/search/multi?"+values.Encode(), &resp); err != nil {
return nil, err
}
return normalizeMultiSearchPage(resp), nil
default:
return nil, fmt.Errorf("tmdb: invalid media type for search: %q", mediaType)
}
}
// DiscoverSection fetches one of Silo's request-discovery sections directly
// from TMDB. It intentionally does not go through collection sync or
// collection templates.
func (c *Client) DiscoverSection(ctx context.Context, section string, page int) (*MediaPage, error) {
if page <= 0 {
page = 1
}
cacheKey := "discover_section:" + section + ":" + strconv.Itoa(page)
if c.discoverSectionCache != nil {
if cached, ok := c.discoverSectionCache.Get(cacheKey); ok {
return cloneMediaPage(cached), nil
}
}
value, err, _ := c.cacheGroup.Do(cacheKey, func() (any, error) {
if c.discoverSectionCache != nil {
if cached, ok := c.discoverSectionCache.Get(cacheKey); ok {
return cached, nil
}
}
page, err := c.fetchDiscoverSection(ctx, section, page)
if err != nil {
return nil, err
}
cached := cloneMediaPage(page)
if c.discoverSectionCache != nil && c.responseCacheTTL > 0 {
c.discoverSectionCache.Set(cacheKey, cached, c.responseCacheTTL)
}
return cached, nil
})
if err != nil {
return nil, err
}
pageValue, ok := value.(*MediaPage)
if !ok {
return nil, fmt.Errorf("tmdb: invalid cached discovery section response")
}
return cloneMediaPage(pageValue), nil
}
func (c *Client) fetchDiscoverSection(ctx context.Context, section string, page int) (*MediaPage, error) {
values := url.Values{}
values.Set("page", strconv.Itoa(page))
switch section {
case "trending_movies":
var resp paginatedResponse[mediaMovieResponse]
if err := c.doGet(ctx, "/trending/movie/week?"+values.Encode(), &resp); err != nil {
return nil, err
}
return normalizeMoviePage(resp), nil
case "trending_series":
var resp paginatedResponse[mediaTVResponse]
if err := c.doGet(ctx, "/trending/tv/week?"+values.Encode(), &resp); err != nil {
return nil, err
}
return normalizeTVPage(resp), nil
case "popular_movies":
var resp paginatedResponse[mediaMovieResponse]
if err := c.doGet(ctx, "/movie/popular?"+values.Encode(), &resp); err != nil {
return nil, err
}
return normalizeMoviePage(resp), nil
case "popular_series":
var resp paginatedResponse[mediaTVResponse]
if err := c.doGet(ctx, "/tv/popular?"+values.Encode(), &resp); err != nil {
return nil, err
}
return normalizeTVPage(resp), nil
case "upcoming_movies":
var resp paginatedResponse[mediaMovieResponse]
if err := c.doGet(ctx, "/movie/upcoming?"+values.Encode(), &resp); err != nil {
return nil, err
}
return normalizeMoviePage(resp), nil
case "on_air_series":
var resp paginatedResponse[mediaTVResponse]
if err := c.doGet(ctx, "/tv/on_the_air?"+values.Encode(), &resp); err != nil {
return nil, err
}
return normalizeTVPage(resp), nil
default:
return nil, fmt.Errorf("tmdb: invalid discovery section: %q", section)
}
}
func cloneMediaPage(page *MediaPage) *MediaPage {
if page == nil {
return nil
}
cloned := *page
if page.Results != nil {
cloned.Results = append([]MediaResult(nil), page.Results...)
}
return &cloned
}
func normalizeMoviePage(resp paginatedResponse[mediaMovieResponse]) *MediaPage {
page := &MediaPage{
Page: resp.Page,
TotalPages: resp.TotalPages,
TotalResults: resp.TotalResults,
Results: make([]MediaResult, 0, len(resp.Results)),
}
for _, item := range resp.Results {
page.Results = append(page.Results, MediaResult{
ID: item.ID,
MediaType: "movie",
Title: item.Title,
Overview: item.Overview,
PosterPath: item.PosterPath,
BackdropPath: item.BackdropPath,
ReleaseDate: item.ReleaseDate,
Year: releaseYear(item.ReleaseDate),
Popularity: item.Popularity,
VoteAverage: item.VoteAverage,
})
}
return page
}
func normalizeTVPage(resp paginatedResponse[mediaTVResponse]) *MediaPage {
page := &MediaPage{
Page: resp.Page,
TotalPages: resp.TotalPages,
TotalResults: resp.TotalResults,
Results: make([]MediaResult, 0, len(resp.Results)),
}
for _, item := range resp.Results {
page.Results = append(page.Results, MediaResult{
ID: item.ID,
MediaType: "series",
Title: item.Name,
Overview: item.Overview,
PosterPath: item.PosterPath,
BackdropPath: item.BackdropPath,
ReleaseDate: item.FirstAirDate,
Year: releaseYear(item.FirstAirDate),
Popularity: item.Popularity,
VoteAverage: item.VoteAverage,
})
}
return page
}
func normalizeMultiSearchPage(resp paginatedResponse[mediaMultiSearchResponse]) *MediaPage {
page := &MediaPage{
Page: resp.Page,
TotalPages: resp.TotalPages,
TotalResults: resp.TotalResults,
Results: make([]MediaResult, 0, len(resp.Results)),
}
for _, item := range resp.Results {
switch item.MediaType {
case "movie":
page.Results = append(page.Results, MediaResult{
ID: item.ID,
MediaType: "movie",
Title: item.Title,
Overview: item.Overview,
PosterPath: item.PosterPath,
BackdropPath: item.BackdropPath,
ReleaseDate: item.ReleaseDate,
Year: releaseYear(item.ReleaseDate),
Popularity: item.Popularity,
VoteAverage: item.VoteAverage,
})
case "tv":
page.Results = append(page.Results, MediaResult{
ID: item.ID,
MediaType: "series",
Title: item.Name,
Overview: item.Overview,
PosterPath: item.PosterPath,
BackdropPath: item.BackdropPath,
ReleaseDate: item.FirstAirDate,
Year: releaseYear(item.FirstAirDate),
Popularity: item.Popularity,
VoteAverage: item.VoteAverage,
})
}
}
return page
}
func releaseYear(value string) int {
if len(value) < 4 {
return 0
}
year, err := strconv.Atoi(value[:4])
if err != nil {
return 0
}
return year
}
func normalizeCollectionPreset(preset, mediaType, timeWindow string) (string, string, string, string, error) {
switch preset {
case "trending":
@@ -358,6 +633,75 @@ func (c *Client) Discover(ctx context.Context, mediaType string, params Discover
return results, nil
}
// DiscoverPage fetches a single page from TMDB's /discover/{movie,tv} endpoint
// and returns the full MediaPage shape (with posters, overviews, etc.) so the
// request system can enrich it with availability and request state. Unlike
// Discover (which is intended for collection templates and returns just IDs
// and titles), DiscoverPage exposes single-page semantics: callers control
// pagination explicitly.
func (c *Client) DiscoverPage(ctx context.Context, mediaType string, params DiscoverParams, page int) (*MediaPage, error) {
switch mediaType {
case "movie", "tv":
default:
return nil, fmt.Errorf("tmdb: invalid media type for discover: %q", mediaType)
}
if strings.TrimSpace(params.SortBy) == "" {
return nil, fmt.Errorf("tmdb: discover requires sort_by")
}
if page <= 0 {
page = 1
}
query := buildDiscoverQuery(mediaType, params) + "&page=" + strconv.Itoa(page)
path := "/discover/" + mediaType + "?" + query
cacheKey := "discover_page:" + path
if c.discoverPageCache != nil {
if cached, ok := c.discoverPageCache.Get(cacheKey); ok {
return cloneMediaPage(cached), nil
}
}
value, err, _ := c.cacheGroup.Do(cacheKey, func() (any, error) {
if c.discoverPageCache != nil {
if cached, ok := c.discoverPageCache.Get(cacheKey); ok {
return cached, nil
}
}
page, err := c.fetchDiscoverPage(ctx, mediaType, path)
if err != nil {
return nil, err
}
cached := cloneMediaPage(page)
if c.discoverPageCache != nil && c.responseCacheTTL > 0 {
c.discoverPageCache.Set(cacheKey, cached, c.responseCacheTTL)
}
return cached, nil
})
if err != nil {
return nil, err
}
pageValue, ok := value.(*MediaPage)
if !ok {
return nil, fmt.Errorf("tmdb: invalid cached discover page response")
}
return cloneMediaPage(pageValue), nil
}
func (c *Client) fetchDiscoverPage(ctx context.Context, mediaType, path string) (*MediaPage, error) {
if mediaType == "tv" {
var resp paginatedResponse[mediaTVResponse]
if err := c.doGet(ctx, path, &resp); err != nil {
return nil, err
}
return normalizeTVPage(resp), nil
}
var resp paginatedResponse[mediaMovieResponse]
if err := c.doGet(ctx, path, &resp); err != nil {
return nil, err
}
return normalizeMoviePage(resp), nil
}
// buildDiscoverQuery composes the TMDB discover query string (without the
// leading "?" and without page or api_key — doGet handles those).
func buildDiscoverQuery(mediaType string, params DiscoverParams) string {
@@ -370,6 +714,12 @@ func buildDiscoverQuery(mediaType string, params DiscoverParams) string {
if without := joinIntSlice(params.WithoutGenres, ","); without != "" {
values.Set("without_genres", without)
}
if companies := joinIntSlice(params.WithCompanies, ","); companies != "" {
values.Set("with_companies", companies)
}
if networks := joinIntSlice(params.WithNetworks, ","); networks != "" {
values.Set("with_networks", networks)
}
if params.VoteCountGte > 0 {
values.Set("vote_count.gte", strconv.Itoa(params.VoteCountGte))
}
@@ -464,21 +814,322 @@ func (c *Client) GetCollection(ctx context.Context, id int) (*Collection, error)
}, nil
}
// GetMediaDetail fetches a single TMDB movie or series with credits, external
// IDs, recommendations, and the appropriate certification feed, returning a
// normalized MediaDetail. mediaType accepts Silo-facing "movie" or "series".
//
// Cast is sorted by TMDB billing order and capped at 24 entries to keep the
// payload bounded.
func (c *Client) GetMediaDetail(ctx context.Context, mediaType string, id int) (*MediaDetail, error) {
if id <= 0 {
return nil, fmt.Errorf("tmdb: media id must be > 0 (got %d)", id)
}
switch mediaType {
case "movie":
path := fmt.Sprintf("/movie/%d?append_to_response=credits,external_ids,recommendations,release_dates", id)
var resp movieDetailResponse
if err := c.doGet(ctx, path, &resp); err != nil {
return nil, err
}
return normalizeMovieDetail(&resp), nil
case "series", "tv":
path := fmt.Sprintf("/tv/%d?append_to_response=credits,external_ids,recommendations,content_ratings", id)
var resp tvDetailResponse
if err := c.doGet(ctx, path, &resp); err != nil {
return nil, err
}
return normalizeTVDetail(&resp), nil
default:
return nil, fmt.Errorf("tmdb: invalid media type for detail: %q", mediaType)
}
}
func normalizeMovieDetail(resp *movieDetailResponse) *MediaDetail {
detail := &MediaDetail{
MediaType: "movie",
ID: resp.ID,
IMDbID: resp.IMDbID,
Title: resp.Title,
OriginalTitle: resp.OriginalTitle,
Tagline: resp.Tagline,
Overview: resp.Overview,
PosterPath: resp.PosterPath,
BackdropPath: resp.BackdropPath,
ReleaseDate: resp.ReleaseDate,
Year: releaseYear(resp.ReleaseDate),
Runtime: resp.Runtime,
Genres: namesFromGenres(resp.Genres),
VoteAverage: resp.VoteAverage,
VoteCount: resp.VoteCount,
Status: resp.Status,
Homepage: resp.Homepage,
ContentRating: pickMovieCertification(resp.ReleaseDates),
}
for _, company := range resp.ProductionCompanies {
if name := strings.TrimSpace(company.Name); name != "" {
detail.ProductionCompanies = append(detail.ProductionCompanies, name)
}
}
if resp.ExternalIDs != nil {
detail.TVDBID = resp.ExternalIDs.TVDBID
if detail.IMDbID == "" {
detail.IMDbID = resp.ExternalIDs.IMDbID
}
}
if resp.Credits != nil {
detail.Cast = normalizeCast(resp.Credits.Cast)
detail.Director = pickDirector(resp.Credits.Crew)
}
if resp.Recommendations != nil {
detail.Recommendations = make([]MediaResult, 0, len(resp.Recommendations.Results))
for _, item := range resp.Recommendations.Results {
detail.Recommendations = append(detail.Recommendations, MediaResult{
ID: item.ID,
MediaType: "movie",
Title: item.Title,
Overview: item.Overview,
PosterPath: item.PosterPath,
BackdropPath: item.BackdropPath,
ReleaseDate: item.ReleaseDate,
Year: releaseYear(item.ReleaseDate),
Popularity: item.Popularity,
VoteAverage: item.VoteAverage,
})
}
}
return detail
}
func normalizeTVDetail(resp *tvDetailResponse) *MediaDetail {
detail := &MediaDetail{
MediaType: "series",
ID: resp.ID,
Title: resp.Name,
OriginalTitle: resp.OriginalName,
Tagline: resp.Tagline,
Overview: resp.Overview,
PosterPath: resp.PosterPath,
BackdropPath: resp.BackdropPath,
ReleaseDate: resp.FirstAirDate,
FirstAirDate: resp.FirstAirDate,
LastAirDate: resp.LastAirDate,
Year: releaseYear(resp.FirstAirDate),
Genres: namesFromGenres(resp.Genres),
VoteAverage: resp.VoteAverage,
VoteCount: resp.VoteCount,
Status: resp.Status,
Homepage: resp.Homepage,
NumberOfSeasons: resp.NumberOfSeasons,
NumberOfEpisodes: resp.NumberOfEpisodes,
ContentRating: pickTVRating(resp.ContentRatings),
}
if len(resp.EpisodeRunTime) > 0 {
detail.Runtime = resp.EpisodeRunTime[0]
}
for _, network := range resp.Networks {
if name := strings.TrimSpace(network.Name); name != "" {
detail.Networks = append(detail.Networks, name)
}
}
if resp.ExternalIDs != nil {
detail.IMDbID = resp.ExternalIDs.IMDbID
detail.TVDBID = resp.ExternalIDs.TVDBID
}
if resp.Credits != nil {
detail.Cast = normalizeCast(resp.Credits.Cast)
}
for _, person := range resp.CreatedBy {
if name := strings.TrimSpace(person.Name); name != "" {
detail.Creators = append(detail.Creators, name)
}
}
if resp.Recommendations != nil {
detail.Recommendations = make([]MediaResult, 0, len(resp.Recommendations.Results))
for _, item := range resp.Recommendations.Results {
detail.Recommendations = append(detail.Recommendations, MediaResult{
ID: item.ID,
MediaType: "series",
Title: item.Name,
Overview: item.Overview,
PosterPath: item.PosterPath,
BackdropPath: item.BackdropPath,
ReleaseDate: item.FirstAirDate,
Year: releaseYear(item.FirstAirDate),
Popularity: item.Popularity,
VoteAverage: item.VoteAverage,
})
}
}
return detail
}
func namesFromGenres(genres []genreEntry) []string {
if len(genres) == 0 {
return nil
}
out := make([]string, 0, len(genres))
for _, g := range genres {
if name := strings.TrimSpace(g.Name); name != "" {
out = append(out, name)
}
}
return out
}
// normalizeCast sorts by billing order and caps the result so the response
// payload stays bounded — the request detail UI surfaces only the top of the
// list anyway.
func normalizeCast(cast []castEntry) []MediaCastMember {
if len(cast) == 0 {
return nil
}
sorted := make([]castEntry, len(cast))
copy(sorted, cast)
sort.SliceStable(sorted, func(i, j int) bool {
return sorted[i].Order < sorted[j].Order
})
const maxCast = 24
if len(sorted) > maxCast {
sorted = sorted[:maxCast]
}
out := make([]MediaCastMember, 0, len(sorted))
for _, member := range sorted {
name := strings.TrimSpace(member.Name)
if name == "" {
continue
}
out = append(out, MediaCastMember{
Name: name,
Character: strings.TrimSpace(member.Character),
ProfilePath: member.ProfilePath,
Order: member.Order,
})
}
return out
}
func pickDirector(crew []crewEntry) string {
for _, member := range crew {
if strings.EqualFold(member.Job, "Director") {
return strings.TrimSpace(member.Name)
}
}
return ""
}
// pickMovieCertification picks the US theatrical certification if available,
// then falls back to any non-empty US certification, then any non-empty
// certification at all. Type 3 is theatrical in TMDB's release-type taxonomy.
func pickMovieCertification(rd *releaseDatesResponse) string {
if rd == nil {
return ""
}
var fallbackUS, fallbackAny string
for _, country := range rd.Results {
isUS := strings.EqualFold(country.ISO3166, "US")
for _, entry := range country.ReleaseDates {
cert := strings.TrimSpace(entry.Certification)
if cert == "" {
continue
}
if isUS && entry.Type == 3 {
return cert
}
if isUS && fallbackUS == "" {
fallbackUS = cert
}
if fallbackAny == "" {
fallbackAny = cert
}
}
}
if fallbackUS != "" {
return fallbackUS
}
return fallbackAny
}
func pickTVRating(cr *contentRatingsResponse) string {
if cr == nil {
return ""
}
var fallback string
for _, entry := range cr.Results {
rating := strings.TrimSpace(entry.Rating)
if rating == "" {
continue
}
if strings.EqualFold(entry.ISO3166, "US") {
return rating
}
if fallback == "" {
fallback = rating
}
}
return fallback
}
// GetExternalIDs fetches external IDs for a TMDB movie or TV entry.
// Uses the dedicated external_ids endpoint instead of append_to_response on
// the full detail, which would return a 100+ KB payload to extract a handful
// of identifiers.
func (c *Client) GetExternalIDs(ctx context.Context, mediaType string, id int) (*ExternalIDs, error) {
var path string
switch mediaType {
case "movie":
path = fmt.Sprintf("/movie/%d?append_to_response=external_ids", id)
path = fmt.Sprintf("/movie/%d/external_ids", id)
case "tv":
path = fmt.Sprintf("/tv/%d?append_to_response=external_ids", id)
path = fmt.Sprintf("/tv/%d/external_ids", id)
default:
return nil, fmt.Errorf("tmdb: invalid media type: %q", mediaType)
}
var resp externalIDsResponse
cacheKey := "external_ids:" + path
if c.externalIDCache != nil {
if cached, ok := c.externalIDCache.Get(cacheKey); ok {
return cloneExternalIDs(cached), nil
}
}
value, err, _ := c.cacheGroup.Do(cacheKey, func() (any, error) {
if c.externalIDCache != nil {
if cached, ok := c.externalIDCache.Get(cacheKey); ok {
return cached, nil
}
}
ids, err := c.fetchExternalIDs(ctx, path)
if err != nil {
return nil, err
}
cached := cloneExternalIDs(ids)
if c.externalIDCache != nil && c.responseCacheTTL > 0 {
c.externalIDCache.Set(cacheKey, cached, c.responseCacheTTL)
}
return cached, nil
})
if err != nil {
return nil, err
}
ids, ok := value.(*ExternalIDs)
if !ok {
return nil, fmt.Errorf("tmdb: invalid cached external IDs response")
}
return cloneExternalIDs(ids), nil
}
func cloneExternalIDs(ids *ExternalIDs) *ExternalIDs {
if ids == nil {
return nil
}
cloned := *ids
return &cloned
}
func (c *Client) fetchExternalIDs(ctx context.Context, path string) (*ExternalIDs, error) {
var resp ExternalIDs
if err := c.doGet(ctx, path, &resp); err != nil {
return nil, err
}
return resp.ExternalIDs, nil
return &resp, nil
}
+552 -8
View File
@@ -4,9 +4,28 @@ import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
)
func TestNewClientUsesProjectAPIKeyWhenEmpty(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("api_key"); got != projectAPIKey {
t.Fatalf("api_key query = %q, want project API key", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"page":1,"total_pages":1,"total_results":0,"results":[]}`))
}))
defer server.Close()
client := NewClient("", 1000)
client.SetBaseURL(server.URL)
if _, err := client.GetCollectionPreset(context.Background(), "trending", "all", "day", 10); err != nil {
t.Fatalf("GetCollectionPreset returned error: %v", err)
}
}
func TestGetCollectionPresetTrending(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/trending/all/day" {
@@ -52,6 +71,212 @@ func TestGetCollectionPresetTrending(t *testing.T) {
}
}
func TestDiscoverSectionCachesSuccess(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.URL.Path != "/movie/popular" {
http.NotFound(w, r)
return
}
if got := r.URL.Query().Get("page"); got != "1" {
t.Fatalf("page query = %q, want 1", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": 1,
"total_pages": 1,
"total_results": 1,
"results": [
{"id": 11, "title": "Cached Movie", "overview": "from tmdb"}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
first, err := client.DiscoverSection(context.Background(), "popular_movies", 1)
if err != nil {
t.Fatalf("first DiscoverSection returned error: %v", err)
}
second, err := client.DiscoverSection(context.Background(), "popular_movies", 1)
if err != nil {
t.Fatalf("second DiscoverSection returned error: %v", err)
}
if got := calls.Load(); got != 1 {
t.Fatalf("upstream calls = %d, want 1", got)
}
for name, page := range map[string]*MediaPage{"first": first, "second": second} {
if page == nil || len(page.Results) != 1 {
t.Fatalf("%s page results = %#v, want one result", name, page)
}
if page.Results[0].ID != 11 || page.Results[0].Title != "Cached Movie" {
t.Fatalf("%s result = %+v", name, page.Results[0])
}
}
}
func TestDiscoverSectionCacheKeyIncludesPage(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.URL.Path != "/movie/popular" {
http.NotFound(w, r)
return
}
page := r.URL.Query().Get("page")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": ` + page + `,
"total_pages": 2,
"total_results": 2,
"results": [
{"id": ` + page + `, "title": "Movie ` + page + `"}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
first, err := client.DiscoverSection(context.Background(), "popular_movies", 1)
if err != nil {
t.Fatalf("page 1 DiscoverSection returned error: %v", err)
}
second, err := client.DiscoverSection(context.Background(), "popular_movies", 2)
if err != nil {
t.Fatalf("page 2 DiscoverSection returned error: %v", err)
}
if got := calls.Load(); got != 2 {
t.Fatalf("upstream calls = %d, want 2", got)
}
if first.Results[0].ID != 1 || second.Results[0].ID != 2 {
t.Fatalf("cached pages collapsed unexpectedly: first=%+v second=%+v", first.Results[0], second.Results[0])
}
}
func TestDiscoverSectionDoesNotCacheClientErrors(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
call := calls.Add(1)
if r.URL.Path != "/movie/popular" {
http.NotFound(w, r)
return
}
if call == 1 {
http.Error(w, `{"status_message":"bad section"}`, http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": 1,
"total_pages": 1,
"total_results": 1,
"results": [
{"id": 22, "title": "Recovered Movie"}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
if _, err := client.DiscoverSection(context.Background(), "popular_movies", 1); err == nil {
t.Fatal("first DiscoverSection expected error")
}
page, err := client.DiscoverSection(context.Background(), "popular_movies", 1)
if err != nil {
t.Fatalf("second DiscoverSection returned error: %v", err)
}
if got := calls.Load(); got != 2 {
t.Fatalf("upstream calls = %d, want 2", got)
}
if page == nil || len(page.Results) != 1 || page.Results[0].ID != 22 {
t.Fatalf("second page = %#v, want recovered movie", page)
}
}
func TestDiscoverSectionReturnsClonedCachedPage(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.URL.Path != "/movie/popular" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": 1,
"total_pages": 1,
"total_results": 1,
"results": [
{"id": 33, "title": "Immutable Movie"}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
first, err := client.DiscoverSection(context.Background(), "popular_movies", 1)
if err != nil {
t.Fatalf("first DiscoverSection returned error: %v", err)
}
first.Results[0].Title = "mutated by caller"
second, err := client.DiscoverSection(context.Background(), "popular_movies", 1)
if err != nil {
t.Fatalf("second DiscoverSection returned error: %v", err)
}
if got := calls.Load(); got != 1 {
t.Fatalf("upstream calls = %d, want 1", got)
}
if second.Results[0].Title != "Immutable Movie" {
t.Fatalf("cached title = %q, want Immutable Movie", second.Results[0].Title)
}
}
func TestGetExternalIDsCachesSuccess(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.URL.Path != "/movie/123/external_ids" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"imdb_id":"tt123","tvdb_id":456}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
first, err := client.GetExternalIDs(context.Background(), "movie", 123)
if err != nil {
t.Fatalf("first GetExternalIDs returned error: %v", err)
}
second, err := client.GetExternalIDs(context.Background(), "movie", 123)
if err != nil {
t.Fatalf("second GetExternalIDs returned error: %v", err)
}
if got := calls.Load(); got != 1 {
t.Fatalf("upstream calls = %d, want 1", got)
}
if first.IMDbID != "tt123" || first.TVDBID != 456 || second.IMDbID != "tt123" || second.TVDBID != 456 {
t.Fatalf("external IDs = first %+v second %+v", first, second)
}
}
func TestDiscoverMovieAppliesFilters(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -204,6 +429,34 @@ func TestDiscoverTVUsesFirstAirDate(t *testing.T) {
}
}
func TestDiscoverIncludesCompaniesAndNetworks(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if got := q.Get("with_companies"); got != "420,2" {
t.Errorf("with_companies = %q, want 420,2", got)
}
if got := q.Get("with_networks"); got != "213,49" {
t.Errorf("with_networks = %q, want 213,49", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"page":1,"total_pages":1,"total_results":0,"results":[]}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
_, err := client.Discover(context.Background(), "movie", DiscoverParams{
SortBy: "popularity.desc",
WithCompanies: []int{420, 2},
WithNetworks: []int{213, 49},
Limit: 5,
})
if err != nil {
t.Fatalf("Discover returned error: %v", err)
}
}
func TestDiscoverRejectsInvalidMediaType(t *testing.T) {
client := NewClient("test-key", 1000)
_, err := client.Discover(context.Background(), "all", DiscoverParams{SortBy: "popularity.desc"})
@@ -220,6 +473,302 @@ func TestDiscoverRequiresSortBy(t *testing.T) {
}
}
func TestDiscoverPageMovieReturnsFullResults(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/discover/movie" {
http.NotFound(w, r)
return
}
q := r.URL.Query()
if got := q.Get("sort_by"); got != "popularity.desc" {
t.Errorf("sort_by = %q, want popularity.desc", got)
}
if got := q.Get("with_companies"); got != "420" {
t.Errorf("with_companies = %q, want 420", got)
}
if got := q.Get("page"); got != "2" {
t.Errorf("page = %q, want 2", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": 2,
"total_pages": 8,
"total_results": 160,
"results": [
{"id": 24428, "title": "The Avengers", "release_date": "2012-04-25", "poster_path": "/p.jpg", "overview": "earth's mightiest", "popularity": 100.5, "vote_average": 7.7}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
page, err := client.DiscoverPage(context.Background(), "movie", DiscoverParams{
SortBy: "popularity.desc",
WithCompanies: []int{420},
}, 2)
if err != nil {
t.Fatalf("DiscoverPage: %v", err)
}
if page.Page != 2 || page.TotalPages != 8 || page.TotalResults != 160 {
t.Fatalf("page = %+v", page)
}
if len(page.Results) != 1 {
t.Fatalf("results = %d, want 1", len(page.Results))
}
got := page.Results[0]
if got.ID != 24428 || got.MediaType != "movie" || got.Title != "The Avengers" || got.Year != 2012 {
t.Errorf("result = %+v", got)
}
if got.PosterPath != "/p.jpg" || got.Overview != "earth's mightiest" {
t.Errorf("result detail mismatch: %+v", got)
}
}
func TestDiscoverPageTVUsesFirstAirDate(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/discover/tv" {
http.NotFound(w, r)
return
}
q := r.URL.Query()
if got := q.Get("with_networks"); got != "213" {
t.Errorf("with_networks = %q, want 213", got)
}
if got := q.Get("first_air_date.gte"); got != "" {
t.Errorf("first_air_date.gte = %q, want empty", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": 1,
"total_pages": 1,
"total_results": 1,
"results": [
{"id": 1399, "name": "Game of Thrones", "first_air_date": "2011-04-17", "poster_path": "/g.jpg"}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
page, err := client.DiscoverPage(context.Background(), "tv", DiscoverParams{
SortBy: "vote_average.desc",
WithNetworks: []int{213},
}, 1)
if err != nil {
t.Fatalf("DiscoverPage tv: %v", err)
}
if len(page.Results) != 1 {
t.Fatalf("results = %d, want 1", len(page.Results))
}
got := page.Results[0]
if got.MediaType != "series" || got.Title != "Game of Thrones" || got.Year != 2011 {
t.Errorf("result = %+v", got)
}
}
func TestDiscoverPageRejectsInvalidMediaType(t *testing.T) {
client := NewClient("test-key", 1000)
_, err := client.DiscoverPage(context.Background(), "all", DiscoverParams{SortBy: "popularity.desc"}, 1)
if err == nil {
t.Fatal("expected error for invalid media type")
}
}
func TestDiscoverPageRequiresSortBy(t *testing.T) {
client := NewClient("test-key", 1000)
_, err := client.DiscoverPage(context.Background(), "movie", DiscoverParams{}, 1)
if err == nil {
t.Fatal("expected error when sort_by is empty")
}
}
func TestDiscoverPageDefaultsToPage1(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("page"); got != "1" {
t.Errorf("page = %q, want 1", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"page":1,"total_pages":1,"total_results":0,"results":[]}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
if _, err := client.DiscoverPage(context.Background(), "movie", DiscoverParams{SortBy: "popularity.desc"}, 0); err != nil {
t.Fatalf("DiscoverPage: %v", err)
}
}
func TestSearchMediaMovie(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/search/movie" {
http.NotFound(w, r)
return
}
q := r.URL.Query()
if got := q.Get("query"); got != "fight club" {
t.Fatalf("query = %q, want fight club", got)
}
if got := q.Get("include_adult"); got != "false" {
t.Fatalf("include_adult = %q, want false", got)
}
if got := q.Get("page"); got != "2" {
t.Fatalf("page = %q, want 2", got)
}
if got := q.Get("api_key"); got != "test-key" {
t.Fatalf("api_key query = %q, want test-key", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": 2,
"total_pages": 5,
"total_results": 50,
"results": [
{
"id": 550,
"title": "Fight Club",
"overview": "overview",
"poster_path": "/poster.jpg",
"backdrop_path": "/backdrop.jpg",
"release_date": "1999-10-15",
"popularity": 10.5,
"vote_average": 8.4
}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
page, err := client.SearchMedia(context.Background(), "movie", "fight club", 2)
if err != nil {
t.Fatalf("SearchMedia returned error: %v", err)
}
if page.Page != 2 || page.TotalPages != 5 || len(page.Results) != 1 {
t.Fatalf("page = %+v, want page metadata and one result", page)
}
result := page.Results[0]
if result.ID != 550 || result.MediaType != "movie" || result.Year != 1999 {
t.Fatalf("result = %+v, want normalized movie result", result)
}
}
func TestSearchMediaAllUsesMultiSearchAndFiltersToMoviesAndSeries(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/search/multi" {
http.NotFound(w, r)
return
}
q := r.URL.Query()
if got := q.Get("query"); got != "fight club" {
t.Fatalf("query = %q, want fight club", got)
}
if got := q.Get("include_adult"); got != "false" {
t.Fatalf("include_adult = %q, want false", got)
}
if got := q.Get("page"); got != "2" {
t.Fatalf("page = %q, want 2", got)
}
if got := q.Get("api_key"); got != "test-key" {
t.Fatalf("api_key query = %q, want test-key", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": 2,
"total_pages": 4,
"total_results": 40,
"results": [
{
"id": 550,
"media_type": "movie",
"title": "Fight Club",
"release_date": "1999-10-15"
},
{
"id": 1399,
"media_type": "tv",
"name": "Fight Club: The Series",
"first_air_date": "2020-01-01"
},
{
"id": 123,
"media_type": "person",
"name": "A Performer"
}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
page, err := client.SearchMedia(context.Background(), "all", "fight club", 2)
if err != nil {
t.Fatalf("SearchMedia returned error: %v", err)
}
if page.Page != 2 || page.TotalPages != 4 || page.TotalResults != 40 {
t.Fatalf("page metadata = %+v, want TMDB pagination metadata", page)
}
if len(page.Results) != 2 {
t.Fatalf("len(results) = %d, want 2", len(page.Results))
}
if page.Results[0].ID != 550 || page.Results[0].MediaType != "movie" || page.Results[0].Year != 1999 {
t.Fatalf("results[0] = %+v, want normalized movie", page.Results[0])
}
if page.Results[1].ID != 1399 || page.Results[1].MediaType != "series" || page.Results[1].Year != 2020 {
t.Fatalf("results[1] = %+v, want normalized series", page.Results[1])
}
}
func TestDiscoverSectionTrendingSeries(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/trending/tv/week" {
http.NotFound(w, r)
return
}
if got := r.URL.Query().Get("page"); got != "1" {
t.Fatalf("page = %q, want 1", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": 1,
"total_pages": 1,
"total_results": 1,
"results": [
{
"id": 1399,
"name": "Game of Thrones",
"first_air_date": "2011-04-17"
}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
page, err := client.DiscoverSection(context.Background(), "trending_series", 1)
if err != nil {
t.Fatalf("DiscoverSection returned error: %v", err)
}
if len(page.Results) != 1 {
t.Fatalf("len(results) = %d, want 1", len(page.Results))
}
result := page.Results[0]
if result.ID != 1399 || result.MediaType != "series" || result.Year != 2011 {
t.Fatalf("result = %+v, want normalized series result", result)
}
}
func TestGetCollection(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/collection/86311" {
@@ -304,23 +853,18 @@ func TestGetCollectionRejectsNonPositiveID(t *testing.T) {
func TestGetExternalIDs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/movie/123" {
if r.URL.Path != "/movie/123/external_ids" {
http.NotFound(w, r)
return
}
if got := r.URL.Query().Get("append_to_response"); got != "external_ids" {
t.Fatalf("append_to_response = %q, want external_ids", got)
}
if got := r.URL.Query().Get("api_key"); got != "test-key" {
t.Fatalf("api_key query = %q, want test-key", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"external_ids": {
"imdb_id": "tt0133093",
"tvdb_id": 12345
}
"imdb_id": "tt0133093",
"tvdb_id": 12345
}`))
}))
defer server.Close()
+234 -2
View File
@@ -35,12 +35,43 @@ type CollectionResult struct {
Title string
}
// MediaResult is a normalized TMDB movie or TV result for request search and
// discovery surfaces. MediaType is Silo-facing: "movie" or "series".
//
// PosterPath and BackdropPath are raw TMDB path fragments (e.g. "/abc.jpg").
// Callers must compose the full URL by prepending the TMDB image base
// (https://image.tmdb.org/t/p/{size}); the values are not browser-loadable
// on their own.
type MediaResult struct {
ID int
MediaType string
Title string
Overview string
PosterPath string
BackdropPath string
ReleaseDate string
Year int
Popularity float64
VoteAverage float64
}
// MediaPage is a paginated TMDB search/discovery response normalized for the
// request system.
type MediaPage struct {
Page int
TotalPages int
TotalResults int
Results []MediaResult
}
// DiscoverParams mirrors the TMDB `/discover/{movie,tv}` query parameters and
// is shaped to map 1:1 onto TMDBDiscoverSpec. Limit caps the total results
// the client paginates over.
type DiscoverParams struct {
WithGenres []int
WithoutGenres []int
WithCompanies []int
WithNetworks []int
SortBy string
VoteCountGte int
VoteAverageGte float64
@@ -95,11 +126,212 @@ type collectionResponsePart struct {
ReleaseDate string `json:"release_date"`
}
type externalIDsResponse struct {
ExternalIDs *ExternalIDs `json:"external_ids"`
type mediaMovieResponse struct {
ID int `json:"id"`
Title string `json:"title"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"`
ReleaseDate string `json:"release_date"`
Popularity float64 `json:"popularity"`
VoteAverage float64 `json:"vote_average"`
}
type mediaTVResponse struct {
ID int `json:"id"`
Name string `json:"name"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"`
FirstAirDate string `json:"first_air_date"`
Popularity float64 `json:"popularity"`
VoteAverage float64 `json:"vote_average"`
}
type mediaMultiSearchResponse struct {
ID int `json:"id"`
MediaType string `json:"media_type"`
Title string `json:"title"`
Name string `json:"name"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"`
ReleaseDate string `json:"release_date"`
FirstAirDate string `json:"first_air_date"`
Popularity float64 `json:"popularity"`
VoteAverage float64 `json:"vote_average"`
}
type apiError struct {
StatusMessage string `json:"status_message"`
StatusCode int `json:"status_code"`
}
// MediaDetail is a normalized TMDB detail payload for the request system.
// MediaType is Silo-facing: "movie" or "series". Series-specific fields are
// zero-valued for movies and vice versa.
type MediaDetail struct {
MediaType string
ID int
IMDbID string
TVDBID int
Title string
OriginalTitle string
Tagline string
Overview string
PosterPath string
BackdropPath string
ReleaseDate string
Year int
Runtime int
Genres []string
VoteAverage float64
VoteCount int
Status string
Homepage string
ContentRating string
ProductionCompanies []string
NumberOfSeasons int
NumberOfEpisodes int
FirstAirDate string
LastAirDate string
Networks []string
Cast []MediaCastMember
Director string
Creators []string
Recommendations []MediaResult
}
// MediaCastMember is a single cast member entry from a TMDB credits response,
// normalized for the request detail surface.
type MediaCastMember struct {
Name string
Character string
ProfilePath string
Order int
}
// genreEntry / companyEntry / networkEntry / personEntry mirror small object
// shapes from the TMDB JSON. They're internal to the decode path.
type genreEntry struct {
ID int `json:"id"`
Name string `json:"name"`
}
type companyEntry struct {
ID int `json:"id"`
Name string `json:"name"`
}
type networkEntry struct {
ID int `json:"id"`
Name string `json:"name"`
}
type creditsResponse struct {
Cast []castEntry `json:"cast"`
Crew []crewEntry `json:"crew"`
}
type castEntry struct {
ID int `json:"id"`
Name string `json:"name"`
Character string `json:"character"`
ProfilePath string `json:"profile_path"`
Order int `json:"order"`
}
type crewEntry struct {
ID int `json:"id"`
Name string `json:"name"`
Job string `json:"job"`
Department string `json:"department"`
}
type personEntry struct {
ID int `json:"id"`
Name string `json:"name"`
}
type recommendationsMovieResponse struct {
Results []mediaMovieResponse `json:"results"`
}
type recommendationsTVResponse struct {
Results []mediaTVResponse `json:"results"`
}
type releaseDatesResponse struct {
Results []releaseDatesCountryEntry `json:"results"`
}
type releaseDatesCountryEntry struct {
ISO3166 string `json:"iso_3166_1"`
ReleaseDates []releaseDateEntry `json:"release_dates"`
}
type releaseDateEntry struct {
Certification string `json:"certification"`
ReleaseDate string `json:"release_date"`
Type int `json:"type"`
}
type contentRatingsResponse struct {
Results []contentRatingEntry `json:"results"`
}
type contentRatingEntry struct {
ISO3166 string `json:"iso_3166_1"`
Rating string `json:"rating"`
}
type movieDetailResponse struct {
ID int `json:"id"`
IMDbID string `json:"imdb_id"`
Title string `json:"title"`
OriginalTitle string `json:"original_title"`
Tagline string `json:"tagline"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"`
ReleaseDate string `json:"release_date"`
Runtime int `json:"runtime"`
Genres []genreEntry `json:"genres"`
VoteAverage float64 `json:"vote_average"`
VoteCount int `json:"vote_count"`
Status string `json:"status"`
Homepage string `json:"homepage"`
ProductionCompanies []companyEntry `json:"production_companies"`
Credits *creditsResponse `json:"credits"`
ExternalIDs *ExternalIDs `json:"external_ids"`
Recommendations *recommendationsMovieResponse `json:"recommendations"`
ReleaseDates *releaseDatesResponse `json:"release_dates"`
}
type tvDetailResponse struct {
ID int `json:"id"`
Name string `json:"name"`
OriginalName string `json:"original_name"`
Tagline string `json:"tagline"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"`
FirstAirDate string `json:"first_air_date"`
LastAirDate string `json:"last_air_date"`
EpisodeRunTime []int `json:"episode_run_time"`
NumberOfSeasons int `json:"number_of_seasons"`
NumberOfEpisodes int `json:"number_of_episodes"`
Genres []genreEntry `json:"genres"`
VoteAverage float64 `json:"vote_average"`
VoteCount int `json:"vote_count"`
Status string `json:"status"`
Homepage string `json:"homepage"`
Networks []networkEntry `json:"networks"`
CreatedBy []personEntry `json:"created_by"`
Credits *creditsResponse `json:"credits"`
ExternalIDs *ExternalIDs `json:"external_ids"`
Recommendations *recommendationsTVResponse `json:"recommendations"`
ContentRatings *contentRatingsResponse `json:"content_ratings"`
}
+120
View File
@@ -0,0 +1,120 @@
package arrclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const maxResponseBody = 1 << 20
type Client struct {
baseURL string
apiKey string
httpClient *http.Client
}
type HTTPError struct {
StatusCode int
Body string
}
type DecodeError struct {
StatusCode int
Err error
}
func (e HTTPError) Error() string {
if e.Body == "" {
return fmt.Sprintf("arr: HTTP %d", e.StatusCode)
}
return fmt.Sprintf("arr: HTTP %d: %s", e.StatusCode, e.Body)
}
func (e DecodeError) Error() string {
return fmt.Sprintf("arr: decode response: %v", e.Err)
}
func (e DecodeError) Unwrap() error {
return e.Err
}
func IsEmptyOrTruncatedDecodeError(err error) bool {
var decodeErr DecodeError
if !errors.As(err, &decodeErr) {
return false
}
return errors.Is(decodeErr.Err, io.EOF) || errors.Is(decodeErr.Err, io.ErrUnexpectedEOF)
}
func New(baseURL, apiKey string, httpClient *http.Client) *Client {
if httpClient == nil {
httpClient = &http.Client{Timeout: 30 * time.Second}
}
return &Client{
baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
apiKey: strings.TrimSpace(apiKey),
httpClient: httpClient,
}
}
func (c *Client) GetJSON(ctx context.Context, path string, dest any) error {
return c.DoJSON(ctx, http.MethodGet, path, nil, dest)
}
func (c *Client) PostJSON(ctx context.Context, path string, body, dest any) error {
return c.DoJSON(ctx, http.MethodPost, path, body, dest)
}
func (c *Client) DoJSON(ctx context.Context, method, path string, body, dest any) error {
if c.baseURL == "" {
return fmt.Errorf("arr: base url is required")
}
if c.apiKey == "" {
return fmt.Errorf("arr: api key is required")
}
var reader io.Reader
if body != nil {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(body); err != nil {
return fmt.Errorf("arr: encode request: %w", err)
}
reader = &buf
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
if err != nil {
return fmt.Errorf("arr: create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Api-Key", c.apiKey)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("arr: request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
return HTTPError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))}
}
if dest == nil || resp.StatusCode == http.StatusNoContent {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBody))
return nil
}
if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(dest); err != nil {
return DecodeError{StatusCode: resp.StatusCode, Err: err}
}
return nil
}
+34
View File
@@ -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
}
+85
View File
@@ -0,0 +1,85 @@
package arrclient
import "strings"
type QueueResource struct {
ID int `json:"id,omitempty"`
MovieID int `json:"movieId,omitempty"`
SeriesID int `json:"seriesId,omitempty"`
Title string `json:"title,omitempty"`
Status string `json:"status,omitempty"`
TrackedDownloadStatus string `json:"trackedDownloadStatus,omitempty"`
TrackedDownloadState string `json:"trackedDownloadState,omitempty"`
DownloadID string `json:"downloadId,omitempty"`
OutputPath string `json:"outputPath,omitempty"`
}
type QueueEvaluation struct {
State string
ExternalStatus string
Message string
}
const (
QueueStateQueued = "queued"
QueueStateDownloading = "downloading"
QueueStateFailed = "failed"
)
func EvaluateQueue(resources []QueueResource) QueueEvaluation {
if len(resources) == 0 {
return QueueEvaluation{State: QueueStateQueued, ExternalStatus: "not_in_queue"}
}
queued := false
downloadingStatus := ""
for _, resource := range resources {
status := strings.TrimSpace(resource.Status)
state := strings.TrimSpace(resource.TrackedDownloadState)
externalStatus := joinStatus(status, state)
if status == "failed" || state == "failed" || state == "failedPending" {
return QueueEvaluation{
State: QueueStateFailed,
ExternalStatus: externalStatus,
Message: queueFailureMessage(resource, externalStatus),
}
}
if status == "downloading" || state == "downloading" || state == "importPending" || state == "importing" {
if downloadingStatus == "" {
downloadingStatus = externalStatus
}
queued = true
continue
}
queued = true
}
if downloadingStatus != "" {
return QueueEvaluation{State: QueueStateDownloading, ExternalStatus: downloadingStatus}
}
if queued {
return QueueEvaluation{State: QueueStateQueued, ExternalStatus: "queued"}
}
return QueueEvaluation{State: QueueStateQueued, ExternalStatus: "unknown"}
}
func joinStatus(status, state string) string {
switch {
case status != "" && state != "":
return status + "/" + state
case status != "":
return status
case state != "":
return state
default:
return "unknown"
}
}
func queueFailureMessage(resource QueueResource, externalStatus string) string {
title := strings.TrimSpace(resource.Title)
if title == "" {
return "external queue failed: " + externalStatus
}
return "external queue failed for " + title + ": " + externalStatus
}
+20
View File
@@ -0,0 +1,20 @@
package arrclient
import "testing"
func TestEvaluateQueueFailureWinsOverDownloading(t *testing.T) {
result := EvaluateQueue([]QueueResource{
{Status: "downloading", TrackedDownloadState: "downloading"},
{Title: "Bad Release", Status: "queued", TrackedDownloadState: "failedPending"},
})
if result.State != QueueStateFailed {
t.Fatalf("state = %q, want failed", result.State)
}
if result.ExternalStatus != "queued/failedPending" {
t.Fatalf("external status = %q, want queued/failedPending", result.ExternalStatus)
}
if result.Message == "" {
t.Fatal("message should describe the queue failure")
}
}
+103
View File
@@ -0,0 +1,103 @@
package arrclient
import (
"context"
"strconv"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
)
type RootFolderResource struct {
Path string `json:"path"`
FreeSpace int64 `json:"freeSpace"`
TotalSpace int64 `json:"totalSpace"`
Accessible bool `json:"accessible"`
}
type QualityProfileResource struct {
ID int `json:"id"`
Name string `json:"name"`
}
type TagResource struct {
ID int `json:"id"`
Label string `json:"label"`
}
func ListRootFolders(ctx context.Context, client *Client) ([]mediarequests.IntegrationRootFolder, error) {
var resources []RootFolderResource
if err := client.GetJSON(ctx, "/api/v3/rootfolder", &resources); err != nil {
return nil, err
}
out := make([]mediarequests.IntegrationRootFolder, 0, len(resources))
for _, resource := range resources {
out = append(out, mediarequests.IntegrationRootFolder{
Path: resource.Path,
FreeSpace: resource.FreeSpace,
TotalSpace: resource.TotalSpace,
Accessible: resource.Accessible,
})
}
return out, nil
}
func ListQualityProfiles(ctx context.Context, client *Client) ([]mediarequests.IntegrationQualityProfile, error) {
var resources []QualityProfileResource
if err := client.GetJSON(ctx, "/api/v3/qualityprofile", &resources); err != nil {
return nil, err
}
out := make([]mediarequests.IntegrationQualityProfile, 0, len(resources))
for _, resource := range resources {
out = append(out, mediarequests.IntegrationQualityProfile{
ID: resource.ID,
Name: resource.Name,
})
}
return out, nil
}
func ListTags(ctx context.Context, client *Client) ([]mediarequests.IntegrationTag, error) {
var resources []TagResource
if err := client.GetJSON(ctx, "/api/v3/tag", &resources); err != nil {
return nil, err
}
out := make([]mediarequests.IntegrationTag, 0, len(resources))
for _, resource := range resources {
out = append(out, mediarequests.IntegrationTag{
ID: resource.ID,
Label: resource.Label,
})
}
return out, nil
}
// AcceptedWithoutResponse returns a FulfillmentResult marking the submission
// as accepted by the downstream integration but with no external id captured
// (typically a 201 with empty body when lookup recovery also fails).
func AcceptedWithoutResponse(kind string) mediarequests.FulfillmentResult {
return mediarequests.FulfillmentResult{
IntegrationKind: kind,
ExternalStatus: "accepted_without_response",
}
}
// StatusFromQueueEvaluation translates an arrclient.QueueEvaluation into the
// mediarequests.FulfillmentStatus shape shared by Radarr and Sonarr clients.
func StatusFromQueueEvaluation(kind string, externalID int, evaluation QueueEvaluation) mediarequests.FulfillmentStatus {
status := mediarequests.StatusQueued
outcome := mediarequests.Outcome("")
if evaluation.State == QueueStateDownloading {
status = mediarequests.StatusDownloading
}
if evaluation.State == QueueStateFailed {
outcome = mediarequests.OutcomeFailed
}
return mediarequests.FulfillmentStatus{
Status: status,
Outcome: outcome,
IntegrationKind: kind,
ExternalID: strconv.Itoa(externalID),
ExternalStatus: evaluation.ExternalStatus,
Message: evaluation.Message,
}
}
+268
View File
@@ -0,0 +1,268 @@
package requests
import (
"context"
"fmt"
"strings"
"github.com/Silo-Server/silo-server/internal/metadata/tmdb"
)
// DiscoverBrandCard is one card on the Studios / Networks / Genres carousels.
// Studios and networks carry a TMDB ID and a logo URL rendered with TMDB's
// duotone filter; genres carry gradient hints and a display name instead.
type DiscoverBrandCard struct {
TMDBID int `json:"tmdb_id,omitempty"`
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
LogoURL *string `json:"logo_url,omitempty"`
GradientFrom string `json:"gradient_from,omitempty"`
GradientTo string `json:"gradient_to,omitempty"`
SeriesSupported bool `json:"series_supported,omitempty"`
}
// ListStudios returns the bundled studios with their curated logo URLs.
func (s *Service) ListStudios(_ context.Context, _ Viewer) ([]DiscoverBrandCard, error) {
if s == nil {
return nil, fmt.Errorf("request service is not configured")
}
out := make([]DiscoverBrandCard, 0, len(BundledStudios))
for _, studio := range BundledStudios {
out = append(out, DiscoverBrandCard{
TMDBID: studio.TMDBID,
Slug: studio.Slug,
DisplayName: studio.DisplayName,
LogoURL: duotoneLogoURL(studio.LogoPath),
})
}
return out, nil
}
// ListNetworks returns the bundled TV networks with their curated logo URLs.
func (s *Service) ListNetworks(_ context.Context, _ Viewer) ([]DiscoverBrandCard, error) {
if s == nil {
return nil, fmt.Errorf("request service is not configured")
}
out := make([]DiscoverBrandCard, 0, len(BundledNetworks))
for _, network := range BundledNetworks {
out = append(out, DiscoverBrandCard{
TMDBID: network.TMDBID,
Slug: network.Slug,
DisplayName: network.DisplayName,
LogoURL: duotoneLogoURL(network.LogoPath),
})
}
return out, nil
}
// ListGenres returns the bundled genres. Each card carries gradient hints
// (no logo URL) and a SeriesSupported flag for the browse page to decide
// whether to show the Series tab.
func (s *Service) ListGenres(_ context.Context, _ Viewer) ([]DiscoverBrandCard, error) {
if s == nil {
return nil, fmt.Errorf("request service is not configured")
}
out := make([]DiscoverBrandCard, 0, len(BundledGenres))
for _, genre := range BundledGenres {
out = append(out, DiscoverBrandCard{
Slug: genre.Slug,
DisplayName: genre.DisplayName,
GradientFrom: genre.GradientFrom,
GradientTo: genre.GradientTo,
SeriesSupported: genre.SeriesID > 0,
})
}
return out, nil
}
// duotoneLogoURL returns a TMDB CDN URL that recolors the logo into a
// white-on-light-gray duotone. Studio/network logos vary wildly in color
// and contrast; the duotone treatment keeps every card legible against a
// neutral background. Returns nil for empty paths.
func duotoneLogoURL(path string) *string {
if path == "" {
return nil
}
url := "https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)" + path
return &url
}
// DiscoverBrowseResponse is the shape returned by the browse endpoints.
// Results share the same MediaResult shape as search and the existing
// discovery sections, so the frontend can reuse RequestPosterCard.
type DiscoverBrowseResponse struct {
Kind string `json:"kind"`
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
LogoURL *string `json:"logo_url,omitempty"`
MediaType MediaType `json:"media_type"`
Sort string `json:"sort"`
Page int `json:"page"`
TotalPages int `json:"total_pages"`
Results []MediaResult `json:"results"`
}
var validBrowseSorts = map[string]string{
"popularity": "popularity.desc",
"vote_average": "vote_average.desc",
"release_date": "primary_release_date.desc",
}
const defaultBrowseSort = "popularity"
// BrowseStudio returns a page of movies from a bundled studio, enriched with
// Silo availability and request state.
func (s *Service) BrowseStudio(ctx context.Context, viewer Viewer, slug, sort string, page int) (*DiscoverBrowseResponse, error) {
if s == nil || s.tmdb == nil {
return nil, fmt.Errorf("request service is not configured")
}
studio, ok := FindStudioBySlug(strings.TrimSpace(slug))
if !ok {
return nil, ErrNotFound
}
tmdbSort, sortKey, err := normalizeBrowseSort(sort, "movie")
if err != nil {
return nil, err
}
tmdbPage, err := s.tmdb.DiscoverPage(ctx, "movie", tmdb.DiscoverParams{
SortBy: tmdbSort,
WithCompanies: []int{studio.TMDBID},
VoteCountGte: voteCountFloorForSort(sortKey),
}, page)
if err != nil {
return nil, err
}
enriched, err := s.enrichPage(ctx, viewer, tmdbPage)
if err != nil {
return nil, err
}
return &DiscoverBrowseResponse{
Kind: "studio",
Slug: studio.Slug,
DisplayName: studio.DisplayName,
LogoURL: duotoneLogoURL(studio.LogoPath),
MediaType: MediaTypeMovie,
Sort: sortKey,
Page: enriched.Page,
TotalPages: enriched.TotalPages,
Results: enriched.Results,
}, nil
}
// BrowseNetwork returns a page of series from a bundled TV network.
func (s *Service) BrowseNetwork(ctx context.Context, viewer Viewer, slug, sort string, page int) (*DiscoverBrowseResponse, error) {
if s == nil || s.tmdb == nil {
return nil, fmt.Errorf("request service is not configured")
}
network, ok := FindNetworkBySlug(strings.TrimSpace(slug))
if !ok {
return nil, ErrNotFound
}
tmdbSort, sortKey, err := normalizeBrowseSort(sort, "tv")
if err != nil {
return nil, err
}
tmdbPage, err := s.tmdb.DiscoverPage(ctx, "tv", tmdb.DiscoverParams{
SortBy: tmdbSort,
WithNetworks: []int{network.TMDBID},
VoteCountGte: voteCountFloorForSort(sortKey),
}, page)
if err != nil {
return nil, err
}
enriched, err := s.enrichPage(ctx, viewer, tmdbPage)
if err != nil {
return nil, err
}
return &DiscoverBrowseResponse{
Kind: "network",
Slug: network.Slug,
DisplayName: network.DisplayName,
LogoURL: duotoneLogoURL(network.LogoPath),
MediaType: MediaTypeSeries,
Sort: sortKey,
Page: enriched.Page,
TotalPages: enriched.TotalPages,
Results: enriched.Results,
}, nil
}
// BrowseGenre returns a page of movies or series from a bundled genre.
func (s *Service) BrowseGenre(ctx context.Context, viewer Viewer, slug string, rawMediaType MediaType, sort string, page int) (*DiscoverBrowseResponse, error) {
if s == nil || s.tmdb == nil {
return nil, fmt.Errorf("request service is not configured")
}
genre, ok := FindGenreBySlug(strings.TrimSpace(slug))
if !ok {
return nil, ErrNotFound
}
mediaType, err := normalizeMediaType(rawMediaType)
if err != nil {
return nil, fmt.Errorf("%w: media_type is required for genre browse", ErrInvalidInput)
}
var (
tmdbMediaType string
genreID int
)
switch mediaType {
case MediaTypeMovie:
tmdbMediaType = "movie"
genreID = genre.MovieID
case MediaTypeSeries:
tmdbMediaType = "tv"
genreID = genre.SeriesID
}
if genreID == 0 {
return nil, fmt.Errorf("%w: %s has no %s equivalent", ErrInvalidInput, slug, mediaType)
}
tmdbSort, sortKey, err := normalizeBrowseSort(sort, tmdbMediaType)
if err != nil {
return nil, err
}
tmdbPage, err := s.tmdb.DiscoverPage(ctx, tmdbMediaType, tmdb.DiscoverParams{
SortBy: tmdbSort,
WithGenres: []int{genreID},
VoteCountGte: voteCountFloorForSort(sortKey),
}, page)
if err != nil {
return nil, err
}
enriched, err := s.enrichPage(ctx, viewer, tmdbPage)
if err != nil {
return nil, err
}
return &DiscoverBrowseResponse{
Kind: "genre",
Slug: genre.Slug,
DisplayName: genre.DisplayName,
MediaType: mediaType,
Sort: sortKey,
Page: enriched.Page,
TotalPages: enriched.TotalPages,
Results: enriched.Results,
}, nil
}
func normalizeBrowseSort(sort, tmdbMediaType string) (string, string, error) {
sort = strings.TrimSpace(sort)
if sort == "" {
sort = defaultBrowseSort
}
tmdbSort, ok := validBrowseSorts[sort]
if !ok {
return "", "", fmt.Errorf("%w: unknown sort %q", ErrInvalidInput, sort)
}
if sort == "release_date" && tmdbMediaType == "tv" {
tmdbSort = "first_air_date.desc"
}
return tmdbSort, sort, nil
}
func voteCountFloorForSort(sortKey string) int {
if sortKey == "vote_average" {
return 100
}
return 0
}
+108
View File
@@ -0,0 +1,108 @@
package requests
// BundledStudio is a curated movie studio surfaced in the request discover
// section. LogoPath is a TMDB image file path that is rendered through the
// duotone filter for uniform white-on-gray presentation.
type BundledStudio struct {
TMDBID int
Slug string
DisplayName string
LogoPath string
}
// BundledNetwork is a curated TV network surfaced in the request discover
// section. LogoPath is a TMDB image file path rendered through the duotone
// filter.
type BundledNetwork struct {
TMDBID int
Slug string
DisplayName string
LogoPath string
}
// BundledGenre is a curated genre. MovieID is the TMDB movie genre ID;
// SeriesID is the TMDB tv genre ID, or 0 when no TV equivalent exists.
type BundledGenre struct {
Slug string
DisplayName string
GradientFrom string
GradientTo string
MovieID int
SeriesID int
}
// BundledStudios is the compile-time list of studios shown in the Studios
// carousel. TMDB IDs and logo paths follow the curated set used by Overseerr
// so logos resolve reliably through the duotone filter.
var BundledStudios = []BundledStudio{
{TMDBID: 2, Slug: "disney", DisplayName: "Disney", LogoPath: "/wdrCwmRnLFJhEoH8GSfymY85KHT.png"},
{TMDBID: 3, Slug: "pixar", DisplayName: "Pixar", LogoPath: "/1TjvGVDMYsj6JBxOAkUHpPEwLf7.png"},
{TMDBID: 420, Slug: "marvel-studios", DisplayName: "Marvel Studios", LogoPath: "/hUzeosd33nzE5MCNsZxCGEKTXaQ.png"},
{TMDBID: 174, Slug: "warner-bros-pictures", DisplayName: "Warner Bros. Pictures", LogoPath: "/ky0xOc5OrhzkZ1N6KyUxacfQsCk.png"},
{TMDBID: 33, Slug: "universal-pictures", DisplayName: "Universal Pictures", LogoPath: "/8lvHyhjr8oUKOOy2dKXoALWKdp0.png"},
{TMDBID: 4, Slug: "paramount-pictures", DisplayName: "Paramount Pictures", LogoPath: "/fycMZt242LVjagMByZOLUGbCvv3.png"},
{TMDBID: 34, Slug: "sony-pictures", DisplayName: "Sony Pictures", LogoPath: "/GagSvqWlyPdkFHMfQ3pNq6ix9P.png"},
{TMDBID: 127928, Slug: "20th-century-studios", DisplayName: "20th Century Studios", LogoPath: "/h0rjX5vjW5r8yEnUBStFarjcLT4.png"},
{TMDBID: 521, Slug: "dreamworks", DisplayName: "DreamWorks", LogoPath: "/kP7t6RwGz2AvvTkvnI1uteEwHet.png"},
{TMDBID: 41077, Slug: "a24", DisplayName: "A24", LogoPath: "/1ZXsGaFPgrgS6ZZGS37AqD5uU12.png"},
}
// BundledNetworks is the compile-time list of networks shown in the Networks
// carousel.
var BundledNetworks = []BundledNetwork{
{TMDBID: 213, Slug: "netflix", DisplayName: "Netflix", LogoPath: "/wwemzKWzjKYJFfCeiB57q3r4Bcm.png"},
{TMDBID: 2739, Slug: "disney-plus", DisplayName: "Disney+", LogoPath: "/gJ8VX6JSu3ciXHuC2dDGAo2lvwM.png"},
{TMDBID: 1024, Slug: "prime-video", DisplayName: "Prime Video", LogoPath: "/ifhbNuuVnlwYy5oXA5VIb2YR8AZ.png"},
{TMDBID: 2552, Slug: "apple-tv-plus", DisplayName: "Apple TV+", LogoPath: "/4KAy34EHvRM25Ih8wb82AuGU7zJ.png"},
{TMDBID: 453, Slug: "hulu", DisplayName: "Hulu", LogoPath: "/pqUTCleNUiTLAVlelGxUgWn1ELh.png"},
{TMDBID: 49, Slug: "hbo", DisplayName: "HBO", LogoPath: "/tuomPhY2UtuPTqqFnKMVHvSb724.png"},
{TMDBID: 4330, Slug: "paramount-plus", DisplayName: "Paramount+", LogoPath: "/fi83B1oztoS47xxcemFdPMhIzK.png"},
{TMDBID: 174, Slug: "amc", DisplayName: "AMC", LogoPath: "/pmvRmATOCaDykE6JrVoeYxlFHw3.png"},
{TMDBID: 4, Slug: "bbc-one", DisplayName: "BBC One", LogoPath: "/mVn7xESaTNmjBUyUtGNvDQd3CT1.png"},
{TMDBID: 3353, Slug: "peacock", DisplayName: "Peacock", LogoPath: "/gIAcGTjKKr0KOHL5s4O36roJ8p7.png"},
}
// BundledGenres is the compile-time list of genres shown in the Genres
// carousel. SeriesID = 0 means the genre has no direct TV equivalent and
// the browse page hides the Series tab.
var BundledGenres = []BundledGenre{
{Slug: "action", DisplayName: "Action", GradientFrom: "#dc2626", GradientTo: "#7f1d1d", MovieID: 28, SeriesID: 10759},
{Slug: "comedy", DisplayName: "Comedy", GradientFrom: "#fbbf24", GradientTo: "#b45309", MovieID: 35, SeriesID: 35},
{Slug: "drama", DisplayName: "Drama", GradientFrom: "#64748b", GradientTo: "#1e293b", MovieID: 18, SeriesID: 18},
{Slug: "sci-fi", DisplayName: "Sci-Fi", GradientFrom: "#7c3aed", GradientTo: "#312e81", MovieID: 878, SeriesID: 10765},
{Slug: "horror", DisplayName: "Horror", GradientFrom: "#7f1d1d", GradientTo: "#1f2937", MovieID: 27, SeriesID: 0},
{Slug: "romance", DisplayName: "Romance", GradientFrom: "#ec4899", GradientTo: "#831843", MovieID: 10749, SeriesID: 0},
{Slug: "animation", DisplayName: "Animation", GradientFrom: "#06b6d4", GradientTo: "#155e75", MovieID: 16, SeriesID: 16},
{Slug: "documentary", DisplayName: "Documentary", GradientFrom: "#475569", GradientTo: "#0f172a", MovieID: 99, SeriesID: 99},
}
// FindStudioBySlug looks up a bundled studio by slug. Returns (zero, false)
// if not found.
func FindStudioBySlug(slug string) (BundledStudio, bool) {
for _, s := range BundledStudios {
if s.Slug == slug {
return s, true
}
}
return BundledStudio{}, false
}
// FindNetworkBySlug looks up a bundled network by slug.
func FindNetworkBySlug(slug string) (BundledNetwork, bool) {
for _, n := range BundledNetworks {
if n.Slug == slug {
return n, true
}
}
return BundledNetwork{}, false
}
// FindGenreBySlug looks up a bundled genre by slug.
func FindGenreBySlug(slug string) (BundledGenre, bool) {
for _, g := range BundledGenres {
if g.Slug == slug {
return g, true
}
}
return BundledGenre{}, false
}
+145
View File
@@ -0,0 +1,145 @@
package requests
import (
"strings"
"testing"
)
func TestBundleHasExpectedCounts(t *testing.T) {
if len(BundledStudios) != 10 {
t.Errorf("BundledStudios = %d, want 10", len(BundledStudios))
}
if len(BundledNetworks) != 10 {
t.Errorf("BundledNetworks = %d, want 10", len(BundledNetworks))
}
if len(BundledGenres) != 8 {
t.Errorf("BundledGenres = %d, want 8", len(BundledGenres))
}
}
func TestBundleStudiosHaveRequiredFields(t *testing.T) {
for _, s := range BundledStudios {
if s.TMDBID <= 0 {
t.Errorf("studio %q missing TMDBID", s.Slug)
}
if strings.TrimSpace(s.Slug) == "" {
t.Errorf("studio %+v missing Slug", s)
}
if strings.TrimSpace(s.DisplayName) == "" {
t.Errorf("studio %q missing DisplayName", s.Slug)
}
if !strings.HasPrefix(s.LogoPath, "/") || !strings.HasSuffix(s.LogoPath, ".png") {
t.Errorf("studio %q LogoPath must be a TMDB file path (/...png), got %q", s.Slug, s.LogoPath)
}
}
}
func TestBundleNetworksHaveRequiredFields(t *testing.T) {
for _, n := range BundledNetworks {
if n.TMDBID <= 0 {
t.Errorf("network %q missing TMDBID", n.Slug)
}
if strings.TrimSpace(n.Slug) == "" {
t.Errorf("network %+v missing Slug", n)
}
if strings.TrimSpace(n.DisplayName) == "" {
t.Errorf("network %q missing DisplayName", n.Slug)
}
if !strings.HasPrefix(n.LogoPath, "/") || !strings.HasSuffix(n.LogoPath, ".png") {
t.Errorf("network %q LogoPath must be a TMDB file path (/...png), got %q", n.Slug, n.LogoPath)
}
}
}
func TestBundleGenresHaveRequiredFields(t *testing.T) {
for _, g := range BundledGenres {
if strings.TrimSpace(g.Slug) == "" {
t.Errorf("genre %+v missing Slug", g)
}
if strings.TrimSpace(g.DisplayName) == "" {
t.Errorf("genre %q missing DisplayName", g.Slug)
}
if g.MovieID <= 0 {
t.Errorf("genre %q must have MovieID > 0 in v1", g.Slug)
}
if !strings.HasPrefix(g.GradientFrom, "#") || !strings.HasPrefix(g.GradientTo, "#") {
t.Errorf("genre %q gradient must use # hex, got from=%q to=%q", g.Slug, g.GradientFrom, g.GradientTo)
}
}
}
func TestBundleSlugsAreUniqueWithinKind(t *testing.T) {
seen := map[string]string{}
for _, s := range BundledStudios {
key := "studio:" + s.Slug
if prior, ok := seen[key]; ok {
t.Errorf("duplicate studio slug %q (also %q)", s.Slug, prior)
}
seen[key] = s.DisplayName
}
for _, n := range BundledNetworks {
key := "network:" + n.Slug
if prior, ok := seen[key]; ok {
t.Errorf("duplicate network slug %q (also %q)", n.Slug, prior)
}
seen[key] = n.DisplayName
}
for _, g := range BundledGenres {
key := "genre:" + g.Slug
if prior, ok := seen[key]; ok {
t.Errorf("duplicate genre slug %q (also %q)", g.Slug, prior)
}
seen[key] = g.DisplayName
}
}
func TestFindStudioBySlug(t *testing.T) {
got, ok := FindStudioBySlug("marvel-studios")
if !ok {
t.Fatal("expected marvel-studios to exist")
}
if got.DisplayName != "Marvel Studios" {
t.Errorf("display = %q, want Marvel Studios", got.DisplayName)
}
if _, ok := FindStudioBySlug("not-a-real-studio"); ok {
t.Error("expected unknown slug to return false")
}
}
func TestFindNetworkBySlug(t *testing.T) {
got, ok := FindNetworkBySlug("netflix")
if !ok {
t.Fatal("expected netflix to exist")
}
if got.DisplayName != "Netflix" {
t.Errorf("display = %q, want Netflix", got.DisplayName)
}
}
func TestFindGenreBySlug(t *testing.T) {
got, ok := FindGenreBySlug("action")
if !ok {
t.Fatal("expected action to exist")
}
if got.MovieID != 28 {
t.Errorf("movie id = %d, want 28", got.MovieID)
}
}
func TestGenresWithoutTVEquivalentHaveZeroSeriesID(t *testing.T) {
horror, ok := FindGenreBySlug("horror")
if !ok {
t.Fatal("expected horror to exist")
}
if horror.SeriesID != 0 {
t.Errorf("horror.SeriesID = %d, want 0", horror.SeriesID)
}
romance, ok := FindGenreBySlug("romance")
if !ok {
t.Fatal("expected romance to exist")
}
if romance.SeriesID != 0 {
t.Errorf("romance.SeriesID = %d, want 0", romance.SeriesID)
}
}
+30
View File
@@ -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
}
+121
View File
@@ -0,0 +1,121 @@
package requests
import (
"context"
"log/slog"
"strconv"
"strings"
"github.com/Silo-Server/silo-server/internal/catalog"
)
type PresenceCandidate struct {
TMDBID int
TVDBID *int
IMDbID string
}
type PresenceMatch struct {
Available bool
ContentID string
MatchedProvider string
}
type PresenceResolver interface {
Lookup(ctx context.Context, mediaType MediaType, candidates []PresenceCandidate) (map[int]PresenceMatch, error)
}
type presenceItemLookup interface {
LookupExternalIDs(ctx context.Context, mediaType string, candidates []catalog.ExternalIDLookupCandidate) ([]catalog.ExternalIDMatchRow, error)
}
type tmdbBackfiller interface {
AttachTMDBID(ctx context.Context, contentID, itemType string, tmdbID int) error
}
type CatalogPresence struct {
items presenceItemLookup
tmdbBackfill tmdbBackfiller
}
func NewCatalogPresence(items *catalog.ItemRepository, providerIDs ...*catalog.ProviderIDRepository) *CatalogPresence {
var itemLookup presenceItemLookup
if items != nil {
itemLookup = items
}
var backfill tmdbBackfiller
if len(providerIDs) > 0 && providerIDs[0] != nil {
backfill = providerIDs[0]
}
return &CatalogPresence{items: itemLookup, tmdbBackfill: backfill}
}
func (p *CatalogPresence) Lookup(ctx context.Context, mediaType MediaType, candidates []PresenceCandidate) (map[int]PresenceMatch, error) {
out := map[int]PresenceMatch{}
if p == nil || p.items == nil || len(candidates) == 0 {
return out, nil
}
lookupCandidates := make([]catalog.ExternalIDLookupCandidate, 0, len(candidates))
for _, candidate := range candidates {
if candidate.TMDBID <= 0 {
continue
}
row := catalog.ExternalIDLookupCandidate{
TMDBID: strconv.Itoa(candidate.TMDBID),
IMDbID: strings.TrimSpace(candidate.IMDbID),
}
if candidate.TVDBID != nil && *candidate.TVDBID > 0 {
row.TVDBID = strconv.Itoa(*candidate.TVDBID)
}
lookupCandidates = append(lookupCandidates, row)
}
if len(lookupCandidates) == 0 {
return out, nil
}
rows, err := p.items.LookupExternalIDs(ctx, string(mediaType), lookupCandidates)
if err != nil {
return nil, err
}
for _, row := range rows {
id, err := strconv.Atoi(row.QueryTMDBID)
if err != nil || id <= 0 {
continue
}
out[id] = PresenceMatch{
Available: true,
ContentID: row.MediaID,
MatchedProvider: row.MatchedProvider,
}
if row.MatchedProvider != "tmdb" && p.tmdbBackfill != nil {
if err := p.tmdbBackfill.AttachTMDBID(ctx, row.MediaID, string(mediaType), id); err != nil {
slog.Warn("requests: failed to backfill tmdb id from presence lookup",
"content_id", row.MediaID,
"media_type", mediaType,
"tmdb_id", id,
"matched_provider", row.MatchedProvider,
"error", err)
}
}
}
return out, nil
}
func (p *CatalogPresence) LookupTMDB(ctx context.Context, mediaType MediaType, tmdbIDs []int) (map[int]bool, error) {
candidates := make([]PresenceCandidate, 0, len(tmdbIDs))
for _, id := range tmdbIDs {
if id > 0 {
candidates = append(candidates, PresenceCandidate{TMDBID: id})
}
}
matches, err := p.Lookup(ctx, mediaType, candidates)
if err != nil {
return nil, err
}
out := map[int]bool{}
for id, match := range matches {
out[id] = match.Available
}
return out, nil
}
+94
View File
@@ -0,0 +1,94 @@
package requests
import (
"context"
"testing"
"github.com/Silo-Server/silo-server/internal/catalog"
)
type fakePresenceLookup struct {
rows []catalog.ExternalIDMatchRow
got []catalog.ExternalIDLookupCandidate
}
func (f *fakePresenceLookup) LookupExternalIDs(_ context.Context, _ string, candidates []catalog.ExternalIDLookupCandidate) ([]catalog.ExternalIDMatchRow, error) {
f.got = append([]catalog.ExternalIDLookupCandidate(nil), candidates...)
return f.rows, nil
}
type fakeTMDBBackfiller struct {
contentID string
itemType string
tmdbID int
}
func (f *fakeTMDBBackfiller) AttachTMDBID(_ context.Context, contentID, itemType string, tmdbID int) error {
f.contentID = contentID
f.itemType = itemType
f.tmdbID = tmdbID
return nil
}
func TestCatalogPresenceMatchesByTVDBAndBackfillsTMDB(t *testing.T) {
tvdbID := 420105
lookup := &fakePresenceLookup{rows: []catalog.ExternalIDMatchRow{{
QueryTMDBID: "201992",
MediaID: "120983767174086659",
MatchedProvider: "tvdb",
LibraryID: "2",
Title: "The Rookie: Feds",
}}}
backfill := &fakeTMDBBackfiller{}
presence := &CatalogPresence{items: lookup, tmdbBackfill: backfill}
result, err := presence.Lookup(context.Background(), MediaTypeSeries, []PresenceCandidate{{
TMDBID: 201992,
TVDBID: &tvdbID,
IMDbID: "tt18076310",
}})
if err != nil {
t.Fatalf("Lookup returned error: %v", err)
}
if !result[201992].Available {
t.Fatalf("available = false, want true")
}
if result[201992].MatchedProvider != "tvdb" {
t.Fatalf("matched provider = %q, want tvdb", result[201992].MatchedProvider)
}
if backfill.contentID != "120983767174086659" || backfill.itemType != "series" || backfill.tmdbID != 201992 {
t.Fatalf("backfill = %+v, want content 120983767174086659 series tmdb 201992", backfill)
}
}
func TestCatalogPresenceKeepsLookupTMDBCompatibility(t *testing.T) {
lookup := &fakePresenceLookup{rows: []catalog.ExternalIDMatchRow{{
QueryTMDBID: "550",
MediaID: "movie-1",
MatchedProvider: "tmdb",
LibraryID: "1",
Title: "Fight Club",
}}}
presence := &CatalogPresence{items: lookup}
result, err := presence.LookupTMDB(context.Background(), MediaTypeMovie, []int{550})
if err != nil {
t.Fatalf("LookupTMDB returned error: %v", err)
}
if !result[550] {
t.Fatalf("result[550] = false, want true")
}
if len(lookup.got) != 1 || lookup.got[0].TMDBID != "550" {
t.Fatalf("lookup candidates = %+v, want tmdb candidate", lookup.got)
}
}
func TestNewCatalogPresenceIgnoresNilRepositories(t *testing.T) {
presence := NewCatalogPresence(nil, nil)
if presence.items != nil {
t.Fatalf("items = %#v, want nil", presence.items)
}
if presence.tmdbBackfill != nil {
t.Fatalf("tmdbBackfill = %#v, want nil", presence.tmdbBackfill)
}
}
+176
View File
@@ -0,0 +1,176 @@
package radarr
import (
"context"
"fmt"
"net/http"
"net/url"
"strconv"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
"github.com/Silo-Server/silo-server/internal/requests/arrclient"
)
type Client struct {
httpClient *http.Client
}
type movieResource struct {
ID int `json:"id,omitempty"`
Title string `json:"title,omitempty"`
TMDBID int `json:"tmdbId,omitempty"`
Year int `json:"year,omitempty"`
TitleSlug string `json:"titleSlug,omitempty"`
QualityProfileID int `json:"qualityProfileId,omitempty"`
RootFolderPath string `json:"rootFolderPath,omitempty"`
Monitored bool `json:"monitored"`
MinimumAvailability string `json:"minimumAvailability,omitempty"`
Tags []int `json:"tags,omitempty"`
AddOptions addMovieOptions `json:"addOptions,omitempty"`
}
type addMovieOptions struct {
SearchForMovie bool `json:"searchForMovie"`
Monitor string `json:"monitor,omitempty"`
}
func NewClient(httpClient *http.Client) *Client {
return &Client{httpClient: httpClient}
}
func (c *Client) ListMovieIntegrationOptions(ctx context.Context, integration mediarequests.Integration) (*mediarequests.IntegrationOptions, error) {
client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient)
rootFolders, err := arrclient.ListRootFolders(ctx, client)
if err != nil {
return nil, err
}
qualityProfiles, err := arrclient.ListQualityProfiles(ctx, client)
if err != nil {
return nil, err
}
tags, err := arrclient.ListTags(ctx, client)
if err != nil {
return nil, err
}
return &mediarequests.IntegrationOptions{
Kind: "radarr",
RootFolders: rootFolders,
QualityProfiles: qualityProfiles,
Tags: tags,
}, nil
}
func (c *Client) SubmitMovie(ctx context.Context, req mediarequests.Request, integration mediarequests.Integration) (mediarequests.FulfillmentResult, error) {
if req.MediaType != mediarequests.MediaTypeMovie {
return mediarequests.FulfillmentResult{}, fmt.Errorf("radarr: request is not a movie")
}
if integration.QualityProfileID == nil {
return mediarequests.FulfillmentResult{}, fmt.Errorf("radarr: quality profile is required")
}
client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient)
movie, err := c.lookupMovie(ctx, client, req.TMDBID)
if err != nil {
return mediarequests.FulfillmentResult{}, err
}
movie.RootFolderPath = integration.RootFolder
movie.QualityProfileID = *integration.QualityProfileID
movie.Monitored = arrclient.BoolOption(integration.Options, "monitored", true)
movie.MinimumAvailability = arrclient.StringOption(integration.Options, "minimum_availability", "released")
movie.Tags = integration.Tags
movie.AddOptions = addMovieOptions{
SearchForMovie: arrclient.BoolOption(
integration.Options,
"search_for_movie",
arrclient.BoolOption(integration.Options, "search_on_add", true),
),
Monitor: arrclient.StringOption(integration.Options, "monitor", "movieOnly"),
}
var created movieResource
if err := client.PostJSON(ctx, "/api/v3/movie", movie, &created); err != nil {
if !arrclient.IsEmptyOrTruncatedDecodeError(err) {
return mediarequests.FulfillmentResult{}, err
}
// POST accepted but Radarr returned an empty body. Recover the
// new movie's Radarr ID by listing movies filtered by TMDB ID;
// without the ID the reconcile loop cannot advance the request.
if found, lookErr := c.findMovieByTMDBID(ctx, client, req.TMDBID); lookErr == nil && found.ID > 0 {
return resultFromMovie(found), nil
}
return arrclient.AcceptedWithoutResponse("radarr"), nil
}
return resultFromMovie(created), nil
}
func (c *Client) findMovieByTMDBID(ctx context.Context, client *arrclient.Client, tmdbID int) (movieResource, error) {
values := url.Values{}
values.Set("tmdbId", strconv.Itoa(tmdbID))
var matches []movieResource
if err := client.GetJSON(ctx, "/api/v3/movie?"+values.Encode(), &matches); err != nil {
return movieResource{}, err
}
for _, m := range matches {
if m.ID > 0 {
return m, nil
}
}
return movieResource{}, fmt.Errorf("radarr: movie not found after add for tmdb_id %d", tmdbID)
}
func (c *Client) CheckMovieStatus(ctx context.Context, req mediarequests.Request, integration mediarequests.Integration) (mediarequests.FulfillmentStatus, error) {
client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient)
movieID, _ := strconv.Atoi(req.ExternalID)
if movieID <= 0 {
return mediarequests.FulfillmentStatus{
Status: mediarequests.StatusQueued,
IntegrationKind: "radarr",
ExternalStatus: "external_id_unavailable",
}, nil
}
queues, err := c.queueDetails(ctx, client, movieID)
if err != nil {
return mediarequests.FulfillmentStatus{}, err
}
evaluation := arrclient.EvaluateQueue(queues)
return arrclient.StatusFromQueueEvaluation("radarr", movieID, evaluation), nil
}
func (c *Client) lookupMovie(ctx context.Context, client *arrclient.Client, tmdbID int) (movieResource, error) {
values := url.Values{}
values.Set("tmdbId", strconv.Itoa(tmdbID))
// Radarr's /api/v3/movie/lookup/tmdb returns a single MovieResource, unlike
// /api/v3/movie/lookup which returns an array. Missing IDs return non-2xx
// (handled as HTTPError upstream), so a successful response is the movie.
var movie movieResource
if err := client.GetJSON(ctx, "/api/v3/movie/lookup/tmdb?"+values.Encode(), &movie); err != nil {
return movieResource{}, err
}
if movie.TMDBID == 0 {
movie.TMDBID = tmdbID
}
return movie, nil
}
func (c *Client) queueDetails(ctx context.Context, client *arrclient.Client, movieID int) ([]arrclient.QueueResource, error) {
values := url.Values{}
values.Set("movieId", strconv.Itoa(movieID))
var queues []arrclient.QueueResource
if err := client.GetJSON(ctx, "/api/v3/queue/details?"+values.Encode(), &queues); err != nil {
return nil, err
}
return queues, nil
}
func resultFromMovie(movie movieResource) mediarequests.FulfillmentResult {
externalID := ""
if movie.ID > 0 {
externalID = strconv.Itoa(movie.ID)
}
return mediarequests.FulfillmentResult{
IntegrationKind: "radarr",
ExternalID: externalID,
ExternalStatus: "queued",
}
}
+236
View File
@@ -0,0 +1,236 @@
package radarr
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
)
func TestSubmitMovieAddsLookupResult(t *testing.T) {
qualityProfileID := 7
var posted movieResource
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Api-Key"); got != "radarr-key" {
t.Fatalf("X-Api-Key = %q, want radarr-key", got)
}
switch r.URL.Path {
case "/api/v3/movie":
if r.Method == http.MethodGet {
if got := r.URL.Query().Get("tmdbId"); got != "550" {
t.Fatalf("tmdbId = %q, want 550", got)
}
w.Write([]byte(`[]`))
return
}
if r.Method == http.MethodPost {
if err := json.NewDecoder(r.Body).Decode(&posted); err != nil {
t.Fatalf("decode posted movie: %v", err)
}
w.Write([]byte(`{"id":42,"tmdbId":550}`))
return
}
case "/api/v3/movie/lookup/tmdb":
if got := r.URL.Query().Get("tmdbId"); got != "550" {
t.Fatalf("lookup tmdbId = %q, want 550", got)
}
w.Write([]byte(`{"title":"Fight Club","tmdbId":550,"titleSlug":"fight-club"}`))
return
}
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}))
defer server.Close()
client := NewClient(server.Client())
result, err := client.SubmitMovie(context.Background(), mediarequests.Request{
MediaType: mediarequests.MediaTypeMovie,
TMDBID: 550,
Title: "Fight Club",
}, mediarequests.Integration{
Kind: "radarr",
BaseURL: server.URL,
APIKeyRef: "radarr-key",
RootFolder: "/movies",
QualityProfileID: &qualityProfileID,
Options: map[string]any{
"search_on_add": false,
},
})
if err != nil {
t.Fatalf("SubmitMovie returned error: %v", err)
}
if result.ExternalID != "42" || result.IntegrationKind != "radarr" {
t.Fatalf("result = %+v, want radarr external id 42", result)
}
if posted.RootFolderPath != "/movies" || posted.QualityProfileID != qualityProfileID {
t.Fatalf("posted movie = %+v, missing root folder/quality profile", posted)
}
if posted.AddOptions.SearchForMovie {
t.Fatalf("searchForMovie = true, want false")
}
}
func TestSubmitMovieRecoversFromEmptyAddResponse(t *testing.T) {
qualityProfileID := 7
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v3/movie/lookup/tmdb":
w.Write([]byte(`{"title":"Fight Club","tmdbId":550,"titleSlug":"fight-club"}`))
case "/api/v3/movie":
if r.Method == http.MethodPost {
w.WriteHeader(http.StatusCreated)
return
}
if r.Method == http.MethodGet {
if got := r.URL.Query().Get("tmdbId"); got != "550" {
t.Fatalf("tmdbId = %q, want 550", got)
}
w.Write([]byte(`[{"id":99,"tmdbId":550,"title":"Fight Club"}]`))
return
}
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
}))
defer server.Close()
client := NewClient(server.Client())
result, err := client.SubmitMovie(context.Background(), mediarequests.Request{
MediaType: mediarequests.MediaTypeMovie,
TMDBID: 550,
Title: "Fight Club",
}, mediarequests.Integration{
Kind: "radarr",
BaseURL: server.URL,
APIKeyRef: "radarr-key",
RootFolder: "/movies",
QualityProfileID: &qualityProfileID,
})
if err != nil {
t.Fatalf("SubmitMovie returned error: %v", err)
}
if result.ExternalID != "99" {
t.Fatalf("ExternalID = %q, want 99 (recovered after empty 201)", result.ExternalID)
}
if result.ExternalStatus != "queued" {
t.Fatalf("ExternalStatus = %q, want queued", result.ExternalStatus)
}
}
func TestSubmitMovieFallsBackWhenEmptyResponseAndLookupFails(t *testing.T) {
qualityProfileID := 7
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v3/movie/lookup/tmdb":
w.Write([]byte(`{"title":"Fight Club","tmdbId":550,"titleSlug":"fight-club"}`))
case "/api/v3/movie":
if r.Method == http.MethodPost {
w.WriteHeader(http.StatusCreated)
return
}
if r.Method == http.MethodGet {
w.Write([]byte(`[]`))
return
}
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
}))
defer server.Close()
client := NewClient(server.Client())
result, err := client.SubmitMovie(context.Background(), mediarequests.Request{
MediaType: mediarequests.MediaTypeMovie,
TMDBID: 550,
Title: "Fight Club",
}, mediarequests.Integration{
Kind: "radarr",
BaseURL: server.URL,
APIKeyRef: "radarr-key",
RootFolder: "/movies",
QualityProfileID: &qualityProfileID,
})
if err != nil {
t.Fatalf("SubmitMovie returned error: %v", err)
}
if result.ExternalStatus != "accepted_without_response" {
t.Fatalf("ExternalStatus = %q, want accepted_without_response", result.ExternalStatus)
}
}
func TestCheckMovieStatusReadsQueueDetails(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Api-Key"); got != "radarr-key" {
t.Fatalf("X-Api-Key = %q, want radarr-key", got)
}
if r.URL.Path != "/api/v3/queue/details" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
if got := r.URL.Query().Get("movieId"); got != "42" {
t.Fatalf("movieId = %q, want 42", got)
}
w.Write([]byte(`[{"movieId":42,"status":"downloading","trackedDownloadState":"downloading"}]`))
}))
defer server.Close()
client := NewClient(server.Client())
status, err := client.CheckMovieStatus(context.Background(), mediarequests.Request{
MediaType: mediarequests.MediaTypeMovie,
TMDBID: 550,
ExternalID: "42",
}, mediarequests.Integration{
Kind: "radarr",
BaseURL: server.URL,
APIKeyRef: "radarr-key",
})
if err != nil {
t.Fatalf("CheckMovieStatus returned error: %v", err)
}
if status.Status != mediarequests.StatusDownloading || status.ExternalStatus != "downloading/downloading" {
t.Fatalf("status = %+v, want downloading", status)
}
}
func TestListMovieIntegrationOptionsLoadsChoices(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Api-Key"); got != "radarr-key" {
t.Fatalf("X-Api-Key = %q, want radarr-key", got)
}
switch r.URL.Path {
case "/api/v3/rootfolder":
w.Write([]byte(`[{"path":"/movies","freeSpace":123,"totalSpace":456,"accessible":true}]`))
case "/api/v3/qualityprofile":
w.Write([]byte(`[{"id":7,"name":"HD-1080p"}]`))
case "/api/v3/tag":
w.Write([]byte(`[{"id":2,"label":"requests"}]`))
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer server.Close()
client := NewClient(server.Client())
options, err := client.ListMovieIntegrationOptions(context.Background(), mediarequests.Integration{
Kind: "radarr",
BaseURL: server.URL,
APIKeyRef: "radarr-key",
})
if err != nil {
t.Fatalf("ListMovieIntegrationOptions returned error: %v", err)
}
if options.Kind != "radarr" {
t.Fatalf("kind = %q, want radarr", options.Kind)
}
if len(options.RootFolders) != 1 || options.RootFolders[0].Path != "/movies" {
t.Fatalf("root folders = %+v, want /movies", options.RootFolders)
}
if len(options.QualityProfiles) != 1 || options.QualityProfiles[0].ID != 7 {
t.Fatalf("quality profiles = %+v, want id 7", options.QualityProfiles)
}
if len(options.Tags) != 1 || options.Tags[0].ID != 2 {
t.Fatalf("tags = %+v, want id 2", options.Tags)
}
}
+755
View File
@@ -0,0 +1,755 @@
package requests
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
type Repository struct {
pool *pgxpool.Pool
}
func NewRepository(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool}
}
func (r *Repository) GetSettings(ctx context.Context) (Settings, error) {
var s Settings
err := r.pool.QueryRow(ctx, `
SELECT requests_enabled, global_max_requests, global_window_days,
global_auto_approval_enabled, updated_at
FROM request_settings
WHERE id = true
`).Scan(&s.RequestsEnabled, &s.GlobalMaxRequests, &s.GlobalWindowDays, &s.GlobalAutoApprovalEnabled, &s.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Settings{
RequestsEnabled: false,
GlobalMaxRequests: 5,
GlobalWindowDays: 7,
GlobalAutoApprovalEnabled: false,
}, nil
}
return Settings{}, fmt.Errorf("get request settings: %w", err)
}
return s, nil
}
func (r *Repository) UpdateSettings(ctx context.Context, settings Settings) (Settings, error) {
if settings.GlobalWindowDays <= 0 {
settings.GlobalWindowDays = 7
}
if settings.GlobalMaxRequests < 0 {
settings.GlobalMaxRequests = 0
}
var s Settings
err := r.pool.QueryRow(ctx, `
INSERT INTO request_settings (
id, requests_enabled, global_max_requests, global_window_days,
global_auto_approval_enabled, updated_at
)
VALUES (true, $1, $2, $3, $4, now())
ON CONFLICT (id) DO UPDATE SET
requests_enabled = EXCLUDED.requests_enabled,
global_max_requests = EXCLUDED.global_max_requests,
global_window_days = EXCLUDED.global_window_days,
global_auto_approval_enabled = EXCLUDED.global_auto_approval_enabled,
updated_at = now()
RETURNING requests_enabled, global_max_requests, global_window_days,
global_auto_approval_enabled, updated_at
`, settings.RequestsEnabled, settings.GlobalMaxRequests, settings.GlobalWindowDays, settings.GlobalAutoApprovalEnabled).
Scan(&s.RequestsEnabled, &s.GlobalMaxRequests, &s.GlobalWindowDays, &s.GlobalAutoApprovalEnabled, &s.UpdatedAt)
if err != nil {
return Settings{}, fmt.Errorf("update request settings: %w", err)
}
return s, nil
}
func (r *Repository) GetUserLimit(ctx context.Context, userID int) (*UserLimit, error) {
var row UserLimit
var max, window sql.NullInt64
err := r.pool.QueryRow(ctx, `
SELECT user_id, limit_mode, max_requests, window_days, approval_mode, updated_at
FROM request_user_limits
WHERE user_id = $1
`, userID).Scan(&row.UserID, &row.LimitMode, &max, &window, &row.ApprovalMode, &row.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("get request user limit: %w", err)
}
if max.Valid {
v := int(max.Int64)
row.MaxRequests = &v
}
if window.Valid {
v := int(window.Int64)
row.WindowDays = &v
}
return &row, nil
}
func (r *Repository) UpsertUserLimit(ctx context.Context, limit UserLimit) (*UserLimit, error) {
var max, window any
if limit.MaxRequests != nil {
max = *limit.MaxRequests
}
if limit.WindowDays != nil {
window = *limit.WindowDays
}
var row UserLimit
var scannedMax, scannedWindow sql.NullInt64
err := r.pool.QueryRow(ctx, `
INSERT INTO request_user_limits (
user_id, limit_mode, max_requests, window_days, approval_mode, updated_at
)
VALUES ($1, $2, $3, $4, $5, now())
ON CONFLICT (user_id) DO UPDATE SET
limit_mode = EXCLUDED.limit_mode,
max_requests = EXCLUDED.max_requests,
window_days = EXCLUDED.window_days,
approval_mode = EXCLUDED.approval_mode,
updated_at = now()
RETURNING user_id, limit_mode, max_requests, window_days, approval_mode, updated_at
`, limit.UserID, limit.LimitMode, max, window, limit.ApprovalMode).
Scan(&row.UserID, &row.LimitMode, &scannedMax, &scannedWindow, &row.ApprovalMode, &row.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("upsert request user limit: %w", err)
}
if scannedMax.Valid {
v := int(scannedMax.Int64)
row.MaxRequests = &v
}
if scannedWindow.Valid {
v := int(scannedWindow.Int64)
row.WindowDays = &v
}
return &row, nil
}
func (r *Repository) CountUserRequestsSince(ctx context.Context, userID int, since time.Time) (int, error) {
var count int
if err := r.pool.QueryRow(ctx, `
SELECT COUNT(*)
FROM media_requests
WHERE requested_by_user_id = $1
AND created_at >= $2
`, userID, since).Scan(&count); err != nil {
return 0, fmt.Errorf("count user requests: %w", err)
}
return count, nil
}
func (r *Repository) ListActiveByTMDB(ctx context.Context, mediaType MediaType, tmdbIDs []int) (map[int]*Request, error) {
if len(tmdbIDs) == 0 {
return map[int]*Request{}, nil
}
rows, err := r.pool.Query(ctx, requestSelectSQL()+`
WHERE media_type = $1
AND provider = 'tmdb'
AND tmdb_id = ANY($2)
AND outcome = 'active'
AND status <> 'completed'
`, mediaType, tmdbIDs)
if err != nil {
return nil, fmt.Errorf("list active requests by tmdb: %w", err)
}
defer rows.Close()
out := map[int]*Request{}
for rows.Next() {
req, err := scanRequest(rows)
if err != nil {
return nil, err
}
out[req.TMDBID] = req
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate active requests by tmdb: %w", err)
}
return out, nil
}
func (r *Repository) DeleteFailedByTMDB(ctx context.Context, mediaType MediaType, tmdbID int) (int, error) {
if tmdbID <= 0 {
return 0, nil
}
tag, err := r.pool.Exec(ctx, `
DELETE FROM media_requests
WHERE media_type = $1
AND provider = 'tmdb'
AND tmdb_id = $2
AND outcome = 'failed'
`, mediaType, tmdbID)
if err != nil {
return 0, fmt.Errorf("delete failed requests by tmdb: %w", err)
}
return int(tag.RowsAffected()), nil
}
// quotaLockNamespace partitions advisory locks so request-quota locks do not
// collide with advisory locks held elsewhere in the database. The value is
// arbitrary; what matters is that it is stable.
const quotaLockNamespace = 139
func (r *Repository) CreateRequest(ctx context.Context, input CreateRequestRecord) (*Request, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin create request transaction: %w", err)
}
defer tx.Rollback(ctx)
if input.Quota != nil {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1::int4, $2::int4)`,
quotaLockNamespace, input.Quota.UserID); err != nil {
return nil, fmt.Errorf("acquire request quota lock: %w", err)
}
var count int
if err := tx.QueryRow(ctx, `
SELECT COUNT(*)
FROM media_requests
WHERE requested_by_user_id = $1
AND created_at >= $2
`, input.Quota.UserID, input.Quota.WindowStart).Scan(&count); err != nil {
return nil, fmt.Errorf("count requests for quota: %w", err)
}
if count >= input.Quota.MaxRequests {
return nil, ErrQuotaExceeded
}
}
now := input.Now
if now.IsZero() {
now = time.Now().UTC()
}
status := input.Status
if status == "" {
status = StatusPending
}
outcome := input.Outcome
if outcome == "" {
outcome = OutcomeActive
}
var approvedAt any
if status != StatusPending {
approvedAt = now
}
req, err := r.insertRequest(ctx, tx, input, status, outcome, now, approvedAt)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return nil, ErrAlreadyRequested
}
return nil, err
}
if err := r.recordEvent(ctx, tx, req.ID, "created", input.Requester, ""); err != nil {
return nil, err
}
if status == StatusApproved {
if err := r.recordEvent(ctx, tx, req.ID, "approved", input.Requester, "auto approved"); err != nil {
return nil, err
}
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit create request transaction: %w", err)
}
return req, nil
}
type requestExecutor interface {
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
}
func (r *Repository) insertRequest(
ctx context.Context,
exec requestExecutor,
input CreateRequestRecord,
status Status,
outcome Outcome,
now time.Time,
approvedAt any,
) (*Request, error) {
var tvdbID any
if input.Input.TVDBID != nil {
tvdbID = *input.Input.TVDBID
}
var year any
if input.Input.Year != nil {
year = *input.Input.Year
}
row := exec.QueryRow(ctx, `
INSERT INTO media_requests (
id, provider, media_type, tmdb_id, tvdb_id, imdb_id, title, year,
overview, poster_path, backdrop_path, status, outcome,
requested_by_user_id, requested_by_profile_id, created_at, updated_at, approved_at
)
VALUES (
$1, 'tmdb', $2, $3, $4, $5, $6, $7,
$8, $9, $10, $11, $12,
$13, $14, $15, $15, $16
)
RETURNING `+requestColumns(), input.ID, input.Input.MediaType, input.Input.TMDBID, tvdbID,
strings.TrimSpace(input.Input.IMDbID), strings.TrimSpace(input.Input.Title), year,
strings.TrimSpace(input.Input.Overview), strings.TrimSpace(input.Input.PosterPath),
strings.TrimSpace(input.Input.BackdropPath), status, outcome,
input.Requester.UserID, input.Requester.ProfileID, now, approvedAt)
req, err := scanRequest(row)
if err != nil {
return nil, fmt.Errorf("insert request: %w", err)
}
return req, nil
}
func (r *Repository) GetRequest(ctx context.Context, id string) (*Request, error) {
req, err := scanRequest(r.pool.QueryRow(ctx, requestSelectSQL()+`
WHERE id = $1
`, id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return req, nil
}
func (r *Repository) ListReconciliationCandidates(ctx context.Context, limit int) ([]*Request, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := r.pool.Query(ctx, requestSelectSQL()+`
WHERE outcome = 'active'
AND status IN ('approved', 'queued', 'downloading')
ORDER BY updated_at ASC
LIMIT $1
`, limit)
if err != nil {
return nil, fmt.Errorf("list request reconciliation candidates: %w", err)
}
defer rows.Close()
var out []*Request
for rows.Next() {
req, err := scanRequest(rows)
if err != nil {
return nil, err
}
out = append(out, req)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate request reconciliation candidates: %w", err)
}
return out, nil
}
func (r *Repository) ListMine(ctx context.Context, userID int, filter ListFilter) ([]*Request, error) {
sqlText, args := buildRequestListSQL("requested_by_user_id = $1", []any{userID}, filter)
return r.listRequests(ctx, sqlText, args)
}
func (r *Repository) ListAdmin(ctx context.Context, filter ListFilter) ([]*Request, error) {
sqlText, args := buildRequestListSQL("true", nil, filter)
return r.listRequests(ctx, sqlText, args)
}
func (r *Repository) listRequests(ctx context.Context, sqlText string, args []any) ([]*Request, error) {
rows, err := r.pool.Query(ctx, sqlText, args...)
if err != nil {
return nil, fmt.Errorf("list requests: %w", err)
}
defer rows.Close()
var out []*Request
for rows.Next() {
req, err := scanRequest(rows)
if err != nil {
return nil, err
}
out = append(out, req)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate requests: %w", err)
}
return out, nil
}
func (r *Repository) SetStatus(ctx context.Context, id string, status Status, actor Viewer) (*Request, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin request status transaction: %w", err)
}
defer tx.Rollback(ctx)
req, err := scanRequest(tx.QueryRow(ctx, `
UPDATE media_requests
SET status = $2,
updated_at = now(),
approved_at = CASE WHEN $2 = 'approved' AND approved_at IS NULL THEN now() ELSE approved_at END,
completed_at = CASE WHEN $2 = 'completed' AND completed_at IS NULL THEN now() ELSE completed_at END
WHERE id = $1
RETURNING `+requestColumns(), id, status))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("set request status: %w", err)
}
if err := r.recordEvent(ctx, tx, id, "status_"+string(status), actor, ""); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit request status transaction: %w", err)
}
return req, nil
}
func (r *Repository) MarkQueued(ctx context.Context, id string, update QueueUpdate, actor Viewer) (*Request, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin request queue transaction: %w", err)
}
defer tx.Rollback(ctx)
externalStatus := strings.TrimSpace(update.ExternalStatus)
if externalStatus == "" {
externalStatus = "queued"
}
req, err := scanRequest(tx.QueryRow(ctx, `
UPDATE media_requests
SET status = 'queued',
outcome = 'active',
integration_kind = $2,
external_id = $3,
external_status = $4,
last_error = '',
updated_at = now(),
approved_at = CASE WHEN approved_at IS NULL THEN now() ELSE approved_at END
WHERE id = $1
RETURNING `+requestColumns(), id,
strings.TrimSpace(update.IntegrationKind),
strings.TrimSpace(update.ExternalID),
externalStatus,
))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("mark request queued: %w", err)
}
if err := r.recordEvent(ctx, tx, id, "status_queued", actor, externalStatus); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit request queue transaction: %w", err)
}
return req, nil
}
func (r *Repository) SetOutcome(ctx context.Context, id string, outcome Outcome, actor Viewer, message string) (*Request, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin request outcome transaction: %w", err)
}
defer tx.Rollback(ctx)
req, err := scanRequest(tx.QueryRow(ctx, `
UPDATE media_requests
SET outcome = $2,
last_error = CASE
WHEN $2 = 'failed' THEN $3
WHEN $2 = 'active' THEN ''
ELSE last_error
END,
updated_at = now()
WHERE id = $1
RETURNING `+requestColumns(), id, outcome, strings.TrimSpace(message)))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("set request outcome: %w", err)
}
if err := r.recordEvent(ctx, tx, id, "outcome_"+string(outcome), actor, message); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit request outcome transaction: %w", err)
}
return req, nil
}
func (r *Repository) ListIntegrations(ctx context.Context) ([]Integration, error) {
rows, err := r.pool.Query(ctx, `
SELECT kind, enabled, base_url, api_key_ref, root_folder, quality_profile_id,
tags, options, last_check_at, last_check_status, last_check_error, updated_at
FROM request_integrations
ORDER BY kind
`)
if err != nil {
return nil, fmt.Errorf("list request integrations: %w", err)
}
defer rows.Close()
var out []Integration
for rows.Next() {
integration, err := scanIntegration(rows)
if err != nil {
return nil, err
}
out = append(out, integration)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate request integrations: %w", err)
}
return out, nil
}
func (r *Repository) UpsertIntegration(ctx context.Context, integration Integration) (*Integration, error) {
out, err := r.upsertIntegration(ctx, r.pool, integration)
if err != nil {
return nil, fmt.Errorf("upsert request integration: %w", err)
}
return out, nil
}
func (r *Repository) UpsertIntegrations(ctx context.Context, integrations []Integration) ([]Integration, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin request integrations transaction: %w", err)
}
defer tx.Rollback(ctx)
out := make([]Integration, 0, len(integrations))
for _, integration := range integrations {
updated, err := r.upsertIntegration(ctx, tx, integration)
if err != nil {
return nil, err
}
out = append(out, *updated)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit request integrations transaction: %w", err)
}
return out, nil
}
func (r *Repository) upsertIntegration(ctx context.Context, exec requestExecutor, integration Integration) (*Integration, error) {
if integration.Options == nil {
integration.Options = map[string]any{}
}
options, err := json.Marshal(integration.Options)
if err != nil {
return nil, fmt.Errorf("marshal request integration options: %w", err)
}
tags := int32Slice(integration.Tags)
row := exec.QueryRow(ctx, `
INSERT INTO request_integrations (
kind, enabled, base_url, api_key_ref, root_folder, quality_profile_id,
tags, options, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
ON CONFLICT (kind) DO UPDATE SET
enabled = EXCLUDED.enabled,
base_url = EXCLUDED.base_url,
api_key_ref = CASE
WHEN EXCLUDED.api_key_ref = '' THEN request_integrations.api_key_ref
ELSE EXCLUDED.api_key_ref
END,
root_folder = EXCLUDED.root_folder,
quality_profile_id = EXCLUDED.quality_profile_id,
tags = EXCLUDED.tags,
options = EXCLUDED.options,
updated_at = now()
RETURNING kind, enabled, base_url, api_key_ref, root_folder, quality_profile_id,
tags, options, last_check_at, last_check_status, last_check_error, updated_at
`, integration.Kind, integration.Enabled, strings.TrimSpace(integration.BaseURL),
strings.TrimSpace(integration.APIKeyRef), strings.TrimSpace(integration.RootFolder),
integration.QualityProfileID, tags, options)
out, err := scanIntegration(row)
if err != nil {
return nil, err
}
return &out, nil
}
func (r *Repository) recordEvent(ctx context.Context, exec requestExecutor, requestID, eventType string, actor Viewer, message string) error {
var actorUserID any
if actor.UserID > 0 {
actorUserID = actor.UserID
}
_, err := exec.Exec(ctx, `
INSERT INTO media_request_events (
request_id, event_type, actor_user_id, actor_profile_id, message
)
VALUES ($1, $2, $3, $4, $5)
`, requestID, eventType, actorUserID, actor.ProfileID, strings.TrimSpace(message))
if err != nil {
return fmt.Errorf("record request event: %w", err)
}
return nil
}
func buildRequestListSQL(baseCondition string, baseArgs []any, filter ListFilter) (string, []any) {
args := append([]any(nil), baseArgs...)
conditions := []string{baseCondition}
if filter.Status != "" {
args = append(args, filter.Status)
conditions = append(conditions, "status = $"+strconv.Itoa(len(args)))
}
if filter.Outcome != "" {
args = append(args, filter.Outcome)
conditions = append(conditions, "outcome = $"+strconv.Itoa(len(args)))
}
limit := filter.Limit
if limit <= 0 || limit > 100 {
limit = 50
}
offset := filter.Offset
if offset < 0 {
offset = 0
}
args = append(args, limit, offset)
return requestSelectSQL() + `
WHERE ` + strings.Join(conditions, " AND ") + `
ORDER BY created_at DESC
LIMIT $` + strconv.Itoa(len(args)-1) + ` OFFSET $` + strconv.Itoa(len(args)), args
}
func requestSelectSQL() string {
return "SELECT " + requestColumns() + " FROM media_requests "
}
func requestColumns() string {
return `id, provider, media_type, tmdb_id, tvdb_id, imdb_id, title, year,
overview, poster_path, backdrop_path, status, outcome,
requested_by_user_id, requested_by_profile_id, integration_kind,
external_id, external_status, last_error, created_at, updated_at,
approved_at, completed_at`
}
type requestScanner interface {
Scan(dest ...any) error
}
func scanRequest(row requestScanner) (*Request, error) {
var req Request
var tvdbID, year sql.NullInt64
var approvedAt, completedAt sql.NullTime
if err := row.Scan(
&req.ID,
&req.Provider,
&req.MediaType,
&req.TMDBID,
&tvdbID,
&req.IMDbID,
&req.Title,
&year,
&req.Overview,
&req.PosterPath,
&req.BackdropPath,
&req.Status,
&req.Outcome,
&req.RequestedByUserID,
&req.RequestedByProfileID,
&req.IntegrationKind,
&req.ExternalID,
&req.ExternalStatus,
&req.LastError,
&req.CreatedAt,
&req.UpdatedAt,
&approvedAt,
&completedAt,
); err != nil {
return nil, err
}
if tvdbID.Valid {
v := int(tvdbID.Int64)
req.TVDBID = &v
}
if year.Valid {
v := int(year.Int64)
req.Year = &v
}
if approvedAt.Valid {
req.ApprovedAt = &approvedAt.Time
}
if completedAt.Valid {
req.CompletedAt = &completedAt.Time
}
return &req, nil
}
type integrationScanner interface {
Scan(dest ...any) error
}
func scanIntegration(row integrationScanner) (Integration, error) {
var integration Integration
var quality sql.NullInt64
var tags []int32
var optionsRaw []byte
var lastCheckAt sql.NullTime
if err := row.Scan(
&integration.Kind,
&integration.Enabled,
&integration.BaseURL,
&integration.APIKeyRef,
&integration.RootFolder,
&quality,
&tags,
&optionsRaw,
&lastCheckAt,
&integration.LastCheckStatus,
&integration.LastCheckError,
&integration.UpdatedAt,
); err != nil {
return Integration{}, err
}
if quality.Valid {
v := int(quality.Int64)
integration.QualityProfileID = &v
}
integration.Tags = intsFromInt32(tags)
if len(optionsRaw) > 0 {
if err := json.Unmarshal(optionsRaw, &integration.Options); err != nil {
return Integration{}, fmt.Errorf("unmarshal request integration options for %s: %w", integration.Kind, err)
}
}
if integration.Options == nil {
integration.Options = map[string]any{}
}
if lastCheckAt.Valid {
integration.LastCheckAt = &lastCheckAt.Time
}
return integration, nil
}
func int32Slice(values []int) []int32 {
out := make([]int32, 0, len(values))
for _, value := range values {
out = append(out, int32(value))
}
return out
}
func intsFromInt32(values []int32) []int {
out := make([]int, 0, len(values))
for _, value := range values {
out = append(out, int(value))
}
return out
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+183
View File
@@ -0,0 +1,183 @@
package sonarr
import (
"context"
"fmt"
"net/http"
"net/url"
"strconv"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
"github.com/Silo-Server/silo-server/internal/requests/arrclient"
)
type Client struct {
httpClient *http.Client
}
type seriesResource struct {
ID int `json:"id,omitempty"`
Title string `json:"title,omitempty"`
TVDBID int `json:"tvdbId,omitempty"`
TMDBID int `json:"tmdbId,omitempty"`
TitleSlug string `json:"titleSlug,omitempty"`
QualityProfileID int `json:"qualityProfileId,omitempty"`
RootFolderPath string `json:"rootFolderPath,omitempty"`
SeasonFolder bool `json:"seasonFolder"`
Monitored bool `json:"monitored"`
SeriesType string `json:"seriesType,omitempty"`
Tags []int `json:"tags,omitempty"`
AddOptions addSeriesOptions `json:"addOptions,omitempty"`
}
type addSeriesOptions struct {
Monitor string `json:"monitor,omitempty"`
SearchForMissingEpisodes bool `json:"searchForMissingEpisodes"`
SearchForCutoffUnmetEpisodes bool `json:"searchForCutoffUnmetEpisodes,omitempty"`
}
func NewClient(httpClient *http.Client) *Client {
return &Client{httpClient: httpClient}
}
func (c *Client) ListSeriesIntegrationOptions(ctx context.Context, integration mediarequests.Integration) (*mediarequests.IntegrationOptions, error) {
client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient)
rootFolders, err := arrclient.ListRootFolders(ctx, client)
if err != nil {
return nil, err
}
qualityProfiles, err := arrclient.ListQualityProfiles(ctx, client)
if err != nil {
return nil, err
}
tags, err := arrclient.ListTags(ctx, client)
if err != nil {
return nil, err
}
return &mediarequests.IntegrationOptions{
Kind: "sonarr",
RootFolders: rootFolders,
QualityProfiles: qualityProfiles,
Tags: tags,
}, nil
}
func (c *Client) SubmitSeries(ctx context.Context, req mediarequests.Request, integration mediarequests.Integration) (mediarequests.FulfillmentResult, error) {
if req.MediaType != mediarequests.MediaTypeSeries {
return mediarequests.FulfillmentResult{}, fmt.Errorf("sonarr: request is not a series")
}
if integration.QualityProfileID == nil {
return mediarequests.FulfillmentResult{}, fmt.Errorf("sonarr: quality profile is required")
}
if req.TVDBID == nil || *req.TVDBID <= 0 {
return mediarequests.FulfillmentResult{}, fmt.Errorf("sonarr: tvdb_id is required")
}
client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient)
series, err := c.lookupSeries(ctx, client, *req.TVDBID)
if err != nil {
return mediarequests.FulfillmentResult{}, err
}
series.RootFolderPath = integration.RootFolder
series.QualityProfileID = *integration.QualityProfileID
series.SeasonFolder = arrclient.BoolOption(integration.Options, "season_folder", true)
series.Monitored = arrclient.BoolOption(integration.Options, "monitored", true)
series.SeriesType = arrclient.StringOption(integration.Options, "series_type", "standard")
series.Tags = integration.Tags
series.AddOptions = addSeriesOptions{
Monitor: arrclient.StringOption(integration.Options, "monitor", "all"),
SearchForMissingEpisodes: arrclient.BoolOption(
integration.Options,
"search_for_missing_episodes",
arrclient.BoolOption(integration.Options, "search_on_add", true),
),
SearchForCutoffUnmetEpisodes: arrclient.BoolOption(integration.Options, "search_for_cutoff_unmet", false),
}
var created seriesResource
if err := client.PostJSON(ctx, "/api/v3/series", series, &created); err != nil {
if !arrclient.IsEmptyOrTruncatedDecodeError(err) {
return mediarequests.FulfillmentResult{}, err
}
// POST accepted but Sonarr returned an empty body. Recover the
// new series' Sonarr ID by listing series filtered by TVDB ID;
// without the ID the reconcile loop cannot advance the request.
if found, lookErr := c.findSeriesByTVDBID(ctx, client, *req.TVDBID); lookErr == nil && found.ID > 0 {
return resultFromSeries(found), nil
}
return arrclient.AcceptedWithoutResponse("sonarr"), nil
}
return resultFromSeries(created), nil
}
func (c *Client) findSeriesByTVDBID(ctx context.Context, client *arrclient.Client, tvdbID int) (seriesResource, error) {
values := url.Values{}
values.Set("tvdbId", strconv.Itoa(tvdbID))
var matches []seriesResource
if err := client.GetJSON(ctx, "/api/v3/series?"+values.Encode(), &matches); err != nil {
return seriesResource{}, err
}
for _, s := range matches {
if s.ID > 0 && s.TVDBID == tvdbID {
return s, nil
}
}
return seriesResource{}, fmt.Errorf("sonarr: series not found after add for tvdb_id %d", tvdbID)
}
func (c *Client) CheckSeriesStatus(ctx context.Context, req mediarequests.Request, integration mediarequests.Integration) (mediarequests.FulfillmentStatus, error) {
client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient)
seriesID, _ := strconv.Atoi(req.ExternalID)
if seriesID <= 0 {
return mediarequests.FulfillmentStatus{
Status: mediarequests.StatusQueued,
IntegrationKind: "sonarr",
ExternalStatus: "external_id_unavailable",
}, nil
}
queues, err := c.queueDetails(ctx, client, seriesID)
if err != nil {
return mediarequests.FulfillmentStatus{}, err
}
evaluation := arrclient.EvaluateQueue(queues)
return arrclient.StatusFromQueueEvaluation("sonarr", seriesID, evaluation), nil
}
func (c *Client) lookupSeries(ctx context.Context, client *arrclient.Client, tvdbID int) (seriesResource, error) {
values := url.Values{}
values.Set("term", "tvdb:"+strconv.Itoa(tvdbID))
var matches []seriesResource
if err := client.GetJSON(ctx, "/api/v3/series/lookup?"+values.Encode(), &matches); err != nil {
return seriesResource{}, err
}
for _, match := range matches {
if match.TVDBID == tvdbID {
return match, nil
}
}
return seriesResource{}, fmt.Errorf("sonarr: no series found for tvdb_id %d", tvdbID)
}
func (c *Client) queueDetails(ctx context.Context, client *arrclient.Client, seriesID int) ([]arrclient.QueueResource, error) {
values := url.Values{}
values.Set("seriesId", strconv.Itoa(seriesID))
var queues []arrclient.QueueResource
if err := client.GetJSON(ctx, "/api/v3/queue/details?"+values.Encode(), &queues); err != nil {
return nil, err
}
return queues, nil
}
func resultFromSeries(series seriesResource) mediarequests.FulfillmentResult {
externalID := ""
if series.ID > 0 {
externalID = strconv.Itoa(series.ID)
}
return mediarequests.FulfillmentResult{
IntegrationKind: "sonarr",
ExternalID: externalID,
ExternalStatus: "queued",
}
}
+228
View File
@@ -0,0 +1,228 @@
package sonarr
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
)
func TestSubmitSeriesAddsLookupResult(t *testing.T) {
qualityProfileID := 3
tvdbID := 121361
var posted seriesResource
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Api-Key"); got != "sonarr-key" {
t.Fatalf("X-Api-Key = %q, want sonarr-key", got)
}
switch r.URL.Path {
case "/api/v3/series":
if r.Method == http.MethodGet {
if got := r.URL.Query().Get("tvdbId"); got != "121361" {
t.Fatalf("tvdbId = %q, want 121361", got)
}
w.Write([]byte(`[]`))
return
}
if r.Method == http.MethodPost {
if err := json.NewDecoder(r.Body).Decode(&posted); err != nil {
t.Fatalf("decode posted series: %v", err)
}
w.Write([]byte(`{"id":24,"tvdbId":121361}`))
return
}
case "/api/v3/series/lookup":
if got := r.URL.Query().Get("term"); got != "tvdb:121361" {
t.Fatalf("lookup term = %q, want tvdb:121361", got)
}
w.Write([]byte(`[{"title":"Game of Thrones","tvdbId":121361,"titleSlug":"game-of-thrones"}]`))
return
}
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}))
defer server.Close()
client := NewClient(server.Client())
result, err := client.SubmitSeries(context.Background(), mediarequests.Request{
MediaType: mediarequests.MediaTypeSeries,
TMDBID: 1399,
TVDBID: &tvdbID,
Title: "Game of Thrones",
}, mediarequests.Integration{
Kind: "sonarr",
BaseURL: server.URL,
APIKeyRef: "sonarr-key",
RootFolder: "/series",
QualityProfileID: &qualityProfileID,
Options: map[string]any{
"monitor": "future",
},
})
if err != nil {
t.Fatalf("SubmitSeries returned error: %v", err)
}
if result.ExternalID != "24" || result.IntegrationKind != "sonarr" {
t.Fatalf("result = %+v, want sonarr external id 24", result)
}
if posted.RootFolderPath != "/series" || posted.QualityProfileID != qualityProfileID {
t.Fatalf("posted series = %+v, missing root folder/quality profile", posted)
}
if posted.AddOptions.Monitor != "future" {
t.Fatalf("monitor = %q, want future", posted.AddOptions.Monitor)
}
}
func TestSubmitSeriesRecoversFromEmptyAddResponse(t *testing.T) {
qualityProfileID := 3
tvdbID := 121361
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v3/series/lookup":
w.Write([]byte(`[{"title":"Game of Thrones","tvdbId":121361,"titleSlug":"game-of-thrones"}]`))
case "/api/v3/series":
if r.Method == http.MethodPost {
w.WriteHeader(http.StatusCreated)
return
}
if r.Method == http.MethodGet {
if got := r.URL.Query().Get("tvdbId"); got != "121361" {
t.Fatalf("tvdbId = %q, want 121361", got)
}
w.Write([]byte(`[{"id":77,"tvdbId":121361,"title":"Game of Thrones"}]`))
return
}
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
}))
defer server.Close()
client := NewClient(server.Client())
result, err := client.SubmitSeries(context.Background(), mediarequests.Request{
MediaType: mediarequests.MediaTypeSeries,
TMDBID: 1399,
TVDBID: &tvdbID,
Title: "Game of Thrones",
}, mediarequests.Integration{
Kind: "sonarr",
BaseURL: server.URL,
APIKeyRef: "sonarr-key",
RootFolder: "/series",
QualityProfileID: &qualityProfileID,
})
if err != nil {
t.Fatalf("SubmitSeries returned error: %v", err)
}
if result.ExternalID != "77" {
t.Fatalf("ExternalID = %q, want 77 (recovered after empty 201)", result.ExternalID)
}
if result.ExternalStatus != "queued" {
t.Fatalf("ExternalStatus = %q, want queued", result.ExternalStatus)
}
}
func TestSubmitSeriesRejectsNonExactTVDBLookupMatch(t *testing.T) {
qualityProfileID := 3
tvdbID := 121361
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v3/series/lookup" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
w.Write([]byte(`[{"title":"Wrong Show","tvdbId":999,"titleSlug":"wrong-show"}]`))
}))
defer server.Close()
client := NewClient(server.Client())
_, err := client.SubmitSeries(context.Background(), mediarequests.Request{
MediaType: mediarequests.MediaTypeSeries,
TMDBID: 1399,
TVDBID: &tvdbID,
Title: "Game of Thrones",
}, mediarequests.Integration{
Kind: "sonarr",
BaseURL: server.URL,
APIKeyRef: "sonarr-key",
RootFolder: "/series",
QualityProfileID: &qualityProfileID,
})
if err == nil {
t.Fatal("expected error for non-exact TVDB lookup match")
}
}
func TestCheckSeriesStatusReadsQueueDetails(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Api-Key"); got != "sonarr-key" {
t.Fatalf("X-Api-Key = %q, want sonarr-key", got)
}
if r.URL.Path != "/api/v3/queue/details" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
if got := r.URL.Query().Get("seriesId"); got != "24" {
t.Fatalf("seriesId = %q, want 24", got)
}
w.Write([]byte(`[{"seriesId":24,"status":"downloading","trackedDownloadState":"importing"}]`))
}))
defer server.Close()
client := NewClient(server.Client())
status, err := client.CheckSeriesStatus(context.Background(), mediarequests.Request{
MediaType: mediarequests.MediaTypeSeries,
TMDBID: 1399,
ExternalID: "24",
}, mediarequests.Integration{
Kind: "sonarr",
BaseURL: server.URL,
APIKeyRef: "sonarr-key",
})
if err != nil {
t.Fatalf("CheckSeriesStatus returned error: %v", err)
}
if status.Status != mediarequests.StatusDownloading || status.ExternalStatus != "downloading/importing" {
t.Fatalf("status = %+v, want downloading", status)
}
}
func TestListSeriesIntegrationOptionsLoadsChoices(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Api-Key"); got != "sonarr-key" {
t.Fatalf("X-Api-Key = %q, want sonarr-key", got)
}
switch r.URL.Path {
case "/api/v3/rootfolder":
w.Write([]byte(`[{"path":"/series","freeSpace":123,"totalSpace":456,"accessible":true}]`))
case "/api/v3/qualityprofile":
w.Write([]byte(`[{"id":3,"name":"HD-1080p"}]`))
case "/api/v3/tag":
w.Write([]byte(`[{"id":4,"label":"requests"}]`))
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer server.Close()
client := NewClient(server.Client())
options, err := client.ListSeriesIntegrationOptions(context.Background(), mediarequests.Integration{
Kind: "sonarr",
BaseURL: server.URL,
APIKeyRef: "sonarr-key",
})
if err != nil {
t.Fatalf("ListSeriesIntegrationOptions returned error: %v", err)
}
if options.Kind != "sonarr" {
t.Fatalf("kind = %q, want sonarr", options.Kind)
}
if len(options.RootFolders) != 1 || options.RootFolders[0].Path != "/series" {
t.Fatalf("root folders = %+v, want /series", options.RootFolders)
}
if len(options.QualityProfiles) != 1 || options.QualityProfiles[0].ID != 3 {
t.Fatalf("quality profiles = %+v, want id 3", options.QualityProfiles)
}
if len(options.Tags) != 1 || options.Tags[0].ID != 4 {
t.Fatalf("tags = %+v, want id 4", options.Tags)
}
}
+49
View File
@@ -0,0 +1,49 @@
package requests
import (
"context"
"time"
)
type Store interface {
GetSettings(ctx context.Context) (Settings, error)
UpdateSettings(ctx context.Context, settings Settings) (Settings, error)
GetUserLimit(ctx context.Context, userID int) (*UserLimit, error)
UpsertUserLimit(ctx context.Context, limit UserLimit) (*UserLimit, error)
CountUserRequestsSince(ctx context.Context, userID int, since time.Time) (int, error)
ListActiveByTMDB(ctx context.Context, mediaType MediaType, tmdbIDs []int) (map[int]*Request, error)
// DeleteFailedByTMDB removes prior failed requests for a given media so a
// re-request does not leave behind stale rows in user/admin lists.
DeleteFailedByTMDB(ctx context.Context, mediaType MediaType, tmdbID int) (int, error)
CreateRequest(ctx context.Context, input CreateRequestRecord) (*Request, error)
GetRequest(ctx context.Context, id string) (*Request, error)
ListReconciliationCandidates(ctx context.Context, limit int) ([]*Request, error)
ListMine(ctx context.Context, userID int, filter ListFilter) ([]*Request, error)
ListAdmin(ctx context.Context, filter ListFilter) ([]*Request, error)
SetStatus(ctx context.Context, id string, status Status, actor Viewer) (*Request, error)
MarkQueued(ctx context.Context, id string, update QueueUpdate, actor Viewer) (*Request, error)
SetOutcome(ctx context.Context, id string, outcome Outcome, actor Viewer, message string) (*Request, error)
ListIntegrations(ctx context.Context) ([]Integration, error)
UpsertIntegration(ctx context.Context, integration Integration) (*Integration, error)
UpsertIntegrations(ctx context.Context, integrations []Integration) ([]Integration, error)
}
type CreateRequestRecord struct {
ID string
Input CreateRequestInput
Status Status
Outcome Outcome
Requester Viewer
Now time.Time
// Quota, when non-nil, instructs the store to atomically verify the
// requester is below their per-user limit before inserting. The check
// runs inside the same transaction as the insert with a per-user
// advisory lock so concurrent submissions cannot both exceed the limit.
Quota *QuotaCheck
}
type QuotaCheck struct {
UserID int
WindowStart time.Time
MaxRequests int
}
+287
View File
@@ -0,0 +1,287 @@
package requests
import (
"time"
)
type MediaType string
const (
MediaTypeMovie MediaType = "movie"
MediaTypeSeries MediaType = "series"
MediaTypeAll MediaType = "all"
)
type Status string
const (
StatusPending Status = "pending"
StatusApproved Status = "approved"
StatusQueued Status = "queued"
StatusDownloading Status = "downloading"
StatusCompleted Status = "completed"
)
type Outcome string
const (
OutcomeActive Outcome = "active"
OutcomeDeclined Outcome = "declined"
OutcomeCancelled Outcome = "cancelled"
OutcomeFailed Outcome = "failed"
)
type Availability string
const (
AvailabilityMissing Availability = "missing"
AvailabilityAvailable Availability = "available"
)
type LimitMode string
const (
LimitModeInherit LimitMode = "inherit"
LimitModeCustom LimitMode = "custom"
LimitModeUnlimited LimitMode = "unlimited"
LimitModeBlocked LimitMode = "blocked"
)
type ApprovalMode string
const (
ApprovalModeInherit ApprovalMode = "inherit"
ApprovalModeManual ApprovalMode = "manual"
ApprovalModeAuto ApprovalMode = "auto"
ApprovalModeBlocked ApprovalMode = "blocked"
)
type Viewer struct {
UserID int
ProfileID string
IsAdmin bool
}
type Settings struct {
RequestsEnabled bool `json:"requests_enabled"`
GlobalMaxRequests int `json:"global_max_requests"`
GlobalWindowDays int `json:"global_window_days"`
GlobalAutoApprovalEnabled bool `json:"global_auto_approval_enabled"`
UpdatedAt time.Time `json:"updated_at"`
}
type UserLimit struct {
UserID int `json:"user_id"`
LimitMode LimitMode `json:"limit_mode"`
MaxRequests *int `json:"max_requests,omitempty"`
WindowDays *int `json:"window_days,omitempty"`
ApprovalMode ApprovalMode `json:"approval_mode"`
UpdatedAt time.Time `json:"updated_at"`
}
type EffectivePolicy struct {
RequestsEnabled bool
MaxRequests int
WindowDays int
Unlimited bool
Blocked bool
AutoApprove bool
Used int
Remaining int
WindowStart time.Time
}
type Request struct {
ID string `json:"id"`
Provider string `json:"provider"`
MediaType MediaType `json:"media_type"`
TMDBID int `json:"tmdb_id"`
TVDBID *int `json:"tvdb_id,omitempty"`
IMDbID string `json:"imdb_id,omitempty"`
Title string `json:"title"`
Year *int `json:"year,omitempty"`
Overview string `json:"overview,omitempty"`
PosterPath string `json:"poster_path,omitempty"`
BackdropPath string `json:"backdrop_path,omitempty"`
Status Status `json:"status"`
Outcome Outcome `json:"outcome"`
RequestedByUserID int `json:"requested_by_user_id,omitempty"`
RequestedByProfileID string `json:"requested_by_profile_id,omitempty"`
IntegrationKind string `json:"integration_kind,omitempty"`
ExternalID string `json:"external_id,omitempty"`
ExternalStatus string `json:"external_status,omitempty"`
LastError string `json:"last_error,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ApprovedAt *time.Time `json:"approved_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
}
type RequestEvent struct {
ID int64 `json:"id"`
RequestID string `json:"request_id"`
EventType string `json:"event_type"`
ActorUserID *int `json:"actor_user_id,omitempty"`
ActorProfileID string `json:"actor_profile_id,omitempty"`
Message string `json:"message,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type RequestState struct {
Status Status `json:"status,omitempty"`
Requestable bool `json:"requestable"`
Reason string `json:"reason,omitempty"`
RequestID string `json:"request_id,omitempty"`
}
type MediaResult struct {
MediaType MediaType `json:"media_type"`
TMDBID int `json:"tmdb_id"`
Title string `json:"title"`
Year int `json:"year,omitempty"`
Overview string `json:"overview,omitempty"`
PosterPath string `json:"poster_path,omitempty"`
BackdropPath string `json:"backdrop_path,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
Popularity float64 `json:"popularity,omitempty"`
VoteAverage float64 `json:"vote_average,omitempty"`
Availability Availability `json:"availability"`
Request RequestState `json:"request"`
}
type MediaPage struct {
Page int `json:"page"`
TotalPages int `json:"total_pages"`
TotalResults int `json:"total_results"`
Results []MediaResult `json:"results"`
}
type MediaCastMember struct {
Name string `json:"name"`
Character string `json:"character,omitempty"`
ProfilePath string `json:"profile_path,omitempty"`
Order int `json:"order"`
}
type MediaDetail struct {
MediaType MediaType `json:"media_type"`
TMDBID int `json:"tmdb_id"`
IMDbID string `json:"imdb_id,omitempty"`
TVDBID *int `json:"tvdb_id,omitempty"`
Title string `json:"title"`
OriginalTitle string `json:"original_title,omitempty"`
Tagline string `json:"tagline,omitempty"`
Overview string `json:"overview,omitempty"`
PosterPath string `json:"poster_path,omitempty"`
BackdropPath string `json:"backdrop_path,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
Year int `json:"year,omitempty"`
Runtime int `json:"runtime,omitempty"`
Genres []string `json:"genres,omitempty"`
VoteAverage float64 `json:"vote_average,omitempty"`
VoteCount int `json:"vote_count,omitempty"`
Status string `json:"status,omitempty"`
Homepage string `json:"homepage,omitempty"`
ContentRating string `json:"content_rating,omitempty"`
ProductionCompanies []string `json:"production_companies,omitempty"`
NumberOfSeasons int `json:"number_of_seasons,omitempty"`
NumberOfEpisodes int `json:"number_of_episodes,omitempty"`
FirstAirDate string `json:"first_air_date,omitempty"`
LastAirDate string `json:"last_air_date,omitempty"`
Networks []string `json:"networks,omitempty"`
Cast []MediaCastMember `json:"cast,omitempty"`
Director string `json:"director,omitempty"`
Creators []string `json:"creators,omitempty"`
Recommendations []MediaResult `json:"recommendations,omitempty"`
Availability Availability `json:"availability"`
Request RequestState `json:"request"`
}
type CreateRequestInput struct {
MediaType MediaType `json:"media_type"`
TMDBID int `json:"tmdb_id"`
TVDBID *int `json:"tvdb_id,omitempty"`
IMDbID string `json:"imdb_id,omitempty"`
Title string `json:"title"`
Year *int `json:"year,omitempty"`
Overview string `json:"overview,omitempty"`
PosterPath string `json:"poster_path,omitempty"`
BackdropPath string `json:"backdrop_path,omitempty"`
}
type ListFilter struct {
Status Status
Outcome Outcome
Limit int
Offset int
}
type Integration struct {
Kind string `json:"kind"`
Enabled bool `json:"enabled"`
BaseURL string `json:"base_url"`
APIKeyRef string `json:"api_key_ref,omitempty"`
RootFolder string `json:"root_folder"`
QualityProfileID *int `json:"quality_profile_id,omitempty"`
Tags []int `json:"tags"`
Options map[string]any `json:"options"`
LastCheckAt *time.Time `json:"last_check_at,omitempty"`
LastCheckStatus string `json:"last_check_status,omitempty"`
LastCheckError string `json:"last_check_error,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type IntegrationRootFolder struct {
Path string `json:"path"`
FreeSpace int64 `json:"free_space,omitempty"`
TotalSpace int64 `json:"total_space,omitempty"`
Accessible bool `json:"accessible"`
}
type IntegrationQualityProfile struct {
ID int `json:"id"`
Name string `json:"name"`
}
type IntegrationTag struct {
ID int `json:"id"`
Label string `json:"label"`
}
type IntegrationOptions struct {
Kind string `json:"kind"`
RootFolders []IntegrationRootFolder `json:"root_folders"`
QualityProfiles []IntegrationQualityProfile `json:"quality_profiles"`
Tags []IntegrationTag `json:"tags"`
}
type QueueUpdate struct {
IntegrationKind string
ExternalID string
ExternalStatus string
}
type FulfillmentResult struct {
IntegrationKind string
ExternalID string
ExternalStatus string
}
type FulfillmentStatus struct {
Status Status
Outcome Outcome
IntegrationKind string
ExternalID string
ExternalStatus string
Message string
}
type ReconcileResult struct {
Checked int `json:"checked"`
Submitted int `json:"submitted"`
Downloading int `json:"downloading"`
Completed int `json:"completed"`
Failed int `json:"failed"`
Skipped int `json:"skipped"`
Errors int `json:"errors"`
}
@@ -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
}
+10
View File
@@ -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;
+114
View File
@@ -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);
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
printf 'usage: %s [--cached]\n' "${0##*/}" >&2
}
cached=0
case "${1:-}" in
"")
;;
--cached)
cached=1
;;
-h|--help)
usage
exit 0
;;
*)
usage
exit 2
;;
esac
repo_root=$(git rev-parse --show-toplevel)
cd "$repo_root"
failed=0
t3_worktree_dir='\.t3'/'worktrees'
t3_worktree_id_prefix='t3''code-'
check_pattern() {
local label=$1
local pattern=$2
shift 2
local matches
if [[ "$cached" -eq 1 ]]; then
matches=$(git grep --cached -n -I -E "$pattern" -- "$@" 2>/dev/null) || return 0
else
matches=$(git grep -n -I -E "$pattern" -- "$@" 2>/dev/null) || return 0
fi
if [[ -n "$matches" ]]; then
printf '%s\n' "local path leak check failed: $label" >&2
printf '%s\n\n' "$matches" >&2
failed=1
fi
}
check_pattern \
"T3 worktree path or generated worktree id" \
"(${t3_worktree_dir}|/Users/[^[:space:]]*/${t3_worktree_dir}/|${t3_worktree_id_prefix}[0-9a-f]{8})" \
docs/superpowers/specs docs/superpowers/plans
check_pattern \
"absolute local filesystem path in generated superpowers docs" \
'(/Users/[^[:space:]]+|/home/[^[:space:]]+|/Volumes/[^[:space:]]+|/var/folders/[^[:space:]]+|/private/tmp/[^[:space:]]+|[A-Za-z]:\\Users\\[^[:space:]]+)' \
docs/superpowers/specs docs/superpowers/plans
if [[ "$failed" -ne 0 ]]; then
printf '%s\n' "Remove local machine paths from committed content. Use repository-relative paths instead." >&2
exit 1
fi
+20
View File
@@ -32,10 +32,14 @@ import ItemDetail from "@/pages/ItemDetail/index";
import PersonDetail from "@/pages/PersonDetail";
import Collections from "@/pages/Collections";
import CollectionEditor from "@/pages/CollectionEditor";
import Requests from "@/pages/Requests";
import RequestBrowse from "@/pages/RequestBrowse";
import RequestDetail from "@/pages/RequestDetail";
import AdminDashboard from "@/pages/AdminDashboard";
import AdminActivity from "@/pages/AdminActivity";
import AdminLogs from "@/pages/AdminLogs";
import AdminUsers from "@/pages/AdminUsers";
import AdminRequests from "@/pages/AdminRequests";
import AdminDevices from "@/pages/AdminDevices";
import AdminLibraries from "@/pages/AdminLibraries";
import AdminSettingsLayout from "@/pages/admin-settings/AdminSettingsLayout";
@@ -216,6 +220,7 @@ function QueryCacheManager() {
qc.removeQueries({ queryKey: ["progress"] });
qc.removeQueries({ queryKey: ["sections"] });
qc.removeQueries({ queryKey: ["calendar"] });
qc.removeQueries({ queryKey: ["requests"] });
// Recommendation rows include per-profile user_state (is_favorite, etc.);
// the taste-seed picker depends on this for pre-selection.
qc.removeQueries({ queryKey: ["recommendations"] });
@@ -350,6 +355,7 @@ function AppRoutes() {
<Route path="collections" element={<AdminCollections />} />
<Route path="collections/new" element={<AdminCollectionEditor />} />
<Route path="collections/:id/edit" element={<AdminCollectionEditor />} />
<Route path="requests" element={<AdminRequests />} />
<Route path="history" element={<AdminPlaybackHistory />} />
<Route path="history-import" element={<AdminHistoryImport />} />
<Route path="users" element={<AdminUsers />} />
@@ -443,6 +449,20 @@ function AppRoutes() {
path="/collections/:id"
element={<LegacyUserCollectionRedirect />}
/>
<Route path="/requests" element={<Requests />} />
<Route path="/requests/:mediaType/:tmdbId" element={<RequestDetail />} />
<Route
path="/requests/browse/studio/:slug"
element={<RequestBrowse kind="studio" />}
/>
<Route
path="/requests/browse/network/:slug"
element={<RequestBrowse kind="network" />}
/>
<Route
path="/requests/browse/genre/:slug"
element={<RequestBrowse kind="genre" />}
/>
<Route path="/recommendations" element={<Recommendations />} />
<Route
path="/recommendations/section/:kind"
+239
View File
@@ -1382,6 +1382,245 @@ export interface ImportUserCollectionResponse {
sync?: UserCollectionSyncResult;
}
// 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";
export type RequestLimitMode = "inherit" | "custom" | "unlimited" | "blocked";
export type RequestApprovalMode = "inherit" | "manual" | "auto" | "blocked";
export interface RequestState {
status?: MediaRequestStatus;
requestable: boolean;
reason?: string;
request_id?: string;
}
export interface RequestMediaResult {
media_type: RequestMediaType;
tmdb_id: number;
title: string;
year?: number;
overview?: string;
poster_path?: string;
backdrop_path?: string;
release_date?: string;
popularity?: number;
vote_average?: number;
availability: RequestAvailability;
request: RequestState;
}
export interface RequestMediaPage {
page: number;
total_pages: number;
total_results: number;
results: RequestMediaResult[];
}
export interface RequestMediaCastMember {
name: string;
character?: string;
profile_path?: string;
order: number;
}
export interface RequestMediaDetail {
media_type: RequestMediaType;
tmdb_id: number;
imdb_id?: string;
tvdb_id?: number;
title: string;
original_title?: string;
tagline?: string;
overview?: string;
poster_path?: string;
backdrop_path?: string;
release_date?: string;
year?: number;
runtime?: number;
genres?: string[];
vote_average?: number;
vote_count?: number;
status?: string;
homepage?: string;
content_rating?: string;
production_companies?: string[];
number_of_seasons?: number;
number_of_episodes?: number;
first_air_date?: string;
last_air_date?: string;
networks?: string[];
cast?: RequestMediaCastMember[];
director?: string;
creators?: string[];
recommendations?: RequestMediaResult[];
availability: RequestAvailability;
request: RequestState;
}
export interface RequestDiscoverySection extends RequestMediaPage {
key: string;
title: string;
}
export interface RequestDiscoveryResponse {
sections: RequestDiscoverySection[];
}
export interface DiscoverBrandCard {
tmdb_id?: number;
slug: string;
display_name: 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;
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;
tvdb_id?: number;
imdb_id?: string;
title: string;
year?: number;
overview?: string;
poster_path?: string;
backdrop_path?: string;
}
export interface MediaRequest {
id: string;
provider: string;
media_type: RequestMediaType;
tmdb_id: number;
tvdb_id?: number;
imdb_id?: string;
title: string;
year?: number;
overview?: string;
poster_path?: string;
backdrop_path?: string;
status: MediaRequestStatus;
outcome: MediaRequestOutcome;
requested_by_user_id?: number;
requested_by_profile_id?: string;
integration_kind?: string;
external_id?: string;
external_status?: string;
last_error?: string;
created_at: string;
updated_at: string;
approved_at?: string;
completed_at?: string;
}
export interface MediaRequestsListResponse {
requests: MediaRequest[];
}
export interface RequestSettings {
requests_enabled: boolean;
global_max_requests: number;
global_window_days: number;
global_auto_approval_enabled: boolean;
updated_at: string;
}
export interface RequestUserLimit {
user_id: number;
limit_mode: RequestLimitMode;
max_requests?: number | null;
window_days?: number | null;
approval_mode: RequestApprovalMode;
updated_at?: string;
}
export interface RequestIntegration {
kind: string;
enabled: boolean;
base_url: string;
api_key_ref?: string;
has_api_key?: boolean;
root_folder: string;
quality_profile_id?: number | null;
tags: number[];
options: Record<string, unknown>;
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;
+22
View File
@@ -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;
+6
View File
@@ -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: <LayoutPanelTop className="h-[18px] w-[18px]" />,
href: "/admin/collections",
},
{
label: "Requests",
icon: <Send className="h-[18px] w-[18px]" />,
href: "/admin/requests",
},
{
label: "Sections",
icon: <PanelsTopLeft className="h-[18px] w-[18px]" />,
+18
View File
@@ -50,6 +50,7 @@ import {
PinOff,
LayoutGrid,
Puzzle,
Send,
} from "lucide-react";
import { useTheme } from "@/hooks/useTheme";
import { CURATED_THEME_IDS, THEMES } from "@/lib/themes";
@@ -494,6 +495,23 @@ export default function AppSidebar({ onNavigate, collapsed = false }: AppSidebar
<SidebarLabel show={showLabels}>Recommendations</SidebarLabel>
</ViewTransitionLink>
</li>
<li>
<ViewTransitionLink
to="/requests"
onClick={onNavigate}
className={navLinkClass("/requests")}
aria-current={isActive("/requests") ? "page" : undefined}
>
{isActive("/requests") && (
<span
className="absolute top-1/2 left-0 h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
)}
<Send className="h-[18px] w-[18px] shrink-0" />
<SidebarLabel show={showLabels}>Requests</SidebarLabel>
</ViewTransitionLink>
</li>
<li>
<ViewTransitionLink
to="/calendar"
+77
View File
@@ -0,0 +1,77 @@
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";
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);
}
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 (
<button
type="button"
onClick={handleClick}
aria-label={card.display_name}
className={cn(
baseClasses,
"ring-white/10 hover:ring-white/40 focus:ring-2 focus:ring-white",
)}
style={{ background }}
>
<span className="px-3 text-center text-base leading-tight font-semibold text-white drop-shadow">
{card.display_name}
</span>
</button>
);
}
return (
<button
type="button"
onClick={handleClick}
aria-label={card.display_name}
className={cn(
baseClasses,
"bg-gray-800 ring-gray-700 hover:bg-gray-700 hover:ring-gray-500 focus:ring-2 focus:ring-white",
)}
>
{card.logo_url ? (
<img
src={card.logo_url}
alt={card.display_name}
loading="lazy"
className="h-full w-full object-contain px-6 py-7 sm:px-8 sm:py-8"
/>
) : (
<span className="px-3 text-center text-base leading-tight font-semibold text-white">
{card.display_name}
</span>
)}
</button>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import type { DiscoverBrandCard, DiscoverBrowseKind } from "@/api/types";
import BrandCard from "@/components/BrandCard";
import { Skeleton } from "@/components/ui/skeleton";
import { useCarouselEmbla } from "@/hooks/useCarouselEmbla";
interface BrandCarouselProps {
kind: DiscoverBrowseKind;
title: string;
cards: DiscoverBrandCard[] | undefined;
isLoading: boolean;
isError: boolean;
onRetry?: () => void;
}
export default function BrandCarousel({
kind,
title,
cards,
isLoading,
isError,
onRetry,
}: BrandCarouselProps) {
const { emblaRef, canScrollPrev, canScrollNext, scrollPrev, scrollNext } = useCarouselEmbla();
const slides = isLoading
? Array.from({ length: 8 }).map((_, idx) => (
<Skeleton key={idx} className="h-28 w-52 flex-none rounded-xl sm:h-32 sm:w-64" />
))
: (cards ?? []).map((card) => <BrandCard key={card.slug} kind={kind} card={card} />);
return (
<section className="group/carousel relative isolate space-y-3">
<div className="flex items-center justify-between px-4 sm:px-6 lg:px-10 xl:px-12">
<h2 className="text-muted-foreground text-sm font-semibold tracking-normal">{title}</h2>
{isError && onRetry ? (
<button
type="button"
onClick={onRetry}
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
>
Retry
</button>
) : null}
</div>
{isError && !isLoading ? (
<div className="text-muted-foreground flex h-28 items-center px-4 text-xs sm:px-6 lg:px-10 xl:px-12">
Could not load {title.toLowerCase()}.
</div>
) : (
<div className="relative">
{canScrollPrev && (
<div className="from-background/80 pointer-events-none absolute top-0 bottom-0 left-0 z-[5] w-10 bg-gradient-to-r to-transparent" />
)}
{canScrollPrev && (
<button
type="button"
onClick={scrollPrev}
className="from-background/80 absolute top-0 bottom-0 left-0 z-10 flex h-11 w-11 items-center justify-center self-center bg-gradient-to-r to-transparent opacity-0 transition-opacity duration-[--duration-fast] group-hover/carousel:opacity-100 focus-visible:opacity-100"
aria-label="Scroll left"
>
<ChevronLeft className="text-foreground h-6 w-6" />
</button>
)}
<div
ref={emblaRef}
className="embla__viewport overflow-hidden pr-4 sm:pr-6 lg:pr-10 xl:pr-12"
tabIndex={0}
aria-label={`${title} carousel`}
onKeyDown={(e) => {
if (e.key === "ArrowLeft") {
scrollPrev();
} else if (e.key === "ArrowRight") {
scrollNext();
}
}}
>
<ul
role="list"
className="embla__container flex cursor-grab list-none gap-4 py-2 pl-4 sm:pl-6 lg:pl-10 xl:pl-12"
>
{slides.map((slide, index) => (
<li key={index} className="embla__slide shrink-0">
{slide}
</li>
))}
</ul>
</div>
{canScrollNext && (
<button
type="button"
onClick={scrollNext}
className="from-background/80 absolute top-0 right-0 bottom-0 z-10 flex h-11 w-11 items-center justify-center self-center bg-gradient-to-l to-transparent opacity-0 transition-opacity duration-[--duration-fast] group-hover/carousel:opacity-100 focus-visible:opacity-100"
aria-label="Scroll right"
>
<ChevronRight className="text-foreground h-6 w-6" />
</button>
)}
</div>
)}
</section>
);
}
+74 -36
View File
@@ -4,13 +4,20 @@ import type { CastMember } from "@/api/types";
import { useCarouselEmbla } from "@/hooks/useCarouselEmbla";
import { buildPersonCatalogHref } from "@/pages/catalogSearchParams";
import { getInitials } from "@/lib/text";
import { cn } from "@/lib/utils";
interface CastCarouselProps {
cast: CastMember[];
limit?: number;
/**
* When true, the carousel adds MediaCarousel-style edge padding so it can sit
* at the top level of a page (outside `.page-shell`) and align with other
* full-bleed rows.
*/
fullBleed?: boolean;
}
export default function CastCarousel({ cast, limit = 20 }: CastCarouselProps) {
export default function CastCarousel({ cast, limit = 20, fullBleed = false }: CastCarouselProps) {
const { emblaRef, canScrollPrev, canScrollNext, scrollPrev, scrollNext } = useCarouselEmbla();
if (cast.length === 0) return null;
@@ -26,46 +33,38 @@ export default function CastCarousel({ cast, limit = 20 }: CastCarouselProps) {
<button
type="button"
onClick={scrollPrev}
className="from-background/90 absolute top-0 bottom-0 left-0 z-10 flex h-11 w-11 items-center justify-center self-center bg-gradient-to-r to-transparent opacity-0 transition-opacity duration-200 group-hover/carousel:opacity-100 focus-visible:opacity-100"
className={cn(
"from-background/90 absolute top-0 bottom-0 z-10 flex h-11 w-11 items-center justify-center self-center bg-gradient-to-r to-transparent opacity-0 transition-opacity duration-200 group-hover/carousel:opacity-100 focus-visible:opacity-100",
fullBleed ? "left-4 sm:left-6 lg:left-10 xl:left-12" : "left-0",
)}
aria-label="Scroll left"
>
<ChevronLeft className="text-foreground h-6 w-6" />
</button>
)}
<div ref={emblaRef} className="embla__viewport overflow-hidden">
<ul role="list" className="embla__container flex cursor-grab list-none gap-3">
{visible.map((member) => (
<li key={`${member.name}-${member.order}`} className="embla__slide shrink-0">
<ViewTransitionLink
to={member.person_id ? buildPersonCatalogHref(member.person_id) : "#"}
className="group/cast block w-[110px]"
>
<div className="media-card-image mb-2.5 aspect-[2/3] overflow-hidden rounded-lg">
{member.photo_url ? (
<img
src={member.photo_url}
alt={member.name}
className="h-full w-full object-cover transition-transform duration-300 group-hover/cast:scale-105"
loading="lazy"
/>
) : (
<div className="bg-surface text-muted-foreground flex h-full w-full items-center justify-center text-lg font-semibold">
{getInitials(member.name)}
</div>
)}
</div>
<div className="px-0.5">
<div className="text-foreground truncate text-[13px] font-medium">
{member.name}
</div>
<div className="text-muted-foreground truncate text-[11px]">
{member.character}
</div>
</div>
</ViewTransitionLink>
</li>
))}
<div
ref={emblaRef}
className={cn(
"embla__viewport overflow-hidden",
fullBleed && "pr-4 sm:pr-6 lg:pr-10 xl:pr-12",
)}
>
<ul
role="list"
className={cn(
"embla__container flex cursor-grab list-none gap-3",
fullBleed && "pl-4 sm:pl-6 lg:pl-10 xl:pl-12",
)}
>
{visible.map((member) => {
const href = member.person_id ? buildPersonCatalogHref(member.person_id) : null;
return (
<li key={`${member.name}-${member.order}`} className="embla__slide shrink-0">
<CastCard member={member} href={href} />
</li>
);
})}
</ul>
</div>
@@ -73,7 +72,10 @@ export default function CastCarousel({ cast, limit = 20 }: CastCarouselProps) {
<button
type="button"
onClick={scrollNext}
className="from-background/90 absolute top-0 right-0 bottom-0 z-10 flex h-11 w-11 items-center justify-center self-center bg-gradient-to-l to-transparent opacity-0 transition-opacity duration-200 group-hover/carousel:opacity-100 focus-visible:opacity-100"
className={cn(
"from-background/90 absolute top-0 bottom-0 z-10 flex h-11 w-11 items-center justify-center self-center bg-gradient-to-l to-transparent opacity-0 transition-opacity duration-200 group-hover/carousel:opacity-100 focus-visible:opacity-100",
fullBleed ? "right-4 sm:right-6 lg:right-10 xl:right-12" : "right-0",
)}
aria-label="Scroll right"
>
<ChevronRight className="text-foreground h-6 w-6" />
@@ -82,3 +84,39 @@ export default function CastCarousel({ cast, limit = 20 }: CastCarouselProps) {
</div>
);
}
function CastCard({ member, href }: { member: CastMember; href: string | null }) {
const inner = (
<>
<div className="media-card-image mb-2.5 aspect-[2/3] overflow-hidden rounded-lg">
{member.photo_url ? (
<img
src={member.photo_url}
alt={member.name}
className="h-full w-full object-cover transition-transform duration-300 group-hover/cast:scale-105"
loading="lazy"
/>
) : (
<div className="bg-surface text-muted-foreground flex h-full w-full items-center justify-center text-lg font-semibold">
{getInitials(member.name)}
</div>
)}
</div>
<div className="px-0.5">
<div className="text-foreground truncate text-[13px] font-medium">{member.name}</div>
{member.character ? (
<div className="text-muted-foreground truncate text-[11px]">{member.character}</div>
) : null}
</div>
</>
);
if (href) {
return (
<ViewTransitionLink to={href} className="group/cast block w-[110px]">
{inner}
</ViewTransitionLink>
);
}
return <div className="group/cast w-[110px]">{inner}</div>;
}
+2
View File
@@ -42,10 +42,12 @@ export default function Layout({ children }: LayoutProps) {
})();
const isRecommendationsRoute = location.pathname === "/recommendations";
const isCalendarRoute = location.pathname === "/calendar";
const isRequestDetailRoute = /^\/requests\/(movie|series)\//.test(location.pathname);
const needsNoPadding =
isHomePath ||
isLibraryRoute ||
isItemRoute ||
isRequestDetailRoute ||
isSearchLandingRoute ||
isRecommendationsRoute ||
isCalendarRoute;
+370
View File
@@ -0,0 +1,370 @@
import { Link } from "react-router";
import { Check, Film, Loader2, Plus, Tv } from "lucide-react";
import type { MediaRequest, RequestMediaResult } from "@/api/types";
import { cn } from "@/lib/utils";
import { formatRequestReason, formatRequestStatus, tmdbImageURL } from "@/lib/mediaRequests";
const POSTER_WIDTH = "w-[148px] sm:w-[164px] lg:w-[184px]";
type DiscoverProps = {
variant: "discover";
item: RequestMediaResult;
isSubmitting: boolean;
onRequest: () => void;
/** When true, fills the parent (use inside grids). Default: fixed carousel width. */
fluid?: boolean;
};
type MineProps = {
variant: "mine";
request: MediaRequest;
fluid?: boolean;
};
export type RequestPosterCardProps = DiscoverProps | MineProps;
export default function RequestPosterCard(props: RequestPosterCardProps) {
if (props.variant === "mine") {
return <MineCard request={props.request} fluid={props.fluid} />;
}
return (
<DiscoverCard
item={props.item}
isSubmitting={props.isSubmitting}
onRequest={props.onRequest}
fluid={props.fluid}
/>
);
}
function DiscoverCard({
item,
isSubmitting,
onRequest,
fluid,
}: {
item: RequestMediaResult;
isSubmitting: boolean;
onRequest: () => void;
fluid?: boolean;
}) {
const poster = tmdbImageURL(item.poster_path);
const requestable = item.request.requestable;
const statusLabel = item.request.status ? formatRequestStatus(item.request.status) : null;
const reasonLabel =
!requestable && !item.request.status ? formatRequestReason(item.request.reason) : null;
const availableInLibrary = item.availability === "available" && !item.request.status;
const ribbon: { kind: RibbonKind; label: string } | null = statusLabel
? { kind: (item.request.status as RibbonKind) ?? "pending", label: statusLabel }
: availableInLibrary
? { kind: "completed", label: "In library" }
: reasonLabel
? { kind: "blocked", label: reasonLabel }
: null;
return (
<div
className={cn(
"group/req-card relative block focus-within:outline-none",
fluid ? "w-full" : POSTER_WIDTH,
)}
>
<Link
to={`/requests/${item.media_type}/${item.tmdb_id}`}
className="block focus:outline-none focus-visible:outline-none"
>
<PosterFrame
poster={poster}
title={item.title}
mediaType={item.media_type}
dim={!requestable}
accent={ribbon?.kind ?? null}
>
{ribbon && <StatusRibbon status={ribbon.kind} label={ribbon.label} />}
</PosterFrame>
<CardMeta
title={item.title}
year={item.year}
rating={item.vote_average}
mediaType={item.media_type}
/>
</Link>
{requestable && (
<div className="pointer-events-none absolute inset-x-0 top-0 flex aspect-[2/3] translate-y-2 items-end justify-center bg-gradient-to-t from-black/85 via-black/45 to-transparent p-3 opacity-0 transition-all duration-200 ease-out group-focus-within/req-card:translate-y-0 group-focus-within/req-card:opacity-100 group-hover/req-card:translate-y-0 group-hover/req-card:opacity-100">
<button
type="button"
disabled={isSubmitting}
onClick={(e) => {
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"
>
{isSubmitting ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Sending
</>
) : (
<>
<Plus className="h-3.5 w-3.5 stroke-[2.5]" />
Request
</>
)}
</button>
</div>
)}
</div>
);
}
function MineCard({ request, fluid }: { request: MediaRequest; fluid?: boolean }) {
const poster = tmdbImageURL(request.poster_path);
const isDownloading = request.status === "downloading";
const isCompleted = request.status === "completed";
const isFailed =
request.outcome === "failed" ||
request.outcome === "declined" ||
request.outcome === "cancelled";
const kind: RibbonKind = isFailed ? "blocked" : (request.status as RibbonKind);
const label = isFailed ? formatOutcome(request.outcome) : formatRequestStatus(request.status);
return (
<Link
to={`/requests/${request.media_type}/${request.tmdb_id}`}
className={cn(
"group/req-card relative block focus:outline-none focus-visible:outline-none",
fluid ? "w-full" : POSTER_WIDTH,
)}
>
<PosterFrame
poster={poster}
title={request.title}
mediaType={request.media_type}
dim={isFailed}
accent={kind}
>
<StatusRibbon status={kind} label={label} />
{isDownloading && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-1 overflow-hidden bg-black/40">
<div className="downloading-shimmer h-full bg-sky-400" />
</div>
)}
{isCompleted && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-center justify-center bg-gradient-to-t from-emerald-950/90 via-emerald-900/40 to-transparent p-3">
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2.5 py-0.5 text-[11px] font-semibold tracking-wide text-emerald-200 ring-1 ring-emerald-400/30">
<Check className="h-3 w-3 stroke-[2.5]" />
Ready to watch
</span>
</div>
)}
</PosterFrame>
<CardMeta title={request.title} year={request.year} mediaType={request.media_type} />
{request.last_error ? (
<p
className="mt-1 line-clamp-2 text-[11px] leading-tight text-red-300/90"
title={request.last_error}
>
{request.last_error}
</p>
) : null}
</Link>
);
}
const ACCENT_BAR: Record<RibbonKind, string> = {
pending: "bg-amber-300/70",
approved: "bg-emerald-300/70",
queued: "bg-sky-300/70",
downloading: "bg-sky-300/70",
completed: "bg-emerald-300/70",
blocked: "bg-zinc-400/40",
};
function PosterFrame({
poster,
title,
mediaType,
dim,
accent,
children,
}: {
poster: string | null;
title: string;
mediaType: "movie" | "series";
dim?: boolean;
accent?: RibbonKind | null;
children?: React.ReactNode;
}) {
return (
<div className="media-card-image relative aspect-[2/3]">
{poster ? (
<img
src={poster}
alt={title ? `${title} poster` : "Poster"}
loading="lazy"
className={cn(
"h-full w-full object-cover transition-[transform,filter] duration-300 group-hover/req-card:scale-[1.04]",
dim && "brightness-[0.85] saturate-[0.8]",
)}
/>
) : (
<PosterFallback title={title} mediaType={mediaType} dim={dim} />
)}
{/* subtle bottom vignette for legibility behind ribbons / hover overlays */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/55 to-transparent opacity-90" />
{/* thin status accent bar, hugs bottom edge */}
{accent && (
<div
className={cn(
"pointer-events-none absolute inset-x-0 bottom-0 h-[2px] opacity-90",
ACCENT_BAR[accent],
)}
/>
)}
{children}
</div>
);
}
function PosterFallback({
title,
mediaType,
dim,
}: {
title: string;
mediaType: "movie" | "series";
dim?: boolean;
}) {
const hue = stringHue(title);
const Icon = mediaType === "series" ? Tv : Film;
return (
<div
className={cn(
"relative flex h-full w-full flex-col justify-end overflow-hidden p-3.5",
dim && "opacity-90",
)}
style={{
background: `linear-gradient(160deg, hsl(${hue} 30% 22%) 0%, hsl(${hue} 22% 11%) 60%, hsl(${(hue + 28) % 360} 18% 7%) 100%)`,
}}
>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<Icon className="h-28 w-28 text-white/[0.05]" strokeWidth={1.25} />
</div>
<div
className="pointer-events-none absolute inset-0"
style={{
backgroundImage: "radial-gradient(rgba(255,255,255,0.55) 1px, transparent 1px)",
backgroundSize: "9px 9px",
opacity: 0.05,
}}
/>
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white/15 to-transparent" />
<div className="relative space-y-1.5">
<span className="text-[9px] font-semibold tracking-[0.22em] text-white/45 uppercase">
{mediaType === "series" ? "Series" : "Motion picture"}
</span>
<h4 className="font-display line-clamp-4 text-[15px] leading-tight font-bold tracking-tight text-balance text-white/90">
{title}
</h4>
</div>
</div>
);
}
function stringHue(input: string): number {
let hash = 0;
for (let i = 0; i < input.length; i++) {
hash = (Math.imul(hash, 31) + input.charCodeAt(i)) | 0;
}
return Math.abs(hash) % 360;
}
function CardMeta({
title,
year,
rating,
mediaType,
}: {
title: string;
year?: number;
rating?: number;
mediaType?: "movie" | "series";
}) {
const Icon = mediaType === "series" ? Tv : Film;
const hasMeta = mediaType || year !== undefined || rating !== undefined;
return (
<div className="mt-2.5 min-w-0 px-0.5">
<h3 className="text-foreground line-clamp-1 text-[13px] leading-tight font-semibold tracking-tight">
{title}
</h3>
{hasMeta && (
<div className="text-muted-foreground mt-1 flex items-center gap-1.5 text-[11px]">
{mediaType && (
<Icon className="h-3 w-3 shrink-0 opacity-60" strokeWidth={2} aria-hidden />
)}
{year ? <span className="tabular-nums">{year}</span> : null}
{(year || mediaType) && rating ? (
<span aria-hidden className="text-muted-foreground/40">
·
</span>
) : null}
{rating ? (
<span className="tabular-nums">
<span className="text-amber-300/90"></span> {rating.toFixed(1)}
</span>
) : null}
</div>
)}
</div>
);
}
type RibbonKind = "pending" | "approved" | "queued" | "downloading" | "completed" | "blocked";
const RIBBON_STYLES: Record<RibbonKind, string> = {
pending:
"bg-amber-950/75 text-amber-100 ring-amber-400/30 [&_.dot]:bg-amber-300 [&_.dot]:animate-pulse",
approved: "bg-emerald-950/75 text-emerald-100 ring-emerald-400/30 [&_.dot]:bg-emerald-300",
queued: "bg-sky-950/75 text-sky-100 ring-sky-400/30 [&_.dot]:bg-sky-300 [&_.dot]:animate-pulse",
downloading:
"bg-sky-950/80 text-sky-100 ring-sky-400/35 [&_.dot]:bg-sky-300 [&_.dot]:animate-pulse",
completed: "bg-emerald-950/80 text-emerald-100 ring-emerald-400/30 [&_.dot]:bg-emerald-300",
blocked: "bg-zinc-900/80 text-zinc-200 ring-white/10 [&_.dot]:bg-zinc-400",
};
function StatusRibbon({ status, label }: { status: string; label: string }) {
const kind = (RIBBON_STYLES[status as RibbonKind] ? status : "blocked") as RibbonKind;
return (
<span
className={cn(
"absolute top-2 right-2 inline-flex max-w-[calc(100%-1rem)] items-center gap-1.5 rounded-full px-2 py-[3px] text-[10px] leading-none font-medium tracking-[0.06em] uppercase shadow-sm ring-1 shadow-black/40 backdrop-blur-md",
RIBBON_STYLES[kind],
)}
>
<span className="dot inline-block h-1.5 w-1.5 shrink-0 rounded-full" />
<span className="truncate">{label}</span>
</span>
);
}
function formatOutcome(outcome: MediaRequest["outcome"]): string {
switch (outcome) {
case "declined":
return "Declined";
case "cancelled":
return "Cancelled";
case "failed":
return "Failed";
default:
return "Active";
}
}
+26
View File
@@ -116,6 +116,27 @@ export const collectionKeys = {
mdblistTop: () => ["collections", "mdblist", "top"] as const,
};
export const requestKeys = {
all: ["requests"] as const,
discovery: () => ["requests", "discovery"] as const,
discoverySection: (section: string, page: number) =>
["requests", "discovery", section, page] as const,
discoverStudios: () => ["requests", "discover", "studios"] as const,
discoverNetworks: () => ["requests", "discover", "networks"] as const,
discoverGenres: () => ["requests", "discover", "genres"] as const,
discoverBrowse: (
kind: "studio" | "network" | "genre",
slug: string,
mediaType: string | undefined,
sort: string,
page: number,
) => ["requests", "discover", "browse", kind, slug, mediaType ?? "", sort, page] as const,
search: (mediaType: string, query: string, page: number) =>
["requests", "search", mediaType, query, page] as const,
detail: (mediaType: string, tmdbID: number) => ["requests", "detail", mediaType, tmdbID] as const,
mine: (params: Record<string, unknown>) => ["requests", "mine", params] as const,
};
export const libraryCollectionKeys = {
all: ["libraryCollections"] as const,
list: (libraryId: number) => ["libraryCollections", "list", libraryId] as const,
@@ -317,6 +338,11 @@ export const adminKeys = {
stats: () => ["admin", "stats"] as const,
sessions: () => ["admin", "sessions"] as const,
serverSettings: () => ["admin", "serverSettings"] as const,
requestsRoot: () => ["admin", "requests"] as const,
requests: (params: Record<string, unknown>) => ["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,
+352
View File
@@ -0,0 +1,352 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { api } from "@/api/client";
import type {
CreateMediaRequestInput,
DiscoverBrowseKind,
DiscoverBrowseResponse,
DiscoverGenresResponse,
DiscoverNetworksResponse,
DiscoverStudiosResponse,
LoadRequestIntegrationOptionsRequest,
MediaRequest,
MediaRequestsListResponse,
RequestDiscoveryResponse,
RequestDiscoverySection,
RequestIntegration,
RequestIntegrationOptions,
RequestIntegrationsResponse,
RequestListParams,
RequestMediaDetail,
RequestMediaPage,
RequestSearchMediaType,
RequestMediaType,
RequestSettings,
RequestUserLimit,
} from "@/api/types";
import { adminKeys, requestKeys } from "./keys";
const REQUESTS_STALE_TIME = 30_000;
const DISCOVER_BRAND_STALE_TIME = 24 * 60 * 60 * 1000;
const BROWSE_STALE_TIME = 60 * 1000;
function listParamsKey(params: RequestListParams) {
return {
status: params.status ?? "all",
outcome: params.outcome ?? "all",
limit: params.limit ?? 50,
offset: params.offset ?? 0,
};
}
function buildListQuery(params: RequestListParams = {}) {
const query = new URLSearchParams();
if (params.status && params.status !== "all") query.set("status", params.status);
if (params.outcome && params.outcome !== "all") query.set("outcome", params.outcome);
if (params.limit != null && params.limit > 0) query.set("limit", String(params.limit));
if (params.offset != null && params.offset > 0) query.set("offset", String(params.offset));
const encoded = query.toString();
return encoded ? `?${encoded}` : "";
}
function invalidateRequestSurfaces(queryClient: ReturnType<typeof useQueryClient>) {
queryClient.invalidateQueries({ queryKey: requestKeys.all });
queryClient.invalidateQueries({ queryKey: adminKeys.requestsRoot() });
}
export function useRequestDiscovery() {
return useQuery({
queryKey: requestKeys.discovery(),
queryFn: () =>
api<RequestDiscoveryResponse>("/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<RequestDiscoverySection>(
`/requests/discover/${encodeURIComponent(section)}?page=${page}`,
),
enabled: section.trim().length > 0,
staleTime: REQUESTS_STALE_TIME,
});
}
export function useDiscoverStudios() {
return useQuery({
queryKey: requestKeys.discoverStudios(),
queryFn: () =>
api<DiscoverStudiosResponse>("/requests/discover/studios").then((data) => data.studios ?? []),
staleTime: DISCOVER_BRAND_STALE_TIME,
});
}
export function useDiscoverNetworks() {
return useQuery({
queryKey: requestKeys.discoverNetworks(),
queryFn: () =>
api<DiscoverNetworksResponse>("/requests/discover/networks").then(
(data) => data.networks ?? [],
),
staleTime: DISCOVER_BRAND_STALE_TIME,
});
}
export function useDiscoverGenres() {
return useQuery({
queryKey: requestKeys.discoverGenres(),
queryFn: () =>
api<DiscoverGenresResponse>("/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<DiscoverBrowseResponse>(
`/requests/discover/browse/${kind}/${encodeURIComponent(slug)}?${params}`,
);
},
enabled: slug.trim().length > 0 && (kind !== "genre" || Boolean(mediaType)),
staleTime: BROWSE_STALE_TIME,
});
}
export function useRequestMediaDetail(mediaType: RequestMediaType, tmdbID: number) {
return useQuery({
queryKey: requestKeys.detail(mediaType, tmdbID),
queryFn: () =>
api<RequestMediaDetail>(
`/requests/detail/${encodeURIComponent(mediaType)}/${encodeURIComponent(String(tmdbID))}`,
),
enabled: tmdbID > 0,
staleTime: REQUESTS_STALE_TIME,
});
}
export function useRequestSearch(mediaType: RequestSearchMediaType, query: string, page = 1) {
const normalizedQuery = query.trim();
return useQuery({
queryKey: requestKeys.search(mediaType, normalizedQuery, page),
queryFn: () => {
const params = new URLSearchParams({
q: normalizedQuery,
media_type: mediaType,
page: String(page),
});
return api<RequestMediaPage>(`/requests/search?${params}`);
},
enabled: normalizedQuery.length > 1,
staleTime: REQUESTS_STALE_TIME,
});
}
export function useCreateMediaRequest() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: CreateMediaRequestInput) =>
api<MediaRequest>("/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<MediaRequestsListResponse>(`/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<MediaRequestsListResponse>(`/admin/requests${buildListQuery(params)}`).then(
(data) => data.requests ?? [],
),
staleTime: 10_000,
});
}
export function useApproveMediaRequest() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
api<MediaRequest>(`/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<MediaRequest>(`/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<MediaRequest>(`/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<RequestSettings>("/admin/request-settings"),
staleTime: REQUESTS_STALE_TIME,
});
}
export function useUpdateRequestSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: RequestSettings) =>
api<RequestSettings>("/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<RequestIntegrationsResponse>("/admin/request-integrations").then(
(data) => data.integrations ?? [],
),
staleTime: REQUESTS_STALE_TIME,
});
}
export function useUpdateRequestIntegrations() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (integrations: RequestIntegration[]) =>
api<RequestIntegrationsResponse>("/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<RequestIntegrationOptions>(
`/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<RequestUserLimit>(`/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<RequestUserLimit>(`/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");
},
});
}
+1
View File
@@ -29,6 +29,7 @@ const ADMIN_TITLES: Record<string, string> = {
maintenance: "Admin Maintenance",
nodes: "Admin Nodes",
recommendations: "Admin Recommendations",
requests: "Admin Requests",
sections: "Admin Sections",
settings: "Admin Settings",
tasks: "Admin Tasks",
+129
View File
@@ -0,0 +1,129 @@
import type {
CreateMediaRequestInput,
MediaRequest,
MediaRequestOutcome,
MediaRequestStatus,
RequestMediaResult,
RequestMediaType,
} from "@/api/types";
export const REQUEST_STATUSES: Array<MediaRequestStatus | "all"> = [
"all",
"pending",
"approved",
"queued",
"downloading",
"completed",
];
export const REQUEST_OUTCOMES: Array<MediaRequestOutcome | "all"> = [
"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<MediaRequest, "created_at">): string {
return new Date(request.created_at).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
File diff suppressed because it is too large Load Diff
+265
View File
@@ -0,0 +1,265 @@
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/useRequests";
import { requestInputFromMediaResult } from "@/lib/mediaRequests";
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 rawPage = Number(searchParams.get("page") ?? "1");
const page = Number.isInteger(rawPage) && rawPage > 0 ? rawPage : 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 pendingRequestKey = createRequest.variables
? mediaRequestKey(createRequest.variables.media_type, createRequest.variables.tmdb_id)
: undefined;
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 (
<div className="space-y-4 py-10 text-center">
<p className="text-foreground text-lg font-semibold">
{kind === "studio" ? "Studio" : kind === "network" ? "Network" : "Genre"} not found.
</p>
<Link
to="/requests"
className="text-muted-foreground hover:text-foreground text-sm underline"
>
Back to Requests
</Link>
</div>
);
}
return (
<div className="space-y-6 py-6 sm:py-8">
<div className="space-y-4 px-4 sm:px-6 lg:px-10 xl:px-12">
<Link
to="/requests"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-sm"
>
<ArrowLeft className="h-4 w-4" /> Back to Requests
</Link>
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-4">
<BrowseHeaderTile browse={browse.data} kind={kind} fallback={title} />
<div className="min-w-0">
<h1 className="text-foreground truncate text-2xl font-semibold">{title}</h1>
<p className="text-muted-foreground text-sm">
{browse.isLoading
? "Loading..."
: results.length > 0
? `Page ${page} of ${totalPages}`
: "No results."}
</p>
</div>
</div>
<Select value={sort} onValueChange={updateSort}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Sort" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{kind === "genre" ? (
<Tabs
value={mediaType ?? "movie"}
onValueChange={(value) => updateMediaType(value as RequestMediaType)}
>
<TabsList>
<TabsTrigger value="movie">Movies</TabsTrigger>
<TabsTrigger value="series">Series</TabsTrigger>
</TabsList>
</Tabs>
) : null}
</div>
<div className="px-4 sm:px-6 lg:px-10 xl:px-12">
{browse.isLoading ? (
<BrowseGridSkeleton />
) : browse.isError ? (
<p className="text-muted-foreground text-sm">
Could not load this browse page. Try a different sort or media type.
</p>
) : results.length === 0 ? (
<p className="text-muted-foreground text-sm">Nothing matched. Try a different sort.</p>
) : (
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
{results.map((item) => (
<RequestPosterCard
key={`${item.media_type}-${item.tmdb_id}`}
variant="discover"
item={item}
onRequest={() => submitRequest(item)}
isSubmitting={
createRequest.isPending &&
pendingRequestKey === mediaRequestKey(item.media_type, item.tmdb_id)
}
fluid
/>
))}
</div>
)}
</div>
{totalPages > 1 ? (
<div className="flex items-center justify-center gap-3 px-4">
<Button variant="outline" disabled={page <= 1} onClick={() => goToPage(page - 1)}>
Prev
</Button>
<span className="text-muted-foreground text-sm tabular-nums">
Page {page} of {totalPages}
</span>
<Button
variant="outline"
disabled={page >= totalPages}
onClick={() => goToPage(page + 1)}
>
Next
</Button>
</div>
) : null}
</div>
);
}
function BrowseHeaderTile({
browse,
kind,
fallback,
}: {
browse: DiscoverBrowseResponse | undefined;
kind: DiscoverBrowseKind;
fallback: string;
}) {
if (!browse) {
return <div className="bg-muted h-16 w-28 rounded-md" aria-hidden />;
}
if (kind === "genre") {
return (
<div className="bg-muted text-foreground flex h-16 w-28 items-center justify-center rounded-md px-2 text-center text-sm font-semibold">
{browse.display_name || fallback}
</div>
);
}
return (
<div className="flex h-16 w-28 items-center justify-center overflow-hidden rounded-md bg-gray-800 ring-1 ring-gray-700">
{browse.logo_url ? (
<img
src={browse.logo_url}
alt={browse.display_name}
className="h-full w-full object-contain p-2"
/>
) : (
<span className="px-2 text-center text-xs font-semibold text-white">
{browse.display_name || fallback}
</span>
)}
</div>
);
}
function BrowseGridSkeleton() {
return (
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
{Array.from({ length: 16 }).map((_, idx) => (
<Skeleton key={idx} className="aspect-[2/3] w-full rounded-lg" />
))}
</div>
);
}
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 mediaRequestKey(mediaType: RequestMediaType, tmdbID: number): string {
return `${mediaType}-${tmdbID}`;
}
function humanizeSlug(slug: string) {
return slug
.split("-")
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
+417
View File
@@ -0,0 +1,417 @@
import { useNavigate, useParams } from "react-router";
import { ArrowLeft, Check, Clock, Loader2, Plus, Star } from "lucide-react";
import CastCarousel from "@/components/CastCarousel";
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 {
CastMember,
RequestMediaCastMember,
RequestMediaDetail,
RequestMediaResult,
} from "@/api/types";
import { useCreateMediaRequest, useRequestMediaDetail } from "@/hooks/queries/useRequests";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
import { cn } from "@/lib/utils";
import {
formatRequestReason,
formatRequestStatus,
requestInputFromMediaResult,
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 <RequestDetailSkeleton />;
}
if (detail.isError || !detail.data) {
return (
<div className="page-shell space-y-3 py-12 text-center">
<p className="text-foreground text-base font-semibold">Couldn't load this title.</p>
<p className="text-muted-foreground text-sm">
The TMDB record may be temporarily unavailable.
</p>
<div className="pt-4">
<Button variant="outline" size="sm" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
</div>
</div>
);
}
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 (
<div>
<DetailHero
title={item.title}
context={<RequestContext mediaType={mediaType} />}
studioLabel={studioLabel}
backdropUrl={backdropUrl}
posterUrl={posterUrl}
tagline={item.tagline || undefined}
metadata={<MetaPills item={item} />}
scoreRow={<RequestScoreRow item={item} />}
crewLine={<RequestCrewLine item={item} />}
overview={item.overview}
actions={
<RequestActions
item={item}
isSubmitting={
createRequest.isPending && createRequest.variables?.tmdb_id === item.tmdb_id
}
onRequest={() => createRequest.mutate(requestInputFromMediaResult(item))}
onBack={() => navigate(-1)}
/>
}
/>
<div className="space-y-12 py-10 sm:space-y-14">
{item.cast && item.cast.length > 0 && (
<section className="section-row">
<div className="mb-5 px-4 sm:px-6 lg:px-10 xl:px-12">
<h2 className="text-foreground text-xl font-semibold tracking-tight">Cast</h2>
</div>
<CastCarousel cast={adaptRequestCast(item.cast)} fullBleed />
</section>
)}
{item.recommendations && item.recommendations.length > 0 && (
<RecommendationsRow
recommendations={item.recommendations}
pendingTMDBID={createRequest.variables?.tmdb_id}
isSubmitting={createRequest.isPending}
onRequest={(rec) => createRequest.mutate(requestInputFromMediaResult(rec))}
/>
)}
</div>
</div>
);
}
function adaptRequestCast(cast: RequestMediaCastMember[]): CastMember[] {
return cast.map((member) => ({
name: member.name,
character: member.character ?? "",
order: member.order,
person_id: "",
photo_url: tmdbImageURL(member.profile_path, "w185") ?? undefined,
}));
}
function RequestContext({ mediaType }: { mediaType: "movie" | "series" }) {
return (
<span className="text-muted-foreground inline-flex items-center gap-1.5 text-[11px] font-semibold tracking-[0.22em] uppercase">
Request · {mediaType === "series" ? "Series" : "Movie"}
</span>
);
}
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 (
<div className="flex flex-wrap items-center gap-2">
{pills.map((pill) => (
<span
key={pill}
className="border-border/60 bg-card/40 inline-flex items-center rounded-md border px-2 py-0.5 text-[12px] font-medium tracking-normal"
>
{pill}
</span>
))}
{(item.genres ?? []).slice(0, 4).map((genre) => (
<span
key={genre}
className="text-muted-foreground inline-flex items-center rounded-md px-1 py-0.5 text-[12px]"
>
{genre}
</span>
))}
</div>
);
}
function RequestScoreRow({ item }: { item: RequestMediaDetail }) {
if (!item.vote_average) return null;
return (
<div className="text-muted-foreground flex items-center gap-3 text-[13px]">
<span className="inline-flex items-center gap-1.5">
<Star className="h-3.5 w-3.5 fill-amber-300/90 text-amber-300/90" />
<span className="text-foreground tabular-nums">{item.vote_average.toFixed(1)}</span>
<span className="opacity-60">TMDB</span>
</span>
{item.vote_count ? (
<span className="tabular-nums opacity-70">{formatVoteCount(item.vote_count)} votes</span>
) : null}
</div>
);
}
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 (
<div className="text-muted-foreground flex flex-wrap gap-x-5 gap-y-1 text-[13px]">
{parts.map((part) => (
<span key={part.label} className="inline-flex gap-1.5">
<span className="opacity-60">{part.label}:</span>
<span className="text-foreground/90">{part.value}</span>
</span>
))}
</div>
);
}
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 (
<div className="flex flex-wrap items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={onBack}
className="text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
{requestable ? (
<Button
onClick={onRequest}
disabled={isSubmitting}
className="h-11 rounded-full px-6 text-sm font-semibold"
>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Submitting
</>
) : (
<>
<Plus className="h-4 w-4 stroke-[2.5]" />
Request {item.media_type === "series" ? "series" : "movie"}
</>
)}
</Button>
) : availableInLibrary ? (
<StatusBlock
tone="emerald"
icon={<Check className="h-4 w-4 stroke-[2.5]" />}
label="Already in your library"
/>
) : statusLabel ? (
<StatusBlock
tone={statusToneForStatus(item.request.status!)}
icon={<Clock className="h-4 w-4" />}
label={statusLabel}
/>
) : (
<StatusBlock
tone="zinc"
icon={<Clock className="h-4 w-4" />}
label={reasonLabel ?? "Unavailable"}
/>
)}
{item.imdb_id ? (
<a
href={`https://www.imdb.com/title/${item.imdb_id}`}
target="_blank"
rel="noreferrer"
className="text-muted-foreground hover:text-foreground border-border/60 inline-flex items-center rounded-full border px-3 py-1.5 text-[12px] font-medium tracking-wide transition-colors"
>
IMDb
</a>
) : null}
<a
href={`https://www.themoviedb.org/${item.media_type === "series" ? "tv" : "movie"}/${item.tmdb_id}`}
target="_blank"
rel="noreferrer"
className="text-muted-foreground hover:text-foreground border-border/60 inline-flex items-center rounded-full border px-3 py-1.5 text-[12px] font-medium tracking-wide transition-colors"
>
TMDB
</a>
</div>
);
}
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 (
<span
className={cn(
"inline-flex h-11 items-center gap-2 rounded-full px-5 text-sm font-semibold ring-1",
STATUS_TONES[tone],
)}
>
{icon}
{label}
</span>
);
}
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 RecommendationsRow({
recommendations,
pendingTMDBID,
isSubmitting,
onRequest,
}: {
recommendations: RequestMediaResult[];
pendingTMDBID?: number;
isSubmitting: boolean;
onRequest: (item: RequestMediaResult) => void;
}) {
return (
<MediaCarousel title="More Like This">
{recommendations.map((item) => (
<RequestPosterCard
key={`${item.media_type}-${item.tmdb_id}`}
variant="discover"
item={item}
isSubmitting={isSubmitting && pendingTMDBID === item.tmdb_id}
onRequest={() => onRequest(item)}
/>
))}
</MediaCarousel>
);
}
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 (
<div>
<section className="border-border/10 relative isolate overflow-hidden border-b">
<div className="absolute inset-0 bg-gradient-to-r from-[var(--background)] via-[var(--background)]/70 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-t from-[var(--background)] via-[var(--background)]/40 to-transparent" />
<div className="page-shell-wide relative flex min-h-[60dvh] flex-col justify-end pt-28 pb-8 lg:min-h-[72dvh]">
<div className="flex flex-col gap-6 lg:flex-row lg:items-end">
<Skeleton className="aspect-[2/3] w-[170px] flex-shrink-0 rounded-lg sm:w-[220px]" />
<div className="max-w-3xl flex-1 space-y-4">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-80 max-w-full" />
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-full max-w-2xl" />
<Skeleton className="h-4 w-5/6 max-w-xl" />
<Skeleton className="h-4 w-3/4 max-w-lg" />
<div className="flex gap-3 pt-2">
<Skeleton className="h-11 w-40 rounded-full" />
<Skeleton className="h-11 w-20 rounded-full" />
</div>
</div>
</div>
</div>
</section>
<div className="space-y-12 py-10 sm:space-y-14">
<div className="section-row">
<div className="mb-5 px-4 sm:px-6 lg:px-10 xl:px-12">
<Skeleton className="h-6 w-24 rounded" />
</div>
<div className="flex gap-3 overflow-hidden pl-4 sm:pl-6 lg:pl-10 xl:pl-12">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="aspect-[2/3] w-[110px] shrink-0 rounded-lg" />
))}
</div>
</div>
</div>
</div>
);
}
+898
View File
@@ -0,0 +1,898 @@
import { useEffect, useMemo, useState } from "react";
import type { FormEvent } from "react";
import { useSearchParams } from "react-router";
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";
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,
MediaRequestOutcome,
MediaRequestStatus,
RequestDiscoverySection,
RequestMediaResult,
RequestSearchMediaType,
} from "@/api/types";
import {
useCreateMediaRequest,
useDiscoverGenres,
useDiscoverNetworks,
useDiscoverStudios,
useMyMediaRequests,
useRequestDiscovery,
useRequestSearch,
} from "@/hooks/queries/useRequests";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
import { cn } from "@/lib/utils";
import { formatRequestStatus, requestInputFromMediaResult } from "@/lib/mediaRequests";
type MineBucketKey = "motion" | "completed" | "issues";
type RequestTab = "discover" | "yours";
type StatusGuideItem = {
description: string;
tone: string;
};
const REQUEST_TABS = ["discover", "yours"] as const;
const MINE_BUCKET_META: Record<MineBucketKey, { title: string; eyebrow: string; accent: string }> =
{
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",
},
};
const REQUEST_PROGRESS_GUIDE: Array<StatusGuideItem & { status: MediaRequestStatus }> = [
{
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<MediaRequestOutcome, "declined" | "cancelled" | "failed">;
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");
const [searchParams, setSearchParams] = useSearchParams();
const submittedQuery = (searchParams.get("q") ?? "").trim();
const activeTab = normalizeRequestTab(searchParams.get("tab"));
const mediaType = normalizeRequestMediaType(
searchParams.get("type") ?? searchParams.get("media_type"),
);
const searchPage = normalizeSearchPage(searchParams.get("page"));
const searchQuery = activeTab === "discover" ? submittedQuery : "";
const [searchInput, setSearchInput] = useState(submittedQuery);
const discovery = useRequestDiscovery();
const studios = useDiscoverStudios();
const networks = useDiscoverNetworks();
const genres = useDiscoverGenres();
const search = useRequestSearch(mediaType, searchQuery, searchPage);
const mine = useMyMediaRequests({ limit: 100 });
const createRequest = useCreateMediaRequest();
const pendingRequestKey = createRequest.variables
? mediaRequestKey(createRequest.variables.media_type, createRequest.variables.tmdb_id)
: undefined;
const hasSubmittedSearch = searchQuery.length > 1;
useEffect(() => {
setSearchInput(submittedQuery);
}, [submittedQuery]);
function updateRequestParams(
update: (next: URLSearchParams) => void,
options: { replace?: boolean } = {},
) {
const next = new URLSearchParams(searchParams);
update(next);
setSearchParams(next, options);
}
function setActiveTab(value: string) {
const nextTab = normalizeRequestTab(value);
updateRequestParams((next) => {
if (nextTab === "discover") {
next.delete("tab");
} else {
next.set("tab", nextTab);
next.delete("q");
next.delete("page");
next.delete("type");
next.delete("media_type");
}
});
}
function handleSearch(event: FormEvent) {
event.preventDefault();
const normalizedQuery = searchInput.trim();
if (normalizedQuery.length < 2) return;
updateRequestParams((next) => {
next.delete("tab");
next.set("q", normalizedQuery);
next.delete("page");
if (mediaType === "all") {
next.delete("type");
next.delete("media_type");
} else {
next.set("type", mediaType);
next.delete("media_type");
}
});
}
function handleMediaTypeChange(value: RequestSearchMediaType) {
updateRequestParams((next) => {
if (value === "all") {
next.delete("type");
next.delete("media_type");
} else {
next.set("type", value);
next.delete("media_type");
}
next.delete("page");
});
}
function clearSearch() {
setSearchInput("");
updateRequestParams((next) => {
next.delete("q");
next.delete("page");
next.delete("type");
next.delete("media_type");
next.delete("tab");
});
}
function setSearchPageParam(page: number) {
updateRequestParams(
(next) => {
if (page <= 1) {
next.delete("page");
} else {
next.set("page", String(page));
}
},
{ replace: true },
);
}
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 (
<div className="space-y-8 py-6 sm:py-8">
<div className="space-y-8 px-4 sm:px-6 lg:px-10 xl:px-12">
<PageHeader />
<SearchBar
mediaType={mediaType}
onMediaTypeChange={handleMediaTypeChange}
searchInput={searchInput}
onSearchInputChange={setSearchInput}
onSubmit={handleSearch}
onClear={clearSearch}
isSearching={hasSubmittedSearch}
/>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<div className="px-4 sm:px-6 lg:px-10 xl:px-12">
<TabsList variant="line" className="border-border w-full justify-start border-b">
<TabsTrigger value="discover" className="px-3 text-[13px]">
Discover
</TabsTrigger>
<TabsTrigger value="yours" className="px-3 text-[13px]">
Yours
{totalMine > 0 && (
<span className="bg-muted/80 text-muted-foreground ml-1.5 inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-[10px] font-semibold tabular-nums">
{totalMine}
</span>
)}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="discover" className="space-y-8 pt-2">
{hasSubmittedSearch ? (
<SearchResultsView
query={submittedQuery}
mediaType={mediaType}
page={searchPage}
onPageChange={setSearchPageParam}
isLoading={search.isLoading || search.isFetching}
isError={search.isError}
totalPages={search.data?.total_pages ?? 0}
totalResults={search.data?.total_results ?? 0}
results={search.data?.results ?? []}
pendingRequestKey={pendingRequestKey}
isSubmitting={createRequest.isPending}
onRequest={submitRequest}
/>
) : discovery.isLoading ? (
<DiscoveryCarouselSkeleton />
) : discovery.isError ? (
<EmptyPanel
title="Discovery is offline"
detail="TMDB couldn't be reached. Try the search bar above, or refresh in a moment."
/>
) : (
<div className="space-y-10">
{(discovery.data ?? []).map((section) => (
<DiscoverySectionRow
key={section.key}
section={section}
pendingRequestKey={pendingRequestKey}
isSubmitting={createRequest.isPending}
onRequest={submitRequest}
/>
))}
<BrandCarousel
kind="studio"
title="Studios"
cards={studios.data}
isLoading={studios.isLoading}
isError={studios.isError}
onRetry={() => void studios.refetch()}
/>
<BrandCarousel
kind="network"
title="Networks"
cards={networks.data}
isLoading={networks.isLoading}
isError={networks.isError}
onRetry={() => void networks.refetch()}
/>
<BrandCarousel
kind="genre"
title="Genres"
cards={genres.data}
isLoading={genres.isLoading}
isError={genres.isError}
onRetry={() => void genres.refetch()}
/>
</div>
)}
</TabsContent>
<TabsContent value="yours" className="space-y-8 pt-2">
{mine.isLoading ? (
<DiscoveryCarouselSkeleton />
) : mine.isError ? (
<EmptyPanel
title="Couldn't load your requests"
detail="Refresh in a moment, or check back later."
/>
) : totalMine === 0 ? (
<EmptyMineState />
) : (
<>
<MineSummary counts={mineCounts} />
<RequestStatusGuide />
<div className="space-y-10">
{(Object.keys(MINE_BUCKET_META) as MineBucketKey[]).map((key) => {
const items = buckets[key];
if (items.length === 0) return null;
return <MineBucketRow key={key} bucket={key} requests={items} />;
})}
</div>
</>
)}
</TabsContent>
</Tabs>
</div>
);
}
function RequestStatusGuide() {
return (
<section className="px-4 sm:px-6 lg:px-10 xl:px-12" aria-labelledby="request-status-guide">
<div className="border-border/60 bg-card/40 rounded-2xl border px-4 py-4 sm:px-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-1 lg:w-[220px] lg:shrink-0">
<h2 id="request-status-guide" className="text-foreground text-sm font-semibold">
Status guide
</h2>
<p className="text-muted-foreground text-[13px] leading-5">
Statuses update automatically as Silo checks the library and connected request
integrations.
</p>
</div>
<div className="grid min-w-0 flex-1 gap-5 md:grid-cols-2">
<StatusGuideGroup title="Request progress">
{REQUEST_PROGRESS_GUIDE.map((item) => (
<StatusGuideRow
key={item.status}
label={formatRequestStatus(item.status)}
description={item.description}
tone={item.tone}
/>
))}
</StatusGuideGroup>
<StatusGuideGroup title="Needs attention">
{REQUEST_ISSUE_GUIDE.map((item) => (
<StatusGuideRow
key={item.outcome}
label={item.label}
description={item.description}
tone={item.tone}
/>
))}
</StatusGuideGroup>
</div>
</div>
</div>
</section>
);
}
function StatusGuideGroup({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="space-y-2">
<h3 className="text-muted-foreground text-xs font-medium">{title}</h3>
<dl className="space-y-2">{children}</dl>
</section>
);
}
function StatusGuideRow({
label,
description,
tone,
}: {
label: string;
description: string;
tone: string;
}) {
return (
<div className="grid gap-1 sm:grid-cols-[118px_minmax(0,1fr)] sm:items-start sm:gap-3">
<dt>
<span
className={cn(
"inline-flex max-w-full items-center rounded-full px-2 py-0.5 text-[11px] leading-5 font-semibold ring-1",
tone,
)}
>
{label}
</span>
</dt>
<dd className="text-muted-foreground text-[13px] leading-5">{description}</dd>
</div>
);
}
function PageHeader() {
return (
<header className="space-y-3">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-amber-300/80" />
<span className="text-muted-foreground text-[11px] font-semibold tracking-[0.22em] uppercase">
Your wishlist
</span>
</div>
<h1 className="font-display text-foreground text-[clamp(1.8rem,3vw,2.6rem)] leading-[1.05] font-bold tracking-tight text-balance">
Find something worth waiting for.
</h1>
<p className="text-muted-foreground max-w-xl text-sm leading-6">
Browse what's trending, search the full TMDB catalog, and watch your requests move from{" "}
<span className="text-foreground">pending</span> to{" "}
<span className="text-foreground">ready</span>.
</p>
</header>
);
}
function SearchBar({
mediaType,
onMediaTypeChange,
searchInput,
onSearchInputChange,
onSubmit,
onClear,
isSearching,
}: {
mediaType: RequestSearchMediaType;
onMediaTypeChange: (value: RequestSearchMediaType) => void;
searchInput: string;
onSearchInputChange: (value: string) => void;
onSubmit: (event: FormEvent) => void;
onClear: () => void;
isSearching: boolean;
}) {
return (
<form
onSubmit={onSubmit}
className="border-border/70 bg-card/60 grid items-center gap-2 rounded-2xl border p-2 shadow-[0_1px_0_0_rgba(255,255,255,0.04)_inset,0_20px_50px_-30px_rgba(0,0,0,0.7)] backdrop-blur-sm sm:grid-cols-[150px_minmax(0,1fr)_auto]"
>
<Select
value={mediaType}
onValueChange={(value) => onMediaTypeChange(value as RequestSearchMediaType)}
>
<SelectTrigger className="border-border/60 bg-background/40 h-10 w-full rounded-xl border text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="movie">Movies</SelectItem>
<SelectItem value="series">Series</SelectItem>
</SelectContent>
</Select>
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3.5 h-4 w-4 -translate-y-1/2" />
<Input
value={searchInput}
onChange={(event) => 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) && (
<button
type="button"
onClick={onClear}
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-2.5 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full transition-colors"
aria-label="Clear search"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<Button
type="submit"
disabled={searchInput.trim().length < 2}
className="h-10 rounded-xl px-5"
>
Search
</Button>
</form>
);
}
function DiscoverySectionRow({
section,
pendingRequestKey,
isSubmitting,
onRequest,
}: {
section: RequestDiscoverySection;
pendingRequestKey?: string;
isSubmitting: boolean;
onRequest: (item: RequestMediaResult) => void;
}) {
const eyebrow = sectionEyebrow(section.key);
return (
<section className="space-y-1">
<div className="px-4 sm:px-6 lg:px-10 xl:px-12">
<span className="text-muted-foreground text-[10px] font-semibold tracking-[0.22em] uppercase">
{eyebrow}
</span>
</div>
<MediaCarousel title={section.title}>
{section.results.map((item) => (
<RequestPosterCard
key={`${item.media_type}-${item.tmdb_id}`}
variant="discover"
item={item}
isSubmitting={
isSubmitting && pendingRequestKey === mediaRequestKey(item.media_type, item.tmdb_id)
}
onRequest={() => onRequest(item)}
/>
))}
</MediaCarousel>
</section>
);
}
function MineBucketRow({ bucket, requests }: { bucket: MineBucketKey; requests: MediaRequest[] }) {
const meta = MINE_BUCKET_META[bucket];
return (
<section className="space-y-1">
<div className="px-4 sm:px-6 lg:px-10 xl:px-12">
<span className={cn("text-[10px] font-semibold tracking-[0.22em] uppercase", meta.accent)}>
{meta.eyebrow}
</span>
</div>
<MediaCarousel title={meta.title}>
{requests.map((request) => (
<RequestPosterCard key={request.id} variant="mine" request={request} />
))}
</MediaCarousel>
</section>
);
}
function SearchResultsView({
query,
mediaType,
page,
onPageChange,
isLoading,
isError,
totalPages,
totalResults,
results,
pendingRequestKey,
isSubmitting,
onRequest,
}: {
query: string;
mediaType: RequestSearchMediaType;
page: number;
onPageChange: (page: number) => void;
isLoading: boolean;
isError: boolean;
totalPages: number;
totalResults: number;
results: RequestMediaResult[];
pendingRequestKey?: string;
isSubmitting: boolean;
onRequest: (item: RequestMediaResult) => void;
}) {
const typeLabel =
mediaType === "series" ? "series" : mediaType === "movie" ? "movies" : "movies and series";
const filterLabel = mediaType === "series" ? "Series" : mediaType === "movie" ? "Movies" : "All";
const shown = results.length;
const showCount = !isLoading && !isError && shown > 0;
return (
<div className="space-y-6 px-4 sm:px-6 lg:px-10 xl:px-12">
<header className="border-border/50 flex flex-col gap-2 border-b pb-4">
<span className="text-muted-foreground text-[10px] font-semibold tracking-[0.24em] uppercase">
Search results · {filterLabel}
</span>
<div className="flex flex-wrap items-end justify-between gap-x-6 gap-y-2">
<h2 className="font-display text-foreground text-[clamp(1.4rem,2.2vw,1.9rem)] leading-[1.1] font-bold tracking-tight">
<span className="text-muted-foreground/50 font-normal"></span>
{query}
<span className="text-muted-foreground/50 font-normal"></span>
</h2>
{showCount && (
<span className="text-muted-foreground text-[12px] tabular-nums">
{totalResults > 0 ? (
<>
<span className="text-foreground/90 font-semibold">
{totalResults.toLocaleString()}
</span>{" "}
{totalResults === 1 ? typeLabel.slice(0, -1) : typeLabel}
{totalPages > 1 ? (
<span className="text-muted-foreground/70">
{" · "}
Page {page} of {totalPages}
</span>
) : null}
</>
) : (
<>
{shown} on this page
{totalPages > 1 ? (
<span className="text-muted-foreground/70">
{" · "}
Page {page} of {totalPages}
</span>
) : null}
</>
)}
</span>
)}
</div>
</header>
{isError ? (
<EmptyPanel
title="Search failed"
detail="TMDB search couldn't be loaded. Try again in a moment."
/>
) : isLoading ? (
<SearchGridSkeleton />
) : results.length === 0 ? (
<EmptyPanel
title="Nothing found"
detail={
mediaType === "all"
? `No movies or series matched "${query}". Try a different spelling.`
: `No ${typeLabel} matched "${query}". Try a different spelling or switch to ${mediaType === "series" ? "Movies" : "Series"}.`
}
/>
) : (
<>
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
{results.map((item) => (
<RequestPosterCard
key={`${item.media_type}-${item.tmdb_id}`}
variant="discover"
item={item}
isSubmitting={
isSubmitting &&
pendingRequestKey === mediaRequestKey(item.media_type, item.tmdb_id)
}
onRequest={() => onRequest(item)}
fluid
/>
))}
</div>
{totalPages > 1 && (
<div className="border-border/50 flex items-center justify-between gap-3 border-t pt-4">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(Math.max(1, page - 1))}
disabled={page <= 1 || isLoading}
>
Previous
</Button>
<span className="text-muted-foreground text-xs tabular-nums">
Page <span className="text-foreground font-semibold">{page}</span> of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages || isLoading}
>
Next
</Button>
</div>
)}
</>
)}
</div>
);
}
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 (
<div className="flex flex-wrap items-center gap-2 px-4 sm:px-6 lg:px-10 xl:px-12">
{chips.map((chip) => (
<span
key={chip.label}
className={cn(
"inline-flex items-center gap-2 rounded-full px-3 py-1 text-[12px] font-medium ring-1",
chip.tone,
)}
>
<span className="text-foreground/95 tabular-nums">{chip.value}</span>
<span className="opacity-80">{chip.label}</span>
</span>
))}
</div>
);
}
function EmptyMineState() {
return (
<div className="border-border/60 bg-card/40 mx-4 flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed px-6 py-16 text-center sm:mx-6 lg:mx-10 xl:mx-12">
<Sparkles className="h-6 w-6 text-amber-300/70" />
<p className="text-foreground text-base font-semibold">Your wishlist is empty.</p>
<p className="text-muted-foreground max-w-sm text-sm">
Browse the Discover tab or search above. The moment you request something, it'll show up
here with live status.
</p>
</div>
);
}
function EmptyPanel({ title, detail }: { title: string; detail: string }) {
return (
<div className="border-border/60 bg-card/40 mx-4 flex flex-col items-center justify-center gap-2 rounded-2xl border border-dashed px-6 py-12 text-center sm:mx-6 lg:mx-10 xl:mx-12">
<p className="text-foreground text-sm font-semibold">{title}</p>
<p className="text-muted-foreground max-w-sm text-sm leading-6">{detail}</p>
</div>
);
}
function DiscoveryCarouselSkeleton() {
return (
<div className="space-y-10">
{Array.from({ length: 3 }).map((_, sectionIndex) => (
<section key={sectionIndex} className="space-y-3">
<div className="px-4 sm:px-6 lg:px-10 xl:px-12">
<Skeleton className="h-3 w-24 rounded" />
<Skeleton className="mt-2 h-6 w-44 rounded" />
</div>
<div className="flex gap-4 overflow-hidden px-4 sm:px-6 lg:px-10 xl:px-12">
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="w-[148px] shrink-0 sm:w-[164px] lg:w-[184px]">
<Skeleton className="aspect-[2/3] w-full rounded-xl" />
<Skeleton className="mt-2 h-4 w-3/4 rounded" />
<Skeleton className="mt-1 h-3 w-1/2 rounded" />
</div>
))}
</div>
</section>
))}
</div>
);
}
function SearchGridSkeleton() {
return (
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
{Array.from({ length: 16 }).map((_, i) => (
<div key={i}>
<Skeleton className="aspect-[2/3] w-full rounded-lg" />
<Skeleton className="mt-2 h-4 w-3/4 rounded" />
</div>
))}
</div>
);
}
function sectionEyebrow(key: string): string {
if (key.startsWith("trending")) return "Trending now";
if (key.startsWith("popular")) return "Crowd favorites";
return "Discover";
}
function normalizeRequestTab(value: string | null): RequestTab {
if (value === "mine") return "yours";
if (REQUEST_TABS.includes(value as RequestTab)) return value as RequestTab;
return "discover";
}
function normalizeRequestMediaType(value: string | null): RequestSearchMediaType {
if (value === "movie" || value === "series") return value;
return "all";
}
function normalizeSearchPage(value: string | null): number {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 1) return 1;
return parsed;
}
function mediaRequestKey(mediaType: RequestMediaResult["media_type"], tmdbID: number): string {
return `${mediaType}-${tmdbID}`;
}
function isIssueOutcome(outcome: MediaRequestOutcome): boolean {
return outcome === "declined" || outcome === "cancelled" || outcome === "failed";
}
function groupMineRequests(requests: MediaRequest[]) {
const buckets: Record<MineBucketKey, MediaRequest[]> = {
motion: [],
completed: [],
issues: [],
};
for (const request of requests) {
if (isIssueOutcome(request.outcome)) {
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 (isIssueOutcome(request.outcome)) {
issues += 1;
} else if (request.status === "completed") {
completed += 1;
} else if (request.status === "pending") {
pending += 1;
} else {
inFlight += 1;
}
}
return { pending, inFlight, completed, issues };
}