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:
committed by
GitHub
co-authored by
Claude Opus 4.8
Quick
parent
a6b510c7c5
commit
ea3b5a2e29
@@ -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`
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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 ©, 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 ©, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) MarkQueued(_ context.Context, id string, update QueueUpdate, _ Viewer) (*Request, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.queued = append(f.queued, update)
|
||||
req := f.requests[id]
|
||||
if req == nil {
|
||||
req = &Request{ID: id}
|
||||
f.requests[id] = req
|
||||
}
|
||||
req.Status = StatusQueued
|
||||
req.Outcome = OutcomeActive
|
||||
req.IntegrationKind = update.IntegrationKind
|
||||
req.ExternalID = update.ExternalID
|
||||
req.ExternalStatus = update.ExternalStatus
|
||||
copy := *req
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetOutcome(_ context.Context, id string, outcome Outcome, _ Viewer, message string) (*Request, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
@@ -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 ©, 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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user