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

60 KiB
Raw Permalink Blame History

Multi-Instance Sonarr/Radarr Routing Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replicate Seerr's Sonarr/Radarr management inside Silo's request system — many instances per kind, HD/4K default routing, entitlement-driven dual-quality fan-out, and per-instance anime overrides.

Architecture: request_integrations becomes a multi-row table keyed by id with default-HD/default-4K/anime fields. A new media_request_targets table makes fulfillment one-to-many. A pure routeTargets function chooses targets from the requester's MaxPlaybackQuality entitlement, a force_dual_quality setting, and detected anime status. The Radarr/Sonarr adapters are unchanged — the service builds a resolved Integration (right profile/folder/tags + options["series_type"]) and a per-target Request copy (ExternalID) per call.

Tech Stack: Go (pgx, standard testing), PostgreSQL (paired numbered migrations), React/TypeScript (Vite, React Query).

Spec: docs/superpowers/specs/2026-06-01-request-multi-instance-arr-routing-design.md

Commands assume the repository root is the cwd. Run Go tests with go test ./internal/requests/.... Run the full lint with make lint. Frontend lint: cd web && pnpm run lint.


Phase 0 — Branch

  • Step 0.1: Create a feature branch
git checkout main
git pull
git checkout -b feat/request-multi-instance-arr

Expected: on a new branch off main.


Phase 1 — Data model: migration & Go types

Task 1: Migration 169 (schema)

Files:

  • Create: migrations/169_request_multi_instance.up.sql

  • Create: migrations/169_request_multi_instance.down.sql

  • Step 1.1: Write the up migration

migrations/169_request_multi_instance.up.sql:

-- request_integrations: one-row-per-kind -> many instances keyed by id.
ALTER TABLE public.request_integrations
    ADD COLUMN IF NOT EXISTS id text,
    ADD COLUMN IF NOT EXISTS name text NOT NULL DEFAULT '',
    ADD COLUMN IF NOT EXISTS is_4k boolean NOT NULL DEFAULT false,
    ADD COLUMN IF NOT EXISTS is_default boolean NOT NULL DEFAULT false,
    ADD COLUMN IF NOT EXISTS is_default_4k boolean NOT NULL DEFAULT false,
    ADD COLUMN IF NOT EXISTS anime_enabled boolean NOT NULL DEFAULT false,
    ADD COLUMN IF NOT EXISTS anime_quality_profile_id integer,
    ADD COLUMN IF NOT EXISTS anime_root_folder text NOT NULL DEFAULT '',
    ADD COLUMN IF NOT EXISTS anime_tags integer[] NOT NULL DEFAULT '{}';

-- Backfill: the lone existing row per kind becomes that kind's HD default.
UPDATE public.request_integrations
SET id = gen_random_uuid()::text,
    name = initcap(kind),
    is_default = enabled
WHERE id IS NULL;

-- Swap the primary key from kind to id; keep kind as a plain column.
ALTER TABLE public.request_integrations
    DROP CONSTRAINT request_integrations_pkey;
ALTER TABLE public.request_integrations
    ALTER COLUMN id SET NOT NULL,
    ADD PRIMARY KEY (id);

-- Quality-role invariants: at most one default / one 4K-default per kind.
CREATE UNIQUE INDEX IF NOT EXISTS idx_request_integrations_default_per_kind
    ON public.request_integrations (kind) WHERE is_default;
CREATE UNIQUE INDEX IF NOT EXISTS idx_request_integrations_default4k_per_kind
    ON public.request_integrations (kind) WHERE is_default_4k;

-- Targets: one request -> N fulfillment targets.
CREATE TABLE IF NOT EXISTS public.media_request_targets (
    id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    request_id text NOT NULL REFERENCES public.media_requests(id) ON DELETE CASCADE,
    integration_id text REFERENCES public.request_integrations(id) ON DELETE SET NULL,
    integration_kind text NOT NULL DEFAULT '',
    quality text NOT NULL,
    is_anime boolean NOT NULL DEFAULT false,
    external_id text NOT NULL DEFAULT '',
    external_status text NOT NULL DEFAULT '',
    status text NOT NULL DEFAULT 'queued',
    last_error text NOT NULL DEFAULT '',
    created_at timestamp with time zone DEFAULT now() NOT NULL,
    updated_at timestamp with time zone DEFAULT now() NOT NULL,
    CONSTRAINT media_request_targets_quality_check CHECK (quality IN ('1080p', '2160p')),
    CONSTRAINT media_request_targets_status_check
        CHECK (status IN ('queued', 'downloading', 'completed', 'failed'))
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_media_request_targets_request_quality
    ON public.media_request_targets (request_id, quality);
CREATE INDEX IF NOT EXISTS idx_media_request_targets_request
    ON public.media_request_targets (request_id);

-- Backfill targets from already-submitted requests (those with an external id).
INSERT INTO public.media_request_targets
    (request_id, integration_id, integration_kind, quality, is_anime,
     external_id, external_status, status, created_at, updated_at)
SELECT mr.id,
       ri.id,
       mr.integration_kind,
       '1080p',
       false,
       mr.external_id,
       mr.external_status,
       CASE
         WHEN mr.status = 'completed' THEN 'completed'
         WHEN mr.status = 'downloading' THEN 'downloading'
         WHEN mr.outcome = 'failed' THEN 'failed'
         ELSE 'queued'
       END,
       mr.created_at,
       mr.updated_at
FROM public.media_requests mr
LEFT JOIN public.request_integrations ri ON ri.kind = mr.integration_kind
WHERE mr.external_id <> '';

-- media_requests: add is_anime, move per-fulfillment columns out to targets.
ALTER TABLE public.media_requests
    ADD COLUMN IF NOT EXISTS is_anime boolean NOT NULL DEFAULT false;
ALTER TABLE public.media_requests
    DROP COLUMN IF EXISTS integration_kind,
    DROP COLUMN IF EXISTS external_id,
    DROP COLUMN IF EXISTS external_status;
  • Step 1.2: Write the down migration

migrations/169_request_multi_instance.down.sql:

-- Restore per-fulfillment columns on media_requests.
ALTER TABLE public.media_requests
    ADD COLUMN IF NOT EXISTS integration_kind text NOT NULL DEFAULT '',
    ADD COLUMN IF NOT EXISTS external_id text NOT NULL DEFAULT '',
    ADD COLUMN IF NOT EXISTS external_status text NOT NULL DEFAULT '';

-- Copy back the 1080p target's fulfillment fields (lossy: 4K/anime targets dropped).
UPDATE public.media_requests mr
SET integration_kind = t.integration_kind,
    external_id = t.external_id,
    external_status = t.external_status
FROM public.media_request_targets t
WHERE t.request_id = mr.id AND t.quality = '1080p';

ALTER TABLE public.media_requests DROP COLUMN IF EXISTS is_anime;

DROP TABLE IF EXISTS public.media_request_targets;

-- Collapse request_integrations back to kind-PK (lossy: keep one default per kind).
DELETE FROM public.request_integrations a
USING public.request_integrations b
WHERE a.kind = b.kind AND a.id <> b.id AND b.is_default AND NOT a.is_default;
-- If a kind has no default, keep an arbitrary row and drop the rest.
DELETE FROM public.request_integrations a
USING public.request_integrations b
WHERE a.kind = b.kind AND a.ctid < b.ctid;

DROP INDEX IF EXISTS idx_request_integrations_default_per_kind;
DROP INDEX IF EXISTS idx_request_integrations_default4k_per_kind;

ALTER TABLE public.request_integrations DROP CONSTRAINT request_integrations_pkey;
ALTER TABLE public.request_integrations ADD PRIMARY KEY (kind);
ALTER TABLE public.request_integrations
    DROP COLUMN IF EXISTS id,
    DROP COLUMN IF EXISTS name,
    DROP COLUMN IF EXISTS is_4k,
    DROP COLUMN IF EXISTS is_default,
    DROP COLUMN IF EXISTS is_default_4k,
    DROP COLUMN IF EXISTS anime_enabled,
    DROP COLUMN IF EXISTS anime_quality_profile_id,
    DROP COLUMN IF EXISTS anime_root_folder,
    DROP COLUMN IF EXISTS anime_tags;
  • Step 1.3: Apply the migration against a local DB and verify
docker compose up -d postgres
# Apply via the project's migrate path (matches how the server migrates on boot):
make dev-backend   # boots, runs migrations, then Ctrl-C; OR run your migrate tool.
psql "$DATABASE_URL" -c "\d public.media_request_targets"
psql "$DATABASE_URL" -c "\d public.request_integrations"

Expected: media_request_targets exists; request_integrations PK is id; media_requests has no external_id column.

  • Step 1.4: Commit
git add migrations/169_request_multi_instance.up.sql migrations/169_request_multi_instance.down.sql
git commit -m "feat(requests): migration for multi-instance arr routing"

Task 2: Go types for instances and targets

Files:

  • Modify: internal/requests/types.go (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:

type Quality string

const (
	Quality1080p Quality = "1080p"
	Quality2160p Quality = "2160p"
)

// Target is one fulfillment of a request against a single instance at a single
// quality. A request fans out to one Target per resolved quality.
type Target struct {
	ID              int64     `json:"id"`
	RequestID       string    `json:"request_id"`
	IntegrationID   string    `json:"integration_id,omitempty"`
	IntegrationKind string    `json:"integration_kind,omitempty"`
	InstanceName    string    `json:"instance_name,omitempty"`
	Quality         Quality   `json:"quality"`
	IsAnime         bool      `json:"is_anime"`
	ExternalID      string    `json:"external_id,omitempty"`
	ExternalStatus  string    `json:"external_status,omitempty"`
	Status          Status    `json:"status"`
	LastError       string    `json:"last_error,omitempty"`
	CreatedAt       time.Time `json:"created_at"`
	UpdatedAt       time.Time `json:"updated_at"`
}

Add these fields to the existing Integration struct (keep all current fields):

	ID                    string `json:"id"`
	Name                  string `json:"name"`
	Is4K                  bool   `json:"is_4k"`
	IsDefault             bool   `json:"is_default"`
	IsDefault4K           bool   `json:"is_default_4k"`
	AnimeEnabled          bool   `json:"anime_enabled"`
	AnimeQualityProfileID *int   `json:"anime_quality_profile_id,omitempty"`
	AnimeRootFolder       string `json:"anime_root_folder,omitempty"`
	AnimeTags             []int  `json:"anime_tags"`

Add IsAnime to Request (after IntegrationKind) and Targets for responses:

	IsAnime bool      `json:"is_anime"`
	Targets []Target  `json:"targets,omitempty"`

Add ForceDualQuality to Settings:

	ForceDualQuality bool `json:"force_dual_quality"`

Note: the existing Request.IntegrationKind/ExternalID/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
go build ./internal/requests/...

Expected: builds (no usages broken yet — repository changes come next).

  • Step 2.3: Commit
git add internal/requests/types.go
git commit -m "feat(requests): instance, target, and dual-quality types"

Phase 2 — Repository layer

Task 3: Integration scan/CRUD by id

Files:

  • Modify: internal/requests/repository.go (scanIntegration, ListIntegrations, 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:

const integrationColumns = `id, kind, name, enabled, base_url, api_key_ref,
	root_folder, quality_profile_id, tags, is_4k, is_default, is_default_4k,
	anime_enabled, anime_quality_profile_id, anime_root_folder, anime_tags,
	options, last_check_at, last_check_status, last_check_error, updated_at`

Rewrite scanIntegration to scan in that exact order:

func scanIntegration(row integrationScanner) (Integration, error) {
	var i Integration
	var quality, animeQuality sql.NullInt64
	var tags, animeTags []int32
	var optionsRaw []byte
	var lastCheckAt sql.NullTime
	if err := row.Scan(
		&i.ID, &i.Kind, &i.Name, &i.Enabled, &i.BaseURL, &i.APIKeyRef,
		&i.RootFolder, &quality, &tags, &i.Is4K, &i.IsDefault, &i.IsDefault4K,
		&i.AnimeEnabled, &animeQuality, &i.AnimeRootFolder, &animeTags,
		&optionsRaw, &lastCheckAt, &i.LastCheckStatus, &i.LastCheckError, &i.UpdatedAt,
	); err != nil {
		return Integration{}, err
	}
	if quality.Valid {
		v := int(quality.Int64)
		i.QualityProfileID = &v
	}
	if animeQuality.Valid {
		v := int(animeQuality.Int64)
		i.AnimeQualityProfileID = &v
	}
	i.Tags = intsFromInt32(tags)
	i.AnimeTags = intsFromInt32(animeTags)
	if len(optionsRaw) > 0 {
		if err := json.Unmarshal(optionsRaw, &i.Options); err != nil {
			return Integration{}, fmt.Errorf("unmarshal request integration options for %s: %w", i.ID, err)
		}
	}
	if i.Options == nil {
		i.Options = map[string]any{}
	}
	if lastCheckAt.Valid {
		i.LastCheckAt = &lastCheckAt.Time
	}
	return i, nil
}

Update ListIntegrations to SELECT +integrationColumns+ FROM request_integrations ORDER BY kind, name.

  • Step 3.2: Replace upsertIntegration/UpsertIntegration(s) with id-based CRUD

Remove upsertIntegration, UpsertIntegration, UpsertIntegrations. Add:

func (r *Repository) GetIntegration(ctx context.Context, id string) (*Integration, error) {
	row := r.pool.QueryRow(ctx, `SELECT `+integrationColumns+
		` FROM request_integrations WHERE id = $1`, id)
	i, err := scanIntegration(row)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return nil, ErrNotFound
		}
		return nil, fmt.Errorf("get request integration: %w", err)
	}
	return &i, nil
}

func (r *Repository) CreateIntegration(ctx context.Context, i Integration) (*Integration, error) {
	if i.Options == nil {
		i.Options = map[string]any{}
	}
	options, err := json.Marshal(i.Options)
	if err != nil {
		return nil, fmt.Errorf("marshal options: %w", err)
	}
	row := r.pool.QueryRow(ctx, `
		INSERT INTO request_integrations (
			id, kind, name, enabled, base_url, api_key_ref, root_folder,
			quality_profile_id, tags, is_4k, is_default, is_default_4k,
			anime_enabled, anime_quality_profile_id, anime_root_folder, anime_tags,
			options, updated_at)
		VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17, now())
		RETURNING `+integrationColumns,
		i.ID, i.Kind, strings.TrimSpace(i.Name), i.Enabled, strings.TrimSpace(i.BaseURL),
		strings.TrimSpace(i.APIKeyRef), strings.TrimSpace(i.RootFolder), i.QualityProfileID,
		int32Slice(i.Tags), i.Is4K, i.IsDefault, i.IsDefault4K, i.AnimeEnabled,
		i.AnimeQualityProfileID, strings.TrimSpace(i.AnimeRootFolder), int32Slice(i.AnimeTags),
		options)
	out, err := scanIntegration(row)
	if err != nil {
		return nil, fmt.Errorf("create request integration: %w", err)
	}
	return &out, nil
}

func (r *Repository) UpdateIntegration(ctx context.Context, i Integration) (*Integration, error) {
	if i.Options == nil {
		i.Options = map[string]any{}
	}
	options, err := json.Marshal(i.Options)
	if err != nil {
		return nil, fmt.Errorf("marshal options: %w", err)
	}
	// Preserve the stored api_key_ref when the caller submits an empty one
	// (write-only field: empty means "unchanged").
	row := r.pool.QueryRow(ctx, `
		UPDATE request_integrations SET
			name=$2, enabled=$3, base_url=$4,
			api_key_ref = CASE WHEN $5 = '' THEN api_key_ref ELSE $5 END,
			root_folder=$6, quality_profile_id=$7, tags=$8, is_4k=$9,
			is_default=$10, is_default_4k=$11, anime_enabled=$12,
			anime_quality_profile_id=$13, anime_root_folder=$14, anime_tags=$15,
			options=$16, updated_at=now()
		WHERE id=$1
		RETURNING `+integrationColumns,
		i.ID, strings.TrimSpace(i.Name), i.Enabled, strings.TrimSpace(i.BaseURL),
		strings.TrimSpace(i.APIKeyRef), strings.TrimSpace(i.RootFolder), i.QualityProfileID,
		int32Slice(i.Tags), i.Is4K, i.IsDefault, i.IsDefault4K, i.AnimeEnabled,
		i.AnimeQualityProfileID, strings.TrimSpace(i.AnimeRootFolder), int32Slice(i.AnimeTags),
		options)
	out, err := scanIntegration(row)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return nil, ErrNotFound
		}
		return nil, fmt.Errorf("update request integration: %w", err)
	}
	return &out, nil
}

func (r *Repository) DeleteIntegration(ctx context.Context, id string) error {
	tag, err := r.pool.Exec(ctx, `DELETE FROM request_integrations WHERE id = $1`, id)
	if err != nil {
		return fmt.Errorf("delete request integration: %w", err)
	}
	if tag.RowsAffected() == 0 {
		return ErrNotFound
	}
	return nil
}

The single-default-per-kind invariant is enforced by the partial unique indexes from Task 1. The service (Task 12) clears the prior default in the same transaction before setting a new one; for the repository, add a transactional helper:

func (r *Repository) ClearDefault(ctx context.Context, exec requestExecutor, kind string, fourK bool) error {
	col := "is_default"
	if fourK {
		col = "is_default_4k"
	}
	_, err := exec.Exec(ctx, `UPDATE request_integrations SET `+col+` = false WHERE kind = $1`, kind)
	if err != nil {
		return fmt.Errorf("clear default: %w", err)
	}
	return nil
}
  • Step 3.3: Update the Store interface

In internal/requests/store.go, replace the UpsertIntegration/UpsertIntegrations lines with:

	GetIntegration(ctx context.Context, id string) (*Integration, error)
	CreateIntegration(ctx context.Context, integration Integration) (*Integration, error)
	UpdateIntegration(ctx context.Context, integration Integration) (*Integration, error)
	DeleteIntegration(ctx context.Context, id string) error

(Keep ListIntegrations. The target methods are added in Task 4.)

  • Step 3.4: Build
go build ./internal/requests/...

Expected: fails only in service.go/router.go/main.go referencing removed methods — those are fixed in Phase 4/5/6. Repository + store compile in isolation:

go vet ./internal/requests/ 2>&1 | head
  • Step 3.5: Commit
git add internal/requests/repository.go internal/requests/store.go
git commit -m "feat(requests): id-based integration CRUD"

Task 4: Target persistence + aggregate status

Files:

  • Create: internal/requests/targets.go (target SQL + aggregate logic)

  • Create: internal/requests/targets_test.go

  • Modify: internal/requests/store.go (add target methods)

  • Step 4.1: Write the failing aggregate test

internal/requests/targets_test.go:

package requests

import "testing"

func TestAggregateStatus(t *testing.T) {
	cases := []struct {
		name    string
		targets []Target
		status  Status
		outcome Outcome
	}{
		{"all completed", []Target{{Status: StatusCompleted}, {Status: StatusCompleted}}, StatusCompleted, OutcomeActive},
		{"one downloading", []Target{{Status: StatusCompleted}, {Status: StatusDownloading}}, StatusDownloading, OutcomeActive},
		{"queued only", []Target{{Status: StatusQueued}}, StatusQueued, OutcomeActive},
		{"all failed", []Target{{Status: StatusFailed}, {Status: StatusFailed}}, StatusQueued, OutcomeFailed},
		{"partial fail stays active", []Target{{Status: StatusFailed}, {Status: StatusDownloading}}, StatusDownloading, OutcomeActive},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			gotStatus, gotOutcome := aggregateStatus(tc.targets)
			if gotStatus != tc.status || gotOutcome != tc.outcome {
				t.Fatalf("aggregateStatus = (%s,%s), want (%s,%s)", gotStatus, gotOutcome, tc.status, tc.outcome)
			}
		})
	}
}

This requires a new StatusFailed constant. Add to types.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
go test ./internal/requests/ -run TestAggregateStatus -v

Expected: FAIL (aggregateStatus undefined).

  • Step 4.3: Implement aggregateStatus and target SQL

internal/requests/targets.go:

package requests

import (
	"context"
	"errors"
	"fmt"

	"github.com/jackc/pgx/v5"
)

const targetColumns = `t.id, t.request_id, t.integration_id, t.integration_kind,
	COALESCE(ri.name, ''), t.quality, t.is_anime, t.external_id, t.external_status,
	t.status, t.last_error, t.created_at, t.updated_at`

// aggregateStatus derives a request's status/outcome from its targets.
func aggregateStatus(targets []Target) (Status, Outcome) {
	if len(targets) == 0 {
		return StatusApproved, OutcomeActive
	}
	failed, completed := 0, 0
	anyDownloading, anyQueued := false, false
	for _, t := range targets {
		switch t.Status {
		case StatusFailed:
			failed++
		case StatusCompleted:
			completed++
		case StatusDownloading:
			anyDownloading = true
		case StatusQueued:
			anyQueued = true
		}
	}
	if failed == len(targets) {
		return StatusQueued, OutcomeFailed
	}
	if completed == len(targets) {
		return StatusCompleted, OutcomeActive
	}
	if anyDownloading {
		return StatusDownloading, OutcomeActive
	}
	if anyQueued {
		return StatusQueued, OutcomeActive
	}
	// remaining: mix of completed + failed, none active -> treat as completed.
	return StatusCompleted, OutcomeActive
}

func scanTarget(row requestScanner) (Target, error) {
	var t Target
	var integrationID *string
	if err := row.Scan(&t.ID, &t.RequestID, &integrationID, &t.IntegrationKind,
		&t.InstanceName, &t.Quality, &t.IsAnime, &t.ExternalID, &t.ExternalStatus,
		&t.Status, &t.LastError, &t.CreatedAt, &t.UpdatedAt); err != nil {
		return Target{}, err
	}
	if integrationID != nil {
		t.IntegrationID = *integrationID
	}
	return t, nil
}

func (r *Repository) ListTargets(ctx context.Context, requestID string) ([]Target, error) {
	rows, err := r.pool.Query(ctx, `SELECT `+targetColumns+`
		FROM media_request_targets t
		LEFT JOIN request_integrations ri ON ri.id = t.integration_id
		WHERE t.request_id = $1 ORDER BY t.quality`, requestID)
	if err != nil {
		return nil, fmt.Errorf("list targets: %w", err)
	}
	defer rows.Close()
	var out []Target
	for rows.Next() {
		t, err := scanTarget(rows)
		if err != nil {
			return nil, err
		}
		out = append(out, t)
	}
	return out, rows.Err()
}

func (r *Repository) CreateTarget(ctx context.Context, t Target) (Target, error) {
	var integrationID any
	if t.IntegrationID != "" {
		integrationID = t.IntegrationID
	}
	row := r.pool.QueryRow(ctx, `
		INSERT INTO media_request_targets
			(request_id, integration_id, integration_kind, quality, is_anime,
			 external_id, external_status, status, last_error, updated_at)
		VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9, now())
		RETURNING id`,
		t.RequestID, integrationID, t.IntegrationKind, t.Quality, t.IsAnime,
		t.ExternalID, t.ExternalStatus, t.Status, t.LastError)
	if err := row.Scan(&t.ID); err != nil {
		return Target{}, fmt.Errorf("create target: %w", err)
	}
	return t, nil
}

// UpdateTargetStatus updates one target and recomputes the parent request's
// aggregate status/outcome, all in one transaction.
func (r *Repository) UpdateTargetStatus(ctx context.Context, targetID int64, status Status,
	externalID, externalStatus, lastErr string, actor Viewer) (*Request, error) {
	tx, err := r.pool.Begin(ctx)
	if err != nil {
		return nil, fmt.Errorf("begin target update: %w", err)
	}
	defer tx.Rollback(ctx)

	var requestID string
	if err := tx.QueryRow(ctx, `
		UPDATE media_request_targets
		SET status=$2,
		    external_id = CASE WHEN $3 = '' THEN external_id ELSE $3 END,
		    external_status = CASE WHEN $4 = '' THEN external_status ELSE $4 END,
		    last_error=$5, updated_at=now()
		WHERE id=$1 RETURNING request_id`,
		targetID, status, externalID, externalStatus, lastErr).Scan(&requestID); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return nil, ErrNotFound
		}
		return nil, fmt.Errorf("update target: %w", err)
	}

	req, err := r.recomputeAggregate(ctx, tx, requestID, actor)
	if err != nil {
		return nil, err
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, fmt.Errorf("commit target update: %w", err)
	}
	return req, nil
}

func (r *Repository) recomputeAggregate(ctx context.Context, exec requestExecutor, requestID string, actor Viewer) (*Request, error) {
	rows, err := exec.Query(ctx, `SELECT status FROM media_request_targets WHERE request_id = $1`, requestID)
	if err != nil {
		return nil, fmt.Errorf("load target statuses: %w", err)
	}
	var targets []Target
	for rows.Next() {
		var t Target
		if err := rows.Scan(&t.Status); err != nil {
			rows.Close()
			return nil, err
		}
		targets = append(targets, t)
	}
	rows.Close()
	if err := rows.Err(); err != nil {
		return nil, err
	}
	status, outcome := aggregateStatus(targets)

	var lastErr string
	for _, t := range targets {
		if t.Status == StatusFailed {
			lastErr = "one or more fulfillment targets failed"
			break
		}
	}
	req, err := scanRequest(exec.QueryRow(ctx, `
		UPDATE media_requests
		SET status=$2, outcome=$3,
		    last_error = CASE WHEN $3 = 'failed' THEN $4 ELSE '' END,
		    completed_at = CASE WHEN $2 = 'completed' AND completed_at IS NULL THEN now() ELSE completed_at END,
		    updated_at = now()
		WHERE id=$1 RETURNING `+requestColumns(), requestID, status, outcome, lastErr))
	if err != nil {
		return nil, fmt.Errorf("recompute aggregate: %w", err)
	}
	_ = r.recordEvent(ctx, exec, requestID, "status_"+string(status), actor, string(req.ExternalStatus))
	return req, nil
}

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:

	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):

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
go test ./internal/requests/ -run TestAggregateStatus -v

Expected: PASS.

  • Step 4.8: Commit
git add internal/requests/targets.go internal/requests/targets_test.go internal/requests/store.go internal/requests/repository.go internal/requests/types.go
git commit -m "feat(requests): target persistence and aggregate status"

Phase 3 — Anime detection

Task 5: TMDB keyword ids

Files:

  • Modify: internal/metadata/tmdb/types.go (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):

	OriginalLanguage string
	KeywordIDs       []int

And to the internal detail-response structs (the ones with Genres []genreEntry, around lines 301 and 326) add:

	OriginalLanguage string `json:"original_language"`
	Keywords         struct {
		Keywords []idEntry `json:"keywords"` // movies
		Results  []idEntry `json:"results"`  // tv
	} `json:"keywords"`

Add an idEntry helper type if not present:

type idEntry struct {
	ID   int    `json:"id"`
	Name string `json:"name"`
}
  • Step 5.2: Map keyword ids in 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:

		OriginalLanguage: resp.OriginalLanguage,
		KeywordIDs:       keywordIDs(resp.Keywords.Keywords, resp.Keywords.Results),

Add the helper:

func keywordIDs(groups ...[]idEntry) []int {
	var out []int
	for _, g := range groups {
		for _, e := range g {
			out = append(out, e.ID)
		}
	}
	return out
}
  • Step 5.3: Verify build + existing tmdb tests
go test ./internal/metadata/tmdb/... -run TestGetMediaDetail -v
go build ./internal/metadata/...

Expected: existing tests pass (they assert with_original_language on discover, unaffected); build succeeds.

  • Step 5.4: Commit
git add internal/metadata/tmdb/types.go internal/metadata/tmdb/client.go
git commit -m "feat(tmdb): expose keyword ids and original language on detail"

Task 6: detectAnime

Files:

  • Create: internal/requests/anime.go

  • Create: internal/requests/anime_test.go

  • Step 6.1: Write the failing test

internal/requests/anime_test.go:

package requests

import "testing"

func TestDetectAnime(t *testing.T) {
	if !detectAnime([]int{99, animeKeywordID, 7}) {
		t.Fatal("expected anime when keyword 210024 present")
	}
	if detectAnime([]int{99, 7}) {
		t.Fatal("expected non-anime when keyword 210024 absent")
	}
	if detectAnime(nil) {
		t.Fatal("expected non-anime for empty keywords")
	}
}
  • Step 6.2: Run it — expect failure
go test ./internal/requests/ -run TestDetectAnime -v

Expected: FAIL (detectAnime/animeKeywordID undefined).

  • Step 6.3: Implement

internal/requests/anime.go:

package requests

// animeKeywordID is TMDB's "anime" keyword id. Matches Seerr's ANIME_KEYWORD_ID
// exactly (server/api/themoviedb/constants.ts). Detection is keyword-id only —
// no genre/language fallback — to mirror upstream behavior.
const animeKeywordID = 210024

func detectAnime(keywordIDs []int) bool {
	for _, id := range keywordIDs {
		if id == animeKeywordID {
			return true
		}
	}
	return false
}
  • Step 6.4: Run it — expect pass
go test ./internal/requests/ -run TestDetectAnime -v

Expected: PASS.

  • Step 6.5: Commit
git add internal/requests/anime.go internal/requests/anime_test.go
git commit -m "feat(requests): Seerr-exact anime detection (keyword 210024)"

Phase 4 — Routing engine

Task 7: routeTargets

Files:

  • Create: internal/requests/routing.go

  • Create: internal/requests/routing_test.go

  • Step 7.1: Write the failing table-driven test

internal/requests/routing_test.go:

package requests

import "testing"

func inst(kind, id string, def, def4k, anime bool) Integration {
	qp := 1
	return Integration{
		ID: id, Kind: kind, Name: id, Enabled: true, BaseURL: "http://x",
		APIKeyRef: "k", RootFolder: "/std", QualityProfileID: &qp,
		Is4K: def4k, IsDefault: def, IsDefault4K: def4k, AnimeEnabled: anime,
	}
}

func TestRouteTargets(t *testing.T) {
	hd := inst("radarr", "hd", true, false, false)
	uhd := inst("radarr", "uhd", false, true, false)
	hdAnime := inst("radarr", "hda", true, false, true)

	cases := []struct {
		name      string
		req       Request
		ceiling   string
		force     bool
		instances []Integration
		want      []Quality
		wantAnime bool
	}{
		{"hd only, sd user", Request{MediaType: MediaTypeMovie}, "1080p", false, []Integration{hd, uhd}, []Quality{Quality1080p}, false},
		{"4k user dual", Request{MediaType: MediaTypeMovie}, "2160p", false, []Integration{hd, uhd}, []Quality{Quality1080p, Quality2160p}, false},
		{"force dual overrides role", Request{MediaType: MediaTypeMovie}, "1080p", true, []Integration{hd, uhd}, []Quality{Quality1080p, Quality2160p}, false},
		{"4k user but no 4k default", Request{MediaType: MediaTypeMovie}, "2160p", false, []Integration{hd}, []Quality{Quality1080p}, false},
		{"no hd default", Request{MediaType: MediaTypeMovie}, "2160p", false, []Integration{uhd}, []Quality{Quality2160p}, false},
		{"anime on anime-enabled hd", Request{MediaType: MediaTypeMovie, IsAnime: true}, "1080p", false, []Integration{hdAnime}, []Quality{Quality1080p}, true},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got := routeTargets(tc.req, tc.ceiling, Settings{ForceDualQuality: tc.force}, tc.instances)
			if len(got) != len(tc.want) {
				t.Fatalf("got %d targets, want %d (%v)", len(got), len(tc.want), got)
			}
			for i, q := range tc.want {
				if got[i].Quality != q {
					t.Fatalf("target %d quality = %s, want %s", i, got[i].Quality, q)
				}
				if got[i].IsAnime != tc.wantAnime {
					t.Fatalf("target %d isAnime = %v, want %v", i, got[i].IsAnime, tc.wantAnime)
				}
			}
		})
	}
}
  • Step 7.2: Run it — expect failure
go test ./internal/requests/ -run TestRouteTargets -v

Expected: FAIL (routeTargets/plannedTarget undefined).

  • Step 7.3: Implement

internal/requests/routing.go:

package requests

import "github.com/Silo-Server/silo-server/internal/access"

// plannedTarget is a routing decision: which instance, at which quality, with
// which profile/folder/tags resolved (standard vs anime).
type plannedTarget struct {
	Instance Integration
	Quality  Quality
	IsAnime  bool
}

func integrationKindForMediaType(mediaType MediaType) string {
	if mediaType == MediaTypeSeries {
		return "sonarr"
	}
	return "radarr"
}

// routeTargets decides the fulfillment targets for an approved request.
// 1080p is always desired; 2160p is added when the requester's ceiling allows
// 4K OR force-dual is on. A quality is emitted only if its default instance
// exists for the kind.
func routeTargets(req Request, ceiling string, settings Settings, instances []Integration) []plannedTarget {
	kind := integrationKindForMediaType(req.MediaType)

	wants4K := settings.ForceDualQuality || access.QualityAllowed(access.PlaybackQuality4K, ceiling)

	var hd, uhd *Integration
	for i := range instances {
		in := instances[i]
		if in.Kind != kind || !in.Enabled {
			continue
		}
		if in.IsDefault && hd == nil {
			hd = &instances[i]
		}
		if in.IsDefault4K && uhd == nil {
			uhd = &instances[i]
		}
	}

	var out []plannedTarget
	if hd != nil {
		out = append(out, plannedTarget{Instance: *hd, Quality: Quality1080p, IsAnime: req.IsAnime && hd.AnimeEnabled})
	}
	if wants4K && uhd != nil {
		out = append(out, plannedTarget{Instance: *uhd, Quality: Quality2160p, IsAnime: req.IsAnime && uhd.AnimeEnabled})
	}
	return out
}

// resolveInstance returns a copy of the instance with root folder / quality
// profile / tags (and Sonarr series_type) set for standard vs anime fulfillment.
func resolveInstance(pt plannedTarget) Integration {
	in := pt.Instance
	if in.Options == nil {
		in.Options = map[string]any{}
	} else {
		clone := make(map[string]any, len(in.Options))
		for k, v := range in.Options {
			clone[k] = v
		}
		in.Options = clone
	}
	if pt.IsAnime {
		in.RootFolder = in.AnimeRootFolder
		in.QualityProfileID = in.AnimeQualityProfileID
		in.Tags = in.AnimeTags
		if in.Kind == "sonarr" {
			in.Options["series_type"] = "anime"
		}
	}
	return in
}

access.QualityAllowed(file, ceiling) returns true 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
go test ./internal/requests/ -run TestRouteTargets -v

Expected: PASS.

  • Step 7.5: Commit
git add internal/requests/routing.go internal/requests/routing_test.go
git commit -m "feat(requests): quality/anime routing engine"

Phase 5 — Fulfillment, reconcile, retry, settings

Task 8: Requester entitlement lookup

Files:

  • Modify: internal/requests/service.go (add an entitlement resolver dependency)

The service needs each requester's MaxPlaybackQuality. It already has a PresenceResolver and SecretResolver injected. Add an EntitlementResolver.

  • Step 8.1: Define the interface and wire a setter

In service.go:

type EntitlementResolver interface {
	MaxPlaybackQuality(ctx context.Context, userID int) (string, error)
}

Add a field entitlements EntitlementResolver to Service and:

func (s *Service) SetEntitlementResolver(r EntitlementResolver) { s.entitlements = r }

func (s *Service) requesterCeiling(ctx context.Context, userID int) string {
	if s.entitlements == nil {
		return "" // no resolver -> treat as unlimited (1080p baseline still applies)
	}
	q, err := s.entitlements.MaxPlaybackQuality(ctx, userID)
	if err != nil {
		return access.PlaybackQualityStandard // fail safe: HD only
	}
	return q
}

Add the access import.

  • Step 8.2: Implement the resolver against userstore

userstore exposes per-user MaxPlaybackQuality (see internal/userstore/types.go). In the package that wires the service (where userstore is available — internal/api/router.go and cmd/silo/main.go), add a small adapter. Create internal/requests/entitlements.go:

package requests

import "context"

// QualityLookup is the minimal dependency for resolving a user's ceiling.
type QualityLookup interface {
	GetUserMaxPlaybackQuality(ctx context.Context, userID int) (string, error)
}

type userstoreEntitlements struct{ lookup QualityLookup }

func NewUserstoreEntitlements(lookup QualityLookup) EntitlementResolver {
	return userstoreEntitlements{lookup: lookup}
}

func (e userstoreEntitlements) MaxPlaybackQuality(ctx context.Context, userID int) (string, error) {
	return e.lookup.GetUserMaxPlaybackQuality(ctx, userID)
}

If 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
go build ./internal/requests/...

Expected: builds.

  • Step 8.4: Commit
git add internal/requests/service.go internal/requests/entitlements.go
git commit -m "feat(requests): requester playback-quality entitlement resolver"

Task 9: Detect anime at request creation

Files:

  • Modify: internal/requests/service.go (CreateRequest)

  • Step 9.1: Set is_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:

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
go build ./internal/requests/...

Expected: builds.

  • Step 9.4: Commit
git add internal/requests/service.go internal/requests/store.go internal/requests/repository.go
git commit -m "feat(requests): record anime detection at request creation"

Task 10: Settings — force_dual_quality

Files:

  • Create: migrations/170_request_force_dual_quality.up.sql / .down.sql

  • Modify: internal/requests/repository.go (GetSettings/UpdateSettings)

  • Step 10.1: Migration 170

migrations/170_request_force_dual_quality.up.sql:

ALTER TABLE public.request_settings
    ADD COLUMN IF NOT EXISTS force_dual_quality boolean NOT NULL DEFAULT false;

migrations/170_request_force_dual_quality.down.sql:

ALTER TABLE public.request_settings DROP COLUMN IF EXISTS force_dual_quality;
  • Step 10.2: Read/write the new column

In repository.go, add force_dual_quality to the SELECT in GetSettings and the UPDATE in UpdateSettings, scanning/binding Settings.ForceDualQuality.

  • Step 10.3: Build + commit
go build ./internal/requests/... && git add migrations/170_request_force_dual_quality.up.sql migrations/170_request_force_dual_quality.down.sql internal/requests/repository.go && git commit -m "feat(requests): force_dual_quality setting"

Task 11: Multi-target fulfillment

Files:

  • Modify: internal/requests/service.go (submitApprovedRequest, integrationConfigured)

  • Modify: internal/requests/service_test.go (extend fakes)

  • Step 11.1: Write the failing service test

Add to service_test.go a test that a 4K-entitled user's approved movie request submits to both HD and 4K adapters. Use the existing fake-adapter pattern (see existing service_test.go around the SetFulfillmentAdapters usages). Sketch:

func TestSubmitApprovedFansOutDualQuality(t *testing.T) {
	store := newFakeStore(t) // existing helper
	// seed two enabled radarr instances: one is_default (hd), one is_default_4k (uhd)
	store.addIntegration(inst("radarr", "hd", true, false, false))
	store.addIntegration(inst("radarr", "uhd", false, true, false))

	rec := &recordingMovieAdapter{}
	svc := NewService(store, fakeTMDB{}, fakePresence{})
	svc.SetFulfillmentAdapters(rec, nil)
	svc.SetEntitlementResolver(fixedCeiling{"2160p"})

	req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7}
	if _, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}); err != nil {
		t.Fatal(err)
	}
	if len(rec.calls) != 2 {
		t.Fatalf("expected 2 submissions (hd+uhd), got %d", len(rec.calls))
	}
}

Define recordingMovieAdapter (captures 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
go test ./internal/requests/ -run TestSubmitApprovedFansOutDualQuality -v

Expected: FAIL (still single-target logic).

  • Step 11.3: Rewrite submitApprovedRequest
func (s *Service) submitApprovedRequest(ctx context.Context, req Request, actor Viewer) (*Request, error) {
	if req.Outcome != OutcomeActive || req.Status != StatusApproved {
		return &req, nil
	}
	instances, err := s.store.ListIntegrations(ctx)
	if err != nil {
		return nil, err
	}
	ceiling := s.requesterCeiling(ctx, req.RequestedByUserID)
	settings, err := s.store.GetSettings(ctx)
	if err != nil {
		return nil, err
	}
	planned := routeTargets(req, ceiling, settings, instances)
	if len(planned) == 0 {
		// No routable instance: leave approved, surface in the queue.
		return s.markSubmissionFailed(ctx, req.ID, actor,
			fmt.Errorf("no %s instance configured for the requested quality",
				integrationKindForMediaType(req.MediaType)))
	}

	var lastReq *Request
	for _, pt := range planned {
		resolved := resolveInstance(pt)
		apiKey, err := s.resolveAPIKey(ctx, resolved)
		if err != nil || apiKey == "" {
			lastReq, _ = s.recordFailedTarget(ctx, req, pt, actor, err)
			continue
		}
		resolved.APIKeyRef = apiKey

		target, cerr := s.store.CreateTarget(ctx, Target{
			RequestID: req.ID, IntegrationID: resolved.ID, IntegrationKind: resolved.Kind,
			Quality: pt.Quality, IsAnime: pt.IsAnime, Status: StatusQueued,
		})
		if cerr != nil {
			return nil, cerr
		}

		result, serr := s.submitTarget(ctx, req, resolved)
		if serr != nil {
			lastReq, _ = s.store.UpdateTargetStatus(ctx, target.ID, StatusFailed, "", "", serr.Error(), actor)
			continue
		}
		lastReq, err = s.store.UpdateTargetStatus(ctx, target.ID, StatusQueued,
			result.ExternalID, result.ExternalStatus, "", actor)
		if err != nil {
			return nil, err
		}
	}
	if lastReq == nil {
		return &req, nil
	}
	return lastReq, nil
}

// submitTarget calls the right adapter with a per-target Request copy carrying
// the target's (eventual) external id. ExternalID is empty on first submit.
func (s *Service) submitTarget(ctx context.Context, req Request, resolved Integration) (FulfillmentResult, error) {
	switch req.MediaType {
	case MediaTypeMovie:
		if s.movieAdapter == nil {
			return FulfillmentResult{}, fmt.Errorf("no movie adapter")
		}
		return s.movieAdapter.SubmitMovie(ctx, req, resolved)
	case MediaTypeSeries:
		if s.seriesAdapter == nil {
			return FulfillmentResult{}, fmt.Errorf("no series adapter")
		}
		return s.seriesAdapter.SubmitSeries(ctx, req, resolved)
	default:
		return FulfillmentResult{}, fmt.Errorf("unsupported media type")
	}
}

Add recordFailedTarget (creates a target row already in failed state for unresolved api-key cases) and keep markSubmissionFailed for the zero-targets case (it already sets the request outcome=failed + last_error). Update integrationConfigured to mean "is there an enabled default instance for this kind" (used by CreateRequest's auto-approve gate):

func (s *Service) integrationConfigured(ctx context.Context, mediaType MediaType) (bool, error) {
	instances, err := s.store.ListIntegrations(ctx)
	if err != nil {
		return false, err
	}
	kind := integrationKindForMediaType(mediaType)
	for _, in := range instances {
		if in.Kind == kind && in.Enabled && in.IsDefault && integrationIsConfigured(in) {
			return true, nil
		}
	}
	return false, nil
}

Delete the now-unused integrationForMediaType and integrationKindForMediaType duplicate (keep the one in routing.go).

  • Step 11.4: Run it — expect pass; then full package tests
go test ./internal/requests/ -run TestSubmitApprovedFansOutDualQuality -v
go test ./internal/requests/...

Expected: the new test passes; fix any remaining compile/test breaks in service_test.go from the dropped MarkQueued/QueueUpdate.

  • Step 11.5: Commit
git add internal/requests/service.go internal/requests/service_test.go
git commit -m "feat(requests): multi-target fulfillment fan-out"

Task 12: Reconcile per target + target-scoped retry

Files:

  • Modify: internal/requests/service.go (reconcileRequest, checkFulfillmentStatus, Retry)

  • Step 12.1: Reconcile each non-terminal target

Rewrite reconcileRequest to iterate the request's targets, load each target's instance by integration_id, build a per-target Request copy with ExternalID = target.ExternalID, call the status adapter, and UpdateTargetStatus. Map the adapter's FulfillmentStatus.Status onto the target status (queued/downloading/completed, or failed on outcome). Skip targets already completed/failed.

func (s *Service) reconcileRequest(ctx context.Context, req Request) (reconcileChange, error) {
	targets, err := s.store.ListTargets(ctx, req.ID)
	if err != nil {
		return reconcileChange{}, err
	}
	instances, err := s.store.ListIntegrations(ctx)
	if err != nil {
		return reconcileChange{}, err
	}
	byID := map[string]Integration{}
	for _, in := range instances {
		byID[in.ID] = in
	}
	for _, t := range targets {
		if t.Status == StatusCompleted || t.Status == StatusFailed {
			continue
		}
		in, ok := byID[t.IntegrationID]
		if !ok {
			continue
		}
		apiKey, err := s.resolveAPIKey(ctx, in)
		if err != nil || apiKey == "" {
			continue
		}
		in.APIKeyRef = apiKey
		probe := req
		probe.ExternalID = t.ExternalID
		st, err := s.checkFulfillmentStatus(ctx, probe, in)
		if err != nil {
			continue
		}
		newStatus := targetStatusFromFulfillment(st)
		if newStatus == t.Status {
			continue
		}
		if _, err := s.store.UpdateTargetStatus(ctx, t.ID, newStatus,
			st.ExternalID, st.ExternalStatus, "", Viewer{}); err != nil {
			return reconcileChange{}, err
		}
	}
	return reconcileChange{ /* counters as today */ }, nil
}

Add the mapping helper:

func targetStatusFromFulfillment(st FulfillmentStatus) Status {
	switch st.Status {
	case StatusCompleted:
		return StatusCompleted
	case StatusDownloading:
		return StatusDownloading
	default:
		if st.Outcome == OutcomeFailed {
			return StatusFailed
		}
		return StatusQueued
	}
}

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
go test ./internal/requests/...

Expected: PASS (extend service_test.go with a reconcile case where one target completes and another stays downloading → request downloading; and a retry case re-submitting only the failed target).

  • Step 12.4: Commit
git add internal/requests/service.go internal/requests/service_test.go
git commit -m "feat(requests): per-target reconcile and retry"

Task 13: Service-level integration CRUD + default toggling

Files:

  • Modify: internal/requests/service.go (replace UpsertIntegration(s) with CreateIntegration/UpdateIntegration/DeleteIntegration, enforce invariants)

  • Step 13.1: Validation + default handling

Add service methods that validate the invariants before persisting:

func (s *Service) CreateIntegration(ctx context.Context, viewer Viewer, in Integration) (*Integration, error) {
	if err := requireAdmin(viewer); err != nil { return nil, err }
	if err := validateInstance(&in); err != nil { return nil, err }
	id, err := idgen.NextID()
	if err != nil { return nil, err }
	in.ID = id
	return s.persistInstanceWithDefaults(ctx, in, true)
}

validateInstance enforces: kind ∈ {radarr,sonarr}, non-empty name/base_url, IsDefault ⇒ !Is4K, IsDefault4K ⇒ Is4K. persistInstanceWithDefaults runs in a transaction: if in.IsDefault clear other defaults for the kind, if in.IsDefault4K clear other 4K defaults, then create/update. (Add a repo method WithTx(ctx, func(exec) error) or expose ClearDefault + CreateIntegration/UpdateIntegration accepting an exec — simplest is a repo method SaveIntegrationWithDefaults(ctx, in, isCreate) that does the clear+write atomically using ClearDefault.)

Mirror for UpdateIntegration; DeleteIntegration just calls the repo (FK ON DELETE SET NULL keeps target history).

  • Step 13.2: Build + test + commit
go test ./internal/requests/...
git add internal/requests/service.go internal/requests/repository.go
git commit -m "feat(requests): instance CRUD with default-toggle invariants"

Phase 6 — API handlers & wiring

Task 14: HTTP handlers + service wiring

Files:

  • Modify: the request integration handlers (find with grep -rn "UpsertIntegration\|LoadIntegrationOptions\|ListIntegrations" internal/api)

  • Modify: internal/api/router.go (wire entitlement resolver; update adapter wiring stays the same)

  • Modify: cmd/silo/main.go (same wiring for the reconcile service)

  • Step 14.1: Replace upsert handler with CRUD endpoints

Locate the admin handler that currently calls UpsertIntegrations (it serves the single-form save). Replace with:

  • GET /…/requests/integrations → 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:

requestSvc.SetEntitlementResolver(mediarequests.NewUserstoreEntitlements(userStore))

where userStore implements GetUserMaxPlaybackQuality(ctx, userID) (add that method in userstore if missing, Task 8).

  • Step 14.4: Build the whole server + lint
go build ./...
make lint

Expected: builds clean; lint passes.

  • Step 14.5: Commit
git add internal/api cmd/silo/main.go internal/userstore
git commit -m "feat(api): request integration CRUD endpoints and entitlement wiring"

Phase 7 — Frontend

Frontend tasks follow existing patterns in web/src/pages/admin-settings/IntegrationsSettings.tsx, web/src/hooks/queries/useRequests.ts, 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
cd web && pnpm run lint && pnpm run format:check
git add web/src/hooks/queries/useRequests.ts
git commit -m "feat(web): request integration multi-instance types and hooks"

Task 16: Instance-list manager UI

Files:

  • Modify: web/src/pages/admin-settings/IntegrationsSettings.tsx

  • Modify: web/src/pages/setup-wizard/steps/IntegrationsStep.tsx

  • Step 16.1: Render an instance list per kind

Replace the single Radarr/Sonarr forms with, per kind, a list of instance cards plus an "Add instance" action. Each card is an editable form with: name, base URL, API key (write-only — placeholder "configured" when api_key_ref is empty-from-server but instance exists), a Test connection button that calls useIntegrationOptions and populates root-folder/quality-profile select inputs, tags multiselect, is_4k switch, Default (HD) toggle, Default 4K toggle, and a collapsible Anime section (anime_enabled + anime quality profile / root folder / tags selects).

Client-side invariant enforcement: disable Default (HD) when is_4k is on; disable Default 4K when is_4k is off; when the user enables a default, optimistically clear that default on sibling cards (server is source of truth on save).

  • Step 16.2: Setup wizard stays minimal

In IntegrationsStep.tsx, keep a single Radarr + single Sonarr quick form that creates one instance each via useCreateIntegration with is_default: true. Link to full settings for advanced config.

  • Step 16.3: Lint + commit
cd web && pnpm run lint && pnpm run format:check
git add web/src/pages/admin-settings/IntegrationsSettings.tsx web/src/pages/setup-wizard/steps/IntegrationsStep.tsx
git commit -m "feat(web): multi-instance arr settings manager"

Task 17: Force-dual toggle + queue targets

Files:

  • Modify: web/src/pages/admin-settings/IntegrationsSettings.tsx (or the request settings panel)

  • Modify: web/src/pages/AdminRequests.tsx

  • Step 17.1: Add the global toggle

In the request settings panel, add a switch bound to settings.force_dual_quality with helper text: "Always fulfill in both 1080p and 4K when both a Default HD and Default 4K instance exist, regardless of user role." Save via the existing settings mutation.

  • Step 17.2: Show targets in the admin queue

In AdminRequests.tsx, expand each request row to render request.targets: a quality badge (1080p/2160p), instance_name, per-target status/external_status, and a per-target Retry button (calls the existing retry mutation; if retry is request-level today, it re-submits failed targets — acceptable, since Task 12 made retry target-scoped server-side).

  • Step 17.3: Lint + commit
cd web && pnpm run lint && pnpm run format:check
git add web/src/pages/admin-settings/IntegrationsSettings.tsx web/src/pages/AdminRequests.tsx
git commit -m "feat(web): force-dual toggle and per-target request queue"

Phase 8 — Verification & cross-repo flag

Task 18: Full verification

  • Step 18.1: Backend tests + lint
go test ./...
make lint

Expected: all pass.

  • Step 18.2: Frontend checks
cd web && pnpm run lint && pnpm run format:check && pnpm run build

Expected: clean build.

  • Step 18.3: Manual smoke (local)

Start docker compose up -d postgres redis, make dev-backend, make dev-frontend. As admin: add two Radarr instances (HD default + 4K default), set a user's MaxPlaybackQuality to 2160p, request a movie as that user, confirm two targets appear (1080p + 2160p) in the admin queue. Request an anime series on an anime-enabled Sonarr; confirm the target shows is_anime and Sonarr received seriesType=anime.

  • Step 18.4: make verify-local-paths
make verify-local-paths

Expected: passes (no absolute/local paths committed).

Task 19: Cross-repo client follow-up flag

  • Step 19.1: Record the client-model follow-up

The Request API response dropped top-level integration_kind/external_id/external_status and added is_anime/targets. Open a tracking note/issue for silo-android and silo-apple to verify their request-model deserialization tolerates the removed fields and ignores/parses targets. End-user flow is otherwise unchanged (no 4K toggle, no new permission).


Self-review notes (resolved)

  • Spec §1 data model → Tasks 1–4. §2 routing → Task 7. §3 lifecycle → Tasks 11–12. §4 anime → Tasks 5–6, 9. §5 admin UI → Tasks 16–17. §6 API/clients → Tasks 14, 19. §7 migration → Tasks 1, 10.
  • Adapters unchanged: confirmed Sonarr reads options["series_type"] and status adapters 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.