* docs: design spec for multi-instance Sonarr/Radarr request routing Seerr-style multi-instance arr management inside Silo's request system: many instances per kind, HD/4K default routing, entitlement-driven dual-quality fan-out, per-instance anime overrides (keyword 210024), and a one-to-many media_request_targets model. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: implementation plan for multi-instance arr request routing Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(requests): migration for multi-instance arr routing Adds migration 169 to convert request_integrations from a one-row-per-kind table keyed on `kind` to a multi-instance table keyed on `id`, with HD/4K defaults, anime overrides, and a new one-to-many media_request_targets table for per-quality fulfillment tracking. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(requests): instance, target, and dual-quality types Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(requests): id-based integration CRUD Replace upsert-by-kind (UpsertIntegration/UpsertIntegrations) with GetIntegration, CreateIntegration, UpdateIntegration, DeleteIntegration, and ClearDefault. Rewrites scanIntegration and integrationColumns to cover all new multi-instance columns (id, name, is_4k, is_default, is_default_4k, anime_* fields). Updates the Store interface accordingly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(requests): target persistence and aggregate status * feat(tmdb): expose keyword ids and original language on detail * feat(requests): Seerr-exact anime detection (keyword 210024) * feat(requests): quality/anime routing engine Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(requests): force_dual_quality setting Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(requests): multi-target fulfillment, reconcile, retry, and instance CRUD Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(api): request integration CRUD endpoints, targets in responses, entitlement wiring Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): multi-instance request integration types and CRUD hooks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(web): multi-instance arr manager, dual-quality toggle, per-target queue Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): UX review fixes for arr manager (delete confirm, switch hints, test feedback, dirty + target status) * fix(requests): address code-review findings (test-connection by id, HD-only default ceiling, retryable partial failure, idempotent submit, transactional defaults, presence/target reconcile, auto-approve gate) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: address CodeRabbit review (anime override fallback, non-null slices, save gate, a11y, DeleteTarget not-found) - routing: anime fields only override standard root/profile/tags when set, so enabling anime with blank fields reuses standard values instead of clearing them into an invalid submission - api: normalize nil Tags/AnimeTags to [] so they serialize as arrays not null - web: require an API key before saving a NEW instance; add aria-expanded/ aria-controls to the anime-overrides disclosure toggle - repo: DeleteTarget returns ErrNotFound when no row was deleted Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(requests): address PR review findings --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
1705 lines
60 KiB
Markdown
1705 lines
60 KiB
Markdown
# 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.
|