# 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** ```bash 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`: ```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`: ```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** ```bash 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** ```bash 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` (extend `Integration`, add `Target`, add settings field) - [ ] **Step 2.1: Extend `Integration` and add `Target` + `Quality` constants** In `internal/requests/types.go`, add quality constants and a `Target` type, and extend `Integration`: ```go 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): ```go 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: ```go IsAnime bool `json:"is_anime"` Targets []Target `json:"targets,omitempty"` ``` Add `ForceDualQuality` to `Settings`: ```go ForceDualQuality bool `json:"force_dual_quality"` ``` > Note: the existing `Request.IntegrationKind`/`ExternalID`/`ExternalStatus` fields stay on the struct as transient per-target carriers for the adapters; they have `omitempty` JSON tags and are now empty at the request level, so responses surface fulfillment via `Targets` only. - [ ] **Step 2.2: Verify it compiles** ```bash go build ./internal/requests/... ``` Expected: builds (no usages broken yet — repository changes come next). - [ ] **Step 2.3: Commit** ```bash 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`, replace `upsertIntegration`, add `CreateIntegration`/`UpdateIntegration`/`DeleteIntegration`/`SetDefault`) - Modify: `internal/requests/store.go` (Store interface) - [ ] **Step 3.1: Update `scanIntegration` and 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`: ```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: ```go 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: ```go 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: ```go 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 `Store` interface** In `internal/requests/store.go`, replace the `UpsertIntegration`/`UpsertIntegrations` lines with: ```go 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** ```bash 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: ```bash go vet ./internal/requests/ 2>&1 | head ``` - [ ] **Step 3.5: Commit** ```bash 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`: ```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 `StatusFailed` constant. Add to `types.go`: > ```go > const StatusFailed Status = "failed" // target-only status; requests use outcome=failed > ``` > Note: `media_requests.status` CHECK does not include `failed`; only `media_request_targets.status` does. `aggregateStatus` never returns `StatusFailed` for the request (it returns `StatusQueued` + `OutcomeFailed`). - [ ] **Step 4.2: Run it — expect failure** ```bash go test ./internal/requests/ -run TestAggregateStatus -v ``` Expected: FAIL (`aggregateStatus` undefined). - [ ] **Step 4.3: Implement `aggregateStatus` and target SQL** `internal/requests/targets.go`: ```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 } ``` > `recomputeAggregate` uses `exec.Query`; ensure `requestExecutor` (defined in repository.go) includes `Query`. If it only has `QueryRow`/`Exec`, add `Query(ctx, sql, args...) (pgx.Rows, error)` to that interface — `*pgxpool.Pool` and `pgx.Tx` both satisfy it. - [ ] **Step 4.4: Add target methods to `Store`** In `store.go`: ```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`/`scanRequest` to 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`): ```go 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 `MarkQueued` with 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** ```bash go test ./internal/requests/ -run TestAggregateStatus -v ``` Expected: PASS. - [ ] **Step 4.8: Commit** ```bash 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` (`MediaDetail` gets `Keywords []int`, `OriginalLanguage string`) - Modify: `internal/metadata/tmdb/client.go` (request `append_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): ```go OriginalLanguage string KeywordIDs []int ``` And to the internal detail-response structs (the ones with `Genres []genreEntry`, around lines 301 and 326) add: ```go 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: ```go type idEntry struct { ID int `json:"id"` Name string `json:"name"` } ``` - [ ] **Step 5.2: Map keyword ids in `GetMediaDetail` and 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: ```go OriginalLanguage: resp.OriginalLanguage, KeywordIDs: keywordIDs(resp.Keywords.Keywords, resp.Keywords.Results), ``` Add the helper: ```go 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** ```bash 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** ```bash 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`: ```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** ```bash go test ./internal/requests/ -run TestDetectAnime -v ``` Expected: FAIL (`detectAnime`/`animeKeywordID` undefined). - [ ] **Step 6.3: Implement** `internal/requests/anime.go`: ```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** ```bash go test ./internal/requests/ -run TestDetectAnime -v ``` Expected: PASS. - [ ] **Step 6.5: Commit** ```bash 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`: ```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** ```bash go test ./internal/requests/ -run TestRouteTargets -v ``` Expected: FAIL (`routeTargets`/`plannedTarget` undefined). - [ ] **Step 7.3: Implement** `internal/requests/routing.go`: ```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 when `file <= ceiling`. So `QualityAllowed("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** ```bash go test ./internal/requests/ -run TestRouteTargets -v ``` Expected: PASS. - [ ] **Step 7.5: Commit** ```bash 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`: ```go type EntitlementResolver interface { MaxPlaybackQuality(ctx context.Context, userID int) (string, error) } ``` Add a field `entitlements EntitlementResolver` to `Service` and: ```go 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`: ```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 `userstore` lacks a `GetUserMaxPlaybackQuality(ctx, userID)` method, add a thin one that selects `max_playback_quality` for the user. Keep it in `userstore`, not here. - [ ] **Step 8.3: Build** ```bash go build ./internal/requests/... ``` Expected: builds. - [ ] **Step 8.4: Commit** ```bash 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_anime` from 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: ```go 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_anime` in `Repository.CreateRequest`** In `repository.go` `insertRequest`/`CreateRequest`, add `is_anime` to the INSERT column list and values, sourced from `record.IsAnime`. - [ ] **Step 9.3: Build** ```bash go build ./internal/requests/... ``` Expected: builds. - [ ] **Step 9.4: Commit** ```bash 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`: ```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`: ```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** ```bash 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: ```go 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 each `SubmitMovie` call's `integration.ID`) and `fixedCeiling` (implements `EntitlementResolver`) in the test file. Extend `newFakeStore` to support `addIntegration`, `ListIntegrations`, `ListTargets`, `CreateTarget`, `UpdateTargetStatus` if not already present. - [ ] **Step 11.2: Run it — expect failure** ```bash go test ./internal/requests/ -run TestSubmitApprovedFansOutDualQuality -v ``` Expected: FAIL (still single-target logic). - [ ] **Step 11.3: Rewrite `submitApprovedRequest`** ```go 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): ```go 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** ```bash 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** ```bash 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`. ```go 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: ```go 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 } } ``` > `checkFulfillmentStatus` already dispatches to the movie/series status adapter using `(req, integration)`; keep it but have it take the resolved instance. Update `reconcileChange` counter population to match existing fields used by `ReconcileResult`. - [ ] **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** ```bash 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** ```bash 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` (replace `UpsertIntegration(s)` with `CreateIntegration`/`UpdateIntegration`/`DeleteIntegration`, enforce invariants) - [ ] **Step 13.1: Validation + default handling** Add service methods that validate the invariants before persisting: ```go 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** ```bash 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` → `ListIntegrations` - `POST /…/requests/integrations` → `CreateIntegration` - `PUT /…/requests/integrations/{id}` → `UpdateIntegration` - `DELETE /…/requests/integrations/{id}` → `DeleteIntegration` - `POST /…/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_quality` to 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: ```go 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** ```bash go build ./... make lint ``` Expected: builds clean; lint passes. - [ ] **Step 14.5: Commit** ```bash 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`, and `web/src/pages/AdminRequests.tsx`. Use the existing React Query mutation/query conventions and the shared form components in those files. Each task ends with `cd 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** ```bash 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** ```bash 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** ```bash 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** ```bash go test ./... make lint ``` Expected: all pass. - [ ] **Step 18.2: Frontend checks** ```bash 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`** ```bash 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 read `req.ExternalID`; the service supplies both via `resolveInstance` + per-target `Request` copy (Tasks 11–12). - **`StatusFailed`** is a target-only status; `aggregateStatus` never assigns it to a request (Task 4). - **Entitlement empty-ceiling** semantics flagged in Task 7.3 for confirmation during implementation.