Files
silo-server/docs/superpowers/plans/2026-06-01-request-multi-instance-arr-routing.md
ea3b5a2e29 feat(requests): multi-instance Sonarr/Radarr routing with HD/4K defaults and anime overrides (#39)
* docs: design spec for multi-instance Sonarr/Radarr request routing

Seerr-style multi-instance arr management inside Silo's request system:
many instances per kind, HD/4K default routing, entitlement-driven
dual-quality fan-out, per-instance anime overrides (keyword 210024),
and a one-to-many media_request_targets model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for multi-instance arr request routing

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): migration for multi-instance arr routing

Adds migration 169 to convert request_integrations from a one-row-per-kind
table keyed on `kind` to a multi-instance table keyed on `id`, with HD/4K
defaults, anime overrides, and a new one-to-many media_request_targets table
for per-quality fulfillment tracking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(requests): instance, target, and dual-quality types

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(requests): id-based integration CRUD

Replace upsert-by-kind (UpsertIntegration/UpsertIntegrations) with
GetIntegration, CreateIntegration, UpdateIntegration, DeleteIntegration,
and ClearDefault. Rewrites scanIntegration and integrationColumns to cover
all new multi-instance columns (id, name, is_4k, is_default, is_default_4k,
anime_* fields). Updates the Store interface accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(requests): target persistence and aggregate status

* feat(tmdb): expose keyword ids and original language on detail

* feat(requests): Seerr-exact anime detection (keyword 210024)

* feat(requests): quality/anime routing engine

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): force_dual_quality setting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(requests): multi-target fulfillment, reconcile, retry, and instance CRUD

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): request integration CRUD endpoints, targets in responses, entitlement wiring

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): multi-instance request integration types and CRUD hooks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(web): multi-instance arr manager, dual-quality toggle, per-target queue

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): UX review fixes for arr manager (delete confirm, switch hints, test feedback, dirty + target status)

* fix(requests): address code-review findings (test-connection by id, HD-only default ceiling, retryable partial failure, idempotent submit, transactional defaults, presence/target reconcile, auto-approve gate)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: address CodeRabbit review (anime override fallback, non-null slices, save gate, a11y, DeleteTarget not-found)

- routing: anime fields only override standard root/profile/tags when set,
  so enabling anime with blank fields reuses standard values instead of
  clearing them into an invalid submission
- api: normalize nil Tags/AnimeTags to [] so they serialize as arrays not null
- web: require an API key before saving a NEW instance; add aria-expanded/
  aria-controls to the anime-overrides disclosure toggle
- repo: DeleteTarget returns ErrNotFound when no row was deleted

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): address PR review findings

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-02 11:25:18 -04:00

1705 lines
60 KiB
Markdown
Raw Permalink Blame History

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