* 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>
193 lines
5.8 KiB
Go
193 lines
5.8 KiB
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 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
|
|
}
|