Files
silo-server/internal/autoscan/adminform.go
T
Quick 251282186e feat(autoscan): make source setup descriptor-driven and self-contained
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.
2026-07-29 14:52:16 -04:00

119 lines
4.7 KiB
Go

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
}