diff --git a/cmd/silo/main.go b/cmd/silo/main.go index 907145fb..cde16fc4 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -29,6 +29,7 @@ import ( pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" + "github.com/Silo-Server/silo-server/internal/access" "github.com/Silo-Server/silo-server/internal/activitylog" "github.com/Silo-Server/silo-server/internal/adminjob" "github.com/Silo-Server/silo-server/internal/api" @@ -1320,6 +1321,14 @@ func main() { ) requestReconcileSvc.SetSecretResolver(settingsRepo) requestReconcileSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil)) + if userStoreProvider != nil { + reconcileResolver := access.NewResolver( + auth.NewUserRepository(deps.DB), + userStoreProvider, + access.NewProfileTokenService(cfg.Auth.JWTSecret, 0), + ) + requestReconcileSvc.SetEntitlementResolver(mediarequests.NewAccessEntitlements(reconcileResolver)) + } taskMgr.Register(tasks.NewReconcileRequestsTask(requestReconcileSvc, 100)) reconcileProviderIDRepo := catalog.NewProviderIDRepository(deps.DB) reconcileEpisodeRepo := catalog.NewEpisodeRepository(deps.DB) diff --git a/docs/superpowers/plans/2026-06-01-request-multi-instance-arr-routing.md b/docs/superpowers/plans/2026-06-01-request-multi-instance-arr-routing.md new file mode 100644 index 00000000..f5cf21ab --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-request-multi-instance-arr-routing.md @@ -0,0 +1,1704 @@ +# 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. diff --git a/docs/superpowers/specs/2026-06-01-request-multi-instance-arr-routing-design.md b/docs/superpowers/specs/2026-06-01-request-multi-instance-arr-routing-design.md new file mode 100644 index 00000000..68a6a8aa --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-request-multi-instance-arr-routing-design.md @@ -0,0 +1,353 @@ +# Multi-Instance Sonarr/Radarr Routing for Requests (Seerr-style) + +**Status:** Design approved, ready for implementation plan +**Date:** 2026-06-01 +**Area:** `internal/requests`, `migrations/`, `web/src` (admin settings + request queue) + +## Summary + +Silo's request system today fulfills every approved request through a single +Radarr (movies) or single Sonarr (series) instance, at whatever quality that +one instance's profile is configured for. There is no 4K concept, no anime +handling, and exactly one instance per kind (`request_integrations.kind` is the +primary key). + +This work replicates Seerr's Sonarr/Radarr management model *inside* Silo's own +request system (no external Seerr dependency): + +- **Many instances per kind** — N Radarr and N Sonarr servers, each with its own + connection, root folder, quality profile, and tags. +- **Default HD + Default 4K routing** — one HD-default and one 4K-default + instance per kind; requests route to the matching default(s). +- **Per-server anime overrides** (Overseerr-style) — each instance carries an + optional anime profile / root folder / tags applied when a title is detected + as anime. +- **Entitlement-driven dual-quality fan-out** — a single user request can be + fulfilled in **both** 1080p and 2160p when the requester's `MaxPlaybackQuality` + allows 4K, or when an admin global toggle forces it for everyone. + +The end-user request action is unchanged: quality is derived from the user's +existing entitlement, not a per-request choice. + +## Goals + +- Support multiple Radarr/Sonarr instances per kind. +- Route by quality (HD vs 4K) to per-kind default instances. +- Fan a single request out to both 1080p and 2160p targets based on the + requester's `MaxPlaybackQuality` entitlement, with an admin override that + forces dual-quality regardless of role. +- Apply per-instance anime overrides using Seerr-exact anime detection. +- Keep the request lifecycle (quotas, approval, reconcile) coherent under + one-to-many fulfillment. + +## Non-Goals + +- No integration *with* a Seerr instance (an earlier idea, dropped). Silo + remains the system of record. +- No per-request manual server/profile picker (Overseerr's advanced UI). Routing + is automatic from defaults + entitlement. +- No separate 1080p/4K *availability* tracking. The pre-request presence check + stays binary ("is this title in the Silo library at all"). +- No admin-configurable anime keyword list (the heuristic is isolated so one can + be added later). +- No dedicated-anime-instance flag — anime is handled by per-instance overrides + (decided during design). + +## Background: current flow + +When a user requests a movie today (`internal/requests/service.go`): + +1. **Browse/discover** via TMDB; each result carries a `RequestState`. +2. `CreateRequest` validates: requests enabled, not already available + (presence), not already actively requested (dedup by `tmdb_id`), computes the + requester's `EffectivePolicy` (quota, blocked, auto-approve), enforces quota. +3. Auto-approve users with a configured integration → row created `approved`, + else `pending` for the admin queue. +4. `submitApprovedRequest` → `integrationForMediaType(mediaType)` finds the + single instance by kind → `radarr.Client.SubmitMovie` POSTs to Radarr using + that instance's root folder / quality profile / tags → request goes `queued`. +5. `ReconcileRequests` polls the instance: `queued → downloading → completed` + (or `failed`). + +The single insertion point that changes is step 4's instance selection. + +## Design + +### 1. Data model + +**`request_integrations` — reworked from one-row-per-kind to many instances:** + +| column | purpose | +|---|---| +| `id` (text PK, via `idgen`) | replaces `kind` as PK | +| `kind` (text, `radarr`\|`sonarr`) | now a plain column, keeps its CHECK | +| `name` (text) | admin-facing label | +| `enabled` (bool) | | +| `base_url`, `api_key_ref` | connection; API key stays a Fernet secret ref, never plaintext | +| `root_folder`, `quality_profile_id`, `tags[]` | standard (non-anime) defaults | +| `is_4k` (bool) | marks this as a 4K server | +| `is_default` (bool) | the HD default for its kind | +| `is_default_4k` (bool) | the 4K default for its kind | +| `anime_enabled` (bool) | apply anime overrides on this instance | +| `anime_quality_profile_id`, `anime_root_folder`, `anime_tags[]` | anime overrides | +| `options` (jsonb) | kind-specific extras (Radarr min-availability, Sonarr season-folders/series-type) | +| `last_check_*`, `created_at`, `updated_at` | unchanged | + +**Invariants** (partial unique indexes + service validation): + +- At most one `is_default` per kind; at most one `is_default_4k` per kind. +- `is_default ⇒ NOT is_4k` and `is_default_4k ⇒ is_4k`. The HD default is an HD + server; the 4K default is a 4K server. HD and 4K defaults are therefore always + distinct instances. + +**New `media_request_targets`:** + +| column | purpose | +|---|---| +| `id` (bigint identity PK) | | +| `request_id` (text → `media_requests` ON DELETE CASCADE) | | +| `integration_id` (text → `request_integrations` ON DELETE SET NULL) | | +| `integration_kind` (text snapshot) | survives instance deletion for history | +| `quality` (text, `1080p`\|`2160p`) | reuses `internal/access` presets | +| `is_anime` (bool) | which profile set was applied | +| `external_id`, `external_status`, `status`, `last_error` | per-target Radarr/Sonarr lifecycle | +| `created_at`, `updated_at` | | +| UNIQUE `(request_id, quality)` | one target per quality per request | + +**`media_requests` changes:** the per-fulfillment columns +(`integration_kind`, `external_id`, `external_status`) move out to +`media_request_targets`. Add `is_anime` (bool). The request's `status` becomes an +**aggregate** over its targets (see §3). `idx_media_requests_active_tmdb` stays — +dedup is still one active request per title regardless of quality, and a +dual-quality request counts as **one** row against the user's quota. + +### 2. Routing engine + +A pure function `routeTargets(req, entitlement, settings, instances) -> []plannedTarget` +replaces `integrationForMediaType` as the selection point in +`submitApprovedRequest`. + +**Inputs:** the request (incl. detected `is_anime`), the requester's +`MaxPlaybackQuality`, the global `force_dual_quality` setting, the configured +instances for the kind. + +**Algorithm:** + +1. **Kind** = `movie → radarr`, `series → sonarr`. +2. **Desired qualities:** + - `1080p` is always desired (baseline for everyone). + - Add `2160p` if `access.QualityAllowed("2160p", user.MaxPlaybackQuality)` + **OR** `force_dual_quality` is on (the toggle overrides role). +3. **Pick the default instance per desired quality:** `1080p → is_default`, + `2160p → is_default_4k` (for that kind). +4. **Profile selection per chosen instance:** if `req.is_anime` **and** the + instance has `anime_enabled` → use its anime profile / root folder / tags + (and, for Sonarr, `seriesType: anime`); otherwise the standard set. +5. Emit one `plannedTarget{instance, quality, isAnime, profile, rootFolder, tags}` + per resolved quality. + +**Edge cases:** + +- A desired quality with **no default instance assigned** is silently skipped + (cannot route 4K without a 4K default — matches "when both are assigned"). +- **Zero** resolved targets (e.g. no HD default configured): the request stays + `approved` but unfulfilled, with `last_error` = "no Radarr/Sonarr instance + configured for this quality" so it surfaces in the admin queue. +- No dedup needed: the `is_default`/`is_default_4k` invariants guarantee HD and + 4K defaults are distinct instances. + +### 3. Fulfillment & reconcile lifecycle + +The adapter interfaces (`SubmitMovie`/`SubmitSeries`, +`CheckMovieStatus`/`CheckSeriesStatus`) are **unchanged** — they already take +`(req, integration)`. We call them **once per target**, passing a *resolved* +`Integration` whose `RootFolder`/`QualityProfileID`/`Tags` are filled from the +standard or anime block. Radarr/Sonarr's notion of "4K" is just that instance's +quality profile + root folder, so adapters stay quality-agnostic. (The Sonarr +adapter additionally sets `seriesType: anime` for anime targets.) + +**On approval (`submitApprovedRequest`):** + +1. `routeTargets(...)` → planned targets. +2. For each: insert a `media_request_targets` row, then call the matching + adapter with the resolved instance + decrypted key. +3. On success → target `queued` with its `external_id`/`external_status`; on + error → target `failed` + `last_error`. +4. **Recompute the request aggregate** in the same transaction as the target + writes. + +**Reconcile loop (`ReconcileRequests`):** for each candidate request, iterate its +**non-terminal targets**, load each target's instance by `integration_id`, call +the status adapter, update the target, then recompute the aggregate. Targets +reconcile independently. + +**Aggregate recompute (single source of truth):** + +- all targets `completed` → request `completed` (+ `completed_at`) +- any target `downloading` → `downloading`; else any `queued` → `queued` +- all targets `failed` → `outcome = failed`; **some** failed while others active + → request stays active, `last_error` set from the failed target(s) + +**Retry** is target-aware: an admin retry re-submits only the `failed` targets +(re-routing if instance config changed), leaving healthy targets alone. + +### 4. Anime detection (Seerr-exact) + +Detection matches upstream Seerr exactly — a single TMDB keyword id, no genre or +language fallback: + +- `server/api/themoviedb/constants.ts`: `ANIME_KEYWORD_ID = 210024` +- `server/entity/MediaRequest.ts`: anime ⇔ `keywords.results` contains that id. + +Implementation: + +- A named constant `animeKeywordID = 210024`. +- `detectAnime(detail) bool` → true iff the TMDB keyword **ids** contain + `210024`. Matching by id (not the name string) avoids false positives from + neighboring keywords like "based on anime". +- Detected once at `CreateRequest` time and stored on `media_requests.is_anime`, + so routing, reconcile, and retries stay consistent. +- Applies to both movies and series; ignored if no instance for that kind has + `anime_enabled`. + +**Supporting TMDB-client change:** extend `tmdb.MediaDetail` to carry keyword +**ids** (add `append_to_response=keywords` to the detail fetch). `GetMediaDetail` +is already called in the request path, so no extra round-trip beyond the append. +(`OriginalLanguage` is not needed under the parity-only decision but may be +mapped opportunistically since it is already in the response.) + +The heuristic is isolated in one function so an admin-configurable keyword list +can be added later without touching routing. + +### 5. Admin UI + +`web/src/pages/admin-settings/IntegrationsSettings.tsx` and the setup wizard +`web/src/pages/setup-wizard/steps/IntegrationsStep.tsx` move from a single +Radarr/Sonarr form to an **instance-list manager** per kind: + +**Per kind — list of instance cards** (add / edit / delete): + +- Connection: name, base URL, API key (write-only; shows "configured" once set, + never echoes the secret), **Test connection** → `LoadIntegrationOptions` + populates root-folder and quality-profile **dropdowns**. +- Standard block: root folder, quality profile, tags. +- Quality role: `is_4k` switch, **Default (HD)** toggle, **Default 4K** toggle. + The form enforces invariants client-side (no Default-HD on a 4K server; + selecting a new default clears the prior one visually); server is source of + truth. +- Anime block (collapsible, `anime_enabled`): anime quality profile, anime root + folder, anime tags — dropdowns from the same test-connection options. + +**Request settings panel** gains the **"Always fulfill in both 1080p and 4K"** +toggle (`force_dual_quality`) with helper text: applies to all requests when both +a Default HD and Default 4K instance exist, regardless of user role. + +**`web/src/pages/AdminRequests.tsx`** (queue): each request row expands to show +its **targets** — quality badge (1080p/2160p), instance name, per-target status, +and per-target **retry** on failed ones. The aggregate status stays the headline. + +**Setup wizard** stays minimal: add one Radarr + one Sonarr, auto-marked Default +HD. Advanced multi-instance/4K/anime config lives in the full settings page. + +### 6. API contract & multi-repo coordination + +**End-user request flow is unchanged** — quality is derived from +`MaxPlaybackQuality`, so the request action and payload are identical (no 4K +toggle, no new permission). + +**`Request` response model** gains `is_anime` and a `targets[]` array +(`quality`, `instance_name`, `status`, `external_status`, `last_error`); the +top-level `integration_kind`/`external_id`/`external_status` are removed in favor +of the aggregate `status` + `targets`. + +- Per `CLAUDE.md`'s multi-repo rule: end-user `silo-android` / `silo-apple` + clients show a single aggregate status, so they likely need **no change** — + but this is flagged as explicit Apple/Android follow-up to verify the request + model tolerates the removed fields and ignores/parses the new `targets`. + +**Admin endpoints** change from "upsert by kind" to instance CRUD: + +- `GET/POST/PUT/DELETE` request integrations **by `id`**; `LoadIntegrationOptions` + (test-connection) stays, keyed by instance. +- Default-toggle handled server-side transactionally (a new HD/4K default clears + the prior one for that kind). +- `request_settings` gains `force_dual_quality`. +- Admin-web-only; no mobile client surface. + +### 7. Migration & backfill + +Next migration number: **169** (`169_request_multi_instance.{up,down}.sql`). + +**Up:** + +1. `ALTER request_integrations`: add `id`, `name`, `is_4k`, `is_default`, + `is_default_4k`, `anime_enabled`, `anime_quality_profile_id`, + `anime_root_folder`, `anime_tags[]`. Backfill existing rows: `id = gen`, + `name = initcap(kind)`, `is_default = enabled` (the lone existing instance + becomes that kind's HD default), everything else default/false. Swap the PK + from `kind` to `id`; keep `kind` as a plain column with its CHECK. +2. Partial unique indexes: one `is_default` per kind, one `is_default_4k` per + kind. +3. `CREATE media_request_targets` (per §1). +4. Backfill targets: for any `media_requests` row with an `external_id`, insert a + target `(quality='1080p', is_anime=false, integration_id = that kind's + instance, integration_kind, external_id, external_status, status = request's + current status)`. Rows never submitted get no target yet. +5. `ALTER media_requests`: add `is_anime bool DEFAULT false`; drop + `integration_kind`, `external_id`, `external_status`. +6. `idx_media_requests_active_tmdb` stays unchanged. + +**Down:** re-add the three columns to `media_requests` and copy back the **1080p** +target's external fields; drop `media_request_targets`; drop `is_anime`; collapse +`request_integrations` to `kind`-PK by keeping each kind's `is_default` instance +and discarding extras; drop the new columns/indexes. + +**Caveat (explicit):** the down migration is **lossy** — rollback discards any +additional instances (beyond one default per kind) and any 4K/anime target +history. This is the unavoidable cost of collapsing a one-to-many back to +one-to-one and is acceptable for a down migration. + +**Go-side data access** (implementation, not SQL): `Integration` struct + +`scanIntegration` gain the new fields; the `Store` interface gains target CRUD + +`ListInstances(kind)`; `scanRequest` drops the external fields; `UpsertIntegration` +becomes id-based CRUD with default-toggle handling in-transaction. + +### 8. Testing + +- **`routeTargets` — table-driven (core of the feature):** matrix of + {1080p vs 2160p entitlement} × {`force_dual_quality` on/off} × {which defaults + exist} × {anime vs not} → expected target set. +- **`detectAnime`:** keyword `210024` present/absent; no name-substring false + positives. +- **Aggregate recompute:** all-complete, partial-failure (request stays active + + `last_error`), all-failed (`outcome=failed`), mixed downloading/queued. +- **Repository:** target CRUD; single-default-per-kind invariant rejects a second + default; migration backfill yields exactly one 1080p target per previously + submitted request. +- **Adapter:** Sonarr `SubmitSeries` sets `seriesType=anime` only for anime + targets on anime-enabled instances. +- **Service:** extend `service_test.go` fakes — a 4K-entitled user's single + request fans out to two adapter submissions with the right resolved instances; + target-scoped retry re-submits only the failed target. +- **Frontend:** instance-form default-invariant enforcement; queue renders + per-target rows + retry. + +## Risks & open considerations + +- **Reconcile cost** scales with targets, not requests (≤2× today). Bounded and + acceptable; the reconcile loop already batches. +- **Anime keyword imperfection** is inherited from Seerr by design (parity + choice). Mitigated by isolating `detectAnime` for future tuning. +- **Client model drift**: removing top-level external fields from `Request` must + be verified against `silo-android` / `silo-apple` deserialization before + release (flagged in §6). + +## References + +- Seerr upstream: https://github.com/seerr-team/seerr + (`server/api/themoviedb/constants.ts`, `server/entity/MediaRequest.ts`) +- Overseerr anime discussions: #2876 (Sonarr for anime only), #3777 (configurable + series type) +- Existing code: `internal/requests/{service,repository,types,store}.go`, + `internal/requests/{radarr,sonarr,arrclient}`, `internal/access/quality.go`, + `migrations/139_media_requests.up.sql` diff --git a/internal/api/handlers/requests.go b/internal/api/handlers/requests.go index a7ca5f6b..420b3152 100644 --- a/internal/api/handlers/requests.go +++ b/internal/api/handlers/requests.go @@ -35,8 +35,9 @@ type RequestService interface { GetUserLimit(ctx context.Context, viewer mediarequests.Viewer, userID int) (*mediarequests.UserLimit, error) UpsertUserLimit(ctx context.Context, viewer mediarequests.Viewer, limit mediarequests.UserLimit) (*mediarequests.UserLimit, error) ListIntegrations(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.Integration, error) - UpsertIntegration(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error) - UpsertIntegrations(ctx context.Context, viewer mediarequests.Viewer, integrations []mediarequests.Integration) ([]mediarequests.Integration, error) + CreateIntegration(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error) + UpdateIntegration(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error) + DeleteIntegration(ctx context.Context, viewer mediarequests.Viewer, id string) error LoadIntegrationOptions(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.IntegrationOptions, error) ListStudios(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error) @@ -422,26 +423,53 @@ func (h *RequestsHandler) HandleListIntegrations(w http.ResponseWriter, r *http. }{Integrations: toIntegrationResponses(integrations)}) } -func (h *RequestsHandler) HandleUpdateIntegrations(w http.ResponseWriter, r *http.Request) { +func (h *RequestsHandler) HandleCreateIntegration(w http.ResponseWriter, r *http.Request) { viewer, ok := requestViewer(w, r, false) if !ok { return } - var body struct { - Integrations []mediarequests.Integration `json:"integrations"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + var integration mediarequests.Integration + if err := json.NewDecoder(r.Body).Decode(&integration); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") return } - updated, err := h.service.UpsertIntegrations(r.Context(), viewer, body.Integrations) + created, err := h.service.CreateIntegration(r.Context(), viewer, integration) if err != nil { writeRequestServiceError(w, err) return } - writeJSON(w, http.StatusOK, struct { - Integrations []requestIntegrationResponse `json:"integrations"` - }{Integrations: toIntegrationResponses(updated)}) + writeJSON(w, http.StatusCreated, requestIntegrationResponseFrom(*created)) +} + +func (h *RequestsHandler) HandleUpdateIntegration(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, false) + if !ok { + return + } + var integration mediarequests.Integration + if err := json.NewDecoder(r.Body).Decode(&integration); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + integration.ID = chi.URLParam(r, "id") + updated, err := h.service.UpdateIntegration(r.Context(), viewer, integration) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, requestIntegrationResponseFrom(*updated)) +} + +func (h *RequestsHandler) HandleDeleteIntegration(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, false) + if !ok { + return + } + if err := h.service.DeleteIntegration(r.Context(), viewer, chi.URLParam(r, "id")); err != nil { + writeRequestServiceError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) } func (h *RequestsHandler) HandleLoadIntegrationOptions(w http.ResponseWriter, r *http.Request) { @@ -456,7 +484,9 @@ func (h *RequestsHandler) HandleLoadIntegrationOptions(w http.ResponseWriter, r return } } - integration.Kind = chi.URLParam(r, "kind") + if id := strings.TrimSpace(chi.URLParam(r, "id")); id != "" { + integration.ID = id + } options, err := h.service.LoadIntegrationOptions(r.Context(), viewer, integration) if err != nil { writeRequestServiceError(w, err) @@ -558,37 +588,69 @@ func parsePositivePathInt(w http.ResponseWriter, r *http.Request, key string) (i } type requestIntegrationResponse struct { - Kind string `json:"kind"` - Enabled bool `json:"enabled"` - BaseURL string `json:"base_url"` - HasAPIKey bool `json:"has_api_key"` - RootFolder string `json:"root_folder"` - QualityProfileID *int `json:"quality_profile_id,omitempty"` - Tags []int `json:"tags"` - Options map[string]any `json:"options"` - LastCheckAt *time.Time `json:"last_check_at,omitempty"` - LastCheckStatus string `json:"last_check_status,omitempty"` - LastCheckError string `json:"last_check_error,omitempty"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + Enabled bool `json:"enabled"` + 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"` + BaseURL string `json:"base_url"` + HasAPIKey bool `json:"has_api_key"` + RootFolder string `json:"root_folder"` + QualityProfileID *int `json:"quality_profile_id,omitempty"` + Tags []int `json:"tags"` + Options map[string]any `json:"options"` + LastCheckAt *time.Time `json:"last_check_at,omitempty"` + LastCheckStatus string `json:"last_check_status,omitempty"` + LastCheckError string `json:"last_check_error,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +func requestIntegrationResponseFrom(integration mediarequests.Integration) requestIntegrationResponse { + // Normalize nil slices so they serialize as [] (not null); the frontend + // types them as number[] and indexes/maps over them. + tags := integration.Tags + if tags == nil { + tags = []int{} + } + animeTags := integration.AnimeTags + if animeTags == nil { + animeTags = []int{} + } + return requestIntegrationResponse{ + ID: integration.ID, + Name: integration.Name, + Kind: integration.Kind, + Enabled: integration.Enabled, + Is4K: integration.Is4K, + IsDefault: integration.IsDefault, + IsDefault4K: integration.IsDefault4K, + AnimeEnabled: integration.AnimeEnabled, + AnimeQualityProfileID: integration.AnimeQualityProfileID, + AnimeRootFolder: integration.AnimeRootFolder, + AnimeTags: animeTags, + BaseURL: integration.BaseURL, + HasAPIKey: strings.TrimSpace(integration.APIKeyRef) != "", + RootFolder: integration.RootFolder, + QualityProfileID: integration.QualityProfileID, + Tags: tags, + Options: integration.Options, + LastCheckAt: integration.LastCheckAt, + LastCheckStatus: integration.LastCheckStatus, + LastCheckError: integration.LastCheckError, + UpdatedAt: integration.UpdatedAt, + } } func toIntegrationResponses(integrations []mediarequests.Integration) []requestIntegrationResponse { out := make([]requestIntegrationResponse, 0, len(integrations)) for _, integration := range integrations { - out = append(out, requestIntegrationResponse{ - Kind: integration.Kind, - Enabled: integration.Enabled, - BaseURL: integration.BaseURL, - HasAPIKey: strings.TrimSpace(integration.APIKeyRef) != "", - RootFolder: integration.RootFolder, - QualityProfileID: integration.QualityProfileID, - Tags: integration.Tags, - Options: integration.Options, - LastCheckAt: integration.LastCheckAt, - LastCheckStatus: integration.LastCheckStatus, - LastCheckError: integration.LastCheckError, - UpdatedAt: integration.UpdatedAt, - }) + out = append(out, requestIntegrationResponseFrom(integration)) } return out } diff --git a/internal/api/handlers/requests_test.go b/internal/api/handlers/requests_test.go index 2f0da73e..95ea16b7 100644 --- a/internal/api/handlers/requests_test.go +++ b/internal/api/handlers/requests_test.go @@ -127,12 +127,16 @@ func (f *fakeRequestService) ListIntegrations(context.Context, mediarequests.Vie return nil, nil } -func (f *fakeRequestService) UpsertIntegration(context.Context, mediarequests.Viewer, mediarequests.Integration) (*mediarequests.Integration, error) { - return nil, nil +func (f *fakeRequestService) CreateIntegration(_ context.Context, _ mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error) { + return &integration, nil } -func (f *fakeRequestService) UpsertIntegrations(context.Context, mediarequests.Viewer, []mediarequests.Integration) ([]mediarequests.Integration, error) { - return nil, nil +func (f *fakeRequestService) UpdateIntegration(_ context.Context, _ mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error) { + return &integration, nil +} + +func (f *fakeRequestService) DeleteIntegration(context.Context, mediarequests.Viewer, string) error { + return nil } func (f *fakeRequestService) LoadIntegrationOptions(context.Context, mediarequests.Viewer, mediarequests.Integration) (*mediarequests.IntegrationOptions, error) { diff --git a/internal/api/router.go b/internal/api/router.go index b5927215..6bf72947 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -410,6 +410,9 @@ func NewRouter(deps Dependencies) chi.Router { ) requestSvc.SetSecretResolver(settingsRepo) requestSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil)) + if viewerResolver != nil { + requestSvc.SetEntitlementResolver(mediarequests.NewAccessEntitlements(viewerResolver)) + } requestHandler = handlers.NewRequestsHandler(requestSvc) if deps.PersonRepo != nil { @@ -2040,8 +2043,10 @@ func NewRouter(deps Dependencies) chi.Router { r.Get("/request-users/{user_id}/limit", requestHandler.HandleGetUserLimit) r.Put("/request-users/{user_id}/limit", requestHandler.HandleUpdateUserLimit) r.Get("/request-integrations", requestHandler.HandleListIntegrations) - r.Put("/request-integrations", requestHandler.HandleUpdateIntegrations) - r.Post("/request-integrations/{kind}/options", requestHandler.HandleLoadIntegrationOptions) + r.Post("/request-integrations", requestHandler.HandleCreateIntegration) + r.Put("/request-integrations/{id}", requestHandler.HandleUpdateIntegration) + r.Delete("/request-integrations/{id}", requestHandler.HandleDeleteIntegration) + r.Post("/request-integrations/{id}/options", requestHandler.HandleLoadIntegrationOptions) } if deps.ActivityLogRepo != nil { diff --git a/internal/metadata/tmdb/client.go b/internal/metadata/tmdb/client.go index 57bab21b..ec15e2ab 100644 --- a/internal/metadata/tmdb/client.go +++ b/internal/metadata/tmdb/client.go @@ -827,14 +827,14 @@ func (c *Client) GetMediaDetail(ctx context.Context, mediaType string, id int) ( switch mediaType { case "movie": - path := fmt.Sprintf("/movie/%d?append_to_response=credits,external_ids,recommendations,release_dates", id) + path := fmt.Sprintf("/movie/%d?append_to_response=credits,external_ids,recommendations,release_dates,keywords", id) var resp movieDetailResponse if err := c.doGet(ctx, path, &resp); err != nil { return nil, err } return normalizeMovieDetail(&resp), nil case "series", "tv": - path := fmt.Sprintf("/tv/%d?append_to_response=credits,external_ids,recommendations,content_ratings", id) + path := fmt.Sprintf("/tv/%d?append_to_response=credits,external_ids,recommendations,content_ratings,keywords", id) var resp tvDetailResponse if err := c.doGet(ctx, path, &resp); err != nil { return nil, err @@ -847,24 +847,26 @@ func (c *Client) GetMediaDetail(ctx context.Context, mediaType string, id int) ( func normalizeMovieDetail(resp *movieDetailResponse) *MediaDetail { detail := &MediaDetail{ - MediaType: "movie", - ID: resp.ID, - IMDbID: resp.IMDbID, - Title: resp.Title, - OriginalTitle: resp.OriginalTitle, - Tagline: resp.Tagline, - Overview: resp.Overview, - PosterPath: resp.PosterPath, - BackdropPath: resp.BackdropPath, - ReleaseDate: resp.ReleaseDate, - Year: releaseYear(resp.ReleaseDate), - Runtime: resp.Runtime, - Genres: namesFromGenres(resp.Genres), - VoteAverage: resp.VoteAverage, - VoteCount: resp.VoteCount, - Status: resp.Status, - Homepage: resp.Homepage, - ContentRating: pickMovieCertification(resp.ReleaseDates), + MediaType: "movie", + ID: resp.ID, + IMDbID: resp.IMDbID, + Title: resp.Title, + OriginalTitle: resp.OriginalTitle, + Tagline: resp.Tagline, + Overview: resp.Overview, + PosterPath: resp.PosterPath, + BackdropPath: resp.BackdropPath, + ReleaseDate: resp.ReleaseDate, + Year: releaseYear(resp.ReleaseDate), + Runtime: resp.Runtime, + Genres: namesFromGenres(resp.Genres), + VoteAverage: resp.VoteAverage, + VoteCount: resp.VoteCount, + Status: resp.Status, + Homepage: resp.Homepage, + ContentRating: pickMovieCertification(resp.ReleaseDates), + OriginalLanguage: resp.OriginalLanguage, + KeywordIDs: keywordIDs(resp.Keywords.Keywords, resp.Keywords.Results), } for _, company := range resp.ProductionCompanies { if name := strings.TrimSpace(company.Name); name != "" { @@ -923,6 +925,8 @@ func normalizeTVDetail(resp *tvDetailResponse) *MediaDetail { NumberOfSeasons: resp.NumberOfSeasons, NumberOfEpisodes: resp.NumberOfEpisodes, ContentRating: pickTVRating(resp.ContentRatings), + OriginalLanguage: resp.OriginalLanguage, + KeywordIDs: keywordIDs(resp.Keywords.Keywords, resp.Keywords.Results), } if len(resp.EpisodeRunTime) > 0 { detail.Runtime = resp.EpisodeRunTime[0] @@ -977,6 +981,18 @@ func namesFromGenres(genres []genreEntry) []string { return out } +// keywordIDs flattens one or more TMDB keyword lists (movies use the +// "keywords" array, tv uses "results") into their numeric ids. +func keywordIDs(groups ...[]idEntry) []int { + var out []int + for _, g := range groups { + for _, e := range g { + out = append(out, e.ID) + } + } + return out +} + // normalizeCast sorts by billing order and caps the result so the response // payload stays bounded — the request detail UI surfaces only the top of the // list anyway. diff --git a/internal/metadata/tmdb/types.go b/internal/metadata/tmdb/types.go index e3cffe4a..2a6cbe23 100644 --- a/internal/metadata/tmdb/types.go +++ b/internal/metadata/tmdb/types.go @@ -191,6 +191,8 @@ type MediaDetail struct { Homepage string ContentRating string ProductionCompanies []string + OriginalLanguage string + KeywordIDs []int NumberOfSeasons int NumberOfEpisodes int @@ -255,6 +257,12 @@ type personEntry struct { Name string `json:"name"` } +// idEntry is a generic {id,name} TMDB object used for keyword lists. +type idEntry struct { + ID int `json:"id"` + Name string `json:"name"` +} + type recommendationsMovieResponse struct { Results []mediaMovieResponse `json:"results"` } @@ -288,16 +296,21 @@ type contentRatingEntry struct { } type movieDetailResponse struct { - ID int `json:"id"` - IMDbID string `json:"imdb_id"` - Title string `json:"title"` - OriginalTitle string `json:"original_title"` - Tagline string `json:"tagline"` - Overview string `json:"overview"` - PosterPath string `json:"poster_path"` - BackdropPath string `json:"backdrop_path"` - ReleaseDate string `json:"release_date"` - Runtime int `json:"runtime"` + ID int `json:"id"` + IMDbID string `json:"imdb_id"` + Title string `json:"title"` + OriginalTitle string `json:"original_title"` + Tagline string `json:"tagline"` + Overview string `json:"overview"` + PosterPath string `json:"poster_path"` + BackdropPath string `json:"backdrop_path"` + ReleaseDate string `json:"release_date"` + Runtime int `json:"runtime"` + OriginalLanguage string `json:"original_language"` + Keywords struct { + Keywords []idEntry `json:"keywords"` // movies + Results []idEntry `json:"results"` // tv + } `json:"keywords"` Genres []genreEntry `json:"genres"` VoteAverage float64 `json:"vote_average"` VoteCount int `json:"vote_count"` @@ -311,27 +324,32 @@ type movieDetailResponse struct { } type tvDetailResponse struct { - ID int `json:"id"` - Name string `json:"name"` - OriginalName string `json:"original_name"` - Tagline string `json:"tagline"` - Overview string `json:"overview"` - PosterPath string `json:"poster_path"` - BackdropPath string `json:"backdrop_path"` - FirstAirDate string `json:"first_air_date"` - LastAirDate string `json:"last_air_date"` - EpisodeRunTime []int `json:"episode_run_time"` - NumberOfSeasons int `json:"number_of_seasons"` - NumberOfEpisodes int `json:"number_of_episodes"` - Genres []genreEntry `json:"genres"` - VoteAverage float64 `json:"vote_average"` - VoteCount int `json:"vote_count"` - Status string `json:"status"` - Homepage string `json:"homepage"` - Networks []networkEntry `json:"networks"` - CreatedBy []personEntry `json:"created_by"` - Credits *creditsResponse `json:"credits"` - ExternalIDs *ExternalIDs `json:"external_ids"` - Recommendations *recommendationsTVResponse `json:"recommendations"` - ContentRatings *contentRatingsResponse `json:"content_ratings"` + ID int `json:"id"` + Name string `json:"name"` + OriginalName string `json:"original_name"` + Tagline string `json:"tagline"` + Overview string `json:"overview"` + PosterPath string `json:"poster_path"` + BackdropPath string `json:"backdrop_path"` + FirstAirDate string `json:"first_air_date"` + LastAirDate string `json:"last_air_date"` + EpisodeRunTime []int `json:"episode_run_time"` + NumberOfSeasons int `json:"number_of_seasons"` + NumberOfEpisodes int `json:"number_of_episodes"` + OriginalLanguage string `json:"original_language"` + Keywords struct { + Keywords []idEntry `json:"keywords"` // movies + Results []idEntry `json:"results"` // tv + } `json:"keywords"` + Genres []genreEntry `json:"genres"` + VoteAverage float64 `json:"vote_average"` + VoteCount int `json:"vote_count"` + Status string `json:"status"` + Homepage string `json:"homepage"` + Networks []networkEntry `json:"networks"` + CreatedBy []personEntry `json:"created_by"` + Credits *creditsResponse `json:"credits"` + ExternalIDs *ExternalIDs `json:"external_ids"` + Recommendations *recommendationsTVResponse `json:"recommendations"` + ContentRatings *contentRatingsResponse `json:"content_ratings"` } diff --git a/internal/requests/anime.go b/internal/requests/anime.go new file mode 100644 index 00000000..809e49e5 --- /dev/null +++ b/internal/requests/anime.go @@ -0,0 +1,15 @@ +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 +} diff --git a/internal/requests/anime_test.go b/internal/requests/anime_test.go new file mode 100644 index 00000000..cdc73a26 --- /dev/null +++ b/internal/requests/anime_test.go @@ -0,0 +1,15 @@ +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") + } +} diff --git a/internal/requests/entitlements.go b/internal/requests/entitlements.go new file mode 100644 index 00000000..bcd7734c --- /dev/null +++ b/internal/requests/entitlements.go @@ -0,0 +1,30 @@ +package requests + +import ( + "context" + + "github.com/Silo-Server/silo-server/internal/access" +) + +// accessEntitlements resolves a requester's effective playback-quality ceiling +// (account + profile caps combined) via the shared access resolver. +type accessEntitlements struct { + resolver *access.Resolver +} + +// NewAccessEntitlements wraps the shared access resolver as an EntitlementResolver. +func NewAccessEntitlements(resolver *access.Resolver) EntitlementResolver { + return accessEntitlements{resolver: resolver} +} + +func (e accessEntitlements) MaxPlaybackQuality(ctx context.Context, userID int, profileID string) (string, error) { + scope, err := e.resolver.Resolve(ctx, access.ResolveInput{ + UserID: userID, + ProfileID: profileID, + SkipPINVerification: true, + }) + if err != nil { + return "", err + } + return scope.MaxPlaybackQuality, nil +} diff --git a/internal/requests/repository.go b/internal/requests/repository.go index 24ce7e54..cf110dc6 100644 --- a/internal/requests/repository.go +++ b/internal/requests/repository.go @@ -27,10 +27,10 @@ func (r *Repository) GetSettings(ctx context.Context) (Settings, error) { var s Settings err := r.pool.QueryRow(ctx, ` SELECT requests_enabled, global_max_requests, global_window_days, - global_auto_approval_enabled, updated_at + global_auto_approval_enabled, force_dual_quality, updated_at FROM request_settings WHERE id = true - `).Scan(&s.RequestsEnabled, &s.GlobalMaxRequests, &s.GlobalWindowDays, &s.GlobalAutoApprovalEnabled, &s.UpdatedAt) + `).Scan(&s.RequestsEnabled, &s.GlobalMaxRequests, &s.GlobalWindowDays, &s.GlobalAutoApprovalEnabled, &s.ForceDualQuality, &s.UpdatedAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return Settings{ @@ -57,19 +57,20 @@ func (r *Repository) UpdateSettings(ctx context.Context, settings Settings) (Set err := r.pool.QueryRow(ctx, ` INSERT INTO request_settings ( id, requests_enabled, global_max_requests, global_window_days, - global_auto_approval_enabled, updated_at + global_auto_approval_enabled, force_dual_quality, updated_at ) - VALUES (true, $1, $2, $3, $4, now()) + VALUES (true, $1, $2, $3, $4, $5, now()) ON CONFLICT (id) DO UPDATE SET requests_enabled = EXCLUDED.requests_enabled, global_max_requests = EXCLUDED.global_max_requests, global_window_days = EXCLUDED.global_window_days, global_auto_approval_enabled = EXCLUDED.global_auto_approval_enabled, + force_dual_quality = EXCLUDED.force_dual_quality, updated_at = now() RETURNING requests_enabled, global_max_requests, global_window_days, - global_auto_approval_enabled, updated_at - `, settings.RequestsEnabled, settings.GlobalMaxRequests, settings.GlobalWindowDays, settings.GlobalAutoApprovalEnabled). - Scan(&s.RequestsEnabled, &s.GlobalMaxRequests, &s.GlobalWindowDays, &s.GlobalAutoApprovalEnabled, &s.UpdatedAt) + global_auto_approval_enabled, force_dual_quality, updated_at + `, settings.RequestsEnabled, settings.GlobalMaxRequests, settings.GlobalWindowDays, settings.GlobalAutoApprovalEnabled, settings.ForceDualQuality). + Scan(&s.RequestsEnabled, &s.GlobalMaxRequests, &s.GlobalWindowDays, &s.GlobalAutoApprovalEnabled, &s.ForceDualQuality, &s.UpdatedAt) if err != nil { return Settings{}, fmt.Errorf("update request settings: %w", err) } @@ -272,6 +273,7 @@ func (r *Repository) CreateRequest(ctx context.Context, input CreateRequestRecor type requestExecutor interface { QueryRow(ctx context.Context, sql string, args ...any) pgx.Row + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) } @@ -296,18 +298,18 @@ func (r *Repository) insertRequest( INSERT INTO media_requests ( 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, created_at, updated_at, approved_at + requested_by_user_id, requested_by_profile_id, is_anime, created_at, updated_at, approved_at ) VALUES ( $1, 'tmdb', $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, - $13, $14, $15, $15, $16 + $13, $14, $15, $16, $16, $17 ) RETURNING `+requestColumns(), input.ID, input.Input.MediaType, input.Input.TMDBID, tvdbID, strings.TrimSpace(input.Input.IMDbID), strings.TrimSpace(input.Input.Title), year, strings.TrimSpace(input.Input.Overview), strings.TrimSpace(input.Input.PosterPath), strings.TrimSpace(input.Input.BackdropPath), status, outcome, - input.Requester.UserID, input.Requester.ProfileID, now, approvedAt) + input.Requester.UserID, input.Requester.ProfileID, input.IsAnime, now, approvedAt) req, err := scanRequest(row) if err != nil { return nil, fmt.Errorf("insert request: %w", err) @@ -417,48 +419,6 @@ func (r *Repository) SetStatus(ctx context.Context, id string, status Status, ac return req, nil } -func (r *Repository) MarkQueued(ctx context.Context, id string, update QueueUpdate, actor Viewer) (*Request, error) { - tx, err := r.pool.Begin(ctx) - if err != nil { - return nil, fmt.Errorf("begin request queue transaction: %w", err) - } - defer tx.Rollback(ctx) - - externalStatus := strings.TrimSpace(update.ExternalStatus) - if externalStatus == "" { - externalStatus = "queued" - } - req, err := scanRequest(tx.QueryRow(ctx, ` - UPDATE media_requests - SET status = 'queued', - outcome = 'active', - integration_kind = $2, - external_id = $3, - external_status = $4, - last_error = '', - updated_at = now(), - approved_at = CASE WHEN approved_at IS NULL THEN now() ELSE approved_at END - WHERE id = $1 - RETURNING `+requestColumns(), id, - strings.TrimSpace(update.IntegrationKind), - strings.TrimSpace(update.ExternalID), - externalStatus, - )) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrNotFound - } - return nil, fmt.Errorf("mark request queued: %w", err) - } - if err := r.recordEvent(ctx, tx, id, "status_queued", actor, externalStatus); err != nil { - return nil, err - } - if err := tx.Commit(ctx); err != nil { - return nil, fmt.Errorf("commit request queue transaction: %w", err) - } - return req, nil -} - func (r *Repository) SetOutcome(ctx context.Context, id string, outcome Outcome, actor Viewer, message string) (*Request, error) { tx, err := r.pool.Begin(ctx) if err != nil { @@ -492,13 +452,13 @@ func (r *Repository) SetOutcome(ctx context.Context, id string, outcome Outcome, return req, nil } +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` + func (r *Repository) ListIntegrations(ctx context.Context) ([]Integration, error) { - rows, err := r.pool.Query(ctx, ` - SELECT kind, enabled, base_url, api_key_ref, root_folder, quality_profile_id, - tags, options, last_check_at, last_check_status, last_check_error, updated_at - FROM request_integrations - ORDER BY kind - `) + rows, err := r.pool.Query(ctx, `SELECT `+integrationColumns+` FROM request_integrations ORDER BY kind, name`) if err != nil { return nil, fmt.Errorf("list request integrations: %w", err) } @@ -518,72 +478,181 @@ func (r *Repository) ListIntegrations(ctx context.Context) ([]Integration, error return out, nil } -func (r *Repository) UpsertIntegration(ctx context.Context, integration Integration) (*Integration, error) { - out, err := r.upsertIntegration(ctx, r.pool, integration) +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 { - return nil, fmt.Errorf("upsert request integration: %w", err) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("get request integration: %w", err) } - return out, nil + return &i, nil } -func (r *Repository) UpsertIntegrations(ctx context.Context, integrations []Integration) ([]Integration, error) { +func (r *Repository) CreateIntegration(ctx context.Context, i Integration) (*Integration, error) { + return r.insertIntegration(ctx, r.pool, i) +} + +func (r *Repository) UpdateIntegration(ctx context.Context, i Integration) (*Integration, error) { + return r.updateIntegration(ctx, r.pool, i) +} + +// insertIntegration runs the integration INSERT against any executor (pool or +// tx) so the same SQL is reused by the plain create path and the transactional +// SaveIntegrationWithDefaults path. +func (r *Repository) insertIntegration(ctx context.Context, exec requestExecutor, 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 := exec.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 +} + +// updateIntegration runs the integration UPDATE against any executor (pool or +// tx) so the plain update path and SaveIntegrationWithDefaults share the SQL. +func (r *Repository) updateIntegration(ctx context.Context, exec requestExecutor, 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 := exec.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 +} + +// SaveIntegrationWithDefaults clears the conflicting kind default(s) and +// creates/updates the instance in a single transaction so a save failure can +// never leave the kind with zero defaults. +func (r *Repository) SaveIntegrationWithDefaults(ctx context.Context, in Integration, isCreate bool) (*Integration, error) { tx, err := r.pool.Begin(ctx) if err != nil { - return nil, fmt.Errorf("begin request integrations transaction: %w", err) + return nil, fmt.Errorf("begin save integration: %w", err) } defer tx.Rollback(ctx) - out := make([]Integration, 0, len(integrations)) - for _, integration := range integrations { - updated, err := r.upsertIntegration(ctx, tx, integration) - if err != nil { + if in.IsDefault { + if err := r.ClearDefault(ctx, tx, in.Kind, false, in.ID); err != nil { return nil, err } - out = append(out, *updated) + } + if in.IsDefault4K { + if err := r.ClearDefault(ctx, tx, in.Kind, true, in.ID); err != nil { + return nil, err + } + } + + var out *Integration + if isCreate { + out, err = r.insertIntegration(ctx, tx, in) + } else { + out, err = r.updateIntegration(ctx, tx, in) + } + if err != nil { + return nil, err } if err := tx.Commit(ctx); err != nil { - return nil, fmt.Errorf("commit request integrations transaction: %w", err) + return nil, fmt.Errorf("commit save integration: %w", err) } return out, nil } -func (r *Repository) upsertIntegration(ctx context.Context, exec requestExecutor, integration Integration) (*Integration, error) { - if integration.Options == nil { - integration.Options = map[string]any{} - } - options, err := json.Marshal(integration.Options) +func (r *Repository) DeleteIntegration(ctx context.Context, id string) error { + tx, err := r.pool.Begin(ctx) if err != nil { - return nil, fmt.Errorf("marshal request integration options: %w", err) + return fmt.Errorf("begin delete integration: %w", err) } - tags := int32Slice(integration.Tags) - row := exec.QueryRow(ctx, ` - INSERT INTO request_integrations ( - kind, enabled, base_url, api_key_ref, root_folder, quality_profile_id, - tags, options, updated_at + defer tx.Rollback(ctx) + + var lockedID string + if err := tx.QueryRow(ctx, ` + SELECT id FROM request_integrations WHERE id = $1 FOR UPDATE + `, id).Scan(&lockedID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + return fmt.Errorf("lock request integration: %w", err) + } + + var hasLiveTargets bool + if err := tx.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM media_request_targets + WHERE integration_id = $1 AND status IN ('queued', 'downloading') ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now()) - ON CONFLICT (kind) DO UPDATE SET - enabled = EXCLUDED.enabled, - base_url = EXCLUDED.base_url, - api_key_ref = CASE - WHEN EXCLUDED.api_key_ref = '' THEN request_integrations.api_key_ref - ELSE EXCLUDED.api_key_ref - END, - root_folder = EXCLUDED.root_folder, - quality_profile_id = EXCLUDED.quality_profile_id, - tags = EXCLUDED.tags, - options = EXCLUDED.options, - updated_at = now() - RETURNING kind, enabled, base_url, api_key_ref, root_folder, quality_profile_id, - tags, options, last_check_at, last_check_status, last_check_error, updated_at - `, integration.Kind, integration.Enabled, strings.TrimSpace(integration.BaseURL), - strings.TrimSpace(integration.APIKeyRef), strings.TrimSpace(integration.RootFolder), - integration.QualityProfileID, tags, options) - out, err := scanIntegration(row) - if err != nil { - return nil, err + `, id).Scan(&hasLiveTargets); err != nil { + return fmt.Errorf("check integration targets: %w", err) } - return &out, nil + if hasLiveTargets { + return ErrInvalidState + } + + if _, err := tx.Exec(ctx, `DELETE FROM request_integrations WHERE id = $1`, id); err != nil { + return fmt.Errorf("delete request integration: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit delete integration: %w", err) + } + return nil +} + +// ClearDefault unsets the HD (or 4K) default flag for every instance of a kind +// except excludeID, so saving an instance that is itself the new default does +// not clear its own freshly-written flag. +func (r *Repository) ClearDefault(ctx context.Context, exec requestExecutor, kind string, fourK bool, excludeID string) error { + col := "is_default" + if fourK { + col = "is_default_4k" + } + _, err := exec.Exec(ctx, `UPDATE request_integrations SET `+col+` = false WHERE kind = $1 AND id <> $2`, kind, excludeID) + if err != nil { + return fmt.Errorf("clear default: %w", err) + } + return nil } func (r *Repository) recordEvent(ctx context.Context, exec requestExecutor, requestID, eventType string, actor Viewer, message string) error { @@ -636,9 +705,8 @@ func requestSelectSQL() string { 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, integration_kind, - external_id, external_status, last_error, created_at, updated_at, - approved_at, completed_at` + requested_by_user_id, requested_by_profile_id, is_anime, + last_error, created_at, updated_at, approved_at, completed_at` } type requestScanner interface { @@ -665,9 +733,7 @@ func scanRequest(row requestScanner) (*Request, error) { &req.Outcome, &req.RequestedByUserID, &req.RequestedByProfileID, - &req.IntegrationKind, - &req.ExternalID, - &req.ExternalStatus, + &req.IsAnime, &req.LastError, &req.CreatedAt, &req.UpdatedAt, @@ -698,44 +764,41 @@ type integrationScanner interface { } func scanIntegration(row integrationScanner) (Integration, error) { - var integration Integration - var quality sql.NullInt64 - var tags []int32 + var i Integration + var quality, animeQuality sql.NullInt64 + var tags, animeTags []int32 var optionsRaw []byte var lastCheckAt sql.NullTime if err := row.Scan( - &integration.Kind, - &integration.Enabled, - &integration.BaseURL, - &integration.APIKeyRef, - &integration.RootFolder, - &quality, - &tags, - &optionsRaw, - &lastCheckAt, - &integration.LastCheckStatus, - &integration.LastCheckError, - &integration.UpdatedAt, + &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) - integration.QualityProfileID = &v + i.QualityProfileID = &v } - integration.Tags = intsFromInt32(tags) + 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, &integration.Options); err != nil { - return Integration{}, fmt.Errorf("unmarshal request integration options for %s: %w", integration.Kind, err) + if err := json.Unmarshal(optionsRaw, &i.Options); err != nil { + return Integration{}, fmt.Errorf("unmarshal request integration options for %s: %w", i.ID, err) } } - if integration.Options == nil { - integration.Options = map[string]any{} + if i.Options == nil { + i.Options = map[string]any{} } if lastCheckAt.Valid { - integration.LastCheckAt = &lastCheckAt.Time + i.LastCheckAt = &lastCheckAt.Time } - return integration, nil + return i, nil } func int32Slice(values []int) []int32 { diff --git a/internal/requests/routing.go b/internal/requests/routing.go new file mode 100644 index 00000000..918957b7 --- /dev/null +++ b/internal/requests/routing.go @@ -0,0 +1,85 @@ +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.CompareQuality(ceiling, access.PlaybackQuality4K) >= 0 + + 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 { + // Anime fields are overrides: only replace the standard value when the + // anime counterpart is set, so an admin can enable anime detection while + // reusing the standard root folder / quality profile / tags for any field + // they leave blank (rather than clearing them into an invalid submission). + if in.AnimeRootFolder != "" { + in.RootFolder = in.AnimeRootFolder + } + if in.AnimeQualityProfileID != nil { + in.QualityProfileID = in.AnimeQualityProfileID + } + if len(in.AnimeTags) > 0 { + in.Tags = in.AnimeTags + } + if in.Kind == "sonarr" { + in.Options["series_type"] = "anime" + } + } + return in +} diff --git a/internal/requests/routing_test.go b/internal/requests/routing_test.go new file mode 100644 index 00000000..8e855154 --- /dev/null +++ b/internal/requests/routing_test.go @@ -0,0 +1,52 @@ +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}, + {"empty ceiling hd only", Request{MediaType: MediaTypeMovie}, "", false, []Integration{hd, uhd}, []Quality{Quality1080p}, false}, + } + 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) + } + } + }) + } +} diff --git a/internal/requests/service.go b/internal/requests/service.go index dc265871..781627cd 100644 --- a/internal/requests/service.go +++ b/internal/requests/service.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/Silo-Server/silo-server/internal/access" "github.com/Silo-Server/silo-server/internal/idgen" "github.com/Silo-Server/silo-server/internal/metadata/tmdb" "golang.org/x/sync/errgroup" @@ -54,6 +55,13 @@ type SeriesIntegrationOptionsAdapter interface { ListSeriesIntegrationOptions(ctx context.Context, integration Integration) (*IntegrationOptions, error) } +type EntitlementResolver interface { + // MaxPlaybackQuality returns the requester's effective playback-quality + // ceiling (already combining account- and profile-level caps). Empty string + // means "no cap". + MaxPlaybackQuality(ctx context.Context, userID int, profileID string) (string, error) +} + type Service struct { store Store tmdb TMDBClient @@ -61,6 +69,7 @@ type Service struct { secrets SecretResolver movieAdapter MovieFulfillmentAdapter seriesAdapter SeriesFulfillmentAdapter + entitlements EntitlementResolver Now func() time.Time } @@ -91,6 +100,19 @@ func (s *Service) SetFulfillmentAdapters(movie MovieFulfillmentAdapter, series S s.seriesAdapter = series } +func (s *Service) SetEntitlementResolver(r EntitlementResolver) { s.entitlements = r } + +func (s *Service) requesterCeiling(ctx context.Context, userID int, profileID string) string { + if s.entitlements == nil { + return "" // no resolver -> unlimited (1080p baseline still applies) + } + q, err := s.entitlements.MaxPlaybackQuality(ctx, userID, profileID) + if err != nil { + return access.PlaybackQualityStandard // fail safe: HD only + } + return q +} + func (s *Service) Search(ctx context.Context, viewer Viewer, query string, mediaType MediaType, page int) (*MediaPage, error) { if s == nil || s.store == nil || s.tmdb == nil { return nil, fmt.Errorf("request service is not configured") @@ -282,6 +304,7 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create return nil, err } s.enrichExternalIDs(ctx, &normalized) + isAnime := s.detectRequestAnime(ctx, normalized.MediaType, normalized.TMDBID) matches, err := s.lookupPresence(ctx, normalized.MediaType, []PresenceCandidate{createPresenceCandidate(normalized)}) if err != nil { @@ -329,6 +352,7 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create Input: normalized, Status: status, Outcome: OutcomeActive, + IsAnime: isAnime, Requester: viewer, Now: s.now(), } @@ -366,14 +390,44 @@ func (s *Service) ListMine(ctx context.Context, viewer Viewer, filter ListFilter if err := s.ensureRequestsEnabled(ctx); err != nil { return nil, err } - return s.store.ListMine(ctx, viewer.UserID, normalizeListFilter(filter)) + reqs, err := s.store.ListMine(ctx, viewer.UserID, normalizeListFilter(filter)) + if err != nil { + return nil, err + } + if err := s.attachTargets(ctx, reqs...); err != nil { + return nil, err + } + return reqs, nil } func (s *Service) ListAdmin(ctx context.Context, viewer Viewer, filter ListFilter) ([]*Request, error) { if !viewer.IsAdmin { return nil, ErrForbidden } - return s.store.ListAdmin(ctx, normalizeListFilter(filter)) + reqs, err := s.store.ListAdmin(ctx, normalizeListFilter(filter)) + if err != nil { + return nil, err + } + if err := s.attachTargets(ctx, reqs...); err != nil { + return nil, err + } + return reqs, nil +} + +// attachTargets loads and attaches the per-instance fulfillment targets for each +// request so callers (admin queue, detail view) can surface multi-target status. +func (s *Service) attachTargets(ctx context.Context, reqs ...*Request) error { + for _, r := range reqs { + if r == nil { + continue + } + targets, err := s.store.ListTargets(ctx, r.ID) + if err != nil { + return err + } + r.Targets = targets + } + return nil } func (s *Service) GetRequest(ctx context.Context, viewer Viewer, id string) (*Request, error) { @@ -387,6 +441,9 @@ func (s *Service) GetRequest(ctx context.Context, viewer Viewer, id string) (*Re if !viewer.IsAdmin && req.RequestedByUserID != viewer.UserID { return nil, ErrForbidden } + if err := s.attachTargets(ctx, req); err != nil { + return nil, err + } return req, nil } @@ -478,12 +535,65 @@ func (s *Service) Retry(ctx context.Context, viewer Viewer, id string) (*Request if err != nil { return nil, err } - if active.Status == StatusApproved || active.Status == StatusQueued || active.Status == StatusDownloading { - retry := *active - retry.Status = StatusApproved - return s.submitApprovedRequest(ctx, retry, viewer) + + instances, err := s.store.ListIntegrations(ctx) + if err != nil { + return nil, err } - return active, nil + settings, err := s.store.GetSettings(ctx) + if err != nil { + return nil, err + } + existing, err := s.store.ListTargets(ctx, active.ID) + if err != nil { + return nil, err + } + ceiling := s.requesterCeiling(ctx, active.RequestedByUserID, active.RequestedByProfileID) + planned := routeTargets(*active, ceiling, settings, instances) + if len(planned) == 0 { + return s.markSubmissionFailed(ctx, active.ID, viewer, + fmt.Errorf("no %s instance configured for the requested quality", + integrationKindForMediaType(active.MediaType))) + } + + return s.submitPlannedTargets(ctx, *active, planned, existing, viewer) +} + +// submitPlannedTargets submits each planned target idempotently against the +// already-recorded targets: a non-failed target for a quality is left alone, a +// failed one is deleted and re-submitted, and a missing one is submitted fresh. +// This is shared by Retry and the reconcile-driven submit so a re-run never +// violates the UNIQUE(request_id, quality) constraint. +func (s *Service) submitPlannedTargets(ctx context.Context, req Request, planned []plannedTarget, existing []Target, actor Viewer) (*Request, error) { + hasOK := make(map[Quality]bool) + for _, t := range existing { + if t.Status != StatusFailed { + hasOK[t.Quality] = true + } + } + + latest := &req + for _, pt := range planned { + if hasOK[pt.Quality] { + continue // healthy target already exists for this quality + } + // remove the stale failed target for this quality before re-submitting + for _, t := range existing { + if t.Quality == pt.Quality && t.Status == StatusFailed { + if err := s.store.DeleteTarget(ctx, t.ID); err != nil { + return nil, err + } + } + } + updated, err := s.submitPlannedTarget(ctx, req, pt, actor) + if err != nil { + return nil, err + } + if updated != nil { + latest = updated + } + } + return latest, nil } func (s *Service) ReconcileRequests(ctx context.Context, limit int) (ReconcileResult, error) { @@ -606,43 +716,88 @@ func (s *Service) ListIntegrations(ctx context.Context, viewer Viewer) ([]Integr return s.store.ListIntegrations(ctx) } -func (s *Service) UpsertIntegration(ctx context.Context, viewer Viewer, integration Integration) (*Integration, error) { +func (s *Service) CreateIntegration(ctx context.Context, viewer Viewer, in Integration) (*Integration, error) { if !viewer.IsAdmin { return nil, ErrForbidden } - normalized, err := normalizeIntegration(integration) + if err := validateInstance(&in); err != nil { + return nil, err + } + id, err := idgen.NextID() if err != nil { return nil, err } - return s.store.UpsertIntegration(ctx, normalized) + in.ID = id + return s.store.SaveIntegrationWithDefaults(ctx, in, true) } -func (s *Service) UpsertIntegrations(ctx context.Context, viewer Viewer, integrations []Integration) ([]Integration, error) { +func (s *Service) UpdateIntegration(ctx context.Context, viewer Viewer, in Integration) (*Integration, error) { if !viewer.IsAdmin { return nil, ErrForbidden } - normalized := make([]Integration, 0, len(integrations)) - for _, integration := range integrations { - item, err := normalizeIntegration(integration) - if err != nil { - return nil, err - } - normalized = append(normalized, item) + if strings.TrimSpace(in.ID) == "" { + return nil, fmt.Errorf("%w: integration id required", ErrInvalidInput) } - return s.store.UpsertIntegrations(ctx, normalized) + if err := validateInstance(&in); err != nil { + return nil, err + } + return s.store.SaveIntegrationWithDefaults(ctx, in, false) +} + +func (s *Service) DeleteIntegration(ctx context.Context, viewer Viewer, id string) error { + if !viewer.IsAdmin { + return ErrForbidden + } + return s.store.DeleteIntegration(ctx, strings.TrimSpace(id)) +} + +func validateInstance(in *Integration) error { + in.Kind = strings.TrimSpace(in.Kind) + if in.Kind != "radarr" && in.Kind != "sonarr" { + return fmt.Errorf("%w: kind must be radarr or sonarr", ErrInvalidInput) + } + if strings.TrimSpace(in.Name) == "" { + return fmt.Errorf("%w: name is required", ErrInvalidInput) + } + if in.IsDefault && in.Is4K { + return fmt.Errorf("%w: the HD default cannot be a 4K server", ErrInvalidInput) + } + if in.IsDefault4K && !in.Is4K { + return fmt.Errorf("%w: the 4K default must be a 4K server", ErrInvalidInput) + } + return nil } func (s *Service) LoadIntegrationOptions(ctx context.Context, viewer Viewer, integration Integration) (*IntegrationOptions, error) { if !viewer.IsAdmin { return nil, ErrForbidden } + // For a saved instance the request body carries only the path id (no kind and + // often no creds), so resolve the saved row by id and backfill what the body + // omitted. This makes "Test connection" reuse the correct per-instance key + // (each kind can have multiple instances) instead of borrowing a sibling's. + if id := strings.TrimSpace(integration.ID); id != "" && id != "new" { + stored, err := s.store.GetIntegration(ctx, id) + if err != nil && !errors.Is(err, ErrNotFound) { + return nil, err + } + if stored != nil { + if strings.TrimSpace(integration.Kind) == "" { + integration.Kind = stored.Kind + } + if strings.TrimSpace(integration.BaseURL) == "" { + integration.BaseURL = stored.BaseURL + } + if strings.TrimSpace(integration.APIKeyRef) == "" { + integration.APIKeyRef = stored.APIKeyRef + } + } + } + normalized, err := normalizeIntegrationConnection(integration) if err != nil { return nil, err } - if err := s.applyStoredIntegrationCredentials(ctx, &normalized); err != nil { - return nil, err - } if strings.TrimSpace(normalized.BaseURL) == "" { return nil, fmt.Errorf("%w: base_url is required", ErrInvalidInput) } @@ -941,85 +1096,118 @@ func (s *Service) enrichExternalIDs(ctx context.Context, input *CreateRequestInp } } -func (s *Service) integrationConfigured(ctx context.Context, mediaType MediaType) (bool, error) { - integration, err := s.integrationForMediaType(ctx, mediaType) - if err != nil { - return false, err +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 } - if integration == nil || !integrationIsConfigured(*integration) { - return false, nil - } - apiKey, err := s.resolveAPIKey(ctx, *integration) - if err != nil { - return false, err - } - return apiKey != "", nil + return detectAnime(detail.KeywordIDs) } -func (s *Service) integrationForMediaType(ctx context.Context, mediaType MediaType) (*Integration, error) { - want := integrationKindForMediaType(mediaType) - integrations, err := s.store.ListIntegrations(ctx) +func (s *Service) integrationConfigured(ctx context.Context, mediaType MediaType) (bool, error) { + instances, err := s.store.ListIntegrations(ctx) if err != nil { - return nil, err + return false, err } - for _, integration := range integrations { - if integration.Kind == want { - integration := integration - return &integration, nil + kind := integrationKindForMediaType(mediaType) + for _, in := range instances { + if in.Kind == kind && in.Enabled && (in.IsDefault || in.IsDefault4K) && integrationIsConfigured(in) { + return true, nil } } - return nil, nil + return false, nil } func (s *Service) submitApprovedRequest(ctx context.Context, req Request, actor Viewer) (*Request, error) { if req.Outcome != OutcomeActive || req.Status != StatusApproved { return &req, nil } - - integration, err := s.integrationForMediaType(ctx, req.MediaType) + instances, err := s.store.ListIntegrations(ctx) if err != nil { return nil, err } - if integration == nil || !integrationIsConfigured(*integration) { - return &req, nil + settings, err := s.store.GetSettings(ctx) + if err != nil { + return nil, err + } + ceiling := s.requesterCeiling(ctx, req.RequestedByUserID, req.RequestedByProfileID) + planned := routeTargets(req, ceiling, settings, instances) + if len(planned) == 0 { + return s.markSubmissionFailed(ctx, req.ID, actor, + fmt.Errorf("no %s instance configured for the requested quality", + integrationKindForMediaType(req.MediaType))) } - resolved := *integration - apiKey, err := s.resolveAPIKey(ctx, resolved) + // Reconcile can re-run submit while the request is still 'approved'; skip + // qualities that already have a live target and replace failed ones so the + // UNIQUE(request_id, quality) constraint is never violated. + existing, err := s.store.ListTargets(ctx, req.ID) if err != nil { - return s.markSubmissionFailed(ctx, req.ID, actor, err) + return nil, err } - if apiKey == "" { - return &req, nil + return s.submitPlannedTargets(ctx, req, planned, existing, actor) +} + +// submitPlannedTarget creates a target row, submits it to the adapter, and +// records the result (queued or failed) via UpdateTargetStatus (which recomputes +// the request aggregate). Returns the latest request snapshot. +func (s *Service) submitPlannedTarget(ctx context.Context, req Request, pt plannedTarget, actor Viewer) (*Request, error) { + resolved := resolveInstance(pt) + apiKey, err := s.resolveAPIKey(ctx, resolved) + if err != nil || apiKey == "" { + msg := "missing api key" + if err != nil { + msg = err.Error() + } + return s.createFailedTarget(ctx, req, pt, resolved, msg, actor) } resolved.APIKeyRef = apiKey - var result FulfillmentResult + target, err := s.store.CreateTarget(ctx, Target{ + RequestID: req.ID, IntegrationID: resolved.ID, IntegrationKind: resolved.Kind, + Quality: pt.Quality, IsAnime: pt.IsAnime, Status: StatusQueued, + }) + if err != nil { + return nil, err + } + + result, serr := s.submitTarget(ctx, req, resolved) + if serr != nil { + return s.store.UpdateTargetStatus(ctx, target.ID, StatusFailed, "", "", serr.Error(), actor) + } + return s.store.UpdateTargetStatus(ctx, target.ID, StatusQueued, + result.ExternalID, result.ExternalStatus, "", actor) +} + +func (s *Service) createFailedTarget(ctx context.Context, req Request, pt plannedTarget, resolved Integration, msg string, actor Viewer) (*Request, error) { + target, err := s.store.CreateTarget(ctx, Target{ + RequestID: req.ID, IntegrationID: resolved.ID, IntegrationKind: resolved.Kind, + Quality: pt.Quality, IsAnime: pt.IsAnime, Status: StatusFailed, LastError: msg, + }) + if err != nil { + return nil, err + } + return s.store.UpdateTargetStatus(ctx, target.ID, StatusFailed, "", "", msg, actor) +} + +// submitTarget calls the correct adapter with a per-target Request copy. The +// adapters read root folder/quality/tags (and Sonarr series_type) from the +// resolved Integration. +func (s *Service) submitTarget(ctx context.Context, req Request, resolved Integration) (FulfillmentResult, error) { switch req.MediaType { case MediaTypeMovie: if s.movieAdapter == nil { - return &req, nil + return FulfillmentResult{}, fmt.Errorf("no movie adapter configured") } - result, err = s.movieAdapter.SubmitMovie(ctx, req, resolved) + return s.movieAdapter.SubmitMovie(ctx, req, resolved) case MediaTypeSeries: if s.seriesAdapter == nil { - return &req, nil + return FulfillmentResult{}, fmt.Errorf("no series adapter configured") } - result, err = s.seriesAdapter.SubmitSeries(ctx, req, resolved) + return s.seriesAdapter.SubmitSeries(ctx, req, resolved) default: - return &req, nil + return FulfillmentResult{}, fmt.Errorf("unsupported media type %q", req.MediaType) } - if err != nil { - return s.markSubmissionFailed(ctx, req.ID, actor, err) - } - if result.IntegrationKind == "" { - result.IntegrationKind = resolved.Kind - } - return s.store.MarkQueued(ctx, req.ID, QueueUpdate{ - IntegrationKind: result.IntegrationKind, - ExternalID: result.ExternalID, - ExternalStatus: result.ExternalStatus, - }, actor) } func (s *Service) markSubmissionFailed(ctx context.Context, requestID string, actor Viewer, submitErr error) (*Request, error) { @@ -1047,13 +1235,24 @@ func (s *Service) reconcileRequest(ctx context.Context, req Request) (reconcileC return reconcileUnchanged, err } if completed { - if req.Status == StatusCompleted { - return reconcileUnchanged, nil - } - if _, err := s.store.SetStatus(ctx, req.ID, StatusCompleted, Viewer{}); err != nil { + // The presence check is quality-agnostic (TMDB id only), so it must not + // force-complete a request whose targets are still in flight — that would + // orphan in-progress downloads. Only take the shortcut for legacy/no-live + // -target requests; otherwise let per-target reconcile + aggregate drive + // completion. + hasLiveTargets, err := s.hasLiveTargets(ctx, req.ID) + if err != nil { return reconcileUnchanged, err } - return reconcileCompleted, nil + if !hasLiveTargets { + if req.Status == StatusCompleted { + return reconcileUnchanged, nil + } + if _, err := s.store.SetStatus(ctx, req.ID, StatusCompleted, Viewer{}); err != nil { + return reconcileUnchanged, err + } + return reconcileCompleted, nil + } } if req.Status == StatusApproved { @@ -1071,33 +1270,91 @@ func (s *Service) reconcileRequest(ctx context.Context, req Request) (reconcileC } } - status, err := s.checkFulfillmentStatus(ctx, req) + targets, err := s.store.ListTargets(ctx, req.ID) if err != nil { return reconcileUnchanged, err } - if status.Status == "" && status.Outcome == "" { - return reconcileSkipped, nil + instances, err := s.store.ListIntegrations(ctx) + if err != nil { + return reconcileUnchanged, err } - if status.Outcome == OutcomeFailed { - message := strings.TrimSpace(status.Message) - if message == "" { - message = strings.TrimSpace(status.ExternalStatus) + byID := make(map[string]Integration, len(instances)) + for _, in := range instances { + byID[in.ID] = in + } + change := reconcileUnchanged + for _, t := range targets { + if t.Status == StatusCompleted || t.Status == StatusFailed { + continue } - if message == "" { - message = "external fulfillment failed" + in, ok := byID[t.IntegrationID] + if !ok { + continue } - if _, err := s.store.SetOutcome(ctx, req.ID, OutcomeFailed, Viewer{}, message); err != nil { + 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 + } + if st.Status == "" && st.Outcome == "" { + 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 reconcileUnchanged, err } - return reconcileFailed, nil - } - if status.Status == StatusDownloading && req.Status != StatusDownloading { - if _, err := s.store.SetStatus(ctx, req.ID, StatusDownloading, Viewer{}); err != nil { - return reconcileUnchanged, err + switch newStatus { + case StatusCompleted: + change = reconcileCompleted + case StatusDownloading: + if change == reconcileUnchanged { + change = reconcileDownloading + } + case StatusFailed: + if change == reconcileUnchanged { + change = reconcileFailed + } } - return reconcileDownloading, nil } - return reconcileUnchanged, nil + return change, nil +} + +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 + } +} + +// hasLiveTargets reports whether the request has any non-terminal (queued or +// downloading) fulfillment target. +func (s *Service) hasLiveTargets(ctx context.Context, requestID string) (bool, error) { + targets, err := s.store.ListTargets(ctx, requestID) + if err != nil { + return false, err + } + for _, t := range targets { + if t.Status == StatusQueued || t.Status == StatusDownloading { + return true, nil + } + } + return false, nil } func (s *Service) requestAvailable(ctx context.Context, req Request) (bool, error) { @@ -1108,24 +1365,7 @@ func (s *Service) requestAvailable(ctx context.Context, req Request) (bool, erro return matches[req.TMDBID].Available, nil } -func (s *Service) checkFulfillmentStatus(ctx context.Context, req Request) (FulfillmentStatus, error) { - integration, err := s.integrationForMediaType(ctx, req.MediaType) - if err != nil { - return FulfillmentStatus{}, err - } - if integration == nil || !integrationIsConfigured(*integration) { - return FulfillmentStatus{}, nil - } - resolved := *integration - apiKey, err := s.resolveAPIKey(ctx, resolved) - if err != nil { - return FulfillmentStatus{}, err - } - if apiKey == "" { - return FulfillmentStatus{}, nil - } - resolved.APIKeyRef = apiKey - +func (s *Service) checkFulfillmentStatus(ctx context.Context, req Request, resolved Integration) (FulfillmentStatus, error) { switch req.MediaType { case MediaTypeMovie: checker, ok := s.movieAdapter.(MovieStatusAdapter) @@ -1160,13 +1400,6 @@ func (s *Service) resolveAPIKey(ctx context.Context, integration Integration) (s return resolved, nil } -func integrationKindForMediaType(mediaType MediaType) string { - if mediaType == MediaTypeSeries { - return "sonarr" - } - return "radarr" -} - func integrationIsConfigured(integration Integration) bool { return integration.Enabled && strings.TrimSpace(integration.BaseURL) != "" && @@ -1283,29 +1516,6 @@ func normalizeUserLimit(limit UserLimit) (UserLimit, error) { return limit, nil } -func normalizeIntegration(integration Integration) (Integration, error) { - var err error - integration, err = normalizeIntegrationConnection(integration) - if err != nil { - return Integration{}, err - } - integration.RootFolder = strings.TrimSpace(integration.RootFolder) - if integration.QualityProfileID != nil && *integration.QualityProfileID <= 0 { - return Integration{}, fmt.Errorf("%w: quality_profile_id must be positive", ErrInvalidInput) - } - filteredTags := make([]int, 0, len(integration.Tags)) - for _, tag := range integration.Tags { - if tag > 0 { - filteredTags = append(filteredTags, tag) - } - } - integration.Tags = filteredTags - if integration.Options == nil { - integration.Options = map[string]any{} - } - return integration, nil -} - func normalizeIntegrationConnection(integration Integration) (Integration, error) { integration.Kind = strings.ToLower(strings.TrimSpace(integration.Kind)) switch integration.Kind { @@ -1321,32 +1531,6 @@ func normalizeIntegrationConnection(integration Integration) (Integration, error return integration, nil } -func (s *Service) applyStoredIntegrationCredentials(ctx context.Context, integration *Integration) error { - if integration == nil || s.store == nil { - return nil - } - if strings.TrimSpace(integration.BaseURL) != "" && strings.TrimSpace(integration.APIKeyRef) != "" { - return nil - } - stored, err := s.store.ListIntegrations(ctx) - if err != nil { - return err - } - for _, candidate := range stored { - if candidate.Kind != integration.Kind { - continue - } - if strings.TrimSpace(integration.BaseURL) == "" { - integration.BaseURL = candidate.BaseURL - } - if strings.TrimSpace(integration.APIKeyRef) == "" { - integration.APIKeyRef = candidate.APIKeyRef - } - return nil - } - return nil -} - func normalizeMediaType(mediaType MediaType) (MediaType, error) { switch MediaType(strings.ToLower(strings.TrimSpace(string(mediaType)))) { case MediaTypeMovie: diff --git a/internal/requests/service_test.go b/internal/requests/service_test.go index b25af25f..1f40c400 100644 --- a/internal/requests/service_test.go +++ b/internal/requests/service_test.go @@ -164,12 +164,20 @@ func TestCreateRequestAutoApprovesWithConfiguredIntegration(t *testing.T) { store.integrations = []Integration{{ Kind: "radarr", Enabled: true, + IsDefault: true, BaseURL: "http://radarr.local", APIKeyRef: "request.radarr.api_key", RootFolder: "/movies", QualityProfileID: &qualityProfileID, }} + adapter := &fakeMovieAdapter{result: FulfillmentResult{ + IntegrationKind: "radarr", + ExternalID: "123", + ExternalStatus: "queued", + }} service := newTestService(store) + service.SetSecretResolver(fakeSecrets{"request.radarr.api_key": "radarr-key"}) + service.SetFulfillmentAdapters(adapter, nil) req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ MediaType: MediaTypeMovie, @@ -179,12 +187,14 @@ func TestCreateRequestAutoApprovesWithConfiguredIntegration(t *testing.T) { if err != nil { t.Fatalf("CreateRequest returned error: %v", err) } - if req.Status != StatusApproved { - t.Fatalf("status = %q, want approved", req.Status) + // A configured HD default auto-approves and immediately submits, so the + // request lands in the fulfillment pipeline (aggregate of one queued target). + if req.Status != StatusQueued { + t.Fatalf("status = %q, want queued (auto-approved and submitted)", req.Status) } } -func TestCreateRequestAutoApprovalFallsBackToPendingOnIntegrationCheckError(t *testing.T) { +func TestCreateRequestAutoApprovalSecretFailureMarksTargetFailed(t *testing.T) { store := newFakeStore() store.settings.RequestsEnabled = true store.settings.GlobalAutoApprovalEnabled = true @@ -192,6 +202,7 @@ func TestCreateRequestAutoApprovalFallsBackToPendingOnIntegrationCheckError(t *t store.integrations = []Integration{{ Kind: "radarr", Enabled: true, + IsDefault: true, BaseURL: "http://radarr.local", APIKeyRef: "requests.radarr.api_key", RootFolder: "/movies", @@ -199,6 +210,7 @@ func TestCreateRequestAutoApprovalFallsBackToPendingOnIntegrationCheckError(t *t }} service := newTestService(store) service.SetSecretResolver(fakeSecretError{err: errors.New("secret lookup unavailable")}) + service.SetFulfillmentAdapters(&fakeMovieAdapter{}, nil) req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ MediaType: MediaTypeMovie, @@ -208,8 +220,10 @@ func TestCreateRequestAutoApprovalFallsBackToPendingOnIntegrationCheckError(t *t if err != nil { t.Fatalf("CreateRequest returned error: %v", err) } - if req.Status != StatusPending { - t.Fatalf("status = %q, want pending", req.Status) + // The auto-approval gate no longer resolves secrets; submission does. A + // secret-resolution failure now surfaces as a failed fulfillment target. + if req.Outcome != OutcomeFailed { + t.Fatalf("outcome = %q, want failed (secret resolution failed during submit)", req.Outcome) } } @@ -221,6 +235,7 @@ func TestCreateRequestAutoApprovalSubmitsMovie(t *testing.T) { store.integrations = []Integration{{ Kind: "radarr", Enabled: true, + IsDefault: true, BaseURL: "http://radarr.local", APIKeyRef: "requests.radarr.api_key", RootFolder: "/movies", @@ -262,6 +277,7 @@ func TestCreateRequestSubmissionFailureMarksFailed(t *testing.T) { store.integrations = []Integration{{ Kind: "radarr", Enabled: true, + IsDefault: true, BaseURL: "http://radarr.local", APIKeyRef: "radarr-key", RootFolder: "/movies", @@ -306,6 +322,37 @@ func TestCreateRequestEnrichesSeriesTVDBID(t *testing.T) { } } +func TestListMineAttachesTargets(t *testing.T) { + store := newFakeStore() + store.mine = []*Request{{ + ID: "req-1", + MediaType: MediaTypeMovie, + TMDBID: 550, + Status: StatusQueued, + Outcome: OutcomeActive, + RequestedByUserID: 1, + }} + store.targets = map[string][]Target{ + "req-1": {{ + ID: 10, + RequestID: "req-1", + Quality: Quality2160p, + Status: StatusQueued, + }}, + } + + got, err := newTestService(store).ListMine(context.Background(), testViewer(1), ListFilter{}) + if err != nil { + t.Fatalf("ListMine returned error: %v", err) + } + if len(got) != 1 { + t.Fatalf("ListMine returned %d requests, want 1", len(got)) + } + if len(got[0].Targets) != 1 || got[0].Targets[0].Quality != Quality2160p { + t.Fatalf("targets = %+v, want attached 2160p target", got[0].Targets) + } +} + func TestCreateRequestBlocksWhenHydratedTVDBIDIsAvailable(t *testing.T) { store := newFakeStore() store.settings.RequestsEnabled = true @@ -742,8 +789,10 @@ func TestReconcileRequestsMarksDownloadingFromAdapter(t *testing.T) { store := newFakeStore() qualityProfileID := 1 store.integrations = []Integration{{ + ID: "radarr-hd", Kind: "radarr", Enabled: true, + IsDefault: true, BaseURL: "http://radarr.local", APIKeyRef: "radarr-key", RootFolder: "/movies", @@ -757,6 +806,14 @@ func TestReconcileRequestsMarksDownloadingFromAdapter(t *testing.T) { Outcome: OutcomeActive, ExternalID: "123", }} + // Reconcile now drives status per-target; seed a queued target for the request. + store.requests["req-1"] = &Request{ID: "req-1", MediaType: MediaTypeMovie, TMDBID: 550, Status: StatusQueued, Outcome: OutcomeActive} + if _, err := store.CreateTarget(context.Background(), Target{ + RequestID: "req-1", IntegrationID: "radarr-hd", IntegrationKind: "radarr", + Quality: Quality1080p, Status: StatusQueued, ExternalID: "123", + }); err != nil { + t.Fatalf("seed target: %v", err) + } adapter := &fakeMovieAdapter{status: FulfillmentStatus{ Status: StatusDownloading, IntegrationKind: "radarr", @@ -778,6 +835,36 @@ func TestReconcileRequestsMarksDownloadingFromAdapter(t *testing.T) { } } +func TestDeleteIntegrationRejectsLiveTargets(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{{ + ID: "radarr-hd", + Kind: "radarr", + Enabled: true, + }} + store.targets = map[string][]Target{ + "req-1": {{ + ID: 10, + RequestID: "req-1", + IntegrationID: "radarr-hd", + Quality: Quality1080p, + Status: StatusDownloading, + }}, + } + + err := newTestService(store).DeleteIntegration( + context.Background(), + Viewer{UserID: 1, IsAdmin: true}, + "radarr-hd", + ) + if !errors.Is(err, ErrInvalidState) { + t.Fatalf("err = %v, want ErrInvalidState", err) + } + if len(store.integrations) != 1 { + t.Fatalf("integrations = %d, want delete blocked", len(store.integrations)) + } +} + func TestCancelOwnerCanWithdrawPendingRequest(t *testing.T) { store := newFakeStore() store.requests["req-mine"] = &Request{ @@ -903,6 +990,7 @@ func TestRetryResubmitsFailedQueuedRequest(t *testing.T) { store.integrations = []Integration{{ Kind: "radarr", Enabled: true, + IsDefault: true, BaseURL: "http://radarr.local", APIKeyRef: "radarr-key", RootFolder: "/movies", @@ -930,9 +1018,9 @@ func TestRetryResubmitsFailedQueuedRequest(t *testing.T) { if adapter.calls != 1 { t.Fatalf("adapter calls = %d, want 1", adapter.calls) } - if adapter.gotReq.Status != StatusApproved { - t.Fatalf("submitted status = %q, want approved", adapter.gotReq.Status) - } + // Retry re-routes the failed request to its default instance and re-submits + // a fresh target; the request aggregate returns to queued with the new + // external id reported by the adapter. if req.Status != StatusQueued || req.ExternalID != "99" { t.Fatalf("request = %+v, want re-queued with external id 99", req) } @@ -960,10 +1048,12 @@ type fakeStore struct { active map[MediaType]map[int]*Request created []CreateRequestRecord integrations []Integration - queued []QueueUpdate candidates []*Request + mine []*Request statusUpdates []Status requests map[string]*Request + targets map[string][]Target + targetSeq int64 } func newFakeStore() *fakeStore { @@ -1067,7 +1157,7 @@ func (f *fakeStore) CreateRequest(_ context.Context, input CreateRequestRecord) } } f.created = append(f.created, input) - return &Request{ + req := &Request{ ID: input.ID, Provider: "tmdb", MediaType: input.Input.MediaType, @@ -1077,11 +1167,15 @@ func (f *fakeStore) CreateRequest(_ context.Context, input CreateRequestRecord) Title: input.Input.Title, Status: input.Status, Outcome: input.Outcome, + IsAnime: input.IsAnime, RequestedByUserID: input.Requester.UserID, RequestedByProfileID: input.Requester.ProfileID, CreatedAt: input.Now, UpdatedAt: input.Now, - }, nil + } + f.requests[input.ID] = req + copy := *req + return ©, nil } func (f *fakeStore) GetRequest(_ context.Context, id string) (*Request, error) { @@ -1102,7 +1196,9 @@ func (f *fakeStore) ListReconciliationCandidates(context.Context, int) ([]*Reque } func (f *fakeStore) ListMine(context.Context, int, ListFilter) ([]*Request, error) { - return nil, nil + f.mu.Lock() + defer f.mu.Unlock() + return append([]*Request(nil), f.mine...), nil } func (f *fakeStore) ListAdmin(context.Context, ListFilter) ([]*Request, error) { @@ -1123,24 +1219,6 @@ func (f *fakeStore) SetStatus(_ context.Context, id string, status Status, _ Vie return ©, nil } -func (f *fakeStore) MarkQueued(_ context.Context, id string, update QueueUpdate, _ Viewer) (*Request, error) { - f.mu.Lock() - defer f.mu.Unlock() - f.queued = append(f.queued, update) - req := f.requests[id] - if req == nil { - req = &Request{ID: id} - f.requests[id] = req - } - req.Status = StatusQueued - req.Outcome = OutcomeActive - req.IntegrationKind = update.IntegrationKind - req.ExternalID = update.ExternalID - req.ExternalStatus = update.ExternalStatus - copy := *req - return ©, nil -} - func (f *fakeStore) SetOutcome(_ context.Context, id string, outcome Outcome, _ Viewer, message string) (*Request, error) { f.mu.Lock() defer f.mu.Unlock() @@ -1161,15 +1239,165 @@ func (f *fakeStore) ListIntegrations(context.Context) ([]Integration, error) { return f.integrations, nil } -func (f *fakeStore) UpsertIntegration(context.Context, Integration) (*Integration, error) { - return nil, nil -} - -func (f *fakeStore) UpsertIntegrations(_ context.Context, integrations []Integration) ([]Integration, error) { +func (f *fakeStore) GetIntegration(_ context.Context, id string) (*Integration, error) { f.mu.Lock() defer f.mu.Unlock() - f.integrations = append([]Integration(nil), integrations...) - return append([]Integration(nil), integrations...), nil + for i := range f.integrations { + if f.integrations[i].ID == id { + cp := f.integrations[i] + return &cp, nil + } + } + return nil, ErrNotFound +} + +func (f *fakeStore) CreateIntegration(_ context.Context, in Integration) (*Integration, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.integrations = append(f.integrations, in) + cp := in + return &cp, nil +} + +func (f *fakeStore) UpdateIntegration(_ context.Context, in Integration) (*Integration, error) { + f.mu.Lock() + defer f.mu.Unlock() + for i := range f.integrations { + if f.integrations[i].ID == in.ID { + f.integrations[i] = in + cp := in + return &cp, nil + } + } + return nil, ErrNotFound +} + +func (f *fakeStore) SaveIntegrationWithDefaults(_ context.Context, in Integration, isCreate bool) (*Integration, error) { + f.mu.Lock() + defer f.mu.Unlock() + for i := range f.integrations { + if f.integrations[i].Kind != in.Kind || f.integrations[i].ID == in.ID { + continue + } + if in.IsDefault { + f.integrations[i].IsDefault = false + } + if in.IsDefault4K { + f.integrations[i].IsDefault4K = false + } + } + if isCreate { + f.integrations = append(f.integrations, in) + cp := in + return &cp, nil + } + for i := range f.integrations { + if f.integrations[i].ID == in.ID { + f.integrations[i] = in + cp := in + return &cp, nil + } + } + return nil, ErrNotFound +} + +func (f *fakeStore) DeleteIntegration(_ context.Context, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + for _, targets := range f.targets { + for _, target := range targets { + if target.IntegrationID == id && (target.Status == StatusQueued || target.Status == StatusDownloading) { + return ErrInvalidState + } + } + } + for i := range f.integrations { + if f.integrations[i].ID == id { + f.integrations = append(f.integrations[:i], f.integrations[i+1:]...) + return nil + } + } + return ErrNotFound +} + +func (f *fakeStore) ListTargets(_ context.Context, requestID string) ([]Target, error) { + f.mu.Lock() + defer f.mu.Unlock() + return append([]Target(nil), f.targets[requestID]...), nil +} + +func (f *fakeStore) CreateTarget(_ context.Context, t Target) (Target, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.targets == nil { + f.targets = map[string][]Target{} + } + f.targetSeq++ + t.ID = f.targetSeq + f.targets[t.RequestID] = append(f.targets[t.RequestID], t) + return t, nil +} + +func (f *fakeStore) DeleteTarget(_ context.Context, id int64) error { + f.mu.Lock() + defer f.mu.Unlock() + for rid, ts := range f.targets { + for i := range ts { + if ts[i].ID == id { + f.targets[rid] = append(ts[:i], ts[i+1:]...) + return nil + } + } + } + return ErrNotFound +} + +func (f *fakeStore) UpdateTargetStatus(_ context.Context, targetID int64, status Status, externalID, externalStatus, lastErr string, _ Viewer) (*Request, error) { + f.mu.Lock() + defer f.mu.Unlock() + var requestID string + for rid, ts := range f.targets { + for i := range ts { + if ts[i].ID == targetID { + if externalID != "" { + f.targets[rid][i].ExternalID = externalID + } + if externalStatus != "" { + f.targets[rid][i].ExternalStatus = externalStatus + } + f.targets[rid][i].Status = status + f.targets[rid][i].LastError = lastErr + requestID = rid + } + } + } + if requestID == "" { + return nil, ErrNotFound + } + f.statusUpdates = append(f.statusUpdates, status) + st, outcome := aggregateStatus(f.targets[requestID]) + req := f.requests[requestID] + if req == nil { + req = &Request{ID: requestID} + f.requests[requestID] = req + } + req.Status = st + req.Outcome = outcome + // Surface the first target's external identity on the request snapshot so + // existing assertions on req.ExternalID/IntegrationKind keep working. + for _, t := range f.targets[requestID] { + if t.ExternalID != "" { + req.ExternalID = t.ExternalID + req.ExternalStatus = t.ExternalStatus + req.IntegrationKind = t.IntegrationKind + break + } + } + if outcome == OutcomeFailed { + req.LastError = lastErr + } + copy := *req + return ©, nil } func TestListStudiosReturnsBundleWithDuotoneLogos(t *testing.T) { @@ -1462,6 +1690,45 @@ func (f *fakeMovieAdapter) CheckMovieStatus(_ context.Context, req Request, inte return f.status, f.statusErr } +type fixedCeiling struct{ q string } + +func (f fixedCeiling) MaxPlaybackQuality(context.Context, int, string) (string, error) { + return f.q, nil +} + +type recordingMovieAdapter struct { + mu sync.Mutex + ids []string +} + +func (a *recordingMovieAdapter) SubmitMovie(_ context.Context, _ Request, integration Integration) (FulfillmentResult, error) { + a.mu.Lock() + defer a.mu.Unlock() + a.ids = append(a.ids, integration.ID) + return FulfillmentResult{IntegrationKind: integration.Kind, ExternalID: "ext-" + integration.ID, ExternalStatus: "queued"}, nil +} + +func TestSubmitApprovedFansOutDualQuality(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{ + inst("radarr", "hd", true, false, false), + inst("radarr", "uhd", false, true, false), + } + rec := &recordingMovieAdapter{} + svc := NewService(store, &fakeTMDBClient{}, &fakePresence{}) + svc.SetFulfillmentAdapters(rec, nil) + svc.SetEntitlementResolver(fixedCeiling{q: "2160p"}) + + req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7} + store.requests["r1"] = &req + if _, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}); err != nil { + t.Fatalf("submit: %v", err) + } + if len(rec.ids) != 2 { + t.Fatalf("expected 2 submissions (hd+uhd), got %d: %v", len(rec.ids), rec.ids) + } +} + type fakeSecrets map[string]string func (f fakeSecrets) Get(_ context.Context, key string) (string, error) { diff --git a/internal/requests/store.go b/internal/requests/store.go index 91637b4e..c70a8bae 100644 --- a/internal/requests/store.go +++ b/internal/requests/store.go @@ -21,11 +21,19 @@ type Store interface { ListMine(ctx context.Context, userID int, filter ListFilter) ([]*Request, error) ListAdmin(ctx context.Context, filter ListFilter) ([]*Request, error) SetStatus(ctx context.Context, id string, status Status, actor Viewer) (*Request, error) - MarkQueued(ctx context.Context, id string, update QueueUpdate, actor Viewer) (*Request, error) SetOutcome(ctx context.Context, id string, outcome Outcome, actor Viewer, message string) (*Request, error) + ListTargets(ctx context.Context, requestID string) ([]Target, error) + CreateTarget(ctx context.Context, target Target) (Target, error) + DeleteTarget(ctx context.Context, id int64) error + UpdateTargetStatus(ctx context.Context, targetID int64, status Status, externalID, externalStatus, lastErr string, actor Viewer) (*Request, error) ListIntegrations(ctx context.Context) ([]Integration, error) - UpsertIntegration(ctx context.Context, integration Integration) (*Integration, error) - UpsertIntegrations(ctx context.Context, integrations []Integration) ([]Integration, error) + GetIntegration(ctx context.Context, id string) (*Integration, error) + CreateIntegration(ctx context.Context, integration Integration) (*Integration, error) + UpdateIntegration(ctx context.Context, integration Integration) (*Integration, error) + // SaveIntegrationWithDefaults clears the conflicting kind default(s) and + // creates (isCreate) or updates the instance atomically in one transaction. + SaveIntegrationWithDefaults(ctx context.Context, integration Integration, isCreate bool) (*Integration, error) + DeleteIntegration(ctx context.Context, id string) error } type CreateRequestRecord struct { @@ -33,6 +41,7 @@ type CreateRequestRecord struct { Input CreateRequestInput Status Status Outcome Outcome + IsAnime bool Requester Viewer Now time.Time // Quota, when non-nil, instructs the store to atomically verify the diff --git a/internal/requests/targets.go b/internal/requests/targets.go new file mode 100644 index 00000000..242e2dba --- /dev/null +++ b/internal/requests/targets.go @@ -0,0 +1,192 @@ +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 completed == len(targets) { + return StatusCompleted, OutcomeActive + } + // Active targets keep the request active even with a failed sibling so the + // in-flight targets can finish (partial failure stays active). + if anyDownloading { + return StatusDownloading, OutcomeActive + } + if anyQueued { + return StatusQueued, OutcomeActive + } + // No active targets remain and at least one failed (all-failed, or a mix of + // completed + failed) -> surface as failed so Retry can re-submit the failed + // target while leaving completed ones untouched. + if failed > 0 { + return StatusQueued, OutcomeFailed + } + 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 +} + +func (r *Repository) DeleteTarget(ctx context.Context, id int64) error { + tag, err := r.pool.Exec(ctx, `DELETE FROM media_request_targets WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("delete target: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return 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 +} diff --git a/internal/requests/targets_test.go b/internal/requests/targets_test.go new file mode 100644 index 00000000..fd1bcd42 --- /dev/null +++ b/internal/requests/targets_test.go @@ -0,0 +1,27 @@ +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}, + {"completed plus failed", []Target{{Status: StatusCompleted}, {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) + } + }) + } +} diff --git a/internal/requests/types.go b/internal/requests/types.go index b4c5b6c0..9d3b9fad 100644 --- a/internal/requests/types.go +++ b/internal/requests/types.go @@ -22,6 +22,8 @@ const ( StatusCompleted Status = "completed" ) +const StatusFailed Status = "failed" // target-only status; requests use outcome=failed + type Outcome string const ( @@ -31,6 +33,31 @@ const ( OutcomeFailed Outcome = "failed" ) +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"` +} + type Availability string const ( @@ -67,6 +94,7 @@ type Settings struct { GlobalMaxRequests int `json:"global_max_requests"` GlobalWindowDays int `json:"global_window_days"` GlobalAutoApprovalEnabled bool `json:"global_auto_approval_enabled"` + ForceDualQuality bool `json:"force_dual_quality"` UpdatedAt time.Time `json:"updated_at"` } @@ -112,6 +140,8 @@ type Request struct { RequestedByUserID int `json:"requested_by_user_id,omitempty"` RequestedByProfileID string `json:"requested_by_profile_id,omitempty"` IntegrationKind string `json:"integration_kind,omitempty"` + IsAnime bool `json:"is_anime"` + Targets []Target `json:"targets,omitempty"` ExternalID string `json:"external_id,omitempty"` ExternalStatus string `json:"external_status,omitempty"` LastError string `json:"last_error,omitempty"` @@ -221,18 +251,27 @@ type ListFilter struct { } type Integration struct { - Kind string `json:"kind"` - Enabled bool `json:"enabled"` - BaseURL string `json:"base_url"` - APIKeyRef string `json:"api_key_ref,omitempty"` - RootFolder string `json:"root_folder"` - QualityProfileID *int `json:"quality_profile_id,omitempty"` - Tags []int `json:"tags"` - Options map[string]any `json:"options"` - LastCheckAt *time.Time `json:"last_check_at,omitempty"` - LastCheckStatus string `json:"last_check_status,omitempty"` - LastCheckError string `json:"last_check_error,omitempty"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + Enabled bool `json:"enabled"` + 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"` + BaseURL string `json:"base_url"` + APIKeyRef string `json:"api_key_ref,omitempty"` + RootFolder string `json:"root_folder"` + QualityProfileID *int `json:"quality_profile_id,omitempty"` + Tags []int `json:"tags"` + Options map[string]any `json:"options"` + LastCheckAt *time.Time `json:"last_check_at,omitempty"` + LastCheckStatus string `json:"last_check_status,omitempty"` + LastCheckError string `json:"last_check_error,omitempty"` + UpdatedAt time.Time `json:"updated_at"` } type IntegrationRootFolder struct { @@ -259,12 +298,6 @@ type IntegrationOptions struct { Tags []IntegrationTag `json:"tags"` } -type QueueUpdate struct { - IntegrationKind string - ExternalID string - ExternalStatus string -} - type FulfillmentResult struct { IntegrationKind string ExternalID string diff --git a/migrations/169_request_multi_instance.down.sql b/migrations/169_request_multi_instance.down.sql new file mode 100644 index 00000000..21285744 --- /dev/null +++ b/migrations/169_request_multi_instance.down.sql @@ -0,0 +1,42 @@ +-- 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; diff --git a/migrations/169_request_multi_instance.up.sql b/migrations/169_request_multi_instance.up.sql new file mode 100644 index 00000000..e2658922 --- /dev/null +++ b/migrations/169_request_multi_instance.up.sql @@ -0,0 +1,86 @@ +-- 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; diff --git a/migrations/170_request_force_dual_quality.down.sql b/migrations/170_request_force_dual_quality.down.sql new file mode 100644 index 00000000..cf81072d --- /dev/null +++ b/migrations/170_request_force_dual_quality.down.sql @@ -0,0 +1 @@ +ALTER TABLE public.request_settings DROP COLUMN IF EXISTS force_dual_quality; diff --git a/migrations/170_request_force_dual_quality.up.sql b/migrations/170_request_force_dual_quality.up.sql new file mode 100644 index 00000000..1afb8bcf --- /dev/null +++ b/migrations/170_request_force_dual_quality.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.request_settings + ADD COLUMN IF NOT EXISTS force_dual_quality boolean NOT NULL DEFAULT false; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index d67a8d57..8d9ba897 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1531,6 +1531,22 @@ export interface CreateMediaRequestInput { backdrop_path?: string; } +export interface RequestTarget { + id: number; + request_id: string; + integration_id?: string; + integration_kind?: string; + instance_name?: string; + quality: "1080p" | "2160p"; + is_anime: boolean; + external_id?: string; + external_status?: string; + status: MediaRequestStatus | "failed"; + last_error?: string; + created_at: string; + updated_at: string; +} + export interface MediaRequest { id: string; provider: string; @@ -1547,6 +1563,8 @@ export interface MediaRequest { outcome: MediaRequestOutcome; requested_by_user_id?: number; requested_by_profile_id?: string; + is_anime?: boolean; + targets?: RequestTarget[]; integration_kind?: string; external_id?: string; external_status?: string; @@ -1570,6 +1588,7 @@ export interface RequestSettings { global_max_requests: number; global_window_days: number; global_auto_approval_enabled: boolean; + force_dual_quality: boolean; updated_at: string; } @@ -1583,14 +1602,23 @@ export interface RequestUserLimit { } export interface RequestIntegration { + id: string; + name: string; kind: string; enabled: boolean; + is_4k: boolean; + is_default: boolean; + is_default_4k: boolean; base_url: string; api_key_ref?: string; has_api_key?: boolean; root_folder: string; quality_profile_id?: number | null; tags: number[]; + anime_enabled: boolean; + anime_quality_profile_id?: number | null; + anime_root_folder?: string; + anime_tags: number[]; options: Record; last_check_at?: string | null; last_check_status?: string; @@ -1623,6 +1651,7 @@ export interface RequestIntegrationOptions { } export interface LoadRequestIntegrationOptionsRequest { + kind: "radarr" | "sonarr"; base_url: string; api_key_ref?: string; } diff --git a/web/src/hooks/queries/useRequests.ts b/web/src/hooks/queries/useRequests.ts index f9423dda..422aa88d 100644 --- a/web/src/hooks/queries/useRequests.ts +++ b/web/src/hooks/queries/useRequests.ts @@ -326,35 +326,75 @@ export function useRequestIntegrations() { }); } -export function useUpdateRequestIntegrations() { +export function useCreateRequestIntegration() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (integrations: RequestIntegration[]) => - api("/admin/request-integrations", { - method: "PUT", - body: JSON.stringify({ integrations }), + mutationFn: (integration: RequestIntegration) => + api("/admin/request-integrations", { + method: "POST", + body: JSON.stringify(integration), }), onSuccess: () => { - toast.success("Integrations saved"); + toast.success("Integration created"); queryClient.invalidateQueries({ queryKey: adminKeys.requestIntegrations() }); invalidateRequestSurfaces(queryClient); }, onError: (err) => { - toast.error(err instanceof Error ? err.message : "Failed to save integrations"); + toast.error(err instanceof Error ? err.message : "Failed to create integration"); + }, + }); +} + +export function useUpdateRequestIntegration() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...integration }: RequestIntegration) => + api(`/admin/request-integrations/${encodeURIComponent(id)}`, { + method: "PUT", + body: JSON.stringify({ id, ...integration }), + }), + onSuccess: () => { + toast.success("Integration saved"); + queryClient.invalidateQueries({ queryKey: adminKeys.requestIntegrations() }); + invalidateRequestSurfaces(queryClient); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to save integration"); + }, + }); +} + +export function useDeleteRequestIntegration() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + api(`/admin/request-integrations/${encodeURIComponent(id)}`, { + method: "DELETE", + }), + onSuccess: () => { + toast.success("Integration deleted"); + queryClient.invalidateQueries({ queryKey: adminKeys.requestIntegrations() }); + invalidateRequestSurfaces(queryClient); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to delete integration"); }, }); } export function useLoadRequestIntegrationOptions() { return useMutation({ - mutationFn: ({ kind, body }: { kind: string; body: LoadRequestIntegrationOptionsRequest }) => + mutationFn: ({ id, body }: { id: string; body: LoadRequestIntegrationOptionsRequest }) => api( - `/admin/request-integrations/${encodeURIComponent(kind)}/options`, + `/admin/request-integrations/${encodeURIComponent(id)}/options`, { method: "POST", body: JSON.stringify(body), }, ), + onSuccess: () => { + toast.success("Connection successful"); + }, onError: (err) => { toast.error(err instanceof Error ? err.message : "Failed to load integration settings"); }, diff --git a/web/src/pages/AdminRequests.tsx b/web/src/pages/AdminRequests.tsx index e7c339a6..9c588bc2 100644 --- a/web/src/pages/AdminRequests.tsx +++ b/web/src/pages/AdminRequests.tsx @@ -1,7 +1,20 @@ -import { useMemo, useState } from "react"; +import { useId, useMemo, useState } from "react"; import type { ReactNode } from "react"; import { Link, useSearchParams } from "react-router"; -import { Check, Plug, RefreshCw, Save, Settings2, SlidersHorizontal, X } from "lucide-react"; +import { + AlertTriangle, + Check, + ChevronDown, + ChevronRight, + Plug, + Plus, + RefreshCw, + Save, + Settings2, + SlidersHorizontal, + Trash2, + X, +} from "lucide-react"; import type { MediaRequest, MediaRequestOutcome, @@ -11,6 +24,7 @@ import type { RequestIntegrationOptions, RequestLimitMode, RequestSettings, + RequestTarget, RequestUserLimit, } from "@/api/types"; import { Badge } from "@/components/ui/badge"; @@ -47,13 +61,15 @@ import { useAdminUsers } from "@/hooks/queries/admin/users"; import { useAdminMediaRequests, useApproveMediaRequest, + useCreateRequestIntegration, useDeclineMediaRequest, + useDeleteRequestIntegration, useLoadRequestIntegrationOptions, useRequestIntegrations, useRequestSettings, useRequestUserLimit, useRetryMediaRequest, - useUpdateRequestIntegrations, + useUpdateRequestIntegration, useUpdateRequestSettings, useUpdateRequestUserLimit, } from "@/hooks/queries/useRequests"; @@ -357,8 +373,20 @@ function RequestQueueRow({ - {request.integration_kind || "Not submitted"} - {request.external_status ? {request.external_status} : null} + {request.targets?.length ? ( +
+ {request.is_anime ? ( + + Anime + + ) : null} + {request.targets.map((target) => ( + + ))} +
+ ) : ( + "Not submitted" + )}
@@ -390,11 +418,35 @@ function RequestQueueRow({ ); } +function RequestTargetBadge({ target }: { target: RequestTarget }) { + const qualityLabel = target.quality === "2160p" ? "2160p" : "1080p"; + const instanceLabel = target.instance_name || target.integration_kind || "Unknown"; + const failed = target.status === "failed"; + const statusLabel = target.status === "failed" ? "Failed" : formatRequestStatus(target.status); + return ( +
+
+ {qualityLabel} + {instanceLabel} + {statusLabel} + {target.external_status ? {target.external_status} : null} +
+ {failed && target.last_error ? ( +

+ + {target.last_error} +

+ ) : null} +
+ ); +} + type SettingsFormState = { requests_enabled: boolean; global_max_requests: string; global_window_days: string; global_auto_approval_enabled: boolean; + force_dual_quality: boolean; updated_at: string; }; @@ -419,6 +471,7 @@ function RequestSettingsForm({ settings }: { settings: RequestSettings }) { global_max_requests: String(settings.global_max_requests), global_window_days: String(settings.global_window_days), global_auto_approval_enabled: settings.global_auto_approval_enabled, + force_dual_quality: settings.force_dual_quality, updated_at: settings.updated_at, })); @@ -428,6 +481,7 @@ function RequestSettingsForm({ settings }: { settings: RequestSettings }) { global_max_requests: Math.max(0, Number(form.global_max_requests) || 0), global_window_days: Math.max(1, Number(form.global_window_days) || 1), global_auto_approval_enabled: form.global_auto_approval_enabled, + force_dual_quality: form.force_dual_quality, updated_at: form.updated_at, }; updateSettings.mutate(payload); @@ -475,6 +529,16 @@ function RequestSettingsForm({ settings }: { settings: RequestSettings }) { } /> +
+ + setForm((current) => ({ ...current, force_dual_quality: checked })) + } + /> +
+
+ {INTEGRATION_KINDS.map((kind) => { + const kindCards = cards.filter((card) => card.form.kind === kind); + const title = kind === "radarr" ? "Radarr" : "Sonarr"; + return ( +
+
+

{title} instances

+ +
+ {kindCards.length === 0 ? ( + + ) : ( +
+ {kindCards.map((card) => ( + updateCard(card.key, patch)} + onRemove={() => removeCard(card.key)} + /> + ))} +
+ )} +
+ ); + })}
); } -function integrationsFormKey(integrations: RequestIntegration[]): string { - if (integrations.length === 0) return "empty"; - return integrations - .map( - (integration) => - `${integration.kind}:${integration.updated_at ?? ""}:${integration.enabled}:${integration.has_api_key}`, - ) - .join("|"); -} - function IntegrationEditor({ form, + source, onChange, + onRemove, }: { form: IntegrationFormState; + source: RequestIntegration | null; onChange: (patch: Partial) => void; + onRemove: () => void; }) { const title = form.kind === "radarr" ? "Radarr" : "Sonarr"; + const isNew = form.id === ""; + const createIntegration = useCreateRequestIntegration(); + const updateIntegration = useUpdateRequestIntegration(); + const deleteIntegration = useDeleteRequestIntegration(); + const [animeOpen, setAnimeOpen] = useState(form.anime_enabled); + const [confirmDelete, setConfirmDelete] = useState(false); + const animePanelID = useId(); + const isDirty = !isNew && source !== null && isIntegrationDirty(form, source); const loadOptions = useLoadRequestIntegrationOptions(); const [options, setOptions] = useState(null); const rootFolders = rootFolderChoices(options, form.root_folder); const qualityProfiles = qualityProfileChoices(options, form.quality_profile_id); + const animeRootFolders = rootFolderChoices(options, form.anime_root_folder); + const animeQualityProfiles = qualityProfileChoices(options, form.anime_quality_profile_id); const tags = options?.tags ?? []; const selectedTags = parseTags(form.tags); + const selectedAnimeTags = parseTags(form.anime_tags); const canLoadOptions = form.base_url.trim().length > 0 && Boolean(form.api_key_ref.trim() || form.has_api_key); async function handleLoadOptions() { try { const loaded = await loadOptions.mutateAsync({ - kind: form.kind, + id: form.id || "new", body: { + kind: form.kind, base_url: form.base_url, api_key_ref: form.api_key_ref.trim() || undefined, }, @@ -620,6 +740,21 @@ function IntegrationEditor({ } } + const saving = createIntegration.isPending || updateIntegration.isPending; + // New instances must carry an API key (there's no saved key to fall back on); + // edits may leave it blank to keep the stored key (has_api_key). + const hasApiKey = form.api_key_ref.trim().length > 0 || form.has_api_key; + const canSave = form.name.trim().length > 0 && form.base_url.trim().length > 0 && hasApiKey; + + function handleSave() { + const payload = formToIntegration(form); + if (isNew) { + createIntegration.mutate(payload); + } else { + updateIntegration.mutate(payload); + } + } + return (
@@ -627,6 +762,8 @@ function IntegrationEditor({

{title}

{form.has_api_key ? Key saved : null} + {isNew ? New : null} + {isDirty ? Unsaved changes : null}
onChange({ enabled })} />
+
+ + onChange({ name: event.target.value })} + placeholder={`${title} instance`} + /> + + + onChange({ + is_4k, + is_default: is_4k ? false : form.is_default, + is_default_4k: is_4k ? form.is_default_4k : false, + }) + } + /> + onChange({ is_default })} + /> + onChange({ is_default_4k })} + /> +
+
) : null} + +
+ + {animeOpen ? ( +
+ onChange({ anime_enabled })} + /> + {form.anime_enabled ? ( +
+ + {animeQualityProfiles.length > 0 ? ( + + ) : ( + + onChange({ anime_quality_profile_id: event.target.value }) + } + /> + )} + + + {animeRootFolders.length > 0 ? ( + + ) : ( + onChange({ anime_root_folder: event.target.value })} + placeholder="/media/anime" + /> + )} + + + onChange({ anime_tags: event.target.value })} + placeholder="1, 2" + /> + {tags.length > 0 ? ( +
+ {tags.map((tag) => { + const selected = selectedAnimeTags.includes(tag.id); + return ( + + ); + })} +
+ ) : null} +
+
+ ) : null} +
+ ) : null} +
+ +
+ + {isNew ? ( + + ) : ( + + )} +
+ + { + if (!open) setConfirmDelete(false); + }} + > + + + Delete instance + + {`"${form.name.trim() || title}" will be permanently removed. New requests will no longer route to this instance.`} + + + + + + + +
); } @@ -795,8 +1143,13 @@ function integrationToForm( ): IntegrationFormState { const options = integration?.options ?? {}; return { + id: integration?.id ?? "", + name: integration?.name ?? "", kind, - enabled: integration?.enabled ?? false, + enabled: integration?.enabled ?? true, + is_4k: integration?.is_4k ?? false, + is_default: integration?.is_default ?? false, + is_default_4k: integration?.is_default_4k ?? false, base_url: integration?.base_url ?? "", api_key_ref: "", root_folder: integration?.root_folder ?? "", @@ -804,6 +1157,12 @@ function integrationToForm( ? String(integration.quality_profile_id) : "", tags: integration?.tags?.join(", ") ?? "", + anime_enabled: integration?.anime_enabled ?? false, + anime_quality_profile_id: integration?.anime_quality_profile_id + ? String(integration.anime_quality_profile_id) + : "", + anime_root_folder: integration?.anime_root_folder ?? "", + anime_tags: integration?.anime_tags?.join(", ") ?? "", search_on_add: boolOption(options, "search_on_add", true), minimum_availability: stringOption(options, "minimum_availability", "released"), series_type: stringOption(options, "series_type", "standard"), @@ -814,6 +1173,7 @@ function integrationToForm( function formToIntegration(form: IntegrationFormState): RequestIntegration { const qualityProfileID = Number(form.quality_profile_id); + const animeQualityProfileID = Number(form.anime_quality_profile_id); const options: Record = { search_on_add: form.search_on_add, }; @@ -825,18 +1185,51 @@ function formToIntegration(form: IntegrationFormState): RequestIntegration { } return { + id: form.id, + name: form.name.trim(), kind: form.kind, enabled: form.enabled, + is_4k: form.is_4k, + is_default: form.is_default, + is_default_4k: form.is_default_4k, base_url: form.base_url.trim(), api_key_ref: form.api_key_ref.trim() || undefined, root_folder: form.root_folder.trim(), quality_profile_id: Number.isFinite(qualityProfileID) && qualityProfileID > 0 ? qualityProfileID : undefined, tags: parseTags(form.tags), + anime_enabled: form.anime_enabled, + anime_quality_profile_id: + Number.isFinite(animeQualityProfileID) && animeQualityProfileID > 0 + ? animeQualityProfileID + : undefined, + anime_root_folder: form.anime_root_folder.trim() || undefined, + anime_tags: parseTags(form.anime_tags), options, }; } +function isIntegrationDirty(form: IntegrationFormState, source: RequestIntegration): boolean { + // A pending API key entry always counts as an unsaved change. + if (form.api_key_ref.trim().length > 0) return true; + + const next = formToIntegration(form); + const seeded = integrationToForm(form.kind, source); + const original = formToIntegration(seeded); + + return ( + JSON.stringify(stripIntegrationForCompare(next)) !== + JSON.stringify(stripIntegrationForCompare(original)) + ); +} + +function stripIntegrationForCompare(integration: RequestIntegration): Record { + // api_key_ref is write-only (never round-trips from the source) and is handled + // separately above, so exclude it from the structural comparison. + const { api_key_ref: _apiKeyRef, ...rest } = integration; + return rest; +} + function rootFolderChoices(options: RequestIntegrationOptions | null, currentPath: string) { const folders = options?.root_folders ?? []; if (!currentPath || folders.some((folder) => folder.path === currentPath)) { @@ -1077,15 +1470,22 @@ function SwitchField({ label, checked, onCheckedChange, + description, + disabled, }: { label: string; checked: boolean; onCheckedChange: (checked: boolean) => void; + description?: string; + disabled?: boolean; }) { return (
- - +
+ + {description ?

{description}

: null} +
+
); }