feat(requests): multi-instance Sonarr/Radarr routing with HD/4K defaults and anime overrides (#39)

* docs: design spec for multi-instance Sonarr/Radarr request routing

Seerr-style multi-instance arr management inside Silo's request system:
many instances per kind, HD/4K default routing, entitlement-driven
dual-quality fan-out, per-instance anime overrides (keyword 210024),
and a one-to-many media_request_targets model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for multi-instance arr request routing

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): migration for multi-instance arr routing

Adds migration 169 to convert request_integrations from a one-row-per-kind
table keyed on `kind` to a multi-instance table keyed on `id`, with HD/4K
defaults, anime overrides, and a new one-to-many media_request_targets table
for per-quality fulfillment tracking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(requests): instance, target, and dual-quality types

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(requests): id-based integration CRUD

Replace upsert-by-kind (UpsertIntegration/UpsertIntegrations) with
GetIntegration, CreateIntegration, UpdateIntegration, DeleteIntegration,
and ClearDefault. Rewrites scanIntegration and integrationColumns to cover
all new multi-instance columns (id, name, is_4k, is_default, is_default_4k,
anime_* fields). Updates the Store interface accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(requests): target persistence and aggregate status

* feat(tmdb): expose keyword ids and original language on detail

* feat(requests): Seerr-exact anime detection (keyword 210024)

* feat(requests): quality/anime routing engine

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): force_dual_quality setting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(requests): multi-target fulfillment, reconcile, retry, and instance CRUD

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): request integration CRUD endpoints, targets in responses, entitlement wiring

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): multi-instance request integration types and CRUD hooks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(web): multi-instance arr manager, dual-quality toggle, per-target queue

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): UX review fixes for arr manager (delete confirm, switch hints, test feedback, dirty + target status)

* fix(requests): address code-review findings (test-connection by id, HD-only default ceiling, retryable partial failure, idempotent submit, transactional defaults, presence/target reconcile, auto-approve gate)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: address CodeRabbit review (anime override fallback, non-null slices, save gate, a11y, DeleteTarget not-found)

- routing: anime fields only override standard root/profile/tags when set,
  so enabling anime with blank fields reuses standard values instead of
  clearing them into an invalid submission
- api: normalize nil Tags/AnimeTags to [] so they serialize as arrays not null
- web: require an API key before saving a NEW instance; add aria-expanded/
  aria-controls to the anime-overrides disclosure toggle
- repo: DeleteTarget returns ErrNotFound when no row was deleted

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): address PR review findings

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
This commit is contained in:
fluxis
2026-06-02 11:25:18 -04:00
committed by GitHub
co-authored by Claude Opus 4.8 Quick
parent a6b510c7c5
commit ea3b5a2e29
27 changed files with 4257 additions and 514 deletions
+9
View File
@@ -29,6 +29,7 @@ import (
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/activitylog"
"github.com/Silo-Server/silo-server/internal/adminjob"
"github.com/Silo-Server/silo-server/internal/api"
@@ -1320,6 +1321,14 @@ func main() {
)
requestReconcileSvc.SetSecretResolver(settingsRepo)
requestReconcileSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil))
if userStoreProvider != nil {
reconcileResolver := access.NewResolver(
auth.NewUserRepository(deps.DB),
userStoreProvider,
access.NewProfileTokenService(cfg.Auth.JWTSecret, 0),
)
requestReconcileSvc.SetEntitlementResolver(mediarequests.NewAccessEntitlements(reconcileResolver))
}
taskMgr.Register(tasks.NewReconcileRequestsTask(requestReconcileSvc, 100))
reconcileProviderIDRepo := catalog.NewProviderIDRepository(deps.DB)
reconcileEpisodeRepo := catalog.NewEpisodeRepository(deps.DB)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,353 @@
# Multi-Instance Sonarr/Radarr Routing for Requests (Seerr-style)
**Status:** Design approved, ready for implementation plan
**Date:** 2026-06-01
**Area:** `internal/requests`, `migrations/`, `web/src` (admin settings + request queue)
## Summary
Silo's request system today fulfills every approved request through a single
Radarr (movies) or single Sonarr (series) instance, at whatever quality that
one instance's profile is configured for. There is no 4K concept, no anime
handling, and exactly one instance per kind (`request_integrations.kind` is the
primary key).
This work replicates Seerr's Sonarr/Radarr management model *inside* Silo's own
request system (no external Seerr dependency):
- **Many instances per kind** — N Radarr and N Sonarr servers, each with its own
connection, root folder, quality profile, and tags.
- **Default HD + Default 4K routing** — one HD-default and one 4K-default
instance per kind; requests route to the matching default(s).
- **Per-server anime overrides** (Overseerr-style) — each instance carries an
optional anime profile / root folder / tags applied when a title is detected
as anime.
- **Entitlement-driven dual-quality fan-out** — a single user request can be
fulfilled in **both** 1080p and 2160p when the requester's `MaxPlaybackQuality`
allows 4K, or when an admin global toggle forces it for everyone.
The end-user request action is unchanged: quality is derived from the user's
existing entitlement, not a per-request choice.
## Goals
- Support multiple Radarr/Sonarr instances per kind.
- Route by quality (HD vs 4K) to per-kind default instances.
- Fan a single request out to both 1080p and 2160p targets based on the
requester's `MaxPlaybackQuality` entitlement, with an admin override that
forces dual-quality regardless of role.
- Apply per-instance anime overrides using Seerr-exact anime detection.
- Keep the request lifecycle (quotas, approval, reconcile) coherent under
one-to-many fulfillment.
## Non-Goals
- No integration *with* a Seerr instance (an earlier idea, dropped). Silo
remains the system of record.
- No per-request manual server/profile picker (Overseerr's advanced UI). Routing
is automatic from defaults + entitlement.
- No separate 1080p/4K *availability* tracking. The pre-request presence check
stays binary ("is this title in the Silo library at all").
- No admin-configurable anime keyword list (the heuristic is isolated so one can
be added later).
- No dedicated-anime-instance flag — anime is handled by per-instance overrides
(decided during design).
## Background: current flow
When a user requests a movie today (`internal/requests/service.go`):
1. **Browse/discover** via TMDB; each result carries a `RequestState`.
2. `CreateRequest` validates: requests enabled, not already available
(presence), not already actively requested (dedup by `tmdb_id`), computes the
requester's `EffectivePolicy` (quota, blocked, auto-approve), enforces quota.
3. Auto-approve users with a configured integration → row created `approved`,
else `pending` for the admin queue.
4. `submitApprovedRequest` → `integrationForMediaType(mediaType)` finds the
single instance by kind → `radarr.Client.SubmitMovie` POSTs to Radarr using
that instance's root folder / quality profile / tags → request goes `queued`.
5. `ReconcileRequests` polls the instance: `queued → downloading → completed`
(or `failed`).
The single insertion point that changes is step 4's instance selection.
## Design
### 1. Data model
**`request_integrations` — reworked from one-row-per-kind to many instances:**
| column | purpose |
|---|---|
| `id` (text PK, via `idgen`) | replaces `kind` as PK |
| `kind` (text, `radarr`\|`sonarr`) | now a plain column, keeps its CHECK |
| `name` (text) | admin-facing label |
| `enabled` (bool) | |
| `base_url`, `api_key_ref` | connection; API key stays a Fernet secret ref, never plaintext |
| `root_folder`, `quality_profile_id`, `tags[]` | standard (non-anime) defaults |
| `is_4k` (bool) | marks this as a 4K server |
| `is_default` (bool) | the HD default for its kind |
| `is_default_4k` (bool) | the 4K default for its kind |
| `anime_enabled` (bool) | apply anime overrides on this instance |
| `anime_quality_profile_id`, `anime_root_folder`, `anime_tags[]` | anime overrides |
| `options` (jsonb) | kind-specific extras (Radarr min-availability, Sonarr season-folders/series-type) |
| `last_check_*`, `created_at`, `updated_at` | unchanged |
**Invariants** (partial unique indexes + service validation):
- At most one `is_default` per kind; at most one `is_default_4k` per kind.
- `is_default ⇒ NOT is_4k` and `is_default_4k ⇒ is_4k`. The HD default is an HD
server; the 4K default is a 4K server. HD and 4K defaults are therefore always
distinct instances.
**New `media_request_targets`:**
| column | purpose |
|---|---|
| `id` (bigint identity PK) | |
| `request_id` (text → `media_requests` ON DELETE CASCADE) | |
| `integration_id` (text → `request_integrations` ON DELETE SET NULL) | |
| `integration_kind` (text snapshot) | survives instance deletion for history |
| `quality` (text, `1080p`\|`2160p`) | reuses `internal/access` presets |
| `is_anime` (bool) | which profile set was applied |
| `external_id`, `external_status`, `status`, `last_error` | per-target Radarr/Sonarr lifecycle |
| `created_at`, `updated_at` | |
| UNIQUE `(request_id, quality)` | one target per quality per request |
**`media_requests` changes:** the per-fulfillment columns
(`integration_kind`, `external_id`, `external_status`) move out to
`media_request_targets`. Add `is_anime` (bool). The request's `status` becomes an
**aggregate** over its targets (see §3). `idx_media_requests_active_tmdb` stays —
dedup is still one active request per title regardless of quality, and a
dual-quality request counts as **one** row against the user's quota.
### 2. Routing engine
A pure function `routeTargets(req, entitlement, settings, instances) -> []plannedTarget`
replaces `integrationForMediaType` as the selection point in
`submitApprovedRequest`.
**Inputs:** the request (incl. detected `is_anime`), the requester's
`MaxPlaybackQuality`, the global `force_dual_quality` setting, the configured
instances for the kind.
**Algorithm:**
1. **Kind** = `movie → radarr`, `series → sonarr`.
2. **Desired qualities:**
- `1080p` is always desired (baseline for everyone).
- Add `2160p` if `access.QualityAllowed("2160p", user.MaxPlaybackQuality)`
**OR** `force_dual_quality` is on (the toggle overrides role).
3. **Pick the default instance per desired quality:** `1080p → is_default`,
`2160p → is_default_4k` (for that kind).
4. **Profile selection per chosen instance:** if `req.is_anime` **and** the
instance has `anime_enabled` → use its anime profile / root folder / tags
(and, for Sonarr, `seriesType: anime`); otherwise the standard set.
5. Emit one `plannedTarget{instance, quality, isAnime, profile, rootFolder, tags}`
per resolved quality.
**Edge cases:**
- A desired quality with **no default instance assigned** is silently skipped
(cannot route 4K without a 4K default — matches "when both are assigned").
- **Zero** resolved targets (e.g. no HD default configured): the request stays
`approved` but unfulfilled, with `last_error` = "no Radarr/Sonarr instance
configured for this quality" so it surfaces in the admin queue.
- No dedup needed: the `is_default`/`is_default_4k` invariants guarantee HD and
4K defaults are distinct instances.
### 3. Fulfillment & reconcile lifecycle
The adapter interfaces (`SubmitMovie`/`SubmitSeries`,
`CheckMovieStatus`/`CheckSeriesStatus`) are **unchanged** — they already take
`(req, integration)`. We call them **once per target**, passing a *resolved*
`Integration` whose `RootFolder`/`QualityProfileID`/`Tags` are filled from the
standard or anime block. Radarr/Sonarr's notion of "4K" is just that instance's
quality profile + root folder, so adapters stay quality-agnostic. (The Sonarr
adapter additionally sets `seriesType: anime` for anime targets.)
**On approval (`submitApprovedRequest`):**
1. `routeTargets(...)` → planned targets.
2. For each: insert a `media_request_targets` row, then call the matching
adapter with the resolved instance + decrypted key.
3. On success → target `queued` with its `external_id`/`external_status`; on
error → target `failed` + `last_error`.
4. **Recompute the request aggregate** in the same transaction as the target
writes.
**Reconcile loop (`ReconcileRequests`):** for each candidate request, iterate its
**non-terminal targets**, load each target's instance by `integration_id`, call
the status adapter, update the target, then recompute the aggregate. Targets
reconcile independently.
**Aggregate recompute (single source of truth):**
- all targets `completed` → request `completed` (+ `completed_at`)
- any target `downloading` → `downloading`; else any `queued` → `queued`
- all targets `failed` → `outcome = failed`; **some** failed while others active
→ request stays active, `last_error` set from the failed target(s)
**Retry** is target-aware: an admin retry re-submits only the `failed` targets
(re-routing if instance config changed), leaving healthy targets alone.
### 4. Anime detection (Seerr-exact)
Detection matches upstream Seerr exactly — a single TMDB keyword id, no genre or
language fallback:
- `server/api/themoviedb/constants.ts`: `ANIME_KEYWORD_ID = 210024`
- `server/entity/MediaRequest.ts`: anime ⇔ `keywords.results` contains that id.
Implementation:
- A named constant `animeKeywordID = 210024`.
- `detectAnime(detail) bool` → true iff the TMDB keyword **ids** contain
`210024`. Matching by id (not the name string) avoids false positives from
neighboring keywords like "based on anime".
- Detected once at `CreateRequest` time and stored on `media_requests.is_anime`,
so routing, reconcile, and retries stay consistent.
- Applies to both movies and series; ignored if no instance for that kind has
`anime_enabled`.
**Supporting TMDB-client change:** extend `tmdb.MediaDetail` to carry keyword
**ids** (add `append_to_response=keywords` to the detail fetch). `GetMediaDetail`
is already called in the request path, so no extra round-trip beyond the append.
(`OriginalLanguage` is not needed under the parity-only decision but may be
mapped opportunistically since it is already in the response.)
The heuristic is isolated in one function so an admin-configurable keyword list
can be added later without touching routing.
### 5. Admin UI
`web/src/pages/admin-settings/IntegrationsSettings.tsx` and the setup wizard
`web/src/pages/setup-wizard/steps/IntegrationsStep.tsx` move from a single
Radarr/Sonarr form to an **instance-list manager** per kind:
**Per kind — list of instance cards** (add / edit / delete):
- Connection: name, base URL, API key (write-only; shows "configured" once set,
never echoes the secret), **Test connection** → `LoadIntegrationOptions`
populates root-folder and quality-profile **dropdowns**.
- Standard block: root folder, quality profile, tags.
- Quality role: `is_4k` switch, **Default (HD)** toggle, **Default 4K** toggle.
The form enforces invariants client-side (no Default-HD on a 4K server;
selecting a new default clears the prior one visually); server is source of
truth.
- Anime block (collapsible, `anime_enabled`): anime quality profile, anime root
folder, anime tags — dropdowns from the same test-connection options.
**Request settings panel** gains the **"Always fulfill in both 1080p and 4K"**
toggle (`force_dual_quality`) with helper text: applies to all requests when both
a Default HD and Default 4K instance exist, regardless of user role.
**`web/src/pages/AdminRequests.tsx`** (queue): each request row expands to show
its **targets** — quality badge (1080p/2160p), instance name, per-target status,
and per-target **retry** on failed ones. The aggregate status stays the headline.
**Setup wizard** stays minimal: add one Radarr + one Sonarr, auto-marked Default
HD. Advanced multi-instance/4K/anime config lives in the full settings page.
### 6. API contract & multi-repo coordination
**End-user request flow is unchanged** — quality is derived from
`MaxPlaybackQuality`, so the request action and payload are identical (no 4K
toggle, no new permission).
**`Request` response model** gains `is_anime` and a `targets[]` array
(`quality`, `instance_name`, `status`, `external_status`, `last_error`); the
top-level `integration_kind`/`external_id`/`external_status` are removed in favor
of the aggregate `status` + `targets`.
- Per `CLAUDE.md`'s multi-repo rule: end-user `silo-android` / `silo-apple`
clients show a single aggregate status, so they likely need **no change** —
but this is flagged as explicit Apple/Android follow-up to verify the request
model tolerates the removed fields and ignores/parses the new `targets`.
**Admin endpoints** change from "upsert by kind" to instance CRUD:
- `GET/POST/PUT/DELETE` request integrations **by `id`**; `LoadIntegrationOptions`
(test-connection) stays, keyed by instance.
- Default-toggle handled server-side transactionally (a new HD/4K default clears
the prior one for that kind).
- `request_settings` gains `force_dual_quality`.
- Admin-web-only; no mobile client surface.
### 7. Migration & backfill
Next migration number: **169** (`169_request_multi_instance.{up,down}.sql`).
**Up:**
1. `ALTER request_integrations`: add `id`, `name`, `is_4k`, `is_default`,
`is_default_4k`, `anime_enabled`, `anime_quality_profile_id`,
`anime_root_folder`, `anime_tags[]`. Backfill existing rows: `id = gen`,
`name = initcap(kind)`, `is_default = enabled` (the lone existing instance
becomes that kind's HD default), everything else default/false. Swap the PK
from `kind` to `id`; keep `kind` as a plain column with its CHECK.
2. Partial unique indexes: one `is_default` per kind, one `is_default_4k` per
kind.
3. `CREATE media_request_targets` (per §1).
4. Backfill targets: for any `media_requests` row with an `external_id`, insert a
target `(quality='1080p', is_anime=false, integration_id = that kind's
instance, integration_kind, external_id, external_status, status = request's
current status)`. Rows never submitted get no target yet.
5. `ALTER media_requests`: add `is_anime bool DEFAULT false`; drop
`integration_kind`, `external_id`, `external_status`.
6. `idx_media_requests_active_tmdb` stays unchanged.
**Down:** re-add the three columns to `media_requests` and copy back the **1080p**
target's external fields; drop `media_request_targets`; drop `is_anime`; collapse
`request_integrations` to `kind`-PK by keeping each kind's `is_default` instance
and discarding extras; drop the new columns/indexes.
**Caveat (explicit):** the down migration is **lossy** — rollback discards any
additional instances (beyond one default per kind) and any 4K/anime target
history. This is the unavoidable cost of collapsing a one-to-many back to
one-to-one and is acceptable for a down migration.
**Go-side data access** (implementation, not SQL): `Integration` struct +
`scanIntegration` gain the new fields; the `Store` interface gains target CRUD +
`ListInstances(kind)`; `scanRequest` drops the external fields; `UpsertIntegration`
becomes id-based CRUD with default-toggle handling in-transaction.
### 8. Testing
- **`routeTargets` — table-driven (core of the feature):** matrix of
{1080p vs 2160p entitlement} × {`force_dual_quality` on/off} × {which defaults
exist} × {anime vs not} → expected target set.
- **`detectAnime`:** keyword `210024` present/absent; no name-substring false
positives.
- **Aggregate recompute:** all-complete, partial-failure (request stays active +
`last_error`), all-failed (`outcome=failed`), mixed downloading/queued.
- **Repository:** target CRUD; single-default-per-kind invariant rejects a second
default; migration backfill yields exactly one 1080p target per previously
submitted request.
- **Adapter:** Sonarr `SubmitSeries` sets `seriesType=anime` only for anime
targets on anime-enabled instances.
- **Service:** extend `service_test.go` fakes — a 4K-entitled user's single
request fans out to two adapter submissions with the right resolved instances;
target-scoped retry re-submits only the failed target.
- **Frontend:** instance-form default-invariant enforcement; queue renders
per-target rows + retry.
## Risks & open considerations
- **Reconcile cost** scales with targets, not requests (≤2× today). Bounded and
acceptable; the reconcile loop already batches.
- **Anime keyword imperfection** is inherited from Seerr by design (parity
choice). Mitigated by isolating `detectAnime` for future tuning.
- **Client model drift**: removing top-level external fields from `Request` must
be verified against `silo-android` / `silo-apple` deserialization before
release (flagged in §6).
## References
- Seerr upstream: https://github.com/seerr-team/seerr
(`server/api/themoviedb/constants.ts`, `server/entity/MediaRequest.ts`)
- Overseerr anime discussions: #2876 (Sonarr for anime only), #3777 (configurable
series type)
- Existing code: `internal/requests/{service,repository,types,store}.go`,
`internal/requests/{radarr,sonarr,arrclient}`, `internal/access/quality.go`,
`migrations/139_media_requests.up.sql`
+100 -38
View File
@@ -35,8 +35,9 @@ type RequestService interface {
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)
CreateIntegration(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error)
UpdateIntegration(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error)
DeleteIntegration(ctx context.Context, viewer mediarequests.Viewer, id string) error
LoadIntegrationOptions(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.IntegrationOptions, error)
ListStudios(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error)
@@ -422,26 +423,53 @@ func (h *RequestsHandler) HandleListIntegrations(w http.ResponseWriter, r *http.
}{Integrations: toIntegrationResponses(integrations)})
}
func (h *RequestsHandler) HandleUpdateIntegrations(w http.ResponseWriter, r *http.Request) {
func (h *RequestsHandler) HandleCreateIntegration(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 {
var integration mediarequests.Integration
if err := json.NewDecoder(r.Body).Decode(&integration); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
updated, err := h.service.UpsertIntegrations(r.Context(), viewer, body.Integrations)
created, err := h.service.CreateIntegration(r.Context(), viewer, integration)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, struct {
Integrations []requestIntegrationResponse `json:"integrations"`
}{Integrations: toIntegrationResponses(updated)})
writeJSON(w, http.StatusCreated, requestIntegrationResponseFrom(*created))
}
func (h *RequestsHandler) HandleUpdateIntegration(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
var integration mediarequests.Integration
if err := json.NewDecoder(r.Body).Decode(&integration); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
integration.ID = chi.URLParam(r, "id")
updated, err := h.service.UpdateIntegration(r.Context(), viewer, integration)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, requestIntegrationResponseFrom(*updated))
}
func (h *RequestsHandler) HandleDeleteIntegration(w http.ResponseWriter, r *http.Request) {
viewer, ok := requestViewer(w, r, false)
if !ok {
return
}
if err := h.service.DeleteIntegration(r.Context(), viewer, chi.URLParam(r, "id")); err != nil {
writeRequestServiceError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *RequestsHandler) HandleLoadIntegrationOptions(w http.ResponseWriter, r *http.Request) {
@@ -456,7 +484,9 @@ func (h *RequestsHandler) HandleLoadIntegrationOptions(w http.ResponseWriter, r
return
}
}
integration.Kind = chi.URLParam(r, "kind")
if id := strings.TrimSpace(chi.URLParam(r, "id")); id != "" {
integration.ID = id
}
options, err := h.service.LoadIntegrationOptions(r.Context(), viewer, integration)
if err != nil {
writeRequestServiceError(w, err)
@@ -558,37 +588,69 @@ func parsePositivePathInt(w http.ResponseWriter, r *http.Request, key string) (i
}
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"`
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
Enabled bool `json:"enabled"`
Is4K bool `json:"is_4k"`
IsDefault bool `json:"is_default"`
IsDefault4K bool `json:"is_default_4k"`
AnimeEnabled bool `json:"anime_enabled"`
AnimeQualityProfileID *int `json:"anime_quality_profile_id,omitempty"`
AnimeRootFolder string `json:"anime_root_folder,omitempty"`
AnimeTags []int `json:"anime_tags"`
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 requestIntegrationResponseFrom(integration mediarequests.Integration) requestIntegrationResponse {
// Normalize nil slices so they serialize as [] (not null); the frontend
// types them as number[] and indexes/maps over them.
tags := integration.Tags
if tags == nil {
tags = []int{}
}
animeTags := integration.AnimeTags
if animeTags == nil {
animeTags = []int{}
}
return requestIntegrationResponse{
ID: integration.ID,
Name: integration.Name,
Kind: integration.Kind,
Enabled: integration.Enabled,
Is4K: integration.Is4K,
IsDefault: integration.IsDefault,
IsDefault4K: integration.IsDefault4K,
AnimeEnabled: integration.AnimeEnabled,
AnimeQualityProfileID: integration.AnimeQualityProfileID,
AnimeRootFolder: integration.AnimeRootFolder,
AnimeTags: animeTags,
BaseURL: integration.BaseURL,
HasAPIKey: strings.TrimSpace(integration.APIKeyRef) != "",
RootFolder: integration.RootFolder,
QualityProfileID: integration.QualityProfileID,
Tags: tags,
Options: integration.Options,
LastCheckAt: integration.LastCheckAt,
LastCheckStatus: integration.LastCheckStatus,
LastCheckError: integration.LastCheckError,
UpdatedAt: integration.UpdatedAt,
}
}
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,
})
out = append(out, requestIntegrationResponseFrom(integration))
}
return out
}
+8 -4
View File
@@ -127,12 +127,16 @@ func (f *fakeRequestService) ListIntegrations(context.Context, mediarequests.Vie
return nil, nil
}
func (f *fakeRequestService) UpsertIntegration(context.Context, mediarequests.Viewer, mediarequests.Integration) (*mediarequests.Integration, error) {
return nil, nil
func (f *fakeRequestService) CreateIntegration(_ context.Context, _ mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error) {
return &integration, nil
}
func (f *fakeRequestService) UpsertIntegrations(context.Context, mediarequests.Viewer, []mediarequests.Integration) ([]mediarequests.Integration, error) {
return nil, nil
func (f *fakeRequestService) UpdateIntegration(_ context.Context, _ mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error) {
return &integration, nil
}
func (f *fakeRequestService) DeleteIntegration(context.Context, mediarequests.Viewer, string) error {
return nil
}
func (f *fakeRequestService) LoadIntegrationOptions(context.Context, mediarequests.Viewer, mediarequests.Integration) (*mediarequests.IntegrationOptions, error) {
+7 -2
View File
@@ -410,6 +410,9 @@ func NewRouter(deps Dependencies) chi.Router {
)
requestSvc.SetSecretResolver(settingsRepo)
requestSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil))
if viewerResolver != nil {
requestSvc.SetEntitlementResolver(mediarequests.NewAccessEntitlements(viewerResolver))
}
requestHandler = handlers.NewRequestsHandler(requestSvc)
if deps.PersonRepo != nil {
@@ -2040,8 +2043,10 @@ func NewRouter(deps Dependencies) chi.Router {
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)
r.Post("/request-integrations", requestHandler.HandleCreateIntegration)
r.Put("/request-integrations/{id}", requestHandler.HandleUpdateIntegration)
r.Delete("/request-integrations/{id}", requestHandler.HandleDeleteIntegration)
r.Post("/request-integrations/{id}/options", requestHandler.HandleLoadIntegrationOptions)
}
if deps.ActivityLogRepo != nil {
+36 -20
View File
@@ -827,14 +827,14 @@ func (c *Client) GetMediaDetail(ctx context.Context, mediaType string, id int) (
switch mediaType {
case "movie":
path := fmt.Sprintf("/movie/%d?append_to_response=credits,external_ids,recommendations,release_dates", id)
path := fmt.Sprintf("/movie/%d?append_to_response=credits,external_ids,recommendations,release_dates,keywords", 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)
path := fmt.Sprintf("/tv/%d?append_to_response=credits,external_ids,recommendations,content_ratings,keywords", id)
var resp tvDetailResponse
if err := c.doGet(ctx, path, &resp); err != nil {
return nil, err
@@ -847,24 +847,26 @@ func (c *Client) GetMediaDetail(ctx context.Context, mediaType string, id int) (
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),
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),
OriginalLanguage: resp.OriginalLanguage,
KeywordIDs: keywordIDs(resp.Keywords.Keywords, resp.Keywords.Results),
}
for _, company := range resp.ProductionCompanies {
if name := strings.TrimSpace(company.Name); name != "" {
@@ -923,6 +925,8 @@ func normalizeTVDetail(resp *tvDetailResponse) *MediaDetail {
NumberOfSeasons: resp.NumberOfSeasons,
NumberOfEpisodes: resp.NumberOfEpisodes,
ContentRating: pickTVRating(resp.ContentRatings),
OriginalLanguage: resp.OriginalLanguage,
KeywordIDs: keywordIDs(resp.Keywords.Keywords, resp.Keywords.Results),
}
if len(resp.EpisodeRunTime) > 0 {
detail.Runtime = resp.EpisodeRunTime[0]
@@ -977,6 +981,18 @@ func namesFromGenres(genres []genreEntry) []string {
return out
}
// keywordIDs flattens one or more TMDB keyword lists (movies use the
// "keywords" array, tv uses "results") into their numeric ids.
func keywordIDs(groups ...[]idEntry) []int {
var out []int
for _, g := range groups {
for _, e := range g {
out = append(out, e.ID)
}
}
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.
+51 -33
View File
@@ -191,6 +191,8 @@ type MediaDetail struct {
Homepage string
ContentRating string
ProductionCompanies []string
OriginalLanguage string
KeywordIDs []int
NumberOfSeasons int
NumberOfEpisodes int
@@ -255,6 +257,12 @@ type personEntry struct {
Name string `json:"name"`
}
// idEntry is a generic {id,name} TMDB object used for keyword lists.
type idEntry struct {
ID int `json:"id"`
Name string `json:"name"`
}
type recommendationsMovieResponse struct {
Results []mediaMovieResponse `json:"results"`
}
@@ -288,16 +296,21 @@ type contentRatingEntry struct {
}
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"`
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"`
OriginalLanguage string `json:"original_language"`
Keywords struct {
Keywords []idEntry `json:"keywords"` // movies
Results []idEntry `json:"results"` // tv
} `json:"keywords"`
Genres []genreEntry `json:"genres"`
VoteAverage float64 `json:"vote_average"`
VoteCount int `json:"vote_count"`
@@ -311,27 +324,32 @@ type movieDetailResponse struct {
}
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"`
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"`
OriginalLanguage string `json:"original_language"`
Keywords struct {
Keywords []idEntry `json:"keywords"` // movies
Results []idEntry `json:"results"` // tv
} `json:"keywords"`
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"`
}
+15
View File
@@ -0,0 +1,15 @@
package requests
// animeKeywordID is TMDB's "anime" keyword id. Matches Seerr's ANIME_KEYWORD_ID
// exactly (server/api/themoviedb/constants.ts). Detection is keyword-id only —
// no genre/language fallback — to mirror upstream behavior.
const animeKeywordID = 210024
func detectAnime(keywordIDs []int) bool {
for _, id := range keywordIDs {
if id == animeKeywordID {
return true
}
}
return false
}
+15
View File
@@ -0,0 +1,15 @@
package requests
import "testing"
func TestDetectAnime(t *testing.T) {
if !detectAnime([]int{99, animeKeywordID, 7}) {
t.Fatal("expected anime when keyword 210024 present")
}
if detectAnime([]int{99, 7}) {
t.Fatal("expected non-anime when keyword 210024 absent")
}
if detectAnime(nil) {
t.Fatal("expected non-anime for empty keywords")
}
}
+30
View File
@@ -0,0 +1,30 @@
package requests
import (
"context"
"github.com/Silo-Server/silo-server/internal/access"
)
// accessEntitlements resolves a requester's effective playback-quality ceiling
// (account + profile caps combined) via the shared access resolver.
type accessEntitlements struct {
resolver *access.Resolver
}
// NewAccessEntitlements wraps the shared access resolver as an EntitlementResolver.
func NewAccessEntitlements(resolver *access.Resolver) EntitlementResolver {
return accessEntitlements{resolver: resolver}
}
func (e accessEntitlements) MaxPlaybackQuality(ctx context.Context, userID int, profileID string) (string, error) {
scope, err := e.resolver.Resolve(ctx, access.ResolveInput{
UserID: userID,
ProfileID: profileID,
SkipPINVerification: true,
})
if err != nil {
return "", err
}
return scope.MaxPlaybackQuality, nil
}
+195 -132
View File
@@ -27,10 +27,10 @@ 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
global_auto_approval_enabled, force_dual_quality, updated_at
FROM request_settings
WHERE id = true
`).Scan(&s.RequestsEnabled, &s.GlobalMaxRequests, &s.GlobalWindowDays, &s.GlobalAutoApprovalEnabled, &s.UpdatedAt)
`).Scan(&s.RequestsEnabled, &s.GlobalMaxRequests, &s.GlobalWindowDays, &s.GlobalAutoApprovalEnabled, &s.ForceDualQuality, &s.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Settings{
@@ -57,19 +57,20 @@ func (r *Repository) UpdateSettings(ctx context.Context, settings Settings) (Set
err := r.pool.QueryRow(ctx, `
INSERT INTO request_settings (
id, requests_enabled, global_max_requests, global_window_days,
global_auto_approval_enabled, updated_at
global_auto_approval_enabled, force_dual_quality, updated_at
)
VALUES (true, $1, $2, $3, $4, now())
VALUES (true, $1, $2, $3, $4, $5, 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,
force_dual_quality = EXCLUDED.force_dual_quality,
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)
global_auto_approval_enabled, force_dual_quality, updated_at
`, settings.RequestsEnabled, settings.GlobalMaxRequests, settings.GlobalWindowDays, settings.GlobalAutoApprovalEnabled, settings.ForceDualQuality).
Scan(&s.RequestsEnabled, &s.GlobalMaxRequests, &s.GlobalWindowDays, &s.GlobalAutoApprovalEnabled, &s.ForceDualQuality, &s.UpdatedAt)
if err != nil {
return Settings{}, fmt.Errorf("update request settings: %w", err)
}
@@ -272,6 +273,7 @@ func (r *Repository) CreateRequest(ctx context.Context, input CreateRequestRecor
type requestExecutor interface {
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
}
@@ -296,18 +298,18 @@ func (r *Repository) insertRequest(
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
requested_by_user_id, requested_by_profile_id, is_anime, 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
$13, $14, $15, $16, $16, $17
)
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)
input.Requester.UserID, input.Requester.ProfileID, input.IsAnime, now, approvedAt)
req, err := scanRequest(row)
if err != nil {
return nil, fmt.Errorf("insert request: %w", err)
@@ -417,48 +419,6 @@ func (r *Repository) SetStatus(ctx context.Context, id string, status Status, ac
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 {
@@ -492,13 +452,13 @@ func (r *Repository) SetOutcome(ctx context.Context, id string, outcome Outcome,
return req, nil
}
const integrationColumns = `id, kind, name, enabled, base_url, api_key_ref,
root_folder, quality_profile_id, tags, is_4k, is_default, is_default_4k,
anime_enabled, anime_quality_profile_id, anime_root_folder, anime_tags,
options, last_check_at, last_check_status, last_check_error, updated_at`
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
`)
rows, err := r.pool.Query(ctx, `SELECT `+integrationColumns+` FROM request_integrations ORDER BY kind, name`)
if err != nil {
return nil, fmt.Errorf("list request integrations: %w", err)
}
@@ -518,72 +478,181 @@ func (r *Repository) ListIntegrations(ctx context.Context) ([]Integration, error
return out, nil
}
func (r *Repository) UpsertIntegration(ctx context.Context, integration Integration) (*Integration, error) {
out, err := r.upsertIntegration(ctx, r.pool, integration)
func (r *Repository) GetIntegration(ctx context.Context, id string) (*Integration, error) {
row := r.pool.QueryRow(ctx, `SELECT `+integrationColumns+
` FROM request_integrations WHERE id = $1`, id)
i, err := scanIntegration(row)
if err != nil {
return nil, fmt.Errorf("upsert request integration: %w", err)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("get request integration: %w", err)
}
return out, nil
return &i, nil
}
func (r *Repository) UpsertIntegrations(ctx context.Context, integrations []Integration) ([]Integration, error) {
func (r *Repository) CreateIntegration(ctx context.Context, i Integration) (*Integration, error) {
return r.insertIntegration(ctx, r.pool, i)
}
func (r *Repository) UpdateIntegration(ctx context.Context, i Integration) (*Integration, error) {
return r.updateIntegration(ctx, r.pool, i)
}
// insertIntegration runs the integration INSERT against any executor (pool or
// tx) so the same SQL is reused by the plain create path and the transactional
// SaveIntegrationWithDefaults path.
func (r *Repository) insertIntegration(ctx context.Context, exec requestExecutor, i Integration) (*Integration, error) {
if i.Options == nil {
i.Options = map[string]any{}
}
options, err := json.Marshal(i.Options)
if err != nil {
return nil, fmt.Errorf("marshal options: %w", err)
}
row := exec.QueryRow(ctx, `
INSERT INTO request_integrations (
id, kind, name, enabled, base_url, api_key_ref, root_folder,
quality_profile_id, tags, is_4k, is_default, is_default_4k,
anime_enabled, anime_quality_profile_id, anime_root_folder, anime_tags,
options, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17, now())
RETURNING `+integrationColumns,
i.ID, i.Kind, strings.TrimSpace(i.Name), i.Enabled, strings.TrimSpace(i.BaseURL),
strings.TrimSpace(i.APIKeyRef), strings.TrimSpace(i.RootFolder), i.QualityProfileID,
int32Slice(i.Tags), i.Is4K, i.IsDefault, i.IsDefault4K, i.AnimeEnabled,
i.AnimeQualityProfileID, strings.TrimSpace(i.AnimeRootFolder), int32Slice(i.AnimeTags),
options)
out, err := scanIntegration(row)
if err != nil {
return nil, fmt.Errorf("create request integration: %w", err)
}
return &out, nil
}
// updateIntegration runs the integration UPDATE against any executor (pool or
// tx) so the plain update path and SaveIntegrationWithDefaults share the SQL.
func (r *Repository) updateIntegration(ctx context.Context, exec requestExecutor, i Integration) (*Integration, error) {
if i.Options == nil {
i.Options = map[string]any{}
}
options, err := json.Marshal(i.Options)
if err != nil {
return nil, fmt.Errorf("marshal options: %w", err)
}
row := exec.QueryRow(ctx, `
UPDATE request_integrations SET
name=$2, enabled=$3, base_url=$4,
api_key_ref = CASE WHEN $5 = '' THEN api_key_ref ELSE $5 END,
root_folder=$6, quality_profile_id=$7, tags=$8, is_4k=$9,
is_default=$10, is_default_4k=$11, anime_enabled=$12,
anime_quality_profile_id=$13, anime_root_folder=$14, anime_tags=$15,
options=$16, updated_at=now()
WHERE id=$1
RETURNING `+integrationColumns,
i.ID, strings.TrimSpace(i.Name), i.Enabled, strings.TrimSpace(i.BaseURL),
strings.TrimSpace(i.APIKeyRef), strings.TrimSpace(i.RootFolder), i.QualityProfileID,
int32Slice(i.Tags), i.Is4K, i.IsDefault, i.IsDefault4K, i.AnimeEnabled,
i.AnimeQualityProfileID, strings.TrimSpace(i.AnimeRootFolder), int32Slice(i.AnimeTags),
options)
out, err := scanIntegration(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("update request integration: %w", err)
}
return &out, nil
}
// SaveIntegrationWithDefaults clears the conflicting kind default(s) and
// creates/updates the instance in a single transaction so a save failure can
// never leave the kind with zero defaults.
func (r *Repository) SaveIntegrationWithDefaults(ctx context.Context, in Integration, isCreate bool) (*Integration, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin request integrations transaction: %w", err)
return nil, fmt.Errorf("begin save integration: %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 {
if in.IsDefault {
if err := r.ClearDefault(ctx, tx, in.Kind, false, in.ID); err != nil {
return nil, err
}
out = append(out, *updated)
}
if in.IsDefault4K {
if err := r.ClearDefault(ctx, tx, in.Kind, true, in.ID); err != nil {
return nil, err
}
}
var out *Integration
if isCreate {
out, err = r.insertIntegration(ctx, tx, in)
} else {
out, err = r.updateIntegration(ctx, tx, in)
}
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit request integrations transaction: %w", err)
return nil, fmt.Errorf("commit save integration: %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)
func (r *Repository) DeleteIntegration(ctx context.Context, id string) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("marshal request integration options: %w", err)
return fmt.Errorf("begin delete integration: %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
defer tx.Rollback(ctx)
var lockedID string
if err := tx.QueryRow(ctx, `
SELECT id FROM request_integrations WHERE id = $1 FOR UPDATE
`, id).Scan(&lockedID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return fmt.Errorf("lock request integration: %w", err)
}
var hasLiveTargets bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM media_request_targets
WHERE integration_id = $1 AND status IN ('queued', 'downloading')
)
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
`, id).Scan(&hasLiveTargets); err != nil {
return fmt.Errorf("check integration targets: %w", err)
}
return &out, nil
if hasLiveTargets {
return ErrInvalidState
}
if _, err := tx.Exec(ctx, `DELETE FROM request_integrations WHERE id = $1`, id); err != nil {
return fmt.Errorf("delete request integration: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit delete integration: %w", err)
}
return nil
}
// ClearDefault unsets the HD (or 4K) default flag for every instance of a kind
// except excludeID, so saving an instance that is itself the new default does
// not clear its own freshly-written flag.
func (r *Repository) ClearDefault(ctx context.Context, exec requestExecutor, kind string, fourK bool, excludeID string) error {
col := "is_default"
if fourK {
col = "is_default_4k"
}
_, err := exec.Exec(ctx, `UPDATE request_integrations SET `+col+` = false WHERE kind = $1 AND id <> $2`, kind, excludeID)
if err != nil {
return fmt.Errorf("clear default: %w", err)
}
return nil
}
func (r *Repository) recordEvent(ctx context.Context, exec requestExecutor, requestID, eventType string, actor Viewer, message string) error {
@@ -636,9 +705,8 @@ func requestSelectSQL() string {
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`
requested_by_user_id, requested_by_profile_id, is_anime,
last_error, created_at, updated_at, approved_at, completed_at`
}
type requestScanner interface {
@@ -665,9 +733,7 @@ func scanRequest(row requestScanner) (*Request, error) {
&req.Outcome,
&req.RequestedByUserID,
&req.RequestedByProfileID,
&req.IntegrationKind,
&req.ExternalID,
&req.ExternalStatus,
&req.IsAnime,
&req.LastError,
&req.CreatedAt,
&req.UpdatedAt,
@@ -698,44 +764,41 @@ type integrationScanner interface {
}
func scanIntegration(row integrationScanner) (Integration, error) {
var integration Integration
var quality sql.NullInt64
var tags []int32
var i Integration
var quality, animeQuality sql.NullInt64
var tags, animeTags []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,
&i.ID, &i.Kind, &i.Name, &i.Enabled, &i.BaseURL, &i.APIKeyRef,
&i.RootFolder, &quality, &tags, &i.Is4K, &i.IsDefault, &i.IsDefault4K,
&i.AnimeEnabled, &animeQuality, &i.AnimeRootFolder, &animeTags,
&optionsRaw, &lastCheckAt, &i.LastCheckStatus, &i.LastCheckError, &i.UpdatedAt,
); err != nil {
return Integration{}, err
}
if quality.Valid {
v := int(quality.Int64)
integration.QualityProfileID = &v
i.QualityProfileID = &v
}
integration.Tags = intsFromInt32(tags)
if animeQuality.Valid {
v := int(animeQuality.Int64)
i.AnimeQualityProfileID = &v
}
i.Tags = intsFromInt32(tags)
i.AnimeTags = intsFromInt32(animeTags)
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 err := json.Unmarshal(optionsRaw, &i.Options); err != nil {
return Integration{}, fmt.Errorf("unmarshal request integration options for %s: %w", i.ID, err)
}
}
if integration.Options == nil {
integration.Options = map[string]any{}
if i.Options == nil {
i.Options = map[string]any{}
}
if lastCheckAt.Valid {
integration.LastCheckAt = &lastCheckAt.Time
i.LastCheckAt = &lastCheckAt.Time
}
return integration, nil
return i, nil
}
func int32Slice(values []int) []int32 {
+85
View File
@@ -0,0 +1,85 @@
package requests
import "github.com/Silo-Server/silo-server/internal/access"
// plannedTarget is a routing decision: which instance, at which quality, with
// which profile/folder/tags resolved (standard vs anime).
type plannedTarget struct {
Instance Integration
Quality Quality
IsAnime bool
}
func integrationKindForMediaType(mediaType MediaType) string {
if mediaType == MediaTypeSeries {
return "sonarr"
}
return "radarr"
}
// routeTargets decides the fulfillment targets for an approved request.
// 1080p is always desired; 2160p is added when the requester's ceiling allows
// 4K OR force-dual is on. A quality is emitted only if its default instance
// exists for the kind.
func routeTargets(req Request, ceiling string, settings Settings, instances []Integration) []plannedTarget {
kind := integrationKindForMediaType(req.MediaType)
wants4K := settings.ForceDualQuality || access.CompareQuality(ceiling, access.PlaybackQuality4K) >= 0
var hd, uhd *Integration
for i := range instances {
in := instances[i]
if in.Kind != kind || !in.Enabled {
continue
}
if in.IsDefault && hd == nil {
hd = &instances[i]
}
if in.IsDefault4K && uhd == nil {
uhd = &instances[i]
}
}
var out []plannedTarget
if hd != nil {
out = append(out, plannedTarget{Instance: *hd, Quality: Quality1080p, IsAnime: req.IsAnime && hd.AnimeEnabled})
}
if wants4K && uhd != nil {
out = append(out, plannedTarget{Instance: *uhd, Quality: Quality2160p, IsAnime: req.IsAnime && uhd.AnimeEnabled})
}
return out
}
// resolveInstance returns a copy of the instance with root folder / quality
// profile / tags (and Sonarr series_type) set for standard vs anime fulfillment.
func resolveInstance(pt plannedTarget) Integration {
in := pt.Instance
if in.Options == nil {
in.Options = map[string]any{}
} else {
clone := make(map[string]any, len(in.Options))
for k, v := range in.Options {
clone[k] = v
}
in.Options = clone
}
if pt.IsAnime {
// Anime fields are overrides: only replace the standard value when the
// anime counterpart is set, so an admin can enable anime detection while
// reusing the standard root folder / quality profile / tags for any field
// they leave blank (rather than clearing them into an invalid submission).
if in.AnimeRootFolder != "" {
in.RootFolder = in.AnimeRootFolder
}
if in.AnimeQualityProfileID != nil {
in.QualityProfileID = in.AnimeQualityProfileID
}
if len(in.AnimeTags) > 0 {
in.Tags = in.AnimeTags
}
if in.Kind == "sonarr" {
in.Options["series_type"] = "anime"
}
}
return in
}
+52
View File
@@ -0,0 +1,52 @@
package requests
import "testing"
func inst(kind, id string, def, def4k, anime bool) Integration {
qp := 1
return Integration{
ID: id, Kind: kind, Name: id, Enabled: true, BaseURL: "http://x",
APIKeyRef: "k", RootFolder: "/std", QualityProfileID: &qp,
Is4K: def4k, IsDefault: def, IsDefault4K: def4k, AnimeEnabled: anime,
}
}
func TestRouteTargets(t *testing.T) {
hd := inst("radarr", "hd", true, false, false)
uhd := inst("radarr", "uhd", false, true, false)
hdAnime := inst("radarr", "hda", true, false, true)
cases := []struct {
name string
req Request
ceiling string
force bool
instances []Integration
want []Quality
wantAnime bool
}{
{"hd only, sd user", Request{MediaType: MediaTypeMovie}, "1080p", false, []Integration{hd, uhd}, []Quality{Quality1080p}, false},
{"4k user dual", Request{MediaType: MediaTypeMovie}, "2160p", false, []Integration{hd, uhd}, []Quality{Quality1080p, Quality2160p}, false},
{"force dual overrides role", Request{MediaType: MediaTypeMovie}, "1080p", true, []Integration{hd, uhd}, []Quality{Quality1080p, Quality2160p}, false},
{"4k user but no 4k default", Request{MediaType: MediaTypeMovie}, "2160p", false, []Integration{hd}, []Quality{Quality1080p}, false},
{"no hd default", Request{MediaType: MediaTypeMovie}, "2160p", false, []Integration{uhd}, []Quality{Quality2160p}, false},
{"anime on anime-enabled hd", Request{MediaType: MediaTypeMovie, IsAnime: true}, "1080p", false, []Integration{hdAnime}, []Quality{Quality1080p}, true},
{"empty ceiling hd only", Request{MediaType: MediaTypeMovie}, "", false, []Integration{hd, uhd}, []Quality{Quality1080p}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := routeTargets(tc.req, tc.ceiling, Settings{ForceDualQuality: tc.force}, tc.instances)
if len(got) != len(tc.want) {
t.Fatalf("got %d targets, want %d (%v)", len(got), len(tc.want), got)
}
for i, q := range tc.want {
if got[i].Quality != q {
t.Fatalf("target %d quality = %s, want %s", i, got[i].Quality, q)
}
if got[i].IsAnime != tc.wantAnime {
t.Fatalf("target %d isAnime = %v, want %v", i, got[i].IsAnime, tc.wantAnime)
}
}
})
}
}
+349 -165
View File
@@ -8,6 +8,7 @@ import (
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/idgen"
"github.com/Silo-Server/silo-server/internal/metadata/tmdb"
"golang.org/x/sync/errgroup"
@@ -54,6 +55,13 @@ type SeriesIntegrationOptionsAdapter interface {
ListSeriesIntegrationOptions(ctx context.Context, integration Integration) (*IntegrationOptions, error)
}
type EntitlementResolver interface {
// MaxPlaybackQuality returns the requester's effective playback-quality
// ceiling (already combining account- and profile-level caps). Empty string
// means "no cap".
MaxPlaybackQuality(ctx context.Context, userID int, profileID string) (string, error)
}
type Service struct {
store Store
tmdb TMDBClient
@@ -61,6 +69,7 @@ type Service struct {
secrets SecretResolver
movieAdapter MovieFulfillmentAdapter
seriesAdapter SeriesFulfillmentAdapter
entitlements EntitlementResolver
Now func() time.Time
}
@@ -91,6 +100,19 @@ func (s *Service) SetFulfillmentAdapters(movie MovieFulfillmentAdapter, series S
s.seriesAdapter = series
}
func (s *Service) SetEntitlementResolver(r EntitlementResolver) { s.entitlements = r }
func (s *Service) requesterCeiling(ctx context.Context, userID int, profileID string) string {
if s.entitlements == nil {
return "" // no resolver -> unlimited (1080p baseline still applies)
}
q, err := s.entitlements.MaxPlaybackQuality(ctx, userID, profileID)
if err != nil {
return access.PlaybackQualityStandard // fail safe: HD only
}
return q
}
func (s *Service) Search(ctx context.Context, viewer Viewer, query string, mediaType MediaType, page int) (*MediaPage, error) {
if s == nil || s.store == nil || s.tmdb == nil {
return nil, fmt.Errorf("request service is not configured")
@@ -282,6 +304,7 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create
return nil, err
}
s.enrichExternalIDs(ctx, &normalized)
isAnime := s.detectRequestAnime(ctx, normalized.MediaType, normalized.TMDBID)
matches, err := s.lookupPresence(ctx, normalized.MediaType, []PresenceCandidate{createPresenceCandidate(normalized)})
if err != nil {
@@ -329,6 +352,7 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create
Input: normalized,
Status: status,
Outcome: OutcomeActive,
IsAnime: isAnime,
Requester: viewer,
Now: s.now(),
}
@@ -366,14 +390,44 @@ func (s *Service) ListMine(ctx context.Context, viewer Viewer, filter ListFilter
if err := s.ensureRequestsEnabled(ctx); err != nil {
return nil, err
}
return s.store.ListMine(ctx, viewer.UserID, normalizeListFilter(filter))
reqs, err := s.store.ListMine(ctx, viewer.UserID, normalizeListFilter(filter))
if err != nil {
return nil, err
}
if err := s.attachTargets(ctx, reqs...); err != nil {
return nil, err
}
return reqs, nil
}
func (s *Service) ListAdmin(ctx context.Context, viewer Viewer, filter ListFilter) ([]*Request, error) {
if !viewer.IsAdmin {
return nil, ErrForbidden
}
return s.store.ListAdmin(ctx, normalizeListFilter(filter))
reqs, err := s.store.ListAdmin(ctx, normalizeListFilter(filter))
if err != nil {
return nil, err
}
if err := s.attachTargets(ctx, reqs...); err != nil {
return nil, err
}
return reqs, nil
}
// attachTargets loads and attaches the per-instance fulfillment targets for each
// request so callers (admin queue, detail view) can surface multi-target status.
func (s *Service) attachTargets(ctx context.Context, reqs ...*Request) error {
for _, r := range reqs {
if r == nil {
continue
}
targets, err := s.store.ListTargets(ctx, r.ID)
if err != nil {
return err
}
r.Targets = targets
}
return nil
}
func (s *Service) GetRequest(ctx context.Context, viewer Viewer, id string) (*Request, error) {
@@ -387,6 +441,9 @@ func (s *Service) GetRequest(ctx context.Context, viewer Viewer, id string) (*Re
if !viewer.IsAdmin && req.RequestedByUserID != viewer.UserID {
return nil, ErrForbidden
}
if err := s.attachTargets(ctx, req); err != nil {
return nil, err
}
return req, nil
}
@@ -478,12 +535,65 @@ func (s *Service) Retry(ctx context.Context, viewer Viewer, id string) (*Request
if err != nil {
return nil, err
}
if active.Status == StatusApproved || active.Status == StatusQueued || active.Status == StatusDownloading {
retry := *active
retry.Status = StatusApproved
return s.submitApprovedRequest(ctx, retry, viewer)
instances, err := s.store.ListIntegrations(ctx)
if err != nil {
return nil, err
}
return active, nil
settings, err := s.store.GetSettings(ctx)
if err != nil {
return nil, err
}
existing, err := s.store.ListTargets(ctx, active.ID)
if err != nil {
return nil, err
}
ceiling := s.requesterCeiling(ctx, active.RequestedByUserID, active.RequestedByProfileID)
planned := routeTargets(*active, ceiling, settings, instances)
if len(planned) == 0 {
return s.markSubmissionFailed(ctx, active.ID, viewer,
fmt.Errorf("no %s instance configured for the requested quality",
integrationKindForMediaType(active.MediaType)))
}
return s.submitPlannedTargets(ctx, *active, planned, existing, viewer)
}
// submitPlannedTargets submits each planned target idempotently against the
// already-recorded targets: a non-failed target for a quality is left alone, a
// failed one is deleted and re-submitted, and a missing one is submitted fresh.
// This is shared by Retry and the reconcile-driven submit so a re-run never
// violates the UNIQUE(request_id, quality) constraint.
func (s *Service) submitPlannedTargets(ctx context.Context, req Request, planned []plannedTarget, existing []Target, actor Viewer) (*Request, error) {
hasOK := make(map[Quality]bool)
for _, t := range existing {
if t.Status != StatusFailed {
hasOK[t.Quality] = true
}
}
latest := &req
for _, pt := range planned {
if hasOK[pt.Quality] {
continue // healthy target already exists for this quality
}
// remove the stale failed target for this quality before re-submitting
for _, t := range existing {
if t.Quality == pt.Quality && t.Status == StatusFailed {
if err := s.store.DeleteTarget(ctx, t.ID); err != nil {
return nil, err
}
}
}
updated, err := s.submitPlannedTarget(ctx, req, pt, actor)
if err != nil {
return nil, err
}
if updated != nil {
latest = updated
}
}
return latest, nil
}
func (s *Service) ReconcileRequests(ctx context.Context, limit int) (ReconcileResult, error) {
@@ -606,43 +716,88 @@ func (s *Service) ListIntegrations(ctx context.Context, viewer Viewer) ([]Integr
return s.store.ListIntegrations(ctx)
}
func (s *Service) UpsertIntegration(ctx context.Context, viewer Viewer, integration Integration) (*Integration, error) {
func (s *Service) CreateIntegration(ctx context.Context, viewer Viewer, in Integration) (*Integration, error) {
if !viewer.IsAdmin {
return nil, ErrForbidden
}
normalized, err := normalizeIntegration(integration)
if err := validateInstance(&in); err != nil {
return nil, err
}
id, err := idgen.NextID()
if err != nil {
return nil, err
}
return s.store.UpsertIntegration(ctx, normalized)
in.ID = id
return s.store.SaveIntegrationWithDefaults(ctx, in, true)
}
func (s *Service) UpsertIntegrations(ctx context.Context, viewer Viewer, integrations []Integration) ([]Integration, error) {
func (s *Service) UpdateIntegration(ctx context.Context, viewer Viewer, in Integration) (*Integration, error) {
if !viewer.IsAdmin {
return nil, ErrForbidden
}
normalized := make([]Integration, 0, len(integrations))
for _, integration := range integrations {
item, err := normalizeIntegration(integration)
if err != nil {
return nil, err
}
normalized = append(normalized, item)
if strings.TrimSpace(in.ID) == "" {
return nil, fmt.Errorf("%w: integration id required", ErrInvalidInput)
}
return s.store.UpsertIntegrations(ctx, normalized)
if err := validateInstance(&in); err != nil {
return nil, err
}
return s.store.SaveIntegrationWithDefaults(ctx, in, false)
}
func (s *Service) DeleteIntegration(ctx context.Context, viewer Viewer, id string) error {
if !viewer.IsAdmin {
return ErrForbidden
}
return s.store.DeleteIntegration(ctx, strings.TrimSpace(id))
}
func validateInstance(in *Integration) error {
in.Kind = strings.TrimSpace(in.Kind)
if in.Kind != "radarr" && in.Kind != "sonarr" {
return fmt.Errorf("%w: kind must be radarr or sonarr", ErrInvalidInput)
}
if strings.TrimSpace(in.Name) == "" {
return fmt.Errorf("%w: name is required", ErrInvalidInput)
}
if in.IsDefault && in.Is4K {
return fmt.Errorf("%w: the HD default cannot be a 4K server", ErrInvalidInput)
}
if in.IsDefault4K && !in.Is4K {
return fmt.Errorf("%w: the 4K default must be a 4K server", ErrInvalidInput)
}
return nil
}
func (s *Service) LoadIntegrationOptions(ctx context.Context, viewer Viewer, integration Integration) (*IntegrationOptions, error) {
if !viewer.IsAdmin {
return nil, ErrForbidden
}
// For a saved instance the request body carries only the path id (no kind and
// often no creds), so resolve the saved row by id and backfill what the body
// omitted. This makes "Test connection" reuse the correct per-instance key
// (each kind can have multiple instances) instead of borrowing a sibling's.
if id := strings.TrimSpace(integration.ID); id != "" && id != "new" {
stored, err := s.store.GetIntegration(ctx, id)
if err != nil && !errors.Is(err, ErrNotFound) {
return nil, err
}
if stored != nil {
if strings.TrimSpace(integration.Kind) == "" {
integration.Kind = stored.Kind
}
if strings.TrimSpace(integration.BaseURL) == "" {
integration.BaseURL = stored.BaseURL
}
if strings.TrimSpace(integration.APIKeyRef) == "" {
integration.APIKeyRef = stored.APIKeyRef
}
}
}
normalized, err := normalizeIntegrationConnection(integration)
if err != nil {
return nil, err
}
if err := s.applyStoredIntegrationCredentials(ctx, &normalized); err != nil {
return nil, err
}
if strings.TrimSpace(normalized.BaseURL) == "" {
return nil, fmt.Errorf("%w: base_url is required", ErrInvalidInput)
}
@@ -941,85 +1096,118 @@ func (s *Service) enrichExternalIDs(ctx context.Context, input *CreateRequestInp
}
}
func (s *Service) integrationConfigured(ctx context.Context, mediaType MediaType) (bool, error) {
integration, err := s.integrationForMediaType(ctx, mediaType)
if err != nil {
return false, err
func (s *Service) detectRequestAnime(ctx context.Context, mediaType MediaType, tmdbID int) bool {
detail, err := s.tmdb.GetMediaDetail(ctx, tmdbMediaType(mediaType), tmdbID)
if err != nil || detail == nil {
return false
}
if integration == nil || !integrationIsConfigured(*integration) {
return false, nil
}
apiKey, err := s.resolveAPIKey(ctx, *integration)
if err != nil {
return false, err
}
return apiKey != "", nil
return detectAnime(detail.KeywordIDs)
}
func (s *Service) integrationForMediaType(ctx context.Context, mediaType MediaType) (*Integration, error) {
want := integrationKindForMediaType(mediaType)
integrations, err := s.store.ListIntegrations(ctx)
func (s *Service) integrationConfigured(ctx context.Context, mediaType MediaType) (bool, error) {
instances, err := s.store.ListIntegrations(ctx)
if err != nil {
return nil, err
return false, err
}
for _, integration := range integrations {
if integration.Kind == want {
integration := integration
return &integration, nil
kind := integrationKindForMediaType(mediaType)
for _, in := range instances {
if in.Kind == kind && in.Enabled && (in.IsDefault || in.IsDefault4K) && integrationIsConfigured(in) {
return true, nil
}
}
return nil, nil
return false, nil
}
func (s *Service) submitApprovedRequest(ctx context.Context, req Request, actor Viewer) (*Request, error) {
if req.Outcome != OutcomeActive || req.Status != StatusApproved {
return &req, nil
}
integration, err := s.integrationForMediaType(ctx, req.MediaType)
instances, err := s.store.ListIntegrations(ctx)
if err != nil {
return nil, err
}
if integration == nil || !integrationIsConfigured(*integration) {
return &req, nil
settings, err := s.store.GetSettings(ctx)
if err != nil {
return nil, err
}
ceiling := s.requesterCeiling(ctx, req.RequestedByUserID, req.RequestedByProfileID)
planned := routeTargets(req, ceiling, settings, instances)
if len(planned) == 0 {
return s.markSubmissionFailed(ctx, req.ID, actor,
fmt.Errorf("no %s instance configured for the requested quality",
integrationKindForMediaType(req.MediaType)))
}
resolved := *integration
apiKey, err := s.resolveAPIKey(ctx, resolved)
// Reconcile can re-run submit while the request is still 'approved'; skip
// qualities that already have a live target and replace failed ones so the
// UNIQUE(request_id, quality) constraint is never violated.
existing, err := s.store.ListTargets(ctx, req.ID)
if err != nil {
return s.markSubmissionFailed(ctx, req.ID, actor, err)
return nil, err
}
if apiKey == "" {
return &req, nil
return s.submitPlannedTargets(ctx, req, planned, existing, actor)
}
// submitPlannedTarget creates a target row, submits it to the adapter, and
// records the result (queued or failed) via UpdateTargetStatus (which recomputes
// the request aggregate). Returns the latest request snapshot.
func (s *Service) submitPlannedTarget(ctx context.Context, req Request, pt plannedTarget, actor Viewer) (*Request, error) {
resolved := resolveInstance(pt)
apiKey, err := s.resolveAPIKey(ctx, resolved)
if err != nil || apiKey == "" {
msg := "missing api key"
if err != nil {
msg = err.Error()
}
return s.createFailedTarget(ctx, req, pt, resolved, msg, actor)
}
resolved.APIKeyRef = apiKey
var result FulfillmentResult
target, err := s.store.CreateTarget(ctx, Target{
RequestID: req.ID, IntegrationID: resolved.ID, IntegrationKind: resolved.Kind,
Quality: pt.Quality, IsAnime: pt.IsAnime, Status: StatusQueued,
})
if err != nil {
return nil, err
}
result, serr := s.submitTarget(ctx, req, resolved)
if serr != nil {
return s.store.UpdateTargetStatus(ctx, target.ID, StatusFailed, "", "", serr.Error(), actor)
}
return s.store.UpdateTargetStatus(ctx, target.ID, StatusQueued,
result.ExternalID, result.ExternalStatus, "", actor)
}
func (s *Service) createFailedTarget(ctx context.Context, req Request, pt plannedTarget, resolved Integration, msg string, actor Viewer) (*Request, error) {
target, err := s.store.CreateTarget(ctx, Target{
RequestID: req.ID, IntegrationID: resolved.ID, IntegrationKind: resolved.Kind,
Quality: pt.Quality, IsAnime: pt.IsAnime, Status: StatusFailed, LastError: msg,
})
if err != nil {
return nil, err
}
return s.store.UpdateTargetStatus(ctx, target.ID, StatusFailed, "", "", msg, actor)
}
// submitTarget calls the correct adapter with a per-target Request copy. The
// adapters read root folder/quality/tags (and Sonarr series_type) from the
// resolved Integration.
func (s *Service) submitTarget(ctx context.Context, req Request, resolved Integration) (FulfillmentResult, error) {
switch req.MediaType {
case MediaTypeMovie:
if s.movieAdapter == nil {
return &req, nil
return FulfillmentResult{}, fmt.Errorf("no movie adapter configured")
}
result, err = s.movieAdapter.SubmitMovie(ctx, req, resolved)
return s.movieAdapter.SubmitMovie(ctx, req, resolved)
case MediaTypeSeries:
if s.seriesAdapter == nil {
return &req, nil
return FulfillmentResult{}, fmt.Errorf("no series adapter configured")
}
result, err = s.seriesAdapter.SubmitSeries(ctx, req, resolved)
return s.seriesAdapter.SubmitSeries(ctx, req, resolved)
default:
return &req, nil
return FulfillmentResult{}, fmt.Errorf("unsupported media type %q", req.MediaType)
}
if err != nil {
return s.markSubmissionFailed(ctx, req.ID, actor, err)
}
if result.IntegrationKind == "" {
result.IntegrationKind = resolved.Kind
}
return s.store.MarkQueued(ctx, req.ID, QueueUpdate{
IntegrationKind: result.IntegrationKind,
ExternalID: result.ExternalID,
ExternalStatus: result.ExternalStatus,
}, actor)
}
func (s *Service) markSubmissionFailed(ctx context.Context, requestID string, actor Viewer, submitErr error) (*Request, error) {
@@ -1047,13 +1235,24 @@ func (s *Service) reconcileRequest(ctx context.Context, req Request) (reconcileC
return reconcileUnchanged, err
}
if completed {
if req.Status == StatusCompleted {
return reconcileUnchanged, nil
}
if _, err := s.store.SetStatus(ctx, req.ID, StatusCompleted, Viewer{}); err != nil {
// The presence check is quality-agnostic (TMDB id only), so it must not
// force-complete a request whose targets are still in flight — that would
// orphan in-progress downloads. Only take the shortcut for legacy/no-live
// -target requests; otherwise let per-target reconcile + aggregate drive
// completion.
hasLiveTargets, err := s.hasLiveTargets(ctx, req.ID)
if err != nil {
return reconcileUnchanged, err
}
return reconcileCompleted, nil
if !hasLiveTargets {
if req.Status == StatusCompleted {
return reconcileUnchanged, nil
}
if _, err := s.store.SetStatus(ctx, req.ID, StatusCompleted, Viewer{}); err != nil {
return reconcileUnchanged, err
}
return reconcileCompleted, nil
}
}
if req.Status == StatusApproved {
@@ -1071,33 +1270,91 @@ func (s *Service) reconcileRequest(ctx context.Context, req Request) (reconcileC
}
}
status, err := s.checkFulfillmentStatus(ctx, req)
targets, err := s.store.ListTargets(ctx, req.ID)
if err != nil {
return reconcileUnchanged, err
}
if status.Status == "" && status.Outcome == "" {
return reconcileSkipped, nil
instances, err := s.store.ListIntegrations(ctx)
if err != nil {
return reconcileUnchanged, err
}
if status.Outcome == OutcomeFailed {
message := strings.TrimSpace(status.Message)
if message == "" {
message = strings.TrimSpace(status.ExternalStatus)
byID := make(map[string]Integration, len(instances))
for _, in := range instances {
byID[in.ID] = in
}
change := reconcileUnchanged
for _, t := range targets {
if t.Status == StatusCompleted || t.Status == StatusFailed {
continue
}
if message == "" {
message = "external fulfillment failed"
in, ok := byID[t.IntegrationID]
if !ok {
continue
}
if _, err := s.store.SetOutcome(ctx, req.ID, OutcomeFailed, Viewer{}, message); err != nil {
apiKey, err := s.resolveAPIKey(ctx, in)
if err != nil || apiKey == "" {
continue
}
in.APIKeyRef = apiKey
probe := req
probe.ExternalID = t.ExternalID
st, err := s.checkFulfillmentStatus(ctx, probe, in)
if err != nil {
continue
}
if st.Status == "" && st.Outcome == "" {
continue
}
newStatus := targetStatusFromFulfillment(st)
if newStatus == t.Status {
continue
}
if _, err := s.store.UpdateTargetStatus(ctx, t.ID, newStatus, st.ExternalID, st.ExternalStatus, "", Viewer{}); err != nil {
return reconcileUnchanged, err
}
return reconcileFailed, nil
}
if status.Status == StatusDownloading && req.Status != StatusDownloading {
if _, err := s.store.SetStatus(ctx, req.ID, StatusDownloading, Viewer{}); err != nil {
return reconcileUnchanged, err
switch newStatus {
case StatusCompleted:
change = reconcileCompleted
case StatusDownloading:
if change == reconcileUnchanged {
change = reconcileDownloading
}
case StatusFailed:
if change == reconcileUnchanged {
change = reconcileFailed
}
}
return reconcileDownloading, nil
}
return reconcileUnchanged, nil
return change, nil
}
func targetStatusFromFulfillment(st FulfillmentStatus) Status {
switch st.Status {
case StatusCompleted:
return StatusCompleted
case StatusDownloading:
return StatusDownloading
default:
if st.Outcome == OutcomeFailed {
return StatusFailed
}
return StatusQueued
}
}
// hasLiveTargets reports whether the request has any non-terminal (queued or
// downloading) fulfillment target.
func (s *Service) hasLiveTargets(ctx context.Context, requestID string) (bool, error) {
targets, err := s.store.ListTargets(ctx, requestID)
if err != nil {
return false, err
}
for _, t := range targets {
if t.Status == StatusQueued || t.Status == StatusDownloading {
return true, nil
}
}
return false, nil
}
func (s *Service) requestAvailable(ctx context.Context, req Request) (bool, error) {
@@ -1108,24 +1365,7 @@ func (s *Service) requestAvailable(ctx context.Context, req Request) (bool, erro
return matches[req.TMDBID].Available, nil
}
func (s *Service) checkFulfillmentStatus(ctx context.Context, req Request) (FulfillmentStatus, error) {
integration, err := s.integrationForMediaType(ctx, req.MediaType)
if err != nil {
return FulfillmentStatus{}, err
}
if integration == nil || !integrationIsConfigured(*integration) {
return FulfillmentStatus{}, nil
}
resolved := *integration
apiKey, err := s.resolveAPIKey(ctx, resolved)
if err != nil {
return FulfillmentStatus{}, err
}
if apiKey == "" {
return FulfillmentStatus{}, nil
}
resolved.APIKeyRef = apiKey
func (s *Service) checkFulfillmentStatus(ctx context.Context, req Request, resolved Integration) (FulfillmentStatus, error) {
switch req.MediaType {
case MediaTypeMovie:
checker, ok := s.movieAdapter.(MovieStatusAdapter)
@@ -1160,13 +1400,6 @@ func (s *Service) resolveAPIKey(ctx context.Context, integration Integration) (s
return resolved, nil
}
func integrationKindForMediaType(mediaType MediaType) string {
if mediaType == MediaTypeSeries {
return "sonarr"
}
return "radarr"
}
func integrationIsConfigured(integration Integration) bool {
return integration.Enabled &&
strings.TrimSpace(integration.BaseURL) != "" &&
@@ -1283,29 +1516,6 @@ func normalizeUserLimit(limit UserLimit) (UserLimit, error) {
return limit, nil
}
func normalizeIntegration(integration Integration) (Integration, error) {
var err error
integration, err = normalizeIntegrationConnection(integration)
if err != nil {
return Integration{}, err
}
integration.RootFolder = strings.TrimSpace(integration.RootFolder)
if integration.QualityProfileID != nil && *integration.QualityProfileID <= 0 {
return Integration{}, fmt.Errorf("%w: quality_profile_id must be positive", ErrInvalidInput)
}
filteredTags := make([]int, 0, len(integration.Tags))
for _, tag := range integration.Tags {
if tag > 0 {
filteredTags = append(filteredTags, tag)
}
}
integration.Tags = filteredTags
if integration.Options == nil {
integration.Options = map[string]any{}
}
return integration, nil
}
func normalizeIntegrationConnection(integration Integration) (Integration, error) {
integration.Kind = strings.ToLower(strings.TrimSpace(integration.Kind))
switch integration.Kind {
@@ -1321,32 +1531,6 @@ func normalizeIntegrationConnection(integration Integration) (Integration, error
return integration, nil
}
func (s *Service) applyStoredIntegrationCredentials(ctx context.Context, integration *Integration) error {
if integration == nil || s.store == nil {
return nil
}
if strings.TrimSpace(integration.BaseURL) != "" && strings.TrimSpace(integration.APIKeyRef) != "" {
return nil
}
stored, err := s.store.ListIntegrations(ctx)
if err != nil {
return err
}
for _, candidate := range stored {
if candidate.Kind != integration.Kind {
continue
}
if strings.TrimSpace(integration.BaseURL) == "" {
integration.BaseURL = candidate.BaseURL
}
if strings.TrimSpace(integration.APIKeyRef) == "" {
integration.APIKeyRef = candidate.APIKeyRef
}
return nil
}
return nil
}
func normalizeMediaType(mediaType MediaType) (MediaType, error) {
switch MediaType(strings.ToLower(strings.TrimSpace(string(mediaType)))) {
case MediaTypeMovie:
+304 -37
View File
@@ -164,12 +164,20 @@ func TestCreateRequestAutoApprovesWithConfiguredIntegration(t *testing.T) {
store.integrations = []Integration{{
Kind: "radarr",
Enabled: true,
IsDefault: true,
BaseURL: "http://radarr.local",
APIKeyRef: "request.radarr.api_key",
RootFolder: "/movies",
QualityProfileID: &qualityProfileID,
}}
adapter := &fakeMovieAdapter{result: FulfillmentResult{
IntegrationKind: "radarr",
ExternalID: "123",
ExternalStatus: "queued",
}}
service := newTestService(store)
service.SetSecretResolver(fakeSecrets{"request.radarr.api_key": "radarr-key"})
service.SetFulfillmentAdapters(adapter, nil)
req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
MediaType: MediaTypeMovie,
@@ -179,12 +187,14 @@ func TestCreateRequestAutoApprovesWithConfiguredIntegration(t *testing.T) {
if err != nil {
t.Fatalf("CreateRequest returned error: %v", err)
}
if req.Status != StatusApproved {
t.Fatalf("status = %q, want approved", req.Status)
// A configured HD default auto-approves and immediately submits, so the
// request lands in the fulfillment pipeline (aggregate of one queued target).
if req.Status != StatusQueued {
t.Fatalf("status = %q, want queued (auto-approved and submitted)", req.Status)
}
}
func TestCreateRequestAutoApprovalFallsBackToPendingOnIntegrationCheckError(t *testing.T) {
func TestCreateRequestAutoApprovalSecretFailureMarksTargetFailed(t *testing.T) {
store := newFakeStore()
store.settings.RequestsEnabled = true
store.settings.GlobalAutoApprovalEnabled = true
@@ -192,6 +202,7 @@ func TestCreateRequestAutoApprovalFallsBackToPendingOnIntegrationCheckError(t *t
store.integrations = []Integration{{
Kind: "radarr",
Enabled: true,
IsDefault: true,
BaseURL: "http://radarr.local",
APIKeyRef: "requests.radarr.api_key",
RootFolder: "/movies",
@@ -199,6 +210,7 @@ func TestCreateRequestAutoApprovalFallsBackToPendingOnIntegrationCheckError(t *t
}}
service := newTestService(store)
service.SetSecretResolver(fakeSecretError{err: errors.New("secret lookup unavailable")})
service.SetFulfillmentAdapters(&fakeMovieAdapter{}, nil)
req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
MediaType: MediaTypeMovie,
@@ -208,8 +220,10 @@ func TestCreateRequestAutoApprovalFallsBackToPendingOnIntegrationCheckError(t *t
if err != nil {
t.Fatalf("CreateRequest returned error: %v", err)
}
if req.Status != StatusPending {
t.Fatalf("status = %q, want pending", req.Status)
// The auto-approval gate no longer resolves secrets; submission does. A
// secret-resolution failure now surfaces as a failed fulfillment target.
if req.Outcome != OutcomeFailed {
t.Fatalf("outcome = %q, want failed (secret resolution failed during submit)", req.Outcome)
}
}
@@ -221,6 +235,7 @@ func TestCreateRequestAutoApprovalSubmitsMovie(t *testing.T) {
store.integrations = []Integration{{
Kind: "radarr",
Enabled: true,
IsDefault: true,
BaseURL: "http://radarr.local",
APIKeyRef: "requests.radarr.api_key",
RootFolder: "/movies",
@@ -262,6 +277,7 @@ func TestCreateRequestSubmissionFailureMarksFailed(t *testing.T) {
store.integrations = []Integration{{
Kind: "radarr",
Enabled: true,
IsDefault: true,
BaseURL: "http://radarr.local",
APIKeyRef: "radarr-key",
RootFolder: "/movies",
@@ -306,6 +322,37 @@ func TestCreateRequestEnrichesSeriesTVDBID(t *testing.T) {
}
}
func TestListMineAttachesTargets(t *testing.T) {
store := newFakeStore()
store.mine = []*Request{{
ID: "req-1",
MediaType: MediaTypeMovie,
TMDBID: 550,
Status: StatusQueued,
Outcome: OutcomeActive,
RequestedByUserID: 1,
}}
store.targets = map[string][]Target{
"req-1": {{
ID: 10,
RequestID: "req-1",
Quality: Quality2160p,
Status: StatusQueued,
}},
}
got, err := newTestService(store).ListMine(context.Background(), testViewer(1), ListFilter{})
if err != nil {
t.Fatalf("ListMine returned error: %v", err)
}
if len(got) != 1 {
t.Fatalf("ListMine returned %d requests, want 1", len(got))
}
if len(got[0].Targets) != 1 || got[0].Targets[0].Quality != Quality2160p {
t.Fatalf("targets = %+v, want attached 2160p target", got[0].Targets)
}
}
func TestCreateRequestBlocksWhenHydratedTVDBIDIsAvailable(t *testing.T) {
store := newFakeStore()
store.settings.RequestsEnabled = true
@@ -742,8 +789,10 @@ func TestReconcileRequestsMarksDownloadingFromAdapter(t *testing.T) {
store := newFakeStore()
qualityProfileID := 1
store.integrations = []Integration{{
ID: "radarr-hd",
Kind: "radarr",
Enabled: true,
IsDefault: true,
BaseURL: "http://radarr.local",
APIKeyRef: "radarr-key",
RootFolder: "/movies",
@@ -757,6 +806,14 @@ func TestReconcileRequestsMarksDownloadingFromAdapter(t *testing.T) {
Outcome: OutcomeActive,
ExternalID: "123",
}}
// Reconcile now drives status per-target; seed a queued target for the request.
store.requests["req-1"] = &Request{ID: "req-1", MediaType: MediaTypeMovie, TMDBID: 550, Status: StatusQueued, Outcome: OutcomeActive}
if _, err := store.CreateTarget(context.Background(), Target{
RequestID: "req-1", IntegrationID: "radarr-hd", IntegrationKind: "radarr",
Quality: Quality1080p, Status: StatusQueued, ExternalID: "123",
}); err != nil {
t.Fatalf("seed target: %v", err)
}
adapter := &fakeMovieAdapter{status: FulfillmentStatus{
Status: StatusDownloading,
IntegrationKind: "radarr",
@@ -778,6 +835,36 @@ func TestReconcileRequestsMarksDownloadingFromAdapter(t *testing.T) {
}
}
func TestDeleteIntegrationRejectsLiveTargets(t *testing.T) {
store := newFakeStore()
store.integrations = []Integration{{
ID: "radarr-hd",
Kind: "radarr",
Enabled: true,
}}
store.targets = map[string][]Target{
"req-1": {{
ID: 10,
RequestID: "req-1",
IntegrationID: "radarr-hd",
Quality: Quality1080p,
Status: StatusDownloading,
}},
}
err := newTestService(store).DeleteIntegration(
context.Background(),
Viewer{UserID: 1, IsAdmin: true},
"radarr-hd",
)
if !errors.Is(err, ErrInvalidState) {
t.Fatalf("err = %v, want ErrInvalidState", err)
}
if len(store.integrations) != 1 {
t.Fatalf("integrations = %d, want delete blocked", len(store.integrations))
}
}
func TestCancelOwnerCanWithdrawPendingRequest(t *testing.T) {
store := newFakeStore()
store.requests["req-mine"] = &Request{
@@ -903,6 +990,7 @@ func TestRetryResubmitsFailedQueuedRequest(t *testing.T) {
store.integrations = []Integration{{
Kind: "radarr",
Enabled: true,
IsDefault: true,
BaseURL: "http://radarr.local",
APIKeyRef: "radarr-key",
RootFolder: "/movies",
@@ -930,9 +1018,9 @@ func TestRetryResubmitsFailedQueuedRequest(t *testing.T) {
if adapter.calls != 1 {
t.Fatalf("adapter calls = %d, want 1", adapter.calls)
}
if adapter.gotReq.Status != StatusApproved {
t.Fatalf("submitted status = %q, want approved", adapter.gotReq.Status)
}
// Retry re-routes the failed request to its default instance and re-submits
// a fresh target; the request aggregate returns to queued with the new
// external id reported by the adapter.
if req.Status != StatusQueued || req.ExternalID != "99" {
t.Fatalf("request = %+v, want re-queued with external id 99", req)
}
@@ -960,10 +1048,12 @@ type fakeStore struct {
active map[MediaType]map[int]*Request
created []CreateRequestRecord
integrations []Integration
queued []QueueUpdate
candidates []*Request
mine []*Request
statusUpdates []Status
requests map[string]*Request
targets map[string][]Target
targetSeq int64
}
func newFakeStore() *fakeStore {
@@ -1067,7 +1157,7 @@ func (f *fakeStore) CreateRequest(_ context.Context, input CreateRequestRecord)
}
}
f.created = append(f.created, input)
return &Request{
req := &Request{
ID: input.ID,
Provider: "tmdb",
MediaType: input.Input.MediaType,
@@ -1077,11 +1167,15 @@ func (f *fakeStore) CreateRequest(_ context.Context, input CreateRequestRecord)
Title: input.Input.Title,
Status: input.Status,
Outcome: input.Outcome,
IsAnime: input.IsAnime,
RequestedByUserID: input.Requester.UserID,
RequestedByProfileID: input.Requester.ProfileID,
CreatedAt: input.Now,
UpdatedAt: input.Now,
}, nil
}
f.requests[input.ID] = req
copy := *req
return &copy, nil
}
func (f *fakeStore) GetRequest(_ context.Context, id string) (*Request, error) {
@@ -1102,7 +1196,9 @@ func (f *fakeStore) ListReconciliationCandidates(context.Context, int) ([]*Reque
}
func (f *fakeStore) ListMine(context.Context, int, ListFilter) ([]*Request, error) {
return nil, nil
f.mu.Lock()
defer f.mu.Unlock()
return append([]*Request(nil), f.mine...), nil
}
func (f *fakeStore) ListAdmin(context.Context, ListFilter) ([]*Request, error) {
@@ -1123,24 +1219,6 @@ func (f *fakeStore) SetStatus(_ context.Context, id string, status Status, _ Vie
return &copy, nil
}
func (f *fakeStore) MarkQueued(_ context.Context, id string, update QueueUpdate, _ Viewer) (*Request, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.queued = append(f.queued, update)
req := f.requests[id]
if req == nil {
req = &Request{ID: id}
f.requests[id] = req
}
req.Status = StatusQueued
req.Outcome = OutcomeActive
req.IntegrationKind = update.IntegrationKind
req.ExternalID = update.ExternalID
req.ExternalStatus = update.ExternalStatus
copy := *req
return &copy, nil
}
func (f *fakeStore) SetOutcome(_ context.Context, id string, outcome Outcome, _ Viewer, message string) (*Request, error) {
f.mu.Lock()
defer f.mu.Unlock()
@@ -1161,15 +1239,165 @@ func (f *fakeStore) ListIntegrations(context.Context) ([]Integration, error) {
return f.integrations, nil
}
func (f *fakeStore) UpsertIntegration(context.Context, Integration) (*Integration, error) {
return nil, nil
}
func (f *fakeStore) UpsertIntegrations(_ context.Context, integrations []Integration) ([]Integration, error) {
func (f *fakeStore) GetIntegration(_ context.Context, id string) (*Integration, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.integrations = append([]Integration(nil), integrations...)
return append([]Integration(nil), integrations...), nil
for i := range f.integrations {
if f.integrations[i].ID == id {
cp := f.integrations[i]
return &cp, nil
}
}
return nil, ErrNotFound
}
func (f *fakeStore) CreateIntegration(_ context.Context, in Integration) (*Integration, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.integrations = append(f.integrations, in)
cp := in
return &cp, nil
}
func (f *fakeStore) UpdateIntegration(_ context.Context, in Integration) (*Integration, error) {
f.mu.Lock()
defer f.mu.Unlock()
for i := range f.integrations {
if f.integrations[i].ID == in.ID {
f.integrations[i] = in
cp := in
return &cp, nil
}
}
return nil, ErrNotFound
}
func (f *fakeStore) SaveIntegrationWithDefaults(_ context.Context, in Integration, isCreate bool) (*Integration, error) {
f.mu.Lock()
defer f.mu.Unlock()
for i := range f.integrations {
if f.integrations[i].Kind != in.Kind || f.integrations[i].ID == in.ID {
continue
}
if in.IsDefault {
f.integrations[i].IsDefault = false
}
if in.IsDefault4K {
f.integrations[i].IsDefault4K = false
}
}
if isCreate {
f.integrations = append(f.integrations, in)
cp := in
return &cp, nil
}
for i := range f.integrations {
if f.integrations[i].ID == in.ID {
f.integrations[i] = in
cp := in
return &cp, nil
}
}
return nil, ErrNotFound
}
func (f *fakeStore) DeleteIntegration(_ context.Context, id string) error {
f.mu.Lock()
defer f.mu.Unlock()
for _, targets := range f.targets {
for _, target := range targets {
if target.IntegrationID == id && (target.Status == StatusQueued || target.Status == StatusDownloading) {
return ErrInvalidState
}
}
}
for i := range f.integrations {
if f.integrations[i].ID == id {
f.integrations = append(f.integrations[:i], f.integrations[i+1:]...)
return nil
}
}
return ErrNotFound
}
func (f *fakeStore) ListTargets(_ context.Context, requestID string) ([]Target, error) {
f.mu.Lock()
defer f.mu.Unlock()
return append([]Target(nil), f.targets[requestID]...), nil
}
func (f *fakeStore) CreateTarget(_ context.Context, t Target) (Target, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.targets == nil {
f.targets = map[string][]Target{}
}
f.targetSeq++
t.ID = f.targetSeq
f.targets[t.RequestID] = append(f.targets[t.RequestID], t)
return t, nil
}
func (f *fakeStore) DeleteTarget(_ context.Context, id int64) error {
f.mu.Lock()
defer f.mu.Unlock()
for rid, ts := range f.targets {
for i := range ts {
if ts[i].ID == id {
f.targets[rid] = append(ts[:i], ts[i+1:]...)
return nil
}
}
}
return ErrNotFound
}
func (f *fakeStore) UpdateTargetStatus(_ context.Context, targetID int64, status Status, externalID, externalStatus, lastErr string, _ Viewer) (*Request, error) {
f.mu.Lock()
defer f.mu.Unlock()
var requestID string
for rid, ts := range f.targets {
for i := range ts {
if ts[i].ID == targetID {
if externalID != "" {
f.targets[rid][i].ExternalID = externalID
}
if externalStatus != "" {
f.targets[rid][i].ExternalStatus = externalStatus
}
f.targets[rid][i].Status = status
f.targets[rid][i].LastError = lastErr
requestID = rid
}
}
}
if requestID == "" {
return nil, ErrNotFound
}
f.statusUpdates = append(f.statusUpdates, status)
st, outcome := aggregateStatus(f.targets[requestID])
req := f.requests[requestID]
if req == nil {
req = &Request{ID: requestID}
f.requests[requestID] = req
}
req.Status = st
req.Outcome = outcome
// Surface the first target's external identity on the request snapshot so
// existing assertions on req.ExternalID/IntegrationKind keep working.
for _, t := range f.targets[requestID] {
if t.ExternalID != "" {
req.ExternalID = t.ExternalID
req.ExternalStatus = t.ExternalStatus
req.IntegrationKind = t.IntegrationKind
break
}
}
if outcome == OutcomeFailed {
req.LastError = lastErr
}
copy := *req
return &copy, nil
}
func TestListStudiosReturnsBundleWithDuotoneLogos(t *testing.T) {
@@ -1462,6 +1690,45 @@ func (f *fakeMovieAdapter) CheckMovieStatus(_ context.Context, req Request, inte
return f.status, f.statusErr
}
type fixedCeiling struct{ q string }
func (f fixedCeiling) MaxPlaybackQuality(context.Context, int, string) (string, error) {
return f.q, nil
}
type recordingMovieAdapter struct {
mu sync.Mutex
ids []string
}
func (a *recordingMovieAdapter) SubmitMovie(_ context.Context, _ Request, integration Integration) (FulfillmentResult, error) {
a.mu.Lock()
defer a.mu.Unlock()
a.ids = append(a.ids, integration.ID)
return FulfillmentResult{IntegrationKind: integration.Kind, ExternalID: "ext-" + integration.ID, ExternalStatus: "queued"}, nil
}
func TestSubmitApprovedFansOutDualQuality(t *testing.T) {
store := newFakeStore()
store.integrations = []Integration{
inst("radarr", "hd", true, false, false),
inst("radarr", "uhd", false, true, false),
}
rec := &recordingMovieAdapter{}
svc := NewService(store, &fakeTMDBClient{}, &fakePresence{})
svc.SetFulfillmentAdapters(rec, nil)
svc.SetEntitlementResolver(fixedCeiling{q: "2160p"})
req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7}
store.requests["r1"] = &req
if _, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}); err != nil {
t.Fatalf("submit: %v", err)
}
if len(rec.ids) != 2 {
t.Fatalf("expected 2 submissions (hd+uhd), got %d: %v", len(rec.ids), rec.ids)
}
}
type fakeSecrets map[string]string
func (f fakeSecrets) Get(_ context.Context, key string) (string, error) {
+12 -3
View File
@@ -21,11 +21,19 @@ type Store interface {
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)
ListTargets(ctx context.Context, requestID string) ([]Target, error)
CreateTarget(ctx context.Context, target Target) (Target, error)
DeleteTarget(ctx context.Context, id int64) error
UpdateTargetStatus(ctx context.Context, targetID int64, status Status, externalID, externalStatus, lastErr string, actor Viewer) (*Request, error)
ListIntegrations(ctx context.Context) ([]Integration, error)
UpsertIntegration(ctx context.Context, integration Integration) (*Integration, error)
UpsertIntegrations(ctx context.Context, integrations []Integration) ([]Integration, error)
GetIntegration(ctx context.Context, id string) (*Integration, error)
CreateIntegration(ctx context.Context, integration Integration) (*Integration, error)
UpdateIntegration(ctx context.Context, integration Integration) (*Integration, error)
// SaveIntegrationWithDefaults clears the conflicting kind default(s) and
// creates (isCreate) or updates the instance atomically in one transaction.
SaveIntegrationWithDefaults(ctx context.Context, integration Integration, isCreate bool) (*Integration, error)
DeleteIntegration(ctx context.Context, id string) error
}
type CreateRequestRecord struct {
@@ -33,6 +41,7 @@ type CreateRequestRecord struct {
Input CreateRequestInput
Status Status
Outcome Outcome
IsAnime bool
Requester Viewer
Now time.Time
// Quota, when non-nil, instructs the store to atomically verify the
+192
View File
@@ -0,0 +1,192 @@
package requests
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
)
const targetColumns = `t.id, t.request_id, t.integration_id, t.integration_kind,
COALESCE(ri.name, ''), t.quality, t.is_anime, t.external_id, t.external_status,
t.status, t.last_error, t.created_at, t.updated_at`
// aggregateStatus derives a request's status/outcome from its targets.
func aggregateStatus(targets []Target) (Status, Outcome) {
if len(targets) == 0 {
return StatusApproved, OutcomeActive
}
failed, completed := 0, 0
anyDownloading, anyQueued := false, false
for _, t := range targets {
switch t.Status {
case StatusFailed:
failed++
case StatusCompleted:
completed++
case StatusDownloading:
anyDownloading = true
case StatusQueued:
anyQueued = true
}
}
if completed == len(targets) {
return StatusCompleted, OutcomeActive
}
// Active targets keep the request active even with a failed sibling so the
// in-flight targets can finish (partial failure stays active).
if anyDownloading {
return StatusDownloading, OutcomeActive
}
if anyQueued {
return StatusQueued, OutcomeActive
}
// No active targets remain and at least one failed (all-failed, or a mix of
// completed + failed) -> surface as failed so Retry can re-submit the failed
// target while leaving completed ones untouched.
if failed > 0 {
return StatusQueued, OutcomeFailed
}
return StatusCompleted, OutcomeActive
}
func scanTarget(row requestScanner) (Target, error) {
var t Target
var integrationID *string
if err := row.Scan(&t.ID, &t.RequestID, &integrationID, &t.IntegrationKind,
&t.InstanceName, &t.Quality, &t.IsAnime, &t.ExternalID, &t.ExternalStatus,
&t.Status, &t.LastError, &t.CreatedAt, &t.UpdatedAt); err != nil {
return Target{}, err
}
if integrationID != nil {
t.IntegrationID = *integrationID
}
return t, nil
}
func (r *Repository) ListTargets(ctx context.Context, requestID string) ([]Target, error) {
rows, err := r.pool.Query(ctx, `SELECT `+targetColumns+`
FROM media_request_targets t
LEFT JOIN request_integrations ri ON ri.id = t.integration_id
WHERE t.request_id = $1 ORDER BY t.quality`, requestID)
if err != nil {
return nil, fmt.Errorf("list targets: %w", err)
}
defer rows.Close()
var out []Target
for rows.Next() {
t, err := scanTarget(rows)
if err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
func (r *Repository) CreateTarget(ctx context.Context, t Target) (Target, error) {
var integrationID any
if t.IntegrationID != "" {
integrationID = t.IntegrationID
}
row := r.pool.QueryRow(ctx, `
INSERT INTO media_request_targets
(request_id, integration_id, integration_kind, quality, is_anime,
external_id, external_status, status, last_error, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9, now())
RETURNING id`,
t.RequestID, integrationID, t.IntegrationKind, t.Quality, t.IsAnime,
t.ExternalID, t.ExternalStatus, t.Status, t.LastError)
if err := row.Scan(&t.ID); err != nil {
return Target{}, fmt.Errorf("create target: %w", err)
}
return t, nil
}
func (r *Repository) DeleteTarget(ctx context.Context, id int64) error {
tag, err := r.pool.Exec(ctx, `DELETE FROM media_request_targets WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("delete target: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// UpdateTargetStatus updates one target and recomputes the parent request's
// aggregate status/outcome, all in one transaction.
func (r *Repository) UpdateTargetStatus(ctx context.Context, targetID int64, status Status,
externalID, externalStatus, lastErr string, actor Viewer) (*Request, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin target update: %w", err)
}
defer tx.Rollback(ctx)
var requestID string
if err := tx.QueryRow(ctx, `
UPDATE media_request_targets
SET status=$2,
external_id = CASE WHEN $3 = '' THEN external_id ELSE $3 END,
external_status = CASE WHEN $4 = '' THEN external_status ELSE $4 END,
last_error=$5, updated_at=now()
WHERE id=$1 RETURNING request_id`,
targetID, status, externalID, externalStatus, lastErr).Scan(&requestID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("update target: %w", err)
}
req, err := r.recomputeAggregate(ctx, tx, requestID, actor)
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit target update: %w", err)
}
return req, nil
}
func (r *Repository) recomputeAggregate(ctx context.Context, exec requestExecutor, requestID string, actor Viewer) (*Request, error) {
rows, err := exec.Query(ctx, `SELECT status FROM media_request_targets WHERE request_id = $1`, requestID)
if err != nil {
return nil, fmt.Errorf("load target statuses: %w", err)
}
var targets []Target
for rows.Next() {
var t Target
if err := rows.Scan(&t.Status); err != nil {
rows.Close()
return nil, err
}
targets = append(targets, t)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
status, outcome := aggregateStatus(targets)
var lastErr string
for _, t := range targets {
if t.Status == StatusFailed {
lastErr = "one or more fulfillment targets failed"
break
}
}
req, err := scanRequest(exec.QueryRow(ctx, `
UPDATE media_requests
SET status=$2, outcome=$3,
last_error = CASE WHEN $3 = 'failed' THEN $4 ELSE '' END,
completed_at = CASE WHEN $2 = 'completed' AND completed_at IS NULL THEN now() ELSE completed_at END,
updated_at = now()
WHERE id=$1 RETURNING `+requestColumns(), requestID, status, outcome, lastErr))
if err != nil {
return nil, fmt.Errorf("recompute aggregate: %w", err)
}
_ = r.recordEvent(ctx, exec, requestID, "status_"+string(status), actor, string(req.ExternalStatus))
return req, nil
}
+27
View File
@@ -0,0 +1,27 @@
package requests
import "testing"
func TestAggregateStatus(t *testing.T) {
cases := []struct {
name string
targets []Target
status Status
outcome Outcome
}{
{"all completed", []Target{{Status: StatusCompleted}, {Status: StatusCompleted}}, StatusCompleted, OutcomeActive},
{"one downloading", []Target{{Status: StatusCompleted}, {Status: StatusDownloading}}, StatusDownloading, OutcomeActive},
{"queued only", []Target{{Status: StatusQueued}}, StatusQueued, OutcomeActive},
{"all failed", []Target{{Status: StatusFailed}, {Status: StatusFailed}}, StatusQueued, OutcomeFailed},
{"completed plus failed", []Target{{Status: StatusCompleted}, {Status: StatusFailed}}, StatusQueued, OutcomeFailed},
{"partial fail stays active", []Target{{Status: StatusFailed}, {Status: StatusDownloading}}, StatusDownloading, OutcomeActive},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
gotStatus, gotOutcome := aggregateStatus(tc.targets)
if gotStatus != tc.status || gotOutcome != tc.outcome {
t.Fatalf("aggregateStatus = (%s,%s), want (%s,%s)", gotStatus, gotOutcome, tc.status, tc.outcome)
}
})
}
}
+51 -18
View File
@@ -22,6 +22,8 @@ const (
StatusCompleted Status = "completed"
)
const StatusFailed Status = "failed" // target-only status; requests use outcome=failed
type Outcome string
const (
@@ -31,6 +33,31 @@ const (
OutcomeFailed Outcome = "failed"
)
type Quality string
const (
Quality1080p Quality = "1080p"
Quality2160p Quality = "2160p"
)
// Target is one fulfillment of a request against a single instance at a single
// quality. A request fans out to one Target per resolved quality.
type Target struct {
ID int64 `json:"id"`
RequestID string `json:"request_id"`
IntegrationID string `json:"integration_id,omitempty"`
IntegrationKind string `json:"integration_kind,omitempty"`
InstanceName string `json:"instance_name,omitempty"`
Quality Quality `json:"quality"`
IsAnime bool `json:"is_anime"`
ExternalID string `json:"external_id,omitempty"`
ExternalStatus string `json:"external_status,omitempty"`
Status Status `json:"status"`
LastError string `json:"last_error,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Availability string
const (
@@ -67,6 +94,7 @@ type Settings struct {
GlobalMaxRequests int `json:"global_max_requests"`
GlobalWindowDays int `json:"global_window_days"`
GlobalAutoApprovalEnabled bool `json:"global_auto_approval_enabled"`
ForceDualQuality bool `json:"force_dual_quality"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -112,6 +140,8 @@ type Request struct {
RequestedByUserID int `json:"requested_by_user_id,omitempty"`
RequestedByProfileID string `json:"requested_by_profile_id,omitempty"`
IntegrationKind string `json:"integration_kind,omitempty"`
IsAnime bool `json:"is_anime"`
Targets []Target `json:"targets,omitempty"`
ExternalID string `json:"external_id,omitempty"`
ExternalStatus string `json:"external_status,omitempty"`
LastError string `json:"last_error,omitempty"`
@@ -221,18 +251,27 @@ type ListFilter struct {
}
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"`
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
Enabled bool `json:"enabled"`
Is4K bool `json:"is_4k"`
IsDefault bool `json:"is_default"`
IsDefault4K bool `json:"is_default_4k"`
AnimeEnabled bool `json:"anime_enabled"`
AnimeQualityProfileID *int `json:"anime_quality_profile_id,omitempty"`
AnimeRootFolder string `json:"anime_root_folder,omitempty"`
AnimeTags []int `json:"anime_tags"`
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 {
@@ -259,12 +298,6 @@ type IntegrationOptions struct {
Tags []IntegrationTag `json:"tags"`
}
type QueueUpdate struct {
IntegrationKind string
ExternalID string
ExternalStatus string
}
type FulfillmentResult struct {
IntegrationKind string
ExternalID string
@@ -0,0 +1,42 @@
-- Restore per-fulfillment columns on media_requests.
ALTER TABLE public.media_requests
ADD COLUMN IF NOT EXISTS integration_kind text NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS external_id text NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS external_status text NOT NULL DEFAULT '';
-- Copy back the 1080p target's fulfillment fields (lossy: 4K/anime targets dropped).
UPDATE public.media_requests mr
SET integration_kind = t.integration_kind,
external_id = t.external_id,
external_status = t.external_status
FROM public.media_request_targets t
WHERE t.request_id = mr.id AND t.quality = '1080p';
ALTER TABLE public.media_requests DROP COLUMN IF EXISTS is_anime;
DROP TABLE IF EXISTS public.media_request_targets;
-- Collapse request_integrations back to kind-PK (lossy: keep one default per kind).
DELETE FROM public.request_integrations a
USING public.request_integrations b
WHERE a.kind = b.kind AND a.id <> b.id AND b.is_default AND NOT a.is_default;
-- If a kind has no default, keep an arbitrary row and drop the rest.
DELETE FROM public.request_integrations a
USING public.request_integrations b
WHERE a.kind = b.kind AND a.ctid < b.ctid;
DROP INDEX IF EXISTS idx_request_integrations_default_per_kind;
DROP INDEX IF EXISTS idx_request_integrations_default4k_per_kind;
ALTER TABLE public.request_integrations DROP CONSTRAINT request_integrations_pkey;
ALTER TABLE public.request_integrations ADD PRIMARY KEY (kind);
ALTER TABLE public.request_integrations
DROP COLUMN IF EXISTS id,
DROP COLUMN IF EXISTS name,
DROP COLUMN IF EXISTS is_4k,
DROP COLUMN IF EXISTS is_default,
DROP COLUMN IF EXISTS is_default_4k,
DROP COLUMN IF EXISTS anime_enabled,
DROP COLUMN IF EXISTS anime_quality_profile_id,
DROP COLUMN IF EXISTS anime_root_folder,
DROP COLUMN IF EXISTS anime_tags;
@@ -0,0 +1,86 @@
-- request_integrations: one-row-per-kind -> many instances keyed by id.
ALTER TABLE public.request_integrations
ADD COLUMN IF NOT EXISTS id text,
ADD COLUMN IF NOT EXISTS name text NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS is_4k boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS is_default boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS is_default_4k boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS anime_enabled boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS anime_quality_profile_id integer,
ADD COLUMN IF NOT EXISTS anime_root_folder text NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS anime_tags integer[] NOT NULL DEFAULT '{}';
-- Backfill: the lone existing row per kind becomes that kind's HD default.
UPDATE public.request_integrations
SET id = gen_random_uuid()::text,
name = initcap(kind),
is_default = enabled
WHERE id IS NULL;
-- Swap the primary key from kind to id; keep kind as a plain column.
ALTER TABLE public.request_integrations
DROP CONSTRAINT request_integrations_pkey;
ALTER TABLE public.request_integrations
ALTER COLUMN id SET NOT NULL,
ADD PRIMARY KEY (id);
-- Quality-role invariants: at most one default / one 4K-default per kind.
CREATE UNIQUE INDEX IF NOT EXISTS idx_request_integrations_default_per_kind
ON public.request_integrations (kind) WHERE is_default;
CREATE UNIQUE INDEX IF NOT EXISTS idx_request_integrations_default4k_per_kind
ON public.request_integrations (kind) WHERE is_default_4k;
-- Targets: one request -> N fulfillment targets.
CREATE TABLE IF NOT EXISTS public.media_request_targets (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
request_id text NOT NULL REFERENCES public.media_requests(id) ON DELETE CASCADE,
integration_id text REFERENCES public.request_integrations(id) ON DELETE SET NULL,
integration_kind text NOT NULL DEFAULT '',
quality text NOT NULL,
is_anime boolean NOT NULL DEFAULT false,
external_id text NOT NULL DEFAULT '',
external_status text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'queued',
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,
CONSTRAINT media_request_targets_quality_check CHECK (quality IN ('1080p', '2160p')),
CONSTRAINT media_request_targets_status_check
CHECK (status IN ('queued', 'downloading', 'completed', 'failed'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_request_targets_request_quality
ON public.media_request_targets (request_id, quality);
CREATE INDEX IF NOT EXISTS idx_media_request_targets_request
ON public.media_request_targets (request_id);
-- Backfill targets from already-submitted requests (those with an external id).
INSERT INTO public.media_request_targets
(request_id, integration_id, integration_kind, quality, is_anime,
external_id, external_status, status, created_at, updated_at)
SELECT mr.id,
ri.id,
mr.integration_kind,
'1080p',
false,
mr.external_id,
mr.external_status,
CASE
WHEN mr.status = 'completed' THEN 'completed'
WHEN mr.status = 'downloading' THEN 'downloading'
WHEN mr.outcome = 'failed' THEN 'failed'
ELSE 'queued'
END,
mr.created_at,
mr.updated_at
FROM public.media_requests mr
LEFT JOIN public.request_integrations ri ON ri.kind = mr.integration_kind
WHERE mr.external_id <> '';
-- media_requests: add is_anime, move per-fulfillment columns out to targets.
ALTER TABLE public.media_requests
ADD COLUMN IF NOT EXISTS is_anime boolean NOT NULL DEFAULT false;
ALTER TABLE public.media_requests
DROP COLUMN IF EXISTS integration_kind,
DROP COLUMN IF EXISTS external_id,
DROP COLUMN IF EXISTS external_status;
@@ -0,0 +1 @@
ALTER TABLE public.request_settings DROP COLUMN IF EXISTS force_dual_quality;
@@ -0,0 +1,2 @@
ALTER TABLE public.request_settings
ADD COLUMN IF NOT EXISTS force_dual_quality boolean NOT NULL DEFAULT false;
+29
View File
@@ -1531,6 +1531,22 @@ export interface CreateMediaRequestInput {
backdrop_path?: string;
}
export interface RequestTarget {
id: number;
request_id: string;
integration_id?: string;
integration_kind?: string;
instance_name?: string;
quality: "1080p" | "2160p";
is_anime: boolean;
external_id?: string;
external_status?: string;
status: MediaRequestStatus | "failed";
last_error?: string;
created_at: string;
updated_at: string;
}
export interface MediaRequest {
id: string;
provider: string;
@@ -1547,6 +1563,8 @@ export interface MediaRequest {
outcome: MediaRequestOutcome;
requested_by_user_id?: number;
requested_by_profile_id?: string;
is_anime?: boolean;
targets?: RequestTarget[];
integration_kind?: string;
external_id?: string;
external_status?: string;
@@ -1570,6 +1588,7 @@ export interface RequestSettings {
global_max_requests: number;
global_window_days: number;
global_auto_approval_enabled: boolean;
force_dual_quality: boolean;
updated_at: string;
}
@@ -1583,14 +1602,23 @@ export interface RequestUserLimit {
}
export interface RequestIntegration {
id: string;
name: string;
kind: string;
enabled: boolean;
is_4k: boolean;
is_default: boolean;
is_default_4k: boolean;
base_url: string;
api_key_ref?: string;
has_api_key?: boolean;
root_folder: string;
quality_profile_id?: number | null;
tags: number[];
anime_enabled: boolean;
anime_quality_profile_id?: number | null;
anime_root_folder?: string;
anime_tags: number[];
options: Record<string, unknown>;
last_check_at?: string | null;
last_check_status?: string;
@@ -1623,6 +1651,7 @@ export interface RequestIntegrationOptions {
}
export interface LoadRequestIntegrationOptionsRequest {
kind: "radarr" | "sonarr";
base_url: string;
api_key_ref?: string;
}
+49 -9
View File
@@ -326,35 +326,75 @@ export function useRequestIntegrations() {
});
}
export function useUpdateRequestIntegrations() {
export function useCreateRequestIntegration() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (integrations: RequestIntegration[]) =>
api<RequestIntegrationsResponse>("/admin/request-integrations", {
method: "PUT",
body: JSON.stringify({ integrations }),
mutationFn: (integration: RequestIntegration) =>
api<RequestIntegration>("/admin/request-integrations", {
method: "POST",
body: JSON.stringify(integration),
}),
onSuccess: () => {
toast.success("Integrations saved");
toast.success("Integration created");
queryClient.invalidateQueries({ queryKey: adminKeys.requestIntegrations() });
invalidateRequestSurfaces(queryClient);
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to save integrations");
toast.error(err instanceof Error ? err.message : "Failed to create integration");
},
});
}
export function useUpdateRequestIntegration() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, ...integration }: RequestIntegration) =>
api<RequestIntegration>(`/admin/request-integrations/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify({ id, ...integration }),
}),
onSuccess: () => {
toast.success("Integration saved");
queryClient.invalidateQueries({ queryKey: adminKeys.requestIntegrations() });
invalidateRequestSurfaces(queryClient);
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to save integration");
},
});
}
export function useDeleteRequestIntegration() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
api<void>(`/admin/request-integrations/${encodeURIComponent(id)}`, {
method: "DELETE",
}),
onSuccess: () => {
toast.success("Integration deleted");
queryClient.invalidateQueries({ queryKey: adminKeys.requestIntegrations() });
invalidateRequestSurfaces(queryClient);
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to delete integration");
},
});
}
export function useLoadRequestIntegrationOptions() {
return useMutation({
mutationFn: ({ kind, body }: { kind: string; body: LoadRequestIntegrationOptionsRequest }) =>
mutationFn: ({ id, body }: { id: string; body: LoadRequestIntegrationOptionsRequest }) =>
api<RequestIntegrationOptions>(
`/admin/request-integrations/${encodeURIComponent(kind)}/options`,
`/admin/request-integrations/${encodeURIComponent(id)}/options`,
{
method: "POST",
body: JSON.stringify(body),
},
),
onSuccess: () => {
toast.success("Connection successful");
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to load integration settings");
},
+453 -53
View File
@@ -1,7 +1,20 @@
import { useMemo, useState } from "react";
import { useId, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { Link, useSearchParams } from "react-router";
import { Check, Plug, RefreshCw, Save, Settings2, SlidersHorizontal, X } from "lucide-react";
import {
AlertTriangle,
Check,
ChevronDown,
ChevronRight,
Plug,
Plus,
RefreshCw,
Save,
Settings2,
SlidersHorizontal,
Trash2,
X,
} from "lucide-react";
import type {
MediaRequest,
MediaRequestOutcome,
@@ -11,6 +24,7 @@ import type {
RequestIntegrationOptions,
RequestLimitMode,
RequestSettings,
RequestTarget,
RequestUserLimit,
} from "@/api/types";
import { Badge } from "@/components/ui/badge";
@@ -47,13 +61,15 @@ import { useAdminUsers } from "@/hooks/queries/admin/users";
import {
useAdminMediaRequests,
useApproveMediaRequest,
useCreateRequestIntegration,
useDeclineMediaRequest,
useDeleteRequestIntegration,
useLoadRequestIntegrationOptions,
useRequestIntegrations,
useRequestSettings,
useRequestUserLimit,
useRetryMediaRequest,
useUpdateRequestIntegrations,
useUpdateRequestIntegration,
useUpdateRequestSettings,
useUpdateRequestUserLimit,
} from "@/hooks/queries/useRequests";
@@ -357,8 +373,20 @@ function RequestQueueRow({
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{request.integration_kind || "Not submitted"}
{request.external_status ? <span className="block">{request.external_status}</span> : null}
{request.targets?.length ? (
<div className="flex flex-col gap-1.5">
{request.is_anime ? (
<Badge variant="secondary" className="w-fit">
Anime
</Badge>
) : null}
{request.targets.map((target) => (
<RequestTargetBadge key={target.id} target={target} />
))}
</div>
) : (
"Not submitted"
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
@@ -390,11 +418,35 @@ function RequestQueueRow({
);
}
function RequestTargetBadge({ target }: { target: RequestTarget }) {
const qualityLabel = target.quality === "2160p" ? "2160p" : "1080p";
const instanceLabel = target.instance_name || target.integration_kind || "Unknown";
const failed = target.status === "failed";
const statusLabel = target.status === "failed" ? "Failed" : formatRequestStatus(target.status);
return (
<div className="flex flex-col gap-1">
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant="outline">{qualityLabel}</Badge>
<span className="text-foreground">{instanceLabel}</span>
<Badge variant={failed ? "destructive" : "secondary"}>{statusLabel}</Badge>
{target.external_status ? <span>{target.external_status}</span> : null}
</div>
{failed && target.last_error ? (
<p className="text-destructive flex max-w-xs items-start gap-1">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
<span>{target.last_error}</span>
</p>
) : null}
</div>
);
}
type SettingsFormState = {
requests_enabled: boolean;
global_max_requests: string;
global_window_days: string;
global_auto_approval_enabled: boolean;
force_dual_quality: boolean;
updated_at: string;
};
@@ -419,6 +471,7 @@ function RequestSettingsForm({ settings }: { settings: RequestSettings }) {
global_max_requests: String(settings.global_max_requests),
global_window_days: String(settings.global_window_days),
global_auto_approval_enabled: settings.global_auto_approval_enabled,
force_dual_quality: settings.force_dual_quality,
updated_at: settings.updated_at,
}));
@@ -428,6 +481,7 @@ function RequestSettingsForm({ settings }: { settings: RequestSettings }) {
global_max_requests: Math.max(0, Number(form.global_max_requests) || 0),
global_window_days: Math.max(1, Number(form.global_window_days) || 1),
global_auto_approval_enabled: form.global_auto_approval_enabled,
force_dual_quality: form.force_dual_quality,
updated_at: form.updated_at,
};
updateSettings.mutate(payload);
@@ -475,6 +529,16 @@ function RequestSettingsForm({ settings }: { settings: RequestSettings }) {
}
/>
</Field>
<div className="sm:col-span-2">
<SwitchField
label="Always fulfill in both 1080p and 4K"
description="Applies to all requests when both a Default HD and Default 4K instance exist, regardless of user role."
checked={form.force_dual_quality}
onCheckedChange={(checked) =>
setForm((current) => ({ ...current, force_dual_quality: checked }))
}
/>
</div>
</div>
<Button onClick={saveSettings} disabled={updateSettings.isPending}>
@@ -486,13 +550,22 @@ function RequestSettingsForm({ settings }: { settings: RequestSettings }) {
}
type IntegrationFormState = {
id: string;
name: string;
kind: "radarr" | "sonarr";
enabled: boolean;
is_4k: boolean;
is_default: boolean;
is_default_4k: boolean;
base_url: string;
api_key_ref: string;
root_folder: string;
quality_profile_id: string;
tags: string;
anime_enabled: boolean;
anime_quality_profile_id: string;
anime_root_folder: string;
anime_tags: string;
search_on_add: boolean;
minimum_availability: string;
series_type: string;
@@ -519,86 +592,133 @@ function RequestIntegrationsTab() {
);
}
return (
<RequestIntegrationsForm
key={integrationsFormKey(integrations.data ?? [])}
integrations={integrations.data ?? []}
/>
);
const list = integrations.data ?? [];
const integrationsKey =
list.length === 0
? "empty"
: list.map((integration) => `${integration.id}:${integration.updated_at ?? ""}`).join("|");
return <RequestIntegrationsForm key={integrationsKey} integrations={list} />;
}
type IntegrationCard = {
key: string;
form: IntegrationFormState;
source: RequestIntegration | null;
};
let integrationCardCounter = 0;
function nextCardKey(): string {
integrationCardCounter += 1;
return `card-${integrationCardCounter}`;
}
function RequestIntegrationsForm({ integrations }: { integrations: RequestIntegration[] }) {
const updateIntegrations = useUpdateRequestIntegrations();
const [forms, setForms] = useState<IntegrationFormState[]>(() =>
INTEGRATION_KINDS.map((kind) =>
integrationToForm(
kind,
integrations.find((integration) => integration.kind === kind),
),
),
const [cards, setCards] = useState<IntegrationCard[]>(() =>
integrations.map((integration) => ({
key: integration.id || nextCardKey(),
form: integrationToForm(integration.kind === "sonarr" ? "sonarr" : "radarr", integration),
source: integration,
})),
);
function updateForm(kind: "radarr" | "sonarr", patch: Partial<IntegrationFormState>) {
setForms((current) =>
current.map((form) => (form.kind === kind ? { ...form, ...patch } : form)),
function updateCard(key: string, patch: Partial<IntegrationFormState>) {
setCards((current) =>
current.map((card) =>
card.key === key ? { ...card, form: { ...card.form, ...patch } } : card,
),
);
}
function saveIntegrations() {
updateIntegrations.mutate(forms.map(formToIntegration));
function addCard(kind: "radarr" | "sonarr") {
setCards((current) => [
...current,
{ key: nextCardKey(), form: integrationToForm(kind), source: null },
]);
}
function removeCard(key: string) {
setCards((current) => current.filter((card) => card.key !== key));
}
return (
<div className="space-y-4">
<div className="grid gap-4 xl:grid-cols-2">
{forms.map((form) => (
<IntegrationEditor
key={form.kind}
form={form}
onChange={(patch) => updateForm(form.kind, patch)}
/>
))}
</div>
<Button onClick={saveIntegrations} disabled={updateIntegrations.isPending}>
<Save className="h-4 w-4" />
Save Integrations
</Button>
<div className="space-y-8">
{INTEGRATION_KINDS.map((kind) => {
const kindCards = cards.filter((card) => card.form.kind === kind);
const title = kind === "radarr" ? "Radarr" : "Sonarr";
return (
<div key={kind} className="space-y-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold tracking-normal">{title} instances</h2>
<Button type="button" variant="outline" size="sm" onClick={() => addCard(kind)}>
<Plus className="h-4 w-4" />
Add {title} instance
</Button>
</div>
{kindCards.length === 0 ? (
<EmptyPanel
title={`No ${title} instances`}
detail={`Add a ${title} instance to route requests.`}
/>
) : (
<div className="grid gap-4 xl:grid-cols-2">
{kindCards.map((card) => (
<IntegrationEditor
key={card.key}
form={card.form}
source={card.source}
onChange={(patch) => updateCard(card.key, patch)}
onRemove={() => removeCard(card.key)}
/>
))}
</div>
)}
</div>
);
})}
</div>
);
}
function integrationsFormKey(integrations: RequestIntegration[]): string {
if (integrations.length === 0) return "empty";
return integrations
.map(
(integration) =>
`${integration.kind}:${integration.updated_at ?? ""}:${integration.enabled}:${integration.has_api_key}`,
)
.join("|");
}
function IntegrationEditor({
form,
source,
onChange,
onRemove,
}: {
form: IntegrationFormState;
source: RequestIntegration | null;
onChange: (patch: Partial<IntegrationFormState>) => void;
onRemove: () => void;
}) {
const title = form.kind === "radarr" ? "Radarr" : "Sonarr";
const isNew = form.id === "";
const createIntegration = useCreateRequestIntegration();
const updateIntegration = useUpdateRequestIntegration();
const deleteIntegration = useDeleteRequestIntegration();
const [animeOpen, setAnimeOpen] = useState(form.anime_enabled);
const [confirmDelete, setConfirmDelete] = useState(false);
const animePanelID = useId();
const isDirty = !isNew && source !== null && isIntegrationDirty(form, source);
const loadOptions = useLoadRequestIntegrationOptions();
const [options, setOptions] = useState<RequestIntegrationOptions | null>(null);
const rootFolders = rootFolderChoices(options, form.root_folder);
const qualityProfiles = qualityProfileChoices(options, form.quality_profile_id);
const animeRootFolders = rootFolderChoices(options, form.anime_root_folder);
const animeQualityProfiles = qualityProfileChoices(options, form.anime_quality_profile_id);
const tags = options?.tags ?? [];
const selectedTags = parseTags(form.tags);
const selectedAnimeTags = parseTags(form.anime_tags);
const canLoadOptions =
form.base_url.trim().length > 0 && Boolean(form.api_key_ref.trim() || form.has_api_key);
async function handleLoadOptions() {
try {
const loaded = await loadOptions.mutateAsync({
kind: form.kind,
id: form.id || "new",
body: {
kind: form.kind,
base_url: form.base_url,
api_key_ref: form.api_key_ref.trim() || undefined,
},
@@ -620,6 +740,21 @@ function IntegrationEditor({
}
}
const saving = createIntegration.isPending || updateIntegration.isPending;
// New instances must carry an API key (there's no saved key to fall back on);
// edits may leave it blank to keep the stored key (has_api_key).
const hasApiKey = form.api_key_ref.trim().length > 0 || form.has_api_key;
const canSave = form.name.trim().length > 0 && form.base_url.trim().length > 0 && hasApiKey;
function handleSave() {
const payload = formToIntegration(form);
if (isNew) {
createIntegration.mutate(payload);
} else {
updateIntegration.mutate(payload);
}
}
return (
<div className="border-border bg-card space-y-5 rounded-lg border p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -627,6 +762,8 @@ function IntegrationEditor({
<Plug className="text-primary h-4 w-4" />
<h2 className="text-lg font-semibold tracking-normal">{title}</h2>
{form.has_api_key ? <Badge variant="secondary">Key saved</Badge> : null}
{isNew ? <Badge variant="outline">New</Badge> : null}
{isDirty ? <Badge variant="secondary">Unsaved changes</Badge> : null}
</div>
<div className="flex items-center gap-2">
<Button
@@ -637,12 +774,55 @@ function IntegrationEditor({
disabled={!canLoadOptions || loadOptions.isPending}
>
<RefreshCw className="h-4 w-4" />
{loadOptions.isPending ? "Loading" : "Load Settings"}
{loadOptions.isPending ? "Loading" : "Test connection"}
</Button>
<Switch checked={form.enabled} onCheckedChange={(enabled) => onChange({ enabled })} />
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Name">
<Input
value={form.name}
onChange={(event) => onChange({ name: event.target.value })}
placeholder={`${title} instance`}
/>
</Field>
<SwitchField
label="4K instance"
checked={form.is_4k}
onCheckedChange={(is_4k) =>
onChange({
is_4k,
is_default: is_4k ? false : form.is_default,
is_default_4k: is_4k ? form.is_default_4k : false,
})
}
/>
<SwitchField
label="Default (HD)"
description={
form.is_4k
? "Disabled because this is a 4K instance."
: "Default instance for 1080p requests."
}
checked={form.is_default}
disabled={form.is_4k}
onCheckedChange={(is_default) => onChange({ is_default })}
/>
<SwitchField
label="Default 4K"
description={
form.is_4k
? "Default instance for 2160p requests."
: "Enable the 4K instance toggle to set this."
}
checked={form.is_default_4k}
disabled={!form.is_4k}
onCheckedChange={(is_default_4k) => onChange({ is_default_4k })}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Base URL">
<Input
@@ -785,6 +965,174 @@ function IntegrationEditor({
{options.tags.length === 1 ? "" : "s"}.
</p>
) : null}
<div className="border-border space-y-4 rounded-lg border p-3">
<button
type="button"
className="flex w-full items-center justify-between gap-2 text-left"
onClick={() => setAnimeOpen((open) => !open)}
aria-expanded={animeOpen}
aria-controls={animePanelID}
>
<div className="flex items-center gap-2">
{animeOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
<span className="text-sm font-medium">Anime overrides</span>
{form.anime_enabled ? <Badge variant="secondary">On</Badge> : null}
</div>
</button>
{animeOpen ? (
<div id={animePanelID} className="space-y-4" role="region" aria-label="Anime overrides">
<SwitchField
label="Enable anime overrides"
description="Route anime requests to a dedicated profile, root folder, and tags."
checked={form.anime_enabled}
onCheckedChange={(anime_enabled) => onChange({ anime_enabled })}
/>
{form.anime_enabled ? (
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Anime quality profile">
{animeQualityProfiles.length > 0 ? (
<Select
value={form.anime_quality_profile_id}
onValueChange={(anime_quality_profile_id) =>
onChange({ anime_quality_profile_id })
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select quality profile" />
</SelectTrigger>
<SelectContent>
{animeQualityProfiles.map((profile) => (
<SelectItem key={profile.id} value={String(profile.id)}>
{profile.name} ({profile.id})
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
type="number"
min={1}
value={form.anime_quality_profile_id}
onChange={(event) =>
onChange({ anime_quality_profile_id: event.target.value })
}
/>
)}
</Field>
<Field label="Anime root folder">
{animeRootFolders.length > 0 ? (
<Select
value={form.anime_root_folder}
onValueChange={(anime_root_folder) => onChange({ anime_root_folder })}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select root folder" />
</SelectTrigger>
<SelectContent>
{animeRootFolders.map((folder) => (
<SelectItem key={folder.path} value={folder.path}>
{rootFolderLabel(folder)}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={form.anime_root_folder}
onChange={(event) => onChange({ anime_root_folder: event.target.value })}
placeholder="/media/anime"
/>
)}
</Field>
<Field label="Anime tags">
<Input
value={form.anime_tags}
onChange={(event) => onChange({ anime_tags: event.target.value })}
placeholder="1, 2"
/>
{tags.length > 0 ? (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => {
const selected = selectedAnimeTags.includes(tag.id);
return (
<Button
key={tag.id}
type="button"
size="xs"
variant={selected ? "secondary" : "outline"}
onClick={() =>
onChange({ anime_tags: toggleTag(form.anime_tags, tag.id) })
}
>
{tag.label || `Tag ${tag.id}`}
</Button>
);
})}
</div>
) : null}
</Field>
</div>
) : null}
</div>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" onClick={handleSave} disabled={!canSave || saving}>
<Save className="h-4 w-4" />
{isNew ? "Create instance" : "Save"}
</Button>
{isNew ? (
<Button type="button" variant="ghost" onClick={onRemove}>
<X className="h-4 w-4" />
Discard
</Button>
) : (
<Button
type="button"
variant="ghost"
className="text-destructive"
onClick={() => setConfirmDelete(true)}
disabled={deleteIntegration.isPending}
>
<Trash2 className="h-4 w-4" />
Delete
</Button>
)}
</div>
<Dialog
open={confirmDelete}
onOpenChange={(open) => {
if (!open) setConfirmDelete(false);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete instance</DialogTitle>
<DialogDescription>
{`"${form.name.trim() || title}" will be permanently removed. New requests will no longer route to this instance.`}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
deleteIntegration.mutate(form.id);
setConfirmDelete(false);
}}
disabled={deleteIntegration.isPending}
>
<Trash2 className="h-4 w-4" />
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -795,8 +1143,13 @@ function integrationToForm(
): IntegrationFormState {
const options = integration?.options ?? {};
return {
id: integration?.id ?? "",
name: integration?.name ?? "",
kind,
enabled: integration?.enabled ?? false,
enabled: integration?.enabled ?? true,
is_4k: integration?.is_4k ?? false,
is_default: integration?.is_default ?? false,
is_default_4k: integration?.is_default_4k ?? false,
base_url: integration?.base_url ?? "",
api_key_ref: "",
root_folder: integration?.root_folder ?? "",
@@ -804,6 +1157,12 @@ function integrationToForm(
? String(integration.quality_profile_id)
: "",
tags: integration?.tags?.join(", ") ?? "",
anime_enabled: integration?.anime_enabled ?? false,
anime_quality_profile_id: integration?.anime_quality_profile_id
? String(integration.anime_quality_profile_id)
: "",
anime_root_folder: integration?.anime_root_folder ?? "",
anime_tags: integration?.anime_tags?.join(", ") ?? "",
search_on_add: boolOption(options, "search_on_add", true),
minimum_availability: stringOption(options, "minimum_availability", "released"),
series_type: stringOption(options, "series_type", "standard"),
@@ -814,6 +1173,7 @@ function integrationToForm(
function formToIntegration(form: IntegrationFormState): RequestIntegration {
const qualityProfileID = Number(form.quality_profile_id);
const animeQualityProfileID = Number(form.anime_quality_profile_id);
const options: Record<string, unknown> = {
search_on_add: form.search_on_add,
};
@@ -825,18 +1185,51 @@ function formToIntegration(form: IntegrationFormState): RequestIntegration {
}
return {
id: form.id,
name: form.name.trim(),
kind: form.kind,
enabled: form.enabled,
is_4k: form.is_4k,
is_default: form.is_default,
is_default_4k: form.is_default_4k,
base_url: form.base_url.trim(),
api_key_ref: form.api_key_ref.trim() || undefined,
root_folder: form.root_folder.trim(),
quality_profile_id:
Number.isFinite(qualityProfileID) && qualityProfileID > 0 ? qualityProfileID : undefined,
tags: parseTags(form.tags),
anime_enabled: form.anime_enabled,
anime_quality_profile_id:
Number.isFinite(animeQualityProfileID) && animeQualityProfileID > 0
? animeQualityProfileID
: undefined,
anime_root_folder: form.anime_root_folder.trim() || undefined,
anime_tags: parseTags(form.anime_tags),
options,
};
}
function isIntegrationDirty(form: IntegrationFormState, source: RequestIntegration): boolean {
// A pending API key entry always counts as an unsaved change.
if (form.api_key_ref.trim().length > 0) return true;
const next = formToIntegration(form);
const seeded = integrationToForm(form.kind, source);
const original = formToIntegration(seeded);
return (
JSON.stringify(stripIntegrationForCompare(next)) !==
JSON.stringify(stripIntegrationForCompare(original))
);
}
function stripIntegrationForCompare(integration: RequestIntegration): Record<string, unknown> {
// api_key_ref is write-only (never round-trips from the source) and is handled
// separately above, so exclude it from the structural comparison.
const { api_key_ref: _apiKeyRef, ...rest } = integration;
return rest;
}
function rootFolderChoices(options: RequestIntegrationOptions | null, currentPath: string) {
const folders = options?.root_folders ?? [];
if (!currentPath || folders.some((folder) => folder.path === currentPath)) {
@@ -1077,15 +1470,22 @@ function SwitchField({
label,
checked,
onCheckedChange,
description,
disabled,
}: {
label: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
description?: string;
disabled?: boolean;
}) {
return (
<div className="border-border flex items-center justify-between gap-3 rounded-lg border p-3">
<Label>{label}</Label>
<Switch checked={checked} onCheckedChange={onCheckedChange} />
<div className="space-y-1">
<Label>{label}</Label>
{description ? <p className="text-muted-foreground text-xs">{description}</p> : null}
</div>
<Switch checked={checked} onCheckedChange={onCheckedChange} disabled={disabled} />
</div>
);
}