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:
Quick
2026-07-29 14:52:16 -04:00
parent a7601028d9
commit 251282186e
22 changed files with 3055 additions and 396 deletions
+35 -4
View File
@@ -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 != "":
+10 -3
View File
@@ -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 {
+118
View File
@@ -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
}
+153
View File
@@ -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
}
+226
View File
@@ -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
}
+192
View File
@@ -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)
}
}
+52
View File
@@ -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
+24 -4
View File
@@ -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")
+32
View File
@@ -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 {
+63 -24
View File
@@ -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&apos;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&apos;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;
+369 -358
View File
@@ -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 &amp; 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&apos;t match anything yet.
</p>
) : targets.libraries.length === 0 ? (
<p className="text-xs text-amber-500">
Paths don&apos;t match any library root — scans won&apos;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;
}