Files
silo-server/docs/superpowers/specs/2026-06-01-request-multi-instance-arr-routing-design.md
ea3b5a2e29 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>
2026-06-02 11:25:18 -04:00

354 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`