Autoscan setup asked operators to hold five concepts — plugin, capability, source, connection, delivery mode — before anything scanned, spread across four tabs. Sources never stated which library they fed, so a misconfigured one ran cleanly and silently did nothing. The admin UI also hardcoded two plugin identities: SourcesPanel.tsx carried 33 references to silo.autoscan.cephfs / silo.autoscan.arr-webhook plus a bespoke CephFS config editor. A third-party scan-source plugin could render no configuration UI at all without patching silo-server. Host side, add a ScanSourceDescriptor read from capability manifest metadata: delivery modes, connection requirement, connection kinds, and the per-source config form. Capabilities that declare nothing resolve to poll + optional connection — exactly the pre-descriptor behavior, so existing installs are unaffected. Compatibility descriptors for the two first-party plugins live in one file with a clear exit: the manifest always wins, so a plugin takes ownership by publishing its own. UI side, the Add-source flow builds its steps from the descriptor rather than from plugin ids, so a single-mode source is never asked how changes arrive and a credential-free one never sees the connection step. Connections are created inline (previously a dead end that forced cancelling out of the dialog), and webhook setup finishes in one place: mappings, then the URL with the exact Sonarr/Radarr triggers the host actually parses. Mapping rows seed from library paths collapsed to their common ancestor per mount point. Verified against a real install: 96 library paths become 2 rows. Rewrites match by longest prefix at a segment boundary, so one row per mount covers everything beneath it. Fold the Connections and Settings tabs into an Advanced section (4 tabs -> 2); old ?tab= links land on Sources with it expanded. Source rows now name the libraries they feed and warn when a source can never resolve one. API changes are additive within /api/v1: new optional fields on /autoscan/scan-source-plugins only.
163 lines
6.2 KiB
Go
163 lines
6.2 KiB
Go
package autoscan
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
)
|
|
|
|
// Built-in source identities are host-discovered scan-source entries that need
|
|
// no plugin installation. The ARR webhook identity backs webhook-mode sources:
|
|
// Sonarr/Radarr POST directly to Silo, the host parses the payload, and the
|
|
// plugin provider is never invoked for it.
|
|
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
|
|
// identity offered by the Add-source picker alongside installed plugin
|
|
// capabilities.
|
|
func BuiltinArrWebhookSource() DiscoveredSource {
|
|
return 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"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// IsBuiltinArrWebhookIdentity reports whether (pluginID, capabilityID) is the
|
|
// built-in ARR webhook source identity.
|
|
func IsBuiltinArrWebhookIdentity(pluginID, capabilityID string) bool {
|
|
return pluginID == BuiltinArrWebhookPluginID && capabilityID == BuiltinArrWebhookCapabilityID
|
|
}
|
|
|
|
// DiscoveredSource identifies one installed scan_source.v1 capability instance,
|
|
// enriched with the metadata the Add-source picker needs (plugin id + a
|
|
// human-friendly display name).
|
|
type DiscoveredSource struct {
|
|
// PluginID is the installation's plugin id (e.g. "sonarr"); empty when the
|
|
// lister cannot supply it.
|
|
PluginID string
|
|
CapabilityID string
|
|
// 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
|
|
// engine can offer them in the Add-source picker.
|
|
type ScanSourceLister interface {
|
|
// ListScanSources returns one entry per installed scan_source.v1 capability,
|
|
// enriched with plugin id + display name.
|
|
ListScanSources(ctx context.Context) ([]DiscoveredSource, error)
|
|
}
|
|
|
|
// WithBuiltinSources wraps a ScanSourceLister so host-built-in source
|
|
// identities appear beside installed plugin capabilities. inner may be nil
|
|
// (only the builtins are returned), matching the Service's nil-lister
|
|
// tolerance.
|
|
func WithBuiltinSources(inner ScanSourceLister, builtins ...DiscoveredSource) ScanSourceLister {
|
|
return builtinSourceLister{inner: inner, builtins: builtins}
|
|
}
|
|
|
|
type builtinSourceLister struct {
|
|
inner ScanSourceLister
|
|
builtins []DiscoveredSource
|
|
}
|
|
|
|
func (l builtinSourceLister) ListScanSources(ctx context.Context) ([]DiscoveredSource, error) {
|
|
var out []DiscoveredSource
|
|
if l.inner != nil {
|
|
discovered, err := l.inner.ListScanSources(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = discovered
|
|
}
|
|
return append(out, l.builtins...), nil
|
|
}
|
|
|
|
// AvailableScanSource is one installed scan_source capability an operator can
|
|
// create a source against (the Add-source picker list).
|
|
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
|
|
// an operator can pick one when creating a source. When no lister is configured
|
|
// it returns an empty list. The handler also uses this to validate that a
|
|
// create request targets a currently-installed capability.
|
|
func (s *Service) ListAvailableScanSources(ctx context.Context) ([]AvailableScanSource, error) {
|
|
if s.lister == nil {
|
|
return []AvailableScanSource{}, nil
|
|
}
|
|
discovered, err := s.lister.ListScanSources(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list scan sources: %w", err)
|
|
}
|
|
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
|
|
}
|