Files
silo-server/internal/autoscan/adminform.go
Quick 4504d7c68e fix(autoscan): read descriptors from installed metadata, tighten source config
Second Codex review pass. Two P1s meant the descriptor mechanism was dead for
every real plugin; both are verified against the pinned SDK's converter.

Backend:
- The SDK's CapabilityRecordsFromManifest nests a capability's arbitrary
  metadata struct under "metadata" rather than flattening it, so an installed
  plugin's contract lives at metadata.metadata.scan_source. The top-level lookup
  never matched, silently resolving every plugin to host defaults. Look in the
  nested location first, keeping the flat path for host-built descriptors.
- The same converter persists config_schema without admin_form, so a plugin's
  form could never reach the UI through that route. The canonical location is
  now config_form inside the typed block, which survives installation; the
  config_schema path is retained only as a fallback.
- Compatibility merging now tracks which fields the manifest actually declared
  instead of comparing against defaults. A plugin explicitly declaring a
  default-valued field was previously indistinguishable from silence and got
  overwritten, contradicting the stated manifest-wins contract.
- Dynamic-option fields are dropped from source config forms: their options come
  from a connection-aware probe only the plugin-config page performs, so a
  required one could never be satisfied and would block creation permanently.

Frontend:
- The mapping editor asked for "the path your download manager reports" and
  suggested /downloads/tv. arrwebhook.Parse consumes episodeFile.path /
  movieFile.path — the arr's imported library paths, not the download client's
  working directory — so following the example produced rewrites that never
  matched. Relabelled to the Sonarr/Radarr root folder.
- Full-state saves no longer carry an invalid config draft. Enabling, renaming,
  or editing an interval serialized the in-progress draft, bypassing the
  validity gate; unrelated mutations now send the persisted config instead.
- Legacy movie_nested_paths/tv_nested_paths migration is scoped to the CephFS
  identity. Applied globally it would rename an unrelated plugin's
  identically-named key on first save, losing its configuration.
- Rows re-parse when the descriptor arrives after /sources. Mounting with the
  default descriptor left stored switch values as strings, so "false" rendered
  as an enabled switch. Done during render rather than in an effect, and skipped
  once the row is dirty so an in-progress edit survives.
- Values hidden by an unsatisfied show_when are no longer persisted, and
  displayed defaults are. Both come from reusing buildSchemaValues, the same
  helper the plugin-config page uses.
- The manual connection form asks which service it is when a descriptor accepts
  several, instead of always recording the first kind.
- The inline connection picker remounts per source, so a half-entered server is
  not carried into a different plugin's descriptor.
- emits_native_paths sources report unknown targets rather than "can't match
  anything": they discover Silo-native paths at poll time and need no
  configured root.
2026-07-29 14:52:16 -04:00

152 lines
6.0 KiB
Go

package autoscan
import (
"encoding/json"
"strings"
)
// 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"
ControlPassword = "PASSWORD"
)
// 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
}
form.Fields = withoutUnsupportedFields(form.Fields)
if len(form.Fields) == 0 {
return nil
}
return &form
}
// withoutUnsupportedFields drops fields the source-config surface cannot honour.
//
// Secret fields: a source's values land in autoscan_sources.source_config,
// which is plain JSONB and is returned verbatim by the source API — unlike
// connection API keys, which go through the repository's encrypted path.
// Rendering a masked input over a value stored in the clear would misrepresent
// how it is held, so the host declines to collect it at all. Plugins needing a
// credential should take a connection instead.
//
// Dynamic-option fields: the shared renderer populates those from a
// connection-aware probe that only the plugin-config page performs. On a source
// form they would render as an empty select, and a required one could never be
// satisfied — permanently blocking creation. Dropping them fails visibly at the
// contract rather than invisibly at the operator.
func withoutUnsupportedFields(fields []AdminFormField) []AdminFormField {
kept := make([]AdminFormField, 0, len(fields))
for _, field := range fields {
if field.Secret || strings.EqualFold(field.Control, ControlPassword) {
continue
}
if field.DynamicOptions {
continue
}
kept = append(kept, field)
}
return kept
}