Files
silo-server/internal/requests/routing.go
T
ea3b5a2e29 feat(requests): multi-instance Sonarr/Radarr routing with HD/4K defaults and anime overrides (#39)
* docs: design spec for multi-instance Sonarr/Radarr request routing

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

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

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

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

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

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

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

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

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

* feat(requests): id-based integration CRUD

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

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

* feat(requests): target persistence and aggregate status

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

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

* feat(requests): quality/anime routing engine

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

* feat(requests): force_dual_quality setting

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(requests): address PR review findings

---------

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

86 lines
2.5 KiB
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.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
}