* 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>
60 KiB
Multi-Instance Sonarr/Radarr Routing Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replicate Seerr's Sonarr/Radarr management inside Silo's request system — many instances per kind, HD/4K default routing, entitlement-driven dual-quality fan-out, and per-instance anime overrides.
Architecture: request_integrations becomes a multi-row table keyed by id with default-HD/default-4K/anime fields. A new media_request_targets table makes fulfillment one-to-many. A pure routeTargets function chooses targets from the requester's MaxPlaybackQuality entitlement, a force_dual_quality setting, and detected anime status. The Radarr/Sonarr adapters are unchanged — the service builds a resolved Integration (right profile/folder/tags + options["series_type"]) and a per-target Request copy (ExternalID) per call.
Tech Stack: Go (pgx, standard testing), PostgreSQL (paired numbered migrations), React/TypeScript (Vite, React Query).
Spec: docs/superpowers/specs/2026-06-01-request-multi-instance-arr-routing-design.md
Commands assume the repository root is the cwd. Run Go tests with go test ./internal/requests/.... Run the full lint with make lint. Frontend lint: cd web && pnpm run lint.
Phase 0 — Branch
- Step 0.1: Create a feature branch
git checkout main
git pull
git checkout -b feat/request-multi-instance-arr
Expected: on a new branch off main.
Phase 1 — Data model: migration & Go types
Task 1: Migration 169 (schema)
Files:
-
Create:
migrations/169_request_multi_instance.up.sql -
Create:
migrations/169_request_multi_instance.down.sql -
Step 1.1: Write the up migration
migrations/169_request_multi_instance.up.sql:
-- 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;
- Step 1.2: Write the down migration
migrations/169_request_multi_instance.down.sql:
-- 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;
- Step 1.3: Apply the migration against a local DB and verify
docker compose up -d postgres
# Apply via the project's migrate path (matches how the server migrates on boot):
make dev-backend # boots, runs migrations, then Ctrl-C; OR run your migrate tool.
psql "$DATABASE_URL" -c "\d public.media_request_targets"
psql "$DATABASE_URL" -c "\d public.request_integrations"
Expected: media_request_targets exists; request_integrations PK is id; media_requests has no external_id column.
- Step 1.4: Commit
git add migrations/169_request_multi_instance.up.sql migrations/169_request_multi_instance.down.sql
git commit -m "feat(requests): migration for multi-instance arr routing"
Task 2: Go types for instances and targets
Files:
-
Modify:
internal/requests/types.go(extendIntegration, addTarget, add settings field) -
Step 2.1: Extend
Integrationand addTarget+Qualityconstants
In internal/requests/types.go, add quality constants and a Target type, and extend Integration:
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"`
}
Add these fields to the existing Integration struct (keep all current fields):
ID string `json:"id"`
Name string `json:"name"`
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"`
Add IsAnime to Request (after IntegrationKind) and Targets for responses:
IsAnime bool `json:"is_anime"`
Targets []Target `json:"targets,omitempty"`
Add ForceDualQuality to Settings:
ForceDualQuality bool `json:"force_dual_quality"`
Note: the existing
Request.IntegrationKind/ExternalID/ExternalStatusfields stay on the struct as transient per-target carriers for the adapters; they haveomitemptyJSON tags and are now empty at the request level, so responses surface fulfillment viaTargetsonly.
- Step 2.2: Verify it compiles
go build ./internal/requests/...
Expected: builds (no usages broken yet — repository changes come next).
- Step 2.3: Commit
git add internal/requests/types.go
git commit -m "feat(requests): instance, target, and dual-quality types"
Phase 2 — Repository layer
Task 3: Integration scan/CRUD by id
Files:
-
Modify:
internal/requests/repository.go(scanIntegration,ListIntegrations, replaceupsertIntegration, addCreateIntegration/UpdateIntegration/DeleteIntegration/SetDefault) -
Modify:
internal/requests/store.go(Store interface) -
Step 3.1: Update
scanIntegrationand the column lists
Replace the column list used in ListIntegrations and scanIntegration to include the new fields. New shared column constant near the top of repository.go:
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`
Rewrite scanIntegration to scan in that exact order:
func scanIntegration(row integrationScanner) (Integration, error) {
var i Integration
var quality, animeQuality sql.NullInt64
var tags, animeTags []int32
var optionsRaw []byte
var lastCheckAt sql.NullTime
if err := row.Scan(
&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)
i.QualityProfileID = &v
}
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, &i.Options); err != nil {
return Integration{}, fmt.Errorf("unmarshal request integration options for %s: %w", i.ID, err)
}
}
if i.Options == nil {
i.Options = map[string]any{}
}
if lastCheckAt.Valid {
i.LastCheckAt = &lastCheckAt.Time
}
return i, nil
}
Update ListIntegrations to SELECT +integrationColumns+ FROM request_integrations ORDER BY kind, name.
- Step 3.2: Replace
upsertIntegration/UpsertIntegration(s)with id-based CRUD
Remove upsertIntegration, UpsertIntegration, UpsertIntegrations. Add:
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 {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("get request integration: %w", err)
}
return &i, nil
}
func (r *Repository) CreateIntegration(ctx context.Context, 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 := r.pool.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
}
func (r *Repository) UpdateIntegration(ctx context.Context, 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)
}
// Preserve the stored api_key_ref when the caller submits an empty one
// (write-only field: empty means "unchanged").
row := r.pool.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
}
func (r *Repository) DeleteIntegration(ctx context.Context, id string) error {
tag, err := r.pool.Exec(ctx, `DELETE FROM request_integrations WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("delete request integration: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
The single-default-per-kind invariant is enforced by the partial unique indexes from Task 1. The service (Task 12) clears the prior default in the same transaction before setting a new one; for the repository, add a transactional helper:
func (r *Repository) ClearDefault(ctx context.Context, exec requestExecutor, kind string, fourK bool) error {
col := "is_default"
if fourK {
col = "is_default_4k"
}
_, err := exec.Exec(ctx, `UPDATE request_integrations SET `+col+` = false WHERE kind = $1`, kind)
if err != nil {
return fmt.Errorf("clear default: %w", err)
}
return nil
}
- Step 3.3: Update the
Storeinterface
In internal/requests/store.go, replace the UpsertIntegration/UpsertIntegrations lines with:
GetIntegration(ctx context.Context, id string) (*Integration, error)
CreateIntegration(ctx context.Context, integration Integration) (*Integration, error)
UpdateIntegration(ctx context.Context, integration Integration) (*Integration, error)
DeleteIntegration(ctx context.Context, id string) error
(Keep ListIntegrations. The target methods are added in Task 4.)
- Step 3.4: Build
go build ./internal/requests/...
Expected: fails only in service.go/router.go/main.go referencing removed methods — those are fixed in Phase 4/5/6. Repository + store compile in isolation:
go vet ./internal/requests/ 2>&1 | head
- Step 3.5: Commit
git add internal/requests/repository.go internal/requests/store.go
git commit -m "feat(requests): id-based integration CRUD"
Task 4: Target persistence + aggregate status
Files:
-
Create:
internal/requests/targets.go(target SQL + aggregate logic) -
Create:
internal/requests/targets_test.go -
Modify:
internal/requests/store.go(add target methods) -
Step 4.1: Write the failing aggregate test
internal/requests/targets_test.go:
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},
{"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)
}
})
}
}
This requires a new
StatusFailedconstant. Add totypes.go:const StatusFailed Status = "failed" // target-only status; requests use outcome=failedNote:
media_requests.statusCHECK does not includefailed; onlymedia_request_targets.statusdoes.aggregateStatusnever returnsStatusFailedfor the request (it returnsStatusQueued+OutcomeFailed).
- Step 4.2: Run it — expect failure
go test ./internal/requests/ -run TestAggregateStatus -v
Expected: FAIL (aggregateStatus undefined).
- Step 4.3: Implement
aggregateStatusand target SQL
internal/requests/targets.go:
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 failed == len(targets) {
return StatusQueued, OutcomeFailed
}
if completed == len(targets) {
return StatusCompleted, OutcomeActive
}
if anyDownloading {
return StatusDownloading, OutcomeActive
}
if anyQueued {
return StatusQueued, OutcomeActive
}
// remaining: mix of completed + failed, none active -> treat as completed.
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
}
// 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
}
recomputeAggregateusesexec.Query; ensurerequestExecutor(defined in repository.go) includesQuery. If it only hasQueryRow/Exec, addQuery(ctx, sql, args...) (pgx.Rows, error)to that interface —*pgxpool.Poolandpgx.Txboth satisfy it.
- Step 4.4: Add target methods to
Store
In store.go:
ListTargets(ctx context.Context, requestID string) ([]Target, error)
CreateTarget(ctx context.Context, target Target) (Target, error)
UpdateTargetStatus(ctx context.Context, targetID int64, status Status, externalID, externalStatus, lastErr string, actor Viewer) (*Request, error)
- Step 4.5: Update
requestColumns/scanRequestto drop external fields + add is_anime
In repository.go, change requestColumns() to remove integration_kind, external_id, external_status and add is_anime (place it after requested_by_profile_id):
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, is_anime,
last_error, created_at, updated_at, approved_at, completed_at`
}
In scanRequest, replace the three &req.IntegrationKind, &req.ExternalID, &req.ExternalStatus scans with &req.IsAnime (matching column order).
- Step 4.6: Replace
MarkQueuedwith target-based fulfillment write
MarkQueued and its QueueUpdate referenced the dropped columns. Remove MarkQueued from the repository and store.go (fulfillment now writes targets via CreateTarget/UpdateTargetStatus). Remove the QueueUpdate type from types.go. (Callers are rewritten in Task 11.)
- Step 4.7: Run the aggregate test
go test ./internal/requests/ -run TestAggregateStatus -v
Expected: PASS.
- Step 4.8: Commit
git add internal/requests/targets.go internal/requests/targets_test.go internal/requests/store.go internal/requests/repository.go internal/requests/types.go
git commit -m "feat(requests): target persistence and aggregate status"
Phase 3 — Anime detection
Task 5: TMDB keyword ids
Files:
-
Modify:
internal/metadata/tmdb/types.go(MediaDetailgetsKeywords []int,OriginalLanguage string) -
Modify:
internal/metadata/tmdb/client.go(requestappend_to_response=keywords, map ids) -
Step 5.1: Add fields to
tmdb.MediaDetail
In internal/metadata/tmdb/types.go, add to MediaDetail (the public struct around line 173):
OriginalLanguage string
KeywordIDs []int
And to the internal detail-response structs (the ones with Genres []genreEntry, around lines 301 and 326) add:
OriginalLanguage string `json:"original_language"`
Keywords struct {
Keywords []idEntry `json:"keywords"` // movies
Results []idEntry `json:"results"` // tv
} `json:"keywords"`
Add an idEntry helper type if not present:
type idEntry struct {
ID int `json:"id"`
Name string `json:"name"`
}
- Step 5.2: Map keyword ids in
GetMediaDetailand append the keywords block
In client.go, find where the detail request URL is built and add append_to_response=keywords to the query params (alongside any existing append_to_response; comma-join if one already exists). Where MediaDetail is populated (the two builders near Genres: namesFromGenres(...)), add:
OriginalLanguage: resp.OriginalLanguage,
KeywordIDs: keywordIDs(resp.Keywords.Keywords, resp.Keywords.Results),
Add the helper:
func keywordIDs(groups ...[]idEntry) []int {
var out []int
for _, g := range groups {
for _, e := range g {
out = append(out, e.ID)
}
}
return out
}
- Step 5.3: Verify build + existing tmdb tests
go test ./internal/metadata/tmdb/... -run TestGetMediaDetail -v
go build ./internal/metadata/...
Expected: existing tests pass (they assert with_original_language on discover, unaffected); build succeeds.
- Step 5.4: Commit
git add internal/metadata/tmdb/types.go internal/metadata/tmdb/client.go
git commit -m "feat(tmdb): expose keyword ids and original language on detail"
Task 6: detectAnime
Files:
-
Create:
internal/requests/anime.go -
Create:
internal/requests/anime_test.go -
Step 6.1: Write the failing test
internal/requests/anime_test.go:
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")
}
}
- Step 6.2: Run it — expect failure
go test ./internal/requests/ -run TestDetectAnime -v
Expected: FAIL (detectAnime/animeKeywordID undefined).
- Step 6.3: Implement
internal/requests/anime.go:
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
}
- Step 6.4: Run it — expect pass
go test ./internal/requests/ -run TestDetectAnime -v
Expected: PASS.
- Step 6.5: Commit
git add internal/requests/anime.go internal/requests/anime_test.go
git commit -m "feat(requests): Seerr-exact anime detection (keyword 210024)"
Phase 4 — Routing engine
Task 7: routeTargets
Files:
-
Create:
internal/requests/routing.go -
Create:
internal/requests/routing_test.go -
Step 7.1: Write the failing table-driven test
internal/requests/routing_test.go:
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},
}
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)
}
}
})
}
}
- Step 7.2: Run it — expect failure
go test ./internal/requests/ -run TestRouteTargets -v
Expected: FAIL (routeTargets/plannedTarget undefined).
- Step 7.3: Implement
internal/requests/routing.go:
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.QualityAllowed(access.PlaybackQuality4K, ceiling)
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 {
in.RootFolder = in.AnimeRootFolder
in.QualityProfileID = in.AnimeQualityProfileID
in.Tags = in.AnimeTags
if in.Kind == "sonarr" {
in.Options["series_type"] = "anime"
}
}
return in
}
access.QualityAllowed(file, ceiling)returns true whenfile <= ceiling. SoQualityAllowed("2160p", ceiling)is true exactly when the user's ceiling is 2160p (or unlimited/empty). An empty ceiling means "any" → 4K allowed; confirm that matches your entitlement semantics (it mirrors playback, where empty = no cap).
- Step 7.4: Run it — expect pass
go test ./internal/requests/ -run TestRouteTargets -v
Expected: PASS.
- Step 7.5: Commit
git add internal/requests/routing.go internal/requests/routing_test.go
git commit -m "feat(requests): quality/anime routing engine"
Phase 5 — Fulfillment, reconcile, retry, settings
Task 8: Requester entitlement lookup
Files:
- Modify:
internal/requests/service.go(add an entitlement resolver dependency)
The service needs each requester's MaxPlaybackQuality. It already has a PresenceResolver and SecretResolver injected. Add an EntitlementResolver.
- Step 8.1: Define the interface and wire a setter
In service.go:
type EntitlementResolver interface {
MaxPlaybackQuality(ctx context.Context, userID int) (string, error)
}
Add a field entitlements EntitlementResolver to Service and:
func (s *Service) SetEntitlementResolver(r EntitlementResolver) { s.entitlements = r }
func (s *Service) requesterCeiling(ctx context.Context, userID int) string {
if s.entitlements == nil {
return "" // no resolver -> treat as unlimited (1080p baseline still applies)
}
q, err := s.entitlements.MaxPlaybackQuality(ctx, userID)
if err != nil {
return access.PlaybackQualityStandard // fail safe: HD only
}
return q
}
Add the access import.
- Step 8.2: Implement the resolver against
userstore
userstore exposes per-user MaxPlaybackQuality (see internal/userstore/types.go). In the package that wires the service (where userstore is available — internal/api/router.go and cmd/silo/main.go), add a small adapter. Create internal/requests/entitlements.go:
package requests
import "context"
// QualityLookup is the minimal dependency for resolving a user's ceiling.
type QualityLookup interface {
GetUserMaxPlaybackQuality(ctx context.Context, userID int) (string, error)
}
type userstoreEntitlements struct{ lookup QualityLookup }
func NewUserstoreEntitlements(lookup QualityLookup) EntitlementResolver {
return userstoreEntitlements{lookup: lookup}
}
func (e userstoreEntitlements) MaxPlaybackQuality(ctx context.Context, userID int) (string, error) {
return e.lookup.GetUserMaxPlaybackQuality(ctx, userID)
}
If
userstorelacks aGetUserMaxPlaybackQuality(ctx, userID)method, add a thin one that selectsmax_playback_qualityfor the user. Keep it inuserstore, not here.
- Step 8.3: Build
go build ./internal/requests/...
Expected: builds.
- Step 8.4: Commit
git add internal/requests/service.go internal/requests/entitlements.go
git commit -m "feat(requests): requester playback-quality entitlement resolver"
Task 9: Detect anime at request creation
Files:
-
Modify:
internal/requests/service.go(CreateRequest) -
Step 9.1: Set
is_animefrom the TMDB detail during creation
In CreateRequest, after s.enrichExternalIDs(ctx, &normalized) and before persisting, fetch the detail's keyword ids and set anime on the record. Add a helper:
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
}
return detectAnime(detail.KeywordIDs)
}
In CreateRequest, compute isAnime := s.detectRequestAnime(ctx, normalized.MediaType, normalized.TMDBID) and pass it into CreateRequestRecord (add an IsAnime bool field to CreateRequestRecord in store.go and persist it in Repository.CreateRequest's INSERT — add the is_anime column there).
- Step 9.2: Persist
is_animeinRepository.CreateRequest
In repository.go insertRequest/CreateRequest, add is_anime to the INSERT column list and values, sourced from record.IsAnime.
- Step 9.3: Build
go build ./internal/requests/...
Expected: builds.
- Step 9.4: Commit
git add internal/requests/service.go internal/requests/store.go internal/requests/repository.go
git commit -m "feat(requests): record anime detection at request creation"
Task 10: Settings — force_dual_quality
Files:
-
Create:
migrations/170_request_force_dual_quality.up.sql/.down.sql -
Modify:
internal/requests/repository.go(GetSettings/UpdateSettings) -
Step 10.1: Migration 170
migrations/170_request_force_dual_quality.up.sql:
ALTER TABLE public.request_settings
ADD COLUMN IF NOT EXISTS force_dual_quality boolean NOT NULL DEFAULT false;
migrations/170_request_force_dual_quality.down.sql:
ALTER TABLE public.request_settings DROP COLUMN IF EXISTS force_dual_quality;
- Step 10.2: Read/write the new column
In repository.go, add force_dual_quality to the SELECT in GetSettings and the UPDATE in UpdateSettings, scanning/binding Settings.ForceDualQuality.
- Step 10.3: Build + commit
go build ./internal/requests/... && git add migrations/170_request_force_dual_quality.up.sql migrations/170_request_force_dual_quality.down.sql internal/requests/repository.go && git commit -m "feat(requests): force_dual_quality setting"
Task 11: Multi-target fulfillment
Files:
-
Modify:
internal/requests/service.go(submitApprovedRequest,integrationConfigured) -
Modify:
internal/requests/service_test.go(extend fakes) -
Step 11.1: Write the failing service test
Add to service_test.go a test that a 4K-entitled user's approved movie request submits to both HD and 4K adapters. Use the existing fake-adapter pattern (see existing service_test.go around the SetFulfillmentAdapters usages). Sketch:
func TestSubmitApprovedFansOutDualQuality(t *testing.T) {
store := newFakeStore(t) // existing helper
// seed two enabled radarr instances: one is_default (hd), one is_default_4k (uhd)
store.addIntegration(inst("radarr", "hd", true, false, false))
store.addIntegration(inst("radarr", "uhd", false, true, false))
rec := &recordingMovieAdapter{}
svc := NewService(store, fakeTMDB{}, fakePresence{})
svc.SetFulfillmentAdapters(rec, nil)
svc.SetEntitlementResolver(fixedCeiling{"2160p"})
req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7}
if _, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}); err != nil {
t.Fatal(err)
}
if len(rec.calls) != 2 {
t.Fatalf("expected 2 submissions (hd+uhd), got %d", len(rec.calls))
}
}
Define
recordingMovieAdapter(captures eachSubmitMoviecall'sintegration.ID) andfixedCeiling(implementsEntitlementResolver) in the test file. ExtendnewFakeStoreto supportaddIntegration,ListIntegrations,ListTargets,CreateTarget,UpdateTargetStatusif not already present.
- Step 11.2: Run it — expect failure
go test ./internal/requests/ -run TestSubmitApprovedFansOutDualQuality -v
Expected: FAIL (still single-target logic).
- Step 11.3: Rewrite
submitApprovedRequest
func (s *Service) submitApprovedRequest(ctx context.Context, req Request, actor Viewer) (*Request, error) {
if req.Outcome != OutcomeActive || req.Status != StatusApproved {
return &req, nil
}
instances, err := s.store.ListIntegrations(ctx)
if err != nil {
return nil, err
}
ceiling := s.requesterCeiling(ctx, req.RequestedByUserID)
settings, err := s.store.GetSettings(ctx)
if err != nil {
return nil, err
}
planned := routeTargets(req, ceiling, settings, instances)
if len(planned) == 0 {
// No routable instance: leave approved, surface in the queue.
return s.markSubmissionFailed(ctx, req.ID, actor,
fmt.Errorf("no %s instance configured for the requested quality",
integrationKindForMediaType(req.MediaType)))
}
var lastReq *Request
for _, pt := range planned {
resolved := resolveInstance(pt)
apiKey, err := s.resolveAPIKey(ctx, resolved)
if err != nil || apiKey == "" {
lastReq, _ = s.recordFailedTarget(ctx, req, pt, actor, err)
continue
}
resolved.APIKeyRef = apiKey
target, cerr := s.store.CreateTarget(ctx, Target{
RequestID: req.ID, IntegrationID: resolved.ID, IntegrationKind: resolved.Kind,
Quality: pt.Quality, IsAnime: pt.IsAnime, Status: StatusQueued,
})
if cerr != nil {
return nil, cerr
}
result, serr := s.submitTarget(ctx, req, resolved)
if serr != nil {
lastReq, _ = s.store.UpdateTargetStatus(ctx, target.ID, StatusFailed, "", "", serr.Error(), actor)
continue
}
lastReq, err = s.store.UpdateTargetStatus(ctx, target.ID, StatusQueued,
result.ExternalID, result.ExternalStatus, "", actor)
if err != nil {
return nil, err
}
}
if lastReq == nil {
return &req, nil
}
return lastReq, nil
}
// submitTarget calls the right adapter with a per-target Request copy carrying
// the target's (eventual) external id. ExternalID is empty on first submit.
func (s *Service) submitTarget(ctx context.Context, req Request, resolved Integration) (FulfillmentResult, error) {
switch req.MediaType {
case MediaTypeMovie:
if s.movieAdapter == nil {
return FulfillmentResult{}, fmt.Errorf("no movie adapter")
}
return s.movieAdapter.SubmitMovie(ctx, req, resolved)
case MediaTypeSeries:
if s.seriesAdapter == nil {
return FulfillmentResult{}, fmt.Errorf("no series adapter")
}
return s.seriesAdapter.SubmitSeries(ctx, req, resolved)
default:
return FulfillmentResult{}, fmt.Errorf("unsupported media type")
}
}
Add recordFailedTarget (creates a target row already in failed state for unresolved api-key cases) and keep markSubmissionFailed for the zero-targets case (it already sets the request outcome=failed + last_error). Update integrationConfigured to mean "is there an enabled default instance for this kind" (used by CreateRequest's auto-approve gate):
func (s *Service) integrationConfigured(ctx context.Context, mediaType MediaType) (bool, error) {
instances, err := s.store.ListIntegrations(ctx)
if err != nil {
return false, err
}
kind := integrationKindForMediaType(mediaType)
for _, in := range instances {
if in.Kind == kind && in.Enabled && in.IsDefault && integrationIsConfigured(in) {
return true, nil
}
}
return false, nil
}
Delete the now-unused integrationForMediaType and integrationKindForMediaType duplicate (keep the one in routing.go).
- Step 11.4: Run it — expect pass; then full package tests
go test ./internal/requests/ -run TestSubmitApprovedFansOutDualQuality -v
go test ./internal/requests/...
Expected: the new test passes; fix any remaining compile/test breaks in service_test.go from the dropped MarkQueued/QueueUpdate.
- Step 11.5: Commit
git add internal/requests/service.go internal/requests/service_test.go
git commit -m "feat(requests): multi-target fulfillment fan-out"
Task 12: Reconcile per target + target-scoped retry
Files:
-
Modify:
internal/requests/service.go(reconcileRequest,checkFulfillmentStatus,Retry) -
Step 12.1: Reconcile each non-terminal target
Rewrite reconcileRequest to iterate the request's targets, load each target's instance by integration_id, build a per-target Request copy with ExternalID = target.ExternalID, call the status adapter, and UpdateTargetStatus. Map the adapter's FulfillmentStatus.Status onto the target status (queued/downloading/completed, or failed on outcome). Skip targets already completed/failed.
func (s *Service) reconcileRequest(ctx context.Context, req Request) (reconcileChange, error) {
targets, err := s.store.ListTargets(ctx, req.ID)
if err != nil {
return reconcileChange{}, err
}
instances, err := s.store.ListIntegrations(ctx)
if err != nil {
return reconcileChange{}, err
}
byID := map[string]Integration{}
for _, in := range instances {
byID[in.ID] = in
}
for _, t := range targets {
if t.Status == StatusCompleted || t.Status == StatusFailed {
continue
}
in, ok := byID[t.IntegrationID]
if !ok {
continue
}
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
}
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 reconcileChange{}, err
}
}
return reconcileChange{ /* counters as today */ }, nil
}
Add the mapping helper:
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
}
}
checkFulfillmentStatusalready dispatches to the movie/series status adapter using(req, integration); keep it but have it take the resolved instance. UpdatereconcileChangecounter population to match existing fields used byReconcileResult.
- Step 12.2: Target-scoped retry
Rewrite Retry to re-submit only failed targets (and create missing targets via routeTargets if the plan now yields a quality with no target row). Reuse the submission loop from submitApprovedRequest by extracting a submitPlannedTarget(ctx, req, pt, actor) helper and calling it for the failed/missing qualities.
- Step 12.3: Test reconcile + retry
go test ./internal/requests/...
Expected: PASS (extend service_test.go with a reconcile case where one target completes and another stays downloading → request downloading; and a retry case re-submitting only the failed target).
- Step 12.4: Commit
git add internal/requests/service.go internal/requests/service_test.go
git commit -m "feat(requests): per-target reconcile and retry"
Task 13: Service-level integration CRUD + default toggling
Files:
-
Modify:
internal/requests/service.go(replaceUpsertIntegration(s)withCreateIntegration/UpdateIntegration/DeleteIntegration, enforce invariants) -
Step 13.1: Validation + default handling
Add service methods that validate the invariants before persisting:
func (s *Service) CreateIntegration(ctx context.Context, viewer Viewer, in Integration) (*Integration, error) {
if err := requireAdmin(viewer); err != nil { return nil, err }
if err := validateInstance(&in); err != nil { return nil, err }
id, err := idgen.NextID()
if err != nil { return nil, err }
in.ID = id
return s.persistInstanceWithDefaults(ctx, in, true)
}
validateInstance enforces: kind ∈ {radarr,sonarr}, non-empty name/base_url, IsDefault ⇒ !Is4K, IsDefault4K ⇒ Is4K. persistInstanceWithDefaults runs in a transaction: if in.IsDefault clear other defaults for the kind, if in.IsDefault4K clear other 4K defaults, then create/update. (Add a repo method WithTx(ctx, func(exec) error) or expose ClearDefault + CreateIntegration/UpdateIntegration accepting an exec — simplest is a repo method SaveIntegrationWithDefaults(ctx, in, isCreate) that does the clear+write atomically using ClearDefault.)
Mirror for UpdateIntegration; DeleteIntegration just calls the repo (FK ON DELETE SET NULL keeps target history).
- Step 13.2: Build + test + commit
go test ./internal/requests/...
git add internal/requests/service.go internal/requests/repository.go
git commit -m "feat(requests): instance CRUD with default-toggle invariants"
Phase 6 — API handlers & wiring
Task 14: HTTP handlers + service wiring
Files:
-
Modify: the request integration handlers (find with
grep -rn "UpsertIntegration\|LoadIntegrationOptions\|ListIntegrations" internal/api) -
Modify:
internal/api/router.go(wire entitlement resolver; update adapter wiring stays the same) -
Modify:
cmd/silo/main.go(same wiring for the reconcile service) -
Step 14.1: Replace upsert handler with CRUD endpoints
Locate the admin handler that currently calls UpsertIntegrations (it serves the single-form save). Replace with:
GET /…/requests/integrations→ListIntegrationsPOST /…/requests/integrations→CreateIntegrationPUT /…/requests/integrations/{id}→UpdateIntegrationDELETE /…/requests/integrations/{id}→DeleteIntegrationPOST /…/requests/integrations/{id}/test(or keep the existing options endpoint) →LoadIntegrationOptions
Follow the existing handler/router patterns in the same file (JSON decode into Integration, mediarequests service call, _SENSITIVE_METADATA_KEYS-style stripping is not needed here but never echo api_key_ref back — set it to "" in responses).
- Step 14.2: Add the request settings
force_dual_qualityto the settings handler
The settings GET/PUT already round-trips Settings; the new field flows through automatically once repository.go reads/writes it (Task 10).
- Step 14.3: Wire the entitlement resolver
In router.go and main.go, after requestSvc := mediarequests.NewService(...), add:
requestSvc.SetEntitlementResolver(mediarequests.NewUserstoreEntitlements(userStore))
where userStore implements GetUserMaxPlaybackQuality(ctx, userID) (add that method in userstore if missing, Task 8).
- Step 14.4: Build the whole server + lint
go build ./...
make lint
Expected: builds clean; lint passes.
- Step 14.5: Commit
git add internal/api cmd/silo/main.go internal/userstore
git commit -m "feat(api): request integration CRUD endpoints and entitlement wiring"
Phase 7 — Frontend
Frontend tasks follow existing patterns in
web/src/pages/admin-settings/IntegrationsSettings.tsx,web/src/hooks/queries/useRequests.ts, andweb/src/pages/AdminRequests.tsx. Use the existing React Query mutation/query conventions and the shared form components in those files. Each task ends withcd web && pnpm run lint && pnpm run format:check.
Task 15: Types + query hooks
Files:
-
Modify:
web/src/hooks/queries/useRequests.ts -
Step 15.1: Update TS types
Add to the Integration type: id: string; name: string; is_4k: boolean; is_default: boolean; is_default_4k: boolean; anime_enabled: boolean; anime_quality_profile_id?: number; anime_root_folder?: string; anime_tags: number[];. Add a RequestTarget type (quality: "1080p" | "2160p"; instance_name?: string; status: string; external_status?: string; last_error?: string;) and add is_anime: boolean; targets?: RequestTarget[] to the Request type. Add force_dual_quality: boolean to the Settings type.
- Step 15.2: Replace the upsert mutation with CRUD hooks
Replace useUpsertIntegrations with useCreateIntegration, useUpdateIntegration, useDeleteIntegration (mutations hitting the Task 14 endpoints) and keep useIntegrationOptions (test-connection). Invalidate the integrations query key on success.
- Step 15.3: Lint + commit
cd web && pnpm run lint && pnpm run format:check
git add web/src/hooks/queries/useRequests.ts
git commit -m "feat(web): request integration multi-instance types and hooks"
Task 16: Instance-list manager UI
Files:
-
Modify:
web/src/pages/admin-settings/IntegrationsSettings.tsx -
Modify:
web/src/pages/setup-wizard/steps/IntegrationsStep.tsx -
Step 16.1: Render an instance list per kind
Replace the single Radarr/Sonarr forms with, per kind, a list of instance cards plus an "Add instance" action. Each card is an editable form with: name, base URL, API key (write-only — placeholder "configured" when api_key_ref is empty-from-server but instance exists), a Test connection button that calls useIntegrationOptions and populates root-folder/quality-profile select inputs, tags multiselect, is_4k switch, Default (HD) toggle, Default 4K toggle, and a collapsible Anime section (anime_enabled + anime quality profile / root folder / tags selects).
Client-side invariant enforcement: disable Default (HD) when is_4k is on; disable Default 4K when is_4k is off; when the user enables a default, optimistically clear that default on sibling cards (server is source of truth on save).
- Step 16.2: Setup wizard stays minimal
In IntegrationsStep.tsx, keep a single Radarr + single Sonarr quick form that creates one instance each via useCreateIntegration with is_default: true. Link to full settings for advanced config.
- Step 16.3: Lint + commit
cd web && pnpm run lint && pnpm run format:check
git add web/src/pages/admin-settings/IntegrationsSettings.tsx web/src/pages/setup-wizard/steps/IntegrationsStep.tsx
git commit -m "feat(web): multi-instance arr settings manager"
Task 17: Force-dual toggle + queue targets
Files:
-
Modify:
web/src/pages/admin-settings/IntegrationsSettings.tsx(or the request settings panel) -
Modify:
web/src/pages/AdminRequests.tsx -
Step 17.1: Add the global toggle
In the request settings panel, add a switch bound to settings.force_dual_quality with helper text: "Always fulfill in both 1080p and 4K when both a Default HD and Default 4K instance exist, regardless of user role." Save via the existing settings mutation.
- Step 17.2: Show targets in the admin queue
In AdminRequests.tsx, expand each request row to render request.targets: a quality badge (1080p/2160p), instance_name, per-target status/external_status, and a per-target Retry button (calls the existing retry mutation; if retry is request-level today, it re-submits failed targets — acceptable, since Task 12 made retry target-scoped server-side).
- Step 17.3: Lint + commit
cd web && pnpm run lint && pnpm run format:check
git add web/src/pages/admin-settings/IntegrationsSettings.tsx web/src/pages/AdminRequests.tsx
git commit -m "feat(web): force-dual toggle and per-target request queue"
Phase 8 — Verification & cross-repo flag
Task 18: Full verification
- Step 18.1: Backend tests + lint
go test ./...
make lint
Expected: all pass.
- Step 18.2: Frontend checks
cd web && pnpm run lint && pnpm run format:check && pnpm run build
Expected: clean build.
- Step 18.3: Manual smoke (local)
Start docker compose up -d postgres redis, make dev-backend, make dev-frontend. As admin: add two Radarr instances (HD default + 4K default), set a user's MaxPlaybackQuality to 2160p, request a movie as that user, confirm two targets appear (1080p + 2160p) in the admin queue. Request an anime series on an anime-enabled Sonarr; confirm the target shows is_anime and Sonarr received seriesType=anime.
- Step 18.4:
make verify-local-paths
make verify-local-paths
Expected: passes (no absolute/local paths committed).
Task 19: Cross-repo client follow-up flag
- Step 19.1: Record the client-model follow-up
The Request API response dropped top-level integration_kind/external_id/external_status and added is_anime/targets. Open a tracking note/issue for silo-android and silo-apple to verify their request-model deserialization tolerates the removed fields and ignores/parses targets. End-user flow is otherwise unchanged (no 4K toggle, no new permission).
Self-review notes (resolved)
- Spec §1 data model → Tasks 1–4. §2 routing → Task 7. §3 lifecycle → Tasks 11–12. §4 anime → Tasks 5–6, 9. §5 admin UI → Tasks 16–17. §6 API/clients → Tasks 14, 19. §7 migration → Tasks 1, 10.
- Adapters unchanged: confirmed Sonarr reads
options["series_type"]and status adapters readreq.ExternalID; the service supplies both viaresolveInstance+ per-targetRequestcopy (Tasks 11–12). StatusFailedis a target-only status;aggregateStatusnever assigns it to a request (Task 4).- Entitlement empty-ceiling semantics flagged in Task 7.3 for confirmation during implementation.