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.
196 lines
7.1 KiB
Go
196 lines
7.1 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/autoscan"
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
"github.com/Silo-Server/silo-server/internal/plugins"
|
|
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
|
|
"github.com/Silo-Server/silo-server/internal/scantrigger"
|
|
)
|
|
|
|
// autoscanQueuer is the scan-enqueue surface BuildAutoscanService needs; it is
|
|
// satisfied by *scanqueue.Service (autoscan.Queuer's concrete production impl).
|
|
type autoscanQueuer = autoscan.Queuer
|
|
|
|
// RequestIntegrationLookup adapts the Requests repository to the autoscan
|
|
// connection resolver's RequestIntegrationLookup: it resolves a soft-linked
|
|
// Requests integration to its base URL and api key (the repo decrypts the key
|
|
// on read, so this returns plaintext).
|
|
type RequestIntegrationLookup struct {
|
|
Repo *mediarequests.Repository
|
|
}
|
|
|
|
func (l RequestIntegrationLookup) Get(ctx context.Context, integrationID string) (baseURL, apiKey string, err error) {
|
|
integration, err := l.Repo.GetIntegration(ctx, integrationID)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
// A reused Requests connection must honor the integration's live state. The
|
|
// v1 poll gated on `WHERE ri.enabled = true`; here we surface a disabled or
|
|
// unconfigured (blank base_url) integration as an error so the engine turns
|
|
// it into a logged skip / RecordError rather than polling an unusable target.
|
|
if err := checkRequestIntegrationUsable(integrationID, integration.Enabled, integration.BaseURL); err != nil {
|
|
return "", "", err
|
|
}
|
|
return integration.BaseURL, integration.APIKeyRef, nil
|
|
}
|
|
|
|
// checkRequestIntegrationUsable returns a non-nil error when a linked Requests
|
|
// integration cannot be polled: it is disabled, or it has no base_url. Extracted
|
|
// as a pure function so the gating is unit-testable without a DB-backed repo.
|
|
func checkRequestIntegrationUsable(integrationID string, enabled bool, baseURL string) error {
|
|
if !enabled {
|
|
return fmt.Errorf("linked requests integration %q is disabled", integrationID)
|
|
}
|
|
if strings.TrimSpace(baseURL) == "" {
|
|
return fmt.Errorf("linked requests integration %q has no base_url configured", integrationID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PluginScanSourceAdapter adapts plugins.Service to autoscan.ScanSourceResolver.
|
|
// plugins.Service.ScanSourceClient returns the concrete
|
|
// *pluginhost.ScanSourceClient; that concrete type satisfies
|
|
// autoscan.PollChangesClient (it has the matching PollChanges method), so the
|
|
// adapter declares the exported interface as its return type and returns the
|
|
// concrete value (Go has no return-type covariance, so the method signature must
|
|
// name the interface exactly to satisfy ScanSourceResolver).
|
|
type PluginScanSourceAdapter struct {
|
|
Svc *plugins.Service
|
|
}
|
|
|
|
func (a PluginScanSourceAdapter) ScanSourceClient(ctx context.Context, pluginID, capabilityID string) (autoscan.PollChangesClient, error) {
|
|
return a.Svc.ScanSourceClientByPluginID(ctx, pluginID, capabilityID)
|
|
}
|
|
|
|
// scanSourceCapabilityType is the plugin capability type autoscan discovery
|
|
// enumerates.
|
|
const scanSourceCapabilityType = "scan_source.v1"
|
|
|
|
// PluginScanSourceLister adapts the plugin installation store to
|
|
// autoscan.ScanSourceLister: it enumerates every installed scan_source.v1
|
|
// capability across ALL installed plugins, regardless of enabled state.
|
|
type PluginScanSourceLister struct {
|
|
Store *plugins.InstallationStore
|
|
}
|
|
|
|
func (l PluginScanSourceLister) ListScanSources(ctx context.Context) ([]autoscan.DiscoveredSource, error) {
|
|
installations, err := l.Store.List(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []autoscan.DiscoveredSource
|
|
for _, inst := range installations {
|
|
caps, err := l.Store.ListCapabilities(ctx, inst.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, c := range caps {
|
|
if c == nil || c.Type != scanSourceCapabilityType {
|
|
continue
|
|
}
|
|
out = append(out, autoscan.DiscoveredSource{
|
|
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 name := scanSourceMetadataString(c, "display_name"); name != "" {
|
|
return name
|
|
}
|
|
switch {
|
|
case pluginID != "" && c != nil && c.ID != "":
|
|
return pluginID + " / " + c.ID
|
|
case pluginID != "":
|
|
return pluginID
|
|
case c != nil:
|
|
return c.ID
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// BuildAutoscanService wires the v2 autoscan engine from its concrete
|
|
// dependencies. Both the HTTP router (manual trigger) and the background poll
|
|
// task share this constructor so the adapter wiring lives in exactly one place.
|
|
// Credentials are now decrypted inline by the autoscan/requests repos, so there
|
|
// is no separate secret resolver to thread.
|
|
func BuildAutoscanService(
|
|
repo *autoscan.Repository,
|
|
pluginService *plugins.Service,
|
|
installationStore *plugins.InstallationStore,
|
|
requestsRepo *mediarequests.Repository,
|
|
folderRepo *catalog.FolderRepository,
|
|
queue autoscanQueuer,
|
|
redisClient *redis.Client,
|
|
) *autoscan.Service {
|
|
provider := autoscan.NewPluginProvider(PluginScanSourceAdapter{pluginService})
|
|
connRes := autoscan.NewConnectionResolver(RequestIntegrationLookup{requestsRepo})
|
|
svc := autoscan.NewService(
|
|
repo,
|
|
provider,
|
|
connRes,
|
|
scantrigger.NewResolver(folderRepo),
|
|
queue,
|
|
autoscan.NewRedisSuppressor(redisClient),
|
|
autoscan.WithBuiltinSources(
|
|
PluginScanSourceLister{installationStore},
|
|
autoscan.BuiltinArrWebhookSource(),
|
|
),
|
|
)
|
|
// Wire the connection-test + rewrite-suggester deps: a (long-timeout)
|
|
// arr root-folder/status client and a Silo media-folder lister.
|
|
svc.SetSuggesterDeps(
|
|
autoscan.NewArrRootFolderClient(nil),
|
|
autoscan.NewCatalogFolderLister(folderRepo),
|
|
)
|
|
return svc
|
|
}
|