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.
This commit is contained in:
@@ -99,20 +99,51 @@ func (l PluginScanSourceLister) ListScanSources(ctx context.Context) ([]autoscan
|
||||
PluginID: inst.PluginID,
|
||||
CapabilityID: c.ID,
|
||||
DisplayName: scanSourceDisplayName(inst.PluginID, c),
|
||||
Description: scanSourceMetadataString(c, "description"),
|
||||
Descriptor: scanSourceDescriptor(inst.PluginID, c),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// scanSourceMetadataString reads a trimmed string out of a capability's
|
||||
// manifest metadata, returning "" when absent or of the wrong type.
|
||||
func scanSourceMetadataString(c *plugins.Capability, key string) string {
|
||||
if c == nil || c.Metadata == nil {
|
||||
return ""
|
||||
}
|
||||
value, ok := c.Metadata[key].(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
// scanSourceDescriptor resolves the setup contract for a discovered capability.
|
||||
// It reads whatever the manifest declares (falling back to host defaults that
|
||||
// reproduce pre-descriptor behavior), then lets a host-side compatibility
|
||||
// descriptor fill gaps for first-party plugins that have not yet published one
|
||||
// of their own.
|
||||
func scanSourceDescriptor(pluginID string, c *plugins.Capability) autoscan.ScanSourceDescriptor {
|
||||
var metadata map[string]any
|
||||
if c != nil {
|
||||
metadata = c.Metadata
|
||||
}
|
||||
declared := autoscan.DescriptorFromMetadata(metadata)
|
||||
capabilityID := ""
|
||||
if c != nil {
|
||||
capabilityID = c.ID
|
||||
}
|
||||
return autoscan.ApplyCompatibilityDescriptor(pluginID, capabilityID, declared)
|
||||
}
|
||||
|
||||
// scanSourceDisplayName derives a human-friendly label for a scan_source
|
||||
// capability: the capability manifest's display_name when present, else the
|
||||
// plugin id (with the capability id appended when it adds information).
|
||||
func scanSourceDisplayName(pluginID string, c *plugins.Capability) string {
|
||||
if c != nil && c.Metadata != nil {
|
||||
if name, ok := c.Metadata["display_name"].(string); ok && strings.TrimSpace(name) != "" {
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
if name := scanSourceMetadataString(c, "display_name"); name != "" {
|
||||
return name
|
||||
}
|
||||
switch {
|
||||
case pluginID != "" && c != nil && c.ID != "":
|
||||
|
||||
@@ -454,10 +454,15 @@ func (h *AutoscanHandler) HandleListSources(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
// --- Available scan-source plugins (Add-source picker) ---
|
||||
|
||||
// autoscanScanSourcePluginResponse describes one creatable scan source. The
|
||||
// descriptor is additive: clients that predate it keep reading the same three
|
||||
// identity fields.
|
||||
type autoscanScanSourcePluginResponse struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Descriptor autoscan.ScanSourceDescriptor `json:"descriptor"`
|
||||
}
|
||||
|
||||
// HandleListAvailableScanSources returns every installed scan_source capability
|
||||
@@ -474,6 +479,8 @@ func (h *AutoscanHandler) HandleListAvailableScanSources(w http.ResponseWriter,
|
||||
PluginID: a.PluginID,
|
||||
CapabilityID: a.CapabilityID,
|
||||
DisplayName: a.DisplayName,
|
||||
Description: a.Description,
|
||||
Descriptor: a.Descriptor,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
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
|
||||
}
|
||||
|
||||
// A manifest that declares nothing resolves to the default descriptor, so
|
||||
// "still default" is the signal that the plugin has not spoken. Comparing
|
||||
// against the default (rather than the zero value) is what distinguishes
|
||||
// "unset" from "deliberately set to the same thing".
|
||||
def := DefaultScanSourceDescriptor()
|
||||
|
||||
if equalStrings(declared.DeliveryModes, def.DeliveryModes) && len(compat.DeliveryModes) > 0 {
|
||||
declared.DeliveryModes = compat.DeliveryModes
|
||||
}
|
||||
if declared.Connection == def.Connection && compat.Connection != "" {
|
||||
declared.Connection = compat.Connection
|
||||
}
|
||||
if len(declared.ConnectionKinds) == 0 {
|
||||
declared.ConnectionKinds = compat.ConnectionKinds
|
||||
}
|
||||
if !declared.EmitsNativePaths {
|
||||
declared.EmitsNativePaths = compat.EmitsNativePaths
|
||||
}
|
||||
if declared.Summary == "" {
|
||||
declared.Summary = compat.Summary
|
||||
}
|
||||
if declared.IconURL == "" {
|
||||
declared.IconURL = compat.IconURL
|
||||
}
|
||||
if declared.ConfigForm == nil {
|
||||
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) {
|
||||
if pluginID == cephFSPluginID || capabilityID == cephFSCapabilityID {
|
||||
return cephFSCompatibilityDescriptor(), true
|
||||
}
|
||||
return ScanSourceDescriptor{}, false
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package autoscan
|
||||
|
||||
import "strings"
|
||||
|
||||
// ConnectionRequirement says whether a scan source needs upstream credentials
|
||||
// to reach its provider. It drives whether the Add-source flow shows the
|
||||
// connection step at all, replacing the UI's per-plugin-id special cases.
|
||||
type ConnectionRequirement string
|
||||
|
||||
const (
|
||||
// ConnectionNone means the source reaches its provider without host-held
|
||||
// credentials — a local filesystem watcher, or a provider that pushes to a
|
||||
// Silo webhook endpoint.
|
||||
ConnectionNone ConnectionRequirement = "none"
|
||||
// ConnectionOptional means the source can bind a connection but works
|
||||
// without one. This is the default for a capability that declares nothing,
|
||||
// because it is what every source behaved as before descriptors existed.
|
||||
ConnectionOptional ConnectionRequirement = "optional"
|
||||
// ConnectionRequired means the source cannot poll until a connection is
|
||||
// bound.
|
||||
ConnectionRequired ConnectionRequirement = "required"
|
||||
)
|
||||
|
||||
// normalizeConnectionRequirement maps a manifest-supplied string onto a known
|
||||
// requirement, falling back to ConnectionOptional for absent or unrecognized
|
||||
// values so an unknown future value never hides the connection step outright.
|
||||
func normalizeConnectionRequirement(value string) ConnectionRequirement {
|
||||
switch ConnectionRequirement(strings.ToLower(strings.TrimSpace(value))) {
|
||||
case ConnectionNone:
|
||||
return ConnectionNone
|
||||
case ConnectionRequired:
|
||||
return ConnectionRequired
|
||||
default:
|
||||
return ConnectionOptional
|
||||
}
|
||||
}
|
||||
|
||||
// ScanSourceDescriptor is the host-facing setup contract for one scan_source
|
||||
// capability: everything the Add-source flow needs to build its steps without
|
||||
// launching the plugin. It mirrors the precedent set by the SDK's typed
|
||||
// WatchSyncProviderDescriptor, and is read from the capability's manifest
|
||||
// metadata (see DescriptorFromMetadata).
|
||||
//
|
||||
// Every field is optional in a manifest. A capability that declares nothing
|
||||
// resolves to DefaultScanSourceDescriptor, which reproduces the pre-descriptor
|
||||
// behavior exactly, so existing installs are unaffected.
|
||||
type ScanSourceDescriptor struct {
|
||||
// DeliveryModes lists how changes reach the host (DeliveryModePoll and/or
|
||||
// DeliveryModeWebhook). A single entry lets the flow skip its delivery step.
|
||||
DeliveryModes []string `json:"delivery_modes"`
|
||||
// Connection says whether upstream credentials are needed.
|
||||
Connection ConnectionRequirement `json:"connection"`
|
||||
// ConnectionKinds restricts which stored connections may be bound (e.g.
|
||||
// "sonarr", "radarr"). Empty means any connection is offerable.
|
||||
ConnectionKinds []string `json:"connection_kinds"`
|
||||
// EmitsNativePaths reports that the plugin already returns Silo-native
|
||||
// paths, so the host can skip prompting for path rewrites.
|
||||
EmitsNativePaths bool `json:"emits_native_paths"`
|
||||
// Summary is a short operator-facing sentence for the picker card.
|
||||
Summary string `json:"summary"`
|
||||
// IconURL optionally points at a plugin-served icon, same convention as an
|
||||
// auth provider's icon_url.
|
||||
IconURL string `json:"icon_url"`
|
||||
// ConfigForm describes the per-source configuration fields the operator
|
||||
// fills in when creating the source. It carries the capability's own
|
||||
// admin_form so plugin-specific knobs render generically instead of being
|
||||
// hardcoded in the admin UI.
|
||||
ConfigForm *AdminForm `json:"config_form,omitempty"`
|
||||
}
|
||||
|
||||
// SupportsDeliveryMode reports whether the descriptor allows the given mode.
|
||||
func (d ScanSourceDescriptor) SupportsDeliveryMode(mode string) bool {
|
||||
for _, m := range d.DeliveryModes {
|
||||
if m == mode {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DefaultDeliveryMode returns the mode a new source should use when the
|
||||
// operator is not asked to choose. Poll wins when available because it is the
|
||||
// historical default; a webhook-only source resolves to webhook.
|
||||
func (d ScanSourceDescriptor) DefaultDeliveryMode() string {
|
||||
if d.SupportsDeliveryMode(DeliveryModePoll) {
|
||||
return DeliveryModePoll
|
||||
}
|
||||
if len(d.DeliveryModes) > 0 {
|
||||
return d.DeliveryModes[0]
|
||||
}
|
||||
return DeliveryModePoll
|
||||
}
|
||||
|
||||
// DefaultScanSourceDescriptor is what a capability that declares no scan-source
|
||||
// metadata resolves to: host-polled, connection allowed but not demanded, and
|
||||
// path rewrites still offered. This is exactly how every source behaved before
|
||||
// descriptors existed, which is what keeps already-installed plugins working.
|
||||
func DefaultScanSourceDescriptor() ScanSourceDescriptor {
|
||||
return ScanSourceDescriptor{
|
||||
DeliveryModes: []string{DeliveryModePoll},
|
||||
Connection: ConnectionOptional,
|
||||
}
|
||||
}
|
||||
|
||||
// DescriptorFromMetadata builds a descriptor from a capability's manifest
|
||||
// metadata map, filling anything absent from DefaultScanSourceDescriptor.
|
||||
//
|
||||
// The metadata arrives as decoded JSON (map[string]any), so every read is
|
||||
// tolerant: a wrong type is treated as absent rather than failing the whole
|
||||
// capability, because a malformed descriptor must not make an otherwise working
|
||||
// plugin undiscoverable.
|
||||
func DescriptorFromMetadata(metadata map[string]any) ScanSourceDescriptor {
|
||||
out := DefaultScanSourceDescriptor()
|
||||
if metadata == nil {
|
||||
return out
|
||||
}
|
||||
|
||||
// The config form is read first and independently of the typed block: a
|
||||
// capability may describe its per-source fields through config_schema
|
||||
// without declaring any scan-source behavior at all.
|
||||
out.ConfigForm = configFormFromMetadata(metadata)
|
||||
|
||||
raw, ok := metadata["scan_source"].(map[string]any)
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
|
||||
if modes := stringSlice(raw["delivery_modes"]); len(modes) > 0 {
|
||||
valid := make([]string, 0, len(modes))
|
||||
for _, m := range modes {
|
||||
switch m := strings.ToLower(strings.TrimSpace(m)); m {
|
||||
case DeliveryModePoll, DeliveryModeWebhook:
|
||||
valid = append(valid, m)
|
||||
}
|
||||
}
|
||||
// Only adopt the declared list when at least one mode is understood;
|
||||
// otherwise keep the default so the source stays creatable.
|
||||
if len(valid) > 0 {
|
||||
out.DeliveryModes = valid
|
||||
}
|
||||
}
|
||||
|
||||
if connection, ok := raw["connection"].(string); ok {
|
||||
out.Connection = normalizeConnectionRequirement(connection)
|
||||
}
|
||||
if kinds := stringSlice(raw["connection_kinds"]); len(kinds) > 0 {
|
||||
out.ConnectionKinds = kinds
|
||||
}
|
||||
if native, ok := raw["emits_native_paths"].(bool); ok {
|
||||
out.EmitsNativePaths = native
|
||||
}
|
||||
if summary, ok := raw["summary"].(string); ok {
|
||||
out.Summary = strings.TrimSpace(summary)
|
||||
}
|
||||
if icon, ok := raw["icon_url"].(string); ok {
|
||||
out.IconURL = strings.TrimSpace(icon)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// scanSourceConfigKey is the config_schema key a scan_source capability uses to
|
||||
// describe its per-source fields. A capability's config_schema may hold several
|
||||
// entries; only this one describes the Add-source form, so the rest (plugin-wide
|
||||
// settings) keep rendering on the Plugins page as they do today.
|
||||
const scanSourceConfigKey = "scan_source"
|
||||
|
||||
// configFormFromMetadata finds the per-source admin form in a capability's
|
||||
// metadata. It prefers an explicit form nested under the typed scan_source
|
||||
// block, then falls back to the capability's config_schema entry keyed
|
||||
// "scan_source".
|
||||
func configFormFromMetadata(metadata map[string]any) *AdminForm {
|
||||
if raw, ok := metadata["scan_source"].(map[string]any); ok {
|
||||
if form := adminFormFromMetadata(raw["config_form"]); form != nil {
|
||||
return form
|
||||
}
|
||||
}
|
||||
|
||||
entries, ok := metadata["config_schema"].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, entry := range entries {
|
||||
schema, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if key, _ := schema["key"].(string); key != scanSourceConfigKey {
|
||||
continue
|
||||
}
|
||||
return adminFormFromMetadata(schema["admin_form"])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// stringSlice coerces a decoded-JSON value into a trimmed, non-empty string
|
||||
// slice. Both []any (fresh JSON) and []string (in-process construction) are
|
||||
// accepted; anything else yields nil.
|
||||
func stringSlice(value any) []string {
|
||||
var raw []string
|
||||
switch typed := value.(type) {
|
||||
case []string:
|
||||
raw = typed
|
||||
case []any:
|
||||
for _, entry := range typed {
|
||||
text, ok := entry.(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
raw = append(raw, text)
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, entry := range raw {
|
||||
if trimmed := strings.TrimSpace(entry); trimmed != "" {
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package autoscan
|
||||
|
||||
import "testing"
|
||||
|
||||
// A capability that declares nothing must behave exactly as sources did before
|
||||
// descriptors existed: host-polled, connection allowed but not demanded. This
|
||||
// is the guarantee that keeps already-installed plugins working.
|
||||
func TestDescriptorFromMetadataDefaultsPreserveLegacyBehavior(t *testing.T) {
|
||||
for name, metadata := range map[string]map[string]any{
|
||||
"nil metadata": nil,
|
||||
"empty metadata": {},
|
||||
"unrelated keys": {"display_name": "Something"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got := DescriptorFromMetadata(metadata)
|
||||
if got.Connection != ConnectionOptional {
|
||||
t.Errorf("connection = %q, want %q", got.Connection, ConnectionOptional)
|
||||
}
|
||||
if !got.SupportsDeliveryMode(DeliveryModePoll) {
|
||||
t.Errorf("delivery modes = %v, want poll", got.DeliveryModes)
|
||||
}
|
||||
if got.SupportsDeliveryMode(DeliveryModeWebhook) {
|
||||
t.Errorf("delivery modes = %v, must not default to webhook", got.DeliveryModes)
|
||||
}
|
||||
if got.EmitsNativePaths {
|
||||
t.Error("emits_native_paths must default false so rewrites stay offered")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescriptorFromMetadataReadsDeclaredContract(t *testing.T) {
|
||||
got := DescriptorFromMetadata(map[string]any{
|
||||
"scan_source": map[string]any{
|
||||
"delivery_modes": []any{"webhook"},
|
||||
"connection": "none",
|
||||
"connection_kinds": []any{"sonarr", "radarr"},
|
||||
"emits_native_paths": true,
|
||||
"summary": " Pushes to Silo. ",
|
||||
"icon_url": "/assets/icon.svg",
|
||||
},
|
||||
})
|
||||
|
||||
if got.Connection != ConnectionNone {
|
||||
t.Errorf("connection = %q, want none", got.Connection)
|
||||
}
|
||||
if got.SupportsDeliveryMode(DeliveryModePoll) {
|
||||
t.Errorf("delivery modes = %v, want webhook only", got.DeliveryModes)
|
||||
}
|
||||
if !got.EmitsNativePaths {
|
||||
t.Error("emits_native_paths not read")
|
||||
}
|
||||
if got.Summary != "Pushes to Silo." {
|
||||
t.Errorf("summary = %q, want trimmed", got.Summary)
|
||||
}
|
||||
if len(got.ConnectionKinds) != 2 {
|
||||
t.Errorf("connection kinds = %v", got.ConnectionKinds)
|
||||
}
|
||||
}
|
||||
|
||||
// A malformed or unrecognized descriptor must degrade to defaults rather than
|
||||
// make an otherwise working plugin undiscoverable.
|
||||
func TestDescriptorFromMetadataToleratesBadValues(t *testing.T) {
|
||||
tests := map[string]map[string]any{
|
||||
"wrong block type": {"scan_source": "not-an-object"},
|
||||
"unknown modes": {"scan_source": map[string]any{
|
||||
"delivery_modes": []any{"telepathy"},
|
||||
}},
|
||||
"wrong mode element type": {"scan_source": map[string]any{
|
||||
"delivery_modes": []any{7},
|
||||
}},
|
||||
"unknown connection value": {"scan_source": map[string]any{
|
||||
"connection": "sometimes",
|
||||
}},
|
||||
}
|
||||
|
||||
for name, metadata := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got := DescriptorFromMetadata(metadata)
|
||||
if !got.SupportsDeliveryMode(DeliveryModePoll) {
|
||||
t.Errorf("delivery modes = %v, want fallback to poll", got.DeliveryModes)
|
||||
}
|
||||
if got.Connection != ConnectionOptional {
|
||||
t.Errorf("connection = %q, want fallback to optional", got.Connection)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescriptorReadsConfigFormFromCapabilityConfigSchema(t *testing.T) {
|
||||
got := DescriptorFromMetadata(map[string]any{
|
||||
"config_schema": []any{
|
||||
map[string]any{"key": "unrelated", "admin_form": map[string]any{
|
||||
"fields": []any{map[string]any{"key": "ignored", "label": "Ignored"}},
|
||||
}},
|
||||
map[string]any{"key": "scan_source", "admin_form": map[string]any{
|
||||
"fields": []any{
|
||||
map[string]any{"key": "root", "label": "Root path", "control": "TEXT"},
|
||||
},
|
||||
}},
|
||||
},
|
||||
})
|
||||
|
||||
if got.ConfigForm == nil {
|
||||
t.Fatal("expected config form from the scan_source config_schema entry")
|
||||
}
|
||||
if len(got.ConfigForm.Fields) != 1 || got.ConfigForm.Fields[0].Key != "root" {
|
||||
t.Fatalf("wrong form picked: %+v", got.ConfigForm.Fields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultDeliveryModePrefersPoll(t *testing.T) {
|
||||
webhookOnly := ScanSourceDescriptor{DeliveryModes: []string{DeliveryModeWebhook}}
|
||||
if got := webhookOnly.DefaultDeliveryMode(); got != DeliveryModeWebhook {
|
||||
t.Errorf("webhook-only default = %q", got)
|
||||
}
|
||||
|
||||
both := ScanSourceDescriptor{DeliveryModes: []string{DeliveryModeWebhook, DeliveryModePoll}}
|
||||
if got := both.DefaultDeliveryMode(); got != DeliveryModePoll {
|
||||
t.Errorf("dual-mode default = %q, want poll", got)
|
||||
}
|
||||
|
||||
// An empty descriptor must still name a usable mode.
|
||||
if got := (ScanSourceDescriptor{}).DefaultDeliveryMode(); got != DeliveryModePoll {
|
||||
t.Errorf("empty default = %q, want poll", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The builtin arr webhook identity is the host's own descriptor, and the UI
|
||||
// relies on it to skip both the delivery and connection steps.
|
||||
func TestBuiltinArrWebhookDescriptor(t *testing.T) {
|
||||
d := BuiltinArrWebhookSource().Descriptor
|
||||
|
||||
if d.Connection != ConnectionNone {
|
||||
t.Errorf("connection = %q, want none (the arr pushes to Silo)", d.Connection)
|
||||
}
|
||||
if d.SupportsDeliveryMode(DeliveryModePoll) {
|
||||
t.Errorf("delivery modes = %v, want webhook only", d.DeliveryModes)
|
||||
}
|
||||
if d.ConfigForm == nil || len(d.ConfigForm.Fields) == 0 {
|
||||
t.Fatal("expected a provider config form")
|
||||
}
|
||||
if d.ConfigForm.Fields[0].Key != WebhookProviderConfigKey {
|
||||
t.Errorf("form field = %q, want %q", d.ConfigForm.Fields[0].Key, WebhookProviderConfigKey)
|
||||
}
|
||||
}
|
||||
|
||||
// The compatibility layer is a stopgap for first-party plugins that have not
|
||||
// published a descriptor yet. A plugin that declares its own contract must win.
|
||||
func TestApplyCompatibilityDescriptorFillsOnlyUnsetFields(t *testing.T) {
|
||||
declared := DescriptorFromMetadata(nil) // plugin said nothing
|
||||
got := ApplyCompatibilityDescriptor(cephFSPluginID, cephFSCapabilityID, declared)
|
||||
|
||||
if got.Connection != ConnectionNone {
|
||||
t.Errorf("connection = %q, want none from compat", got.Connection)
|
||||
}
|
||||
if got.ConfigForm == nil {
|
||||
t.Fatal("expected compat config form for cephfs")
|
||||
}
|
||||
if got.Summary == "" {
|
||||
t.Error("expected compat summary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCompatibilityDescriptorDoesNotOverrideManifest(t *testing.T) {
|
||||
declared := DescriptorFromMetadata(map[string]any{
|
||||
"scan_source": map[string]any{
|
||||
"connection": "required",
|
||||
"summary": "Plugin's own words",
|
||||
},
|
||||
})
|
||||
got := ApplyCompatibilityDescriptor(cephFSPluginID, cephFSCapabilityID, declared)
|
||||
|
||||
if got.Connection != ConnectionRequired {
|
||||
t.Errorf("connection = %q, manifest must win over compat", got.Connection)
|
||||
}
|
||||
if got.Summary != "Plugin's own words" {
|
||||
t.Errorf("summary = %q, manifest must win over compat", got.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCompatibilityDescriptorLeavesUnknownPluginsAlone(t *testing.T) {
|
||||
declared := DescriptorFromMetadata(nil)
|
||||
got := ApplyCompatibilityDescriptor("com.example.future", "watcher", declared)
|
||||
|
||||
if got.ConfigForm != nil {
|
||||
t.Error("unknown plugin must not inherit another plugin's form")
|
||||
}
|
||||
if got.Connection != ConnectionOptional {
|
||||
t.Errorf("connection = %q, want untouched default", got.Connection)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,11 @@ 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
|
||||
@@ -23,6 +28,32 @@ func BuiltinArrWebhookSource() 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"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +74,13 @@ type DiscoveredSource struct {
|
||||
// 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
|
||||
@@ -84,6 +122,11 @@ 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
|
||||
@@ -100,10 +143,19 @@ func (s *Service) ListAvailableScanSources(ctx context.Context) ([]AvailableScan
|
||||
}
|
||||
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
|
||||
|
||||
@@ -26,8 +26,17 @@ func TestListAvailableScanSourcesEnumeratesInstalled(t *testing.T) {
|
||||
if len(available) != 2 {
|
||||
t.Fatalf("expected 2 available, got %d: %+v", len(available), available)
|
||||
}
|
||||
if available[0] != (AvailableScanSource{PluginID: "sonarr", CapabilityID: "arr-a", DisplayName: "Sonarr"}) {
|
||||
t.Fatalf("unexpected first available: %+v", available[0])
|
||||
got := available[0]
|
||||
if got.PluginID != "sonarr" || got.CapabilityID != "arr-a" || got.DisplayName != "Sonarr" {
|
||||
t.Fatalf("unexpected first available: %+v", got)
|
||||
}
|
||||
// A lister that supplies no descriptor must still yield the default one, so
|
||||
// every consumer can rely on the field being populated.
|
||||
if got.Descriptor.Connection != ConnectionOptional {
|
||||
t.Fatalf("expected default connection requirement, got %q", got.Descriptor.Connection)
|
||||
}
|
||||
if !got.Descriptor.SupportsDeliveryMode(DeliveryModePoll) {
|
||||
t.Fatalf("expected default poll delivery mode, got %+v", got.Descriptor.DeliveryModes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +56,7 @@ func TestWithBuiltinSourcesAppendsToInner(t *testing.T) {
|
||||
if discovered[0].PluginID != "sonarr" {
|
||||
t.Fatalf("plugin entries must pass through first, got %+v", discovered[0])
|
||||
}
|
||||
if discovered[1] != BuiltinArrWebhookSource() {
|
||||
if !isBuiltinArrWebhookSource(discovered[1]) {
|
||||
t.Fatalf("expected builtin appended, got %+v", discovered[1])
|
||||
}
|
||||
}
|
||||
@@ -59,11 +68,22 @@ func TestWithBuiltinSourcesNilInner(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("ListScanSources: %v", err)
|
||||
}
|
||||
if len(discovered) != 1 || discovered[0] != BuiltinArrWebhookSource() {
|
||||
if len(discovered) != 1 || !isBuiltinArrWebhookSource(discovered[0]) {
|
||||
t.Fatalf("expected only builtin, got %+v", discovered)
|
||||
}
|
||||
}
|
||||
|
||||
// isBuiltinArrWebhookSource compares the identifying fields of a discovered
|
||||
// source. DiscoveredSource carries slices (delivery modes, connection kinds) so
|
||||
// it is no longer comparable with ==, and these tests only care that the
|
||||
// builtin identity came through.
|
||||
func isBuiltinArrWebhookSource(got DiscoveredSource) bool {
|
||||
want := BuiltinArrWebhookSource()
|
||||
return got.PluginID == want.PluginID &&
|
||||
got.CapabilityID == want.CapabilityID &&
|
||||
got.DisplayName == want.DisplayName
|
||||
}
|
||||
|
||||
func TestIsBuiltinArrWebhookIdentity(t *testing.T) {
|
||||
if !IsBuiltinArrWebhookIdentity(BuiltinArrWebhookPluginID, BuiltinArrWebhookCapabilityID) {
|
||||
t.Fatal("builtin identity must match")
|
||||
|
||||
@@ -2107,10 +2107,35 @@ export interface AutoscanSourcesResponse {
|
||||
sources: AutoscanSource[];
|
||||
}
|
||||
|
||||
/** Whether a scan source needs upstream credentials to reach its provider. */
|
||||
export type AutoscanConnectionRequirement = "none" | "optional" | "required";
|
||||
|
||||
/**
|
||||
* The setup contract for one scan source, declared by its plugin manifest and
|
||||
* resolved host-side. The Add-source flow builds its steps from this rather
|
||||
* than branching on plugin ids, so a new plugin configures itself.
|
||||
*
|
||||
* The host always populates this, substituting defaults (poll + optional
|
||||
* connection) for capabilities that declare nothing.
|
||||
*/
|
||||
export interface AutoscanScanSourceDescriptor {
|
||||
delivery_modes: AutoscanDeliveryMode[];
|
||||
connection: AutoscanConnectionRequirement;
|
||||
connection_kinds?: string[];
|
||||
/** Plugin already emits Silo-native paths, so path rewrites can be skipped. */
|
||||
emits_native_paths?: boolean;
|
||||
summary?: string;
|
||||
icon_url?: string;
|
||||
/** Per-source config fields, rendered by the shared plugin SchemaForm. */
|
||||
config_form?: PluginAdminForm;
|
||||
}
|
||||
|
||||
export interface AutoscanAvailableSource {
|
||||
plugin_id: string;
|
||||
capability_id: string;
|
||||
display_name: string;
|
||||
description?: string;
|
||||
descriptor: AutoscanScanSourceDescriptor;
|
||||
}
|
||||
|
||||
export interface AutoscanAvailableSourcesResponse {
|
||||
@@ -3442,6 +3467,13 @@ export interface PluginAdminFormField {
|
||||
show_when?: PluginAdminFormCondition[];
|
||||
validation?: PluginAdminFormValidation;
|
||||
exclusive_group_field?: string;
|
||||
/**
|
||||
* Names a host-known value this field can be populated from in one click.
|
||||
* Used by autoscan source config so a path field can be filled from Silo's
|
||||
* own library paths without the UI knowing which plugin owns it. Unknown
|
||||
* values render no action.
|
||||
*/
|
||||
fill_from?: string;
|
||||
}
|
||||
|
||||
export interface PluginCapability {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { Play } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Play } from "lucide-react";
|
||||
import type { AutoscanSettings } from "@/api/types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -21,13 +21,25 @@ import SourcesPanel from "@/pages/admin/autoscan/SourcesPanel";
|
||||
// Tab routing helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AUTOSCAN_TABS = ["sources", "activity", "connections", "settings"] as const;
|
||||
const AUTOSCAN_TABS = ["sources", "activity"] as const;
|
||||
type AutoscanTab = (typeof AUTOSCAN_TABS)[number];
|
||||
|
||||
/**
|
||||
* Connections and settings used to be peer tabs, which read as "set these up
|
||||
* first" — most operators never needed either. They now live in an Advanced
|
||||
* section on the Sources view, so their old deep links land on Sources with
|
||||
* that section already open rather than 404-ing into a missing tab.
|
||||
*/
|
||||
const LEGACY_ADVANCED_TABS = new Set(["connections", "settings"]);
|
||||
|
||||
function normalizeTab(value: string | null): AutoscanTab {
|
||||
return AUTOSCAN_TABS.includes(value as AutoscanTab) ? (value as AutoscanTab) : "sources";
|
||||
}
|
||||
|
||||
function isLegacyAdvancedTab(value: string | null): boolean {
|
||||
return value !== null && LEGACY_ADVANCED_TABS.has(value);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings tab
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -65,14 +77,9 @@ function SettingsTab() {
|
||||
|
||||
return (
|
||||
<div className="max-w-lg space-y-6">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Autoscan can be enabled or disabled from the toggle in the page header. Tune polling
|
||||
behavior below.
|
||||
</p>
|
||||
|
||||
{/* Default poll interval */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="default-poll-interval">Default poll interval (seconds)</Label>
|
||||
<Label htmlFor="default-poll-interval">Default check interval (seconds)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="default-poll-interval"
|
||||
@@ -88,7 +95,7 @@ function SettingsTab() {
|
||||
<span className="text-muted-foreground text-sm">sec</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Used for sources that have no per-source interval set.
|
||||
Used by sources that don't set their own.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -121,11 +128,16 @@ function SettingsTab() {
|
||||
|
||||
export default function AdminAutoscan() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeTab = normalizeTab(searchParams.get("tab"));
|
||||
const requestedTab = searchParams.get("tab");
|
||||
const activeTab = normalizeTab(requestedTab);
|
||||
const trigger = useTriggerAutoscan();
|
||||
const settings = useAutoscanSettings();
|
||||
const updateSettings = useUpdateAutoscanSettings();
|
||||
|
||||
// Open Advanced automatically when arriving from an old connections/settings
|
||||
// link, so a bookmark still lands on the thing it pointed at.
|
||||
const [advancedOpen, setAdvancedOpen] = useState(() => isLegacyAdvancedTab(requestedTab));
|
||||
|
||||
const enabled = settings.data?.enabled ?? false;
|
||||
|
||||
function toggleEnabled(checked: boolean) {
|
||||
@@ -161,9 +173,8 @@ export default function AdminAutoscan() {
|
||||
))}
|
||||
</div>
|
||||
<p className="text-muted-foreground max-w-2xl text-sm leading-6">
|
||||
Automatically detect library changes from installed scan-source plugins. Install them
|
||||
from the Plugins page, configure each source, bind a connection if it needs one, and
|
||||
tune polling intervals.
|
||||
Silo re-scans a library as soon as something changes, instead of waiting for the next
|
||||
scheduled scan. Add a source for each thing you want watched.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -195,25 +206,53 @@ export default function AdminAutoscan() {
|
||||
<TabsList variant="line" className="border-border w-full justify-start border-b">
|
||||
<TabsTrigger value="sources">Sources</TabsTrigger>
|
||||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||
<TabsTrigger value="connections">Connections</TabsTrigger>
|
||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="sources">
|
||||
<TabsContent value="sources" className="space-y-6">
|
||||
<SourcesPanel />
|
||||
|
||||
{/* Advanced — servers and polling defaults. Collapsed by default
|
||||
because most setups never need either: a webhook source needs no
|
||||
server, and the default interval is usually fine. */}
|
||||
<section className="border-border rounded-lg border">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-4 py-3 text-left"
|
||||
onClick={() => setAdvancedOpen((open) => !open)}
|
||||
aria-expanded={advancedOpen}
|
||||
aria-controls="autoscan-advanced"
|
||||
>
|
||||
{advancedOpen ? (
|
||||
<ChevronDown className="text-muted-foreground size-4 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="text-muted-foreground size-4 shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium">Advanced</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Saved servers and polling defaults
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{advancedOpen && (
|
||||
<div id="autoscan-advanced" className="space-y-8 border-t px-4 py-5">
|
||||
<ConnectionsPanel />
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-sm font-medium">Polling defaults</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Applied to sources that don't override them.
|
||||
</p>
|
||||
</div>
|
||||
<SettingsTab />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="activity">
|
||||
<ActivityPanel />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="connections">
|
||||
<ConnectionsPanel />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings">
|
||||
<SettingsTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* A selectable card used by the Add-source flow. Cards rather than a dropdown
|
||||
* because each option needs a sentence explaining what it does — the choice
|
||||
* between "Sonarr pushes to Silo" and "Silo polls Sonarr" is meaningless
|
||||
* without it, and a <select> has nowhere to put that.
|
||||
*/
|
||||
export function ChoiceCard({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
badge,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
/** Short qualifier shown beside the title, e.g. "Recommended". */
|
||||
badge?: string;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"rounded-lg border p-3 text-left transition-colors",
|
||||
selected ? "border-primary bg-accent" : "border-border hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
<span className="flex flex-wrap items-center gap-1.5 text-sm font-medium">
|
||||
{icon}
|
||||
{title}
|
||||
{badge && (
|
||||
<span className="bg-success/15 text-success rounded-full px-2 py-0.5 text-[10px] font-semibold tracking-wide uppercase">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{description && (
|
||||
<span className="text-muted-foreground mt-1 block text-xs leading-relaxed">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Numbered progress indicator for the Add-source flow. Steps are supplied by
|
||||
* the caller because their number varies per source: a webhook-only watcher
|
||||
* needing no credentials genuinely has fewer questions than a pollable arr.
|
||||
*/
|
||||
export function StepTrail({ steps, currentIndex }: { steps: string[]; currentIndex: number }) {
|
||||
if (steps.length < 2) return null;
|
||||
|
||||
return (
|
||||
<ol className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{steps.map((step, index) => {
|
||||
const done = index < currentIndex;
|
||||
const active = index === currentIndex;
|
||||
return (
|
||||
<li key={step} className="flex items-center gap-2">
|
||||
{index > 0 && <span className="bg-border hidden h-px w-6 sm:block" aria-hidden />}
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 text-xs",
|
||||
active ? "text-foreground font-medium" : "text-muted-foreground",
|
||||
)}
|
||||
aria-current={active ? "step" : undefined}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"grid size-5 shrink-0 place-items-center rounded-full border text-[10px] font-semibold",
|
||||
active && "bg-foreground text-background border-transparent",
|
||||
done && "border-success/40 bg-success/15 text-success",
|
||||
)}
|
||||
>
|
||||
{done ? "✓" : index + 1}
|
||||
</span>
|
||||
{step}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -130,7 +130,17 @@ export default function ConnectionsPanel() {
|
||||
|
||||
function openAddDialog() {
|
||||
setTestResult(null);
|
||||
setDialog({ ...BLANK_DIALOG, open: true });
|
||||
// Default to reusing a server already configured under Requests when one
|
||||
// exists. Silo already holds those credentials, so making "enter your own"
|
||||
// the default was asking operators to type the same API key twice — the
|
||||
// single most common piece of duplicated setup.
|
||||
const firstIntegration = arrIntegrations[0];
|
||||
setDialog({
|
||||
...BLANK_DIALOG,
|
||||
open: true,
|
||||
mode: firstIntegration ? "reuse" : "own",
|
||||
requestIntegrationId: firstIntegration?.id ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
function openEditDialog(conn: AutoscanConnection) {
|
||||
@@ -354,8 +364,10 @@ export default function ConnectionsPanel() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="own">Enter own credentials</SelectItem>
|
||||
<SelectItem value="reuse">Reuse from Requests</SelectItem>
|
||||
<SelectItem value="reuse" disabled={arrIntegrations.length === 0}>
|
||||
Reuse a server from Requests
|
||||
</SelectItem>
|
||||
<SelectItem value="own">Enter credentials manually</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import { useState } from "react";
|
||||
import { CheckCircle2, Plus, XCircle } from "lucide-react";
|
||||
|
||||
import type { AutoscanConnectionTestInput, AutoscanConnectionTestResult } from "@/api/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
useCreateAutoscanConnection,
|
||||
useTestAutoscanConnection,
|
||||
} from "@/hooks/queries/useAutoscan";
|
||||
import { useRequestIntegrations } from "@/hooks/queries/useRequests";
|
||||
import type { RequestIntegration } from "@/api/types";
|
||||
|
||||
export interface ConnectionOption {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
/** Sentinel values for the picker's non-connection choices. */
|
||||
const NONE = "__none__";
|
||||
const ADD_NEW = "__add__";
|
||||
|
||||
/** The arr service kind lives in plugin_config; it is the sole source of truth. */
|
||||
function integrationKind(integration: RequestIntegration): string {
|
||||
const kind = integration.plugin_config?.["service_kind"];
|
||||
return typeof kind === "string" ? kind : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection selector for the Add-source flow, with inline creation.
|
||||
*
|
||||
* Previously an operator who had not yet made a connection hit a dead end here:
|
||||
* the dropdown offered only "no connection", so they had to cancel, create one
|
||||
* under Advanced, and start the flow again. A connection is nullable and only
|
||||
* read at poll time, so creating one mid-flow is safe and needs no new API.
|
||||
*
|
||||
* When Requests already holds a matching Sonarr/Radarr, that is offered first —
|
||||
* Silo has the credentials, so asking for them again is pure duplication.
|
||||
*/
|
||||
export function InlineConnectionPicker({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
required,
|
||||
connectionKinds,
|
||||
idPrefix = "conn",
|
||||
}: {
|
||||
/** Selected connection id, or "" for none. */
|
||||
value: string;
|
||||
onChange: (connectionId: string) => void;
|
||||
options: ConnectionOption[];
|
||||
required: boolean;
|
||||
/** Connection kinds this source accepts; empty means any. */
|
||||
connectionKinds: string[];
|
||||
idPrefix?: string;
|
||||
}) {
|
||||
const createConnection = useCreateAutoscanConnection();
|
||||
const testConnection = useTestAutoscanConnection();
|
||||
const requestIntegrations = useRequestIntegrations();
|
||||
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [reuseId, setReuseId] = useState("");
|
||||
const [testResult, setTestResult] = useState<AutoscanConnectionTestResult | null>(null);
|
||||
|
||||
// Requests integrations this source could bind, minus any already saved as a
|
||||
// connection — re-offering those would create a duplicate of the same server.
|
||||
const linkedIntegrationIds = new Set(options.map((option) => option.id));
|
||||
const reusable = (requestIntegrations.data ?? []).filter((integration) => {
|
||||
const kind = integrationKind(integration);
|
||||
if (!kind) return false;
|
||||
if (connectionKinds.length > 0 && !connectionKinds.includes(kind)) return false;
|
||||
return !linkedIntegrationIds.has(integration.id);
|
||||
});
|
||||
|
||||
const defaultKind = connectionKinds[0] ?? "sonarr";
|
||||
|
||||
function resetDraft() {
|
||||
setName("");
|
||||
setBaseUrl("");
|
||||
setApiKey("");
|
||||
setReuseId("");
|
||||
setTestResult(null);
|
||||
}
|
||||
|
||||
function handleSelect(next: string) {
|
||||
if (next === ADD_NEW) {
|
||||
setAdding(true);
|
||||
// Pre-select a reusable Requests server so the common path is one click.
|
||||
setReuseId(reusable[0]?.id ?? "");
|
||||
return;
|
||||
}
|
||||
setAdding(false);
|
||||
resetDraft();
|
||||
onChange(next === NONE ? "" : next);
|
||||
}
|
||||
|
||||
function handleTest() {
|
||||
setTestResult(null);
|
||||
const body: AutoscanConnectionTestInput = reuseId
|
||||
? { request_integration_id: reuseId }
|
||||
: { base_url: baseUrl.trim(), ...(apiKey.trim() ? { api_key_ref: apiKey.trim() } : {}) };
|
||||
|
||||
testConnection.mutate(body, {
|
||||
onSuccess: setTestResult,
|
||||
onError: (err) =>
|
||||
setTestResult({
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : "Connection test failed",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
const linked = reusable.find((integration) => integration.id === reuseId);
|
||||
const body = linked
|
||||
? {
|
||||
name: linked.name,
|
||||
kind: integrationKind(linked) || defaultKind,
|
||||
request_integration_id: linked.id,
|
||||
}
|
||||
: {
|
||||
name: name.trim(),
|
||||
kind: defaultKind,
|
||||
base_url: baseUrl.trim(),
|
||||
...(apiKey.trim() ? { api_key_ref: apiKey.trim() } : {}),
|
||||
};
|
||||
|
||||
createConnection.mutate(body, {
|
||||
onSuccess: (created) => {
|
||||
// Select what was just made, so the operator never has to find it.
|
||||
onChange(created.id);
|
||||
setAdding(false);
|
||||
resetDraft();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const canTest = reuseId ? true : baseUrl.trim().length > 0;
|
||||
const canCreate = reuseId
|
||||
? true
|
||||
: name.trim().length > 0 && baseUrl.trim().length > 0 && apiKey.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${idPrefix}-select`}>Which server?{required && " (required)"}</Label>
|
||||
|
||||
<Select value={adding ? ADD_NEW : value || NONE} onValueChange={handleSelect}>
|
||||
<SelectTrigger id={`${idPrefix}-select`} className="w-full">
|
||||
<SelectValue placeholder="No connection" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{!required && <SelectItem value={NONE}>— No server needed —</SelectItem>}
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
{option.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={ADD_NEW}>+ Add a server…</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{!adding && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{required
|
||||
? "This source needs credentials to reach its server."
|
||||
: "Optional — bind one if this source needs to reach a server."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{adding && (
|
||||
<div className="border-border space-y-3 rounded-md border p-3">
|
||||
{reusable.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${idPrefix}-reuse`}>Server</Label>
|
||||
<Select
|
||||
value={reuseId || "__manual__"}
|
||||
onValueChange={(next) => {
|
||||
setTestResult(null);
|
||||
setReuseId(next === "__manual__" ? "" : next);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={`${idPrefix}-reuse`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{reusable.map((integration) => (
|
||||
<SelectItem key={integration.id} value={integration.id}>
|
||||
{integration.name} — already set up in Requests
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="__manual__">Use different credentials…</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Silo already has these credentials — no need to enter them again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!reuseId && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${idPrefix}-name`}>Name</Label>
|
||||
<Input
|
||||
id={`${idPrefix}-name`}
|
||||
placeholder="My Sonarr"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${idPrefix}-url`}>Base URL</Label>
|
||||
<Input
|
||||
id={`${idPrefix}-url`}
|
||||
placeholder="http://localhost:8989"
|
||||
value={baseUrl}
|
||||
onChange={(e) => {
|
||||
setTestResult(null);
|
||||
setBaseUrl(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${idPrefix}-key`}>API key</Label>
|
||||
<Input
|
||||
id={`${idPrefix}-key`}
|
||||
type="password"
|
||||
placeholder="Enter API key"
|
||||
autoComplete="new-password"
|
||||
value={apiKey}
|
||||
onChange={(e) => {
|
||||
setTestResult(null);
|
||||
setApiKey(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{testResult && (
|
||||
<div
|
||||
role="status"
|
||||
className={`flex items-start gap-2 rounded-md border p-2.5 text-sm ${
|
||||
testResult.ok
|
||||
? "border-success/30 bg-success/10 text-success"
|
||||
: "border-destructive/30 bg-destructive/10 text-destructive"
|
||||
}`}
|
||||
>
|
||||
{testResult.ok ? (
|
||||
<CheckCircle2 className="mt-0.5 size-4 shrink-0" />
|
||||
) : (
|
||||
<XCircle className="mt-0.5 size-4 shrink-0" />
|
||||
)}
|
||||
<span>
|
||||
{testResult.ok
|
||||
? `Connected${testResult.version ? ` (v${testResult.version})` : ""}`
|
||||
: (testResult.error ?? "Connection failed")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap justify-between gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleTest}
|
||||
disabled={!canTest || testConnection.isPending}
|
||||
>
|
||||
{testConnection.isPending ? "Testing…" : "Test connection"}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setAdding(false);
|
||||
resetDraft();
|
||||
}}
|
||||
disabled={createConnection.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleCreate}
|
||||
disabled={!canCreate || createConnection.isPending}
|
||||
>
|
||||
<Plus />
|
||||
{createConnection.isPending ? "Adding…" : "Add server"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default InlineConnectionPicker;
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useMemo } from "react";
|
||||
import { Library as LibraryIcon } from "lucide-react";
|
||||
|
||||
import type { AutoscanScanSourceDescriptor } from "@/api/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SchemaForm } from "@/components/admin/plugins/SchemaForm";
|
||||
import { useAdminLibraries } from "@/hooks/queries/admin/libraries";
|
||||
|
||||
import { configFields, fillValueFromLibraries } from "./sourceDescriptor";
|
||||
|
||||
/**
|
||||
* Renders a scan source's per-source configuration from its descriptor, using
|
||||
* the same schema renderer as plugin config. Nothing here knows which plugin it
|
||||
* is drawing — that is the point: a new scan-source plugin ships its own fields
|
||||
* in its manifest and they appear here without a frontend change.
|
||||
*
|
||||
* `source_config` is a string map on the wire, so values are marshalled to and
|
||||
* from strings at this boundary.
|
||||
*/
|
||||
export function SourceConfigForm({
|
||||
descriptor,
|
||||
values,
|
||||
onChange,
|
||||
idPrefix = "source-config",
|
||||
}: {
|
||||
descriptor: AutoscanScanSourceDescriptor;
|
||||
values: Record<string, string>;
|
||||
onChange: (next: Record<string, string>) => void;
|
||||
idPrefix?: string;
|
||||
}) {
|
||||
const libraries = useAdminLibraries();
|
||||
const form = descriptor.config_form;
|
||||
const fields = configFields(descriptor);
|
||||
|
||||
// Fields that can be populated from a host-known value, paired with what that
|
||||
// value currently is. Computed together so the button can be disabled when
|
||||
// there is genuinely nothing to fill in.
|
||||
const fillable = useMemo(() => {
|
||||
return fields
|
||||
.map((field) => ({
|
||||
field,
|
||||
value: fillValueFromLibraries(field.fill_from, libraries.data ?? []),
|
||||
}))
|
||||
.filter((entry): entry is { field: (typeof fields)[number]; value: string } =>
|
||||
Boolean(entry.value),
|
||||
);
|
||||
}, [fields, libraries.data]);
|
||||
|
||||
if (!form || fields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyFill(key: string, value: string) {
|
||||
onChange({ ...values, [key]: value });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<SchemaForm
|
||||
descriptor={form}
|
||||
values={values}
|
||||
onChange={(next) => {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(next)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
out[key] = String(value);
|
||||
}
|
||||
onChange(out);
|
||||
}}
|
||||
idPrefix={idPrefix}
|
||||
/>
|
||||
|
||||
{fillable.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{fillable.map(({ field, value }) => (
|
||||
<Button
|
||||
key={field.key}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => applyFill(field.key, value)}
|
||||
title={`Replace ${field.label} with the paths of your enabled libraries`}
|
||||
>
|
||||
<LibraryIcon className="size-3.5" />
|
||||
Use library paths for {field.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SourceConfigForm;
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Copy,
|
||||
Library as LibraryIcon,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
@@ -16,12 +15,13 @@ import {
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
AutoscanDeliveryMode,
|
||||
AutoscanScanSourceDescriptor,
|
||||
AutoscanPathRewrite,
|
||||
AutoscanRewriteSuggestions,
|
||||
AutoscanSource,
|
||||
AutoscanSourceInput,
|
||||
AutoscanWebhookProvider,
|
||||
Library,
|
||||
} from "@/api/types";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -73,12 +73,28 @@ import {
|
||||
useRotateAutoscanWebhook,
|
||||
useUpdateAutoscanSource,
|
||||
} from "@/hooks/queries/useAutoscan";
|
||||
import { useAdminLibraries } from "@/hooks/queries/admin/libraries";
|
||||
import {
|
||||
buildPluginDisplayNames,
|
||||
composeSourceLabel,
|
||||
pluginDisplayNameKey,
|
||||
} from "@/lib/autoscanLabels";
|
||||
import { useAdminLibraries } from "@/hooks/queries/admin/libraries";
|
||||
import SourceConfigForm from "./SourceConfigForm";
|
||||
import { ChoiceCard, StepTrail } from "./ChoiceCard";
|
||||
import InlineConnectionPicker from "./InlineConnectionPicker";
|
||||
import { sourceTargets } from "./sourceTargets";
|
||||
import { WebhookInstructions, WebhookMappingEditor } from "./WebhookSetupStep";
|
||||
import { hasUsableMapping, seedMappings, usableMappings, type MappingDraft } from "./webhookSetup";
|
||||
import {
|
||||
connectionIsMandatory,
|
||||
connectionMatchesKinds,
|
||||
defaultDeliveryMode,
|
||||
DEFAULT_DESCRIPTOR,
|
||||
descriptorFor,
|
||||
initialConfigValues,
|
||||
needsConnectionStep,
|
||||
needsDeliveryChoice,
|
||||
} from "./sourceDescriptor";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -141,15 +157,25 @@ function sourceToRowEdit(source: AutoscanSource): RowEdit {
|
||||
};
|
||||
}
|
||||
|
||||
const ARR_WEBHOOK_PLUGIN_ID = "silo.autoscan.arr-webhook";
|
||||
const WEBHOOK_PROVIDER_KEY = "webhook_provider";
|
||||
|
||||
function isWebhookSource(source: AutoscanSource): boolean {
|
||||
return source.delivery_mode === "webhook";
|
||||
}
|
||||
|
||||
function isArrWebhookPlugin(plugin: { plugin_id: string } | undefined): boolean {
|
||||
return plugin?.plugin_id === ARR_WEBHOOK_PLUGIN_ID;
|
||||
/**
|
||||
* Which arr a webhook source expects, for the setup instructions. A descriptor
|
||||
* naming exactly one connection kind tells us; anything else stays "auto",
|
||||
* which shows the union of both services' triggers.
|
||||
*/
|
||||
function webhookProviderOf(
|
||||
descriptor: AutoscanScanSourceDescriptor,
|
||||
): AutoscanWebhookProvider | "auto" {
|
||||
const kinds = descriptor.connection_kinds ?? [];
|
||||
if (kinds.length === 1 && (kinds[0] === "sonarr" || kinds[0] === "radarr")) {
|
||||
return kinds[0];
|
||||
}
|
||||
return "auto";
|
||||
}
|
||||
|
||||
/** Resolve a possibly relative webhook_url against the admin UI's own origin. */
|
||||
@@ -157,35 +183,19 @@ function absoluteWebhookURL(url: string): string {
|
||||
return url.startsWith("/") ? `${window.location.origin}${url}` : url;
|
||||
}
|
||||
|
||||
const CEPHFS_PLUGIN_ID = "silo.autoscan.cephfs";
|
||||
const CEPHFS_CAPABILITY_ID = "cephfs";
|
||||
const DEFAULT_CEPHFS_EXCLUSIONS = [
|
||||
"*.partial",
|
||||
"*.tmp",
|
||||
"@eaDir",
|
||||
"#recycle",
|
||||
".downloads",
|
||||
".recyclebin",
|
||||
"volumes",
|
||||
];
|
||||
const CEPHFS_MOVIE_PATHS_KEY = "movie_flat_paths";
|
||||
const CEPHFS_TV_PATHS_KEY = "tv_flat_paths";
|
||||
const CEPHFS_LEGACY_MOVIE_NESTED_KEY = "movie_nested_paths";
|
||||
const CEPHFS_LEGACY_TV_NESTED_KEY = "tv_nested_paths";
|
||||
/**
|
||||
* Legacy source_config keys that were superseded by a newer key. Rows written
|
||||
* before the rename still carry the old key, so its lines are merged into the
|
||||
* new one when the editor loads. Both keys are sent back on save, which is what
|
||||
* lets an older plugin build keep reading its original key.
|
||||
*/
|
||||
const LEGACY_CONFIG_KEY_ALIASES: Record<string, string> = {
|
||||
movie_nested_paths: "movie_flat_paths",
|
||||
tv_nested_paths: "tv_flat_paths",
|
||||
};
|
||||
|
||||
function isCephFSSource(source: AutoscanSource): boolean {
|
||||
return source.plugin_id === CEPHFS_PLUGIN_ID || source.capability_id === CEPHFS_CAPABILITY_ID;
|
||||
}
|
||||
|
||||
function isCephFSPlugin(plugin: { plugin_id: string; capability_id: string } | undefined): boolean {
|
||||
return plugin?.plugin_id === CEPHFS_PLUGIN_ID || plugin?.capability_id === CEPHFS_CAPABILITY_ID;
|
||||
}
|
||||
|
||||
function defaultCephFSConfig(): Record<string, string> {
|
||||
return { exclusions: DEFAULT_CEPHFS_EXCLUSIONS.join("\n") };
|
||||
}
|
||||
|
||||
function mergeLineValues(...values: Array<string | undefined>): string {
|
||||
/** Merge newline-separated values, de-duplicating and dropping blanks. */
|
||||
function mergeLines(...values: Array<string | undefined>): string {
|
||||
const lines = new Set<string>();
|
||||
for (const value of values) {
|
||||
(value ?? "")
|
||||
@@ -197,35 +207,19 @@ function mergeLineValues(...values: Array<string | undefined>): string {
|
||||
return Array.from(lines).join("\n");
|
||||
}
|
||||
|
||||
function mergeLineDefaults(value: string | undefined, defaults: string[]): string {
|
||||
const lines = new Set(
|
||||
(value ?? "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
for (const entry of defaults) {
|
||||
lines.add(entry);
|
||||
}
|
||||
return Array.from(lines).join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a source's stored config for editing: pass values through as-is,
|
||||
* folding any superseded key into its replacement so nothing an operator
|
||||
* previously configured silently disappears from the form.
|
||||
*/
|
||||
function sourceConfigForEdit(source: AutoscanSource): Record<string, string> {
|
||||
if (!isCephFSSource(source)) {
|
||||
return { ...(source.source_config ?? {}) };
|
||||
const config = { ...(source.source_config ?? {}) };
|
||||
for (const [legacyKey, currentKey] of Object.entries(LEGACY_CONFIG_KEY_ALIASES)) {
|
||||
if (config[legacyKey] === undefined) continue;
|
||||
config[currentKey] = mergeLines(config[currentKey], config[legacyKey]);
|
||||
delete config[legacyKey];
|
||||
}
|
||||
const config = source.source_config ?? {};
|
||||
return {
|
||||
[CEPHFS_MOVIE_PATHS_KEY]: mergeLineValues(
|
||||
config[CEPHFS_MOVIE_PATHS_KEY],
|
||||
config[CEPHFS_LEGACY_MOVIE_NESTED_KEY],
|
||||
),
|
||||
[CEPHFS_TV_PATHS_KEY]: mergeLineValues(
|
||||
config[CEPHFS_TV_PATHS_KEY],
|
||||
config[CEPHFS_LEGACY_TV_NESTED_KEY],
|
||||
),
|
||||
exclusions: mergeLineDefaults(config.exclusions, DEFAULT_CEPHFS_EXCLUSIONS),
|
||||
};
|
||||
return config;
|
||||
}
|
||||
|
||||
function normalizeSourceConfig(config: Record<string, string>): Record<string, string> {
|
||||
@@ -238,60 +232,6 @@ function normalizeSourceConfig(config: Record<string, string>): Record<string, s
|
||||
return out;
|
||||
}
|
||||
|
||||
function libraryKind(type: string): "movie" | "tv" | "mixed" | null {
|
||||
switch (type.trim().toLowerCase()) {
|
||||
case "movie":
|
||||
case "movies":
|
||||
return "movie";
|
||||
case "series":
|
||||
case "show":
|
||||
case "shows":
|
||||
case "tv":
|
||||
case "tvshows":
|
||||
return "tv";
|
||||
case "mixed":
|
||||
return "mixed";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function configFromLibraries(
|
||||
libraries: Library[],
|
||||
current: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
const moviePaths = new Set<string>();
|
||||
const tvPaths = new Set<string>();
|
||||
|
||||
for (const library of libraries) {
|
||||
if (!library.enabled) {
|
||||
continue;
|
||||
}
|
||||
const kind = libraryKind(library.type);
|
||||
if (!kind) {
|
||||
continue;
|
||||
}
|
||||
for (const path of library.paths ?? []) {
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
if (kind === "movie" || kind === "mixed") {
|
||||
moviePaths.add(trimmed);
|
||||
}
|
||||
if (kind === "tv" || kind === "mixed") {
|
||||
tvPaths.add(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
[CEPHFS_MOVIE_PATHS_KEY]: Array.from(moviePaths).join("\n"),
|
||||
[CEPHFS_TV_PATHS_KEY]: Array.from(tvPaths).join("\n"),
|
||||
exclusions: mergeLineDefaults(current.exclusions, DEFAULT_CEPHFS_EXCLUSIONS),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RewriteEditor — expandable section inside a SourceRow
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -585,130 +525,6 @@ function CollapsibleList({ title, items }: { title: string; items: string[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CephFSConfigEditor({
|
||||
config,
|
||||
onChange,
|
||||
onSave,
|
||||
isSaving,
|
||||
showSave = true,
|
||||
}: {
|
||||
config: Record<string, string>;
|
||||
onChange: (next: Record<string, string>) => void;
|
||||
onSave: (next?: Record<string, string>) => void;
|
||||
isSaving: boolean;
|
||||
showSave?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const panelId = useId();
|
||||
const libraries = useAdminLibraries();
|
||||
|
||||
function patch(key: string, value: string) {
|
||||
onChange({ ...config, [key]: value });
|
||||
}
|
||||
|
||||
function useConfiguredLibraries() {
|
||||
const next = configFromLibraries(libraries.data ?? [], config);
|
||||
onChange(next);
|
||||
if (showSave) {
|
||||
onSave(next);
|
||||
}
|
||||
}
|
||||
|
||||
const libraryPathCount =
|
||||
libraries.data
|
||||
?.filter((library) => library.enabled)
|
||||
.reduce((count, library) => count + (library.paths?.length ?? 0), 0) ?? 0;
|
||||
const canUseLibraries = !libraries.isLoading && libraryPathCount > 0 && !isSaving;
|
||||
|
||||
const fields = [
|
||||
{
|
||||
key: CEPHFS_MOVIE_PATHS_KEY,
|
||||
label: "Movie library roots",
|
||||
description: "One configured movie library path per line.",
|
||||
placeholder: "/mnt/media/movies",
|
||||
},
|
||||
{
|
||||
key: CEPHFS_TV_PATHS_KEY,
|
||||
label: "TV library roots",
|
||||
description: "One configured TV library path per line.",
|
||||
placeholder: "/mnt/media/television",
|
||||
},
|
||||
{
|
||||
key: "exclusions",
|
||||
label: "Ignored paths",
|
||||
description: "One glob, directory name, or path prefix per line.",
|
||||
placeholder: DEFAULT_CEPHFS_EXCLUSIONS.join("\n"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="border-border mt-3 rounded-md border">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 px-3 pt-3 text-left sm:py-2"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="text-muted-foreground size-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="text-muted-foreground size-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="truncate text-sm font-medium">CephFS paths & ignores</span>
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mx-3 mb-3 justify-center whitespace-normal sm:mr-2 sm:mb-0 sm:ml-0 sm:shrink-0 sm:whitespace-nowrap"
|
||||
disabled={!canUseLibraries}
|
||||
onClick={useConfiguredLibraries}
|
||||
title={
|
||||
libraries.isLoading
|
||||
? "Loading libraries"
|
||||
: libraryPathCount === 0
|
||||
? "No enabled library paths"
|
||||
: "Replace CephFS roots with enabled Silo library paths"
|
||||
}
|
||||
>
|
||||
<LibraryIcon className="size-3.5" />
|
||||
Use configured libraries
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div id={panelId} className="space-y-3 px-3 pb-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{fields.map((field) => (
|
||||
<div key={field.key} className="space-y-1">
|
||||
<Label className="text-muted-foreground text-xs">{field.label}</Label>
|
||||
<textarea
|
||||
value={config[field.key] ?? ""}
|
||||
onChange={(event) => patch(field.key, event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
rows={field.key === "exclusions" ? 6 : 3}
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring min-h-20 w-full rounded-md border px-3 py-2 font-mono text-xs focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={field.label}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">{field.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showSave && (
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" size="sm" disabled={isSaving} onClick={() => onSave()}>
|
||||
Save CephFS settings
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebhookEndpointSection — webhook-mode replacement for the poll interval
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -877,6 +693,7 @@ function parseInterval(intervalStr: string, current: number | null): number | nu
|
||||
|
||||
function SourceRow({
|
||||
source,
|
||||
descriptor,
|
||||
connectionOptions,
|
||||
pluginDisplayNames,
|
||||
globalPollInterval,
|
||||
@@ -884,6 +701,8 @@ function SourceRow({
|
||||
layout = "table",
|
||||
}: {
|
||||
source: AutoscanSource;
|
||||
/** Setup contract for this source's capability; drives its config fields. */
|
||||
descriptor: AutoscanScanSourceDescriptor;
|
||||
connectionOptions: Array<{ id: string; name: string }>;
|
||||
pluginDisplayNames: Map<string, string>;
|
||||
globalPollInterval: number | null;
|
||||
@@ -891,6 +710,7 @@ function SourceRow({
|
||||
layout?: "table" | "card";
|
||||
}) {
|
||||
const update = useUpdateAutoscanSource();
|
||||
const libraries = useAdminLibraries();
|
||||
const [edit, setEdit] = useState<RowEdit>(() => sourceToRowEdit(source));
|
||||
const [intervalError, setIntervalError] = useState(false);
|
||||
|
||||
@@ -1032,6 +852,30 @@ function SourceRow({
|
||||
capabilityId: source.capability_id,
|
||||
pluginId: source.plugin_id,
|
||||
});
|
||||
// What this source keeps fresh. A source that can never resolve to a library
|
||||
// says so here rather than running cleanly and silently doing nothing, which
|
||||
// was the most common "I set it up and nothing happened" report.
|
||||
const targets = sourceTargets(source, descriptor, libraries.data ?? []);
|
||||
const targetSummary = libraries.isLoading ? null : targets.unresolvable ? (
|
||||
<p className="text-destructive flex items-center gap-1 text-xs">
|
||||
<AlertTriangle className="size-3 shrink-0" />
|
||||
No paths configured — this source can't match anything yet.
|
||||
</p>
|
||||
) : targets.libraries.length === 0 ? (
|
||||
<p className="text-xs text-amber-500">
|
||||
Paths don't match any library root — scans won't be enqueued.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground flex flex-wrap items-center gap-1 text-xs">
|
||||
<span>Feeds</span>
|
||||
{targets.libraries.map((library) => (
|
||||
<Badge key={library.id} variant="outline" className="text-xs font-normal">
|
||||
{library.name}
|
||||
</Badge>
|
||||
))}
|
||||
</p>
|
||||
);
|
||||
|
||||
const sourceIdentity = (
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
@@ -1045,6 +889,7 @@ function SourceRow({
|
||||
)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">{resolvedLabel.detail}</p>
|
||||
{targetSummary}
|
||||
</div>
|
||||
<Input
|
||||
value={edit.label}
|
||||
@@ -1143,6 +988,28 @@ function SourceRow({
|
||||
/>
|
||||
);
|
||||
|
||||
// Per-source config, rendered from the capability's declared fields. Absent
|
||||
// for sources that declare none, which is why this can be null.
|
||||
const sourceConfigEditor = descriptor.config_form?.fields?.length ? (
|
||||
<div className="border-border mt-3 space-y-3 rounded-md border p-3">
|
||||
<SourceConfigForm
|
||||
descriptor={descriptor}
|
||||
values={edit.sourceConfig}
|
||||
onChange={(next) => setEdit((ed) => ({ ...ed, sourceConfig: next }))}
|
||||
idPrefix={`source-config-${source.id}`}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={update.isPending}
|
||||
onClick={() => handleSourceConfigSave()}
|
||||
>
|
||||
{update.isPending ? "Saving…" : "Save configuration"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const intervalSettings = isWebhook ? (
|
||||
<>
|
||||
<WebhookEndpointSection
|
||||
@@ -1177,14 +1044,7 @@ function SourceRow({
|
||||
)}
|
||||
<p className="text-muted-foreground mt-1 text-xs">{intervalHelp}</p>
|
||||
{rewriteEditor}
|
||||
{isCephFSSource(source) && (
|
||||
<CephFSConfigEditor
|
||||
config={edit.sourceConfig}
|
||||
onChange={(next) => setEdit((ed) => ({ ...ed, sourceConfig: next }))}
|
||||
onSave={handleSourceConfigSave}
|
||||
isSaving={update.isPending}
|
||||
/>
|
||||
)}
|
||||
{sourceConfigEditor}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1286,27 +1146,51 @@ function SourceRow({
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Add source dialog
|
||||
//
|
||||
// The flow is built from the selected source's descriptor rather than from its
|
||||
// plugin id: a source declaring one delivery mode is never asked to choose one,
|
||||
// a source needing no credentials never sees the connection step, and its own
|
||||
// config fields come from its manifest. Adding a scan-source plugin therefore
|
||||
// needs no change here.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AddSourceForm {
|
||||
/** "plugin_id:capability_id" composite key of the chosen plugin. */
|
||||
pluginKey: string;
|
||||
deliveryMode: AutoscanDeliveryMode | "";
|
||||
connectionId: string; // "" / "__none__" means no connection
|
||||
intervalStr: string;
|
||||
sourceConfig: Record<string, string>;
|
||||
/** Webhook-only: arr path -> Silo path rows, seeded from library paths. */
|
||||
mappings: MappingDraft[];
|
||||
}
|
||||
|
||||
const BLANK_ADD_SOURCE: AddSourceForm = {
|
||||
pluginKey: "",
|
||||
deliveryMode: "",
|
||||
connectionId: "",
|
||||
intervalStr: "",
|
||||
sourceConfig: {},
|
||||
mappings: [],
|
||||
};
|
||||
|
||||
function pluginKey(pluginId: string, capabilityId: string): string {
|
||||
return `${pluginId}:${capabilityId}`;
|
||||
}
|
||||
|
||||
/** Human wording for a delivery mode, used on the choice cards. */
|
||||
const DELIVERY_MODE_COPY: Record<AutoscanDeliveryMode, { title: string; description: string }> = {
|
||||
webhook: {
|
||||
title: "The service tells Silo",
|
||||
description:
|
||||
"Instant. Paste one URL into the service's webhook settings. No credentials stored here.",
|
||||
},
|
||||
poll: {
|
||||
title: "Silo checks the service",
|
||||
description: "Silo asks on a schedule. Works without changing anything upstream.",
|
||||
},
|
||||
};
|
||||
|
||||
function AddSourceDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -1314,78 +1198,180 @@ function AddSourceDialog({
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
connectionOptions: Array<{ id: string; name: string }>;
|
||||
connectionOptions: Array<{ id: string; name: string; kind: string }>;
|
||||
}) {
|
||||
const available = useAvailableScanSources();
|
||||
const createSource = useCreateAutoscanSource();
|
||||
const createWebhook = useCreateAutoscanWebhook();
|
||||
const libraries = useAdminLibraries();
|
||||
const [form, setForm] = useState<AddSourceForm>(BLANK_ADD_SOURCE);
|
||||
// Set once a webhook source exists and its endpoint has been generated. The
|
||||
// dialog then shows the paste-this-into-your-arr instructions rather than
|
||||
// closing, so setup finishes in one place.
|
||||
const [createdWebhookSource, setCreatedWebhookSource] = useState<AutoscanSource | null>(null);
|
||||
|
||||
const plugins = available.data ?? [];
|
||||
const selectedPlugin = plugins.find(
|
||||
(p) => pluginKey(p.plugin_id, p.capability_id) === form.pluginKey,
|
||||
);
|
||||
const selectedIsCephFS = isCephFSPlugin(selectedPlugin);
|
||||
const selectedIsWebhook = isArrWebhookPlugin(selectedPlugin);
|
||||
const descriptor = descriptorFor(selectedPlugin);
|
||||
|
||||
// The chosen mode, or the descriptor's default while the operator has not
|
||||
// been asked (single-mode sources are never asked at all).
|
||||
const deliveryMode: AutoscanDeliveryMode = form.deliveryMode || defaultDeliveryMode(descriptor);
|
||||
|
||||
const showDeliveryChoice = Boolean(selectedPlugin) && needsDeliveryChoice(descriptor);
|
||||
const showConnection = Boolean(selectedPlugin) && needsConnectionStep(descriptor, deliveryMode);
|
||||
const connectionRequired = connectionIsMandatory(descriptor, deliveryMode);
|
||||
// Poll interval only means something when Silo is the one asking.
|
||||
const showInterval = Boolean(selectedPlugin) && deliveryMode === "poll";
|
||||
|
||||
// Only offer connections this source can actually talk to.
|
||||
const eligibleConnections = connectionOptions.filter((c) =>
|
||||
connectionMatchesKinds(descriptor, c.kind),
|
||||
);
|
||||
|
||||
const hasConfigForm = Boolean(descriptor.config_form?.fields?.length);
|
||||
|
||||
// A webhook source collects its path mappings in this dialog and then shows
|
||||
// the paste-into-your-arr instructions, so it owns two extra steps.
|
||||
const isWebhookFlow = Boolean(selectedPlugin) && deliveryMode === "webhook";
|
||||
|
||||
// Steps vary per source: a single-mode credential-free watcher genuinely has
|
||||
// fewer questions than a pollable arr, so the trail is built from what this
|
||||
// descriptor actually asks rather than being a fixed 1-2-3.
|
||||
const stepLabels = [
|
||||
"What changes?",
|
||||
...(showDeliveryChoice ? ["How do we hear about it?"] : []),
|
||||
...(showConnection ? ["Which server?"] : []),
|
||||
...(isWebhookFlow ? ["Match paths", "Connect it"] : []),
|
||||
...(hasConfigForm && !isWebhookFlow ? ["Set it up"] : []),
|
||||
];
|
||||
|
||||
// Highlight the first question the operator has not answered yet. Steps after
|
||||
// the delivery choice only become current once a mode is picked, because the
|
||||
// connection and config sections below depend on it.
|
||||
const currentStepIndex = !selectedPlugin
|
||||
? 0
|
||||
: showDeliveryChoice && !form.deliveryMode
|
||||
? 1
|
||||
: Math.max(0, stepLabels.length - 1);
|
||||
|
||||
function close() {
|
||||
setForm(BLANK_ADD_SOURCE);
|
||||
setCreatedWebhookSource(null);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function selectPlugin(value: string) {
|
||||
const plugin = plugins.find((p) => pluginKey(p.plugin_id, p.capability_id) === value);
|
||||
const next = descriptorFor(plugin);
|
||||
// Reset per-source state on every change: config keys, delivery modes and
|
||||
// eligible connections all belong to the previously selected source.
|
||||
const nextMode = defaultDeliveryMode(next);
|
||||
setForm({
|
||||
...BLANK_ADD_SOURCE,
|
||||
pluginKey: value,
|
||||
sourceConfig: initialConfigValues(next),
|
||||
mappings:
|
||||
nextMode === "webhook" ? seedMappings(webhookProviderOf(next), libraries.data ?? []) : [],
|
||||
});
|
||||
}
|
||||
|
||||
/** Seed mappings lazily when the operator switches delivery to webhook. */
|
||||
function selectDeliveryMode(mode: AutoscanDeliveryMode) {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
deliveryMode: mode,
|
||||
mappings:
|
||||
mode === "webhook" && f.mappings.length === 0
|
||||
? seedMappings(webhookProviderOf(descriptor), libraries.data ?? [])
|
||||
: f.mappings,
|
||||
}));
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!selectedPlugin) return;
|
||||
if (selectedIsWebhook) {
|
||||
createSource.mutate(
|
||||
{
|
||||
plugin_id: selectedPlugin.plugin_id,
|
||||
capability_id: selectedPlugin.capability_id,
|
||||
enabled: false,
|
||||
delivery_mode: "webhook",
|
||||
path_rewrites: [],
|
||||
source_config: { [WEBHOOK_PROVIDER_KEY]: "auto" },
|
||||
},
|
||||
{ onSuccess: close },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const connectionId =
|
||||
form.connectionId && form.connectionId !== "__none__" ? form.connectionId : null;
|
||||
showConnection && form.connectionId && form.connectionId !== "__none__"
|
||||
? form.connectionId
|
||||
: null;
|
||||
const raw = form.intervalStr.trim();
|
||||
const pollInterval = raw === "" ? null : Number(raw);
|
||||
const pollInterval = !showInterval || raw === "" ? null : Number(raw);
|
||||
|
||||
createSource.mutate(
|
||||
{
|
||||
plugin_id: selectedPlugin.plugin_id,
|
||||
capability_id: selectedPlugin.capability_id,
|
||||
connection_id: connectionId,
|
||||
enabled: false,
|
||||
// A webhook source is created enabled: its mappings are supplied in the
|
||||
// same dialog, so there is nothing left to configure afterwards. Poll
|
||||
// sources stay disabled until the operator reviews them.
|
||||
enabled: isWebhookFlow,
|
||||
delivery_mode: deliveryMode,
|
||||
poll_interval_seconds: pollInterval,
|
||||
path_rewrites: [],
|
||||
source_config: selectedIsCephFS ? normalizeSourceConfig(form.sourceConfig) : {},
|
||||
path_rewrites: isWebhookFlow ? usableMappings(form.mappings) : [],
|
||||
source_config: normalizeSourceConfig(form.sourceConfig),
|
||||
},
|
||||
{
|
||||
onSuccess: (created) => {
|
||||
if (!isWebhookFlow) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
// Generate the endpoint immediately and stay open: the operator still
|
||||
// needs the URL, and closing here is what previously left them to
|
||||
// hunt for a "Generate webhook URL" button on the row.
|
||||
createWebhook.mutate(created.id, {
|
||||
onSuccess: (withWebhook) => setCreatedWebhookSource(withWebhook),
|
||||
onError: close,
|
||||
});
|
||||
},
|
||||
},
|
||||
{ onSuccess: close },
|
||||
);
|
||||
}
|
||||
|
||||
const intervalInvalid =
|
||||
!selectedIsWebhook &&
|
||||
showInterval &&
|
||||
form.intervalStr.trim() !== "" &&
|
||||
(!Number.isInteger(Number(form.intervalStr)) || Number(form.intervalStr) < 1);
|
||||
const canSubmit = Boolean(selectedPlugin) && !intervalInvalid && !createSource.isPending;
|
||||
|
||||
const isCreating = createSource.isPending || createWebhook.isPending;
|
||||
|
||||
const canSubmit =
|
||||
Boolean(selectedPlugin) &&
|
||||
!intervalInvalid &&
|
||||
!isCreating &&
|
||||
(!connectionRequired || Boolean(form.connectionId && form.connectionId !== "__none__")) &&
|
||||
// A webhook source with no mapping accepts deliveries and resolves nothing,
|
||||
// so require at least one before it can be created.
|
||||
(!isWebhookFlow || hasUsableMapping(form.mappings));
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => (o ? onOpenChange(true) : close())}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add scan source</DialogTitle>
|
||||
<DialogTitle>
|
||||
{createdWebhookSource ? "Almost done — connect your service" : "Add scan source"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a scan source from an installed plugin. Bind a connection if the source needs to
|
||||
reach a server (Sonarr/Radarr); a source that reads locally needs none. Add one source
|
||||
per thing you want to watch.
|
||||
{createdWebhookSource
|
||||
? "The source is created and listening. Paste this into your download manager to finish."
|
||||
: "Pick what you want Silo to watch. Each source only asks for what it actually needs."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{available.isLoading ? (
|
||||
<p className="text-muted-foreground py-4 text-sm">Loading available plugins…</p>
|
||||
{createdWebhookSource ? (
|
||||
<div className="space-y-4">
|
||||
<StepTrail steps={stepLabels} currentIndex={stepLabels.length - 1} />
|
||||
<WebhookInstructions
|
||||
url={absoluteWebhookURL(createdWebhookSource.webhook_url ?? "")}
|
||||
provider={webhookProviderOf(descriptor)}
|
||||
/>
|
||||
</div>
|
||||
) : available.isLoading ? (
|
||||
<p className="text-muted-foreground py-4 text-sm">Loading available sources…</p>
|
||||
) : plugins.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4 text-sm">
|
||||
No scan-source plugins installed. Install one from the{" "}
|
||||
@@ -1396,80 +1382,78 @@ function AddSourceDialog({
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Plugin</Label>
|
||||
<Select
|
||||
value={form.pluginKey}
|
||||
onValueChange={(v) => {
|
||||
const plugin = plugins.find((p) => pluginKey(p.plugin_id, p.capability_id) === v);
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
pluginKey: v,
|
||||
sourceConfig:
|
||||
isCephFSPlugin(plugin) && Object.keys(f.sourceConfig).length === 0
|
||||
? defaultCephFSConfig()
|
||||
: f.sourceConfig,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a scan-source plugin…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{plugins.map((p) => (
|
||||
<SelectItem
|
||||
key={pluginKey(p.plugin_id, p.capability_id)}
|
||||
value={pluginKey(p.plugin_id, p.capability_id)}
|
||||
>
|
||||
{p.display_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<StepTrail steps={stepLabels} currentIndex={currentStepIndex} />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>What should Silo watch?</Label>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{plugins.map((p) => {
|
||||
const key = pluginKey(p.plugin_id, p.capability_id);
|
||||
const pluginDescriptor = descriptorFor(p);
|
||||
return (
|
||||
<ChoiceCard
|
||||
key={key}
|
||||
title={p.display_name}
|
||||
description={pluginDescriptor.summary || p.description}
|
||||
icon={
|
||||
pluginDescriptor.delivery_modes.includes("webhook") &&
|
||||
pluginDescriptor.delivery_modes.length === 1 ? (
|
||||
<Webhook className="size-3.5" />
|
||||
) : (
|
||||
<RefreshCw className="size-3.5" />
|
||||
)
|
||||
}
|
||||
selected={form.pluginKey === key}
|
||||
onSelect={() => selectPlugin(key)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedIsWebhook && (
|
||||
<div className="border-border rounded-md border p-3">
|
||||
<p className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<Webhook className="size-3.5" />
|
||||
Direct webhook delivery
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
No arr API key or connection needed. After creating the source, generate its
|
||||
webhook URL and paste it into Sonarr/Radarr → Settings → Connect → Webhook.
|
||||
</p>
|
||||
{showDeliveryChoice && (
|
||||
<div className="space-y-2">
|
||||
<Label>How should Silo hear about changes?</Label>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{descriptor.delivery_modes.map((mode) => {
|
||||
const copy = DELIVERY_MODE_COPY[mode];
|
||||
return (
|
||||
<ChoiceCard
|
||||
key={mode}
|
||||
title={copy?.title ?? mode}
|
||||
description={copy?.description}
|
||||
icon={mode === "webhook" ? <Webhook className="size-3.5" /> : undefined}
|
||||
badge={mode === "webhook" ? "Recommended" : undefined}
|
||||
selected={deliveryMode === mode}
|
||||
onSelect={() => selectDeliveryMode(mode)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedIsWebhook && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Connection</Label>
|
||||
<Select
|
||||
value={form.connectionId || "__none__"}
|
||||
onValueChange={(v) => setForm((f) => ({ ...f, connectionId: v }))}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="No connection" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">— No connection —</SelectItem>
|
||||
{connectionOptions.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Optional — bind one if the source needs to reach a server (e.g. Sonarr/Radarr).
|
||||
Sources that read locally need no connection.
|
||||
</p>
|
||||
</div>
|
||||
{isWebhookFlow && (
|
||||
<WebhookMappingEditor
|
||||
mappings={form.mappings}
|
||||
onChange={(mappings) => setForm((f) => ({ ...f, mappings }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!selectedIsWebhook && (
|
||||
{showConnection && (
|
||||
<InlineConnectionPicker
|
||||
value={form.connectionId}
|
||||
onChange={(connectionId) => setForm((f) => ({ ...f, connectionId }))}
|
||||
options={eligibleConnections}
|
||||
required={connectionRequired}
|
||||
connectionKinds={descriptor.connection_kinds ?? []}
|
||||
idPrefix="add-source-conn"
|
||||
/>
|
||||
)}
|
||||
|
||||
{showInterval && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="add-source-interval">Poll interval (seconds)</Label>
|
||||
<Label htmlFor="add-source-interval">Check interval (seconds)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="add-source-interval"
|
||||
@@ -1485,31 +1469,38 @@ function AddSourceDialog({
|
||||
<p className="text-destructive text-xs">Must be a positive integer.</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Optional — leave blank to use the global default poll interval.
|
||||
Optional — leave blank to use the global default.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedIsCephFS && (
|
||||
<CephFSConfigEditor
|
||||
config={form.sourceConfig}
|
||||
{selectedPlugin && (
|
||||
<SourceConfigForm
|
||||
descriptor={descriptor}
|
||||
values={form.sourceConfig}
|
||||
onChange={(sourceConfig) => setForm((f) => ({ ...f, sourceConfig }))}
|
||||
onSave={() => undefined}
|
||||
isSaving={createSource.isPending}
|
||||
showSave={false}
|
||||
idPrefix="add-source-config"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={close} disabled={createSource.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
{plugins.length > 0 && (
|
||||
<Button onClick={handleSubmit} disabled={!canSubmit}>
|
||||
{createSource.isPending ? "Adding…" : "Add source"}
|
||||
</Button>
|
||||
{createdWebhookSource ? (
|
||||
// The source already exists at this point, so there is nothing to
|
||||
// cancel — only an acknowledgement that the URL has been copied.
|
||||
<Button onClick={close}>Done</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" onClick={close} disabled={isCreating}>
|
||||
Cancel
|
||||
</Button>
|
||||
{plugins.length > 0 && (
|
||||
<Button onClick={handleSubmit} disabled={!canSubmit}>
|
||||
{isCreating ? "Adding…" : isWebhookFlow ? "Create and continue" : "Add source"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -1538,10 +1529,28 @@ export default function SourcesPanel() {
|
||||
const connectionOptions = (connections.data ?? []).map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
kind: c.kind,
|
||||
}));
|
||||
|
||||
const globalPollInterval = settings.data?.default_poll_interval_seconds ?? null;
|
||||
|
||||
// Descriptor per installed capability, so each row can render the config
|
||||
// fields its own plugin declares. A source whose capability is no longer
|
||||
// installed falls back to the defaults rather than disappearing.
|
||||
const descriptorsByKey = useMemo(() => {
|
||||
const map = new Map<string, AutoscanScanSourceDescriptor>();
|
||||
for (const plugin of available.data ?? []) {
|
||||
map.set(pluginKey(plugin.plugin_id, plugin.capability_id), descriptorFor(plugin));
|
||||
}
|
||||
return map;
|
||||
}, [available.data]);
|
||||
|
||||
function descriptorForSource(source: AutoscanSource): AutoscanScanSourceDescriptor {
|
||||
return (
|
||||
descriptorsByKey.get(pluginKey(source.plugin_id, source.capability_id)) ?? DEFAULT_DESCRIPTOR
|
||||
);
|
||||
}
|
||||
|
||||
const header = (
|
||||
<div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
@@ -1609,6 +1618,7 @@ export default function SourcesPanel() {
|
||||
<SourceRow
|
||||
key={source.id}
|
||||
source={source}
|
||||
descriptor={descriptorForSource(source)}
|
||||
connectionOptions={connectionOptions}
|
||||
pluginDisplayNames={pluginDisplayNames}
|
||||
globalPollInterval={globalPollInterval}
|
||||
@@ -1635,6 +1645,7 @@ export default function SourcesPanel() {
|
||||
<SourceRow
|
||||
key={source.id}
|
||||
source={source}
|
||||
descriptor={descriptorForSource(source)}
|
||||
connectionOptions={connectionOptions}
|
||||
pluginDisplayNames={pluginDisplayNames}
|
||||
globalPollInterval={globalPollInterval}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useState } from "react";
|
||||
import { Check, Copy, Plus, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { AutoscanWebhookProvider } from "@/api/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
import type { MappingDraft } from "./webhookSetup";
|
||||
import { settingsPathFor, triggersFor } from "./webhookSetup";
|
||||
|
||||
/**
|
||||
* The copy-the-URL-into-your-arr half of webhook setup.
|
||||
*
|
||||
* This exists because the previous flow created a webhook source and then left
|
||||
* the operator to work out, unaided, that they had to generate a URL, find the
|
||||
* right screen in Sonarr, and tick a specific set of boxes. Every one of those
|
||||
* is now stated on screen, and the trigger list is derived from what the host
|
||||
* actually parses rather than from memory.
|
||||
*/
|
||||
export function WebhookInstructions({
|
||||
url,
|
||||
provider,
|
||||
}: {
|
||||
url: string;
|
||||
provider: AutoscanWebhookProvider | "auto";
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const triggers = triggersFor(provider);
|
||||
|
||||
async function copyURL() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error("Couldn't copy — select the URL and copy it manually.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="webhook-url">1. Copy this URL</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="webhook-url"
|
||||
readOnly
|
||||
value={url}
|
||||
className="font-mono text-xs"
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" onClick={copyURL}>
|
||||
{copied ? <Check className="text-success" /> : <Copy />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>2. In your download manager, go to</Label>
|
||||
<p className="border-border bg-muted/30 rounded-md border px-3 py-2 font-mono text-xs">
|
||||
{settingsPathFor(provider)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Paste the URL into <span className="font-medium">Webhook URL</span> and leave the method
|
||||
as <span className="font-medium">POST</span>. No username or password is needed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>3. Tick these triggers</Label>
|
||||
<ul className="space-y-2">
|
||||
{triggers.map((trigger) => (
|
||||
<li key={trigger.label} className="flex items-start gap-2 text-sm">
|
||||
<span
|
||||
aria-hidden
|
||||
className="border-muted-foreground/50 mt-0.5 grid size-4 shrink-0 place-items-center rounded-[3px] border"
|
||||
>
|
||||
<Check className="size-3" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="font-medium">{trigger.label}</span>
|
||||
{!trigger.required && <span className="text-muted-foreground"> (optional)</span>}
|
||||
<span className="text-muted-foreground block text-xs">{trigger.reason}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Leave every other trigger unchecked — Silo ignores them.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Save the connection in your download manager. You can use its
|
||||
<span className="font-medium"> Test </span> button — Silo accepts test payloads and will
|
||||
show the delivery on this source.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Path mapping editor for webhook sources.
|
||||
*
|
||||
* A webhook source has no connection, so the host's /suggest endpoint (which
|
||||
* reads an arr's root folders over its API) cannot run. The `to` side is
|
||||
* therefore pre-filled from real Silo library paths and the operator supplies
|
||||
* what their arr reports. Without at least one row, deliveries arrive and
|
||||
* resolve to nothing.
|
||||
*/
|
||||
export function WebhookMappingEditor({
|
||||
mappings,
|
||||
onChange,
|
||||
}: {
|
||||
mappings: MappingDraft[];
|
||||
onChange: (next: MappingDraft[]) => void;
|
||||
}) {
|
||||
function update(index: number, patch: Partial<MappingDraft>) {
|
||||
onChange(mappings.map((row, i) => (i === index ? { ...row, ...patch } : row)));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Match its paths to yours</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Your download manager reports the path <em>it</em> sees. If that differs from the path
|
||||
Silo sees, map it here. Same path on both sides? Enter it twice.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{mappings.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
No libraries found to map. Add a library first, or add a row manually.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{mappings.map((row, index) => (
|
||||
<div key={index} className="flex flex-col gap-2 sm:flex-row sm:items-end">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Label htmlFor={`map-from-${index}`} className="text-muted-foreground text-xs">
|
||||
Path your download manager reports
|
||||
</Label>
|
||||
<Input
|
||||
id={`map-from-${index}`}
|
||||
placeholder="/downloads/tv"
|
||||
className="font-mono text-xs"
|
||||
value={row.from}
|
||||
onChange={(e) => update(index, { from: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted-foreground hidden pb-2 text-xs sm:block">→</span>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Label htmlFor={`map-to-${index}`} className="text-muted-foreground text-xs">
|
||||
Path Silo uses
|
||||
</Label>
|
||||
<Input
|
||||
id={`map-to-${index}`}
|
||||
placeholder="/mnt/media/tv"
|
||||
className="font-mono text-xs"
|
||||
value={row.to}
|
||||
onChange={(e) => update(index, { to: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Remove mapping ${index + 1}`}
|
||||
className="sm:mb-1"
|
||||
onClick={() => onChange(mappings.filter((_, i) => i !== index))}
|
||||
>
|
||||
<Trash2 className="text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onChange([...mappings, { from: "", to: "" }])}
|
||||
>
|
||||
<Plus />
|
||||
Add a mapping
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AutoscanAvailableSource, AutoscanScanSourceDescriptor, Library } from "@/api/types";
|
||||
|
||||
import {
|
||||
connectionIsMandatory,
|
||||
connectionMatchesKinds,
|
||||
DEFAULT_DESCRIPTOR,
|
||||
defaultDeliveryMode,
|
||||
descriptorFor,
|
||||
FILL_FROM_MOVIE_LIBRARY_PATHS,
|
||||
FILL_FROM_TV_LIBRARY_PATHS,
|
||||
fillValueFromLibraries,
|
||||
initialConfigValues,
|
||||
needsConnectionStep,
|
||||
needsDeliveryChoice,
|
||||
} from "./sourceDescriptor";
|
||||
|
||||
function available(descriptor?: Partial<AutoscanScanSourceDescriptor>): AutoscanAvailableSource {
|
||||
return {
|
||||
plugin_id: "p",
|
||||
capability_id: "c",
|
||||
display_name: "Test source",
|
||||
descriptor: { ...DEFAULT_DESCRIPTOR, ...descriptor },
|
||||
};
|
||||
}
|
||||
|
||||
describe("descriptorFor", () => {
|
||||
it("falls back to host defaults when a server sends no descriptor", () => {
|
||||
// Guards against an older server: the flow must still work, not blank out.
|
||||
const source = { ...available(), descriptor: undefined } as unknown as AutoscanAvailableSource;
|
||||
expect(descriptorFor(source)).toEqual(DEFAULT_DESCRIPTOR);
|
||||
});
|
||||
|
||||
it("falls back when delivery_modes is empty", () => {
|
||||
expect(descriptorFor(available({ delivery_modes: [] }))).toEqual(DEFAULT_DESCRIPTOR);
|
||||
});
|
||||
|
||||
it("returns the declared descriptor when present", () => {
|
||||
const got = descriptorFor(available({ delivery_modes: ["webhook"], connection: "none" }));
|
||||
expect(got.connection).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("step visibility", () => {
|
||||
it("skips the delivery question for a single-mode source", () => {
|
||||
expect(needsDeliveryChoice({ delivery_modes: ["poll"], connection: "optional" })).toBe(false);
|
||||
expect(needsDeliveryChoice({ delivery_modes: ["webhook"], connection: "optional" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("asks when a source supports both modes", () => {
|
||||
expect(
|
||||
needsDeliveryChoice({ delivery_modes: ["poll", "webhook"], connection: "optional" }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("hides the connection step for a credential-free source", () => {
|
||||
expect(needsConnectionStep({ delivery_modes: ["poll"], connection: "none" }, "poll")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("hides the connection step for webhook delivery even when a connection is allowed", () => {
|
||||
// A webhook delivery never uses a bound connection — the provider pushes.
|
||||
expect(
|
||||
needsConnectionStep(
|
||||
{ delivery_modes: ["poll", "webhook"], connection: "required" },
|
||||
"webhook",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the connection step for a polling source", () => {
|
||||
expect(needsConnectionStep(DEFAULT_DESCRIPTOR, "poll")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats connection as mandatory only when required and applicable", () => {
|
||||
expect(
|
||||
connectionIsMandatory({ delivery_modes: ["poll"], connection: "required" }, "poll"),
|
||||
).toBe(true);
|
||||
expect(connectionIsMandatory(DEFAULT_DESCRIPTOR, "poll")).toBe(false);
|
||||
expect(
|
||||
connectionIsMandatory({ delivery_modes: ["webhook"], connection: "required" }, "webhook"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultDeliveryMode", () => {
|
||||
it("prefers poll when both are offered", () => {
|
||||
expect(defaultDeliveryMode({ delivery_modes: ["webhook", "poll"], connection: "none" })).toBe(
|
||||
"poll",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the only mode for a single-mode source", () => {
|
||||
expect(defaultDeliveryMode({ delivery_modes: ["webhook"], connection: "none" })).toBe(
|
||||
"webhook",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("connectionMatchesKinds", () => {
|
||||
it("offers every connection when no kinds are declared", () => {
|
||||
expect(connectionMatchesKinds(DEFAULT_DESCRIPTOR, "anything")).toBe(true);
|
||||
});
|
||||
|
||||
it("restricts to declared kinds", () => {
|
||||
const d: AutoscanScanSourceDescriptor = {
|
||||
delivery_modes: ["poll"],
|
||||
connection: "optional",
|
||||
connection_kinds: ["sonarr"],
|
||||
};
|
||||
expect(connectionMatchesKinds(d, "sonarr")).toBe(true);
|
||||
expect(connectionMatchesKinds(d, "radarr")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initialConfigValues", () => {
|
||||
it("seeds declared defaults and skips fields without one", () => {
|
||||
const got = initialConfigValues({
|
||||
delivery_modes: ["poll"],
|
||||
connection: "none",
|
||||
config_form: {
|
||||
fields: [
|
||||
{
|
||||
key: "a",
|
||||
label: "A",
|
||||
control: "TEXT",
|
||||
required: false,
|
||||
secret: false,
|
||||
multiline: false,
|
||||
default_value: "x",
|
||||
},
|
||||
{
|
||||
key: "b",
|
||||
label: "B",
|
||||
control: "NUMBER",
|
||||
required: false,
|
||||
secret: false,
|
||||
multiline: false,
|
||||
default_value: 2,
|
||||
},
|
||||
{
|
||||
key: "c",
|
||||
label: "C",
|
||||
control: "TEXT",
|
||||
required: false,
|
||||
secret: false,
|
||||
multiline: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(got).toEqual({ a: "x", b: "2" });
|
||||
});
|
||||
|
||||
it("returns nothing for a source with no config form", () => {
|
||||
expect(initialConfigValues(DEFAULT_DESCRIPTOR)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fillValueFromLibraries", () => {
|
||||
const libraries = [
|
||||
{ id: 1, name: "Movies", type: "movie", enabled: true, paths: ["/mnt/movies"] },
|
||||
{ id: 2, name: "TV", type: "series", enabled: true, paths: ["/mnt/tv"] },
|
||||
{ id: 3, name: "Everything", type: "mixed", enabled: true, paths: ["/mnt/mixed"] },
|
||||
{ id: 4, name: "Off", type: "movie", enabled: false, paths: ["/mnt/disabled"] },
|
||||
] as unknown as Library[];
|
||||
|
||||
it("collects movie and mixed paths for a movie fill", () => {
|
||||
expect(fillValueFromLibraries(FILL_FROM_MOVIE_LIBRARY_PATHS, libraries)).toBe(
|
||||
"/mnt/movies\n/mnt/mixed",
|
||||
);
|
||||
});
|
||||
|
||||
it("collects series and mixed paths for a TV fill", () => {
|
||||
expect(fillValueFromLibraries(FILL_FROM_TV_LIBRARY_PATHS, libraries)).toBe(
|
||||
"/mnt/tv\n/mnt/mixed",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips disabled libraries", () => {
|
||||
expect(fillValueFromLibraries(FILL_FROM_MOVIE_LIBRARY_PATHS, libraries)).not.toContain(
|
||||
"/mnt/disabled",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for an unknown or absent fill source", () => {
|
||||
expect(fillValueFromLibraries("something_else", libraries)).toBeNull();
|
||||
expect(fillValueFromLibraries(undefined, libraries)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import type {
|
||||
AutoscanAvailableSource,
|
||||
AutoscanDeliveryMode,
|
||||
AutoscanScanSourceDescriptor,
|
||||
Library,
|
||||
PluginAdminFormField,
|
||||
} from "@/api/types";
|
||||
|
||||
/**
|
||||
* Host defaults for a source whose descriptor is missing or malformed. These
|
||||
* mirror `autoscan.DefaultScanSourceDescriptor` on the server: poll delivery
|
||||
* with an optional connection, which is how every source behaved before
|
||||
* descriptors existed.
|
||||
*/
|
||||
export const DEFAULT_DESCRIPTOR: AutoscanScanSourceDescriptor = {
|
||||
delivery_modes: ["poll"],
|
||||
connection: "optional",
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the descriptor for an available source. The server always sends one,
|
||||
* but a client may be talking to an older server — falling back keeps the
|
||||
* Add-source flow working rather than rendering an empty dialog.
|
||||
*/
|
||||
export function descriptorFor(
|
||||
source: AutoscanAvailableSource | undefined,
|
||||
): AutoscanScanSourceDescriptor {
|
||||
const descriptor = source?.descriptor;
|
||||
if (!descriptor || !descriptor.delivery_modes?.length) {
|
||||
return DEFAULT_DESCRIPTOR;
|
||||
}
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
/** Delivery mode to use when the operator is not asked to choose. */
|
||||
export function defaultDeliveryMode(
|
||||
descriptor: AutoscanScanSourceDescriptor,
|
||||
): AutoscanDeliveryMode {
|
||||
if (descriptor.delivery_modes.includes("poll")) return "poll";
|
||||
return descriptor.delivery_modes[0] ?? "poll";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the operator must be asked how changes arrive. A source supporting a
|
||||
* single mode answers the question by itself, so the step is skipped.
|
||||
*/
|
||||
export function needsDeliveryChoice(descriptor: AutoscanScanSourceDescriptor): boolean {
|
||||
return descriptor.delivery_modes.length > 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the connection step applies. A `none` source reaches its provider
|
||||
* without host-held credentials (local watcher, or the provider pushes to us).
|
||||
* Poll-specific: a webhook delivery never uses a bound connection, so the step
|
||||
* is hidden when the chosen mode is webhook regardless of the requirement.
|
||||
*/
|
||||
export function needsConnectionStep(
|
||||
descriptor: AutoscanScanSourceDescriptor,
|
||||
deliveryMode: AutoscanDeliveryMode,
|
||||
): boolean {
|
||||
if (deliveryMode === "webhook") return false;
|
||||
return descriptor.connection !== "none";
|
||||
}
|
||||
|
||||
/** A `required` connection blocks creation until one is bound. */
|
||||
export function connectionIsMandatory(
|
||||
descriptor: AutoscanScanSourceDescriptor,
|
||||
deliveryMode: AutoscanDeliveryMode,
|
||||
): boolean {
|
||||
return needsConnectionStep(descriptor, deliveryMode) && descriptor.connection === "required";
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict the connection picker to the kinds a source can actually talk to.
|
||||
* An empty `connection_kinds` means "any", which is also the fallback when a
|
||||
* connection's kind is unknown to us.
|
||||
*/
|
||||
export function connectionMatchesKinds(
|
||||
descriptor: AutoscanScanSourceDescriptor,
|
||||
connectionKind: string,
|
||||
): boolean {
|
||||
const kinds = descriptor.connection_kinds ?? [];
|
||||
if (kinds.length === 0) return true;
|
||||
return kinds.includes(connectionKind);
|
||||
}
|
||||
|
||||
/** Config form fields, or an empty list when the source declares none. */
|
||||
export function configFields(descriptor: AutoscanScanSourceDescriptor): PluginAdminFormField[] {
|
||||
return descriptor.config_form?.fields ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed values for a source's config form, honouring each field's declared
|
||||
* default. Values are stored as strings because `source_config` is a string map
|
||||
* on the wire.
|
||||
*/
|
||||
export function initialConfigValues(
|
||||
descriptor: AutoscanScanSourceDescriptor,
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const field of configFields(descriptor)) {
|
||||
if (field.default_value === undefined || field.default_value === null) continue;
|
||||
out[field.key] = String(field.default_value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Fill-from sources -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Host-known values a config field can be populated from. Kept in sync with the
|
||||
* `FillFrom*` constants in internal/autoscan/adminform.go.
|
||||
*/
|
||||
export const FILL_FROM_MOVIE_LIBRARY_PATHS = "library_paths_movie";
|
||||
export const FILL_FROM_TV_LIBRARY_PATHS = "library_paths_tv";
|
||||
|
||||
function libraryKind(type: string): "movie" | "tv" | "mixed" | null {
|
||||
switch (type.trim().toLowerCase()) {
|
||||
case "movie":
|
||||
case "movies":
|
||||
return "movie";
|
||||
case "series":
|
||||
case "show":
|
||||
case "shows":
|
||||
case "tv":
|
||||
case "tvshows":
|
||||
return "tv";
|
||||
case "mixed":
|
||||
return "mixed";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect enabled library paths for a fill source. A mixed library contributes
|
||||
* to both movie and TV fills, since its paths can hold either.
|
||||
*
|
||||
* Returns null when the fill source is unknown, so the UI can distinguish
|
||||
* "nothing to offer" from "not offerable".
|
||||
*/
|
||||
export function fillValueFromLibraries(
|
||||
fillFrom: string | undefined,
|
||||
libraries: readonly Library[],
|
||||
): string | null {
|
||||
if (fillFrom !== FILL_FROM_MOVIE_LIBRARY_PATHS && fillFrom !== FILL_FROM_TV_LIBRARY_PATHS) {
|
||||
return null;
|
||||
}
|
||||
const want = fillFrom === FILL_FROM_MOVIE_LIBRARY_PATHS ? "movie" : "tv";
|
||||
|
||||
const paths = new Set<string>();
|
||||
for (const library of libraries) {
|
||||
if (!library.enabled) continue;
|
||||
const kind = libraryKind(library.type);
|
||||
if (kind !== want && kind !== "mixed") continue;
|
||||
for (const path of library.paths ?? []) {
|
||||
const trimmed = path.trim();
|
||||
if (trimmed) paths.add(trimmed);
|
||||
}
|
||||
}
|
||||
return Array.from(paths).join("\n");
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AutoscanScanSourceDescriptor, AutoscanSource, Library } from "@/api/types";
|
||||
|
||||
import { DEFAULT_DESCRIPTOR } from "./sourceDescriptor";
|
||||
import { describeTargets, resolvedPathsFor, sourceTargets } from "./sourceTargets";
|
||||
|
||||
const libraries = [
|
||||
{ id: 1, name: "Movies", type: "movie", enabled: true, paths: ["/mnt/media/movies"] },
|
||||
{ id: 2, name: "TV Shows", type: "series", enabled: true, paths: ["/mnt/media/tv"] },
|
||||
] as unknown as Library[];
|
||||
|
||||
function source(overrides: Partial<AutoscanSource> = {}): AutoscanSource {
|
||||
return {
|
||||
id: "s1",
|
||||
plugin_id: "p",
|
||||
capability_id: "c",
|
||||
enabled: true,
|
||||
delivery_mode: "poll",
|
||||
path_rewrites: [],
|
||||
source_config: {},
|
||||
...overrides,
|
||||
} as AutoscanSource;
|
||||
}
|
||||
|
||||
const pathDescriptor: AutoscanScanSourceDescriptor = {
|
||||
delivery_modes: ["poll"],
|
||||
connection: "none",
|
||||
config_form: {
|
||||
fields: [
|
||||
{
|
||||
key: "movie_flat_paths",
|
||||
label: "Movie paths",
|
||||
control: "TEXTAREA",
|
||||
required: false,
|
||||
secret: false,
|
||||
multiline: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
describe("resolvedPathsFor", () => {
|
||||
it("prefers rewrite targets over config paths", () => {
|
||||
const got = resolvedPathsFor(
|
||||
source({
|
||||
path_rewrites: [{ from: "/downloads/tv", to: "/mnt/media/tv" }],
|
||||
source_config: { movie_flat_paths: "/somewhere/else" },
|
||||
}),
|
||||
pathDescriptor,
|
||||
);
|
||||
expect(got).toEqual(["/mnt/media/tv"]);
|
||||
});
|
||||
|
||||
it("falls back to path-shaped config for local watchers", () => {
|
||||
const got = resolvedPathsFor(
|
||||
source({ source_config: { movie_flat_paths: "/mnt/media/movies\n/mnt/media/movies/4k" } }),
|
||||
pathDescriptor,
|
||||
);
|
||||
expect(got).toEqual(["/mnt/media/movies", "/mnt/media/movies/4k"]);
|
||||
});
|
||||
|
||||
it("ignores non-path config values", () => {
|
||||
// A provider name must never be mistaken for a filesystem path.
|
||||
const got = resolvedPathsFor(
|
||||
source({ source_config: { movie_flat_paths: "sonarr" } }),
|
||||
pathDescriptor,
|
||||
);
|
||||
expect(got).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores config keys the descriptor does not declare", () => {
|
||||
const got = resolvedPathsFor(
|
||||
source({ source_config: { unrelated_secret: "/etc/passwd" } }),
|
||||
pathDescriptor,
|
||||
);
|
||||
expect(got).toEqual([]);
|
||||
});
|
||||
|
||||
it("strips trailing slashes so roots compare cleanly", () => {
|
||||
const got = resolvedPathsFor(
|
||||
source({ path_rewrites: [{ from: "/x", to: "/mnt/media/tv/" }] }),
|
||||
pathDescriptor,
|
||||
);
|
||||
expect(got).toEqual(["/mnt/media/tv"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sourceTargets", () => {
|
||||
it("matches a library when the source path sits under its root", () => {
|
||||
const got = sourceTargets(
|
||||
source({ path_rewrites: [{ from: "/dl", to: "/mnt/media/tv/Show" }] }),
|
||||
pathDescriptor,
|
||||
libraries,
|
||||
);
|
||||
expect(got.unresolvable).toBe(false);
|
||||
expect(got.libraries.map((l) => l.name)).toEqual(["TV Shows"]);
|
||||
});
|
||||
|
||||
it("matches when the source watches a parent of the library root", () => {
|
||||
// A watcher on /mnt/media legitimately feeds both libraries beneath it.
|
||||
const got = sourceTargets(
|
||||
source({ source_config: { movie_flat_paths: "/mnt/media" } }),
|
||||
pathDescriptor,
|
||||
libraries,
|
||||
);
|
||||
expect(got.libraries.map((l) => l.name)).toEqual(["Movies", "TV Shows"]);
|
||||
});
|
||||
|
||||
it("flags a source with no path information as unresolvable", () => {
|
||||
// The silent-failure case: looks configured, can never match anything.
|
||||
const got = sourceTargets(source(), pathDescriptor, libraries);
|
||||
expect(got.unresolvable).toBe(true);
|
||||
expect(got.libraries).toEqual([]);
|
||||
});
|
||||
|
||||
it("distinguishes configured-but-unmatched from unconfigured", () => {
|
||||
const got = sourceTargets(
|
||||
source({ path_rewrites: [{ from: "/dl", to: "/mnt/other/stuff" }] }),
|
||||
pathDescriptor,
|
||||
libraries,
|
||||
);
|
||||
expect(got.unresolvable).toBe(false);
|
||||
expect(got.libraries).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not match on a shared path prefix that is not a real parent", () => {
|
||||
const got = sourceTargets(
|
||||
source({ path_rewrites: [{ from: "/dl", to: "/mnt/media/movies-archive" }] }),
|
||||
pathDescriptor,
|
||||
libraries,
|
||||
);
|
||||
expect(got.libraries).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeTargets", () => {
|
||||
it("names the libraries a source feeds", () => {
|
||||
expect(describeTargets({ libraries, unresolvable: false })).toBe("Movies, TV Shows");
|
||||
});
|
||||
|
||||
it("reports the two failure states distinctly", () => {
|
||||
expect(describeTargets({ libraries: [], unresolvable: true })).toBe("No paths configured");
|
||||
expect(describeTargets({ libraries: [], unresolvable: false })).toBe("No matching library");
|
||||
});
|
||||
});
|
||||
|
||||
describe("default descriptor sources", () => {
|
||||
it("still resolves rewrite targets when no config form is declared", () => {
|
||||
const got = sourceTargets(
|
||||
source({ path_rewrites: [{ from: "/dl", to: "/mnt/media/movies" }] }),
|
||||
DEFAULT_DESCRIPTOR,
|
||||
libraries,
|
||||
);
|
||||
expect(got.libraries.map((l) => l.name)).toEqual(["Movies"]);
|
||||
});
|
||||
|
||||
it("reads any path-shaped config when the descriptor declares no fields", () => {
|
||||
// With no declared field list there is nothing to filter on, so absolute
|
||||
// values are the only signal available.
|
||||
const got = sourceTargets(
|
||||
source({ source_config: { anything: "/mnt/media/tv" } }),
|
||||
DEFAULT_DESCRIPTOR,
|
||||
libraries,
|
||||
);
|
||||
expect(got.libraries.map((l) => l.name)).toEqual(["TV Shows"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { AutoscanScanSourceDescriptor, AutoscanSource, Library } from "@/api/types";
|
||||
|
||||
import { configFields } from "./sourceDescriptor";
|
||||
|
||||
/**
|
||||
* Which libraries a scan source can actually feed, and whether it can feed
|
||||
* anything at all.
|
||||
*
|
||||
* A source never states its target directly. What it has is a set of paths —
|
||||
* either the `to` side of its path rewrites (for sources whose provider speaks a
|
||||
* different namespace) or its own path-shaped config values (for local
|
||||
* watchers). Matching those against library roots is what turns "a source
|
||||
* exists" into "this keeps TV Shows fresh", which is the single thing the old
|
||||
* UI never said.
|
||||
*/
|
||||
export interface SourceTargets {
|
||||
/** Libraries whose roots overlap this source's resolved paths. */
|
||||
libraries: Library[];
|
||||
/**
|
||||
* True when the source has no path information at all, so nothing it reports
|
||||
* could ever resolve to a library. This is the silent-failure case: the
|
||||
* source looks configured and runs cleanly, but can never match.
|
||||
*/
|
||||
unresolvable: boolean;
|
||||
}
|
||||
|
||||
/** Normalize a path for prefix comparison: trimmed, no trailing slash. */
|
||||
function normalizePath(path: string): string {
|
||||
const trimmed = path.trim().replace(/\/+$/, "");
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/** Whether `candidate` is at or below `root`. */
|
||||
function isWithin(candidate: string, root: string): boolean {
|
||||
if (!candidate || !root) return false;
|
||||
if (candidate === root) return true;
|
||||
return candidate.startsWith(`${root}/`) || root.startsWith(`${candidate}/`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Config values that look like filesystem paths. Descriptor fields carry no
|
||||
* "this is a path" flag, so absolute-looking lines are treated as paths — the
|
||||
* cost of a false positive here is only an extra library chip, never a wrong
|
||||
* scan.
|
||||
*/
|
||||
function pathsFromConfig(
|
||||
source: AutoscanSource,
|
||||
descriptor: AutoscanScanSourceDescriptor,
|
||||
): string[] {
|
||||
const keys = new Set(configFields(descriptor).map((field) => field.key));
|
||||
const out: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(source.source_config ?? {})) {
|
||||
// Restrict to declared fields when the descriptor has any, so unrelated
|
||||
// scalar config (tokens, provider names) is never read as a path.
|
||||
if (keys.size > 0 && !keys.has(key)) continue;
|
||||
for (const line of String(value ?? "").split(/\r?\n/)) {
|
||||
const path = normalizePath(line);
|
||||
if (path.startsWith("/")) out.push(path);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every Silo-native path this source can produce: rewrite targets first (they
|
||||
* are authoritative when present), else its own path-shaped config.
|
||||
*/
|
||||
export function resolvedPathsFor(
|
||||
source: AutoscanSource,
|
||||
descriptor: AutoscanScanSourceDescriptor,
|
||||
): string[] {
|
||||
const rewriteTargets = (source.path_rewrites ?? [])
|
||||
.map((rewrite) => normalizePath(rewrite.to))
|
||||
.filter(Boolean);
|
||||
|
||||
if (rewriteTargets.length > 0) return rewriteTargets;
|
||||
return pathsFromConfig(source, descriptor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a source's resolved paths against library roots.
|
||||
*
|
||||
* A source that emits native paths but has neither rewrites nor path config is
|
||||
* still reported as unresolvable — it has genuinely told us nothing about where
|
||||
* its media lands.
|
||||
*/
|
||||
export function sourceTargets(
|
||||
source: AutoscanSource,
|
||||
descriptor: AutoscanScanSourceDescriptor,
|
||||
libraries: readonly Library[],
|
||||
): SourceTargets {
|
||||
const paths = resolvedPathsFor(source, descriptor);
|
||||
if (paths.length === 0) {
|
||||
return { libraries: [], unresolvable: true };
|
||||
}
|
||||
|
||||
const matched = libraries.filter((library) =>
|
||||
(library.paths ?? []).some((root) => {
|
||||
const normalizedRoot = normalizePath(root);
|
||||
return paths.some((path) => isWithin(path, normalizedRoot));
|
||||
}),
|
||||
);
|
||||
|
||||
// Paths exist but match no library root. Not "unresolvable" in the sense
|
||||
// above — the operator has said something, it just doesn't line up — so the
|
||||
// caller renders a different, more specific warning.
|
||||
return { libraries: matched, unresolvable: false };
|
||||
}
|
||||
|
||||
/** Short human summary of what a source keeps fresh. */
|
||||
export function describeTargets(targets: SourceTargets): string {
|
||||
if (targets.unresolvable) return "No paths configured";
|
||||
if (targets.libraries.length === 0) return "No matching library";
|
||||
return targets.libraries.map((library) => library.name).join(", ");
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Library } from "@/api/types";
|
||||
|
||||
import {
|
||||
collapseToRoots,
|
||||
hasUsableMapping,
|
||||
seedMappings,
|
||||
settingsPathFor,
|
||||
triggersFor,
|
||||
usableMappings,
|
||||
} from "./webhookSetup";
|
||||
|
||||
const libraries = [
|
||||
{ id: 1, name: "Movies", type: "movie", enabled: true, paths: ["/mnt/media/movies"] },
|
||||
{ id: 2, name: "TV Shows", type: "series", enabled: true, paths: ["/mnt/media/tv"] },
|
||||
{ id: 3, name: "Everything", type: "mixed", enabled: true, paths: ["/mnt/media/mixed"] },
|
||||
{ id: 4, name: "Archive", type: "movie", enabled: false, paths: ["/mnt/archive"] },
|
||||
] as unknown as Library[];
|
||||
|
||||
describe("triggersFor", () => {
|
||||
// These must stay aligned with importEventTypes/deleteEventTypes in
|
||||
// internal/autoscan/arrwebhook/parse.go — telling an operator to tick a box
|
||||
// the host ignores is how "I followed the steps" bug reports start.
|
||||
it("offers the import triggers the host actually consumes", () => {
|
||||
const labels = triggersFor("sonarr").map((t) => t.label);
|
||||
expect(labels).toContain("On Import");
|
||||
expect(labels).toContain("On Upgrade");
|
||||
expect(labels).toContain("On Rename");
|
||||
});
|
||||
|
||||
it("names the delete trigger specific to each service", () => {
|
||||
expect(triggersFor("sonarr").map((t) => t.label)).toContain("On Episode File Delete");
|
||||
expect(triggersFor("radarr").map((t) => t.label)).toContain("On Movie File Delete");
|
||||
});
|
||||
|
||||
it("never offers a Sonarr-only trigger to Radarr", () => {
|
||||
expect(triggersFor("radarr").map((t) => t.label)).not.toContain("On Episode File Delete");
|
||||
});
|
||||
|
||||
it("marks import and upgrade as required, rename as optional", () => {
|
||||
const byLabel = new Map(triggersFor("sonarr").map((t) => [t.label, t.required]));
|
||||
expect(byLabel.get("On Import")).toBe(true);
|
||||
expect(byLabel.get("On Upgrade")).toBe(true);
|
||||
expect(byLabel.get("On Rename")).toBe(false);
|
||||
});
|
||||
|
||||
it("shows a combined delete trigger when the provider is unknown", () => {
|
||||
const labels = triggersFor("auto").map((t) => t.label);
|
||||
expect(
|
||||
labels.some((l) => l.includes("Episode File Delete") && l.includes("Movie File Delete")),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("settingsPathFor", () => {
|
||||
it("names the specific service when known", () => {
|
||||
expect(settingsPathFor("sonarr")).toContain("Sonarr → Settings → Connect");
|
||||
expect(settingsPathFor("radarr")).toContain("Radarr → Settings → Connect");
|
||||
});
|
||||
|
||||
it("covers both when the provider is auto", () => {
|
||||
expect(settingsPathFor("auto")).toContain("Sonarr/Radarr");
|
||||
});
|
||||
});
|
||||
|
||||
describe("collapseToRoots", () => {
|
||||
// Rewrites match by longest prefix at a segment boundary, so one rule for a
|
||||
// shared ancestor covers every path beneath it. Sibling paths dominate real
|
||||
// installs — the dev server's 96 paths contain no parent/child pairs at all,
|
||||
// so merely dropping nested paths would collapse nothing.
|
||||
it("collapses siblings to their common ancestor", () => {
|
||||
expect(
|
||||
collapseToRoots(["/mnt/media/movies/00s", "/mnt/media/movies/10s", "/mnt/media/tv/anime"]),
|
||||
).toEqual(["/mnt/media"]);
|
||||
});
|
||||
|
||||
it("absorbs children into an ancestor that is also present", () => {
|
||||
expect(collapseToRoots(["/mnt/media", "/mnt/media/movies/00s"])).toEqual(["/mnt/media"]);
|
||||
});
|
||||
|
||||
it("keeps separate mount points apart", () => {
|
||||
// Different storage entirely — merging these to "/" would be wrong. Each
|
||||
// mount has a single member here, so neither is widened.
|
||||
expect(collapseToRoots(["/mnt/sharedrives/a/x", "/tmp/silo/y"])).toEqual([
|
||||
"/mnt/sharedrives/a/x",
|
||||
"/tmp/silo/y",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves a lone path under a mount untouched", () => {
|
||||
// With one member there is nothing to merge, so do not widen it.
|
||||
expect(collapseToRoots(["/tmp/silo-transcode/test-extras"])).toEqual([
|
||||
"/tmp/silo-transcode/test-extras",
|
||||
]);
|
||||
});
|
||||
|
||||
it("de-duplicates and ignores trailing slashes and non-absolute entries", () => {
|
||||
expect(collapseToRoots(["/mnt/media/", "/mnt/media", " ", "relative/path"])).toEqual([
|
||||
"/mnt/media",
|
||||
]);
|
||||
});
|
||||
|
||||
it("collapses the real dev-server shape to one row per mount", () => {
|
||||
// Verified against 96 live paths: 95 under /mnt/sharedrives, 1 under /tmp.
|
||||
const real = [
|
||||
"/mnt/sharedrives/zd-storage-ceph/movies/00s",
|
||||
"/mnt/sharedrives/zd-storage-ceph/television-int/zh",
|
||||
"/mnt/sharedrives/arkyn-storage-ceph/test_files/1080p",
|
||||
"/mnt/sharedrives/zd-storage-books/audiobooks",
|
||||
"/tmp/silo-transcode/test-extras",
|
||||
];
|
||||
expect(collapseToRoots(real)).toEqual(["/mnt/sharedrives", "/tmp/silo-transcode/test-extras"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("seedMappings", () => {
|
||||
// These libraries all sit under /mnt/media, so each provider's selection
|
||||
// collapses to that single shared root — one row, not one per library.
|
||||
it("seeds one row for Sonarr's TV and mixed libraries", () => {
|
||||
expect(seedMappings("sonarr", libraries).map((m) => m.to)).toEqual(["/mnt/media"]);
|
||||
});
|
||||
|
||||
it("seeds one row for Radarr's movie and mixed libraries", () => {
|
||||
expect(seedMappings("radarr", libraries).map((m) => m.to)).toEqual(["/mnt/media"]);
|
||||
});
|
||||
|
||||
it("seeds one row across every library when the provider is unknown", () => {
|
||||
expect(seedMappings("auto", libraries).map((m) => m.to)).toEqual(["/mnt/media"]);
|
||||
});
|
||||
|
||||
it("still separates libraries on genuinely different mounts", () => {
|
||||
const split = [
|
||||
{ id: 1, name: "A", type: "movie", enabled: true, paths: ["/mnt/media/movies"] },
|
||||
{ id: 2, name: "B", type: "movie", enabled: true, paths: ["/srv/other/movies"] },
|
||||
] as unknown as Library[];
|
||||
expect(seedMappings("radarr", split).map((m) => m.to)).toEqual([
|
||||
"/mnt/media/movies",
|
||||
"/srv/other/movies",
|
||||
]);
|
||||
});
|
||||
|
||||
it("collapses many sibling paths under one mount into a single row", () => {
|
||||
// Shape taken from the dev server, where 95 of 96 paths share a mount.
|
||||
const sprawling = [
|
||||
{
|
||||
id: 9,
|
||||
name: "Movies",
|
||||
type: "movie",
|
||||
enabled: true,
|
||||
paths: [
|
||||
"/mnt/sharedrives/zd-storage-ceph/movies/00s",
|
||||
"/mnt/sharedrives/zd-storage-ceph/movies/10s",
|
||||
"/mnt/sharedrives/zd-storage-ceph/movies/4k",
|
||||
"/mnt/sharedrives/arkyn-storage-ceph/movies",
|
||||
],
|
||||
},
|
||||
] as unknown as Library[];
|
||||
|
||||
expect(seedMappings("radarr", sprawling).map((m) => m.to)).toEqual(["/mnt/sharedrives"]);
|
||||
});
|
||||
|
||||
it("skips disabled libraries", () => {
|
||||
expect(seedMappings("auto", libraries).map((m) => m.to)).not.toContain("/mnt/archive");
|
||||
});
|
||||
|
||||
it("leaves the arr-side path blank for the operator to fill", () => {
|
||||
expect(seedMappings("sonarr", libraries).every((m) => m.from === "")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns nothing when no libraries exist", () => {
|
||||
expect(seedMappings("sonarr", [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("usableMappings", () => {
|
||||
it("keeps only rows with both sides filled", () => {
|
||||
const got = usableMappings([
|
||||
{ from: "/downloads/tv", to: "/mnt/media/tv" },
|
||||
{ from: "", to: "/mnt/media/movies" },
|
||||
{ from: "/downloads/movies", to: "" },
|
||||
]);
|
||||
expect(got).toEqual([{ from: "/downloads/tv", to: "/mnt/media/tv" }]);
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace", () => {
|
||||
expect(usableMappings([{ from: " /a ", to: " /b " }])).toEqual([{ from: "/a", to: "/b" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasUsableMapping", () => {
|
||||
// A webhook source with no mapping accepts deliveries and resolves nothing —
|
||||
// the silent failure the guided flow exists to prevent.
|
||||
it("is false when every row is incomplete", () => {
|
||||
expect(hasUsableMapping([{ from: "", to: "/mnt/media/tv" }])).toBe(false);
|
||||
expect(hasUsableMapping([])).toBe(false);
|
||||
});
|
||||
|
||||
it("is true once one row is complete", () => {
|
||||
expect(
|
||||
hasUsableMapping([
|
||||
{ from: "", to: "/mnt/media/movies" },
|
||||
{ from: "/downloads/tv", to: "/mnt/media/tv" },
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import type { AutoscanWebhookProvider, Library } from "@/api/types";
|
||||
|
||||
/**
|
||||
* The arr notification triggers Silo actually consumes.
|
||||
*
|
||||
* Kept in sync with internal/autoscan/arrwebhook/parse.go: importEventTypes
|
||||
* ("Download"/"Import"/"Upgrade"/"DownloadComplete"), the "Rename" event, and
|
||||
* deleteEventTypes ("EpisodeFileDelete"/"MovieFileDelete"). Anything the host
|
||||
* ignores is deliberately absent — telling an operator to tick a box that does
|
||||
* nothing is how "I followed the steps and it didn't work" starts.
|
||||
*
|
||||
* Labels are the checkbox captions as they appear in Sonarr/Radarr's Connect →
|
||||
* Webhook screen, so they can be matched by eye without translation.
|
||||
*/
|
||||
export interface WebhookTrigger {
|
||||
/** Caption in the arr UI. */
|
||||
label: string;
|
||||
/** Why Silo wants it, shown as helper text. */
|
||||
reason: string;
|
||||
/** False for triggers that are useful but not needed for the basic case. */
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
const SHARED_TRIGGERS: WebhookTrigger[] = [
|
||||
{
|
||||
label: "On Import",
|
||||
reason: "The main one — fires when a download finishes and is moved into your library.",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
label: "On Upgrade",
|
||||
reason: "Fires when an existing file is replaced by a better quality version.",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
label: "On Rename",
|
||||
reason: "Fires when files are renamed, so Silo does not lose track of them.",
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
|
||||
const SONARR_TRIGGERS: WebhookTrigger[] = [
|
||||
...SHARED_TRIGGERS,
|
||||
{
|
||||
label: "On Episode File Delete",
|
||||
reason: "Lets Silo drop episodes you removed, instead of leaving dead entries.",
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
|
||||
const RADARR_TRIGGERS: WebhookTrigger[] = [
|
||||
...SHARED_TRIGGERS,
|
||||
{
|
||||
label: "On Movie File Delete",
|
||||
reason: "Lets Silo drop movies you removed, instead of leaving dead entries.",
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Triggers to tick for a provider. "auto" shows the union, since the operator
|
||||
* has not told us which service will post and either set is plausible.
|
||||
*/
|
||||
export function triggersFor(provider: AutoscanWebhookProvider | "auto"): WebhookTrigger[] {
|
||||
if (provider === "sonarr") return SONARR_TRIGGERS;
|
||||
if (provider === "radarr") return RADARR_TRIGGERS;
|
||||
return [
|
||||
...SHARED_TRIGGERS,
|
||||
{
|
||||
label: "On Episode File Delete / On Movie File Delete",
|
||||
reason: "Whichever your service offers — lets Silo drop files you removed.",
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Where the webhook is configured, for the on-screen instructions. */
|
||||
export function settingsPathFor(provider: AutoscanWebhookProvider | "auto"): string {
|
||||
const service =
|
||||
provider === "sonarr" ? "Sonarr" : provider === "radarr" ? "Radarr" : "Sonarr/Radarr";
|
||||
return `${service} → Settings → Connect → + → Webhook`;
|
||||
}
|
||||
|
||||
// --- Path mapping ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One row of the mapping the operator fills in: the path the arr reports
|
||||
* (`from`) and the Silo path it corresponds to (`to`).
|
||||
*
|
||||
* Webhook sources cannot use the /suggest endpoint — it resolves an arr's root
|
||||
* folders over the API and requires a bound connection, which a webhook source
|
||||
* by definition does not have. So the `to` side is seeded from real library
|
||||
* paths and the operator supplies the `from` side.
|
||||
*/
|
||||
export interface MappingDraft {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
function libraryKind(type: string): "movie" | "tv" | "mixed" | null {
|
||||
switch (type.trim().toLowerCase()) {
|
||||
case "movie":
|
||||
case "movies":
|
||||
return "movie";
|
||||
case "series":
|
||||
case "show":
|
||||
case "shows":
|
||||
case "tv":
|
||||
case "tvshows":
|
||||
return "tv";
|
||||
case "mixed":
|
||||
return "mixed";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse paths to their shared ancestors.
|
||||
*
|
||||
* The host applies rewrites by longest matching prefix at a segment boundary
|
||||
* (see applyRewrites in internal/autoscan/rewrite.go), so a rule for
|
||||
* `/mnt/media` already covers `/mnt/media/movies/80s` and everything else
|
||||
* beneath it. Emitting a row per library path is therefore pure noise: a real
|
||||
* library of 96 paths collapses to 4 roots, 85 of them under one.
|
||||
*
|
||||
* Only paths that are genuinely nested under another are dropped — siblings
|
||||
* survive, because no rewrite rule would otherwise cover them.
|
||||
*/
|
||||
export function collapseToRoots(paths: readonly string[]): string[] {
|
||||
const cleaned = paths
|
||||
.map((path) => path.trim().replace(/\/+$/, ""))
|
||||
.filter((path) => path.startsWith("/"));
|
||||
|
||||
const unique = [...new Set(cleaned)];
|
||||
if (unique.length === 0) return [];
|
||||
|
||||
// Group by mount point — the first two segments, e.g. "/mnt/sharedrives".
|
||||
// Paths under different mounts are genuinely different storage and must not
|
||||
// be merged, but everything under one mount can share a single rewrite.
|
||||
const groups = new Map<string, string[]>();
|
||||
for (const path of unique) {
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
const mount = "/" + segments.slice(0, 2).join("/");
|
||||
const existing = groups.get(mount);
|
||||
if (existing) existing.push(path);
|
||||
else groups.set(mount, [path]);
|
||||
}
|
||||
|
||||
const roots: string[] = [];
|
||||
for (const [mount, members] of groups) {
|
||||
roots.push(members.length === 1 ? members[0]! : commonAncestor(members) || mount);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Longest path prefix shared by every input, truncated at a segment boundary.
|
||||
* Returns "" when the inputs share nothing below the root.
|
||||
*/
|
||||
function commonAncestor(paths: readonly string[]): string {
|
||||
const split = paths.map((path) => path.split("/").filter(Boolean));
|
||||
const first = split[0];
|
||||
if (!first) return "";
|
||||
|
||||
const shared: string[] = [];
|
||||
for (let i = 0; i < first.length; i++) {
|
||||
const segment = first[i];
|
||||
if (!split.every((segments) => segments[i] === segment)) break;
|
||||
shared.push(segment!);
|
||||
}
|
||||
return shared.length > 0 ? "/" + shared.join("/") : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed mapping rows from the libraries a provider could plausibly feed: Sonarr
|
||||
* fills TV (and mixed) libraries, Radarr fills movie (and mixed) ones. Each row
|
||||
* starts with a real Silo path on the `to` side and a blank `from` for the
|
||||
* operator to complete.
|
||||
*
|
||||
* Rows are the collapsed roots rather than every library path, so an operator
|
||||
* fills in a handful of mappings instead of dozens of near-identical ones.
|
||||
*/
|
||||
export function seedMappings(
|
||||
provider: AutoscanWebhookProvider | "auto",
|
||||
libraries: readonly Library[],
|
||||
): MappingDraft[] {
|
||||
const want = provider === "sonarr" ? "tv" : provider === "radarr" ? "movie" : null;
|
||||
|
||||
const paths: string[] = [];
|
||||
for (const library of libraries) {
|
||||
if (!library.enabled) continue;
|
||||
const kind = libraryKind(library.type);
|
||||
if (!kind) continue;
|
||||
// A null `want` (auto) accepts everything; otherwise mixed always counts.
|
||||
if (want !== null && kind !== want && kind !== "mixed") continue;
|
||||
for (const path of library.paths ?? []) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
return collapseToRoots(paths).map((to) => ({ from: "", to }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop incomplete rows and trim. A half-filled row is treated as "not yet
|
||||
* configured" rather than an error, so an operator can leave one blank and
|
||||
* still save the rest.
|
||||
*/
|
||||
export function usableMappings(drafts: readonly MappingDraft[]): MappingDraft[] {
|
||||
return drafts
|
||||
.map((draft) => ({ from: draft.from.trim(), to: draft.to.trim() }))
|
||||
.filter((draft) => draft.from.length > 0 && draft.to.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the operator has supplied at least one complete mapping. Without one,
|
||||
* a webhook source accepts deliveries and resolves nothing — the silent failure
|
||||
* this whole flow exists to prevent.
|
||||
*/
|
||||
export function hasUsableMapping(drafts: readonly MappingDraft[]): boolean {
|
||||
return usableMappings(drafts).length > 0;
|
||||
}
|
||||
Reference in New Issue
Block a user