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.
144 lines
5.3 KiB
Go
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
|
|
}
|