Files
silo-server/internal/autoscan/compat.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

144 lines
5.3 KiB
Go

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
}
// Fill only what the manifest did not state. Comparing values would be
// wrong: a plugin that explicitly declares a value equal to a host default
// is indistinguishable from one that said nothing, so the explicit choice
// would be silently overwritten. DescriptorFromMetadata records which fields
// were actually present, which is what makes "manifest wins" true.
if !declared.Declared(fieldDeliveryModes) && len(compat.DeliveryModes) > 0 {
declared.DeliveryModes = compat.DeliveryModes
}
if !declared.Declared(fieldConnection) && compat.Connection != "" {
declared.Connection = compat.Connection
}
if !declared.Declared(fieldConnectionKinds) {
declared.ConnectionKinds = compat.ConnectionKinds
}
if !declared.Declared(fieldEmitsNativePaths) {
declared.EmitsNativePaths = compat.EmitsNativePaths
}
if !declared.Declared(fieldSummary) {
declared.Summary = compat.Summary
}
if !declared.Declared(fieldIconURL) {
declared.IconURL = compat.IconURL
}
if !declared.Declared(fieldConfigForm) {
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) {
// Both must match. Capability ids are chosen by plugin authors and are not
// unique across plugins, so an OR here would hand CephFS's path/exclusion
// form to any unrelated plugin that happened to name a capability "cephfs".
if pluginID == cephFSPluginID && capabilityID == cephFSCapabilityID {
return cephFSCompatibilityDescriptor(), true
}
return ScanSourceDescriptor{}, false
}