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

17 KiB
Raw Permalink Blame History

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