Merge pull request #7 from Silo-Server/t3code/discover-studios-networks-genres-clean
feat(requests): add media request system
This commit is contained in:
Executable
+6
@@ -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,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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,618 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
|
||||
)
|
||||
|
||||
type RequestService interface {
|
||||
Search(ctx context.Context, viewer mediarequests.Viewer, query string, mediaType mediarequests.MediaType, page int) (*mediarequests.MediaPage, error)
|
||||
Discover(ctx context.Context, viewer mediarequests.Viewer, section string, page int) (*mediarequests.DiscoverySection, error)
|
||||
DiscoverAll(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverySection, error)
|
||||
GetDetail(ctx context.Context, viewer mediarequests.Viewer, mediaType mediarequests.MediaType, tmdbID int) (*mediarequests.MediaDetail, error)
|
||||
CreateRequest(ctx context.Context, viewer mediarequests.Viewer, input mediarequests.CreateRequestInput) (*mediarequests.Request, error)
|
||||
ListMine(ctx context.Context, viewer mediarequests.Viewer, filter mediarequests.ListFilter) ([]*mediarequests.Request, error)
|
||||
ListAdmin(ctx context.Context, viewer mediarequests.Viewer, filter mediarequests.ListFilter) ([]*mediarequests.Request, error)
|
||||
GetRequest(ctx context.Context, viewer mediarequests.Viewer, id string) (*mediarequests.Request, error)
|
||||
Approve(ctx context.Context, viewer mediarequests.Viewer, id string) (*mediarequests.Request, error)
|
||||
Decline(ctx context.Context, viewer mediarequests.Viewer, id, reason string) (*mediarequests.Request, error)
|
||||
Cancel(ctx context.Context, viewer mediarequests.Viewer, id, reason string) (*mediarequests.Request, error)
|
||||
Retry(ctx context.Context, viewer mediarequests.Viewer, id string) (*mediarequests.Request, error)
|
||||
GetSettings(ctx context.Context, viewer mediarequests.Viewer) (mediarequests.Settings, error)
|
||||
UpdateSettings(ctx context.Context, viewer mediarequests.Viewer, settings mediarequests.Settings) (mediarequests.Settings, error)
|
||||
GetUserLimit(ctx context.Context, viewer mediarequests.Viewer, userID int) (*mediarequests.UserLimit, error)
|
||||
UpsertUserLimit(ctx context.Context, viewer mediarequests.Viewer, limit mediarequests.UserLimit) (*mediarequests.UserLimit, error)
|
||||
ListIntegrations(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.Integration, error)
|
||||
UpsertIntegration(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error)
|
||||
UpsertIntegrations(ctx context.Context, viewer mediarequests.Viewer, integrations []mediarequests.Integration) ([]mediarequests.Integration, error)
|
||||
LoadIntegrationOptions(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.IntegrationOptions, error)
|
||||
|
||||
ListStudios(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error)
|
||||
ListNetworks(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error)
|
||||
ListGenres(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error)
|
||||
BrowseStudio(ctx context.Context, viewer mediarequests.Viewer, slug, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error)
|
||||
BrowseNetwork(ctx context.Context, viewer mediarequests.Viewer, slug, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error)
|
||||
BrowseGenre(ctx context.Context, viewer mediarequests.Viewer, slug string, mediaType mediarequests.MediaType, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error)
|
||||
}
|
||||
|
||||
type RequestsHandler struct {
|
||||
service RequestService
|
||||
}
|
||||
|
||||
func NewRequestsHandler(service RequestService) *RequestsHandler {
|
||||
return &RequestsHandler{service: service}
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, ok := parsePositiveIntQuery(w, r, "page", 1)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.service.Search(
|
||||
r.Context(),
|
||||
viewer,
|
||||
r.URL.Query().Get("q"),
|
||||
mediarequests.MediaType(r.URL.Query().Get("media_type")),
|
||||
page,
|
||||
)
|
||||
if err != nil {
|
||||
writeRequestServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleDiscover(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sections, err := h.service.DiscoverAll(r.Context(), viewer)
|
||||
if err != nil {
|
||||
writeRequestServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
Sections []mediarequests.DiscoverySection `json:"sections"`
|
||||
}{Sections: sections})
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleDiscoverSection(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, ok := parsePositiveIntQuery(w, r, "page", 1)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
section, err := h.service.Discover(r.Context(), viewer, chi.URLParam(r, "section"), page)
|
||||
if err != nil {
|
||||
writeRequestServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, section)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleListStudios(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
studios, err := h.service.ListStudios(r.Context(), viewer)
|
||||
if err != nil {
|
||||
writeRequestServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
Studios []mediarequests.DiscoverBrandCard `json:"studios"`
|
||||
}{Studios: studios})
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleListNetworks(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
networks, err := h.service.ListNetworks(r.Context(), viewer)
|
||||
if err != nil {
|
||||
writeRequestServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
Networks []mediarequests.DiscoverBrandCard `json:"networks"`
|
||||
}{Networks: networks})
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleListGenres(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
genres, err := h.service.ListGenres(r.Context(), viewer)
|
||||
if err != nil {
|
||||
writeRequestServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
Genres []mediarequests.DiscoverBrandCard `json:"genres"`
|
||||
}{Genres: genres})
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleBrowseStudio(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, ok := parsePositiveIntQuery(w, r, "page", 1)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slug := strings.TrimSpace(chi.URLParam(r, "slug"))
|
||||
sort := strings.TrimSpace(r.URL.Query().Get("sort"))
|
||||
resp, err := h.service.BrowseStudio(r.Context(), viewer, slug, sort, page)
|
||||
if err != nil {
|
||||
writeRequestServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleBrowseNetwork(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, ok := parsePositiveIntQuery(w, r, "page", 1)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slug := strings.TrimSpace(chi.URLParam(r, "slug"))
|
||||
sort := strings.TrimSpace(r.URL.Query().Get("sort"))
|
||||
resp, err := h.service.BrowseNetwork(r.Context(), viewer, slug, sort, page)
|
||||
if err != nil {
|
||||
writeRequestServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleBrowseGenre(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, ok := parsePositiveIntQuery(w, r, "page", 1)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slug := strings.TrimSpace(chi.URLParam(r, "slug"))
|
||||
sort := strings.TrimSpace(r.URL.Query().Get("sort"))
|
||||
mediaType := mediarequests.MediaType(strings.TrimSpace(r.URL.Query().Get("media_type")))
|
||||
resp, err := h.service.BrowseGenre(r.Context(), viewer, slug, mediaType, sort, page)
|
||||
if err != nil {
|
||||
writeRequestServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleGetDetail(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
mediaType := mediarequests.MediaType(strings.TrimSpace(chi.URLParam(r, "media_type")))
|
||||
tmdbID, err := strconv.Atoi(strings.TrimSpace(chi.URLParam(r, "tmdb_id")))
|
||||
if err != nil || tmdbID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid tmdb id")
|
||||
return
|
||||
}
|
||||
detail, err := h.service.GetDetail(r.Context(), viewer, mediaType, tmdbID)
|
||||
if err != nil {
|
||||
writeRequestServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input mediarequests.CreateRequestInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
req, err := h.service.CreateRequest(r.Context(), viewer, input)
|
||||
if err != nil {
|
||||
writeRequestServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, req)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleListMine(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
requests, err := h.service.ListMine(r.Context(), viewer, parseRequestListFilter(r))
|
||||
if err != nil {
|
||||
writeRequestServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
Requests []*mediarequests.Request `json:"requests"`
|
||||
}{Requests: requests})
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req, err := h.service.GetRequest(r.Context(), viewer, chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeRequestServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, req)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleAdminList(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
requests, err := h.service.ListAdmin(r.Context(), viewer, parseRequestListFilter(r))
|
||||
if err != nil {
|
||||
writeRequestServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
Requests []*mediarequests.Request `json:"requests"`
|
||||
}{Requests: requests})
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleApprove(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req, err := h.service.Approve(r.Context(), viewer, chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeRequestServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, req)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleDecline(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if r.Body != nil {
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
}
|
||||
req, err := h.service.Decline(r.Context(), viewer, chi.URLParam(r, "id"), body.Reason)
|
||||
if err != nil {
|
||||
writeRequestServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, req)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleCancel(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if r.Body != nil {
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
}
|
||||
req, err := h.service.Cancel(r.Context(), viewer, chi.URLParam(r, "id"), body.Reason)
|
||||
if err != nil {
|
||||
writeRequestServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, req)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleRetry(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req, err := h.service.Retry(r.Context(), viewer, chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeRequestServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, req)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
settings, err := h.service.GetSettings(r.Context(), viewer)
|
||||
if err != nil {
|
||||
writeRequestServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var settings mediarequests.Settings
|
||||
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
updated, err := h.service.UpdateSettings(r.Context(), viewer, settings)
|
||||
if err != nil {
|
||||
writeRequestServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleListIntegrations(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
integrations, err := h.service.ListIntegrations(r.Context(), viewer)
|
||||
if err != nil {
|
||||
writeRequestServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
Integrations []requestIntegrationResponse `json:"integrations"`
|
||||
}{Integrations: toIntegrationResponses(integrations)})
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleUpdateIntegrations(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Integrations []mediarequests.Integration `json:"integrations"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
updated, err := h.service.UpsertIntegrations(r.Context(), viewer, body.Integrations)
|
||||
if err != nil {
|
||||
writeRequestServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
Integrations []requestIntegrationResponse `json:"integrations"`
|
||||
}{Integrations: toIntegrationResponses(updated)})
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleLoadIntegrationOptions(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var integration mediarequests.Integration
|
||||
if r.Body != nil {
|
||||
if err := json.NewDecoder(r.Body).Decode(&integration); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
}
|
||||
integration.Kind = chi.URLParam(r, "kind")
|
||||
options, err := h.service.LoadIntegrationOptions(r.Context(), viewer, integration)
|
||||
if err != nil {
|
||||
writeRequestServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, options)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleGetUserLimit(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, ok := parsePositivePathInt(w, r, "user_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
limit, err := h.service.GetUserLimit(r.Context(), viewer, userID)
|
||||
if err != nil {
|
||||
writeRequestServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, limit)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleUpdateUserLimit(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, ok := parsePositivePathInt(w, r, "user_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var limit mediarequests.UserLimit
|
||||
if err := json.NewDecoder(r.Body).Decode(&limit); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
limit.UserID = userID
|
||||
updated, err := h.service.UpsertUserLimit(r.Context(), viewer, limit)
|
||||
if err != nil {
|
||||
writeRequestServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
func requestViewer(w http.ResponseWriter, r *http.Request, requireProfile bool) (mediarequests.Viewer, bool) {
|
||||
claims := apimw.GetClaims(r.Context())
|
||||
if claims == nil || claims.UserID == 0 {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
return mediarequests.Viewer{}, false
|
||||
}
|
||||
profileID := strings.TrimSpace(apimw.GetProfileID(r.Context()))
|
||||
if requireProfile && profileID == "" {
|
||||
writeError(w, http.StatusBadRequest, "profile_required", "Profile is required")
|
||||
return mediarequests.Viewer{}, false
|
||||
}
|
||||
return mediarequests.Viewer{
|
||||
UserID: claims.UserID,
|
||||
ProfileID: profileID,
|
||||
IsAdmin: claims.Role == "admin",
|
||||
}, true
|
||||
}
|
||||
|
||||
func parseRequestListFilter(r *http.Request) mediarequests.ListFilter {
|
||||
q := r.URL.Query()
|
||||
limit, _ := strconv.Atoi(q.Get("limit"))
|
||||
offset, _ := strconv.Atoi(q.Get("offset"))
|
||||
return mediarequests.ListFilter{
|
||||
Status: mediarequests.Status(strings.TrimSpace(q.Get("status"))),
|
||||
Outcome: mediarequests.Outcome(strings.TrimSpace(q.Get("outcome"))),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
}
|
||||
|
||||
func parsePositiveIntQuery(w http.ResponseWriter, r *http.Request, key string, fallback int) (int, bool) {
|
||||
raw := strings.TrimSpace(r.URL.Query().Get(key))
|
||||
if raw == "" {
|
||||
return fallback, true
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid "+key)
|
||||
return 0, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func parsePositivePathInt(w http.ResponseWriter, r *http.Request, key string) (int, bool) {
|
||||
value, err := strconv.Atoi(strings.TrimSpace(chi.URLParam(r, key)))
|
||||
if err != nil || value <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid "+key)
|
||||
return 0, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
type requestIntegrationResponse struct {
|
||||
Kind string `json:"kind"`
|
||||
Enabled bool `json:"enabled"`
|
||||
BaseURL string `json:"base_url"`
|
||||
HasAPIKey bool `json:"has_api_key"`
|
||||
RootFolder string `json:"root_folder"`
|
||||
QualityProfileID *int `json:"quality_profile_id,omitempty"`
|
||||
Tags []int `json:"tags"`
|
||||
Options map[string]any `json:"options"`
|
||||
LastCheckAt *time.Time `json:"last_check_at,omitempty"`
|
||||
LastCheckStatus string `json:"last_check_status,omitempty"`
|
||||
LastCheckError string `json:"last_check_error,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func toIntegrationResponses(integrations []mediarequests.Integration) []requestIntegrationResponse {
|
||||
out := make([]requestIntegrationResponse, 0, len(integrations))
|
||||
for _, integration := range integrations {
|
||||
out = append(out, requestIntegrationResponse{
|
||||
Kind: integration.Kind,
|
||||
Enabled: integration.Enabled,
|
||||
BaseURL: integration.BaseURL,
|
||||
HasAPIKey: strings.TrimSpace(integration.APIKeyRef) != "",
|
||||
RootFolder: integration.RootFolder,
|
||||
QualityProfileID: integration.QualityProfileID,
|
||||
Tags: integration.Tags,
|
||||
Options: integration.Options,
|
||||
LastCheckAt: integration.LastCheckAt,
|
||||
LastCheckStatus: integration.LastCheckStatus,
|
||||
LastCheckError: integration.LastCheckError,
|
||||
UpdatedAt: integration.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writeRequestServiceError(w http.ResponseWriter, err error) {
|
||||
var quota mediarequests.QuotaError
|
||||
switch {
|
||||
case errors.As(err, "a):
|
||||
writeJSON(w, http.StatusTooManyRequests, struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Used int `json:"used"`
|
||||
Limit int `json:"limit"`
|
||||
WindowDays int `json:"window_days"`
|
||||
}{
|
||||
Error: "quota_exceeded",
|
||||
Message: "Request quota exceeded",
|
||||
Used: quota.Used,
|
||||
Limit: quota.Limit,
|
||||
WindowDays: quota.WindowDays,
|
||||
})
|
||||
case errors.Is(err, mediarequests.ErrInvalidInput), errors.Is(err, mediarequests.ErrInvalidMediaType):
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
case errors.Is(err, mediarequests.ErrRequestsDisabled):
|
||||
writeError(w, http.StatusForbidden, "requests_disabled", "Requests are disabled")
|
||||
case errors.Is(err, mediarequests.ErrUserBlocked):
|
||||
writeError(w, http.StatusForbidden, "requesting_blocked", "User is blocked from requesting")
|
||||
case errors.Is(err, mediarequests.ErrAlreadyAvailable):
|
||||
writeError(w, http.StatusConflict, "already_available", "Media is already available")
|
||||
case errors.Is(err, mediarequests.ErrAlreadyRequested):
|
||||
writeError(w, http.StatusConflict, "already_requested", "Media is already requested")
|
||||
case errors.Is(err, mediarequests.ErrForbidden):
|
||||
writeError(w, http.StatusForbidden, "forbidden", "Request access denied")
|
||||
case errors.Is(err, mediarequests.ErrNotFound):
|
||||
writeError(w, http.StatusNotFound, "not_found", "Request not found")
|
||||
case errors.Is(err, mediarequests.ErrInvalidState):
|
||||
writeError(w, http.StatusConflict, "invalid_state", "Request is not in a valid state for this action")
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Request operation failed")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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{}{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
Executable
+64
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]" />,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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(" ");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user