diff --git a/cmd/silo/main.go b/cmd/silo/main.go index 3718de69..0f0334fe 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -72,8 +72,6 @@ import ( "github.com/Silo-Server/silo-server/internal/ratelimit" "github.com/Silo-Server/silo-server/internal/recommendations" mediarequests "github.com/Silo-Server/silo-server/internal/requests" - "github.com/Silo-Server/silo-server/internal/requests/radarr" - "github.com/Silo-Server/silo-server/internal/requests/sonarr" "github.com/Silo-Server/silo-server/internal/s3client" "github.com/Silo-Server/silo-server/internal/scanner" "github.com/Silo-Server/silo-server/internal/scanqueue" @@ -1541,7 +1539,8 @@ func main() { catalog.NewProviderIDRepository(deps.DB), ), ) - requestReconcileSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil)) + requestReconcileSvc.SetRequesterIdentityResolver(plugins.RequesterIdentityFromLookup(plugins.NewPgUserIdentityLookup(deps.DB))) + api.AttachRequestRouter(requestReconcileSvc, pluginService) if userStoreProvider != nil { reconcileResolver := access.NewResolver( auth.NewUserRepository(deps.DB), diff --git a/go.mod b/go.mod index 8c01f1cb..6e9a0685 100644 --- a/go.mod +++ b/go.mod @@ -76,7 +76,7 @@ require ( ) require ( - github.com/Silo-Server/silo-plugin-sdk v0.5.0 + github.com/Silo-Server/silo-plugin-sdk v0.6.0 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect diff --git a/go.sum b/go.sum index 2e30ad0f..63677a6b 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ= entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM= github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= -github.com/Silo-Server/silo-plugin-sdk v0.5.0 h1:0yJpD0RP1AetGsWVCXoPih2ggLjN8hVp8blS+2RkIWU= -github.com/Silo-Server/silo-plugin-sdk v0.5.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA= +github.com/Silo-Server/silo-plugin-sdk v0.6.0 h1:Gi9TdH9kt7b8X4xRXH493/nSYb9n0GO4VCWmlll0hKI= +github.com/Silo-Server/silo-plugin-sdk v0.6.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA= github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4= github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc= github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= diff --git a/internal/api/autoscan_wiring_test.go b/internal/api/autoscan_wiring_test.go index 926f2f86..b7aebf1c 100644 --- a/internal/api/autoscan_wiring_test.go +++ b/internal/api/autoscan_wiring_test.go @@ -1,6 +1,10 @@ package api -import "testing" +import ( + "testing" + + mediarequests "github.com/Silo-Server/silo-server/internal/requests" +) // TestCheckRequestIntegrationUsable verifies the Fix 4 gate: a reused Requests // connection is rejected when the integration is disabled or has no base_url, so @@ -22,3 +26,31 @@ func TestCheckRequestIntegrationUsable(t *testing.T) { } }) } + +// TestCheckRequestIntegrationUsableGatesReuse is the Task 17 regression test. It +// locks the autoscan→requests credential-reuse behavior after the +// request_integrations table was generalized into the two-tier connection +// registry (Task 9/10): autoscan must keep reusing the Sonarr/Radarr connection +// rows via the same enabled + base_url gate, regardless of the added +// capability/installation columns. +func TestCheckRequestIntegrationUsableGatesReuse(t *testing.T) { + // Enabled + non-blank base_url -> usable (autoscan can reuse the connection). + if err := checkRequestIntegrationUsable("int-1", true, "http://sonarr:8989"); err != nil { + t.Fatalf("enabled integration with base_url should be usable, got: %v", err) + } + // Disabled -> error (autoscan must not poll a disabled connection). + if err := checkRequestIntegrationUsable("int-1", false, "http://sonarr:8989"); err == nil { + t.Fatal("disabled integration must be rejected") + } + // Blank base_url -> error. + if err := checkRequestIntegrationUsable("int-1", true, " "); err == nil { + t.Fatal("blank base_url must be rejected") + } +} + +// Compile-time guard: autoscan's RequestIntegrationLookup reads these fields off +// mediarequests.Integration. If any is renamed/removed, autoscan reuse breaks — +// fail the build here rather than silently at runtime. +var _ = func(in mediarequests.Integration) (string, string, bool) { + return in.BaseURL, in.APIKeyRef, in.Enabled +} diff --git a/internal/api/handlers/libraries.go b/internal/api/handlers/libraries.go index eb60ee9e..c632d024 100644 --- a/internal/api/handlers/libraries.go +++ b/internal/api/handlers/libraries.go @@ -2060,7 +2060,7 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin var candidates []candidate for _, c := range caps { - defaultPriority := metadata.LookupDefaultPriority(ctx, h.ChainRepo.Pool(), c.PluginInstallationID, level) + defaultPriority := metadata.LookupDefaultPriority(ctx, h.ChainRepo.Pool(), c.PluginInstallationID, c.CapabilityID, level) if defaultPriority > 0 { candidates = append(candidates, candidate{ installationID: c.PluginInstallationID, diff --git a/internal/api/handlers/plugins.go b/internal/api/handlers/plugins.go index e4eef106..bd025336 100644 --- a/internal/api/handlers/plugins.go +++ b/internal/api/handlers/plugins.go @@ -169,22 +169,27 @@ type pluginConfigSchemaJSON struct { } type pluginAdminFormJSON struct { - Fields []pluginAdminFormFieldJSON `json:"fields"` - SubmitLabel string `json:"submit_label,omitempty"` + Fields []pluginAdminFormFieldJSON `json:"fields"` + SubmitLabel string `json:"submit_label,omitempty"` + Sections []pluginAdminFormSectionJSON `json:"sections,omitempty"` } type pluginAdminFormFieldJSON struct { - Key string `json:"key"` - Label string `json:"label"` - Description string `json:"description,omitempty"` - Control string `json:"control"` - Placeholder string `json:"placeholder,omitempty"` - Required bool `json:"required"` - Secret bool `json:"secret"` - Multiline bool `json:"multiline"` - DefaultValue any `json:"default_value,omitempty"` - Options []pluginAdminFormOptionJSON `json:"options,omitempty"` - Rows int32 `json:"rows,omitempty"` + Key string `json:"key"` + Label string `json:"label"` + Description string `json:"description,omitempty"` + Control string `json:"control"` + Placeholder string `json:"placeholder,omitempty"` + Required bool `json:"required"` + Secret bool `json:"secret"` + Multiline bool `json:"multiline"` + DefaultValue any `json:"default_value,omitempty"` + Options []pluginAdminFormOptionJSON `json:"options,omitempty"` + Rows int32 `json:"rows,omitempty"` + DynamicOptions bool `json:"dynamic_options,omitempty"` + ShowWhen []pluginAdminFormConditionJSON `json:"show_when,omitempty"` + Validation *pluginAdminFormValidationJSON `json:"validation,omitempty"` + ExclusiveGroupField string `json:"exclusive_group_field,omitempty"` } type pluginAdminFormOptionJSON struct { @@ -193,6 +198,31 @@ type pluginAdminFormOptionJSON struct { Description string `json:"description,omitempty"` } +type pluginAdminFormConditionJSON struct { + Field string `json:"field"` + Equals []string `json:"equals"` +} + +type pluginAdminFormValidationJSON struct { + HasMin bool `json:"has_min,omitempty"` + Min float64 `json:"min,omitempty"` + HasMax bool `json:"has_max,omitempty"` + Max float64 `json:"max,omitempty"` + Pattern string `json:"pattern,omitempty"` + MinLength int32 `json:"min_length,omitempty"` + MaxLength int32 `json:"max_length,omitempty"` +} + +type pluginAdminFormSectionJSON struct { + Key string `json:"key"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Collapsible bool `json:"collapsible"` + CollapsedDefault bool `json:"collapsed_default"` + FieldKeys []string `json:"field_keys"` + ShowWhen []pluginAdminFormConditionJSON `json:"show_when,omitempty"` +} + type pluginCapabilityJSON struct { Type string `json:"type"` ID string `json:"id"` @@ -660,7 +690,7 @@ func (h *PluginHandler) syncMetadataProviders(ctx context.Context, installation continue } if err := h.chainRepo.AppendProviderToAllChains(ctx, installation.ID, cap.ID, func(level string) int { - return metadata.LookupDefaultPriority(ctx, h.chainRepo.Pool(), installation.ID, level) + return metadata.LookupDefaultPriority(ctx, h.chainRepo.Pool(), installation.ID, cap.ID, level) }); err != nil { slog.Warn("failed to append provider to library chains", "installation_id", installation.ID, @@ -1342,26 +1372,78 @@ func adminFormToJSON(form *pluginv1.AdminFormDescriptor) *pluginAdminFormJSON { if field.GetDefaultValue() != nil { defaultValue = field.GetDefaultValue().AsInterface() } + var validation *pluginAdminFormValidationJSON + if v := field.GetValidation(); v != nil { + validation = &pluginAdminFormValidationJSON{ + HasMin: v.GetHasMin(), + Min: v.GetMin(), + HasMax: v.GetHasMax(), + Max: v.GetMax(), + Pattern: v.GetPattern(), + MinLength: v.GetMinLength(), + MaxLength: v.GetMaxLength(), + } + } fields = append(fields, pluginAdminFormFieldJSON{ - Key: field.GetKey(), - Label: field.GetLabel(), - Description: field.GetDescription(), - Control: strings.TrimPrefix(field.GetControl().String(), "ADMIN_FORM_CONTROL_"), - Placeholder: field.GetPlaceholder(), - Required: field.GetRequired(), - Secret: field.GetSecret(), - Multiline: field.GetMultiline(), - DefaultValue: defaultValue, - Options: options, - Rows: field.GetRows(), + Key: field.GetKey(), + Label: field.GetLabel(), + Description: field.GetDescription(), + Control: strings.TrimPrefix(field.GetControl().String(), "ADMIN_FORM_CONTROL_"), + Placeholder: field.GetPlaceholder(), + Required: field.GetRequired(), + Secret: field.GetSecret(), + Multiline: field.GetMultiline(), + DefaultValue: defaultValue, + Options: options, + Rows: field.GetRows(), + DynamicOptions: field.GetDynamicOptions(), + ShowWhen: adminFormConditionsToJSON(field.GetShowWhen()), + Validation: validation, + ExclusiveGroupField: field.GetExclusiveGroupField(), + }) + } + sections := make([]pluginAdminFormSectionJSON, 0, len(form.GetSections())) + for _, section := range form.GetSections() { + if section == nil { + continue + } + sections = append(sections, pluginAdminFormSectionJSON{ + Key: section.GetKey(), + Title: section.GetTitle(), + Description: section.GetDescription(), + Collapsible: section.GetCollapsible(), + CollapsedDefault: section.GetCollapsedDefault(), + FieldKeys: append([]string(nil), section.GetFieldKeys()...), + ShowWhen: adminFormConditionsToJSON(section.GetShowWhen()), }) } return &pluginAdminFormJSON{ Fields: fields, SubmitLabel: form.GetSubmitLabel(), + Sections: sections, } } +func adminFormConditionsToJSON(conditions []*pluginv1.AdminFormCondition) []pluginAdminFormConditionJSON { + if len(conditions) == 0 { + return nil + } + out := make([]pluginAdminFormConditionJSON, 0, len(conditions)) + for _, condition := range conditions { + if condition == nil { + continue + } + out = append(out, pluginAdminFormConditionJSON{ + Field: condition.GetField(), + Equals: append([]string(nil), condition.GetEquals()...), + }) + } + if len(out) == 0 { + return nil + } + return out +} + func capabilitiesToJSON(descriptors []*pluginv1.CapabilityDescriptor) []pluginCapabilityJSON { response := make([]pluginCapabilityJSON, 0, len(descriptors)) for _, descriptor := range descriptors { diff --git a/internal/api/handlers/plugins_admin_form_test.go b/internal/api/handlers/plugins_admin_form_test.go new file mode 100644 index 00000000..6e4bec2b --- /dev/null +++ b/internal/api/handlers/plugins_admin_form_test.go @@ -0,0 +1,216 @@ +package handlers + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" +) + +// TestAdminFormToJSONSchemaFields locks the bridge between the SDK proto +// AdminForm descriptor and the JSON the frontend SchemaForm consumes. Without +// this, the schema-driven request-connection form silently loses its +// dynamic dropdowns (dynamic_options), conditional visibility (show_when), +// validation, sections, and the MULTI_SELECT control mapping, which makes the +// new form non-functional (required fields can never be set, save stays +// disabled). +func TestAdminFormToJSONSchemaFields(t *testing.T) { + descriptor := &pluginv1.AdminFormDescriptor{ + SubmitLabel: "Connect", + Fields: []*pluginv1.AdminFormField{ + { + Key: "quality_profile_ids", + Label: "Quality Profiles", + Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_MULTI_SELECT, + DynamicOptions: true, + ExclusiveGroupField: "service_kind", + ShowWhen: []*pluginv1.AdminFormCondition{ + {Field: "service_type", Equals: []string{"radarr", "sonarr"}}, + }, + Validation: &pluginv1.AdminFormValidation{ + HasMin: true, + Min: 1, + HasMax: true, + Max: 10, + Pattern: "^[0-9]+$", + MinLength: 1, + MaxLength: 64, + }, + }, + }, + Sections: []*pluginv1.AdminFormSection{ + { + Key: "advanced", + Title: "Advanced", + Description: "Optional tuning", + Collapsible: true, + CollapsedDefault: true, + FieldKeys: []string{"quality_profile_ids"}, + ShowWhen: []*pluginv1.AdminFormCondition{ + {Field: "service_type", Equals: []string{"radarr"}}, + }, + }, + }, + } + + form := adminFormToJSON(descriptor) + if form == nil { + t.Fatal("adminFormToJSON returned nil") + } + + raw, err := json.Marshal(form) + if err != nil { + t.Fatalf("marshal admin form: %v", err) + } + + var decoded struct { + Fields []struct { + Control string `json:"control"` + DynamicOptions bool `json:"dynamic_options"` + ShowWhen []struct { + Field string `json:"field"` + Equals []string `json:"equals"` + } `json:"show_when"` + Validation *struct { + HasMin bool `json:"has_min"` + Min float64 `json:"min"` + HasMax bool `json:"has_max"` + Max float64 `json:"max"` + Pattern string `json:"pattern"` + MinLength int32 `json:"min_length"` + MaxLength int32 `json:"max_length"` + } `json:"validation"` + ExclusiveGroupField string `json:"exclusive_group_field"` + } `json:"fields"` + Sections []struct { + Key string `json:"key"` + Title string `json:"title"` + Description string `json:"description"` + Collapsible bool `json:"collapsible"` + CollapsedDefault bool `json:"collapsed_default"` + FieldKeys []string `json:"field_keys"` + ShowWhen []struct { + Field string `json:"field"` + Equals []string `json:"equals"` + } `json:"show_when"` + } `json:"sections"` + } + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("unmarshal admin form: %v\njson: %s", err, raw) + } + + if len(decoded.Fields) != 1 { + t.Fatalf("expected 1 field, got %d", len(decoded.Fields)) + } + field := decoded.Fields[0] + + // Control must serialize to the SHORT form the frontend switches on. + if field.Control != "MULTI_SELECT" { + t.Errorf("control = %q, want %q", field.Control, "MULTI_SELECT") + } + if !field.DynamicOptions { + t.Error("dynamic_options = false, want true") + } + if field.ExclusiveGroupField != "service_kind" { + t.Errorf("exclusive_group_field = %q, want %q", field.ExclusiveGroupField, "service_kind") + } + if len(field.ShowWhen) != 1 || field.ShowWhen[0].Field != "service_type" || + len(field.ShowWhen[0].Equals) != 2 { + t.Errorf("field show_when not serialized correctly: %+v", field.ShowWhen) + } + if field.Validation == nil { + t.Fatal("validation not serialized") + } + if !field.Validation.HasMin || field.Validation.Min != 1 || + !field.Validation.HasMax || field.Validation.Max != 10 || + field.Validation.Pattern != "^[0-9]+$" || + field.Validation.MinLength != 1 || field.Validation.MaxLength != 64 { + t.Errorf("validation not serialized correctly: %+v", field.Validation) + } + + if len(decoded.Sections) != 1 { + t.Fatalf("expected 1 section, got %d", len(decoded.Sections)) + } + section := decoded.Sections[0] + if section.Key != "advanced" || section.Title != "Advanced" || + section.Description != "Optional tuning" || !section.Collapsible || + !section.CollapsedDefault { + t.Errorf("section scalar fields not serialized correctly: %+v", section) + } + if len(section.FieldKeys) != 1 || section.FieldKeys[0] != "quality_profile_ids" { + t.Errorf("section field_keys not serialized correctly: %+v", section.FieldKeys) + } + if len(section.ShowWhen) != 1 || section.ShowWhen[0].Field != "service_type" || + len(section.ShowWhen[0].Equals) != 1 { + t.Errorf("section show_when not serialized correctly: %+v", section.ShowWhen) + } +} + +// adminFormDTOMappingRenames records intentional proto-field -> DTO-field name +// renames. The DTO field names currently mirror the proto field names 1:1, so +// this is empty; add an entry only when a DTO field is deliberately named +// differently from its proto source (the test then accepts the renamed target). +var adminFormDTOMappingRenames = map[string]string{} + +// protoInternalFields are the protobuf-runtime bookkeeping fields every +// generated message struct carries. They are never serialized to the client +// DTO, so the completeness guard skips them. +var protoInternalFields = map[string]bool{ + "state": true, + "sizeCache": true, + "unknownFields": true, +} + +// TestAdminFormDTOCompleteness is a guard against the C1-class bug: the +// hand-written adminFormToJSON allowlist (pluginAdminFormFieldJSON / +// pluginAdminFormSectionJSON / pluginAdminFormJSON) silently dropping a NEW +// proto field added to AdminFormField / AdminFormDescriptor / AdminFormSection +// later. It reflects over each proto message's exported fields and asserts the +// corresponding JSON DTO struct has a field mapping to it (by identical Go name, +// or via an explicit rename in adminFormDTOMappingRenames). When the next proto +// field lands, this FAILS naming the un-mapped field, forcing the serializer + +// DTO to be updated in lockstep. (We deliberately do not switch the serializer +// to protojson — the short-form control-string MULTI_SELECT mapping makes that +// risky; this guard is the proportionate fix.) +func TestAdminFormDTOCompleteness(t *testing.T) { + cases := []struct { + name string + proto reflect.Type + dto reflect.Type + }{ + {"AdminFormField", reflect.TypeOf(pluginv1.AdminFormField{}), reflect.TypeOf(pluginAdminFormFieldJSON{})}, + {"AdminFormDescriptor", reflect.TypeOf(pluginv1.AdminFormDescriptor{}), reflect.TypeOf(pluginAdminFormJSON{})}, + {"AdminFormSection", reflect.TypeOf(pluginv1.AdminFormSection{}), reflect.TypeOf(pluginAdminFormSectionJSON{})}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dtoFields := map[string]bool{} + for i := 0; i < tc.dto.NumField(); i++ { + dtoFields[tc.dto.Field(i).Name] = true + } + + for i := 0; i < tc.proto.NumField(); i++ { + pf := tc.proto.Field(i) + if !pf.IsExported() { + continue // unexported protobuf-internal state + } + if protoInternalFields[pf.Name] || strings.HasPrefix(pf.Name, "XXX_") { + continue + } + want := pf.Name + if mapped, ok := adminFormDTOMappingRenames[pf.Name]; ok { + want = mapped + } + if !dtoFields[want] { + t.Errorf("proto field %s.%s has no corresponding field %q in the JSON DTO %s; "+ + "adminFormToJSON would silently drop it. Add the field to the DTO + serializer "+ + "(or record an intentional rename in adminFormDTOMappingRenames).", + tc.name, pf.Name, want, tc.dto.Name()) + } + } + }) + } +} diff --git a/internal/api/handlers/requests.go b/internal/api/handlers/requests.go index 420b3152..1ad95ba1 100644 --- a/internal/api/handlers/requests.go +++ b/internal/api/handlers/requests.go @@ -38,7 +38,7 @@ type RequestService interface { 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) + LoadIntegrationOptions(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (map[string][]mediarequests.RouterOption, error) ListStudios(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error) ListNetworks(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error) @@ -588,62 +588,36 @@ func parsePositivePathInt(w http.ResponseWriter, r *http.Request, key string) (i } type requestIntegrationResponse struct { - 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"` + ID string `json:"id"` + Name string `json:"name"` + CapabilityID string `json:"capability_id"` + InstallationID *int `json:"installation_id,omitempty"` + SupportedMediaTypes []string `json:"supported_media_types"` + PluginConfig map[string]any `json:"plugin_config"` + Enabled bool `json:"enabled"` + BaseURL string `json:"base_url"` + HasAPIKey bool `json:"has_api_key"` + 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, + ID: integration.ID, + Name: integration.Name, + CapabilityID: integration.CapabilityID, + InstallationID: integration.InstallationID, + SupportedMediaTypes: integration.SupportedMediaTypes, + PluginConfig: integration.PluginConfig, + Enabled: integration.Enabled, + BaseURL: integration.BaseURL, + HasAPIKey: strings.TrimSpace(integration.APIKeyRef) != "", + LastCheckAt: integration.LastCheckAt, + LastCheckStatus: integration.LastCheckStatus, + LastCheckError: integration.LastCheckError, + UpdatedAt: integration.UpdatedAt, } } @@ -656,6 +630,18 @@ func toIntegrationResponses(integrations []mediarequests.Integration) []requestI } func writeRequestServiceError(w http.ResponseWriter, err error) { + // Plugin/instance validation failures carry inline field/form errors; surface + // them as a structured 400 so any handler routing through here renders them + // inline. Checked first because *ValidationError does not wrap a sentinel. + var verr *mediarequests.ValidationError + if errors.As(err, &verr) { + writeJSON(w, http.StatusBadRequest, map[string]any{ + "error": "validation_failed", + "field_errors": verr.FieldErrors, + "form_error": verr.FormError, + }) + return + } var quota mediarequests.QuotaError switch { case errors.As(err, "a): diff --git a/internal/api/handlers/requests_test.go b/internal/api/handlers/requests_test.go index 95ea16b7..855fc835 100644 --- a/internal/api/handlers/requests_test.go +++ b/internal/api/handlers/requests_test.go @@ -139,7 +139,7 @@ func (f *fakeRequestService) DeleteIntegration(context.Context, mediarequests.Vi return nil } -func (f *fakeRequestService) LoadIntegrationOptions(context.Context, mediarequests.Viewer, mediarequests.Integration) (*mediarequests.IntegrationOptions, error) { +func (f *fakeRequestService) LoadIntegrationOptions(context.Context, mediarequests.Viewer, mediarequests.Integration) (map[string][]mediarequests.RouterOption, error) { return nil, nil } diff --git a/internal/api/requests_wiring.go b/internal/api/requests_wiring.go new file mode 100644 index 00000000..7e03d343 --- /dev/null +++ b/internal/api/requests_wiring.go @@ -0,0 +1,36 @@ +package api + +import ( + "context" + "errors" + + "github.com/Silo-Server/silo-server/internal/plugins" + mediarequests "github.com/Silo-Server/silo-server/internal/requests" +) + +// PluginRequestRouterAdapter adapts plugins.Service to +// mediarequests.RouterClientResolver. The concrete *pluginhost.RequestRouterClient +// satisfies mediarequests.RouterClient, so the adapter names the interface as its +// return type (Go has no return-type covariance). +type PluginRequestRouterAdapter struct { + Svc *plugins.Service +} + +func (a PluginRequestRouterAdapter) RequestRouterClient(ctx context.Context, installationID int, capabilityID string) (mediarequests.RouterClient, error) { + if a.Svc == nil { + return nil, errors.New("request router plugin service is not configured") + } + return a.Svc.RequestRouterClient(ctx, installationID, capabilityID) +} + +// AttachRequestRouter wires the plugin-backed router provider onto a requests +// service. Both the HTTP handler and the reconcile task call this so the wiring +// lives in one place. With either dependency absent (e.g. a build without the +// plugin service) it leaves the requests service router-less, so fulfillment +// degrades to the existing "no backend configured" failure instead of panicking. +func AttachRequestRouter(svc *mediarequests.Service, pluginService *plugins.Service) { + if svc == nil || pluginService == nil { + return + } + svc.SetRouterProvider(mediarequests.NewPluginRouterProvider(PluginRequestRouterAdapter{pluginService})) +} diff --git a/internal/api/requests_wiring_test.go b/internal/api/requests_wiring_test.go new file mode 100644 index 00000000..f0d77a01 --- /dev/null +++ b/internal/api/requests_wiring_test.go @@ -0,0 +1,22 @@ +package api + +import ( + "context" + "testing" +) + +// AttachRequestRouter must tolerate absent dependencies (e.g. a build with the +// plugin service disabled) without panicking — fulfillment then degrades to the +// existing "no backend configured" failure rather than crashing the wiring. +func TestAttachRequestRouterNilDependenciesDoesNotPanic(t *testing.T) { + AttachRequestRouter(nil, nil) +} + +// A router adapter installed without a plugin service must return a controlled +// error rather than panic on the request path. +func TestPluginRequestRouterAdapterNilServiceReturnsError(t *testing.T) { + adapter := PluginRequestRouterAdapter{} + if _, err := adapter.RequestRouterClient(context.Background(), 1, "request_router.v1"); err == nil { + t.Fatal("RequestRouterClient with nil Svc = nil error, want a controlled error") + } +} diff --git a/internal/api/router.go b/internal/api/router.go index 9782aa21..710e5f19 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -48,8 +48,6 @@ import ( "github.com/Silo-Server/silo-server/internal/ratelimit" "github.com/Silo-Server/silo-server/internal/recommendations" mediarequests "github.com/Silo-Server/silo-server/internal/requests" - "github.com/Silo-Server/silo-server/internal/requests/radarr" - "github.com/Silo-Server/silo-server/internal/requests/sonarr" "github.com/Silo-Server/silo-server/internal/s3client" "github.com/Silo-Server/silo-server/internal/scanner" "github.com/Silo-Server/silo-server/internal/scanqueue" @@ -441,7 +439,8 @@ func NewRouter(deps Dependencies) chi.Router { tmdb.NewClient(tmdbAPIKey, 40), mediarequests.NewCatalogPresence(itemRepo, providerIDRepo), ) - requestSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil)) + AttachRequestRouter(requestSvc, deps.PluginService) + requestSvc.SetRequesterIdentityResolver(plugins.RequesterIdentityFromLookup(plugins.NewPgUserIdentityLookup(deps.DB))) if viewerResolver != nil { requestSvc.SetEntitlementResolver(mediarequests.NewAccessEntitlements(viewerResolver)) } diff --git a/internal/metadata/chain.go b/internal/metadata/chain.go index 2d5dfced..f287f1b4 100644 --- a/internal/metadata/chain.go +++ b/internal/metadata/chain.go @@ -309,7 +309,7 @@ func resolveEnabledProvidersByPriority( // single-purpose provider is never invoked for content it does not handle. items := make([]ranked, 0, len(caps)) for _, c := range caps { - metadataJSON := lookupCapabilityMetadata(ctx, pool, c.PluginInstallationID) + metadataJSON := lookupCapabilityMetadata(ctx, pool, c.PluginInstallationID, c.CapabilityID) if !providerSupportsLevel(metadataJSON, contentLevel) { slog.Debug("skipping metadata provider: does not declare support for content level", "installation_id", c.PluginInstallationID, @@ -357,14 +357,16 @@ func providerSupportsLevel(metadataJSON []byte, contentLevel string) bool { } // lookupCapabilityMetadata returns the raw plugin_capabilities.metadata JSON for -// a provider's metadata_provider.v1 capability, or nil if absent. -func lookupCapabilityMetadata(ctx context.Context, pool *pgxpool.Pool, pluginInstallationID int) []byte { +// a provider's exact metadata_provider.v1 capability, or nil if absent. +func lookupCapabilityMetadata(ctx context.Context, pool *pgxpool.Pool, pluginInstallationID int, capabilityID string) []byte { var metadataJSON []byte err := pool.QueryRow(ctx, `SELECT metadata FROM plugin_capabilities - WHERE plugin_installation_id = $1 AND capability_type = 'metadata_provider.v1' - LIMIT 1`, + WHERE plugin_installation_id = $1 + AND capability_id = $2 + AND capability_type = 'metadata_provider.v1'`, pluginInstallationID, + capabilityID, ).Scan(&metadataJSON) if err != nil { return nil @@ -374,8 +376,8 @@ func lookupCapabilityMetadata(ctx context.Context, pool *pgxpool.Pool, pluginIns // LookupDefaultPriority queries plugin_capabilities for a provider's declared // default_priority at the given content level. Returns 0 if not found. -func LookupDefaultPriority(ctx context.Context, pool *pgxpool.Pool, pluginInstallationID int, contentLevel string) int { - return extractDefaultPriority(lookupCapabilityMetadata(ctx, pool, pluginInstallationID), contentLevel) +func LookupDefaultPriority(ctx context.Context, pool *pgxpool.Pool, pluginInstallationID int, capabilityID, contentLevel string) int { + return extractDefaultPriority(lookupCapabilityMetadata(ctx, pool, pluginInstallationID, capabilityID), contentLevel) } // extractDefaultPriority parses the default_priority for a content level from diff --git a/internal/pluginhost/client.go b/internal/pluginhost/client.go index 89cafc1a..b989fe14 100644 --- a/internal/pluginhost/client.go +++ b/internal/pluginhost/client.go @@ -54,6 +54,11 @@ type ScanSourceClient struct { timeout time.Duration } +type RequestRouterClient struct { + client pluginv1.RequestRouterClient + timeout time.Duration +} + type EventConsumerClient struct { client pluginv1.EventConsumerClient timeout time.Duration @@ -148,6 +153,16 @@ func (c *Client) ScanSource(capabilityID string) (*ScanSourceClient, error) { }, nil } +func (c *Client) RequestRouter(capabilityID string) (*RequestRouterClient, error) { + if err := c.requireCapability("request_router.v1", capabilityID); err != nil { + return nil, err + } + return &RequestRouterClient{ + client: c.rpc.RequestRouter(), + timeout: DefaultRequestRouterTimeout, + }, nil +} + func (c *Client) EventConsumer(capabilityID string) (*EventConsumerClient, error) { if err := c.requireCapability("event_consumer.v1", capabilityID); err != nil { return nil, err @@ -283,6 +298,36 @@ func (c *ScanSourceClient) PollChanges(ctx context.Context, req *pluginv1.PollCh return c.client.PollChanges(callCtx, req) } +func (c *RequestRouterClient) Fulfill(ctx context.Context, req *pluginv1.FulfillRequest) (*pluginv1.FulfillResponse, error) { + callCtx, cancel := ensureDeadline(ctx, c.timeout) + defer cancel() + return c.client.Fulfill(callCtx, req) +} + +func (c *RequestRouterClient) CheckStatus(ctx context.Context, req *pluginv1.CheckStatusRequest) (*pluginv1.CheckStatusResponse, error) { + callCtx, cancel := ensureDeadline(ctx, c.timeout) + defer cancel() + return c.client.CheckStatus(callCtx, req) +} + +func (c *RequestRouterClient) ListConfigOptions(ctx context.Context, req *pluginv1.ListConfigOptionsRequest) (*pluginv1.ListConfigOptionsResponse, error) { + callCtx, cancel := ensureDeadline(ctx, c.timeout) + defer cancel() + return c.client.ListConfigOptions(callCtx, req) +} + +func (c *RequestRouterClient) TestConnection(ctx context.Context, req *pluginv1.TestConnectionRequest) (*pluginv1.TestConnectionResponse, error) { + callCtx, cancel := ensureDeadline(ctx, c.timeout) + defer cancel() + return c.client.TestConnection(callCtx, req) +} + +func (c *RequestRouterClient) Validate(ctx context.Context, req *pluginv1.ValidateRequest) (*pluginv1.ValidateResponse, error) { + callCtx, cancel := ensureDeadline(ctx, c.timeout) + defer cancel() + return c.client.Validate(callCtx, req) +} + func (c *EventConsumerClient) HandleEvent(ctx context.Context, req *pluginv1.HandleEventRequest) (*pluginv1.HandleEventResponse, error) { callCtx, cancel := ensureDeadline(ctx, c.timeout) defer cancel() diff --git a/internal/pluginhost/handshake.go b/internal/pluginhost/handshake.go index f67184a2..c76d777e 100644 --- a/internal/pluginhost/handshake.go +++ b/internal/pluginhost/handshake.go @@ -23,6 +23,9 @@ const ( DefaultEventTimeout = 10 * time.Second DefaultAuthTimeout = 10 * time.Second DefaultRouteTimeout = 10 * time.Second + // DefaultRequestRouterTimeout bounds a single request_router RPC. + // Fulfillment hits remote arr instances, so allow generous headroom. + DefaultRequestRouterTimeout = 60 * time.Second ) func HandshakeConfig() plugin.HandshakeConfig { diff --git a/internal/plugins/service.go b/internal/plugins/service.go index a7f2f0f0..79dcf6b9 100644 --- a/internal/plugins/service.go +++ b/internal/plugins/service.go @@ -10,9 +10,11 @@ import ( "os" "path/filepath" "runtime/debug" + "strconv" "sync" "sync/atomic" + "golang.org/x/sync/singleflight" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/structpb" @@ -27,6 +29,7 @@ type pluginClient interface { MediaAnalyzer(capabilityID string) (*pluginhost.MediaAnalyzerClient, error) ScheduledTask(capabilityID string) (*pluginhost.ScheduledTaskClient, error) ScanSource(capabilityID string) (*pluginhost.ScanSourceClient, error) + RequestRouter(capabilityID string) (*pluginhost.RequestRouterClient, error) EventConsumer(capabilityID string) (*pluginhost.EventConsumerClient, error) AuthProvider(capabilityID string) (*pluginhost.AuthProviderClient, error) HTTPRoutes(capabilityID string) (*pluginhost.HTTPRoutesClient, error) @@ -66,6 +69,7 @@ type Service struct { dispatcher *EventDispatcher lifecycleMu sync.RWMutex lifecycleHooks []func(context.Context) + launchGroup singleflight.Group } // SetEventDispatcher wires the EventDispatcher into the Service. The @@ -482,6 +486,18 @@ func (s *Service) ScanSourceClient( return client.ScanSource(capabilityID) } +func (s *Service) RequestRouterClient( + ctx context.Context, + installationID int, + capabilityID string, +) (*pluginhost.RequestRouterClient, error) { + client, err := s.ensureClient(ctx, installationID) + if err != nil { + return nil, err + } + return client.RequestRouter(capabilityID) +} + func (s *Service) ScanSourceClientByPluginID( ctx context.Context, pluginID string, @@ -605,7 +621,25 @@ func (s *Service) ManifestForInstallation( return s.manifestForInstallation(ctx, installationID, false) } +// ensureClient returns a running client for the installation, collapsing +// concurrent first-use of a cold installation into a single launch so a burst of +// callers does not spawn redundant plugin processes (Host.Start releases its lock +// during the slow launch and cannot dedupe). After the flight completes the key +// is freed, so subsequent callers re-run and hit the now-warm cache. func (s *Service) ensureClient(ctx context.Context, installationID int) (pluginClient, error) { + v, err, _ := s.launchGroup.Do(strconv.Itoa(installationID), func() (any, error) { + // Isolate the shared launch from the leader caller's cancellation: other + // waiters depend on this in-flight launch, so a single caller's canceled + // request must not tear it down. Values (tracing, auth) are preserved. + return s.doEnsureClient(context.WithoutCancel(ctx), installationID) + }) + if err != nil { + return nil, err + } + return v.(pluginClient), nil +} + +func (s *Service) doEnsureClient(ctx context.Context, installationID int) (pluginClient, error) { installation, err := s.loadInstallation(ctx, installationID, true) if err != nil { return nil, err diff --git a/internal/plugins/service_hot_reload_test.go b/internal/plugins/service_hot_reload_test.go index fcc1af50..b043f24a 100644 --- a/internal/plugins/service_hot_reload_test.go +++ b/internal/plugins/service_hot_reload_test.go @@ -217,6 +217,10 @@ func (f *fakePluginClient) ScanSource(string) (*pluginhost.ScanSourceClient, err return nil, nil } +func (f *fakePluginClient) RequestRouter(string) (*pluginhost.RequestRouterClient, error) { + return nil, nil +} + func (f *fakePluginClient) EventConsumer(string) (*pluginhost.EventConsumerClient, error) { return nil, nil } diff --git a/internal/plugins/service_singleflight_test.go b/internal/plugins/service_singleflight_test.go new file mode 100644 index 00000000..0f3e4a19 --- /dev/null +++ b/internal/plugins/service_singleflight_test.go @@ -0,0 +1,274 @@ +package plugins + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/pluginhost" +) + +// countingHerdHost simulates the real Host: Start records the launched client so +// a later Client() hits the warm cache; it counts Start calls and can delay each +// launch to force concurrent overlap. +type countingHerdHost struct { + mu sync.Mutex + current map[int]pluginClient + starts int + startResult pluginClient + startErr error + startDelay time.Duration +} + +func (h *countingHerdHost) Client(id int) (pluginClient, error) { + h.mu.Lock() + defer h.mu.Unlock() + if c, ok := h.current[id]; ok { + return c, nil + } + return nil, pluginhost.ErrClientNotFound +} + +func (h *countingHerdHost) Start(_ context.Context, req pluginhost.StartRequest) (pluginClient, error) { + if h.startDelay > 0 { + time.Sleep(h.startDelay) + } + h.mu.Lock() + defer h.mu.Unlock() + h.starts++ + if h.startErr != nil { + return nil, h.startErr + } + if h.current == nil { + h.current = map[int]pluginClient{} + } + h.current[req.InstallationID] = h.startResult + return h.startResult, nil +} + +func (h *countingHerdHost) Stop(id int) error { + h.mu.Lock() + defer h.mu.Unlock() + delete(h.current, id) + return nil +} + +func (h *countingHerdHost) Shutdown(context.Context) error { return nil } + +func (h *countingHerdHost) startCount() int { + h.mu.Lock() + defer h.mu.Unlock() + return h.starts +} + +func TestEnsureClientSingleFlightsConcurrentLaunch(t *testing.T) { + manifest := testPluginManifest(t, "silo.metadb", "0.0.36") + installPath := writeInstalledPluginManifest(t, manifest) + store := newFakeServiceInstallationStore(&Installation{ + ID: 7, PluginID: manifest.GetPluginId(), Version: manifest.GetVersion(), + InstallPath: installPath, Enabled: true, + }) + host := &countingHerdHost{ + startResult: &fakePluginClient{manifest: manifest}, + startDelay: 25 * time.Millisecond, + } + service := &Service{installations: store, host: host} + + const n = 20 + var wg sync.WaitGroup + release := make(chan struct{}) + clients := make([]pluginClient, n) + errs := make([]error, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-release + clients[i], errs[i] = service.ensureClient(context.Background(), 7) + }(i) + } + close(release) + wg.Wait() + + if got := host.startCount(); got != 1 { + t.Fatalf("host.Start calls = %d, want 1 (singleflighted)", got) + } + for i := 0; i < n; i++ { + if errs[i] != nil { + t.Fatalf("caller %d err = %v", i, errs[i]) + } + if clients[i] != host.startResult { + t.Fatalf("caller %d got %#v, want shared launched client", i, clients[i]) + } + } + + if _, err := service.ensureClient(context.Background(), 7); err != nil { + t.Fatalf("post-warm ensureClient err = %v", err) + } + if got := host.startCount(); got != 1 { + t.Fatalf("post-warm host.Start calls = %d, want still 1", got) + } +} + +func TestEnsureClientDistinctInstallationsLaunchIndependently(t *testing.T) { + mA := testPluginManifest(t, "silo.metadb", "0.0.36") + mB := testPluginManifest(t, "silo.other", "1.0.0") + pathA := writeInstalledPluginManifest(t, mA) + pathB := writeInstalledPluginManifest(t, mB) + store := newFakeServiceInstallationStore( + &Installation{ID: 7, PluginID: mA.GetPluginId(), Version: mA.GetVersion(), InstallPath: pathA, Enabled: true}, + &Installation{ID: 8, PluginID: mB.GetPluginId(), Version: mB.GetVersion(), InstallPath: pathB, Enabled: true}, + ) + host := &countingHerdHost{startResult: &fakePluginClient{manifest: mA}, startDelay: 10 * time.Millisecond} + service := &Service{installations: store, host: host} + + var wg sync.WaitGroup + for _, id := range []int{7, 8, 7, 8} { + wg.Add(1) + go func(id int) { defer wg.Done(); _, _ = service.ensureClient(context.Background(), id) }(id) + } + wg.Wait() + + if got := host.startCount(); got != 2 { + t.Fatalf("host.Start calls = %d, want 2 (one per installation)", got) + } +} + +// ctxCaptureHost blocks inside Start until released, and records whether the +// launch context was canceled. It lets a test cancel the singleflight leader's +// ctx while a launch is in flight. +type ctxCaptureHost struct { + mu sync.Mutex + current map[int]pluginClient + startResult pluginClient + entered chan struct{} + proceed chan struct{} + sawCancel bool +} + +func (h *ctxCaptureHost) Client(id int) (pluginClient, error) { + h.mu.Lock() + defer h.mu.Unlock() + if c, ok := h.current[id]; ok { + return c, nil + } + return nil, pluginhost.ErrClientNotFound +} + +func (h *ctxCaptureHost) Start(ctx context.Context, req pluginhost.StartRequest) (pluginClient, error) { + close(h.entered) + select { + case <-ctx.Done(): + h.mu.Lock() + h.sawCancel = true + h.mu.Unlock() + return nil, ctx.Err() + case <-h.proceed: + } + h.mu.Lock() + defer h.mu.Unlock() + if h.current == nil { + h.current = map[int]pluginClient{} + } + h.current[req.InstallationID] = h.startResult + return h.startResult, nil +} + +func (h *ctxCaptureHost) Stop(id int) error { + h.mu.Lock() + defer h.mu.Unlock() + delete(h.current, id) + return nil +} + +func (h *ctxCaptureHost) Shutdown(context.Context) error { return nil } + +func (h *ctxCaptureHost) canceledDuringLaunch() bool { + h.mu.Lock() + defer h.mu.Unlock() + return h.sawCancel +} + +// TestEnsureClientLaunchIsolatedFromLeaderCancellation pins that a deduped +// launch is not coupled to the cancellation of whichever caller happened to be +// the singleflight leader: if that caller's request is canceled mid-launch, the +// shared launch (which other waiters depend on) must continue. +func TestEnsureClientLaunchIsolatedFromLeaderCancellation(t *testing.T) { + manifest := testPluginManifest(t, "silo.metadb", "0.0.36") + installPath := writeInstalledPluginManifest(t, manifest) + store := newFakeServiceInstallationStore(&Installation{ + ID: 7, PluginID: manifest.GetPluginId(), Version: manifest.GetVersion(), + InstallPath: installPath, Enabled: true, + }) + host := &ctxCaptureHost{ + startResult: &fakePluginClient{manifest: manifest}, + entered: make(chan struct{}), + proceed: make(chan struct{}), + } + service := &Service{installations: store, host: host} + + ctx, cancel := context.WithCancel(context.Background()) + type result struct { + c pluginClient + err error + } + done := make(chan result, 1) + go func() { + c, err := service.ensureClient(ctx, 7) + done <- result{c, err} + }() + + <-host.entered // launch is in flight inside Start + cancel() // cancel the leader's ctx mid-launch + + select { + case r := <-done: + t.Fatalf("ensureClient returned early after leader cancel (err=%v); launch was coupled to leader ctx", r.err) + case <-time.After(100 * time.Millisecond): + // still launching — isolated from the leader's cancellation, as required + } + + close(host.proceed) + r := <-done + if r.err != nil { + t.Fatalf("ensureClient err = %v, want nil", r.err) + } + if r.c != host.startResult { + t.Fatalf("got %#v, want launched client", r.c) + } + if host.canceledDuringLaunch() { + t.Fatalf("launch ctx was canceled by the leader; expected isolation") + } +} + +func TestEnsureClientFailedLaunchPropagatesToAllCallers(t *testing.T) { + manifest := testPluginManifest(t, "silo.metadb", "0.0.36") + installPath := writeInstalledPluginManifest(t, manifest) + store := newFakeServiceInstallationStore(&Installation{ + ID: 7, PluginID: manifest.GetPluginId(), Version: manifest.GetVersion(), + InstallPath: installPath, Enabled: true, + }) + host := &countingHerdHost{startErr: context.DeadlineExceeded, startDelay: 15 * time.Millisecond} + service := &Service{installations: store, host: host} + + const n = 10 + var wg sync.WaitGroup + release := make(chan struct{}) + errs := make([]error, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { defer wg.Done(); <-release; _, errs[i] = service.ensureClient(context.Background(), 7) }(i) + } + close(release) + wg.Wait() + + for i := 0; i < n; i++ { + if errs[i] == nil { + t.Fatalf("caller %d err = nil, want the launch error", i) + } + } + if got := host.startCount(); got != 1 { + t.Fatalf("failed launch host.Start calls = %d, want 1 (still single-flighted)", got) + } +} diff --git a/internal/plugins/user_identity_lookup.go b/internal/plugins/user_identity_lookup.go index 535bd6d1..7a057427 100644 --- a/internal/plugins/user_identity_lookup.go +++ b/internal/plugins/user_identity_lookup.go @@ -14,6 +14,7 @@ import ( // UIs without reaching back into the browser's localStorage. type UserIdentity struct { Username string + Email string ProfileName string ProfileIsPrimary bool } @@ -44,8 +45,8 @@ func (l *PgUserIdentityLookup) LookupIdentity(ctx context.Context, userID int, p } err := l.pool.QueryRow(ctx, - "SELECT username FROM users WHERE id = $1", userID, - ).Scan(&out.Username) + "SELECT username, COALESCE(email, '') FROM users WHERE id = $1", userID, + ).Scan(&out.Username, &out.Email) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return out, err } @@ -65,3 +66,22 @@ func (l *PgUserIdentityLookup) LookupIdentity(ctx context.Context, userID int, p } return out, nil } + +// RequesterIdentityFromLookup adapts a UserIdentityLookup into the requests +// service's requester-identity resolver (email + username by user id). +type requesterIdentityAdapter struct{ lookup UserIdentityLookup } + +func RequesterIdentityFromLookup(l UserIdentityLookup) *requesterIdentityAdapter { + return &requesterIdentityAdapter{lookup: l} +} + +func (a *requesterIdentityAdapter) ResolveRequester(ctx context.Context, userID int) (string, string, error) { + if a == nil || a.lookup == nil || userID <= 0 { + return "", "", nil + } + id, err := a.lookup.LookupIdentity(ctx, userID, "") + if err != nil { + return "", "", err + } + return id.Email, id.Username, nil +} diff --git a/internal/requests/arrclient/resources.go b/internal/requests/arrclient/resources.go index eee11bc6..790ca012 100644 --- a/internal/requests/arrclient/resources.go +++ b/internal/requests/arrclient/resources.go @@ -24,51 +24,31 @@ type TagResource struct { Label string `json:"label"` } -func ListRootFolders(ctx context.Context, client *Client) ([]mediarequests.IntegrationRootFolder, error) { +// ListRootFolders returns the arr root folders. It yields the local +// RootFolderResource shape directly; the schema-driven config form consumes +// generic RouterOptions from the plugin instead of arr-specific option types. +func ListRootFolders(ctx context.Context, client *Client) ([]RootFolderResource, error) { var resources []RootFolderResource if err := client.GetJSON(ctx, "/api/v3/rootfolder", &resources); err != nil { return nil, err } - out := make([]mediarequests.IntegrationRootFolder, 0, len(resources)) - for _, resource := range resources { - out = append(out, mediarequests.IntegrationRootFolder{ - Path: resource.Path, - FreeSpace: resource.FreeSpace, - TotalSpace: resource.TotalSpace, - Accessible: resource.Accessible, - }) - } - return out, nil + return resources, nil } -func ListQualityProfiles(ctx context.Context, client *Client) ([]mediarequests.IntegrationQualityProfile, error) { +func ListQualityProfiles(ctx context.Context, client *Client) ([]QualityProfileResource, error) { var resources []QualityProfileResource if err := client.GetJSON(ctx, "/api/v3/qualityprofile", &resources); err != nil { return nil, err } - out := make([]mediarequests.IntegrationQualityProfile, 0, len(resources)) - for _, resource := range resources { - out = append(out, mediarequests.IntegrationQualityProfile{ - ID: resource.ID, - Name: resource.Name, - }) - } - return out, nil + return resources, nil } -func ListTags(ctx context.Context, client *Client) ([]mediarequests.IntegrationTag, error) { +func ListTags(ctx context.Context, client *Client) ([]TagResource, error) { var resources []TagResource if err := client.GetJSON(ctx, "/api/v3/tag", &resources); err != nil { return nil, err } - out := make([]mediarequests.IntegrationTag, 0, len(resources)) - for _, resource := range resources { - out = append(out, mediarequests.IntegrationTag{ - ID: resource.ID, - Label: resource.Label, - }) - } - return out, nil + return resources, nil } // AcceptedWithoutResponse returns a FulfillmentResult marking the submission diff --git a/internal/requests/errors.go b/internal/requests/errors.go index 84998d95..52b46638 100644 --- a/internal/requests/errors.go +++ b/internal/requests/errors.go @@ -28,3 +28,16 @@ func (e QuotaError) Error() string { func (e QuotaError) Unwrap() error { return ErrQuotaExceeded } + +// ValidationError carries plugin Validate results back to the API layer. +type ValidationError struct { + FieldErrors map[string]string + FormError string +} + +func (e *ValidationError) Error() string { + if e.FormError != "" { + return e.FormError + } + return "validation failed" +} diff --git a/internal/requests/provider.go b/internal/requests/provider.go new file mode 100644 index 00000000..9c0bdb79 --- /dev/null +++ b/internal/requests/provider.go @@ -0,0 +1,249 @@ +package requests + +import ( + "context" + "fmt" + "strconv" + + pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" + "google.golang.org/protobuf/types/known/structpb" +) + +// RequestRouterProvider fulfills a whole request and checks target status via a +// request_router.v1 plugin. The host owns governance (which qualities) and the +// target records; the provider is the plugin boundary. +type RequestRouterProvider interface { + Fulfill(ctx context.Context, installationID int, capabilityID string, req Request, qualities []Quality, conns []ResolvedRouterConnection) ([]RouterTarget, string, error) + CheckStatus(ctx context.Context, installationID int, capabilityID string, req Request, targets []RouterTargetRef, conns []ResolvedRouterConnection) ([]RouterTargetStatus, error) + ListConfigOptions(ctx context.Context, installationID int, capabilityID string, conn ResolvedRouterConnection) (map[string][]RouterOption, error) + TestConnection(ctx context.Context, installationID int, capabilityID string, conn ResolvedRouterConnection) (bool, string, error) + Validate(ctx context.Context, installationID int, capabilityID string, conn ResolvedRouterConnection, siblings []ResolvedRouterConnection) (fieldErrors map[string]string, formError string, err error) +} + +// ResolvedRouterConnection is a connection with plaintext credentials + parsed config. +type ResolvedRouterConnection struct { + ID string + BaseURL string + APIKey string + Config map[string]any +} + +type RouterTarget struct { + Quality Quality + ConnectionID string + ExternalID string + ExternalStatus string + Status Status + Message string +} + +type RouterTargetRef struct { + Quality Quality + ConnectionID string + ExternalID string +} + +type RouterTargetStatus struct { + Quality Quality + ConnectionID string + Status Status + ExternalStatus string + Message string +} + +type RouterOption struct { + Value string `json:"value"` + Label string `json:"label"` +} + +// RouterClientResolver yields a per-(installation, capability) router client. +type RouterClientResolver interface { + RequestRouterClient(ctx context.Context, installationID int, capabilityID string) (RouterClient, error) +} + +// RouterClient mirrors *pluginhost.RequestRouterClient (exported so the api +// package can adapt the concrete type across packages without an import cycle). +type RouterClient interface { + Fulfill(ctx context.Context, req *pluginv1.FulfillRequest) (*pluginv1.FulfillResponse, error) + CheckStatus(ctx context.Context, req *pluginv1.CheckStatusRequest) (*pluginv1.CheckStatusResponse, error) + ListConfigOptions(ctx context.Context, req *pluginv1.ListConfigOptionsRequest) (*pluginv1.ListConfigOptionsResponse, error) + TestConnection(ctx context.Context, req *pluginv1.TestConnectionRequest) (*pluginv1.TestConnectionResponse, error) + Validate(ctx context.Context, req *pluginv1.ValidateRequest) (*pluginv1.ValidateResponse, error) +} + +type pluginRouterProvider struct{ resolver RouterClientResolver } + +func NewPluginRouterProvider(r RouterClientResolver) RequestRouterProvider { + return &pluginRouterProvider{resolver: r} +} + +func routerDescriptor(req Request) *pluginv1.RequestDescriptor { + ids := map[string]string{} + if req.TMDBID != 0 { + ids["tmdb"] = strconv.Itoa(req.TMDBID) + } + if req.TVDBID != nil { + ids["tvdb"] = strconv.Itoa(*req.TVDBID) + } + if req.IMDbID != "" { + ids["imdb"] = req.IMDbID + } + year := 0 + if req.Year != nil { + year = *req.Year + } + return &pluginv1.RequestDescriptor{ + MediaType: string(req.MediaType), + Title: req.Title, + Year: int32(year), + ExternalIds: ids, + IsAnime: req.IsAnime, + RequesterUserId: int64(req.RequestedByUserID), + RequesterProfileId: req.RequestedByProfileID, + RequesterEmail: req.RequesterEmail, + RequesterUsername: req.RequesterUsername, + } +} + +func routerProtoConn(c ResolvedRouterConnection) (*pluginv1.RouterConnection, error) { + cfg, err := structpb.NewStruct(c.Config) + if err != nil { + return nil, fmt.Errorf("router: encode connection %s config: %w", c.ID, err) + } + return &pluginv1.RouterConnection{Id: c.ID, BaseUrl: c.BaseURL, ApiKey: c.APIKey, Config: cfg}, nil +} + +func routerProtoConns(conns []ResolvedRouterConnection) ([]*pluginv1.RouterConnection, error) { + out := make([]*pluginv1.RouterConnection, 0, len(conns)) + for _, c := range conns { + pc, err := routerProtoConn(c) + if err != nil { + return nil, err + } + out = append(out, pc) + } + return out, nil +} + +func (p *pluginRouterProvider) Fulfill(ctx context.Context, installationID int, capabilityID string, req Request, qualities []Quality, conns []ResolvedRouterConnection) ([]RouterTarget, string, error) { + client, err := p.resolver.RequestRouterClient(ctx, installationID, capabilityID) + if err != nil { + return nil, "", err + } + qs := make([]*pluginv1.RequestedQuality, 0, len(qualities)) + for _, q := range qualities { + qs = append(qs, &pluginv1.RequestedQuality{Id: string(q), Is4K: q == Quality2160p}) + } + pconns, err := routerProtoConns(conns) + if err != nil { + return nil, "", err + } + resp, err := client.Fulfill(ctx, &pluginv1.FulfillRequest{ + CapabilityId: capabilityID, Request: routerDescriptor(req), Qualities: qs, Connections: pconns, + }) + if err != nil { + return nil, "", err + } + targets := make([]RouterTarget, 0, len(resp.GetTargets())) + for _, t := range resp.GetTargets() { + targets = append(targets, RouterTarget{ + Quality: Quality(t.GetQuality()), ConnectionID: t.GetConnectionId(), + ExternalID: t.GetExternalId(), ExternalStatus: t.GetExternalStatus(), + Status: Status(t.GetStatus()), Message: t.GetMessage(), + }) + } + return targets, resp.GetMessage(), nil +} + +func (p *pluginRouterProvider) CheckStatus(ctx context.Context, installationID int, capabilityID string, req Request, targets []RouterTargetRef, conns []ResolvedRouterConnection) ([]RouterTargetStatus, error) { + client, err := p.resolver.RequestRouterClient(ctx, installationID, capabilityID) + if err != nil { + return nil, err + } + refs := make([]*pluginv1.TargetRef, 0, len(targets)) + for _, t := range targets { + refs = append(refs, &pluginv1.TargetRef{Quality: string(t.Quality), ConnectionId: t.ConnectionID, ExternalId: t.ExternalID}) + } + pconns, err := routerProtoConns(conns) + if err != nil { + return nil, err + } + resp, err := client.CheckStatus(ctx, &pluginv1.CheckStatusRequest{ + CapabilityId: capabilityID, Request: routerDescriptor(req), Targets: refs, Connections: pconns, + }) + if err != nil { + return nil, err + } + out := make([]RouterTargetStatus, 0, len(resp.GetStatuses())) + for _, st := range resp.GetStatuses() { + out = append(out, RouterTargetStatus{ + Quality: Quality(st.GetQuality()), ConnectionID: st.GetConnectionId(), + Status: Status(st.GetStatus()), ExternalStatus: st.GetExternalStatus(), Message: st.GetMessage(), + }) + } + return out, nil +} + +func (p *pluginRouterProvider) ListConfigOptions(ctx context.Context, installationID int, capabilityID string, conn ResolvedRouterConnection) (map[string][]RouterOption, error) { + client, err := p.resolver.RequestRouterClient(ctx, installationID, capabilityID) + if err != nil { + return nil, err + } + pconn, err := routerProtoConn(conn) + if err != nil { + return nil, err + } + resp, err := client.ListConfigOptions(ctx, &pluginv1.ListConfigOptionsRequest{ + CapabilityId: capabilityID, Connection: pconn, + }) + if err != nil { + return nil, err + } + out := map[string][]RouterOption{} + for field, list := range resp.GetOptionsByField() { + opts := make([]RouterOption, 0, len(list.GetOptions())) + for _, o := range list.GetOptions() { + opts = append(opts, RouterOption{Value: o.GetValue(), Label: o.GetLabel()}) + } + out[field] = opts + } + return out, nil +} + +func (p *pluginRouterProvider) TestConnection(ctx context.Context, installationID int, capabilityID string, conn ResolvedRouterConnection) (bool, string, error) { + client, err := p.resolver.RequestRouterClient(ctx, installationID, capabilityID) + if err != nil { + return false, "", err + } + pconn, err := routerProtoConn(conn) + if err != nil { + return false, "", err + } + resp, err := client.TestConnection(ctx, &pluginv1.TestConnectionRequest{ + CapabilityId: capabilityID, Connection: pconn, + }) + if err != nil { + return false, "", err + } + return resp.GetOk(), resp.GetMessage(), nil +} + +func (p *pluginRouterProvider) Validate(ctx context.Context, installationID int, capabilityID string, conn ResolvedRouterConnection, siblings []ResolvedRouterConnection) (map[string]string, string, error) { + client, err := p.resolver.RequestRouterClient(ctx, installationID, capabilityID) + if err != nil { + return nil, "", err + } + pc, err := routerProtoConn(conn) + if err != nil { + return nil, "", err + } + sibs, err := routerProtoConns(siblings) + if err != nil { + return nil, "", err + } + resp, err := client.Validate(ctx, &pluginv1.ValidateRequest{CapabilityId: capabilityID, Connection: pc, Siblings: sibs}) + if err != nil { + return nil, "", err + } + return resp.GetFieldErrors(), resp.GetFormError(), nil +} diff --git a/internal/requests/provider_test.go b/internal/requests/provider_test.go new file mode 100644 index 00000000..137dd620 --- /dev/null +++ b/internal/requests/provider_test.go @@ -0,0 +1,139 @@ +package requests + +import ( + "context" + "testing" + + pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" +) + +type fakeRouterClient struct { + lastReq *pluginv1.FulfillRequest + lastCheckReq *pluginv1.CheckStatusRequest + statuses []*pluginv1.TargetStatus + optionsByField map[string]*pluginv1.ConfigOptionList + validateResp *pluginv1.ValidateResponse +} + +func (f *fakeRouterClient) Fulfill(_ context.Context, req *pluginv1.FulfillRequest) (*pluginv1.FulfillResponse, error) { + f.lastReq = req + return &pluginv1.FulfillResponse{Targets: []*pluginv1.FulfillmentTarget{ + {Quality: "1080p", ConnectionId: "c1", ExternalId: "7", Status: "queued"}, + }}, nil +} +func (f *fakeRouterClient) CheckStatus(_ context.Context, req *pluginv1.CheckStatusRequest) (*pluginv1.CheckStatusResponse, error) { + f.lastCheckReq = req + return &pluginv1.CheckStatusResponse{Statuses: f.statuses}, nil +} +func (f *fakeRouterClient) ListConfigOptions(context.Context, *pluginv1.ListConfigOptionsRequest) (*pluginv1.ListConfigOptionsResponse, error) { + return &pluginv1.ListConfigOptionsResponse{OptionsByField: f.optionsByField}, nil +} +func (f *fakeRouterClient) TestConnection(context.Context, *pluginv1.TestConnectionRequest) (*pluginv1.TestConnectionResponse, error) { + return &pluginv1.TestConnectionResponse{Ok: true}, nil +} +func (f *fakeRouterClient) Validate(context.Context, *pluginv1.ValidateRequest) (*pluginv1.ValidateResponse, error) { + return f.validateResp, nil +} + +type fakeRouterResolver struct{ c RouterClient } + +func (r fakeRouterResolver) RequestRouterClient(context.Context, int, string) (RouterClient, error) { + return r.c, nil +} + +func TestPluginRouterProviderFulfillTranslates(t *testing.T) { + fc := &fakeRouterClient{} + p := NewPluginRouterProvider(fakeRouterResolver{c: fc}) + year := 2020 + req := Request{MediaType: MediaTypeMovie, TMDBID: 42, Title: "X", Year: &year} + targets, msg, err := p.Fulfill(context.Background(), 1, "arr", req, []Quality{Quality1080p}, + []ResolvedRouterConnection{{ID: "c1", BaseURL: "http://r", APIKey: "k", Config: map[string]any{"service_kind": "radarr"}}}) + if err != nil { + t.Fatalf("Fulfill: %v", err) + } + if msg != "" || len(targets) != 1 || targets[0].Status != StatusQueued || targets[0].ExternalID != "7" { + t.Fatalf("unexpected result: %+v msg=%q", targets, msg) + } + gotQ := fc.lastReq.GetQualities() + if fc.lastReq.GetRequest().GetExternalIds()["tmdb"] != "42" || len(gotQ) != 1 { + t.Fatalf("descriptor/qualities not forwarded: %+v", fc.lastReq) + } + if gotQ[0].GetId() != "1080p" || gotQ[0].GetIs4K() { + t.Fatalf("1080p should be id=1080p is4k=false, got %+v", gotQ[0]) + } +} + +func TestPluginRouterProviderCheckStatusTranslates(t *testing.T) { + fc := &fakeRouterClient{statuses: []*pluginv1.TargetStatus{ + {Quality: "1080p", ConnectionId: "c1", Status: "downloading", ExternalStatus: "grabbed", Message: "in queue"}, + {Quality: "2160p", ConnectionId: "c2", Status: "completed", ExternalStatus: "imported", Message: ""}, + }} + p := NewPluginRouterProvider(fakeRouterResolver{c: fc}) + req := Request{MediaType: MediaTypeMovie, TMDBID: 42, Title: "X"} + refs := []RouterTargetRef{ + {Quality: Quality1080p, ConnectionID: "c1", ExternalID: "7"}, + {Quality: Quality2160p, ConnectionID: "c2", ExternalID: "8"}, + } + out, err := p.CheckStatus(context.Background(), 1, "arr", req, refs, + []ResolvedRouterConnection{{ID: "c1", BaseURL: "http://r", APIKey: "k", Config: map[string]any{"service_kind": "radarr"}}}) + if err != nil { + t.Fatalf("CheckStatus: %v", err) + } + if len(out) != 2 { + t.Fatalf("expected 2 statuses, got %d", len(out)) + } + if out[0].Quality != Quality1080p || out[0].ConnectionID != "c1" || + out[0].Status != StatusDownloading || out[0].ExternalStatus != "grabbed" || out[0].Message != "in queue" { + t.Fatalf("status[0] mismapped: %+v", out[0]) + } + if out[1].Quality != Quality2160p || out[1].ConnectionID != "c2" || out[1].Status != StatusCompleted || out[1].ExternalStatus != "imported" { + t.Fatalf("status[1] mismapped: %+v", out[1]) + } + if got := fc.lastCheckReq.GetTargets(); len(got) != 2 || + got[0].GetConnectionId() != "c1" || got[0].GetExternalId() != "7" || + got[1].GetConnectionId() != "c2" || got[1].GetExternalId() != "8" { + t.Fatalf("refs not forwarded: %+v", fc.lastCheckReq.GetTargets()) + } +} + +func TestPluginRouterProviderListConfigOptionsTranslates(t *testing.T) { + fc := &fakeRouterClient{optionsByField: map[string]*pluginv1.ConfigOptionList{ + "root_folder": {Options: []*pluginv1.ConfigOption{ + {Value: "/movies", Label: "Movies"}, + {Value: "/movies4k", Label: "Movies 4K"}, + }}, + "quality_profile_id": {Options: []*pluginv1.ConfigOption{ + {Value: "1", Label: "HD-1080p"}, + }}, + }} + p := NewPluginRouterProvider(fakeRouterResolver{c: fc}) + out, err := p.ListConfigOptions(context.Background(), 1, "arr", + ResolvedRouterConnection{ID: "c1", BaseURL: "http://r", APIKey: "k", Config: map[string]any{"service_kind": "radarr"}}) + if err != nil { + t.Fatalf("ListConfigOptions: %v", err) + } + if len(out) != 2 { + t.Fatalf("expected 2 fields, got %d: %+v", len(out), out) + } + rf := out["root_folder"] + if len(rf) != 2 || rf[0].Value != "/movies" || rf[0].Label != "Movies" || rf[1].Value != "/movies4k" || rf[1].Label != "Movies 4K" { + t.Fatalf("root_folder mismapped: %+v", rf) + } + qp := out["quality_profile_id"] + if len(qp) != 1 || qp[0].Value != "1" || qp[0].Label != "HD-1080p" { + t.Fatalf("quality_profile_id mismapped: %+v", qp) + } +} + +func TestPluginRouterProviderValidateTranslates(t *testing.T) { + fc := &fakeRouterClient{validateResp: &pluginv1.ValidateResponse{ + FieldErrors: map[string]string{"is_default": "cannot be 4K"}, FormError: "", + }} + p := NewPluginRouterProvider(fakeRouterResolver{c: fc}) + fe, form, err := p.Validate(context.Background(), 1, "arr", + ResolvedRouterConnection{ID: "c1", Config: map[string]any{"service_kind": "radarr"}}, + nil) + if err != nil || form != "" || fe["is_default"] != "cannot be 4K" { + t.Fatalf("unexpected: fe=%v form=%q err=%v", fe, form, err) + } +} diff --git a/internal/requests/radarr/client.go b/internal/requests/radarr/client.go deleted file mode 100644 index 7f6f7624..00000000 --- a/internal/requests/radarr/client.go +++ /dev/null @@ -1,176 +0,0 @@ -package radarr - -import ( - "context" - "fmt" - "net/http" - "net/url" - "strconv" - - mediarequests "github.com/Silo-Server/silo-server/internal/requests" - "github.com/Silo-Server/silo-server/internal/requests/arrclient" -) - -type Client struct { - httpClient *http.Client -} - -type movieResource struct { - ID int `json:"id,omitempty"` - Title string `json:"title,omitempty"` - TMDBID int `json:"tmdbId,omitempty"` - Year int `json:"year,omitempty"` - TitleSlug string `json:"titleSlug,omitempty"` - QualityProfileID int `json:"qualityProfileId,omitempty"` - RootFolderPath string `json:"rootFolderPath,omitempty"` - Monitored bool `json:"monitored"` - MinimumAvailability string `json:"minimumAvailability,omitempty"` - Tags []int `json:"tags,omitempty"` - AddOptions addMovieOptions `json:"addOptions,omitempty"` -} - -type addMovieOptions struct { - SearchForMovie bool `json:"searchForMovie"` - Monitor string `json:"monitor,omitempty"` -} - -func NewClient(httpClient *http.Client) *Client { - return &Client{httpClient: httpClient} -} - -func (c *Client) ListMovieIntegrationOptions(ctx context.Context, integration mediarequests.Integration) (*mediarequests.IntegrationOptions, error) { - client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient) - rootFolders, err := arrclient.ListRootFolders(ctx, client) - if err != nil { - return nil, err - } - qualityProfiles, err := arrclient.ListQualityProfiles(ctx, client) - if err != nil { - return nil, err - } - tags, err := arrclient.ListTags(ctx, client) - if err != nil { - return nil, err - } - return &mediarequests.IntegrationOptions{ - Kind: "radarr", - RootFolders: rootFolders, - QualityProfiles: qualityProfiles, - Tags: tags, - }, nil -} - -func (c *Client) SubmitMovie(ctx context.Context, req mediarequests.Request, integration mediarequests.Integration) (mediarequests.FulfillmentResult, error) { - if req.MediaType != mediarequests.MediaTypeMovie { - return mediarequests.FulfillmentResult{}, fmt.Errorf("radarr: request is not a movie") - } - if integration.QualityProfileID == nil { - return mediarequests.FulfillmentResult{}, fmt.Errorf("radarr: quality profile is required") - } - - client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient) - movie, err := c.lookupMovie(ctx, client, req.TMDBID) - if err != nil { - return mediarequests.FulfillmentResult{}, err - } - movie.RootFolderPath = integration.RootFolder - movie.QualityProfileID = *integration.QualityProfileID - movie.Monitored = arrclient.BoolOption(integration.Options, "monitored", true) - movie.MinimumAvailability = arrclient.StringOption(integration.Options, "minimum_availability", "released") - movie.Tags = integration.Tags - movie.AddOptions = addMovieOptions{ - SearchForMovie: arrclient.BoolOption( - integration.Options, - "search_for_movie", - arrclient.BoolOption(integration.Options, "search_on_add", true), - ), - Monitor: arrclient.StringOption(integration.Options, "monitor", "movieOnly"), - } - - var created movieResource - if err := client.PostJSON(ctx, "/api/v3/movie", movie, &created); err != nil { - if !arrclient.IsEmptyOrTruncatedDecodeError(err) { - return mediarequests.FulfillmentResult{}, err - } - // POST accepted but Radarr returned an empty body. Recover the - // new movie's Radarr ID by listing movies filtered by TMDB ID; - // without the ID the reconcile loop cannot advance the request. - if found, lookErr := c.findMovieByTMDBID(ctx, client, req.TMDBID); lookErr == nil && found.ID > 0 { - return resultFromMovie(found), nil - } - return arrclient.AcceptedWithoutResponse("radarr"), nil - } - return resultFromMovie(created), nil -} - -func (c *Client) findMovieByTMDBID(ctx context.Context, client *arrclient.Client, tmdbID int) (movieResource, error) { - values := url.Values{} - values.Set("tmdbId", strconv.Itoa(tmdbID)) - var matches []movieResource - if err := client.GetJSON(ctx, "/api/v3/movie?"+values.Encode(), &matches); err != nil { - return movieResource{}, err - } - for _, m := range matches { - if m.ID > 0 { - return m, nil - } - } - return movieResource{}, fmt.Errorf("radarr: movie not found after add for tmdb_id %d", tmdbID) -} - -func (c *Client) CheckMovieStatus(ctx context.Context, req mediarequests.Request, integration mediarequests.Integration) (mediarequests.FulfillmentStatus, error) { - client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient) - movieID, _ := strconv.Atoi(req.ExternalID) - if movieID <= 0 { - return mediarequests.FulfillmentStatus{ - Status: mediarequests.StatusQueued, - IntegrationKind: "radarr", - ExternalStatus: "external_id_unavailable", - }, nil - } - - queues, err := c.queueDetails(ctx, client, movieID) - if err != nil { - return mediarequests.FulfillmentStatus{}, err - } - evaluation := arrclient.EvaluateQueue(queues) - return arrclient.StatusFromQueueEvaluation("radarr", movieID, evaluation), nil -} - -func (c *Client) lookupMovie(ctx context.Context, client *arrclient.Client, tmdbID int) (movieResource, error) { - values := url.Values{} - values.Set("tmdbId", strconv.Itoa(tmdbID)) - // Radarr's /api/v3/movie/lookup/tmdb returns a single MovieResource, unlike - // /api/v3/movie/lookup which returns an array. Missing IDs return non-2xx - // (handled as HTTPError upstream), so a successful response is the movie. - var movie movieResource - if err := client.GetJSON(ctx, "/api/v3/movie/lookup/tmdb?"+values.Encode(), &movie); err != nil { - return movieResource{}, err - } - if movie.TMDBID == 0 { - movie.TMDBID = tmdbID - } - return movie, nil -} - -func (c *Client) queueDetails(ctx context.Context, client *arrclient.Client, movieID int) ([]arrclient.QueueResource, error) { - values := url.Values{} - values.Set("movieId", strconv.Itoa(movieID)) - var queues []arrclient.QueueResource - if err := client.GetJSON(ctx, "/api/v3/queue/details?"+values.Encode(), &queues); err != nil { - return nil, err - } - return queues, nil -} - -func resultFromMovie(movie movieResource) mediarequests.FulfillmentResult { - externalID := "" - if movie.ID > 0 { - externalID = strconv.Itoa(movie.ID) - } - return mediarequests.FulfillmentResult{ - IntegrationKind: "radarr", - ExternalID: externalID, - ExternalStatus: "queued", - } -} diff --git a/internal/requests/radarr/client_test.go b/internal/requests/radarr/client_test.go deleted file mode 100644 index 63e11d0b..00000000 --- a/internal/requests/radarr/client_test.go +++ /dev/null @@ -1,236 +0,0 @@ -package radarr - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - mediarequests "github.com/Silo-Server/silo-server/internal/requests" -) - -func TestSubmitMovieAddsLookupResult(t *testing.T) { - qualityProfileID := 7 - var posted movieResource - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("X-Api-Key"); got != "radarr-key" { - t.Fatalf("X-Api-Key = %q, want radarr-key", got) - } - switch r.URL.Path { - case "/api/v3/movie": - if r.Method == http.MethodGet { - if got := r.URL.Query().Get("tmdbId"); got != "550" { - t.Fatalf("tmdbId = %q, want 550", got) - } - w.Write([]byte(`[]`)) - return - } - if r.Method == http.MethodPost { - if err := json.NewDecoder(r.Body).Decode(&posted); err != nil { - t.Fatalf("decode posted movie: %v", err) - } - w.Write([]byte(`{"id":42,"tmdbId":550}`)) - return - } - case "/api/v3/movie/lookup/tmdb": - if got := r.URL.Query().Get("tmdbId"); got != "550" { - t.Fatalf("lookup tmdbId = %q, want 550", got) - } - w.Write([]byte(`{"title":"Fight Club","tmdbId":550,"titleSlug":"fight-club"}`)) - return - } - t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) - })) - defer server.Close() - - client := NewClient(server.Client()) - result, err := client.SubmitMovie(context.Background(), mediarequests.Request{ - MediaType: mediarequests.MediaTypeMovie, - TMDBID: 550, - Title: "Fight Club", - }, mediarequests.Integration{ - Kind: "radarr", - BaseURL: server.URL, - APIKeyRef: "radarr-key", - RootFolder: "/movies", - QualityProfileID: &qualityProfileID, - Options: map[string]any{ - "search_on_add": false, - }, - }) - if err != nil { - t.Fatalf("SubmitMovie returned error: %v", err) - } - if result.ExternalID != "42" || result.IntegrationKind != "radarr" { - t.Fatalf("result = %+v, want radarr external id 42", result) - } - if posted.RootFolderPath != "/movies" || posted.QualityProfileID != qualityProfileID { - t.Fatalf("posted movie = %+v, missing root folder/quality profile", posted) - } - if posted.AddOptions.SearchForMovie { - t.Fatalf("searchForMovie = true, want false") - } -} - -func TestSubmitMovieRecoversFromEmptyAddResponse(t *testing.T) { - qualityProfileID := 7 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v3/movie/lookup/tmdb": - w.Write([]byte(`{"title":"Fight Club","tmdbId":550,"titleSlug":"fight-club"}`)) - case "/api/v3/movie": - if r.Method == http.MethodPost { - w.WriteHeader(http.StatusCreated) - return - } - if r.Method == http.MethodGet { - if got := r.URL.Query().Get("tmdbId"); got != "550" { - t.Fatalf("tmdbId = %q, want 550", got) - } - w.Write([]byte(`[{"id":99,"tmdbId":550,"title":"Fight Club"}]`)) - return - } - default: - t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) - } - })) - defer server.Close() - - client := NewClient(server.Client()) - result, err := client.SubmitMovie(context.Background(), mediarequests.Request{ - MediaType: mediarequests.MediaTypeMovie, - TMDBID: 550, - Title: "Fight Club", - }, mediarequests.Integration{ - Kind: "radarr", - BaseURL: server.URL, - APIKeyRef: "radarr-key", - RootFolder: "/movies", - QualityProfileID: &qualityProfileID, - }) - if err != nil { - t.Fatalf("SubmitMovie returned error: %v", err) - } - if result.ExternalID != "99" { - t.Fatalf("ExternalID = %q, want 99 (recovered after empty 201)", result.ExternalID) - } - if result.ExternalStatus != "queued" { - t.Fatalf("ExternalStatus = %q, want queued", result.ExternalStatus) - } -} - -func TestSubmitMovieFallsBackWhenEmptyResponseAndLookupFails(t *testing.T) { - qualityProfileID := 7 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v3/movie/lookup/tmdb": - w.Write([]byte(`{"title":"Fight Club","tmdbId":550,"titleSlug":"fight-club"}`)) - case "/api/v3/movie": - if r.Method == http.MethodPost { - w.WriteHeader(http.StatusCreated) - return - } - if r.Method == http.MethodGet { - w.Write([]byte(`[]`)) - return - } - default: - t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) - } - })) - defer server.Close() - - client := NewClient(server.Client()) - result, err := client.SubmitMovie(context.Background(), mediarequests.Request{ - MediaType: mediarequests.MediaTypeMovie, - TMDBID: 550, - Title: "Fight Club", - }, mediarequests.Integration{ - Kind: "radarr", - BaseURL: server.URL, - APIKeyRef: "radarr-key", - RootFolder: "/movies", - QualityProfileID: &qualityProfileID, - }) - if err != nil { - t.Fatalf("SubmitMovie returned error: %v", err) - } - if result.ExternalStatus != "accepted_without_response" { - t.Fatalf("ExternalStatus = %q, want accepted_without_response", result.ExternalStatus) - } -} - -func TestCheckMovieStatusReadsQueueDetails(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("X-Api-Key"); got != "radarr-key" { - t.Fatalf("X-Api-Key = %q, want radarr-key", got) - } - if r.URL.Path != "/api/v3/queue/details" { - t.Fatalf("unexpected path %s", r.URL.Path) - } - if got := r.URL.Query().Get("movieId"); got != "42" { - t.Fatalf("movieId = %q, want 42", got) - } - w.Write([]byte(`[{"movieId":42,"status":"downloading","trackedDownloadState":"downloading"}]`)) - })) - defer server.Close() - - client := NewClient(server.Client()) - status, err := client.CheckMovieStatus(context.Background(), mediarequests.Request{ - MediaType: mediarequests.MediaTypeMovie, - TMDBID: 550, - ExternalID: "42", - }, mediarequests.Integration{ - Kind: "radarr", - BaseURL: server.URL, - APIKeyRef: "radarr-key", - }) - if err != nil { - t.Fatalf("CheckMovieStatus returned error: %v", err) - } - if status.Status != mediarequests.StatusDownloading || status.ExternalStatus != "downloading/downloading" { - t.Fatalf("status = %+v, want downloading", status) - } -} - -func TestListMovieIntegrationOptionsLoadsChoices(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("X-Api-Key"); got != "radarr-key" { - t.Fatalf("X-Api-Key = %q, want radarr-key", got) - } - switch r.URL.Path { - case "/api/v3/rootfolder": - w.Write([]byte(`[{"path":"/movies","freeSpace":123,"totalSpace":456,"accessible":true}]`)) - case "/api/v3/qualityprofile": - w.Write([]byte(`[{"id":7,"name":"HD-1080p"}]`)) - case "/api/v3/tag": - w.Write([]byte(`[{"id":2,"label":"requests"}]`)) - default: - t.Fatalf("unexpected path %s", r.URL.Path) - } - })) - defer server.Close() - - client := NewClient(server.Client()) - options, err := client.ListMovieIntegrationOptions(context.Background(), mediarequests.Integration{ - Kind: "radarr", - BaseURL: server.URL, - APIKeyRef: "radarr-key", - }) - if err != nil { - t.Fatalf("ListMovieIntegrationOptions returned error: %v", err) - } - if options.Kind != "radarr" { - t.Fatalf("kind = %q, want radarr", options.Kind) - } - if len(options.RootFolders) != 1 || options.RootFolders[0].Path != "/movies" { - t.Fatalf("root folders = %+v, want /movies", options.RootFolders) - } - if len(options.QualityProfiles) != 1 || options.QualityProfiles[0].ID != 7 { - t.Fatalf("quality profiles = %+v, want id 7", options.QualityProfiles) - } - if len(options.Tags) != 1 || options.Tags[0].ID != 2 { - t.Fatalf("tags = %+v, want id 2", options.Tags) - } -} diff --git a/internal/requests/repository.go b/internal/requests/repository.go index 3a932451..89ceae42 100644 --- a/internal/requests/repository.go +++ b/internal/requests/repository.go @@ -26,14 +26,10 @@ func NewRepository(pool *pgxpool.Pool, cipher *secret.Cipher) *Repository { return &Repository{pool: pool, cipher: cipher} } -// apiKeyAAD binds a request_integrations api_key_ref ciphertext to its row. func apiKeyAAD(id string) string { return secret.RowAAD("request_integrations", "api_key_ref", id) } -// encryptAPIKey encrypts a non-empty, trimmed API key bound to the integration -// id. An empty key returns "" so the update path's keep-existing CASE sentinel -// still fires (and an absent key stays absent). func (r *Repository) encryptAPIKey(id, apiKey string) (string, error) { apiKey = strings.TrimSpace(apiKey) if apiKey == "" { @@ -471,13 +467,12 @@ 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` +const integrationColumns = `id, name, enabled, base_url, api_key_ref, + last_check_at, last_check_status, last_check_error, updated_at, + capability_id, installation_id, supported_media_types, plugin_config` func (r *Repository) ListIntegrations(ctx context.Context) ([]Integration, error) { - rows, err := r.pool.Query(ctx, `SELECT `+integrationColumns+` FROM request_integrations ORDER BY kind, name`) + rows, err := r.pool.Query(ctx, `SELECT `+integrationColumns+` FROM request_integrations ORDER BY name`) if err != nil { return nil, fmt.Errorf("list request integrations: %w", err) } @@ -522,12 +517,20 @@ func (r *Repository) UpdateIntegration(ctx context.Context, i Integration) (*Int // 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{} + if i.PluginConfig == nil { + i.PluginConfig = map[string]any{} } - options, err := json.Marshal(i.Options) + pluginConfig, err := json.Marshal(i.PluginConfig) if err != nil { - return nil, fmt.Errorf("marshal options: %w", err) + return nil, fmt.Errorf("marshal plugin config: %w", err) + } + // capability_id is the capability sub-id ("arr"/"seerr"), validated non-empty + // upstream in validateInstance; persist it verbatim (never default it to the + // capability type, which the plugin runtime can't resolve). + capabilityID := strings.TrimSpace(i.CapabilityID) + supportedMediaTypes := i.SupportedMediaTypes + if supportedMediaTypes == nil { + supportedMediaTypes = []string{} } apiKeyRef, err := r.encryptAPIKey(i.ID, i.APIKeyRef) if err != nil { @@ -535,17 +538,13 @@ func (r *Repository) insertIntegration(ctx context.Context, exec requestExecutor } 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()) + id, name, enabled, base_url, api_key_ref, + capability_id, installation_id, supported_media_types, + plugin_config, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9, now()) RETURNING `+integrationColumns, - i.ID, i.Kind, strings.TrimSpace(i.Name), i.Enabled, strings.TrimSpace(i.BaseURL), - 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) + i.ID, strings.TrimSpace(i.Name), i.Enabled, strings.TrimSpace(i.BaseURL), + apiKeyRef, capabilityID, i.InstallationID, supportedMediaTypes, pluginConfig) out, err := r.scanIntegration(row) if err != nil { return nil, fmt.Errorf("create request integration: %w", err) @@ -556,12 +555,20 @@ func (r *Repository) insertIntegration(ctx context.Context, exec requestExecutor // 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{} + if i.PluginConfig == nil { + i.PluginConfig = map[string]any{} } - options, err := json.Marshal(i.Options) + pluginConfig, err := json.Marshal(i.PluginConfig) if err != nil { - return nil, fmt.Errorf("marshal options: %w", err) + return nil, fmt.Errorf("marshal plugin config: %w", err) + } + // capability_id is the capability sub-id ("arr"/"seerr"), validated non-empty + // upstream in validateInstance; persist it verbatim (never default it to the + // capability type, which the plugin runtime can't resolve). + capabilityID := strings.TrimSpace(i.CapabilityID) + supportedMediaTypes := i.SupportedMediaTypes + if supportedMediaTypes == nil { + supportedMediaTypes = []string{} } // Encrypt the incoming key; an empty result preserves the keep-existing CASE // (a blank edit leaves the stored key untouched). @@ -573,17 +580,12 @@ func (r *Repository) updateIntegration(ctx context.Context, exec requestExecutor 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() + capability_id=$6, installation_id=$7, + supported_media_types=$8, plugin_config=$9, updated_at=now() WHERE id=$1 RETURNING `+integrationColumns, i.ID, strings.TrimSpace(i.Name), i.Enabled, strings.TrimSpace(i.BaseURL), - 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) + apiKeyRef, capabilityID, i.InstallationID, supportedMediaTypes, pluginConfig) out, err := r.scanIntegration(row) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -594,9 +596,10 @@ func (r *Repository) updateIntegration(ctx context.Context, exec requestExecutor 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. +// SaveIntegrationWithDefaults creates/updates the instance in a single +// transaction. The host no longer enforces a single HD/4K default per kind; +// the request_router plugin's RouteTargets picks the first is_default +// connection from plugin_config, so the save path is a plain insert-or-update. func (r *Repository) SaveIntegrationWithDefaults(ctx context.Context, in Integration, isCreate bool) (*Integration, error) { tx, err := r.pool.Begin(ctx) if err != nil { @@ -604,17 +607,6 @@ func (r *Repository) SaveIntegrationWithDefaults(ctx context.Context, in Integra } defer tx.Rollback(ctx) - if in.IsDefault { - if err := r.ClearDefault(ctx, tx, in.Kind, false, in.ID); err != nil { - return nil, err - } - } - 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) @@ -669,21 +661,6 @@ func (r *Repository) DeleteIntegration(ctx context.Context, id string) error { 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 { var actorUserID any if actor.UserID > 0 { @@ -794,15 +771,13 @@ type integrationScanner interface { func (r *Repository) scanIntegration(row integrationScanner) (Integration, error) { var i Integration - var quality, animeQuality sql.NullInt64 - var tags, animeTags []int32 - var optionsRaw []byte + var installationID sql.NullInt64 + var pluginConfigRaw []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, + &i.ID, &i.Name, &i.Enabled, &i.BaseURL, &i.APIKeyRef, + &lastCheckAt, &i.LastCheckStatus, &i.LastCheckError, &i.UpdatedAt, + &i.CapabilityID, &installationID, &i.SupportedMediaTypes, &pluginConfigRaw, ); err != nil { return Integration{}, err } @@ -814,42 +789,20 @@ func (r *Repository) scanIntegration(row integrationScanner) (Integration, error return Integration{}, fmt.Errorf("decrypt request integration %s api key: %w", i.ID, err) } i.APIKeyRef = apiKey - if quality.Valid { - v := int(quality.Int64) - i.QualityProfileID = &v + if installationID.Valid { + v := int(installationID.Int64) + i.InstallationID = &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 len(pluginConfigRaw) > 0 { + if err := json.Unmarshal(pluginConfigRaw, &i.PluginConfig); err != nil { + return Integration{}, fmt.Errorf("unmarshal request integration plugin config for %s: %w", i.ID, err) } } - if i.Options == nil { - i.Options = map[string]any{} + if i.PluginConfig == nil { + i.PluginConfig = map[string]any{} } if lastCheckAt.Valid { i.LastCheckAt = &lastCheckAt.Time } return i, nil } - -func int32Slice(values []int) []int32 { - out := make([]int32, 0, len(values)) - for _, value := range values { - out = append(out, int32(value)) - } - return out -} - -func intsFromInt32(values []int32) []int { - out := make([]int, 0, len(values)) - for _, value := range values { - out = append(out, int(value)) - } - return out -} diff --git a/internal/requests/repository_crypto_test.go b/internal/requests/repository_crypto_test.go new file mode 100644 index 00000000..315ee68e --- /dev/null +++ b/internal/requests/repository_crypto_test.go @@ -0,0 +1,59 @@ +package requests + +import ( + "testing" + + "github.com/Silo-Server/silo-server/internal/secret" +) + +// TestEncryptAPIKeyRoundTripAndAAD guards the security-critical invariants of the +// at-rest credential cipher adopted from #95: encryptAPIKey must produce +// ciphertext that DecryptIfEncrypted inverts under the SAME id-bound AAD that +// secret's backfill writes, a blank key must encrypt to "" (the keep-existing +// update sentinel), and the AAD must be row-bound. A drift in apiKeyAAD or the +// envelope would silently corrupt or leak every stored connection api key with no +// other test catching it (there is no DB harness for scanIntegration). +func TestEncryptAPIKeyRoundTripAndAAD(t *testing.T) { + cipher, err := secret.New([]byte("unit-test-master-key-with-at-least-32-bytes")) + if err != nil { + t.Fatalf("secret.New: %v", err) + } + r := &Repository{cipher: cipher} + + const id = "integ-1" + const key = "00178d9a3893480cbf122d29f7aca1a0" + + enc, err := r.encryptAPIKey(id, key) + if err != nil { + t.Fatalf("encryptAPIKey: %v", err) + } + if enc == "" || enc == key { + t.Fatalf("encryptAPIKey did not encrypt: %q", enc) + } + + // The AAD must equal what secret.BackfillReferencedColumns wrote, or rows + // encrypted by the startup backfill fail to decrypt on read. + if got, want := apiKeyAAD(id), secret.RowAAD("request_integrations", "api_key_ref", id); got != want { + t.Fatalf("apiKeyAAD = %q, want %q (must match #95 backfill)", got, want) + } + + dec, err := cipher.DecryptIfEncrypted(enc, apiKeyAAD(id)) + if err != nil { + t.Fatalf("DecryptIfEncrypted: %v", err) + } + if dec != key { + t.Fatalf("round-trip = %q, want %q", dec, key) + } + + // A blank/whitespace key encrypts to "" so updateIntegration's + // CASE WHEN $5 = '' sentinel preserves the stored ciphertext. + if blank, err := r.encryptAPIKey(id, " "); err != nil || blank != "" { + t.Fatalf("encryptAPIKey(blank) = %q, %v; want \"\", nil (keep-existing sentinel)", blank, err) + } + + // AAD is row-bound: ciphertext from one row must not authenticate under + // another row's id. + if _, err := cipher.DecryptIfEncrypted(enc, apiKeyAAD("integ-2")); err == nil { + t.Fatal("ciphertext decrypted under a different id's AAD — AAD is not row-bound") + } +} diff --git a/internal/requests/routing.go b/internal/requests/routing.go deleted file mode 100644 index 918957b7..00000000 --- a/internal/requests/routing.go +++ /dev/null @@ -1,85 +0,0 @@ -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 deleted file mode 100644 index 8e855154..00000000 --- a/internal/requests/routing_test.go +++ /dev/null @@ -1,52 +0,0 @@ -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 d8d3f986..9bd57125 100644 --- a/internal/requests/service.go +++ b/internal/requests/service.go @@ -27,30 +27,6 @@ type TMDBExternalIDClient interface { const externalIDHydrationConcurrency = 4 -type MovieFulfillmentAdapter interface { - SubmitMovie(ctx context.Context, req Request, integration Integration) (FulfillmentResult, error) -} - -type SeriesFulfillmentAdapter interface { - SubmitSeries(ctx context.Context, req Request, integration Integration) (FulfillmentResult, error) -} - -type MovieStatusAdapter interface { - CheckMovieStatus(ctx context.Context, req Request, integration Integration) (FulfillmentStatus, error) -} - -type SeriesStatusAdapter interface { - CheckSeriesStatus(ctx context.Context, req Request, integration Integration) (FulfillmentStatus, error) -} - -type MovieIntegrationOptionsAdapter interface { - ListMovieIntegrationOptions(ctx context.Context, integration Integration) (*IntegrationOptions, error) -} - -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 @@ -58,14 +34,20 @@ type EntitlementResolver interface { MaxPlaybackQuality(ctx context.Context, userID int, profileID string) (string, error) } +// RequesterIdentityResolver resolves a requesting user id into the identity a +// per-user request_router plugin needs (e.g. Seerr attribution by email). +type RequesterIdentityResolver interface { + ResolveRequester(ctx context.Context, userID int) (email, username string, err error) +} + type Service struct { - store Store - tmdb TMDBClient - presence PresenceResolver - movieAdapter MovieFulfillmentAdapter - seriesAdapter SeriesFulfillmentAdapter - entitlements EntitlementResolver - Now func() time.Time + store Store + tmdb TMDBClient + presence PresenceResolver + router RequestRouterProvider + entitlements EntitlementResolver + requesterIdentity RequesterIdentityResolver + Now func() time.Time } type DiscoverySection struct { @@ -86,13 +68,28 @@ func NewService(store Store, tmdbClient TMDBClient, presence PresenceResolver) * } } -func (s *Service) SetFulfillmentAdapters(movie MovieFulfillmentAdapter, series SeriesFulfillmentAdapter) { - s.movieAdapter = movie - s.seriesAdapter = series -} +func (s *Service) SetRouterProvider(p RequestRouterProvider) { s.router = p } func (s *Service) SetEntitlementResolver(r EntitlementResolver) { s.entitlements = r } +func (s *Service) SetRequesterIdentityResolver(r RequesterIdentityResolver) { + s.requesterIdentity = r +} + +// populateRequesterIdentity fills req.RequesterEmail/Username from the resolver. +// Nil resolver or any error leaves them empty (the plugin then behaves as admin). +func (s *Service) populateRequesterIdentity(ctx context.Context, req *Request) { + if s.requesterIdentity == nil || req.RequestedByUserID <= 0 { + return + } + email, username, err := s.requesterIdentity.ResolveRequester(ctx, req.RequestedByUserID) + if err != nil { + slog.WarnContext(ctx, "requests: requester identity resolve failed; attributing to admin", "user_id", req.RequestedByUserID, "error", err) + return + } + req.RequesterEmail, req.RequesterUsername = email, username +} + func (s *Service) requesterCeiling(ctx context.Context, userID int, profileID string) string { if s.entitlements == nil { return "" // no resolver -> unlimited (1080p baseline still applies) @@ -104,6 +101,94 @@ func (s *Service) requesterCeiling(ctx context.Context, userID int, profileID st return q } +// allowedQualities returns the qualities a request may receive: 1080p always, +// plus 2160p when force-dual is on or the requester's entitlement ceiling allows 4K. +func (s *Service) allowedQualities(ctx context.Context, req Request, settings Settings) []Quality { + out := []Quality{Quality1080p} + ceiling := s.requesterCeiling(ctx, req.RequestedByUserID, req.RequestedByProfileID) + // QualityAllowed treats an empty ceiling as "no cap" (the "Any" preset), so a + // requester with unlimited playback quality correctly gets 4K. A raw + // CompareQuality would rank "" as the LOWEST quality and wrongly drop 4K. + if settings.ForceDualQuality || access.QualityAllowed(access.PlaybackQuality4K, ceiling) { + out = append(out, Quality2160p) + } + return out +} + +// fulfillContext caches the global fulfillment inputs for one reconcile cycle +// (or a single Approve/Retry) so integrations and settings are fetched once +// instead of per request. API keys need no cache here: the repository decrypts +// api_key_ref on read, so Integration.APIKeyRef already holds the literal key. +type fulfillContext struct { + integrations []Integration + settings Settings +} + +func (s *Service) newFulfillContext(ctx context.Context) (*fulfillContext, error) { + integrations, err := s.store.ListIntegrations(ctx) + if err != nil { + return nil, err + } + settings, err := s.store.GetSettings(ctx) + if err != nil { + return nil, err + } + return &fulfillContext{integrations: integrations, settings: settings}, nil +} + +// resolveRouterConnections turns enabled request_router integrations that serve +// the given media type into ResolvedRouterConnections (api key resolved to +// plaintext, plugin_config attached), and returns the installation+capability to +// dispatch to. +// +// It filters by media type to match the integrationConfigured auto-approve gate +// (so a series-only connection is never used for a movie request). Multi- +// installation routing isn't supported yet: it picks the first eligible +// connection's installation and includes ONLY connections belonging to it, so a +// second installation's resolved plaintext credentials are never handed to the +// first plugin. A connection whose api key cannot be resolved (or resolves empty) +// is skipped rather than aborting the whole request — a sibling healthy +// connection can still fulfill it, and an unauthenticated request is never sent. +func (s *Service) resolveRouterConnections(ctx context.Context, fc *fulfillContext, mediaType MediaType) ([]ResolvedRouterConnection, int, string, error) { + var conns []ResolvedRouterConnection + installationID, capabilityID := 0, "" + chosen := false + for _, in := range fc.integrations { + if !eligibleRouterConnection(in, mediaType) { + continue + } + // Contain to the first chosen (installation, capability): a plugin may + // expose more than one request_router capability, and a connection of a + // different capability must never be handed to the chosen one. + if chosen && (*in.InstallationID != installationID || in.CapabilityID != capabilityID) { + continue + } + // in.APIKeyRef was decrypted by the repo on read; empty means unconfigured. + apiKey := strings.TrimSpace(in.APIKeyRef) + if apiKey == "" { + slog.WarnContext(ctx, "requests: skipping router connection with no api key", "connection_id", in.ID) + continue + } + // Lock on the first SUCCESSFULLY resolved connection so a skipped + // bad-key connection never pins the installation/capability. + if !chosen { + installationID, capabilityID, chosen = *in.InstallationID, in.CapabilityID, true + } + conns = append(conns, ResolvedRouterConnection{ID: in.ID, BaseURL: in.BaseURL, APIKey: apiKey, Config: in.PluginConfig}) + } + return conns, installationID, capabilityID, nil +} + +// eligibleRouterConnection reports whether a connection is a candidate fulfillment +// backend for the media type: enabled, bound to an installation, and naming a +// capability sub-id that serves the media type. resolveRouterConnections (which +// then resolves credentials) and integrationConfigured (the auto-approval gate) +// share this predicate so the two cannot drift. +func eligibleRouterConnection(in Integration, mediaType MediaType) bool { + return in.Enabled && in.CapabilityID != "" && in.InstallationID != nil && + integrationSupportsMediaType(in, mediaType) +} + 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") @@ -371,7 +456,7 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create return nil, err } if req.Status == StatusApproved { - return s.submitApprovedRequest(ctx, *req, viewer) + return s.submitApprovedRequest(ctx, *req, viewer, nil) } return req, nil } @@ -507,7 +592,7 @@ func (s *Service) Approve(ctx context.Context, viewer Viewer, id string) (*Reque if err != nil { return nil, err } - return s.submitApprovedRequest(ctx, *approved, viewer) + return s.submitApprovedRequest(ctx, *approved, viewer, nil) } func (s *Service) Decline(ctx context.Context, viewer Viewer, id, reason string) (*Request, error) { @@ -576,69 +661,16 @@ func (s *Service) Retry(ctx context.Context, viewer Viewer, id string) (*Request if req.Outcome != OutcomeFailed { return nil, ErrInvalidState } - active, err := s.store.SetOutcome(ctx, req.ID, OutcomeActive, viewer, "retry requested") + if _, err := s.store.SetOutcome(ctx, req.ID, OutcomeActive, viewer, "retry requested"); err != nil { + return nil, err + } + // submitApprovedRequest only re-submits qualities lacking a healthy target, so + // it is idempotent; gate it on the approved status it expects. + active, err := s.store.SetStatus(ctx, req.ID, StatusApproved, viewer) if err != nil { return nil, err } - - instances, err := s.store.ListIntegrations(ctx) - if err != nil { - return nil, err - } - 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 + return s.submitApprovedRequest(ctx, *active, viewer, nil) } func (s *Service) ReconcileRequests(ctx context.Context, limit int) (ReconcileResult, error) { @@ -652,12 +684,16 @@ func (s *Service) ReconcileRequests(ctx context.Context, limit int) (ReconcileRe if err != nil { return ReconcileResult{}, err } + fc, err := s.newFulfillContext(ctx) + if err != nil { + return ReconcileResult{}, err + } result := ReconcileResult{Checked: len(candidates)} for _, req := range candidates { if err := ctx.Err(); err != nil { return result, err } - change, err := s.reconcileRequest(ctx, *req) + change, err := s.reconcileRequest(ctx, *req, fc) if err != nil { slog.WarnContext(ctx, "request reconcile failed", "request_id", req.ID, @@ -765,14 +801,17 @@ func (s *Service) CreateIntegration(ctx context.Context, viewer Viewer, in Integ if !viewer.IsAdmin { return nil, ErrForbidden } - if err := validateInstance(&in); err != nil { - return nil, err - } id, err := idgen.NextID() if err != nil { return nil, err } in.ID = id + if err := validateInstance(&in); err != nil { + return nil, err + } + if err := s.validateViaPlugin(ctx, in); err != nil { + return nil, err + } return s.store.SaveIntegrationWithDefaults(ctx, in, true) } @@ -786,9 +825,86 @@ func (s *Service) UpdateIntegration(ctx context.Context, viewer Viewer, in Integ if err := validateInstance(&in); err != nil { return nil, err } + if err := s.validateViaPlugin(ctx, in); err != nil { + return nil, err + } return s.store.SaveIntegrationWithDefaults(ctx, in, false) } +// validateViaPlugin asks the bound request_router plugin to validate the +// connection config on save. Field/form errors are surfaced as *ValidationError +// so the API layer can render them inline. +func (s *Service) validateViaPlugin(ctx context.Context, in Integration) error { + if s.router == nil || in.InstallationID == nil { + return nil + } + // On UPDATE the client omits api_key_ref ("leave blank to keep saved key"), + // so we would otherwise validate against an empty credential. Mirror + // LoadIntegrationOptions's backfill: load the stored row by id and reuse the + // saved (already-decrypted) api key (and BaseURL/PluginConfig if also blank). + // Nil-safe — a brand-new id has no stored row, so just proceed with what the + // body carries. + if strings.TrimSpace(in.APIKeyRef) == "" && strings.TrimSpace(in.ID) != "" { + stored, err := s.store.GetIntegration(ctx, in.ID) + if err != nil && !errors.Is(err, ErrNotFound) { + return err + } + if stored != nil { + // Don't pair a stored API key with a caller-changed base URL: require the + // key to be re-entered when the server URL changes (defense against + // exfiltrating a stored, API-unreadable key to an attacker-supplied URL). + if strings.TrimSpace(in.BaseURL) != "" && strings.TrimSpace(in.BaseURL) != strings.TrimSpace(stored.BaseURL) { + return &ValidationError{FieldErrors: map[string]string{"api_key_ref": "re-enter the API key when changing the base URL"}} + } + in.APIKeyRef = stored.APIKeyRef + if strings.TrimSpace(in.BaseURL) == "" { + in.BaseURL = stored.BaseURL + } + if in.PluginConfig == nil { + in.PluginConfig = stored.PluginConfig + } + } + } + // in.APIKeyRef is the decrypted literal (from the body, or backfilled from the + // stored row above). + apiKey := strings.TrimSpace(in.APIKeyRef) + conn := ResolvedRouterConnection{ID: in.ID, BaseURL: in.BaseURL, APIKey: apiKey, Config: in.PluginConfig} + siblings, err := s.siblingConnections(ctx, in) + if err != nil { + return err + } + fe, form, err := s.router.Validate(ctx, *in.InstallationID, in.CapabilityID, conn, siblings) + if err != nil { + return err + } + if len(fe) > 0 || form != "" { + return &ValidationError{FieldErrors: fe, FormError: form} + } + return nil +} + +// siblingConnections returns the other connections bound to the same plugin +// installation as `in` (self excluded), carrying only id + config so a plugin +// can enforce cross-connection rules without the host resolving sibling +// credentials. +func (s *Service) siblingConnections(ctx context.Context, in Integration) ([]ResolvedRouterConnection, error) { + if in.InstallationID == nil { + return nil, nil + } + all, err := s.store.ListIntegrations(ctx) + if err != nil { + return nil, err + } + var out []ResolvedRouterConnection + for _, other := range all { + if other.ID == in.ID || other.InstallationID == nil || *other.InstallationID != *in.InstallationID { + continue + } + out = append(out, ResolvedRouterConnection{ID: other.ID, Config: other.PluginConfig}) + } + return out, nil +} + func (s *Service) DeleteIntegration(ctx context.Context, viewer Viewer, id string) error { if !viewer.IsAdmin { return ErrForbidden @@ -797,78 +913,68 @@ func (s *Service) DeleteIntegration(ctx context.Context, viewer Viewer, id strin } 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) + // capability_id carries the capability SUB-ID ("arr"/"seerr"), not the type: + // the host resolves the plugin via requireCapability("request_router.v1", id), + // which keys on (type, id), so storing the type "request_router.v1" here + // resolves nothing. Matches the scan_source/metadata convention + // (autoscan_sources.capability_id = "arr"). The bound plugin's Validate RPC is + // the authority on whether the sub-id names a real capability. + in.CapabilityID = strings.TrimSpace(in.CapabilityID) + if in.CapabilityID == "" { + return fmt.Errorf("%w: capability_id is required", ErrInvalidInput) } - if in.IsDefault4K && !in.Is4K { - return fmt.Errorf("%w: the 4K default must be a 4K server", ErrInvalidInput) + if in.InstallationID == nil { + return fmt.Errorf("%w: installation_id is required", ErrInvalidInput) } + // The is_default/is_4k/is_default_4k cross-field consistency check is owned by + // the request_router plugin's Validate RPC, which surfaces it as an inline + // field error (better UX than a generic host 400). See validateViaPlugin. return nil } -func (s *Service) LoadIntegrationOptions(ctx context.Context, viewer Viewer, integration Integration) (*IntegrationOptions, error) { +func (s *Service) LoadIntegrationOptions(ctx context.Context, viewer Viewer, integration Integration) (map[string][]RouterOption, 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. + // For a saved instance the request body carries only the path id (no creds and + // often no plugin wiring), so resolve the saved row by id and backfill what the + // body omitted. This makes "Test connection" reuse the correct per-instance key + // (each plugin can have multiple connections) 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 - } + submittedBaseURL := strings.TrimSpace(integration.BaseURL) + storedBaseURL := strings.TrimSpace(stored.BaseURL) if strings.TrimSpace(integration.BaseURL) == "" { integration.BaseURL = stored.BaseURL } - if strings.TrimSpace(integration.APIKeyRef) == "" { + if strings.TrimSpace(integration.APIKeyRef) == "" && (submittedBaseURL == "" || submittedBaseURL == storedBaseURL) { integration.APIKeyRef = stored.APIKeyRef } + if strings.TrimSpace(integration.CapabilityID) == "" { + integration.CapabilityID = stored.CapabilityID + } + if integration.InstallationID == nil { + integration.InstallationID = stored.InstallationID + } + if integration.PluginConfig == nil { + integration.PluginConfig = stored.PluginConfig + } } } - normalized, err := normalizeIntegrationConnection(integration) - if err != nil { - return nil, err - } - if strings.TrimSpace(normalized.BaseURL) == "" { - return nil, fmt.Errorf("%w: base_url is required", ErrInvalidInput) - } - if strings.TrimSpace(normalized.APIKeyRef) == "" { - return nil, fmt.Errorf("%w: api_key_ref is required", ErrInvalidInput) - } - // APIKeyRef is already the literal key: a saved instance was decrypted by - // GetIntegration above; a new instance carries the key the admin just typed. - resolved := normalized - - switch resolved.Kind { - case "radarr": - adapter, ok := s.movieAdapter.(MovieIntegrationOptionsAdapter) - if !ok { - return nil, fmt.Errorf("request radarr integration options are not configured") - } - return adapter.ListMovieIntegrationOptions(ctx, resolved) - case "sonarr": - adapter, ok := s.seriesAdapter.(SeriesIntegrationOptionsAdapter) - if !ok { - return nil, fmt.Errorf("request sonarr integration options are not configured") - } - return adapter.ListSeriesIntegrationOptions(ctx, resolved) - default: - return nil, fmt.Errorf("%w: invalid integration kind", ErrInvalidInput) + apiKey := strings.TrimSpace(integration.APIKeyRef) + if s.router == nil || integration.InstallationID == nil { + return nil, fmt.Errorf("no fulfillment backend configured") } + conn := ResolvedRouterConnection{ID: integration.ID, BaseURL: integration.BaseURL, APIKey: apiKey, Config: integration.PluginConfig} + return s.router.ListConfigOptions(ctx, *integration.InstallationID, integration.CapabilityID, conn) } func (s *Service) EffectivePolicy(ctx context.Context, userID int) (EffectivePolicy, error) { @@ -1139,105 +1245,247 @@ func (s *Service) detectRequestAnime(ctx context.Context, mediaType MediaType, t return detectAnime(detail.KeywordIDs) } +// integrationConfigured reports whether a fulfillment backend exists for the +// media type, gating auto-approval (pending vs approved). It uses the same +// router-connection selection as resolveRouterConnections — an enabled +// request_router.v1 connection with an installation — and additionally honors a +// connection's declared media-type support so a movie request only auto-approves +// when a router connection supporting "movie" exists. 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 || in.IsDefault4K) && integrationIsConfigured(in) { + if eligibleRouterConnection(in, mediaType) && + strings.TrimSpace(in.BaseURL) != "" && strings.TrimSpace(in.APIKeyRef) != "" { return true, nil } } return false, nil } -func (s *Service) submitApprovedRequest(ctx context.Context, req Request, actor Viewer) (*Request, error) { +// integrationSupportsMediaType reports whether a router connection serves the +// given media type. An empty SupportedMediaTypes is treated as "supports all". +func integrationSupportsMediaType(in Integration, mediaType MediaType) bool { + if len(in.SupportedMediaTypes) == 0 { + return true + } + for _, mt := range in.SupportedMediaTypes { + if mt == string(mediaType) { + return true + } + } + return false +} + +func (s *Service) submitApprovedRequest(ctx context.Context, req Request, actor Viewer, fc *fulfillContext) (*Request, error) { if req.Outcome != OutcomeActive || req.Status != StatusApproved { return &req, nil } - instances, err := s.store.ListIntegrations(ctx) + if s.router == nil { + return s.markSubmissionFailed(ctx, req.ID, actor, fmt.Errorf("no fulfillment backend configured")) + } + if fc == nil { + built, err := s.newFulfillContext(ctx) + if err != nil { + return nil, err + } + fc = built + } + conns, installationID, capabilityID, err := s.resolveRouterConnections(ctx, fc, req.MediaType) if err != nil { return nil, err } - settings, err := s.store.GetSettings(ctx) - if err != nil { - return nil, err + if len(conns) == 0 { + // Distinguish "no backend at all" from the migration breakage where an + // existing connection row exists but its installation_id is NULL (the row + // predates the plugin install and was never re-bound). + msg := "no fulfillment backend configured" + for _, in := range fc.integrations { + if in.Enabled && in.CapabilityID != "" && in.InstallationID == nil { + msg = "request backend connection is not bound to a plugin installation; re-save it in admin" + break + } + } + return s.markSubmissionFailed(ctx, req.ID, actor, errors.New(msg)) } - 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))) - } - - // 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 nil, err } - 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) - // The repo already decrypted the stored key; a blank one means unconfigured. - if strings.TrimSpace(resolved.APIKeyRef) == "" { - return s.createFailedTarget(ctx, req, pt, resolved, "missing api key", actor) + healthy := map[Quality]bool{} + for _, t := range existing { + if t.Status != StatusFailed { + healthy[t.Quality] = true + } } - - target, err := s.store.CreateTarget(ctx, Target{ - RequestID: req.ID, IntegrationID: resolved.ID, IntegrationKind: resolved.Kind, - Quality: pt.Quality, IsAnime: pt.IsAnime, Status: StatusQueued, - }) + allowed := s.allowedQualities(ctx, req, fc.settings) + if !fc.settings.ForceDualQuality { + allowed = filterUnconfiguredOptionalQualities(allowed, conns) + } + var want []Quality + for _, q := range allowed { + if !healthy[q] { + want = append(want, q) + } + } + if len(want) == 0 { + return &req, nil + } + for _, t := range existing { // drop stale failed targets for the qualities we re-submit + if t.Status == StatusFailed { + for _, q := range want { + if t.Quality == q { + if err := s.store.DeleteTarget(ctx, t.ID); err != nil { + return nil, err + } + } + } + } + } + s.populateRequesterIdentity(ctx, &req) + targets, msg, err := s.router.Fulfill(ctx, installationID, capabilityID, req, want, conns) 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) + if len(targets) == 0 { + if msg == "" { + msg = "fulfillment backend created no targets" + } + return s.markSubmissionFailed(ctx, req.ID, actor, errors.New(msg)) } - return s.store.UpdateTargetStatus(ctx, target.ID, StatusQueued, - result.ExternalID, result.ExternalStatus, "", actor) + connKind := connectionKindByID(conns) + latest := &req + // The plugin is an out-of-process trust boundary: validate every returned + // target against the DB CHECK constraints (quality, status) and skip any + // quality that is duplicated in the batch or already has a healthy target, so + // a misbehaving plugin can't violate UNIQUE(request_id, quality) and wedge the + // request. + validQuality := map[Quality]bool{Quality1080p: true, Quality2160p: true} + validStatus := map[Status]bool{StatusQueued: true, StatusDownloading: true, StatusCompleted: true, StatusFailed: true} + returned := map[Quality]bool{} + for _, rt := range targets { + if !validQuality[rt.Quality] { + slog.WarnContext(ctx, "requests: plugin returned unknown quality; skipping", "request_id", req.ID, "quality", string(rt.Quality)) + continue + } + if returned[rt.Quality] || healthy[rt.Quality] { + continue // dup-in-batch, or a healthy target already exists for this quality + } + if rt.ConnectionID != "" { + if _, ok := connKind[rt.ConnectionID]; !ok { + slog.WarnContext(ctx, "requests: plugin returned unknown connection id; skipping target", "request_id", req.ID, "connection_id", rt.ConnectionID) + continue + } + } + returned[rt.Quality] = true + created, err := s.store.CreateTarget(ctx, Target{ + RequestID: req.ID, IntegrationID: rt.ConnectionID, IntegrationKind: connKind[rt.ConnectionID], + Quality: rt.Quality, IsAnime: req.IsAnime, Status: StatusQueued, + }) + if err != nil { + return nil, err + } + status := rt.Status + if status == "" || !validStatus[status] { + status = StatusQueued // coerce unknown/empty status to the DB-valid default + } + updated, err := s.store.UpdateTargetStatus(ctx, created.ID, status, rt.ExternalID, rt.ExternalStatus, rt.Message, actor) + if err != nil { + return nil, err + } + if updated != nil { + latest = updated + } + } + // Any wanted quality the plugin did not fulfill is recorded as a failed target + // rather than silently dropped, so it stays visible and Retry re-attempts it + // (a failed target is not "healthy"). + const noTargetMsg = "fulfillment backend returned no target for this quality" + for _, q := range want { + if returned[q] { + continue + } + created, err := s.store.CreateTarget(ctx, Target{ + RequestID: req.ID, Quality: q, IsAnime: req.IsAnime, Status: StatusFailed, LastError: noTargetMsg, + }) + if err != nil { + return nil, err + } + updated, err := s.store.UpdateTargetStatus(ctx, created.ID, StatusFailed, "", "", noTargetMsg, actor) + if err != nil { + return nil, err + } + if updated != nil { + latest = updated + } + } + return latest, nil } -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 +// connectionKindByID maps each connection id to its plugin-declared service kind +// (e.g. "radarr"/"sonarr") from PluginConfig["service_kind"], for the +// integration_kind column on persisted targets. Missing kinds map to "". +func connectionKindByID(conns []ResolvedRouterConnection) map[string]string { + out := make(map[string]string, len(conns)) + for _, c := range conns { + out[c.ID] = "" + if c.Config != nil { + if kind, ok := c.Config["service_kind"].(string); ok { + out[c.ID] = kind + } + } } - return s.store.UpdateTargetStatus(ctx, target.ID, StatusFailed, "", "", msg, actor) + return out } -// 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 FulfillmentResult{}, fmt.Errorf("no movie adapter configured") +func filterUnconfiguredOptionalQualities(qualities []Quality, conns []ResolvedRouterConnection) []Quality { + out := make([]Quality, 0, len(qualities)) + for _, q := range qualities { + if q == Quality2160p && !routerQualityConfigured(q, conns) { + continue } - return s.movieAdapter.SubmitMovie(ctx, req, resolved) - case MediaTypeSeries: - if s.seriesAdapter == nil { - return FulfillmentResult{}, fmt.Errorf("no series adapter configured") - } - return s.seriesAdapter.SubmitSeries(ctx, req, resolved) - default: - return FulfillmentResult{}, fmt.Errorf("unsupported media type %q", req.MediaType) + out = append(out, q) } + return out +} + +func routerQualityConfigured(q Quality, conns []ResolvedRouterConnection) bool { + usesTieredDefaults := false + for _, conn := range conns { + if conn.Config == nil { + continue + } + if hasRouterQualityKey(conn.Config) { + usesTieredDefaults = true + } + if q == Quality2160p && boolConfig(conn.Config, "is_default_4k") { + return true + } + } + // Generic request_router implementations may not expose arr-style HD/4K + // default flags. In that case, preserve the host's requested qualities and + // let the plugin decide what it can fulfill. + return !usesTieredDefaults +} + +func hasRouterQualityKey(config map[string]any) bool { + for _, key := range []string{"is_default", "is_default_4k", "is_4k"} { + if _, ok := config[key]; ok { + return true + } + } + return false +} + +func boolConfig(config map[string]any, key string) bool { + v, ok := config[key] + if !ok { + return false + } + b, ok := v.(bool) + return ok && b } func (s *Service) markSubmissionFailed(ctx context.Context, requestID string, actor Viewer, submitErr error) (*Request, error) { @@ -1259,7 +1507,7 @@ const ( reconcileFailed reconcileChange = "failed" ) -func (s *Service) reconcileRequest(ctx context.Context, req Request) (reconcileChange, error) { +func (s *Service) reconcileRequest(ctx context.Context, req Request, fc *fulfillContext) (reconcileChange, error) { completed, err := s.requestAvailable(ctx, req) if err != nil { return reconcileUnchanged, err @@ -1286,7 +1534,7 @@ func (s *Service) reconcileRequest(ctx context.Context, req Request) (reconcileC } if req.Status == StatusApproved { - updated, err := s.submitApprovedRequest(ctx, req, Viewer{}) + updated, err := s.submitApprovedRequest(ctx, req, Viewer{}, fc) if err != nil { return reconcileUnchanged, err } @@ -1304,41 +1552,51 @@ func (s *Service) reconcileRequest(ctx context.Context, req Request) (reconcileC if err != nil { return reconcileUnchanged, err } - instances, err := s.store.ListIntegrations(ctx) + if s.router == nil { + return reconcileUnchanged, nil + } + conns, installationID, capabilityID, err := s.resolveRouterConnections(ctx, fc, req.MediaType) if err != nil { return reconcileUnchanged, err } - byID := make(map[string]Integration, len(instances)) - for _, in := range instances { - byID[in.ID] = in + if len(conns) == 0 { + return reconcileUnchanged, nil } - change := reconcileUnchanged + + var refs []RouterTargetRef for _, t := range targets { if t.Status == StatusCompleted || t.Status == StatusFailed { continue } - in, ok := byID[t.IntegrationID] - if !ok { + refs = append(refs, RouterTargetRef{Quality: t.Quality, ConnectionID: t.IntegrationID, ExternalID: t.ExternalID}) + } + if len(refs) == 0 { + return reconcileUnchanged, nil + } + + statuses, err := s.router.CheckStatus(ctx, installationID, capabilityID, req, refs, conns) + if err != nil { + return reconcileUnchanged, err + } + + change := reconcileUnchanged + for _, st := range statuses { + // Match the returned status to the live target by (quality, connection). + var target *Target + for i := range targets { + if targets[i].Quality == st.Quality && targets[i].IntegrationID == st.ConnectionID { + target = &targets[i] + break + } + } + if target == nil || target.Status == StatusCompleted || target.Status == StatusFailed { continue } - // in.APIKeyRef was decrypted by ListIntegrations; skip if unconfigured. - if strings.TrimSpace(in.APIKeyRef) == "" { + newStatus := st.Status + if newStatus == "" || newStatus == target.Status { continue } - 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 { + if _, err := s.store.UpdateTargetStatus(ctx, target.ID, newStatus, "", st.ExternalStatus, st.Message, Viewer{}); err != nil { return reconcileUnchanged, err } switch newStatus { @@ -1357,20 +1615,6 @@ func (s *Service) reconcileRequest(ctx context.Context, req Request) (reconcileC 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) { @@ -1394,33 +1638,6 @@ 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, resolved Integration) (FulfillmentStatus, error) { - switch req.MediaType { - case MediaTypeMovie: - checker, ok := s.movieAdapter.(MovieStatusAdapter) - if !ok { - return FulfillmentStatus{}, nil - } - return checker.CheckMovieStatus(ctx, req, resolved) - case MediaTypeSeries: - checker, ok := s.seriesAdapter.(SeriesStatusAdapter) - if !ok { - return FulfillmentStatus{}, nil - } - return checker.CheckSeriesStatus(ctx, req, resolved) - default: - return FulfillmentStatus{}, nil - } -} - -func integrationIsConfigured(integration Integration) bool { - return integration.Enabled && - strings.TrimSpace(integration.BaseURL) != "" && - strings.TrimSpace(integration.APIKeyRef) != "" && - strings.TrimSpace(integration.RootFolder) != "" && - integration.QualityProfileID != nil -} - func (s *Service) now() time.Time { if s.Now != nil { return s.Now() @@ -1529,21 +1746,6 @@ func normalizeUserLimit(limit UserLimit) (UserLimit, error) { return limit, nil } -func normalizeIntegrationConnection(integration Integration) (Integration, error) { - integration.Kind = strings.ToLower(strings.TrimSpace(integration.Kind)) - switch integration.Kind { - case "radarr", "sonarr": - default: - return Integration{}, fmt.Errorf("%w: invalid integration kind", ErrInvalidInput) - } - integration.BaseURL = strings.TrimRight(strings.TrimSpace(integration.BaseURL), "/") - integration.APIKeyRef = strings.TrimSpace(integration.APIKeyRef) - if integration.Options == nil { - integration.Options = map[string]any{} - } - return integration, 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 f6be525f..b7550a83 100644 --- a/internal/requests/service_test.go +++ b/internal/requests/service_test.go @@ -157,27 +157,21 @@ func TestCreateRequestAutoApprovalRequiresConfiguredIntegration(t *testing.T) { } } -func TestCreateRequestAutoApprovesWithConfiguredIntegration(t *testing.T) { +// TestCreateRequestAutoApprovalEmptyKeyTreatedAsUnconfigured guards that a router +// connection that is enabled + bound but has no api key (empty after the repo's +// decrypt) reads as "not configured": auto-approval is declined and the request +// stays pending, rather than being auto-approved and then failing submission when +// resolveRouterConnections skips the keyless connection. This pins the empty-key +// check in integrationConfigured against the skip in resolveRouterConnections so +// the two can't drift at the public CreateRequest surface. +func TestCreateRequestAutoApprovalEmptyKeyTreatedAsUnconfigured(t *testing.T) { store := newFakeStore() store.settings.RequestsEnabled = true store.settings.GlobalAutoApprovalEnabled = true - qualityProfileID := 1 - 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", - }} + store.integrations = []Integration{autoApproveRouterInst("router-1", "")} service := newTestService(store) - service.SetFulfillmentAdapters(adapter, nil) + router := &fakeRouterProvider{} + service.SetRouterProvider(router) req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ MediaType: MediaTypeMovie, @@ -187,34 +181,75 @@ func TestCreateRequestAutoApprovesWithConfiguredIntegration(t *testing.T) { if err != nil { t.Fatalf("CreateRequest returned error: %v", err) } - // 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 != StatusPending { + t.Fatalf("status = %q, want pending (empty-key connection is unconfigured)", req.Status) + } + if router.fulfillCalls != 0 { + t.Fatalf("fulfill calls = %d, want 0 (must not submit to a keyless connection)", router.fulfillCalls) + } +} + +func TestCreateRequestAutoApprovesWithConfiguredIntegration(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + store.settings.GlobalAutoApprovalEnabled = true + // A plugin-driven router connection that sets only the generic + // Enabled/CapabilityID/InstallationID fields (no legacy Kind/IsDefault columns) + // must still satisfy the auto-approve gate. + store.integrations = []Integration{routerInst("router-1")} + service := newTestService(store) + service.SetRouterProvider(&fakeRouterProvider{}) + + req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ + MediaType: MediaTypeMovie, + TMDBID: 550, + Title: "Fight Club", + }) + if err != nil { + t.Fatalf("CreateRequest returned error: %v", err) + } + // The configured router connection auto-approves and immediately submits, so the + // request lands in the fulfillment pipeline (one queued target). if req.Status != StatusQueued { t.Fatalf("status = %q, want queued (auto-approved and submitted)", req.Status) } } +func TestCreateRequestAutoApprovalRespectsSupportedMediaTypes(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + store.settings.GlobalAutoApprovalEnabled = true + // A router connection that only serves series must NOT auto-approve a movie + // request; the gate falls back to manual approval (pending). + seriesOnly := routerInst("router-series") + seriesOnly.SupportedMediaTypes = []string{string(MediaTypeSeries)} + store.integrations = []Integration{seriesOnly} + service := newTestService(store) + service.SetRouterProvider(&fakeRouterProvider{}) + + req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ + MediaType: MediaTypeMovie, + TMDBID: 550, + Title: "Fight Club", + }) + if err != nil { + t.Fatalf("CreateRequest returned error: %v", err) + } + if req.Status != StatusPending { + t.Fatalf("status = %q, want pending (no router connection supports movie)", req.Status) + } +} + func TestCreateRequestAutoApprovalSubmitsMovie(t *testing.T) { store := newFakeStore() store.settings.RequestsEnabled = true store.settings.GlobalAutoApprovalEnabled = true - qualityProfileID := 1 - store.integrations = []Integration{{ - Kind: "radarr", - Enabled: true, - IsDefault: true, - BaseURL: "http://radarr.local", - APIKeyRef: "radarr-key", - RootFolder: "/movies", - QualityProfileID: &qualityProfileID, - }} - adapter := &fakeMovieAdapter{result: FulfillmentResult{ - IntegrationKind: "radarr", - ExternalID: "123", - ExternalStatus: "queued", - }} + // The repo decrypts api_key_ref on read, so the connection carries the literal + // key here (no host-side secret resolution). + store.integrations = []Integration{autoApproveRouterInst("router-1", "radarr-key")} + router := &fakeRouterProvider{} service := newTestService(store) - service.SetFulfillmentAdapters(adapter, nil) + service.SetRouterProvider(router) req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ MediaType: MediaTypeMovie, @@ -224,16 +259,15 @@ func TestCreateRequestAutoApprovalSubmitsMovie(t *testing.T) { if err != nil { t.Fatalf("CreateRequest returned error: %v", err) } - if req.Status != StatusQueued || req.IntegrationKind != "radarr" || req.ExternalID != "123" { - t.Fatalf("request = %+v, want queued radarr external id", req) + if req.Status != StatusQueued || req.ExternalID != "ext-1080p" { + t.Fatalf("request = %+v, want queued with router external id", req) } - if adapter.calls != 1 { - t.Fatalf("adapter calls = %d, want 1", adapter.calls) + if router.fulfillCalls != 1 { + t.Fatalf("fulfill calls = %d, want 1", router.fulfillCalls) } - // The repo decrypts api_key_ref on read, so the adapter receives the literal - // key verbatim — no resolver indirection. - if got := adapter.gotIntegration.APIKeyRef; got != "radarr-key" { - t.Fatalf("adapter api key = %q, want radarr-key", got) + // The plaintext credential is resolved before dispatch and handed to the provider. + if len(router.gotConns) != 1 || router.gotConns[0].APIKey != "radarr-key" { + t.Fatalf("router connections = %+v, want resolved api key", router.gotConns) } } @@ -241,19 +275,11 @@ func TestCreateRequestSubmissionFailureMarksFailed(t *testing.T) { store := newFakeStore() store.settings.RequestsEnabled = true store.settings.GlobalAutoApprovalEnabled = true - qualityProfileID := 1 - store.integrations = []Integration{{ - Kind: "radarr", - Enabled: true, - IsDefault: true, - BaseURL: "http://radarr.local", - APIKeyRef: "radarr-key", - RootFolder: "/movies", - QualityProfileID: &qualityProfileID, - }} - adapter := &fakeMovieAdapter{err: errors.New("radarr unavailable")} + store.integrations = []Integration{autoApproveRouterInst("router-1", "radarr-key")} + // A provider that creates no targets (e.g. no radarr instance) returns its own + // message; the host marks the request failed with it. service := newTestService(store) - service.SetFulfillmentAdapters(adapter, nil) + service.SetRouterProvider(&fakeRouterProvider{noTargets: true, fulfillMsg: "radarr unavailable"}) req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ MediaType: MediaTypeMovie, @@ -264,7 +290,7 @@ func TestCreateRequestSubmissionFailureMarksFailed(t *testing.T) { t.Fatalf("CreateRequest returned error: %v", err) } if req.Outcome != OutcomeFailed || req.LastError != "radarr unavailable" { - t.Fatalf("request = %+v, want failed outcome with adapter error", req) + t.Fatalf("request = %+v, want failed outcome with provider message", req) } } @@ -784,43 +810,32 @@ func TestReconcileRequestsCompletesByStoredTVDBID(t *testing.T) { } } -func TestReconcileRequestsMarksDownloadingFromAdapter(t *testing.T) { +func TestReconcileRequestsMarksDownloadingFromProvider(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", - QualityProfileID: &qualityProfileID, - }} + store.integrations = []Integration{routerInst("router-1")} store.candidates = []*Request{{ - ID: "req-1", - MediaType: MediaTypeMovie, - TMDBID: 550, - Status: StatusQueued, - Outcome: OutcomeActive, - ExternalID: "123", + ID: "req-1", + MediaType: MediaTypeMovie, + TMDBID: 550, + Status: StatusQueued, + Outcome: OutcomeActive, }} - // Reconcile now drives status per-target; seed a queued target for the request. + // Reconcile drives status per-target via the provider; seed a queued target. 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", + RequestID: "req-1", IntegrationID: "router-1", Quality: Quality1080p, Status: StatusQueued, ExternalID: "123", }); err != nil { t.Fatalf("seed target: %v", err) } - adapter := &fakeMovieAdapter{status: FulfillmentStatus{ - Status: StatusDownloading, - IntegrationKind: "radarr", - ExternalID: "123", - ExternalStatus: "downloading", - }} + router := &fakeRouterProvider{statuses: []RouterTargetStatus{{ + Quality: Quality1080p, + ConnectionID: "router-1", + Status: StatusDownloading, + ExternalStatus: "downloading", + }}} service := newTestService(store) - service.SetFulfillmentAdapters(adapter, nil) + service.SetRouterProvider(router) result, err := service.ReconcileRequests(context.Background(), 100) if err != nil { @@ -829,8 +844,83 @@ func TestReconcileRequestsMarksDownloadingFromAdapter(t *testing.T) { if result.Downloading != 1 || len(store.statusUpdates) != 1 || store.statusUpdates[0] != StatusDownloading { t.Fatalf("result = %+v statusUpdates = %+v, want one downloading update", result, store.statusUpdates) } - if adapter.statusCalls != 1 { - t.Fatalf("status adapter calls = %d, want 1", adapter.statusCalls) + if router.statusCalls != 1 { + t.Fatalf("provider status calls = %d, want 1", router.statusCalls) + } +} + +func TestReconcileRequestsPreservesProviderFailureMessage(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInst("router-1")} + store.candidates = []*Request{{ + ID: "req-1", + MediaType: MediaTypeMovie, + TMDBID: 550, + Status: StatusQueued, + Outcome: OutcomeActive, + }} + 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: "router-1", + Quality: Quality1080p, Status: StatusQueued, ExternalID: "123", + }); err != nil { + t.Fatalf("seed target: %v", err) + } + router := &fakeRouterProvider{statuses: []RouterTargetStatus{{ + Quality: Quality1080p, + ConnectionID: "router-1", + Status: StatusFailed, + ExternalStatus: "failed", + Message: "indexer rejected the request", + }}} + service := newTestService(store) + service.SetRouterProvider(router) + + result, err := service.ReconcileRequests(context.Background(), 100) + if err != nil { + t.Fatalf("ReconcileRequests returned error: %v", err) + } + if result.Failed != 1 { + t.Fatalf("result = %+v, want one failed target update", result) + } + targets, _ := store.ListTargets(context.Background(), "req-1") + if len(targets) != 1 || targets[0].LastError != "indexer rejected the request" { + t.Fatalf("targets = %+v, want provider failure message preserved", targets) + } +} + +func TestReconcileRequestsResolvesGlobalInputsOncePerCycle(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInst("router-1")} // APIKeyRef "key-router-1" + + // Three approved candidates that all share the same router connection. Each + // one drives submitApprovedRequest, which resolves the router connection. + // Without per-cycle caching this would fetch integrations/settings once per + // request. + for _, id := range []string{"req-1", "req-2", "req-3"} { + req := &Request{ID: id, MediaType: MediaTypeMovie, TMDBID: 550, Status: StatusApproved, Outcome: OutcomeActive} + store.candidates = append(store.candidates, req) + store.requests[id] = req + } + + service := newTestService(store) + service.SetRouterProvider(&fakeRouterProvider{}) + + result, err := service.ReconcileRequests(context.Background(), 100) + if err != nil { + t.Fatalf("ReconcileRequests returned error: %v", err) + } + if result.Checked != 3 || result.Submitted != 3 { + t.Fatalf("result = %+v, want 3 checked / 3 submitted", result) + } + + // Integrations and settings are fetched once per cycle (the connection's key is + // already decrypted by the repo on read, so there is nothing to re-resolve). + if store.listIntegrationsCalls != 1 { + t.Fatalf("ListIntegrations calls = %d, want 1 per cycle", store.listIntegrationsCalls) + } + if store.getSettingsCalls != 1 { + t.Fatalf("GetSettings calls = %d, want 1 per cycle", store.getSettingsCalls) } } @@ -838,7 +928,6 @@ func TestDeleteIntegrationRejectsLiveTargets(t *testing.T) { store := newFakeStore() store.integrations = []Integration{{ ID: "radarr-hd", - Kind: "radarr", Enabled: true, }} store.targets = map[string][]Target{ @@ -864,6 +953,186 @@ func TestDeleteIntegrationRejectsLiveTargets(t *testing.T) { } } +func TestCreateIntegrationRejectedByPluginValidate(t *testing.T) { + store := newFakeStore() + service := newTestService(store) + service.SetRouterProvider(&fakeRouterProvider{ + validateFieldErrors: map[string]string{"root_folder": "root folder does not exist"}, + validateFormError: "connection invalid", + }) + + install := 1 + _, err := service.CreateIntegration(context.Background(), Viewer{UserID: 1, IsAdmin: true}, Integration{ + Name: "radarr", + CapabilityID: "arr", + BaseURL: "http://radarr.local", + InstallationID: &install, + }) + if err == nil { + t.Fatal("expected validation error from plugin Validate") + } + var ve *ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %v, want *ValidationError", err) + } + if ve.FieldErrors["root_folder"] != "root folder does not exist" { + t.Fatalf("field errors = %+v, want root_folder error", ve.FieldErrors) + } + if ve.FormError != "connection invalid" { + t.Fatalf("form error = %q, want connection invalid", ve.FormError) + } + if len(store.integrations) != 0 { + t.Fatalf("integrations = %d, want 0 (rejected before persist)", len(store.integrations)) + } +} + +// TestValidateInstanceRequiresCapabilitySubID locks the capability_id contract: +// the column carries the capability SUB-ID ("arr"/"seerr"), matching the value +// the host passes to pluginhost.Client.RequestRouter -> requireCapability, which +// keys on (type, id). The capability TYPE ("request_router.v1") must NOT be +// accepted or defaulted in, since requireCapability("request_router.v1", +// "request_router.v1") never matches a plugin whose capability id is "arr". +func TestValidateInstanceRequiresCapabilitySubID(t *testing.T) { + install := 1 + if err := validateInstance(&Integration{Name: "radarr", CapabilityID: "arr", InstallationID: &install}); err != nil { + t.Fatalf("validateInstance(sub-id \"arr\") = %v, want nil", err) + } + if err := validateInstance(&Integration{Name: "radarr", CapabilityID: "", InstallationID: &install}); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("validateInstance(empty capability) = %v, want ErrInvalidInput", err) + } +} + +// TestCreateIntegrationPassesCapabilitySubIDToPlugin guards that the sub-id the +// admin selected reaches the router provider verbatim (it used to be rewritten to +// the capability type, which the plugin runtime could never resolve). +func TestCreateIntegrationPassesCapabilitySubIDToPlugin(t *testing.T) { + store := newFakeStore() + router := &fakeRouterProvider{} + service := newTestService(store) + service.SetRouterProvider(router) + + install := 1 + if _, err := service.CreateIntegration(context.Background(), Viewer{UserID: 1, IsAdmin: true}, Integration{ + Name: "radarr", + CapabilityID: "arr", + BaseURL: "http://radarr.local", + APIKeyRef: "key-radarr", + InstallationID: &install, + }); err != nil { + t.Fatalf("CreateIntegration err = %v, want nil", err) + } + if router.gotValidateCapability != "arr" { + t.Fatalf("plugin Validate capability = %q, want \"arr\"", router.gotValidateCapability) + } +} + +// TestUpdateIntegrationRefusesStoredKeyReuseOnChangedBaseURL covers the security +// hardening: when the caller leaves api_key_ref blank ("keep saved key") but +// changes the base_url, the service must refuse rather than pair the stored, +// API-unreadable key with the new (potentially attacker-controlled) URL. The +// plugin Validate is never called and nothing is persisted. +func TestUpdateIntegrationRefusesStoredKeyReuseOnChangedBaseURL(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInst("router-1")} // BaseURL http://router-1.local, APIKeyRef key-router-1 + router := &fakeRouterProvider{} + service := newTestService(store) + service.SetRouterProvider(router) + + install := 1 + _, err := service.UpdateIntegration(context.Background(), Viewer{UserID: 1, IsAdmin: true}, Integration{ + ID: "router-1", + Name: "router-1", + CapabilityID: "arr", + BaseURL: "http://attacker.example", // changed from the stored base URL + APIKeyRef: "", // blank -> "keep saved key" + InstallationID: &install, + }) + var ve *ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %v, want *ValidationError", err) + } + if ve.FieldErrors["api_key_ref"] == "" { + t.Fatalf("field errors = %+v, want api_key_ref message", ve.FieldErrors) + } + if router.validateCalls != 0 { + t.Fatalf("plugin Validate calls = %d, want 0 (refused before dispatch)", router.validateCalls) + } + // Stored row must be unchanged (not persisted with the new URL). + if got := store.integrations[0].BaseURL; got != "http://router-1.local" { + t.Fatalf("stored base_url = %q, want unchanged http://router-1.local", got) + } +} + +// TestUpdateIntegrationKeepsKeyWhenBaseURLUnchanged confirms the normal +// edit-keeping-key flow still works: blank api_key_ref with an unchanged (or +// blank) base_url backfills the stored key, calls the plugin Validate, and +// persists. +func TestUpdateIntegrationKeepsKeyWhenBaseURLUnchanged(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInst("router-1")} + router := &fakeRouterProvider{} + service := newTestService(store) + service.SetRouterProvider(router) + + install := 1 + updated, err := service.UpdateIntegration(context.Background(), Viewer{UserID: 1, IsAdmin: true}, Integration{ + ID: "router-1", + Name: "router-1-renamed", + CapabilityID: "arr", + BaseURL: "http://router-1.local", // unchanged + APIKeyRef: "", // blank -> keep saved key + InstallationID: &install, + }) + if err != nil { + t.Fatalf("UpdateIntegration err = %v, want nil", err) + } + if router.validateCalls != 1 { + t.Fatalf("plugin Validate calls = %d, want 1", router.validateCalls) + } + if updated == nil || updated.Name != "router-1-renamed" { + t.Fatalf("updated = %+v, want persisted name router-1-renamed", updated) + } +} + +func TestLoadIntegrationOptionsDoesNotBackfillStoredKeyForChangedBaseURL(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInst("router-1")} + router := &fakeRouterProvider{} + service := newTestService(store) + service.SetRouterProvider(router) + + if _, err := service.LoadIntegrationOptions(context.Background(), Viewer{UserID: 1, IsAdmin: true}, Integration{ + ID: "router-1", + BaseURL: "http://attacker.example", + }); err != nil { + t.Fatalf("LoadIntegrationOptions: %v", err) + } + if router.gotOptionsConn.APIKey != "" { + t.Fatalf("probe API key = %q, want empty for changed base URL", router.gotOptionsConn.APIKey) + } + if router.gotOptionsConn.BaseURL != "http://attacker.example" { + t.Fatalf("probe base URL = %q, want submitted URL", router.gotOptionsConn.BaseURL) + } +} + +func TestLoadIntegrationOptionsBackfillsStoredKeyForSameBaseURL(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInst("router-1")} + router := &fakeRouterProvider{} + service := newTestService(store) + service.SetRouterProvider(router) + + if _, err := service.LoadIntegrationOptions(context.Background(), Viewer{UserID: 1, IsAdmin: true}, Integration{ + ID: "router-1", + BaseURL: "http://router-1.local", + }); err != nil { + t.Fatalf("LoadIntegrationOptions: %v", err) + } + if router.gotOptionsConn.APIKey != "key-router-1" { + t.Fatalf("probe API key = %q, want stored key for unchanged base URL", router.gotOptionsConn.APIKey) + } +} + func TestCancelOwnerCanWithdrawPendingRequest(t *testing.T) { store := newFakeStore() store.requests["req-mine"] = &Request{ @@ -985,16 +1254,7 @@ func TestDeclineRejectsQueuedRequests(t *testing.T) { func TestRetryResubmitsFailedQueuedRequest(t *testing.T) { store := newFakeStore() - qualityProfileID := 1 - store.integrations = []Integration{{ - Kind: "radarr", - Enabled: true, - IsDefault: true, - BaseURL: "http://radarr.local", - APIKeyRef: "radarr-key", - RootFolder: "/movies", - QualityProfileID: &qualityProfileID, - }} + store.integrations = []Integration{routerInst("router-1")} store.requests["req-1"] = &Request{ ID: "req-1", MediaType: MediaTypeMovie, @@ -1002,26 +1262,21 @@ func TestRetryResubmitsFailedQueuedRequest(t *testing.T) { Status: StatusQueued, Outcome: OutcomeFailed, } - adapter := &fakeMovieAdapter{result: FulfillmentResult{ - IntegrationKind: "radarr", - ExternalID: "99", - ExternalStatus: "queued", - }} + router := &fakeRouterProvider{} service := newTestService(store) - service.SetFulfillmentAdapters(adapter, nil) + service.SetRouterProvider(router) req, err := service.Retry(context.Background(), Viewer{UserID: 1, IsAdmin: true}, "req-1") if err != nil { t.Fatalf("Retry returned error: %v", err) } - if adapter.calls != 1 { - t.Fatalf("adapter calls = %d, want 1", adapter.calls) + if router.fulfillCalls != 1 { + t.Fatalf("fulfill calls = %d, want 1", router.fulfillCalls) } - // 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) + // Retry transitions the failed request back to approved and re-dispatches via + // the router; the request aggregate returns to queued with the new external id. + if req.Status != StatusQueued || req.ExternalID != "ext-1080p" { + t.Fatalf("request = %+v, want re-queued with router external id", req) } } @@ -1053,6 +1308,9 @@ type fakeStore struct { requests map[string]*Request targets map[string][]Target targetSeq int64 + + listIntegrationsCalls int + getSettingsCalls int } func newFakeStore() *fakeStore { @@ -1073,6 +1331,7 @@ func newFakeStore() *fakeStore { func (f *fakeStore) GetSettings(context.Context) (Settings, error) { f.mu.Lock() defer f.mu.Unlock() + f.getSettingsCalls++ return f.settings, nil } @@ -1235,6 +1494,7 @@ func (f *fakeStore) SetOutcome(_ context.Context, id string, outcome Outcome, _ func (f *fakeStore) ListIntegrations(context.Context) ([]Integration, error) { f.mu.Lock() defer f.mu.Unlock() + f.listIntegrationsCalls++ return f.integrations, nil } @@ -1274,17 +1534,6 @@ func (f *fakeStore) UpdateIntegration(_ context.Context, in Integration) (*Integ 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 @@ -1676,66 +1925,744 @@ func (f *fakeTMDBClient) GetMediaDetail(context.Context, string, int) (*tmdb.Med return f.detail, nil } -type fakeMovieAdapter struct { - result FulfillmentResult - status FulfillmentStatus - err error - statusErr error - calls int - statusCalls int - gotReq Request - gotIntegration Integration -} - -func (f *fakeMovieAdapter) SubmitMovie(_ context.Context, req Request, integration Integration) (FulfillmentResult, error) { - f.calls++ - f.gotReq = req - f.gotIntegration = integration - return f.result, f.err -} - -func (f *fakeMovieAdapter) CheckMovieStatus(_ context.Context, req Request, integration Integration) (FulfillmentStatus, error) { - f.statusCalls++ - f.gotReq = req - f.gotIntegration = integration - 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 +// fakeRouterProvider is a canned RequestRouterProvider standing in for a +// request_router.v1 plugin. Fulfill emits one target per requested quality +// (unless noTargets is set), recording the qualities and connections it saw. +type fakeRouterProvider struct { + mu sync.Mutex + + // Fulfill behavior. + noTargets bool + fulfillMsg string + fulfillErr error + targetsOverride []RouterTarget // when non-nil, Fulfill returns this verbatim + gotQualities []Quality + gotConns []ResolvedRouterConnection + gotInstallationID int + fulfillCalls int + + gotRequesterEmail string + gotRequesterUsername string + + // CheckStatus behavior. + statuses []RouterTargetStatus + statusErr error + statusCalls int + + // ListConfigOptions behavior. + options map[string][]RouterOption + gotOptionsConn ResolvedRouterConnection + + // Validate behavior (default empty = valid). + validateFieldErrors map[string]string + validateFormError string + validateErr error + validateCalls int + gotValidateCapability string + gotValidateSiblings []ResolvedRouterConnection } -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 (f *fakeRouterProvider) Fulfill(_ context.Context, installationID int, _ string, req Request, qualities []Quality, conns []ResolvedRouterConnection) ([]RouterTarget, string, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.gotRequesterEmail = req.RequesterEmail + f.gotRequesterUsername = req.RequesterUsername + f.fulfillCalls++ + f.gotQualities = append(f.gotQualities, qualities...) + f.gotConns = conns + f.gotInstallationID = installationID + if f.fulfillErr != nil { + return nil, "", f.fulfillErr + } + if f.targetsOverride != nil { + return f.targetsOverride, f.fulfillMsg, nil + } + if f.noTargets { + return nil, f.fulfillMsg, nil + } + connID := "" + if len(conns) > 0 { + connID = conns[0].ID + } + out := make([]RouterTarget, 0, len(qualities)) + for _, q := range qualities { + out = append(out, RouterTarget{ + Quality: q, + ConnectionID: connID, + ExternalID: "ext-" + string(q), + ExternalStatus: "queued", + Status: StatusQueued, + }) + } + return out, f.fulfillMsg, nil +} + +func (f *fakeRouterProvider) CheckStatus(_ context.Context, _ int, _ string, _ Request, _ []RouterTargetRef, _ []ResolvedRouterConnection) ([]RouterTargetStatus, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.statusCalls++ + return f.statuses, f.statusErr +} + +func (f *fakeRouterProvider) ListConfigOptions(_ context.Context, _ int, _ string, conn ResolvedRouterConnection) (map[string][]RouterOption, error) { + f.mu.Lock() + f.gotOptionsConn = conn + f.mu.Unlock() + return f.options, nil +} + +func (f *fakeRouterProvider) TestConnection(_ context.Context, _ int, _ string, _ ResolvedRouterConnection) (bool, string, error) { + return true, "", nil +} + +func (f *fakeRouterProvider) Validate(_ context.Context, _ int, capabilityID string, _ ResolvedRouterConnection, siblings []ResolvedRouterConnection) (map[string]string, string, error) { + f.mu.Lock() + f.validateCalls++ + f.gotValidateCapability = capabilityID + f.gotValidateSiblings = siblings + f.mu.Unlock() + return f.validateFieldErrors, f.validateFormError, f.validateErr +} + +// routerInst builds an enabled request_router integration connection for tests. +func routerInst(id string) Integration { + return routerInstOn(id, 1) +} + +// routerInstOn builds an enabled request_router connection bound to a specific +// installation id (for multi-installation isolation tests). +func routerInstOn(id string, installID int) Integration { + install := installID + return Integration{ + ID: id, + Name: id, + Enabled: true, + BaseURL: "http://" + id + ".local", + APIKeyRef: "key-" + id, + CapabilityID: "arr", + InstallationID: &install, + } +} + +// autoApproveRouterInst is a router connection that satisfies the auto-approval +// gate (integrationConfigured: an enabled request_router connection bound to an +// installation with a base URL and api key). +func autoApproveRouterInst(id, apiKeyRef string) Integration { + in := routerInst(id) + in.APIKeyRef = apiKeyRef + return in +} + +func TestUpdateIntegrationPassesSiblingsToValidate(t *testing.T) { + store := newFakeStore() + inst := 1 + a := routerInstOn("conn-a", inst) + b := routerInstOn("conn-b", inst) + b.PluginConfig = map[string]any{"service_kind": "radarr"} + store.integrations = []Integration{a, b} + router := &fakeRouterProvider{} + service := newTestService(store) + service.SetRouterProvider(router) + + if _, err := service.UpdateIntegration(context.Background(), Viewer{UserID: 1, IsAdmin: true}, Integration{ + ID: "conn-a", + Name: "conn-a", + CapabilityID: "arr", + BaseURL: "http://conn-a.local", + APIKeyRef: "key-conn-a", + InstallationID: &inst, + }); err != nil { + t.Fatalf("UpdateIntegration: %v", err) + } + if len(router.gotValidateSiblings) != 1 { + t.Fatalf("siblings = %d, want 1 (the other installation-1 connection)", len(router.gotValidateSiblings)) + } + sib := router.gotValidateSiblings[0] + if sib.ID != "conn-b" { + t.Fatalf("sibling id = %q, want conn-b (self excluded)", sib.ID) + } + if sib.APIKey != "" || sib.BaseURL != "" { + t.Fatalf("sibling must carry no credentials, got APIKey=%q BaseURL=%q", sib.APIKey, sib.BaseURL) + } + if sib.Config["service_kind"] != "radarr" { + t.Fatalf("sibling config not passed: %+v", sib.Config) + } +} + +func TestAllowedQualities(t *testing.T) { + svc := newTestService(newFakeStore()) + + t.Run("hd ceiling stays 1080p only", func(t *testing.T) { + svcHD := newTestService(newFakeStore()) + svcHD.SetEntitlementResolver(fixedCeiling{q: "1080p"}) + got := svcHD.allowedQualities(context.Background(), Request{}, Settings{}) + if len(got) != 1 || got[0] != Quality1080p { + t.Fatalf("qualities = %v, want [1080p]", got) + } + }) + + t.Run("any/no-cap ceiling adds 2160p", func(t *testing.T) { + // A requester whose max playback quality is "Any" resolves to an empty + // (no-cap) ceiling. Empty means UNLIMITED, so 4K must be requested + // alongside 1080p — it must not be read as "below 4K". + svcAny := newTestService(newFakeStore()) + svcAny.SetEntitlementResolver(fixedCeiling{q: ""}) + got := svcAny.allowedQualities(context.Background(), Request{}, Settings{}) + if len(got) != 2 || got[1] != Quality2160p { + t.Fatalf("qualities = %v, want [1080p 2160p]", got) + } + }) + + t.Run("force dual adds 2160p", func(t *testing.T) { + got := svc.allowedQualities(context.Background(), Request{}, Settings{ForceDualQuality: true}) + if len(got) != 2 || got[1] != Quality2160p { + t.Fatalf("qualities = %v, want [1080p 2160p]", got) + } + }) + + t.Run("4k ceiling adds 2160p", func(t *testing.T) { + svc4k := newTestService(newFakeStore()) + svc4k.SetEntitlementResolver(fixedCeiling{q: "2160p"}) + got := svc4k.allowedQualities(context.Background(), Request{}, Settings{}) + if len(got) != 2 || got[1] != Quality2160p { + t.Fatalf("qualities = %v, want [1080p 2160p]", got) + } + }) } func TestSubmitApprovedFansOutDualQuality(t *testing.T) { store := newFakeStore() - store.integrations = []Integration{ - inst("radarr", "hd", true, false, false), - inst("radarr", "uhd", false, true, false), - } - rec := &recordingMovieAdapter{} + store.integrations = []Integration{routerInst("router-1")} + router := &fakeRouterProvider{} svc := NewService(store, &fakeTMDBClient{}, &fakePresence{}) - svc.SetFulfillmentAdapters(rec, nil) + svc.SetRouterProvider(router) 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 { + if _, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil); 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) + if len(router.gotQualities) != 2 { + t.Fatalf("expected 2 qualities (hd+uhd), got %d: %v", len(router.gotQualities), router.gotQualities) + } + targets, _ := store.ListTargets(context.Background(), "r1") + if len(targets) != 2 { + t.Fatalf("expected 2 persisted targets, got %d", len(targets)) + } +} + +func TestSubmitApprovedSkipsUnconfiguredOptional4KTarget(t *testing.T) { + store := newFakeStore() + hd := routerInst("router-1") + hd.PluginConfig = map[string]any{ + "service_kind": "radarr", + "is_default": true, + "is_4k": false, + } + store.integrations = []Integration{hd} + router := &fakeRouterProvider{} + svc := newTestService(store) + svc.SetRouterProvider(router) + svc.SetEntitlementResolver(fixedCeiling{q: ""}) + + 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}, nil); err != nil { + t.Fatalf("submit: %v", err) + } + if len(router.gotQualities) != 1 || router.gotQualities[0] != Quality1080p { + t.Fatalf("qualities = %v, want only [1080p] when no 4K default is configured", router.gotQualities) + } + targets, _ := store.ListTargets(context.Background(), "r1") + if len(targets) != 1 || targets[0].Quality != Quality1080p || targets[0].Status == StatusFailed { + t.Fatalf("targets = %+v, want one healthy 1080p target", targets) + } +} + +func TestSubmitApprovedUsesConfiguredOptional4KDefault(t *testing.T) { + store := newFakeStore() + hd := routerInst("router-hd") + hd.PluginConfig = map[string]any{ + "service_kind": "radarr", + "is_default": true, + "is_4k": false, + } + uhd := routerInst("router-uhd") + uhd.PluginConfig = map[string]any{ + "service_kind": "radarr", + "is_4k": true, + "is_default_4k": true, + } + store.integrations = []Integration{hd, uhd} + router := &fakeRouterProvider{} + svc := newTestService(store) + svc.SetRouterProvider(router) + 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}, nil); err != nil { + t.Fatalf("submit: %v", err) + } + if len(router.gotQualities) != 2 || router.gotQualities[0] != Quality1080p || router.gotQualities[1] != Quality2160p { + t.Fatalf("qualities = %v, want [1080p 2160p]", router.gotQualities) + } +} + +func TestSubmitApprovedNoRouterFails(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInst("router-1")} + svc := newTestService(store) // no router provider set + + req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7} + store.requests["r1"] = &req + got, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil) + if err != nil { + t.Fatalf("submit: %v", err) + } + if got.Outcome != OutcomeFailed { + t.Fatalf("outcome = %q, want failed (no router configured)", got.Outcome) + } +} + +func TestSubmitApprovedNoConnectionsFails(t *testing.T) { + store := newFakeStore() // no integrations + svc := newTestService(store) + svc.SetRouterProvider(&fakeRouterProvider{}) + + req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7} + store.requests["r1"] = &req + got, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil) + if err != nil { + t.Fatalf("submit: %v", err) + } + if got.Outcome != OutcomeFailed { + t.Fatalf("outcome = %q, want failed (no enabled router connections)", got.Outcome) + } +} + +func TestSubmitApprovedZeroTargetsUsesProviderMessage(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInst("router-1")} + svc := newTestService(store) + svc.SetRouterProvider(&fakeRouterProvider{noTargets: true, fulfillMsg: "no radarr instance configured for 1080p"}) + + req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7} + store.requests["r1"] = &req + got, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil) + if err != nil { + t.Fatalf("submit: %v", err) + } + if got.Outcome != OutcomeFailed || got.LastError != "no radarr instance configured for 1080p" { + t.Fatalf("request = %+v, want failed with provider message", got) + } +} + +func TestSubmitApprovedIsIdempotentPerQuality(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInst("router-1")} + router := &fakeRouterProvider{} + svc := newTestService(store) + svc.SetRouterProvider(router) + svc.SetEntitlementResolver(fixedCeiling{q: "1080p"}) // HD ceiling -> only 1080p allowed + + req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7} + store.requests["r1"] = &req + // Seed a healthy 1080p target so the re-run should not re-submit that quality. + if _, err := store.CreateTarget(context.Background(), Target{ + RequestID: "r1", IntegrationID: "router-1", Quality: Quality1080p, Status: StatusQueued, ExternalID: "ext-existing", + }); err != nil { + t.Fatalf("seed: %v", err) + } + + if _, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil); err != nil { + t.Fatalf("submit: %v", err) + } + // HD ceiling only -> only 1080p is allowed, and it already has a healthy target, + // so Fulfill is never called. + if router.fulfillCalls != 0 { + t.Fatalf("fulfill calls = %d, want 0 (healthy 1080p target already exists)", router.fulfillCalls) + } +} + +func TestSubmitApprovedRecordsDroppedQualityAsFailed(t *testing.T) { + store := newFakeStore() + store.settings.ForceDualQuality = true // want both 1080p and 2160p + store.integrations = []Integration{routerInst("router-1")} + // Plugin fulfills only 1080p, dropping the wanted 2160p. + router := &fakeRouterProvider{targetsOverride: []RouterTarget{{ + Quality: Quality1080p, ConnectionID: "router-1", ExternalID: "ext-hd", ExternalStatus: "queued", Status: StatusQueued, + }}} + svc := newTestService(store) + svc.SetRouterProvider(router) + + 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}, nil); err != nil { + t.Fatalf("submit: %v", err) + } + + targets, _ := store.ListTargets(context.Background(), "r1") + if len(targets) != 2 { + t.Fatalf("targets = %d, want 2 (1080p queued + 2160p failed)", len(targets)) + } + var failed2160 *Target + for i := range targets { + if targets[i].Quality == Quality2160p { + failed2160 = &targets[i] + } + } + if failed2160 == nil || failed2160.Status != StatusFailed { + t.Fatalf("2160p target = %+v, want a failed target", failed2160) + } + if failed2160.LastError != "fulfillment backend returned no target for this quality" { + t.Fatalf("2160p last error = %q, want the no-target message", failed2160.LastError) + } + + // The failed 2160p target is not "healthy", so a re-run (Retry / reconcile) + // re-attempts only that quality. Provide a normal provider for the re-run. + retryRouter := &fakeRouterProvider{} + svc.SetRouterProvider(retryRouter) + cur := *store.requests["r1"] + cur.Status = StatusApproved + cur.Outcome = OutcomeActive + if _, err := svc.submitApprovedRequest(context.Background(), cur, Viewer{UserID: 7, IsAdmin: true}, nil); err != nil { + t.Fatalf("retry submit: %v", err) + } + if len(retryRouter.gotQualities) != 1 || retryRouter.gotQualities[0] != Quality2160p { + t.Fatalf("retry qualities = %v, want only [2160p] (1080p is healthy)", retryRouter.gotQualities) + } +} + +func TestSubmitApprovedContainsToSingleInstallation(t *testing.T) { + store := newFakeStore() + // Two enabled router connections on DIFFERENT installations. Only the first + // installation's connections may be sent to that plugin. + store.integrations = []Integration{ + routerInstOn("router-a", 1), + routerInstOn("router-b", 2), + } + router := &fakeRouterProvider{} + svc := newTestService(store) + svc.SetRouterProvider(router) + + 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}, nil); err != nil { + t.Fatalf("submit: %v", err) + } + if router.gotInstallationID != 1 { + t.Fatalf("installation id = %d, want 1 (first eligible)", router.gotInstallationID) + } + if len(router.gotConns) != 1 || router.gotConns[0].ID != "router-a" { + t.Fatalf("connections = %+v, want only installation 1's router-a", router.gotConns) + } +} + +// TestSubmitApprovedContainsToChosenCapability guards that when one installation +// exposes connections for more than one request_router capability sub-id, the +// host hands the plugin only the connections for the FIRST chosen capability — +// never a connection belonging to a different capability of the same installation. +func TestSubmitApprovedContainsToChosenCapability(t *testing.T) { + store := newFakeStore() + arrConn := routerInstOn("arr-conn", 1) // CapabilityID "arr" + seerrConn := routerInstOn("seerr-conn", 1) + seerrConn.CapabilityID = "seerr" + store.integrations = []Integration{arrConn, seerrConn} + router := &fakeRouterProvider{} + service := newTestService(store) + service.SetRouterProvider(router) + service.SetEntitlementResolver(fixedCeiling{q: "1080p"}) // single quality, keep it simple + + req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7} + store.requests["r1"] = &req + if _, err := service.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil); err != nil { + t.Fatalf("submit: %v", err) + } + if len(router.gotConns) != 1 { + t.Fatalf("fulfill conns = %d, want 1 (contained to the first chosen capability, not mixed across arr+seerr)", len(router.gotConns)) + } + if router.gotConns[0].ID != "arr-conn" { + t.Fatalf("fulfilled conn = %q, want arr-conn (first chosen)", router.gotConns[0].ID) + } +} + +func TestSubmitApprovedDedupesDuplicateQualityTargets(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInst("router-1")} + // Misbehaving plugin returns two targets for the same quality. + router := &fakeRouterProvider{targetsOverride: []RouterTarget{ + {Quality: Quality1080p, ConnectionID: "router-1", ExternalID: "ext-1", Status: StatusQueued}, + {Quality: Quality1080p, ConnectionID: "router-1", ExternalID: "ext-2", Status: StatusQueued}, + }} + svc := newTestService(store) + svc.SetRouterProvider(router) + // Pin an HD ceiling: this test is about deduping a duplicate quality, not 4K + // entitlement, so keep it to a single requested quality (1080p). + svc.SetEntitlementResolver(fixedCeiling{q: "1080p"}) + + req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7} + store.requests["r1"] = &req + got, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil) + if err != nil { + t.Fatalf("submit returned error: %v", err) + } + if got.Outcome == OutcomeFailed { + t.Fatalf("outcome = failed, want a clean queued aggregate") + } + targets, _ := store.ListTargets(context.Background(), "r1") + if len(targets) != 1 { + t.Fatalf("targets = %d, want 1 (duplicate quality deduped)", len(targets)) + } +} + +func TestSubmitApprovedSkipsMismatchedMediaType(t *testing.T) { + store := newFakeStore() + // Only a series-serving router connection exists; a movie request must not use it. + seriesOnly := routerInst("router-series") + seriesOnly.SupportedMediaTypes = []string{string(MediaTypeSeries)} + store.integrations = []Integration{seriesOnly} + router := &fakeRouterProvider{} + svc := newTestService(store) + svc.SetRouterProvider(router) + + req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7} + store.requests["r1"] = &req + got, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil) + if err != nil { + t.Fatalf("submit: %v", err) + } + if got.Outcome != OutcomeFailed || got.LastError != "no fulfillment backend configured" { + t.Fatalf("request = %+v, want failed with no-backend message", got) + } + if router.fulfillCalls != 0 { + t.Fatalf("fulfill calls = %d, want 0 (series connection filtered out for a movie)", router.fulfillCalls) + } +} + +func TestSubmitApprovedSkipsBadConnectionUsesSibling(t *testing.T) { + store := newFakeStore() + // Two connections on the same installation: one has no api key (unconfigured), + // the other carries a literal key. The healthy sibling must still fulfill the + // request, and the no-key connection must never pin the installation. + bad := routerInstOn("router-bad", 1) + bad.APIKeyRef = "" + good := routerInstOn("router-good", 1) + good.APIKeyRef = "good-key" + store.integrations = []Integration{bad, good} + router := &fakeRouterProvider{} + svc := newTestService(store) + svc.SetRouterProvider(router) + + req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7} + store.requests["r1"] = &req + got, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil) + if err != nil { + t.Fatalf("submit must not abort on a single bad connection: %v", err) + } + if got.Outcome == OutcomeFailed { + t.Fatalf("outcome = failed, want submitted via the healthy sibling") + } + if router.fulfillCalls != 1 { + t.Fatalf("fulfill calls = %d, want 1", router.fulfillCalls) + } + if len(router.gotConns) != 1 || router.gotConns[0].ID != "router-good" || router.gotConns[0].APIKey != "good-key" { + t.Fatalf("router connections = %+v, want only the healthy router-good with resolved key", router.gotConns) + } +} + +func TestSubmitApprovedSkipsConnectionWithEmptyKey(t *testing.T) { + store := newFakeStore() + noKey := routerInstOn("router-nokey", 1) + noKey.APIKeyRef = "" // resolves empty -> must be skipped (never send unauthenticated) + store.integrations = []Integration{noKey} + router := &fakeRouterProvider{} + svc := newTestService(store) + svc.SetRouterProvider(router) + + req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7} + store.requests["r1"] = &req + got, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil) + if err != nil { + t.Fatalf("submit: %v", err) + } + if got.Outcome != OutcomeFailed { + t.Fatalf("outcome = %q, want failed (no usable connection)", got.Outcome) + } + if router.fulfillCalls != 0 { + t.Fatalf("fulfill calls = %d, want 0 (empty-key connection skipped)", router.fulfillCalls) + } +} + +func TestSubmitApprovedSkipsUnknownQuality(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInst("router-1")} + // Plugin returns a bogus quality alongside a valid one. + router := &fakeRouterProvider{targetsOverride: []RouterTarget{ + {Quality: Quality("720p"), ConnectionID: "router-1", ExternalID: "ext-bad", Status: StatusQueued}, + {Quality: Quality1080p, ConnectionID: "router-1", ExternalID: "ext-hd", Status: StatusQueued}, + }} + svc := newTestService(store) + svc.SetRouterProvider(router) + // Pin an HD ceiling: this test is about skipping an unknown quality, not 4K + // entitlement, so keep it to a single requested quality (1080p). + svc.SetEntitlementResolver(fixedCeiling{q: "1080p"}) + + 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}, nil); err != nil { + t.Fatalf("submit: %v", err) + } + targets, _ := store.ListTargets(context.Background(), "r1") + if len(targets) != 1 { + t.Fatalf("targets = %d, want 1 (720p skipped, 1080p persisted)", len(targets)) + } + for _, tg := range targets { + if tg.Quality == Quality("720p") { + t.Fatalf("a 720p target was persisted: %+v", tg) + } + } +} + +func TestSubmitApprovedSkipsUnknownConnectionTarget(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInst("router-1")} + router := &fakeRouterProvider{targetsOverride: []RouterTarget{ + {Quality: Quality1080p, ConnectionID: "missing-router", ExternalID: "ext-hd", Status: StatusQueued}, + }} + svc := newTestService(store) + svc.SetRouterProvider(router) + svc.SetEntitlementResolver(fixedCeiling{q: "1080p"}) + + req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7} + store.requests["r1"] = &req + got, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil) + if err != nil { + t.Fatalf("submit must not abort on an unknown plugin connection: %v", err) + } + if got.Outcome != OutcomeFailed { + t.Fatalf("outcome = %q, want failed missing-quality target", got.Outcome) + } + targets, _ := store.ListTargets(context.Background(), "r1") + if len(targets) != 1 || targets[0].IntegrationID != "" || targets[0].Status != StatusFailed { + t.Fatalf("targets = %+v, want one failed target without unknown integration id", targets) + } +} + +func TestSubmitApprovedCoercesUnknownStatusToQueued(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInst("router-1")} + router := &fakeRouterProvider{targetsOverride: []RouterTarget{ + {Quality: Quality1080p, ConnectionID: "router-1", ExternalID: "ext-hd", Status: Status("bogus")}, + }} + svc := newTestService(store) + svc.SetRouterProvider(router) + // Pin an HD ceiling: this test is about status coercion, not 4K entitlement, + // so keep it to a single requested quality (1080p). + svc.SetEntitlementResolver(fixedCeiling{q: "1080p"}) + + 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}, nil); err != nil { + t.Fatalf("submit: %v", err) + } + targets, _ := store.ListTargets(context.Background(), "r1") + if len(targets) != 1 || targets[0].Status != StatusQueued { + t.Fatalf("targets = %+v, want one StatusQueued target (unknown status coerced)", targets) + } +} + +func TestSubmitApprovedSkipsTargetForHealthyQuality(t *testing.T) { + store := newFakeStore() + store.settings.ForceDualQuality = true // want 1080p + 2160p + store.integrations = []Integration{routerInst("router-1")} + // Seed a healthy 1080p target; the plugin (misbehaving) returns one anyway. + if _, err := store.CreateTarget(context.Background(), Target{ + RequestID: "r1", IntegrationID: "router-1", Quality: Quality1080p, Status: StatusQueued, ExternalID: "ext-existing", + }); err != nil { + t.Fatalf("seed: %v", err) + } + router := &fakeRouterProvider{targetsOverride: []RouterTarget{ + {Quality: Quality1080p, ConnectionID: "router-1", ExternalID: "ext-dupe", Status: StatusQueued}, + {Quality: Quality2160p, ConnectionID: "router-1", ExternalID: "ext-uhd", Status: StatusQueued}, + }} + svc := newTestService(store) + svc.SetRouterProvider(router) + + 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}, nil); err != nil { + t.Fatalf("submit must not error on a duplicate of a healthy quality: %v", err) + } + targets, _ := store.ListTargets(context.Background(), "r1") + if len(targets) != 2 { + t.Fatalf("targets = %d, want 2 (existing 1080p kept + new 2160p; dup 1080p skipped)", len(targets)) + } + count1080 := 0 + for _, tg := range targets { + if tg.Quality == Quality1080p { + count1080++ + } + } + if count1080 != 1 { + t.Fatalf("1080p targets = %d, want 1 (no duplicate persisted)", count1080) + } +} + +func TestSubmitApprovedUnboundInstallationFailsWithGuidance(t *testing.T) { + store := newFakeStore() + // A router connection that exists but is not bound to a plugin installation + // (the migration leaves installation_id NULL for pre-existing rows). + unbound := routerInst("router-unbound") + unbound.InstallationID = nil + store.integrations = []Integration{unbound} + svc := newTestService(store) + svc.SetRouterProvider(&fakeRouterProvider{}) + + req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7} + store.requests["r1"] = &req + got, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil) + if err != nil { + t.Fatalf("submit: %v", err) + } + if got.Outcome != OutcomeFailed || + got.LastError != "request backend connection is not bound to a plugin installation; re-save it in admin" { + t.Fatalf("request = %+v, want failed with unbound-installation guidance", got) + } +} + +type fakeRequesterIdentity struct { + email, username string + err error + gotUserID int +} + +func (f *fakeRequesterIdentity) ResolveRequester(_ context.Context, userID int) (string, string, error) { + f.gotUserID = userID + return f.email, f.username, f.err +} + +func TestSubmitApprovedPopulatesRequesterIdentity(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInstOn("router-1", 1)} + router := &fakeRouterProvider{} + service := newTestService(store) + service.SetRouterProvider(router) + service.SetRequesterIdentityResolver(&fakeRequesterIdentity{email: "u@example.com", username: "bob"}) + + req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7} + store.requests["r1"] = &req + if _, err := service.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}, nil); err != nil { + t.Fatalf("submit: %v", err) + } + if router.gotRequesterEmail != "u@example.com" || router.gotRequesterUsername != "bob" { + t.Fatalf("descriptor identity = %q/%q, want u@example.com/bob", router.gotRequesterEmail, router.gotRequesterUsername) } } diff --git a/internal/requests/sonarr/client.go b/internal/requests/sonarr/client.go deleted file mode 100644 index af5611b8..00000000 --- a/internal/requests/sonarr/client.go +++ /dev/null @@ -1,183 +0,0 @@ -package sonarr - -import ( - "context" - "fmt" - "net/http" - "net/url" - "strconv" - - mediarequests "github.com/Silo-Server/silo-server/internal/requests" - "github.com/Silo-Server/silo-server/internal/requests/arrclient" -) - -type Client struct { - httpClient *http.Client -} - -type seriesResource struct { - ID int `json:"id,omitempty"` - Title string `json:"title,omitempty"` - TVDBID int `json:"tvdbId,omitempty"` - TMDBID int `json:"tmdbId,omitempty"` - TitleSlug string `json:"titleSlug,omitempty"` - QualityProfileID int `json:"qualityProfileId,omitempty"` - RootFolderPath string `json:"rootFolderPath,omitempty"` - SeasonFolder bool `json:"seasonFolder"` - Monitored bool `json:"monitored"` - SeriesType string `json:"seriesType,omitempty"` - Tags []int `json:"tags,omitempty"` - AddOptions addSeriesOptions `json:"addOptions,omitempty"` -} - -type addSeriesOptions struct { - Monitor string `json:"monitor,omitempty"` - SearchForMissingEpisodes bool `json:"searchForMissingEpisodes"` - SearchForCutoffUnmetEpisodes bool `json:"searchForCutoffUnmetEpisodes,omitempty"` -} - -func NewClient(httpClient *http.Client) *Client { - return &Client{httpClient: httpClient} -} - -func (c *Client) ListSeriesIntegrationOptions(ctx context.Context, integration mediarequests.Integration) (*mediarequests.IntegrationOptions, error) { - client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient) - rootFolders, err := arrclient.ListRootFolders(ctx, client) - if err != nil { - return nil, err - } - qualityProfiles, err := arrclient.ListQualityProfiles(ctx, client) - if err != nil { - return nil, err - } - tags, err := arrclient.ListTags(ctx, client) - if err != nil { - return nil, err - } - return &mediarequests.IntegrationOptions{ - Kind: "sonarr", - RootFolders: rootFolders, - QualityProfiles: qualityProfiles, - Tags: tags, - }, nil -} - -func (c *Client) SubmitSeries(ctx context.Context, req mediarequests.Request, integration mediarequests.Integration) (mediarequests.FulfillmentResult, error) { - if req.MediaType != mediarequests.MediaTypeSeries { - return mediarequests.FulfillmentResult{}, fmt.Errorf("sonarr: request is not a series") - } - if integration.QualityProfileID == nil { - return mediarequests.FulfillmentResult{}, fmt.Errorf("sonarr: quality profile is required") - } - if req.TVDBID == nil || *req.TVDBID <= 0 { - return mediarequests.FulfillmentResult{}, fmt.Errorf("sonarr: tvdb_id is required") - } - - client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient) - series, err := c.lookupSeries(ctx, client, *req.TVDBID) - if err != nil { - return mediarequests.FulfillmentResult{}, err - } - series.RootFolderPath = integration.RootFolder - series.QualityProfileID = *integration.QualityProfileID - series.SeasonFolder = arrclient.BoolOption(integration.Options, "season_folder", true) - series.Monitored = arrclient.BoolOption(integration.Options, "monitored", true) - series.SeriesType = arrclient.StringOption(integration.Options, "series_type", "standard") - series.Tags = integration.Tags - series.AddOptions = addSeriesOptions{ - Monitor: arrclient.StringOption(integration.Options, "monitor", "all"), - SearchForMissingEpisodes: arrclient.BoolOption( - integration.Options, - "search_for_missing_episodes", - arrclient.BoolOption(integration.Options, "search_on_add", true), - ), - SearchForCutoffUnmetEpisodes: arrclient.BoolOption(integration.Options, "search_for_cutoff_unmet", false), - } - - var created seriesResource - if err := client.PostJSON(ctx, "/api/v3/series", series, &created); err != nil { - if !arrclient.IsEmptyOrTruncatedDecodeError(err) { - return mediarequests.FulfillmentResult{}, err - } - // POST accepted but Sonarr returned an empty body. Recover the - // new series' Sonarr ID by listing series filtered by TVDB ID; - // without the ID the reconcile loop cannot advance the request. - if found, lookErr := c.findSeriesByTVDBID(ctx, client, *req.TVDBID); lookErr == nil && found.ID > 0 { - return resultFromSeries(found), nil - } - return arrclient.AcceptedWithoutResponse("sonarr"), nil - } - return resultFromSeries(created), nil -} - -func (c *Client) findSeriesByTVDBID(ctx context.Context, client *arrclient.Client, tvdbID int) (seriesResource, error) { - values := url.Values{} - values.Set("tvdbId", strconv.Itoa(tvdbID)) - var matches []seriesResource - if err := client.GetJSON(ctx, "/api/v3/series?"+values.Encode(), &matches); err != nil { - return seriesResource{}, err - } - for _, s := range matches { - if s.ID > 0 && s.TVDBID == tvdbID { - return s, nil - } - } - return seriesResource{}, fmt.Errorf("sonarr: series not found after add for tvdb_id %d", tvdbID) -} - -func (c *Client) CheckSeriesStatus(ctx context.Context, req mediarequests.Request, integration mediarequests.Integration) (mediarequests.FulfillmentStatus, error) { - client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient) - seriesID, _ := strconv.Atoi(req.ExternalID) - if seriesID <= 0 { - return mediarequests.FulfillmentStatus{ - Status: mediarequests.StatusQueued, - IntegrationKind: "sonarr", - ExternalStatus: "external_id_unavailable", - }, nil - } - - queues, err := c.queueDetails(ctx, client, seriesID) - if err != nil { - return mediarequests.FulfillmentStatus{}, err - } - evaluation := arrclient.EvaluateQueue(queues) - return arrclient.StatusFromQueueEvaluation("sonarr", seriesID, evaluation), nil -} - -func (c *Client) lookupSeries(ctx context.Context, client *arrclient.Client, tvdbID int) (seriesResource, error) { - values := url.Values{} - values.Set("term", "tvdb:"+strconv.Itoa(tvdbID)) - var matches []seriesResource - if err := client.GetJSON(ctx, "/api/v3/series/lookup?"+values.Encode(), &matches); err != nil { - return seriesResource{}, err - } - for _, match := range matches { - if match.TVDBID == tvdbID { - return match, nil - } - } - return seriesResource{}, fmt.Errorf("sonarr: no series found for tvdb_id %d", tvdbID) -} - -func (c *Client) queueDetails(ctx context.Context, client *arrclient.Client, seriesID int) ([]arrclient.QueueResource, error) { - values := url.Values{} - values.Set("seriesId", strconv.Itoa(seriesID)) - var queues []arrclient.QueueResource - if err := client.GetJSON(ctx, "/api/v3/queue/details?"+values.Encode(), &queues); err != nil { - return nil, err - } - return queues, nil -} - -func resultFromSeries(series seriesResource) mediarequests.FulfillmentResult { - externalID := "" - if series.ID > 0 { - externalID = strconv.Itoa(series.ID) - } - return mediarequests.FulfillmentResult{ - IntegrationKind: "sonarr", - ExternalID: externalID, - ExternalStatus: "queued", - } -} - diff --git a/internal/requests/sonarr/client_test.go b/internal/requests/sonarr/client_test.go deleted file mode 100644 index 07ac24ad..00000000 --- a/internal/requests/sonarr/client_test.go +++ /dev/null @@ -1,228 +0,0 @@ -package sonarr - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - mediarequests "github.com/Silo-Server/silo-server/internal/requests" -) - -func TestSubmitSeriesAddsLookupResult(t *testing.T) { - qualityProfileID := 3 - tvdbID := 121361 - var posted seriesResource - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("X-Api-Key"); got != "sonarr-key" { - t.Fatalf("X-Api-Key = %q, want sonarr-key", got) - } - switch r.URL.Path { - case "/api/v3/series": - if r.Method == http.MethodGet { - if got := r.URL.Query().Get("tvdbId"); got != "121361" { - t.Fatalf("tvdbId = %q, want 121361", got) - } - w.Write([]byte(`[]`)) - return - } - if r.Method == http.MethodPost { - if err := json.NewDecoder(r.Body).Decode(&posted); err != nil { - t.Fatalf("decode posted series: %v", err) - } - w.Write([]byte(`{"id":24,"tvdbId":121361}`)) - return - } - case "/api/v3/series/lookup": - if got := r.URL.Query().Get("term"); got != "tvdb:121361" { - t.Fatalf("lookup term = %q, want tvdb:121361", got) - } - w.Write([]byte(`[{"title":"Game of Thrones","tvdbId":121361,"titleSlug":"game-of-thrones"}]`)) - return - } - t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) - })) - defer server.Close() - - client := NewClient(server.Client()) - result, err := client.SubmitSeries(context.Background(), mediarequests.Request{ - MediaType: mediarequests.MediaTypeSeries, - TMDBID: 1399, - TVDBID: &tvdbID, - Title: "Game of Thrones", - }, mediarequests.Integration{ - Kind: "sonarr", - BaseURL: server.URL, - APIKeyRef: "sonarr-key", - RootFolder: "/series", - QualityProfileID: &qualityProfileID, - Options: map[string]any{ - "monitor": "future", - }, - }) - if err != nil { - t.Fatalf("SubmitSeries returned error: %v", err) - } - if result.ExternalID != "24" || result.IntegrationKind != "sonarr" { - t.Fatalf("result = %+v, want sonarr external id 24", result) - } - if posted.RootFolderPath != "/series" || posted.QualityProfileID != qualityProfileID { - t.Fatalf("posted series = %+v, missing root folder/quality profile", posted) - } - if posted.AddOptions.Monitor != "future" { - t.Fatalf("monitor = %q, want future", posted.AddOptions.Monitor) - } -} - -func TestSubmitSeriesRecoversFromEmptyAddResponse(t *testing.T) { - qualityProfileID := 3 - tvdbID := 121361 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v3/series/lookup": - w.Write([]byte(`[{"title":"Game of Thrones","tvdbId":121361,"titleSlug":"game-of-thrones"}]`)) - case "/api/v3/series": - if r.Method == http.MethodPost { - w.WriteHeader(http.StatusCreated) - return - } - if r.Method == http.MethodGet { - if got := r.URL.Query().Get("tvdbId"); got != "121361" { - t.Fatalf("tvdbId = %q, want 121361", got) - } - w.Write([]byte(`[{"id":77,"tvdbId":121361,"title":"Game of Thrones"}]`)) - return - } - default: - t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) - } - })) - defer server.Close() - - client := NewClient(server.Client()) - result, err := client.SubmitSeries(context.Background(), mediarequests.Request{ - MediaType: mediarequests.MediaTypeSeries, - TMDBID: 1399, - TVDBID: &tvdbID, - Title: "Game of Thrones", - }, mediarequests.Integration{ - Kind: "sonarr", - BaseURL: server.URL, - APIKeyRef: "sonarr-key", - RootFolder: "/series", - QualityProfileID: &qualityProfileID, - }) - if err != nil { - t.Fatalf("SubmitSeries returned error: %v", err) - } - if result.ExternalID != "77" { - t.Fatalf("ExternalID = %q, want 77 (recovered after empty 201)", result.ExternalID) - } - if result.ExternalStatus != "queued" { - t.Fatalf("ExternalStatus = %q, want queued", result.ExternalStatus) - } -} - -func TestSubmitSeriesRejectsNonExactTVDBLookupMatch(t *testing.T) { - qualityProfileID := 3 - tvdbID := 121361 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v3/series/lookup" { - t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) - } - w.Write([]byte(`[{"title":"Wrong Show","tvdbId":999,"titleSlug":"wrong-show"}]`)) - })) - defer server.Close() - - client := NewClient(server.Client()) - _, err := client.SubmitSeries(context.Background(), mediarequests.Request{ - MediaType: mediarequests.MediaTypeSeries, - TMDBID: 1399, - TVDBID: &tvdbID, - Title: "Game of Thrones", - }, mediarequests.Integration{ - Kind: "sonarr", - BaseURL: server.URL, - APIKeyRef: "sonarr-key", - RootFolder: "/series", - QualityProfileID: &qualityProfileID, - }) - if err == nil { - t.Fatal("expected error for non-exact TVDB lookup match") - } -} - -func TestCheckSeriesStatusReadsQueueDetails(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("X-Api-Key"); got != "sonarr-key" { - t.Fatalf("X-Api-Key = %q, want sonarr-key", got) - } - if r.URL.Path != "/api/v3/queue/details" { - t.Fatalf("unexpected path %s", r.URL.Path) - } - if got := r.URL.Query().Get("seriesId"); got != "24" { - t.Fatalf("seriesId = %q, want 24", got) - } - w.Write([]byte(`[{"seriesId":24,"status":"downloading","trackedDownloadState":"importing"}]`)) - })) - defer server.Close() - - client := NewClient(server.Client()) - status, err := client.CheckSeriesStatus(context.Background(), mediarequests.Request{ - MediaType: mediarequests.MediaTypeSeries, - TMDBID: 1399, - ExternalID: "24", - }, mediarequests.Integration{ - Kind: "sonarr", - BaseURL: server.URL, - APIKeyRef: "sonarr-key", - }) - if err != nil { - t.Fatalf("CheckSeriesStatus returned error: %v", err) - } - if status.Status != mediarequests.StatusDownloading || status.ExternalStatus != "downloading/importing" { - t.Fatalf("status = %+v, want downloading", status) - } -} - -func TestListSeriesIntegrationOptionsLoadsChoices(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("X-Api-Key"); got != "sonarr-key" { - t.Fatalf("X-Api-Key = %q, want sonarr-key", got) - } - switch r.URL.Path { - case "/api/v3/rootfolder": - w.Write([]byte(`[{"path":"/series","freeSpace":123,"totalSpace":456,"accessible":true}]`)) - case "/api/v3/qualityprofile": - w.Write([]byte(`[{"id":3,"name":"HD-1080p"}]`)) - case "/api/v3/tag": - w.Write([]byte(`[{"id":4,"label":"requests"}]`)) - default: - t.Fatalf("unexpected path %s", r.URL.Path) - } - })) - defer server.Close() - - client := NewClient(server.Client()) - options, err := client.ListSeriesIntegrationOptions(context.Background(), mediarequests.Integration{ - Kind: "sonarr", - BaseURL: server.URL, - APIKeyRef: "sonarr-key", - }) - if err != nil { - t.Fatalf("ListSeriesIntegrationOptions returned error: %v", err) - } - if options.Kind != "sonarr" { - t.Fatalf("kind = %q, want sonarr", options.Kind) - } - if len(options.RootFolders) != 1 || options.RootFolders[0].Path != "/series" { - t.Fatalf("root folders = %+v, want /series", options.RootFolders) - } - if len(options.QualityProfiles) != 1 || options.QualityProfiles[0].ID != 3 { - t.Fatalf("quality profiles = %+v, want id 3", options.QualityProfiles) - } - if len(options.Tags) != 1 || options.Tags[0].ID != 4 { - t.Fatalf("tags = %+v, want id 4", options.Tags) - } -} diff --git a/internal/requests/types.go b/internal/requests/types.go index 6e0d6676..e032a72d 100644 --- a/internal/requests/types.go +++ b/internal/requests/types.go @@ -139,6 +139,8 @@ type Request struct { Outcome Outcome `json:"outcome"` RequestedByUserID int `json:"requested_by_user_id,omitempty"` RequestedByProfileID string `json:"requested_by_profile_id,omitempty"` + RequesterEmail string `json:"-"` + RequesterUsername string `json:"-"` IntegrationKind string `json:"integration_kind,omitempty"` IsAnime bool `json:"is_anime"` Targets []Target `json:"targets,omitempty"` @@ -254,51 +256,19 @@ type ListFilter struct { } type Integration struct { - 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 { - Path string `json:"path"` - FreeSpace int64 `json:"free_space,omitempty"` - TotalSpace int64 `json:"total_space,omitempty"` - Accessible bool `json:"accessible"` -} - -type IntegrationQualityProfile struct { - ID int `json:"id"` - Name string `json:"name"` -} - -type IntegrationTag struct { - ID int `json:"id"` - Label string `json:"label"` -} - -type IntegrationOptions struct { - Kind string `json:"kind"` - RootFolders []IntegrationRootFolder `json:"root_folders"` - QualityProfiles []IntegrationQualityProfile `json:"quality_profiles"` - Tags []IntegrationTag `json:"tags"` + ID string `json:"id"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + BaseURL string `json:"base_url"` + APIKeyRef string `json:"api_key_ref,omitempty"` + 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"` + CapabilityID string `json:"capability_id"` + InstallationID *int `json:"installation_id,omitempty"` + SupportedMediaTypes []string `json:"supported_media_types"` + PluginConfig map[string]any `json:"plugin_config"` } type FulfillmentResult struct { diff --git a/migrations/sql/20260607131954_request_router_connections.sql b/migrations/sql/20260607131954_request_router_connections.sql new file mode 100644 index 00000000..b3977a79 --- /dev/null +++ b/migrations/sql/20260607131954_request_router_connections.sql @@ -0,0 +1,41 @@ +-- +goose Up +-- +goose StatementBegin +-- NOTE: existing rows get installation_id = NULL because the plugin install id is +-- not known at migration time. Such connections are treated as "not bound to a +-- plugin installation" and are skipped during fulfillment until an admin re-saves +-- the connection to bind it to an installed request_router plugin. +ALTER TABLE public.request_integrations + ADD COLUMN IF NOT EXISTS capability_id text NOT NULL DEFAULT 'request_router.v1', + ADD COLUMN IF NOT EXISTS installation_id integer, + ADD COLUMN IF NOT EXISTS supported_media_types text[] NOT NULL DEFAULT '{}', + ADD COLUMN IF NOT EXISTS plugin_config jsonb NOT NULL DEFAULT '{}'::jsonb; + +-- Fold the arr-specific typed columns into the generic plugin_config blob. +-- service_kind comes from the old `kind`; supported_media_types is derived from it. +UPDATE public.request_integrations +SET plugin_config = jsonb_strip_nulls( + coalesce(options, '{}'::jsonb) || jsonb_build_object( + 'service_kind', kind, + 'root_folder', root_folder, + 'quality_profile_id', quality_profile_id, + 'tags', to_jsonb(tags), + 'is_4k', is_4k, + 'is_default', is_default, + 'is_default_4k', is_default_4k, + 'anime_enabled', anime_enabled, + 'anime_quality_profile_id', anime_quality_profile_id, + 'anime_root_folder', anime_root_folder, + 'anime_tags', to_jsonb(anime_tags) + )), + supported_media_types = CASE WHEN kind = 'sonarr' THEN ARRAY['series'] ELSE ARRAY['movie'] END +WHERE plugin_config = '{}'::jsonb; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE public.request_integrations + DROP COLUMN IF EXISTS capability_id, + DROP COLUMN IF EXISTS installation_id, + DROP COLUMN IF EXISTS supported_media_types, + DROP COLUMN IF EXISTS plugin_config; +-- +goose StatementEnd diff --git a/migrations/sql/20260607211831_drop_legacy_request_integration_columns.sql b/migrations/sql/20260607211831_drop_legacy_request_integration_columns.sql new file mode 100644 index 00000000..423d4255 --- /dev/null +++ b/migrations/sql/20260607211831_drop_legacy_request_integration_columns.sql @@ -0,0 +1,46 @@ +-- +goose Up +-- +goose StatementBegin +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 IF EXISTS request_integrations_kind_check, + DROP COLUMN IF EXISTS kind, + DROP COLUMN IF EXISTS root_folder, + DROP COLUMN IF EXISTS quality_profile_id, + DROP COLUMN IF EXISTS tags, + 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, + DROP COLUMN IF EXISTS options; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE public.request_integrations + ADD COLUMN IF NOT EXISTS kind text NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS root_folder text NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS quality_profile_id integer, + ADD COLUMN IF NOT EXISTS tags integer[] 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 '{}', + ADD COLUMN IF NOT EXISTS options jsonb NOT NULL DEFAULT '{}'::jsonb; +-- Best-effort backfill from plugin_config (the source of truth). +UPDATE public.request_integrations SET + kind = COALESCE(plugin_config->>'service_kind', ''), + root_folder = COALESCE(plugin_config->>'root_folder', ''), + quality_profile_id = NULLIF(plugin_config->>'quality_profile_id','')::integer, + is_4k = COALESCE((plugin_config->>'is_4k')::boolean, false), + is_default = COALESCE((plugin_config->>'is_default')::boolean, false), + is_default_4k = COALESCE((plugin_config->>'is_default_4k')::boolean, false), + anime_enabled = COALESCE((plugin_config->>'anime_enabled')::boolean, false), + anime_root_folder = COALESCE(plugin_config->>'anime_root_folder', ''); +-- +goose StatementEnd diff --git a/migrations/sql/20260608131649_backfill_request_integration_capability_subid.sql b/migrations/sql/20260608131649_backfill_request_integration_capability_subid.sql new file mode 100644 index 00000000..df33f491 --- /dev/null +++ b/migrations/sql/20260608131649_backfill_request_integration_capability_subid.sql @@ -0,0 +1,51 @@ +-- +goose Up +-- +goose StatementBegin +-- request_integrations.capability_id must carry the capability SUB-ID +-- ("arr"/"seerr"), matching the value the host passes to +-- requireCapability("request_router.v1", id) when dispensing the plugin. Rows +-- created before the fix stored the capability TYPE "request_router.v1", which +-- resolves to no plugin capability (every save/options/fulfill call 500s with +-- "no fulfillment backend configured" / ErrCapabilityNotFound). +-- +-- Backfill the sub-id from each row's bound installation's request_router.v1 +-- capability. Rows with installation_id NULL (created under the pre-plugin +-- direct-to-arr system and never re-bound) are left untouched — an admin +-- re-saves them in Admin -> Requests to bind an installation and set the sub-id. +-- +-- A deterministic correlated subquery (ORDER BY ... LIMIT 1) is used instead of a +-- join so that, in the unusual case where one installation exposes more than one +-- request_router.v1 capability, the same sub-id is chosen for every row rather +-- than letting Postgres pick arbitrarily from the join cross-product. +UPDATE public.request_integrations ri +SET capability_id = ( + SELECT pc.capability_id + FROM public.plugin_capabilities pc + WHERE pc.plugin_installation_id = ri.installation_id + AND pc.capability_type = 'request_router.v1' + ORDER BY pc.capability_id + LIMIT 1 +) +WHERE ri.capability_id = 'request_router.v1' + AND ri.installation_id IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM public.plugin_capabilities pc + WHERE pc.plugin_installation_id = ri.installation_id + AND pc.capability_type = 'request_router.v1' + ); + +-- Drop the misleading column default (the capability type). The application now +-- always supplies the sub-id on insert; the default would silently reintroduce +-- the unresolvable type for any row that omitted capability_id. +ALTER TABLE public.request_integrations + ALTER COLUMN capability_id DROP DEFAULT; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +-- Restore the prior column default. The data backfill is intentionally not +-- reverted: the sub-id is the correct value, and rewriting it back to the +-- capability type would re-break fulfillment. +ALTER TABLE public.request_integrations + ALTER COLUMN capability_id SET DEFAULT 'request_router.v1'; +-- +goose StatementEnd diff --git a/web/src/api/client.ts b/web/src/api/client.ts index bed9d604..c227efb0 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -202,6 +202,14 @@ async function refreshAccessToken( } export class ApiClientError extends Error { + /** + * Raw parsed JSON body of the error response, when the body parsed as JSON. + * Carries fields the normalized `details: ApiError` does not surface (e.g. + * plugin validation `field_errors` / `form_error` on a 400). Undefined for + * non-JSON or empty bodies. + */ + public body?: unknown; + constructor( public status: number, public code: string, @@ -257,14 +265,32 @@ function hasHeader(headers: Record, name: string): boolean { return Object.keys(headers).some((key) => key.toLowerCase() === target); } -async function parseApiError(res: Response): Promise { +interface ParsedApiError { + /** Normalized error with guaranteed `error`/`message` fields. */ + apiErr: ApiError; + /** Raw parsed JSON body, or undefined when the body wasn't JSON/empty. */ + raw?: unknown; +} + +async function parseApiError(res: Response): Promise { let apiErr: Partial = {}; + let raw: unknown; try { - apiErr = await res.json(); + raw = await res.json(); + if (raw && typeof raw === "object") { + apiErr = raw as Partial; + } } catch { // response wasn't JSON } - return normalizeApiError(apiErr, res); + return { apiErr: normalizeApiError(apiErr, res), raw }; +} + +/** Builds an ApiClientError from a parsed error response, attaching the raw body. */ +function apiClientErrorFrom(status: number, parsed: ParsedApiError): ApiClientError { + const err = new ApiClientError(status, parsed.apiErr.error, parsed.apiErr.message, parsed.apiErr); + err.body = parsed.raw; + return err; } export interface RestoredUserSession { @@ -304,8 +330,7 @@ export async function restoreUserSession({ } if (!res.ok) { - const apiErr = await parseApiError(res); - throw new ApiClientError(res.status, apiErr.error, apiErr.message, apiErr); + throw apiClientErrorFrom(res.status, await parseApiError(res)); } return { @@ -335,12 +360,12 @@ export async function api(path: string, options: RequestInit = {}): Promise; + // Two-tier plugin-driven connection model. Generic fields above are owned by + // the host; the arr-specific config now lives in plugin_config. Optional for + // backward-compatible reads of legacy rows that predate the refactor. + capability_id?: string; + installation_id?: number; + supported_media_types?: string[]; + plugin_config?: Record; last_check_at?: string | null; last_check_status?: string; last_check_error?: string; updated_at?: string; } -export interface RequestIntegrationRootFolder { - path: string; - free_space?: number; - total_space?: number; - accessible: boolean; -} +export type RequestIntegrationOptions = Record; -export interface RequestIntegrationQualityProfile { - id: number; - name: string; -} - -export interface RequestIntegrationTag { - id: number; - label: string; -} - -export interface RequestIntegrationOptions { - kind: string; - root_folders: RequestIntegrationRootFolder[]; - quality_profiles: RequestIntegrationQualityProfile[]; - tags: RequestIntegrationTag[]; +export interface RequestIntegrationValidationError { + error: "validation_failed"; + field_errors?: Record; + form_error?: string; } export interface LoadRequestIntegrationOptionsRequest { - kind: "radarr" | "sonarr"; + // Vestigial: the backend resolves the plugin via installation_id + + // plugin_config and ignores this. Optional and no longer sent by the client. + kind?: "radarr" | "sonarr"; base_url: string; api_key_ref?: string; + // For unsaved connections the host cannot backfill from a stored row, so the + // body must carry enough to resolve the plugin (installation + capability + + // arr-specific config) when testing the connection. + capability_id?: string; + installation_id?: number; + plugin_config?: Record; } export interface RequestIntegrationsResponse { @@ -2687,6 +2675,7 @@ export interface AdminSettingsConnectionCheckRequest { export interface PluginAdminForm { fields: PluginAdminFormField[]; submit_label?: string; + sections?: PluginAdminFormSection[]; } export interface PluginAdminFormFieldOption { @@ -2695,11 +2684,36 @@ export interface PluginAdminFormFieldOption { description?: string; } +export interface PluginAdminFormCondition { + field: string; + equals: string[]; +} + +export interface PluginAdminFormValidation { + has_min?: boolean; + min?: number; + has_max?: boolean; + max?: number; + pattern?: string; + min_length?: number; + max_length?: number; +} + +export interface PluginAdminFormSection { + key: string; + title: string; + description?: string; + collapsible: boolean; + collapsed_default: boolean; + field_keys: string[]; + show_when?: PluginAdminFormCondition[]; +} + export interface PluginAdminFormField { key: string; label: string; description?: string; - control: "TEXT" | "TEXTAREA" | "PASSWORD" | "NUMBER" | "SWITCH" | "SELECT"; + control: "TEXT" | "TEXTAREA" | "PASSWORD" | "NUMBER" | "SWITCH" | "SELECT" | "MULTI_SELECT"; placeholder?: string; required: boolean; secret: boolean; @@ -2707,6 +2721,10 @@ export interface PluginAdminFormField { default_value?: unknown; options?: PluginAdminFormFieldOption[]; rows?: number; + dynamic_options?: boolean; + show_when?: PluginAdminFormCondition[]; + validation?: PluginAdminFormValidation; + exclusive_group_field?: string; } export interface PluginCapability { diff --git a/web/src/components/admin/plugins/PluginConfigForm.tsx b/web/src/components/admin/plugins/PluginConfigForm.tsx index 2c3acbb2..acf9f754 100644 --- a/web/src/components/admin/plugins/PluginConfigForm.tsx +++ b/web/src/components/admin/plugins/PluginConfigForm.tsx @@ -2,21 +2,16 @@ import { useEffect, useMemo, useState } from "react"; import type { ConnectionCheckResponse, + PluginAdminForm, PluginAdminFormField, PluginConfigSchema, } from "@/api/types"; import { ConnectionCheckAction } from "@/components/admin/ConnectionCheckAction"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; + +import { SchemaForm } from "./SchemaForm"; +import { buildSchemaValues } from "./schemaFormUtils"; type PluginConfigValue = Record; @@ -124,37 +119,6 @@ function valueForField(field: SupportedField, configValue?: PluginConfigValue): return defaultValueForField(field); } -function buildPayload(fields: SupportedField[], draft: Record) { - const payload: PluginConfigValue = {}; - for (const field of fields) { - const value = draft[field.key]; - if (field.control === "SWITCH") { - payload[field.key] = Boolean(value); - continue; - } - if (typeof value !== "string") { - continue; - } - const trimmed = value.trim(); - if (trimmed === "") { - continue; - } - if ( - field.control === "NUMBER" || - field.inferredType === "number" || - field.inferredType === "integer" - ) { - const numeric = Number(trimmed); - if (!Number.isNaN(numeric)) { - payload[field.key] = numeric; - continue; - } - } - payload[field.key] = value; - } - return payload; -} - export function PluginConfigForm({ schema, value, @@ -174,18 +138,23 @@ export function PluginConfigForm({ const supported = fields.length > 0 && (schema.admin_form?.fields?.length ? true : parsedFallback.supported); - const [draft, setDraft] = useState>(() => + const descriptor = useMemo( + () => schema.admin_form ?? { fields }, + [schema.admin_form, fields], + ); + + const [values, setValues] = useState(() => Object.fromEntries(fields.map((field) => [field.key, valueForField(field, value)])), ); const [testResult, setTestResult] = useState(null); useEffect(() => { - setDraft(Object.fromEntries(fields.map((field) => [field.key, valueForField(field, value)]))); + setValues(Object.fromEntries(fields.map((field) => [field.key, valueForField(field, value)]))); }, [fields, value]); - function setField(key: string, nextValue: string | boolean) { + function handleChange(next: PluginConfigValue) { setTestResult(null); - setDraft((current) => ({ ...current, [key]: nextValue })); + setValues(next); } async function handleTest() { @@ -194,7 +163,7 @@ export function PluginConfigForm({ } try { - setTestResult(await onTest(schema.key, buildPayload(fields, draft))); + setTestResult(await onTest(schema.key, buildSchemaValues(descriptor, values))); } catch (error) { setTestResult({ success: false, @@ -223,69 +192,12 @@ export function PluginConfigForm({ ) : null} -
- {fields.map((field) => ( -
-
- - {field.description ? ( -

{field.description}

- ) : null} -
- - {field.control === "SWITCH" ? ( -
- setField(field.key, checked)} - /> - {field.label || humanizeKey(field.key)} -
- ) : field.control === "SELECT" ? ( - - ) : field.control === "TEXTAREA" || field.multiline ? ( -