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 }