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 */}- Used for sources that have no per-source interval set. + Used by sources that don't set their own.
- 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.
+ Applied to sources that don't override them. +
++ {required + ? "This source needs credentials to reach its server." + : "Optional — bind one if this source needs to reach a server."} +
+ )} + + {adding && ( ++ Silo already has these credentials — no need to enter them again. +
+
+
+ Paths don't match any library root — scans won't be enqueued. +
+ ) : ( +
+ Feeds
+ {targets.libraries.map((library) => (
+
{resolvedLabel.detail}
+ {targetSummary}