* 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>
17 KiB
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
MaxPlaybackQualityallows 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
MaxPlaybackQualityentitlement, 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):
- Browse/discover via TMDB; each result carries a
RequestState. CreateRequestvalidates: requests enabled, not already available (presence), not already actively requested (dedup bytmdb_id), computes the requester'sEffectivePolicy(quota, blocked, auto-approve), enforces quota.- Auto-approve users with a configured integration → row created
approved, elsependingfor the admin queue. submitApprovedRequest→integrationForMediaType(mediaType)finds the single instance by kind →radarr.Client.SubmitMoviePOSTs to Radarr using that instance's root folder / quality profile / tags → request goesqueued.ReconcileRequestspolls the instance:queued → downloading → completed(orfailed).
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_defaultper kind; at most oneis_default_4kper kind. is_default ⇒ NOT is_4kandis_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:
- Kind =
movie → radarr,series → sonarr. - Desired qualities:
1080pis always desired (baseline for everyone).- Add
2160pifaccess.QualityAllowed("2160p", user.MaxPlaybackQuality)ORforce_dual_qualityis on (the toggle overrides role).
- Pick the default instance per desired quality:
1080p → is_default,2160p → is_default_4k(for that kind). - Profile selection per chosen instance: if
req.is_animeand the instance hasanime_enabled→ use its anime profile / root folder / tags (and, for Sonarr,seriesType: anime); otherwise the standard set. - 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
approvedbut unfulfilled, withlast_error= "no Radarr/Sonarr instance configured for this quality" so it surfaces in the admin queue. - No dedup needed: the
is_default/is_default_4kinvariants 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):
routeTargets(...)→ planned targets.- For each: insert a
media_request_targetsrow, then call the matching adapter with the resolved instance + decrypted key. - On success → target
queuedwith itsexternal_id/external_status; on error → targetfailed+last_error. - 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→ requestcompleted(+completed_at) - any target
downloading→downloading; else anyqueued→queued - all targets
failed→outcome = failed; some failed while others active → request stays active,last_errorset 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 = 210024server/entity/MediaRequest.ts: anime ⇔keywords.resultscontains that id.
Implementation:
- A named constant
animeKeywordID = 210024. detectAnime(detail) bool→ true iff the TMDB keyword ids contain210024. Matching by id (not the name string) avoids false positives from neighboring keywords like "based on anime".- Detected once at
CreateRequesttime and stored onmedia_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 →
LoadIntegrationOptionspopulates root-folder and quality-profile dropdowns. - Standard block: root folder, quality profile, tags.
- Quality role:
is_4kswitch, 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-usersilo-android/silo-appleclients 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 newtargets.
Admin endpoints change from "upsert by kind" to instance CRUD:
GET/POST/PUT/DELETErequest integrations byid;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_settingsgainsforce_dual_quality.- Admin-web-only; no mobile client surface.
7. Migration & backfill
Next migration number: 169 (169_request_multi_instance.{up,down}.sql).
Up:
ALTER request_integrations: addid,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 fromkindtoid; keepkindas a plain column with its CHECK.- Partial unique indexes: one
is_defaultper kind, oneis_default_4kper kind. CREATE media_request_targets(per §1).- Backfill targets: for any
media_requestsrow with anexternal_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. ALTER media_requests: addis_anime bool DEFAULT false; dropintegration_kind,external_id,external_status.idx_media_requests_active_tmdbstays 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_qualityon/off} × {which defaults exist} × {anime vs not} → expected target set.detectAnime: keyword210024present/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
SubmitSeriessetsseriesType=animeonly for anime targets on anime-enabled instances. - Service: extend
service_test.gofakes — 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
detectAnimefor future tuning. - Client model drift: removing top-level external fields from
Requestmust be verified againstsilo-android/silo-appledeserialization 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