diff --git a/internal/api/autoscan_wiring.go b/internal/api/autoscan_wiring.go index a78e8e72..97febffa 100644 --- a/internal/api/autoscan_wiring.go +++ b/internal/api/autoscan_wiring.go @@ -99,20 +99,51 @@ func (l PluginScanSourceLister) ListScanSources(ctx context.Context) ([]autoscan PluginID: inst.PluginID, CapabilityID: c.ID, DisplayName: scanSourceDisplayName(inst.PluginID, c), + Description: scanSourceMetadataString(c, "description"), + Descriptor: scanSourceDescriptor(inst.PluginID, c), }) } } return out, nil } +// scanSourceMetadataString reads a trimmed string out of a capability's +// manifest metadata, returning "" when absent or of the wrong type. +func scanSourceMetadataString(c *plugins.Capability, key string) string { + if c == nil || c.Metadata == nil { + return "" + } + value, ok := c.Metadata[key].(string) + if !ok { + return "" + } + return strings.TrimSpace(value) +} + +// scanSourceDescriptor resolves the setup contract for a discovered capability. +// It reads whatever the manifest declares (falling back to host defaults that +// reproduce pre-descriptor behavior), then lets a host-side compatibility +// descriptor fill gaps for first-party plugins that have not yet published one +// of their own. +func scanSourceDescriptor(pluginID string, c *plugins.Capability) autoscan.ScanSourceDescriptor { + var metadata map[string]any + if c != nil { + metadata = c.Metadata + } + declared := autoscan.DescriptorFromMetadata(metadata) + capabilityID := "" + if c != nil { + capabilityID = c.ID + } + return autoscan.ApplyCompatibilityDescriptor(pluginID, capabilityID, declared) +} + // scanSourceDisplayName derives a human-friendly label for a scan_source // capability: the capability manifest's display_name when present, else the // plugin id (with the capability id appended when it adds information). func scanSourceDisplayName(pluginID string, c *plugins.Capability) string { - if c != nil && c.Metadata != nil { - if name, ok := c.Metadata["display_name"].(string); ok && strings.TrimSpace(name) != "" { - return strings.TrimSpace(name) - } + if name := scanSourceMetadataString(c, "display_name"); name != "" { + return name } switch { case pluginID != "" && c != nil && c.ID != "": diff --git a/internal/api/handlers/autoscan.go b/internal/api/handlers/autoscan.go index c0852f6e..d9245dc3 100644 --- a/internal/api/handlers/autoscan.go +++ b/internal/api/handlers/autoscan.go @@ -454,10 +454,15 @@ func (h *AutoscanHandler) HandleListSources(w http.ResponseWriter, r *http.Reque // --- Available scan-source plugins (Add-source picker) --- +// autoscanScanSourcePluginResponse describes one creatable scan source. The +// descriptor is additive: clients that predate it keep reading the same three +// identity fields. type autoscanScanSourcePluginResponse struct { - PluginID string `json:"plugin_id"` - CapabilityID string `json:"capability_id"` - DisplayName string `json:"display_name"` + PluginID string `json:"plugin_id"` + CapabilityID string `json:"capability_id"` + DisplayName string `json:"display_name"` + Description string `json:"description,omitempty"` + Descriptor autoscan.ScanSourceDescriptor `json:"descriptor"` } // HandleListAvailableScanSources returns every installed scan_source capability @@ -474,6 +479,8 @@ func (h *AutoscanHandler) HandleListAvailableScanSources(w http.ResponseWriter, PluginID: a.PluginID, CapabilityID: a.CapabilityID, DisplayName: a.DisplayName, + Description: a.Description, + Descriptor: a.Descriptor, }) } writeJSON(w, http.StatusOK, struct { diff --git a/internal/autoscan/adminform.go b/internal/autoscan/adminform.go new file mode 100644 index 00000000..c9ab15f7 --- /dev/null +++ b/internal/autoscan/adminform.go @@ -0,0 +1,118 @@ +package autoscan + +import "encoding/json" + +// AdminForm describes per-source configuration fields for a scan source, in the +// same shape the admin UI's generic schema renderer already consumes for plugin +// config (see web/src/components/admin/plugins/SchemaForm.tsx). Reusing that +// shape is deliberate: a scan source's config form is the same kind of thing as +// a plugin's config form, and the renderer for it already exists. +// +// The host never interprets these fields. It carries them from the capability +// manifest to the admin UI, and stores whatever values come back in the +// source's SourceConfig map. +type AdminForm struct { + Fields []AdminFormField `json:"fields"` + SubmitLabel string `json:"submit_label,omitempty"` + Sections []AdminFormSection `json:"sections,omitempty"` +} + +// AdminFormField is one control. Control values match the SDK's +// AdminFormControl enum names as rendered by the admin UI ("TEXT", "TEXTAREA", +// "PASSWORD", "NUMBER", "SWITCH", "SELECT", "MULTI_SELECT"); the host passes +// them through without validating, so a newer control name from a newer plugin +// degrades in the UI rather than being rejected here. +type AdminFormField 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,omitempty"` + Secret bool `json:"secret,omitempty"` + Multiline bool `json:"multiline,omitempty"` + DefaultValue any `json:"default_value,omitempty"` + Options []AdminFormOption `json:"options,omitempty"` + Rows int `json:"rows,omitempty"` + DynamicOptions bool `json:"dynamic_options,omitempty"` + ShowWhen []AdminFormCondition `json:"show_when,omitempty"` + Validation *AdminFormValidation `json:"validation,omitempty"` + // FillFrom names a host-known value the admin UI can offer to populate this + // field from, as a one-click action beside it. It exists so a path-shaped + // field can be filled from Silo's own library paths without the UI needing + // to know which plugin it belongs to. Unknown values are ignored by the UI. + FillFrom string `json:"fill_from,omitempty"` +} + +// Control names the admin UI renders. These mirror the SDK's AdminFormControl +// enum; the host only names the ones it builds forms for itself. +const ( + ControlText = "TEXT" + ControlTextarea = "TEXTAREA" + ControlSelect = "SELECT" +) + +// Fill sources the admin UI understands for AdminFormField.FillFrom. +const ( + // FillFromMovieLibraryPaths offers the paths of every enabled movie + // library; FillFromTVLibraryPaths the same for series libraries. Mixed + // libraries contribute to both. + FillFromMovieLibraryPaths = "library_paths_movie" + FillFromTVLibraryPaths = "library_paths_tv" +) + +type AdminFormOption struct { + Value string `json:"value"` + Label string `json:"label"` + Description string `json:"description,omitempty"` +} + +type AdminFormCondition struct { + Field string `json:"field"` + Equals []string `json:"equals"` +} + +type AdminFormValidation 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 int `json:"min_length,omitempty"` + MaxLength int `json:"max_length,omitempty"` +} + +type AdminFormSection struct { + Key string `json:"key"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Collapsible bool `json:"collapsible,omitempty"` + CollapsedDefault bool `json:"collapsed_default,omitempty"` + FieldKeys []string `json:"field_keys"` + ShowWhen []AdminFormCondition `json:"show_when,omitempty"` +} + +// adminFormFromMetadata decodes an admin form out of a decoded-JSON metadata +// value. It round-trips through encoding/json rather than walking the map by +// hand, so the field set stays in sync with the struct tags above. +// +// A malformed form yields nil rather than an error: a plugin with a broken +// config form must still be discoverable and creatable, just without its +// bespoke fields. +func adminFormFromMetadata(value any) *AdminForm { + if value == nil { + return nil + } + raw, err := json.Marshal(value) + if err != nil { + return nil + } + var form AdminForm + if err := json.Unmarshal(raw, &form); err != nil { + return nil + } + if len(form.Fields) == 0 { + return nil + } + return &form +} diff --git a/internal/autoscan/compat.go b/internal/autoscan/compat.go new file mode 100644 index 00000000..5e1f5bfb --- /dev/null +++ b/internal/autoscan/compat.go @@ -0,0 +1,153 @@ +package autoscan + +import "strings" + +// Compatibility descriptors for first-party scan-source plugins that predate +// the descriptor contract. +// +// These exist so the admin UI can be fully descriptor-driven today, before +// every plugin has shipped a manifest that declares its own setup contract. +// Each entry is a stopgap with a clear exit: once the plugin publishes the same +// information in its manifest, its entry here can be deleted with no UI change, +// because the manifest value wins over the compatibility value. +// +// This file is deliberately the *only* place in the host that maps a plugin id +// to setup behavior. It replaces per-plugin conditionals that were previously +// scattered through the admin UI. + +const ( + // cephFSPluginID and cephFSCapabilityID identify the first-party CephFS + // watcher. It reads a mounted filesystem directly, so it needs no upstream + // credentials. + cephFSPluginID = "silo.autoscan.cephfs" + cephFSCapabilityID = "cephfs" + + // CephFSMoviePathsKey and CephFSTVPathsKey are the source_config keys the + // CephFS watcher reads its watch roots from. They are named here only to + // build the compatibility form; the host does not interpret their values. + CephFSMoviePathsKey = "movie_flat_paths" + CephFSTVPathsKey = "tv_flat_paths" + // CephFSExclusionsKey holds newline-separated path fragments to ignore. + CephFSExclusionsKey = "exclusions" +) + +// defaultCephFSExclusions are the ignore patterns the admin UI used to seed by +// hand. They cover partial downloads and NAS bookkeeping directories that would +// otherwise trigger pointless scans. +var defaultCephFSExclusions = []string{ + "*.partial", + "*.tmp", + "@eaDir", + "#recycle", + ".downloads", + ".recyclebin", + "volumes", +} + +// cephFSCompatibilityDescriptor is the setup contract the CephFS watcher would +// declare in its own manifest. Field keys match what the plugin already reads +// from source_config, so existing sources keep working untouched. +func cephFSCompatibilityDescriptor() ScanSourceDescriptor { + return ScanSourceDescriptor{ + DeliveryModes: []string{DeliveryModePoll}, + Connection: ConnectionNone, + Summary: "Watch mounted CephFS paths for new and changed media.", + ConfigForm: &AdminForm{ + Fields: []AdminFormField{ + { + Key: CephFSMoviePathsKey, + Label: "Movie paths", + Description: "One path per line. Leave blank if this watcher only covers TV.", + Control: ControlTextarea, + Placeholder: "/mnt/media/movies", + Multiline: true, + Rows: 4, + FillFrom: FillFromMovieLibraryPaths, + }, + { + Key: CephFSTVPathsKey, + Label: "TV paths", + Description: "One path per line. Leave blank if this watcher only covers movies.", + Control: ControlTextarea, + Placeholder: "/mnt/media/tv", + Multiline: true, + Rows: 4, + FillFrom: FillFromTVLibraryPaths, + }, + { + Key: CephFSExclusionsKey, + Label: "Exclusions", + Description: "Path fragments to ignore, one per line.", + Control: ControlTextarea, + Multiline: true, + Rows: 6, + DefaultValue: strings.Join(defaultCephFSExclusions, "\n"), + }, + }, + }, + } +} + +// ApplyCompatibilityDescriptor fills gaps in a plugin-declared descriptor from +// a host-side stopgap for known first-party plugins. +// +// The manifest always wins: only fields the plugin left unset are filled in. +// That ordering is what lets a plugin take ownership of its own contract simply +// by publishing it, with no coordinated host change. +func ApplyCompatibilityDescriptor(pluginID, capabilityID string, declared ScanSourceDescriptor) ScanSourceDescriptor { + compat, ok := compatibilityDescriptor(pluginID, capabilityID) + if !ok { + return declared + } + + // A manifest that declares nothing resolves to the default descriptor, so + // "still default" is the signal that the plugin has not spoken. Comparing + // against the default (rather than the zero value) is what distinguishes + // "unset" from "deliberately set to the same thing". + def := DefaultScanSourceDescriptor() + + if equalStrings(declared.DeliveryModes, def.DeliveryModes) && len(compat.DeliveryModes) > 0 { + declared.DeliveryModes = compat.DeliveryModes + } + if declared.Connection == def.Connection && compat.Connection != "" { + declared.Connection = compat.Connection + } + if len(declared.ConnectionKinds) == 0 { + declared.ConnectionKinds = compat.ConnectionKinds + } + if !declared.EmitsNativePaths { + declared.EmitsNativePaths = compat.EmitsNativePaths + } + if declared.Summary == "" { + declared.Summary = compat.Summary + } + if declared.IconURL == "" { + declared.IconURL = compat.IconURL + } + if declared.ConfigForm == nil { + declared.ConfigForm = compat.ConfigForm + } + + return declared +} + +// compatibilityDescriptor returns the stopgap descriptor for a known +// first-party plugin, if one exists. +func compatibilityDescriptor(pluginID, capabilityID string) (ScanSourceDescriptor, bool) { + if pluginID == cephFSPluginID || capabilityID == cephFSCapabilityID { + return cephFSCompatibilityDescriptor(), true + } + return ScanSourceDescriptor{}, false +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/autoscan/descriptor.go b/internal/autoscan/descriptor.go new file mode 100644 index 00000000..ad774b63 --- /dev/null +++ b/internal/autoscan/descriptor.go @@ -0,0 +1,226 @@ +package autoscan + +import "strings" + +// ConnectionRequirement says whether a scan source needs upstream credentials +// to reach its provider. It drives whether the Add-source flow shows the +// connection step at all, replacing the UI's per-plugin-id special cases. +type ConnectionRequirement string + +const ( + // ConnectionNone means the source reaches its provider without host-held + // credentials — a local filesystem watcher, or a provider that pushes to a + // Silo webhook endpoint. + ConnectionNone ConnectionRequirement = "none" + // ConnectionOptional means the source can bind a connection but works + // without one. This is the default for a capability that declares nothing, + // because it is what every source behaved as before descriptors existed. + ConnectionOptional ConnectionRequirement = "optional" + // ConnectionRequired means the source cannot poll until a connection is + // bound. + ConnectionRequired ConnectionRequirement = "required" +) + +// normalizeConnectionRequirement maps a manifest-supplied string onto a known +// requirement, falling back to ConnectionOptional for absent or unrecognized +// values so an unknown future value never hides the connection step outright. +func normalizeConnectionRequirement(value string) ConnectionRequirement { + switch ConnectionRequirement(strings.ToLower(strings.TrimSpace(value))) { + case ConnectionNone: + return ConnectionNone + case ConnectionRequired: + return ConnectionRequired + default: + return ConnectionOptional + } +} + +// ScanSourceDescriptor is the host-facing setup contract for one scan_source +// capability: everything the Add-source flow needs to build its steps without +// launching the plugin. It mirrors the precedent set by the SDK's typed +// WatchSyncProviderDescriptor, and is read from the capability's manifest +// metadata (see DescriptorFromMetadata). +// +// Every field is optional in a manifest. A capability that declares nothing +// resolves to DefaultScanSourceDescriptor, which reproduces the pre-descriptor +// behavior exactly, so existing installs are unaffected. +type ScanSourceDescriptor struct { + // DeliveryModes lists how changes reach the host (DeliveryModePoll and/or + // DeliveryModeWebhook). A single entry lets the flow skip its delivery step. + DeliveryModes []string `json:"delivery_modes"` + // Connection says whether upstream credentials are needed. + Connection ConnectionRequirement `json:"connection"` + // ConnectionKinds restricts which stored connections may be bound (e.g. + // "sonarr", "radarr"). Empty means any connection is offerable. + ConnectionKinds []string `json:"connection_kinds"` + // EmitsNativePaths reports that the plugin already returns Silo-native + // paths, so the host can skip prompting for path rewrites. + EmitsNativePaths bool `json:"emits_native_paths"` + // Summary is a short operator-facing sentence for the picker card. + Summary string `json:"summary"` + // IconURL optionally points at a plugin-served icon, same convention as an + // auth provider's icon_url. + IconURL string `json:"icon_url"` + // ConfigForm describes the per-source configuration fields the operator + // fills in when creating the source. It carries the capability's own + // admin_form so plugin-specific knobs render generically instead of being + // hardcoded in the admin UI. + ConfigForm *AdminForm `json:"config_form,omitempty"` +} + +// SupportsDeliveryMode reports whether the descriptor allows the given mode. +func (d ScanSourceDescriptor) SupportsDeliveryMode(mode string) bool { + for _, m := range d.DeliveryModes { + if m == mode { + return true + } + } + return false +} + +// DefaultDeliveryMode returns the mode a new source should use when the +// operator is not asked to choose. Poll wins when available because it is the +// historical default; a webhook-only source resolves to webhook. +func (d ScanSourceDescriptor) DefaultDeliveryMode() string { + if d.SupportsDeliveryMode(DeliveryModePoll) { + return DeliveryModePoll + } + if len(d.DeliveryModes) > 0 { + return d.DeliveryModes[0] + } + return DeliveryModePoll +} + +// DefaultScanSourceDescriptor is what a capability that declares no scan-source +// metadata resolves to: host-polled, connection allowed but not demanded, and +// path rewrites still offered. This is exactly how every source behaved before +// descriptors existed, which is what keeps already-installed plugins working. +func DefaultScanSourceDescriptor() ScanSourceDescriptor { + return ScanSourceDescriptor{ + DeliveryModes: []string{DeliveryModePoll}, + Connection: ConnectionOptional, + } +} + +// DescriptorFromMetadata builds a descriptor from a capability's manifest +// metadata map, filling anything absent from DefaultScanSourceDescriptor. +// +// The metadata arrives as decoded JSON (map[string]any), so every read is +// tolerant: a wrong type is treated as absent rather than failing the whole +// capability, because a malformed descriptor must not make an otherwise working +// plugin undiscoverable. +func DescriptorFromMetadata(metadata map[string]any) ScanSourceDescriptor { + out := DefaultScanSourceDescriptor() + if metadata == nil { + return out + } + + // The config form is read first and independently of the typed block: a + // capability may describe its per-source fields through config_schema + // without declaring any scan-source behavior at all. + out.ConfigForm = configFormFromMetadata(metadata) + + raw, ok := metadata["scan_source"].(map[string]any) + if !ok { + return out + } + + if modes := stringSlice(raw["delivery_modes"]); len(modes) > 0 { + valid := make([]string, 0, len(modes)) + for _, m := range modes { + switch m := strings.ToLower(strings.TrimSpace(m)); m { + case DeliveryModePoll, DeliveryModeWebhook: + valid = append(valid, m) + } + } + // Only adopt the declared list when at least one mode is understood; + // otherwise keep the default so the source stays creatable. + if len(valid) > 0 { + out.DeliveryModes = valid + } + } + + if connection, ok := raw["connection"].(string); ok { + out.Connection = normalizeConnectionRequirement(connection) + } + if kinds := stringSlice(raw["connection_kinds"]); len(kinds) > 0 { + out.ConnectionKinds = kinds + } + if native, ok := raw["emits_native_paths"].(bool); ok { + out.EmitsNativePaths = native + } + if summary, ok := raw["summary"].(string); ok { + out.Summary = strings.TrimSpace(summary) + } + if icon, ok := raw["icon_url"].(string); ok { + out.IconURL = strings.TrimSpace(icon) + } + + return out +} + +// scanSourceConfigKey is the config_schema key a scan_source capability uses to +// describe its per-source fields. A capability's config_schema may hold several +// entries; only this one describes the Add-source form, so the rest (plugin-wide +// settings) keep rendering on the Plugins page as they do today. +const scanSourceConfigKey = "scan_source" + +// configFormFromMetadata finds the per-source admin form in a capability's +// metadata. It prefers an explicit form nested under the typed scan_source +// block, then falls back to the capability's config_schema entry keyed +// "scan_source". +func configFormFromMetadata(metadata map[string]any) *AdminForm { + if raw, ok := metadata["scan_source"].(map[string]any); ok { + if form := adminFormFromMetadata(raw["config_form"]); form != nil { + return form + } + } + + entries, ok := metadata["config_schema"].([]any) + if !ok { + return nil + } + for _, entry := range entries { + schema, ok := entry.(map[string]any) + if !ok { + continue + } + if key, _ := schema["key"].(string); key != scanSourceConfigKey { + continue + } + return adminFormFromMetadata(schema["admin_form"]) + } + return nil +} + +// stringSlice coerces a decoded-JSON value into a trimmed, non-empty string +// slice. Both []any (fresh JSON) and []string (in-process construction) are +// accepted; anything else yields nil. +func stringSlice(value any) []string { + var raw []string + switch typed := value.(type) { + case []string: + raw = typed + case []any: + for _, entry := range typed { + text, ok := entry.(string) + if !ok { + return nil + } + raw = append(raw, text) + } + default: + return nil + } + + out := make([]string, 0, len(raw)) + for _, entry := range raw { + if trimmed := strings.TrimSpace(entry); trimmed != "" { + out = append(out, trimmed) + } + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/internal/autoscan/descriptor_test.go b/internal/autoscan/descriptor_test.go new file mode 100644 index 00000000..593f21ca --- /dev/null +++ b/internal/autoscan/descriptor_test.go @@ -0,0 +1,192 @@ +package autoscan + +import "testing" + +// A capability that declares nothing must behave exactly as sources did before +// descriptors existed: host-polled, connection allowed but not demanded. This +// is the guarantee that keeps already-installed plugins working. +func TestDescriptorFromMetadataDefaultsPreserveLegacyBehavior(t *testing.T) { + for name, metadata := range map[string]map[string]any{ + "nil metadata": nil, + "empty metadata": {}, + "unrelated keys": {"display_name": "Something"}, + } { + t.Run(name, func(t *testing.T) { + got := DescriptorFromMetadata(metadata) + if got.Connection != ConnectionOptional { + t.Errorf("connection = %q, want %q", got.Connection, ConnectionOptional) + } + if !got.SupportsDeliveryMode(DeliveryModePoll) { + t.Errorf("delivery modes = %v, want poll", got.DeliveryModes) + } + if got.SupportsDeliveryMode(DeliveryModeWebhook) { + t.Errorf("delivery modes = %v, must not default to webhook", got.DeliveryModes) + } + if got.EmitsNativePaths { + t.Error("emits_native_paths must default false so rewrites stay offered") + } + }) + } +} + +func TestDescriptorFromMetadataReadsDeclaredContract(t *testing.T) { + got := DescriptorFromMetadata(map[string]any{ + "scan_source": map[string]any{ + "delivery_modes": []any{"webhook"}, + "connection": "none", + "connection_kinds": []any{"sonarr", "radarr"}, + "emits_native_paths": true, + "summary": " Pushes to Silo. ", + "icon_url": "/assets/icon.svg", + }, + }) + + if got.Connection != ConnectionNone { + t.Errorf("connection = %q, want none", got.Connection) + } + if got.SupportsDeliveryMode(DeliveryModePoll) { + t.Errorf("delivery modes = %v, want webhook only", got.DeliveryModes) + } + if !got.EmitsNativePaths { + t.Error("emits_native_paths not read") + } + if got.Summary != "Pushes to Silo." { + t.Errorf("summary = %q, want trimmed", got.Summary) + } + if len(got.ConnectionKinds) != 2 { + t.Errorf("connection kinds = %v", got.ConnectionKinds) + } +} + +// A malformed or unrecognized descriptor must degrade to defaults rather than +// make an otherwise working plugin undiscoverable. +func TestDescriptorFromMetadataToleratesBadValues(t *testing.T) { + tests := map[string]map[string]any{ + "wrong block type": {"scan_source": "not-an-object"}, + "unknown modes": {"scan_source": map[string]any{ + "delivery_modes": []any{"telepathy"}, + }}, + "wrong mode element type": {"scan_source": map[string]any{ + "delivery_modes": []any{7}, + }}, + "unknown connection value": {"scan_source": map[string]any{ + "connection": "sometimes", + }}, + } + + for name, metadata := range tests { + t.Run(name, func(t *testing.T) { + got := DescriptorFromMetadata(metadata) + if !got.SupportsDeliveryMode(DeliveryModePoll) { + t.Errorf("delivery modes = %v, want fallback to poll", got.DeliveryModes) + } + if got.Connection != ConnectionOptional { + t.Errorf("connection = %q, want fallback to optional", got.Connection) + } + }) + } +} + +func TestDescriptorReadsConfigFormFromCapabilityConfigSchema(t *testing.T) { + got := DescriptorFromMetadata(map[string]any{ + "config_schema": []any{ + map[string]any{"key": "unrelated", "admin_form": map[string]any{ + "fields": []any{map[string]any{"key": "ignored", "label": "Ignored"}}, + }}, + map[string]any{"key": "scan_source", "admin_form": map[string]any{ + "fields": []any{ + map[string]any{"key": "root", "label": "Root path", "control": "TEXT"}, + }, + }}, + }, + }) + + if got.ConfigForm == nil { + t.Fatal("expected config form from the scan_source config_schema entry") + } + if len(got.ConfigForm.Fields) != 1 || got.ConfigForm.Fields[0].Key != "root" { + t.Fatalf("wrong form picked: %+v", got.ConfigForm.Fields) + } +} + +func TestDefaultDeliveryModePrefersPoll(t *testing.T) { + webhookOnly := ScanSourceDescriptor{DeliveryModes: []string{DeliveryModeWebhook}} + if got := webhookOnly.DefaultDeliveryMode(); got != DeliveryModeWebhook { + t.Errorf("webhook-only default = %q", got) + } + + both := ScanSourceDescriptor{DeliveryModes: []string{DeliveryModeWebhook, DeliveryModePoll}} + if got := both.DefaultDeliveryMode(); got != DeliveryModePoll { + t.Errorf("dual-mode default = %q, want poll", got) + } + + // An empty descriptor must still name a usable mode. + if got := (ScanSourceDescriptor{}).DefaultDeliveryMode(); got != DeliveryModePoll { + t.Errorf("empty default = %q, want poll", got) + } +} + +// The builtin arr webhook identity is the host's own descriptor, and the UI +// relies on it to skip both the delivery and connection steps. +func TestBuiltinArrWebhookDescriptor(t *testing.T) { + d := BuiltinArrWebhookSource().Descriptor + + if d.Connection != ConnectionNone { + t.Errorf("connection = %q, want none (the arr pushes to Silo)", d.Connection) + } + if d.SupportsDeliveryMode(DeliveryModePoll) { + t.Errorf("delivery modes = %v, want webhook only", d.DeliveryModes) + } + if d.ConfigForm == nil || len(d.ConfigForm.Fields) == 0 { + t.Fatal("expected a provider config form") + } + if d.ConfigForm.Fields[0].Key != WebhookProviderConfigKey { + t.Errorf("form field = %q, want %q", d.ConfigForm.Fields[0].Key, WebhookProviderConfigKey) + } +} + +// The compatibility layer is a stopgap for first-party plugins that have not +// published a descriptor yet. A plugin that declares its own contract must win. +func TestApplyCompatibilityDescriptorFillsOnlyUnsetFields(t *testing.T) { + declared := DescriptorFromMetadata(nil) // plugin said nothing + got := ApplyCompatibilityDescriptor(cephFSPluginID, cephFSCapabilityID, declared) + + if got.Connection != ConnectionNone { + t.Errorf("connection = %q, want none from compat", got.Connection) + } + if got.ConfigForm == nil { + t.Fatal("expected compat config form for cephfs") + } + if got.Summary == "" { + t.Error("expected compat summary") + } +} + +func TestApplyCompatibilityDescriptorDoesNotOverrideManifest(t *testing.T) { + declared := DescriptorFromMetadata(map[string]any{ + "scan_source": map[string]any{ + "connection": "required", + "summary": "Plugin's own words", + }, + }) + got := ApplyCompatibilityDescriptor(cephFSPluginID, cephFSCapabilityID, declared) + + if got.Connection != ConnectionRequired { + t.Errorf("connection = %q, manifest must win over compat", got.Connection) + } + if got.Summary != "Plugin's own words" { + t.Errorf("summary = %q, manifest must win over compat", got.Summary) + } +} + +func TestApplyCompatibilityDescriptorLeavesUnknownPluginsAlone(t *testing.T) { + declared := DescriptorFromMetadata(nil) + got := ApplyCompatibilityDescriptor("com.example.future", "watcher", declared) + + if got.ConfigForm != nil { + t.Error("unknown plugin must not inherit another plugin's form") + } + if got.Connection != ConnectionOptional { + t.Errorf("connection = %q, want untouched default", got.Connection) + } +} diff --git a/internal/autoscan/discovery.go b/internal/autoscan/discovery.go index 09ef48b8..52b28528 100644 --- a/internal/autoscan/discovery.go +++ b/internal/autoscan/discovery.go @@ -13,6 +13,11 @@ const ( BuiltinArrWebhookPluginID = "silo.autoscan.arr-webhook" BuiltinArrWebhookCapabilityID = "arr-webhook" builtinArrWebhookDisplayName = "Sonarr/Radarr Webhook" + + // WebhookProviderConfigKey is the source_config key naming which arr + // service posts to a webhook source's endpoint ("auto", "sonarr", + // "radarr"). + WebhookProviderConfigKey = "webhook_provider" ) // BuiltinArrWebhookSource returns the host-built-in ARR webhook source @@ -23,6 +28,32 @@ func BuiltinArrWebhookSource() DiscoveredSource { PluginID: BuiltinArrWebhookPluginID, CapabilityID: BuiltinArrWebhookCapabilityID, DisplayName: builtinArrWebhookDisplayName, + Description: "Sonarr or Radarr posts to Silo the moment an import finishes.", + // Webhook-only and credential-free: the provider pushes to a Silo + // endpoint, so the flow skips both the delivery-mode question and the + // connection step. This descriptor is what the admin UI used to infer + // from a hardcoded plugin-id comparison. + Descriptor: ScanSourceDescriptor{ + DeliveryModes: []string{DeliveryModeWebhook}, + Connection: ConnectionNone, + ConnectionKinds: []string{"sonarr", "radarr"}, + Summary: "No API key needed — paste one URL into Sonarr/Radarr → Settings → Connect → Webhook.", + ConfigForm: &AdminForm{ + Fields: []AdminFormField{ + { + Key: WebhookProviderConfigKey, + Label: "Provider", + Description: "Which service posts to this endpoint. Auto-detects from the first delivery.", + Control: ControlSelect, + Options: []AdminFormOption{ + {Value: "auto", Label: "Detect automatically"}, + {Value: "sonarr", Label: "Sonarr"}, + {Value: "radarr", Label: "Radarr"}, + }, + }, + }, + }, + }, } } @@ -43,6 +74,13 @@ type DiscoveredSource struct { // DisplayName is a human-friendly label for the capability (from the // capability's manifest display_name, falling back to plugin/capability ids). DisplayName string + // Description is the capability's manifest description, shown under the + // display name in the Add-source picker. Empty when unset. + Description string + // Descriptor is the setup contract the Add-source flow builds its steps + // from. A capability that declares nothing carries + // DefaultScanSourceDescriptor, so this is never a zero value in practice. + Descriptor ScanSourceDescriptor } // ScanSourceLister enumerates every installed scan_source.v1 capability so the @@ -84,6 +122,11 @@ type AvailableScanSource struct { PluginID string `json:"plugin_id"` CapabilityID string `json:"capability_id"` DisplayName string `json:"display_name"` + Description string `json:"description,omitempty"` + // Descriptor tells the Add-source flow which steps to ask for. Always + // populated: discovery substitutes DefaultScanSourceDescriptor for + // capabilities that declare nothing. + Descriptor ScanSourceDescriptor `json:"descriptor"` } // ListAvailableScanSources enumerates every installed scan_source capability so @@ -100,10 +143,19 @@ func (s *Service) ListAvailableScanSources(ctx context.Context) ([]AvailableScan } out := make([]AvailableScanSource, 0, len(discovered)) for _, d := range discovered { + descriptor := d.Descriptor + // A lister that predates descriptors (or a hand-built test double) + // leaves this empty; substituting the default keeps the contract + // "always populated" true for every consumer downstream. + if len(descriptor.DeliveryModes) == 0 { + descriptor = DefaultScanSourceDescriptor() + } out = append(out, AvailableScanSource{ PluginID: d.PluginID, CapabilityID: d.CapabilityID, DisplayName: d.DisplayName, + Description: d.Description, + Descriptor: descriptor, }) } return out, nil diff --git a/internal/autoscan/discovery_test.go b/internal/autoscan/discovery_test.go index 9acc5613..04f29ee2 100644 --- a/internal/autoscan/discovery_test.go +++ b/internal/autoscan/discovery_test.go @@ -26,8 +26,17 @@ func TestListAvailableScanSourcesEnumeratesInstalled(t *testing.T) { if len(available) != 2 { t.Fatalf("expected 2 available, got %d: %+v", len(available), available) } - if available[0] != (AvailableScanSource{PluginID: "sonarr", CapabilityID: "arr-a", DisplayName: "Sonarr"}) { - t.Fatalf("unexpected first available: %+v", available[0]) + got := available[0] + if got.PluginID != "sonarr" || got.CapabilityID != "arr-a" || got.DisplayName != "Sonarr" { + t.Fatalf("unexpected first available: %+v", got) + } + // A lister that supplies no descriptor must still yield the default one, so + // every consumer can rely on the field being populated. + if got.Descriptor.Connection != ConnectionOptional { + t.Fatalf("expected default connection requirement, got %q", got.Descriptor.Connection) + } + if !got.Descriptor.SupportsDeliveryMode(DeliveryModePoll) { + t.Fatalf("expected default poll delivery mode, got %+v", got.Descriptor.DeliveryModes) } } @@ -47,7 +56,7 @@ func TestWithBuiltinSourcesAppendsToInner(t *testing.T) { if discovered[0].PluginID != "sonarr" { t.Fatalf("plugin entries must pass through first, got %+v", discovered[0]) } - if discovered[1] != BuiltinArrWebhookSource() { + if !isBuiltinArrWebhookSource(discovered[1]) { t.Fatalf("expected builtin appended, got %+v", discovered[1]) } } @@ -59,11 +68,22 @@ func TestWithBuiltinSourcesNilInner(t *testing.T) { if err != nil { t.Fatalf("ListScanSources: %v", err) } - if len(discovered) != 1 || discovered[0] != BuiltinArrWebhookSource() { + if len(discovered) != 1 || !isBuiltinArrWebhookSource(discovered[0]) { t.Fatalf("expected only builtin, got %+v", discovered) } } +// isBuiltinArrWebhookSource compares the identifying fields of a discovered +// source. DiscoveredSource carries slices (delivery modes, connection kinds) so +// it is no longer comparable with ==, and these tests only care that the +// builtin identity came through. +func isBuiltinArrWebhookSource(got DiscoveredSource) bool { + want := BuiltinArrWebhookSource() + return got.PluginID == want.PluginID && + got.CapabilityID == want.CapabilityID && + got.DisplayName == want.DisplayName +} + func TestIsBuiltinArrWebhookIdentity(t *testing.T) { if !IsBuiltinArrWebhookIdentity(BuiltinArrWebhookPluginID, BuiltinArrWebhookCapabilityID) { t.Fatal("builtin identity must match") diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 211ec81e..5a5a82c0 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -2107,10 +2107,35 @@ export interface AutoscanSourcesResponse { sources: AutoscanSource[]; } +/** Whether a scan source needs upstream credentials to reach its provider. */ +export type AutoscanConnectionRequirement = "none" | "optional" | "required"; + +/** + * The setup contract for one scan source, declared by its plugin manifest and + * resolved host-side. The Add-source flow builds its steps from this rather + * than branching on plugin ids, so a new plugin configures itself. + * + * The host always populates this, substituting defaults (poll + optional + * connection) for capabilities that declare nothing. + */ +export interface AutoscanScanSourceDescriptor { + delivery_modes: AutoscanDeliveryMode[]; + connection: AutoscanConnectionRequirement; + connection_kinds?: string[]; + /** Plugin already emits Silo-native paths, so path rewrites can be skipped. */ + emits_native_paths?: boolean; + summary?: string; + icon_url?: string; + /** Per-source config fields, rendered by the shared plugin SchemaForm. */ + config_form?: PluginAdminForm; +} + export interface AutoscanAvailableSource { plugin_id: string; capability_id: string; display_name: string; + description?: string; + descriptor: AutoscanScanSourceDescriptor; } export interface AutoscanAvailableSourcesResponse { @@ -3442,6 +3467,13 @@ export interface PluginAdminFormField { show_when?: PluginAdminFormCondition[]; validation?: PluginAdminFormValidation; exclusive_group_field?: string; + /** + * Names a host-known value this field can be populated from in one click. + * Used by autoscan source config so a path field can be filled from Silo's + * own library paths without the UI knowing which plugin owns it. Unknown + * values render no action. + */ + fill_from?: string; } export interface PluginCapability { diff --git a/web/src/pages/AdminAutoscan.tsx b/web/src/pages/AdminAutoscan.tsx index b27dc576..b7e1cd9e 100644 --- a/web/src/pages/AdminAutoscan.tsx +++ b/web/src/pages/AdminAutoscan.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useSearchParams } from "react-router"; -import { Play } from "lucide-react"; +import { ChevronDown, ChevronRight, Play } from "lucide-react"; import type { AutoscanSettings } from "@/api/types"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -21,13 +21,25 @@ import SourcesPanel from "@/pages/admin/autoscan/SourcesPanel"; // Tab routing helpers // --------------------------------------------------------------------------- -const AUTOSCAN_TABS = ["sources", "activity", "connections", "settings"] as const; +const AUTOSCAN_TABS = ["sources", "activity"] as const; type AutoscanTab = (typeof AUTOSCAN_TABS)[number]; +/** + * Connections and settings used to be peer tabs, which read as "set these up + * first" — most operators never needed either. They now live in an Advanced + * section on the Sources view, so their old deep links land on Sources with + * that section already open rather than 404-ing into a missing tab. + */ +const LEGACY_ADVANCED_TABS = new Set(["connections", "settings"]); + function normalizeTab(value: string | null): AutoscanTab { return AUTOSCAN_TABS.includes(value as AutoscanTab) ? (value as AutoscanTab) : "sources"; } +function isLegacyAdvancedTab(value: string | null): boolean { + return value !== null && LEGACY_ADVANCED_TABS.has(value); +} + // --------------------------------------------------------------------------- // Settings tab // --------------------------------------------------------------------------- @@ -65,14 +77,9 @@ function SettingsTab() { return (
-

- Autoscan can be enabled or disabled from the toggle in the page header. Tune polling - behavior below. -

- {/* Default poll interval */}
- +
sec

- Used for sources that have no per-source interval set. + Used by sources that don't set their own.

@@ -121,11 +128,16 @@ function SettingsTab() { export default function AdminAutoscan() { const [searchParams, setSearchParams] = useSearchParams(); - const activeTab = normalizeTab(searchParams.get("tab")); + const requestedTab = searchParams.get("tab"); + const activeTab = normalizeTab(requestedTab); const trigger = useTriggerAutoscan(); const settings = useAutoscanSettings(); const updateSettings = useUpdateAutoscanSettings(); + // Open Advanced automatically when arriving from an old connections/settings + // link, so a bookmark still lands on the thing it pointed at. + const [advancedOpen, setAdvancedOpen] = useState(() => isLegacyAdvancedTab(requestedTab)); + const enabled = settings.data?.enabled ?? false; function toggleEnabled(checked: boolean) { @@ -161,9 +173,8 @@ export default function AdminAutoscan() { ))}

- Automatically detect library changes from installed scan-source plugins. Install them - from the Plugins page, configure each source, bind a connection if it needs one, and - tune polling intervals. + Silo re-scans a library as soon as something changes, instead of waiting for the next + scheduled scan. Add a source for each thing you want watched.

@@ -195,25 +206,53 @@ export default function AdminAutoscan() { Sources Activity - Connections - Settings - + + + {/* Advanced — servers and polling defaults. Collapsed by default + because most setups never need either: a webhook source needs no + server, and the default interval is usually fine. */} +
+ + + {advancedOpen && ( +
+ +
+
+

Polling defaults

+

+ Applied to sources that don't override them. +

+
+ +
+
+ )} +
- - - - - - - -
); diff --git a/web/src/pages/admin/autoscan/ChoiceCard.tsx b/web/src/pages/admin/autoscan/ChoiceCard.tsx new file mode 100644 index 00000000..9a064e93 --- /dev/null +++ b/web/src/pages/admin/autoscan/ChoiceCard.tsx @@ -0,0 +1,94 @@ +import type { ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +/** + * A selectable card used by the Add-source flow. Cards rather than a dropdown + * because each option needs a sentence explaining what it does — the choice + * between "Sonarr pushes to Silo" and "Silo polls Sonarr" is meaningless + * without it, and a diff --git a/web/src/pages/admin/autoscan/InlineConnectionPicker.tsx b/web/src/pages/admin/autoscan/InlineConnectionPicker.tsx new file mode 100644 index 00000000..dc2bb062 --- /dev/null +++ b/web/src/pages/admin/autoscan/InlineConnectionPicker.tsx @@ -0,0 +1,314 @@ +import { useState } from "react"; +import { CheckCircle2, Plus, XCircle } from "lucide-react"; + +import type { AutoscanConnectionTestInput, AutoscanConnectionTestResult } from "@/api/types"; +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 { + useCreateAutoscanConnection, + useTestAutoscanConnection, +} from "@/hooks/queries/useAutoscan"; +import { useRequestIntegrations } from "@/hooks/queries/useRequests"; +import type { RequestIntegration } from "@/api/types"; + +export interface ConnectionOption { + id: string; + name: string; + kind: string; +} + +/** Sentinel values for the picker's non-connection choices. */ +const NONE = "__none__"; +const ADD_NEW = "__add__"; + +/** The arr service kind lives in plugin_config; it is the sole source of truth. */ +function integrationKind(integration: RequestIntegration): string { + const kind = integration.plugin_config?.["service_kind"]; + return typeof kind === "string" ? kind : ""; +} + +/** + * Connection selector for the Add-source flow, with inline creation. + * + * Previously an operator who had not yet made a connection hit a dead end here: + * the dropdown offered only "no connection", so they had to cancel, create one + * under Advanced, and start the flow again. A connection is nullable and only + * read at poll time, so creating one mid-flow is safe and needs no new API. + * + * When Requests already holds a matching Sonarr/Radarr, that is offered first — + * Silo has the credentials, so asking for them again is pure duplication. + */ +export function InlineConnectionPicker({ + value, + onChange, + options, + required, + connectionKinds, + idPrefix = "conn", +}: { + /** Selected connection id, or "" for none. */ + value: string; + onChange: (connectionId: string) => void; + options: ConnectionOption[]; + required: boolean; + /** Connection kinds this source accepts; empty means any. */ + connectionKinds: string[]; + idPrefix?: string; +}) { + const createConnection = useCreateAutoscanConnection(); + const testConnection = useTestAutoscanConnection(); + const requestIntegrations = useRequestIntegrations(); + + const [adding, setAdding] = useState(false); + const [name, setName] = useState(""); + const [baseUrl, setBaseUrl] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [reuseId, setReuseId] = useState(""); + const [testResult, setTestResult] = useState(null); + + // Requests integrations this source could bind, minus any already saved as a + // connection — re-offering those would create a duplicate of the same server. + const linkedIntegrationIds = new Set(options.map((option) => option.id)); + const reusable = (requestIntegrations.data ?? []).filter((integration) => { + const kind = integrationKind(integration); + if (!kind) return false; + if (connectionKinds.length > 0 && !connectionKinds.includes(kind)) return false; + return !linkedIntegrationIds.has(integration.id); + }); + + const defaultKind = connectionKinds[0] ?? "sonarr"; + + function resetDraft() { + setName(""); + setBaseUrl(""); + setApiKey(""); + setReuseId(""); + setTestResult(null); + } + + function handleSelect(next: string) { + if (next === ADD_NEW) { + setAdding(true); + // Pre-select a reusable Requests server so the common path is one click. + setReuseId(reusable[0]?.id ?? ""); + return; + } + setAdding(false); + resetDraft(); + onChange(next === NONE ? "" : next); + } + + function handleTest() { + setTestResult(null); + const body: AutoscanConnectionTestInput = reuseId + ? { request_integration_id: reuseId } + : { base_url: baseUrl.trim(), ...(apiKey.trim() ? { api_key_ref: apiKey.trim() } : {}) }; + + testConnection.mutate(body, { + onSuccess: setTestResult, + onError: (err) => + setTestResult({ + ok: false, + error: err instanceof Error ? err.message : "Connection test failed", + }), + }); + } + + function handleCreate() { + const linked = reusable.find((integration) => integration.id === reuseId); + const body = linked + ? { + name: linked.name, + kind: integrationKind(linked) || defaultKind, + request_integration_id: linked.id, + } + : { + name: name.trim(), + kind: defaultKind, + base_url: baseUrl.trim(), + ...(apiKey.trim() ? { api_key_ref: apiKey.trim() } : {}), + }; + + createConnection.mutate(body, { + onSuccess: (created) => { + // Select what was just made, so the operator never has to find it. + onChange(created.id); + setAdding(false); + resetDraft(); + }, + }); + } + + const canTest = reuseId ? true : baseUrl.trim().length > 0; + const canCreate = reuseId + ? true + : name.trim().length > 0 && baseUrl.trim().length > 0 && apiKey.trim().length > 0; + + return ( +
+ + + + + {!adding && ( +

+ {required + ? "This source needs credentials to reach its server." + : "Optional — bind one if this source needs to reach a server."} +

+ )} + + {adding && ( +
+ {reusable.length > 0 && ( +
+ + +

+ Silo already has these credentials — no need to enter them again. +

+
+ )} + + {!reuseId && ( + <> +
+ + setName(e.target.value)} + /> +
+
+ + { + setTestResult(null); + setBaseUrl(e.target.value); + }} + /> +
+
+ + { + setTestResult(null); + setApiKey(e.target.value); + }} + /> +
+ + )} + + {testResult && ( +
+ {testResult.ok ? ( + + ) : ( + + )} + + {testResult.ok + ? `Connected${testResult.version ? ` (v${testResult.version})` : ""}` + : (testResult.error ?? "Connection failed")} + +
+ )} + +
+ +
+ + +
+
+
+ )} +
+ ); +} + +export default InlineConnectionPicker; diff --git a/web/src/pages/admin/autoscan/SourceConfigForm.tsx b/web/src/pages/admin/autoscan/SourceConfigForm.tsx new file mode 100644 index 00000000..2f2205ab --- /dev/null +++ b/web/src/pages/admin/autoscan/SourceConfigForm.tsx @@ -0,0 +1,94 @@ +import { useMemo } from "react"; +import { Library as LibraryIcon } from "lucide-react"; + +import type { AutoscanScanSourceDescriptor } from "@/api/types"; +import { Button } from "@/components/ui/button"; +import { SchemaForm } from "@/components/admin/plugins/SchemaForm"; +import { useAdminLibraries } from "@/hooks/queries/admin/libraries"; + +import { configFields, fillValueFromLibraries } from "./sourceDescriptor"; + +/** + * Renders a scan source's per-source configuration from its descriptor, using + * the same schema renderer as plugin config. Nothing here knows which plugin it + * is drawing — that is the point: a new scan-source plugin ships its own fields + * in its manifest and they appear here without a frontend change. + * + * `source_config` is a string map on the wire, so values are marshalled to and + * from strings at this boundary. + */ +export function SourceConfigForm({ + descriptor, + values, + onChange, + idPrefix = "source-config", +}: { + descriptor: AutoscanScanSourceDescriptor; + values: Record; + onChange: (next: Record) => void; + idPrefix?: string; +}) { + const libraries = useAdminLibraries(); + const form = descriptor.config_form; + const fields = configFields(descriptor); + + // Fields that can be populated from a host-known value, paired with what that + // value currently is. Computed together so the button can be disabled when + // there is genuinely nothing to fill in. + const fillable = useMemo(() => { + return fields + .map((field) => ({ + field, + value: fillValueFromLibraries(field.fill_from, libraries.data ?? []), + })) + .filter((entry): entry is { field: (typeof fields)[number]; value: string } => + Boolean(entry.value), + ); + }, [fields, libraries.data]); + + if (!form || fields.length === 0) { + return null; + } + + function applyFill(key: string, value: string) { + onChange({ ...values, [key]: value }); + } + + return ( +
+ { + const out: Record = {}; + for (const [key, value] of Object.entries(next)) { + if (value === undefined || value === null) continue; + out[key] = String(value); + } + onChange(out); + }} + idPrefix={idPrefix} + /> + + {fillable.length > 0 && ( +
+ {fillable.map(({ field, value }) => ( + + ))} +
+ )} +
+ ); +} + +export default SourceConfigForm; diff --git a/web/src/pages/admin/autoscan/SourcesPanel.tsx b/web/src/pages/admin/autoscan/SourcesPanel.tsx index 7a40263d..094697d1 100644 --- a/web/src/pages/admin/autoscan/SourcesPanel.tsx +++ b/web/src/pages/admin/autoscan/SourcesPanel.tsx @@ -6,7 +6,6 @@ import { ChevronRight, Clock, Copy, - Library as LibraryIcon, Plus, RefreshCw, Trash2, @@ -16,12 +15,13 @@ import { import { Link } from "react-router"; import { toast } from "sonner"; import type { + AutoscanDeliveryMode, + AutoscanScanSourceDescriptor, AutoscanPathRewrite, AutoscanRewriteSuggestions, AutoscanSource, AutoscanSourceInput, AutoscanWebhookProvider, - Library, } from "@/api/types"; import { AlertDialog, @@ -73,12 +73,28 @@ import { useRotateAutoscanWebhook, useUpdateAutoscanSource, } from "@/hooks/queries/useAutoscan"; -import { useAdminLibraries } from "@/hooks/queries/admin/libraries"; import { buildPluginDisplayNames, composeSourceLabel, pluginDisplayNameKey, } from "@/lib/autoscanLabels"; +import { useAdminLibraries } from "@/hooks/queries/admin/libraries"; +import SourceConfigForm from "./SourceConfigForm"; +import { ChoiceCard, StepTrail } from "./ChoiceCard"; +import InlineConnectionPicker from "./InlineConnectionPicker"; +import { sourceTargets } from "./sourceTargets"; +import { WebhookInstructions, WebhookMappingEditor } from "./WebhookSetupStep"; +import { hasUsableMapping, seedMappings, usableMappings, type MappingDraft } from "./webhookSetup"; +import { + connectionIsMandatory, + connectionMatchesKinds, + defaultDeliveryMode, + DEFAULT_DESCRIPTOR, + descriptorFor, + initialConfigValues, + needsConnectionStep, + needsDeliveryChoice, +} from "./sourceDescriptor"; // --------------------------------------------------------------------------- // Helpers @@ -141,15 +157,25 @@ function sourceToRowEdit(source: AutoscanSource): RowEdit { }; } -const ARR_WEBHOOK_PLUGIN_ID = "silo.autoscan.arr-webhook"; const WEBHOOK_PROVIDER_KEY = "webhook_provider"; function isWebhookSource(source: AutoscanSource): boolean { return source.delivery_mode === "webhook"; } -function isArrWebhookPlugin(plugin: { plugin_id: string } | undefined): boolean { - return plugin?.plugin_id === ARR_WEBHOOK_PLUGIN_ID; +/** + * Which arr a webhook source expects, for the setup instructions. A descriptor + * naming exactly one connection kind tells us; anything else stays "auto", + * which shows the union of both services' triggers. + */ +function webhookProviderOf( + descriptor: AutoscanScanSourceDescriptor, +): AutoscanWebhookProvider | "auto" { + const kinds = descriptor.connection_kinds ?? []; + if (kinds.length === 1 && (kinds[0] === "sonarr" || kinds[0] === "radarr")) { + return kinds[0]; + } + return "auto"; } /** Resolve a possibly relative webhook_url against the admin UI's own origin. */ @@ -157,35 +183,19 @@ function absoluteWebhookURL(url: string): string { return url.startsWith("/") ? `${window.location.origin}${url}` : url; } -const CEPHFS_PLUGIN_ID = "silo.autoscan.cephfs"; -const CEPHFS_CAPABILITY_ID = "cephfs"; -const DEFAULT_CEPHFS_EXCLUSIONS = [ - "*.partial", - "*.tmp", - "@eaDir", - "#recycle", - ".downloads", - ".recyclebin", - "volumes", -]; -const CEPHFS_MOVIE_PATHS_KEY = "movie_flat_paths"; -const CEPHFS_TV_PATHS_KEY = "tv_flat_paths"; -const CEPHFS_LEGACY_MOVIE_NESTED_KEY = "movie_nested_paths"; -const CEPHFS_LEGACY_TV_NESTED_KEY = "tv_nested_paths"; +/** + * Legacy source_config keys that were superseded by a newer key. Rows written + * before the rename still carry the old key, so its lines are merged into the + * new one when the editor loads. Both keys are sent back on save, which is what + * lets an older plugin build keep reading its original key. + */ +const LEGACY_CONFIG_KEY_ALIASES: Record = { + movie_nested_paths: "movie_flat_paths", + tv_nested_paths: "tv_flat_paths", +}; -function isCephFSSource(source: AutoscanSource): boolean { - return source.plugin_id === CEPHFS_PLUGIN_ID || source.capability_id === CEPHFS_CAPABILITY_ID; -} - -function isCephFSPlugin(plugin: { plugin_id: string; capability_id: string } | undefined): boolean { - return plugin?.plugin_id === CEPHFS_PLUGIN_ID || plugin?.capability_id === CEPHFS_CAPABILITY_ID; -} - -function defaultCephFSConfig(): Record { - return { exclusions: DEFAULT_CEPHFS_EXCLUSIONS.join("\n") }; -} - -function mergeLineValues(...values: Array): string { +/** Merge newline-separated values, de-duplicating and dropping blanks. */ +function mergeLines(...values: Array): string { const lines = new Set(); for (const value of values) { (value ?? "") @@ -197,35 +207,19 @@ function mergeLineValues(...values: Array): string { return Array.from(lines).join("\n"); } -function mergeLineDefaults(value: string | undefined, defaults: string[]): string { - const lines = new Set( - (value ?? "") - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean), - ); - for (const entry of defaults) { - lines.add(entry); - } - return Array.from(lines).join("\n"); -} - +/** + * Prepare a source's stored config for editing: pass values through as-is, + * folding any superseded key into its replacement so nothing an operator + * previously configured silently disappears from the form. + */ function sourceConfigForEdit(source: AutoscanSource): Record { - if (!isCephFSSource(source)) { - return { ...(source.source_config ?? {}) }; + const config = { ...(source.source_config ?? {}) }; + for (const [legacyKey, currentKey] of Object.entries(LEGACY_CONFIG_KEY_ALIASES)) { + if (config[legacyKey] === undefined) continue; + config[currentKey] = mergeLines(config[currentKey], config[legacyKey]); + delete config[legacyKey]; } - const config = source.source_config ?? {}; - return { - [CEPHFS_MOVIE_PATHS_KEY]: mergeLineValues( - config[CEPHFS_MOVIE_PATHS_KEY], - config[CEPHFS_LEGACY_MOVIE_NESTED_KEY], - ), - [CEPHFS_TV_PATHS_KEY]: mergeLineValues( - config[CEPHFS_TV_PATHS_KEY], - config[CEPHFS_LEGACY_TV_NESTED_KEY], - ), - exclusions: mergeLineDefaults(config.exclusions, DEFAULT_CEPHFS_EXCLUSIONS), - }; + return config; } function normalizeSourceConfig(config: Record): Record { @@ -238,60 +232,6 @@ function normalizeSourceConfig(config: Record): Record, -): Record { - const moviePaths = new Set(); - const tvPaths = new Set(); - - for (const library of libraries) { - if (!library.enabled) { - continue; - } - const kind = libraryKind(library.type); - if (!kind) { - continue; - } - for (const path of library.paths ?? []) { - const trimmed = path.trim(); - if (!trimmed) { - continue; - } - if (kind === "movie" || kind === "mixed") { - moviePaths.add(trimmed); - } - if (kind === "tv" || kind === "mixed") { - tvPaths.add(trimmed); - } - } - } - - return { - [CEPHFS_MOVIE_PATHS_KEY]: Array.from(moviePaths).join("\n"), - [CEPHFS_TV_PATHS_KEY]: Array.from(tvPaths).join("\n"), - exclusions: mergeLineDefaults(current.exclusions, DEFAULT_CEPHFS_EXCLUSIONS), - }; -} - // --------------------------------------------------------------------------- // RewriteEditor — expandable section inside a SourceRow // --------------------------------------------------------------------------- @@ -585,130 +525,6 @@ function CollapsibleList({ title, items }: { title: string; items: string[] }) { ); } -function CephFSConfigEditor({ - config, - onChange, - onSave, - isSaving, - showSave = true, -}: { - config: Record; - onChange: (next: Record) => void; - onSave: (next?: Record) => void; - isSaving: boolean; - showSave?: boolean; -}) { - const [open, setOpen] = useState(false); - const panelId = useId(); - const libraries = useAdminLibraries(); - - function patch(key: string, value: string) { - onChange({ ...config, [key]: value }); - } - - function useConfiguredLibraries() { - const next = configFromLibraries(libraries.data ?? [], config); - onChange(next); - if (showSave) { - onSave(next); - } - } - - const libraryPathCount = - libraries.data - ?.filter((library) => library.enabled) - .reduce((count, library) => count + (library.paths?.length ?? 0), 0) ?? 0; - const canUseLibraries = !libraries.isLoading && libraryPathCount > 0 && !isSaving; - - const fields = [ - { - key: CEPHFS_MOVIE_PATHS_KEY, - label: "Movie library roots", - description: "One configured movie library path per line.", - placeholder: "/mnt/media/movies", - }, - { - key: CEPHFS_TV_PATHS_KEY, - label: "TV library roots", - description: "One configured TV library path per line.", - placeholder: "/mnt/media/television", - }, - { - key: "exclusions", - label: "Ignored paths", - description: "One glob, directory name, or path prefix per line.", - placeholder: DEFAULT_CEPHFS_EXCLUSIONS.join("\n"), - }, - ]; - - return ( -
-
- - -
- - {open && ( -
-
- {fields.map((field) => ( -
- -