Files
silo-server/internal/api/handlers/autoscan.go
Quick 251282186e 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.
2026-07-29 14:52:16 -04:00

1234 lines
47 KiB
Go

package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/autoscan"
"github.com/Silo-Server/silo-server/internal/taskmanager"
)
// autoscanStore is the subset of *autoscan.Repository the handler needs. The
// repository implements the full CRUD surface; this interface lets tests inject
// a fake.
type autoscanStore interface {
GetSettings(ctx context.Context) (autoscan.Settings, error)
UpdateSettings(ctx context.Context, s autoscan.Settings) (autoscan.Settings, error)
ListConnections(ctx context.Context) ([]autoscan.Connection, error)
CreateConnection(ctx context.Context, c autoscan.Connection) (autoscan.Connection, error)
UpdateConnection(ctx context.Context, c autoscan.Connection) (autoscan.Connection, error)
DeleteConnection(ctx context.Context, id string) error
ListSources(ctx context.Context) ([]autoscan.Source, error)
GetSource(ctx context.Context, id string) (autoscan.Source, error)
CreateSource(ctx context.Context, s autoscan.Source) (autoscan.Source, error)
UpdateSource(ctx context.Context, s autoscan.Source) (autoscan.Source, error)
DeleteSource(ctx context.Context, id string) error
ListAutoscanScans(ctx context.Context, filter autoscan.ScanListFilter) ([]autoscan.ScanWithEvent, error)
CountAutoscanScans(ctx context.Context, filter autoscan.ScanListFilter) (int, error)
ListEvents(ctx context.Context, filter autoscan.EventListFilter) ([]autoscan.EventWithRuns, error)
CountEvents(ctx context.Context, filter autoscan.EventListFilter) (int, error)
ListRunningEvents(ctx context.Context) ([]autoscan.Event, error)
GetQueueSummary(ctx context.Context) (autoscan.QueueSummary, error)
LatestEventAt(ctx context.Context) (*time.Time, error)
CreateWebhookEndpoint(ctx context.Context, sourceID string) (autoscan.WebhookEndpoint, string, error)
RotateWebhookEndpoint(ctx context.Context, sourceID string) (autoscan.WebhookEndpoint, string, error)
DeleteWebhookEndpoint(ctx context.Context, sourceID string) error
GetWebhookEndpoint(ctx context.Context, sourceID string) (autoscan.WebhookEndpoint, error)
ListWebhookEndpoints(ctx context.Context) ([]autoscan.WebhookEndpoint, error)
RevealWebhookToken(ctx context.Context, sourceID string) (string, error)
ResolveWebhookToken(ctx context.Context, token string) (autoscan.Source, autoscan.WebhookEndpoint, error)
TouchWebhookReceived(ctx context.Context, sourceID string) error
RecordWebhookError(ctx context.Context, sourceID, msg string) error
}
// autoscanTriggerer is the subset of *autoscan.Service the handler needs.
type autoscanTriggerer interface {
PollOnce(ctx context.Context) error
// ListAvailableScanSources enumerates installed scan_source capabilities for
// the Add-source picker, and is also used to validate a create request
// targets a currently-installed capability.
ListAvailableScanSources(ctx context.Context) ([]autoscan.AvailableScanSource, error)
// TestConnection probes an ad-hoc connection input; TestConnectionByID probes
// an existing stored connection.
TestConnection(ctx context.Context, c autoscan.Connection) (autoscan.ConnectionTestResult, error)
TestConnectionByID(ctx context.Context, id string) (autoscan.ConnectionTestResult, error)
// SuggestRewrites proposes path rewrites by matching the source's arr root
// folders against Silo's media folders.
SuggestRewrites(ctx context.Context, sourceID string) (autoscan.RewriteSuggestions, error)
// IngestChanges feeds webhook-delivered changes through the shared
// rewrite/resolve/suppress/enqueue pipeline.
IngestChanges(ctx context.Context, in autoscan.ChangeIngest) (autoscan.IngestResult, error)
}
// autoscanTriggerUpdater reconfigures the poll task's schedule when the default
// poll interval changes (satisfied by *taskmanager.TaskManager). It is optional:
// when nil, a settings change still persists but only takes effect on restart.
type autoscanTriggerUpdater interface {
UpdateTriggers(key string, triggerConfigs []taskmanager.TriggerConfig) error
}
// autoscanPollTaskKey is the task-manager key for the autoscan poll task. It must
// match (*tasks.AutoscanPollTask).Key().
const autoscanPollTaskKey = "autoscan_poll"
// AutoscanHandler serves the admin-only autoscan settings/connections/sources/
// trigger API plus the public webhook delivery endpoint.
type AutoscanHandler struct {
repo autoscanStore
svc autoscanTriggerer
triggers autoscanTriggerUpdater // optional; nil skips rescheduling
// publicURL is the server's externally reachable base URL; when empty,
// webhook URLs are returned as absolute paths for the UI to complete.
publicURL string
}
func NewAutoscanHandler(repo autoscanStore, svc autoscanTriggerer) *AutoscanHandler {
return &AutoscanHandler{repo: repo, svc: svc}
}
// SetTriggerUpdater wires the optional poll-task rescheduler so a settings change
// re-applies the poll interval without a restart. Keeping it a setter (rather
// than a constructor arg) lets tests construct the handler without a task
// manager.
func (h *AutoscanHandler) SetTriggerUpdater(t autoscanTriggerUpdater) {
h.triggers = t
}
// SetPublicURL wires the server's public base URL so webhook URLs are returned
// fully qualified. Optional: when unset, responses carry the absolute path and
// the admin UI prefixes its own origin.
func (h *AutoscanHandler) SetPublicURL(publicURL string) {
h.publicURL = strings.TrimRight(strings.TrimSpace(publicURL), "/")
}
// --- Settings ---
type autoscanSettingsResponse struct {
Enabled bool `json:"enabled"`
DefaultPollIntervalSeconds int `json:"default_poll_interval_seconds"`
DebounceSeconds int `json:"debounce_seconds"`
}
func settingsResponse(s autoscan.Settings) autoscanSettingsResponse {
return autoscanSettingsResponse{
Enabled: s.Enabled,
DefaultPollIntervalSeconds: s.DefaultPollIntervalSeconds,
DebounceSeconds: s.DebounceSeconds,
}
}
func (h *AutoscanHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request) {
settings, err := h.repo.GetSettings(r.Context())
if err != nil {
writeAutoscanError(w, err)
return
}
writeJSON(w, http.StatusOK, settingsResponse(settings))
}
func (h *AutoscanHandler) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
var req autoscanSettingsResponse
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if req.DefaultPollIntervalSeconds <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "default_poll_interval_seconds must be greater than 0")
return
}
if req.DebounceSeconds < 0 {
writeError(w, http.StatusBadRequest, "bad_request", "debounce_seconds must be 0 or greater")
return
}
updated, err := h.repo.UpdateSettings(r.Context(), autoscan.Settings{
Enabled: req.Enabled,
DefaultPollIntervalSeconds: req.DefaultPollIntervalSeconds,
DebounceSeconds: req.DebounceSeconds,
})
if err != nil {
writeAutoscanError(w, err)
return
}
// Reschedule the poll task so a changed default_poll_interval_seconds takes
// effect immediately rather than only after a restart. Optional: skip when no
// task manager is wired (e.g. tests). A reschedule failure is non-fatal — the
// new interval is persisted and will apply on the next restart regardless.
if h.triggers != nil {
intervalMs := int64(updated.DefaultPollIntervalSeconds) * 1000
if terr := h.triggers.UpdateTriggers(autoscanPollTaskKey, []taskmanager.TriggerConfig{
{Type: taskmanager.TriggerTypeInterval, IntervalMs: intervalMs},
}); terr != nil {
slog.WarnContext(r.Context(), "autoscan: reschedule poll task failed", "component", "api", "err", terr)
}
}
writeJSON(w, http.StatusOK, settingsResponse(updated))
}
// --- Connections ---
// autoscanConnectionResponse is the client view of a connection. It NEVER
// includes api_key_ref or any resolved credential: callers manage credentials
// either by setting an api-key ref (write-only) or by linking a Requests
// integration.
type autoscanConnectionResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
BaseURL string `json:"base_url,omitempty"`
RequestIntegrationID *string `json:"request_integration_id,omitempty"`
// HasAPIKey reports whether the connection carries its own (write-only)
// api-key ref, without disclosing the ref itself.
HasAPIKey bool `json:"has_api_key"`
}
func connectionResponse(c autoscan.Connection) autoscanConnectionResponse {
return autoscanConnectionResponse{
ID: c.ID,
Name: c.Name,
Kind: c.Kind,
BaseURL: c.BaseURL,
RequestIntegrationID: c.RequestIntegrationID,
HasAPIKey: strings.TrimSpace(c.APIKeyRef) != "",
}
}
// autoscanConnectionInput is the write payload. api_key_ref is accepted (it is
// an opaque reference into platform settings, never a plaintext secret) but is
// never echoed back in any response.
type autoscanConnectionInput struct {
Name string `json:"name"`
Kind string `json:"kind"`
BaseURL string `json:"base_url"`
APIKeyRef string `json:"api_key_ref"`
RequestIntegrationID *string `json:"request_integration_id"`
}
// validateConnectionInput enforces the invariant that migration 171 dropped from
// the DB CHECK and delegated to the application layer: a connection must carry
// either its own base_url or a live link to a Requests integration. A connection
// with neither is a both-NULL orphan that ConnectionResolver.Resolve would hand
// a plugin as an empty base URL. A whitespace-only request_integration_id counts
// as absent. Enforced on both create and update (an update can strip a
// connection to both-empty).
func validateConnectionInput(in autoscanConnectionInput) error {
hasOwn := strings.TrimSpace(in.BaseURL) != ""
hasLink := in.RequestIntegrationID != nil && strings.TrimSpace(*in.RequestIntegrationID) != ""
if !hasOwn && !hasLink {
return errors.New("connection requires base_url or request_integration_id")
}
return nil
}
// normalizeRequestIntegrationID trims whitespace from the optional Requests
// integration link and collapses an empty-after-trim value to nil. This keeps
// the persisted column either a real id or NULL — a pointer to "" or " " must
// never be stored as a bogus link (the resolver guards empty at read time, but
// normalizing on write keeps the data consistent).
func normalizeRequestIntegrationID(ref *string) *string {
if ref == nil {
return nil
}
trimmed := strings.TrimSpace(*ref)
if trimmed == "" {
return nil
}
return &trimmed
}
func (h *AutoscanHandler) HandleListConnections(w http.ResponseWriter, r *http.Request) {
conns, err := h.repo.ListConnections(r.Context())
if err != nil {
writeAutoscanError(w, err)
return
}
out := make([]autoscanConnectionResponse, 0, len(conns))
for _, c := range conns {
out = append(out, connectionResponse(c))
}
writeJSON(w, http.StatusOK, struct {
Connections []autoscanConnectionResponse `json:"connections"`
}{Connections: out})
}
func (h *AutoscanHandler) HandleCreateConnection(w http.ResponseWriter, r *http.Request) {
var in autoscanConnectionInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if strings.TrimSpace(in.Name) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "name is required")
return
}
if err := validateConnectionInput(in); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
created, err := h.repo.CreateConnection(r.Context(), autoscan.Connection{
Name: strings.TrimSpace(in.Name),
Kind: strings.TrimSpace(in.Kind),
BaseURL: strings.TrimSpace(in.BaseURL),
APIKeyRef: strings.TrimSpace(in.APIKeyRef),
RequestIntegrationID: normalizeRequestIntegrationID(in.RequestIntegrationID),
})
if err != nil {
writeAutoscanError(w, err)
return
}
writeJSON(w, http.StatusCreated, connectionResponse(created))
}
func (h *AutoscanHandler) HandleUpdateConnection(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(chi.URLParam(r, "id"))
var in autoscanConnectionInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if strings.TrimSpace(in.Name) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "name is required")
return
}
if err := validateConnectionInput(in); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
updated, err := h.repo.UpdateConnection(r.Context(), autoscan.Connection{
ID: id,
Name: strings.TrimSpace(in.Name),
Kind: strings.TrimSpace(in.Kind),
BaseURL: strings.TrimSpace(in.BaseURL),
APIKeyRef: strings.TrimSpace(in.APIKeyRef),
RequestIntegrationID: normalizeRequestIntegrationID(in.RequestIntegrationID),
})
if err != nil {
writeAutoscanError(w, err)
return
}
writeJSON(w, http.StatusOK, connectionResponse(updated))
}
func (h *AutoscanHandler) HandleDeleteConnection(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(chi.URLParam(r, "id"))
if err := h.repo.DeleteConnection(r.Context(), id); err != nil {
writeAutoscanError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- Sources ---
// autoscanSourceResponse is the client view of a source. It carries no
// credentials: the connection link is by id only. The webhook_* fields are
// admin-only setup surface; webhook_url deliberately stays redisplayable (the
// token's blast radius is spurious scans of already-registered library paths,
// and one-time display would force a rotation after any config loss).
type autoscanSourceResponse struct {
ID string `json:"id"`
PluginID string `json:"plugin_id"`
CapabilityID string `json:"capability_id"`
ConnectionID *string `json:"connection_id"`
Enabled bool `json:"enabled"`
DeliveryMode string `json:"delivery_mode"`
PollIntervalSeconds *int `json:"poll_interval_seconds,omitempty"`
PathRewrites []autoscan.PathRewrite `json:"path_rewrites"`
SourceConfig map[string]string `json:"source_config"`
Label string `json:"label"`
LastRunAt *time.Time `json:"last_run_at,omitempty"`
LastError *string `json:"last_error,omitempty"`
WebhookConfigured bool `json:"webhook_configured"`
WebhookURL string `json:"webhook_url,omitempty"`
WebhookSecretSuffix string `json:"webhook_secret_suffix,omitempty"`
WebhookLastReceivedAt *time.Time `json:"webhook_last_received_at,omitempty"`
WebhookLastErrorAt *time.Time `json:"webhook_last_error_at,omitempty"`
WebhookLastErrorMessage string `json:"webhook_last_error_message,omitempty"`
}
func sourceResponse(s autoscan.Source) autoscanSourceResponse {
// Always emit a (possibly empty) array, never JSON null, so clients can treat
// path_rewrites as a stable list field.
rewrites := s.PathRewrites
if rewrites == nil {
rewrites = []autoscan.PathRewrite{}
}
config := normalizeSourceConfig(s.SourceConfig)
deliveryMode := s.DeliveryMode
if deliveryMode == "" {
deliveryMode = autoscan.DeliveryModePoll
}
return autoscanSourceResponse{
ID: s.ID,
PluginID: s.PluginID,
CapabilityID: s.CapabilityID,
ConnectionID: s.ConnectionID,
Enabled: s.Enabled,
DeliveryMode: deliveryMode,
PollIntervalSeconds: s.PollIntervalSeconds,
PathRewrites: rewrites,
SourceConfig: config,
Label: s.Label,
LastRunAt: s.LastRunAt,
LastError: s.LastError,
}
}
// webhookURLFor builds the delivery URL for a token: fully qualified when the
// server knows its public URL, otherwise an absolute path the admin UI
// completes with its own origin.
func (h *AutoscanHandler) webhookURLFor(token string) string {
return h.publicURL + "/api/v1/autoscan/webhooks/" + token
}
// attachWebhookState decorates a source response with its endpoint status and
// redisplayable URL. A reveal failure (e.g. cipher/key drift) degrades to
// status-without-URL rather than failing the response.
func (h *AutoscanHandler) attachWebhookState(ctx context.Context, resp *autoscanSourceResponse, endpoint autoscan.WebhookEndpoint) {
resp.WebhookConfigured = true
resp.WebhookSecretSuffix = endpoint.SecretSuffix
resp.WebhookLastReceivedAt = endpoint.LastReceivedAt
resp.WebhookLastErrorAt = endpoint.LastErrorAt
resp.WebhookLastErrorMessage = endpoint.LastErrorMessage
token, err := h.repo.RevealWebhookToken(ctx, endpoint.SourceID)
if err != nil {
slog.WarnContext(ctx, "autoscan: reveal webhook token failed", "component", "api", "source_id", endpoint.SourceID, "err", err)
return
}
resp.WebhookURL = h.webhookURLFor(token)
}
// sourceResponseWithWebhook builds a single source response including webhook
// endpoint state (used by the single-source create/update/webhook handlers).
func (h *AutoscanHandler) sourceResponseWithWebhook(ctx context.Context, s autoscan.Source) autoscanSourceResponse {
resp := sourceResponse(s)
endpoint, err := h.repo.GetWebhookEndpoint(ctx, s.ID)
if err != nil {
if !errors.Is(err, autoscan.ErrNotFound) {
slog.WarnContext(ctx, "autoscan: load webhook endpoint failed", "component", "api", "source_id", s.ID, "err", err)
}
return resp
}
h.attachWebhookState(ctx, &resp, endpoint)
return resp
}
func (h *AutoscanHandler) HandleListSources(w http.ResponseWriter, r *http.Request) {
// Sources are operator-created (no auto-seed): just list what exists. Use
// GET /scan-source-plugins for the Add-source picker of installed capabilities.
sources, err := h.repo.ListSources(r.Context())
if err != nil {
writeAutoscanError(w, err)
return
}
// One batched endpoint query for the whole listing (not per source).
endpoints, err := h.repo.ListWebhookEndpoints(r.Context())
if err != nil {
writeAutoscanError(w, err)
return
}
endpointBySource := make(map[string]autoscan.WebhookEndpoint, len(endpoints))
for _, e := range endpoints {
endpointBySource[e.SourceID] = e
}
out := make([]autoscanSourceResponse, 0, len(sources))
for _, s := range sources {
resp := sourceResponse(s)
if endpoint, ok := endpointBySource[s.ID]; ok {
h.attachWebhookState(r.Context(), &resp, endpoint)
}
out = append(out, resp)
}
writeJSON(w, http.StatusOK, struct {
Sources []autoscanSourceResponse `json:"sources"`
}{Sources: out})
}
// --- 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"`
Description string `json:"description,omitempty"`
Descriptor autoscan.ScanSourceDescriptor `json:"descriptor"`
}
// HandleListAvailableScanSources returns every installed scan_source capability
// an operator can create a source against (the Add-source picker list).
func (h *AutoscanHandler) HandleListAvailableScanSources(w http.ResponseWriter, r *http.Request) {
available, err := h.svc.ListAvailableScanSources(r.Context())
if err != nil {
writeAutoscanError(w, err)
return
}
out := make([]autoscanScanSourcePluginResponse, 0, len(available))
for _, a := range available {
out = append(out, autoscanScanSourcePluginResponse{
PluginID: a.PluginID,
CapabilityID: a.CapabilityID,
DisplayName: a.DisplayName,
Description: a.Description,
Descriptor: a.Descriptor,
})
}
writeJSON(w, http.StatusOK, struct {
Plugins []autoscanScanSourcePluginResponse `json:"plugins"`
}{Plugins: out})
}
// --- Create source ---
// autoscanCreateSourceInput is the create payload: it names the scan_source
// capability to bind (plugin_id + capability_id) plus the initial
// binding/scheduling fields. Unlike the update payload, identity is supplied
// here rather than read from an existing row.
type autoscanCreateSourceInput struct {
PluginID string `json:"plugin_id"`
CapabilityID string `json:"capability_id"`
ConnectionID *string `json:"connection_id"`
Enabled bool `json:"enabled"`
DeliveryMode string `json:"delivery_mode"`
PollIntervalSeconds *int `json:"poll_interval_seconds"`
PathRewrites []autoscan.PathRewrite `json:"path_rewrites"`
SourceConfig map[string]string `json:"source_config"`
Label string `json:"label"`
}
// resolveDeliveryMode validates a requested delivery mode against the source
// identity: webhook mode exists only for the built-in ARR webhook identity
// (which cannot poll — it has no plugin), and an empty mode defaults to
// whichever mode the identity supports.
func resolveDeliveryMode(requested, pluginID, capabilityID string) (string, error) {
isBuiltinWebhook := autoscan.IsBuiltinArrWebhookIdentity(pluginID, capabilityID)
switch strings.ToLower(strings.TrimSpace(requested)) {
case "":
if isBuiltinWebhook {
return autoscan.DeliveryModeWebhook, nil
}
return autoscan.DeliveryModePoll, nil
case autoscan.DeliveryModePoll:
if isBuiltinWebhook {
return "", fmt.Errorf("the built-in Sonarr/Radarr webhook source has no plugin to poll; delivery_mode must be webhook")
}
return autoscan.DeliveryModePoll, nil
case autoscan.DeliveryModeWebhook:
if !isBuiltinWebhook {
return "", fmt.Errorf("delivery_mode webhook is only valid for the built-in Sonarr/Radarr webhook source (%s/%s)",
autoscan.BuiltinArrWebhookPluginID, autoscan.BuiltinArrWebhookCapabilityID)
}
return autoscan.DeliveryModeWebhook, nil
default:
return "", fmt.Errorf("delivery_mode must be poll or webhook")
}
}
// validateWebhookProvider checks the optional source_config.webhook_provider
// value used by webhook payload parsing.
func validateWebhookProvider(config map[string]string) error {
provider, ok := config["webhook_provider"]
if !ok {
return nil
}
switch strings.ToLower(strings.TrimSpace(provider)) {
case "", "auto", "sonarr", "radarr":
return nil
}
return fmt.Errorf("source_config.webhook_provider must be auto, sonarr, or radarr")
}
// HandleCreateSource creates a new source bound to an installed scan_source
// capability. Many sources may share one capability (e.g. one Sonarr plugin
// fronting several arr servers, one source per connection). The capability must
// currently be installed; enabling requires a bound connection.
func (h *AutoscanHandler) HandleCreateSource(w http.ResponseWriter, r *http.Request) {
var in autoscanCreateSourceInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
capID := strings.TrimSpace(in.CapabilityID)
pluginID := strings.TrimSpace(in.PluginID)
if pluginID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "plugin_id is required")
return
}
if capID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "capability_id is required")
return
}
if in.PollIntervalSeconds != nil && *in.PollIntervalSeconds <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "poll_interval_seconds must be greater than 0 when set")
return
}
if err := validatePathRewrites(in.PathRewrites); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
deliveryMode, err := resolveDeliveryMode(in.DeliveryMode, pluginID, capID)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
sourceConfig := normalizeSourceConfig(in.SourceConfig)
if deliveryMode == autoscan.DeliveryModeWebhook {
if err := validateWebhookProvider(sourceConfig); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if provider, ok := sourceConfig["webhook_provider"]; ok {
sourceConfig["webhook_provider"] = strings.ToLower(strings.TrimSpace(provider))
}
}
// Validate the (plugin_id, capability_id) is a currently-installed
// scan_source capability — a source may only be created against an installed
// plugin capability. The built-in webhook identity passes because the
// composite lister always supplies it.
available, err := h.svc.ListAvailableScanSources(r.Context())
if err != nil {
writeAutoscanError(w, err)
return
}
if !scanSourceInstalled(available, pluginID, capID) {
writeError(w, http.StatusBadRequest, "bad_request", "plugin_id + capability_id is not a currently-installed scan_source capability")
return
}
connArg := normalizeConnectionID(in.ConnectionID)
created, err := h.repo.CreateSource(r.Context(), autoscan.Source{
PluginID: pluginID,
CapabilityID: capID,
ConnectionID: connArg,
Enabled: in.Enabled,
DeliveryMode: deliveryMode,
PollIntervalSeconds: in.PollIntervalSeconds,
PathRewrites: normalizePathRewrites(in.PathRewrites),
SourceConfig: sourceConfig,
Label: autoscan.NormalizeSourceLabel(in.Label),
})
if err != nil {
writeAutoscanError(w, err)
return
}
writeJSON(w, http.StatusCreated, sourceResponse(created))
}
// scanSourceInstalled reports whether (pluginID, capabilityID) is in the
// set of currently-installed scan_source capabilities.
func scanSourceInstalled(available []autoscan.AvailableScanSource, pluginID, capabilityID string) bool {
for _, a := range available {
if a.PluginID == pluginID && a.CapabilityID == capabilityID {
return true
}
}
return false
}
// autoscanSourceInput is the source write payload. The (plugin_id,
// capability_id) identity is read from the existing source row, so only the
// schedulable/binding fields are accepted.
//
// connection_id is a *string so the caller can express three distinct states:
// - field absent / JSON null → nil → unbind (clear the connection)
// - non-empty string UUID → bind to that connection
//
// The UI must always send the full desired state for all three fields.
// path_rewrites is full-state like connection_id: the UI always sends the
// complete desired list. Each entry needs a non-empty from and to.
type autoscanSourceInput struct {
ConnectionID *string `json:"connection_id"`
Enabled bool `json:"enabled"`
DeliveryMode string `json:"delivery_mode"`
PollIntervalSeconds *int `json:"poll_interval_seconds"`
PathRewrites []autoscan.PathRewrite `json:"path_rewrites"`
SourceConfig map[string]string `json:"source_config"`
Label string `json:"label"`
}
// validatePathRewrites lightly validates a source's rewrite list: each entry
// must carry a non-empty (trimmed) from and to. Returns an error suitable for a
// 400 response.
func validatePathRewrites(rewrites []autoscan.PathRewrite) error {
for i, rw := range rewrites {
if strings.TrimSpace(rw.From) == "" || strings.TrimSpace(rw.To) == "" {
return fmt.Errorf("path_rewrites[%d]: from and to are both required", i)
}
}
return nil
}
// normalizePathRewrites trims whitespace off each rewrite's from/to so stored
// rules match the same trimming applyRewrites does at poll time.
func normalizePathRewrites(rewrites []autoscan.PathRewrite) []autoscan.PathRewrite {
out := make([]autoscan.PathRewrite, 0, len(rewrites))
for _, rw := range rewrites {
out = append(out, autoscan.PathRewrite{
From: strings.TrimSpace(rw.From),
To: strings.TrimSpace(rw.To),
})
}
return out
}
func normalizeSourceConfig(config map[string]string) map[string]string {
out := make(map[string]string, len(config))
for key, value := range config {
key = strings.TrimSpace(key)
if key == "" {
continue
}
out[key] = strings.TrimSpace(value)
}
return out
}
func (h *AutoscanHandler) HandleUpdateSource(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(chi.URLParam(r, "id"))
var in autoscanSourceInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if in.PollIntervalSeconds != nil && *in.PollIntervalSeconds <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "poll_interval_seconds must be greater than 0 when set")
return
}
if err := validatePathRewrites(in.PathRewrites); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
// Delivery mode is validated against the existing row's immutable identity:
// an empty input keeps the stored mode.
existing, err := h.repo.GetSource(r.Context(), id)
if err != nil {
writeAutoscanError(w, err)
return
}
requestedMode := in.DeliveryMode
if strings.TrimSpace(requestedMode) == "" {
requestedMode = existing.DeliveryMode
}
deliveryMode, err := resolveDeliveryMode(requestedMode, existing.PluginID, existing.CapabilityID)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
sourceConfig := normalizeSourceConfig(in.SourceConfig)
if deliveryMode == autoscan.DeliveryModeWebhook {
if err := validateWebhookProvider(sourceConfig); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if provider, ok := sourceConfig["webhook_provider"]; ok {
sourceConfig["webhook_provider"] = strings.ToLower(strings.TrimSpace(provider))
}
}
// connection_id is a full-state field: nil means unbind, a UUID string
// means bind. A whitespace-only string is normalised to nil (unbound).
connArg := normalizeConnectionID(in.ConnectionID)
// Update by id; identity (plugin_id, capability_id) is immutable and
// preserved by the repo. A missing source maps to 404.
updated, err := h.repo.UpdateSource(r.Context(), autoscan.Source{
ID: id,
ConnectionID: connArg,
Enabled: in.Enabled,
DeliveryMode: deliveryMode,
PollIntervalSeconds: in.PollIntervalSeconds,
PathRewrites: normalizePathRewrites(in.PathRewrites),
SourceConfig: sourceConfig,
Label: autoscan.NormalizeSourceLabel(in.Label),
})
if err != nil {
writeAutoscanError(w, err)
return
}
writeJSON(w, http.StatusOK, h.sourceResponseWithWebhook(r.Context(), updated))
}
// normalizeConnectionID maps a full-state connection_id input to a stored value:
// a nil pointer or a whitespace-only string means unbound (nil); a non-empty
// trimmed UUID binds to that connection.
func normalizeConnectionID(in *string) *string {
if in == nil {
return nil
}
trimmed := strings.TrimSpace(*in)
if trimmed == "" {
return nil
}
return &trimmed
}
// HandleDeleteSource removes a source row. This is the operator's escape hatch
// for an orphaned source (one whose scan_source plugin was uninstalled or no
// longer resolves cleanly). An unknown id maps to 404 via autoscan.ErrNotFound.
func (h *AutoscanHandler) HandleDeleteSource(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(chi.URLParam(r, "id"))
if err := h.repo.DeleteSource(r.Context(), id); err != nil {
writeAutoscanError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- Test connection ---
// autoscanTestConnectionInput probes either an ad-hoc connection (own base_url +
// api_key_ref, or a linked request_integration_id) OR an existing stored
// connection by id. When connection_id is set, it takes precedence and the
// ad-hoc fields are ignored.
type autoscanTestConnectionInput struct {
ConnectionID *string `json:"connection_id"`
BaseURL string `json:"base_url"`
APIKeyRef string `json:"api_key_ref"`
RequestIntegrationID *string `json:"request_integration_id"`
}
// autoscanTestConnectionResponse is the probe outcome: ok=true with the reported
// arr version on success, or ok=false with a human-readable error on failure
// (unreachable / 401 / non-200). A failed probe is still a 200 response — the
// failure lives in the payload, not the HTTP status.
type autoscanTestConnectionResponse struct {
OK bool `json:"ok"`
Version string `json:"version,omitempty"`
Error string `json:"error,omitempty"`
}
// HandleTestConnection probes an arr connection's /api/v3/system/status.
func (h *AutoscanHandler) HandleTestConnection(w http.ResponseWriter, r *http.Request) {
var in autoscanTestConnectionInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
var (
result autoscan.ConnectionTestResult
err error
)
if in.ConnectionID != nil && strings.TrimSpace(*in.ConnectionID) != "" {
result, err = h.svc.TestConnectionByID(r.Context(), strings.TrimSpace(*in.ConnectionID))
} else {
// Ad-hoc connection: must carry own base_url or a linked integration,
// same invariant as create/update.
conn := autoscan.Connection{
BaseURL: strings.TrimSpace(in.BaseURL),
APIKeyRef: strings.TrimSpace(in.APIKeyRef),
RequestIntegrationID: in.RequestIntegrationID,
}
hasOwn := conn.BaseURL != ""
hasLink := conn.RequestIntegrationID != nil && strings.TrimSpace(*conn.RequestIntegrationID) != ""
if !hasOwn && !hasLink {
writeError(w, http.StatusBadRequest, "bad_request", "connection requires connection_id, base_url, or request_integration_id")
return
}
result, err = h.svc.TestConnection(r.Context(), conn)
}
if err != nil {
writeAutoscanError(w, err)
return
}
writeJSON(w, http.StatusOK, autoscanTestConnectionResponse{
OK: result.OK,
Version: result.Version,
Error: result.Err,
})
}
// --- Rewrite suggestions (sync from arr) ---
// HandleRewriteSuggestions proposes path rewrites for a source by matching its
// bound connection's arr root folders against Silo's media folders. The
// operator applies chosen suggestions via the normal source PUT (path_rewrites).
func (h *AutoscanHandler) HandleRewriteSuggestions(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(chi.URLParam(r, "id"))
suggestions, err := h.svc.SuggestRewrites(r.Context(), id)
if err != nil {
if errors.Is(err, autoscan.ErrNoConnection) {
writeError(w, http.StatusBadRequest, "bad_request", "source has no bound connection; bind one before syncing rewrites")
return
}
writeAutoscanError(w, err)
return
}
writeJSON(w, http.StatusOK, suggestions)
}
// --- Trigger ---
func (h *AutoscanHandler) HandleTrigger(w http.ResponseWriter, r *http.Request) {
// Run the poll detached: don't block the request on a full cycle, and don't
// tie the poll to the request context (which is cancelled once we respond).
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
if err := h.svc.PollOnce(ctx); err != nil {
slog.WarnContext(ctx, "autoscan: manual poll failed", "component", "api", "err", err)
}
}()
writeJSON(w, http.StatusAccepted, struct {
Status string `json:"status"`
}{Status: "ok"})
}
// --- Events ---
type autoscanEventScanRunResponse struct {
ID string `json:"id"`
LibraryID int `json:"library_id"`
Mode string `json:"mode"`
Path string `json:"path,omitempty"`
Trigger string `json:"trigger"`
Status string `json:"status"`
RequestedAt *time.Time `json:"requested_at,omitempty"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
}
type autoscanEventResponse struct {
ID int64 `json:"id"`
SourceID *string `json:"source_id"`
PluginID string `json:"plugin_id"`
CapabilityID string `json:"capability_id"`
StartedAt time.Time `json:"started_at"`
CompletedAt time.Time `json:"completed_at"`
DurationMS int64 `json:"duration_ms"`
Status string `json:"status"`
DeliveryMode string `json:"delivery_mode"`
ProviderEvent string `json:"provider_event_type,omitempty"`
ChangesReturned int `json:"changes_returned"`
ChangesResolved int `json:"changes_resolved"`
TargetsClaimed int `json:"targets_claimed"`
ScansCreated int `json:"scans_created"`
ScansReused int `json:"scans_reused"`
ScansSuppressed int `json:"scans_suppressed"`
ErrorMessage string `json:"error_message,omitempty"`
ScanRuns []autoscanEventScanRunResponse `json:"scan_runs"`
}
type autoscanEventsResponse struct {
Events []autoscanEventResponse `json:"events"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
func eventResponse(event autoscan.EventWithRuns) autoscanEventResponse {
runs := make([]autoscanEventScanRunResponse, 0, len(event.Runs))
for _, run := range event.Runs {
runs = append(runs, autoscanEventScanRunResponse{
ID: run.ID,
LibraryID: run.MediaFolderID,
Mode: run.Mode,
Path: run.Path,
Trigger: run.Trigger,
Status: run.Status,
RequestedAt: run.RequestedAt,
StartedAt: run.StartedAt,
CompletedAt: run.CompletedAt,
ErrorMessage: run.ErrorMessage,
})
}
e := event.Event
return autoscanEventResponse{
ID: e.ID,
SourceID: e.SourceID,
PluginID: e.PluginID,
CapabilityID: e.CapabilityID,
StartedAt: e.StartedAt,
CompletedAt: e.CompletedAt,
DurationMS: e.DurationMS,
Status: string(e.Status),
DeliveryMode: e.DeliveryMode,
ProviderEvent: e.ProviderEventType,
ChangesReturned: e.ChangesReturned,
ChangesResolved: e.ChangesResolved,
TargetsClaimed: e.TargetsClaimed,
ScansCreated: e.ScansCreated,
ScansReused: e.ScansReused,
ScansSuppressed: e.ScansSuppressed,
ErrorMessage: e.ErrorMessage,
ScanRuns: runs,
}
}
func (h *AutoscanHandler) HandleListEvents(w http.ResponseWriter, r *http.Request) {
filter := autoscan.EventListFilter{
SourceID: strings.TrimSpace(r.URL.Query().Get("source_id")),
Status: autoscan.EventStatus(strings.TrimSpace(r.URL.Query().Get("status"))),
Search: strings.TrimSpace(r.URL.Query().Get("q")),
Limit: 50,
}
if rawLimit := strings.TrimSpace(r.URL.Query().Get("limit")); rawLimit != "" {
limit, err := strconv.Atoi(rawLimit)
if err != nil || limit < 1 {
writeError(w, http.StatusBadRequest, "bad_request", "limit must be a positive integer")
return
}
filter.Limit = limit
}
if rawOffset := strings.TrimSpace(r.URL.Query().Get("offset")); rawOffset != "" {
offset, err := strconv.Atoi(rawOffset)
if err != nil || offset < 0 {
writeError(w, http.StatusBadRequest, "bad_request", "offset must be a non-negative integer")
return
}
filter.Offset = offset
}
switch filter.Status {
case "", autoscan.EventStatusRunning, autoscan.EventStatusSuccess, autoscan.EventStatusError, autoscan.EventStatusUnresolved:
default:
writeError(w, http.StatusBadRequest, "bad_request", "status must be running, success, error, or unresolved")
return
}
events, err := h.repo.ListEvents(r.Context(), filter)
if err != nil {
writeAutoscanError(w, err)
return
}
total, err := h.repo.CountEvents(r.Context(), filter)
if err != nil {
writeAutoscanError(w, err)
return
}
out := make([]autoscanEventResponse, 0, len(events))
for _, event := range events {
out = append(out, eventResponse(event))
}
writeJSON(w, http.StatusOK, autoscanEventsResponse{
Events: out,
Total: total,
Limit: filter.Limit,
Offset: filter.Offset,
})
}
type autoscanScanResponse struct {
ID string `json:"id"`
LibraryID int `json:"library_id"`
Mode string `json:"mode"`
Path string `json:"path,omitempty"`
Trigger string `json:"trigger"`
Status string `json:"status"`
ErrorMessage string `json:"error_message,omitempty"`
RequestedAt *time.Time `json:"requested_at,omitempty"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
AutoscanEventID *int64 `json:"autoscan_event_id,omitempty"`
SourceID *string `json:"source_id,omitempty"`
PluginID string `json:"plugin_id,omitempty"`
CapabilityID string `json:"capability_id,omitempty"`
EventStatus string `json:"event_status,omitempty"`
EventCompletedAt *time.Time `json:"event_completed_at,omitempty"`
}
type autoscanScansResponse struct {
Scans []autoscanScanResponse `json:"scans"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
func autoscanScanRunResponse(scan autoscan.ScanWithEvent) autoscanScanResponse {
return autoscanScanResponse{
ID: scan.ID,
LibraryID: scan.MediaFolderID,
Mode: scan.Mode,
Path: scan.Path,
Trigger: scan.Trigger,
Status: scan.Status,
ErrorMessage: scan.ErrorMessage,
RequestedAt: scan.RequestedAt,
StartedAt: scan.StartedAt,
CompletedAt: scan.CompletedAt,
AutoscanEventID: scan.AutoscanEventID,
SourceID: scan.SourceID,
PluginID: scan.PluginID,
CapabilityID: scan.CapabilityID,
EventStatus: string(scan.EventStatus),
EventCompletedAt: scan.EventCompletedAt,
}
}
func (h *AutoscanHandler) HandleListScans(w http.ResponseWriter, r *http.Request) {
filter := autoscan.ScanListFilter{
Status: strings.TrimSpace(r.URL.Query().Get("status")),
Search: strings.TrimSpace(r.URL.Query().Get("q")),
Limit: 50,
}
if rawLimit := strings.TrimSpace(r.URL.Query().Get("limit")); rawLimit != "" {
limit, err := strconv.Atoi(rawLimit)
if err != nil || limit < 1 {
writeError(w, http.StatusBadRequest, "bad_request", "limit must be a positive integer")
return
}
filter.Limit = limit
}
if rawOffset := strings.TrimSpace(r.URL.Query().Get("offset")); rawOffset != "" {
offset, err := strconv.Atoi(rawOffset)
if err != nil || offset < 0 {
writeError(w, http.StatusBadRequest, "bad_request", "offset must be a non-negative integer")
return
}
filter.Offset = offset
}
switch filter.Status {
case "", "accepted", "running", "completed", "failed", "cancelled":
default:
writeError(w, http.StatusBadRequest, "bad_request", "status must be accepted, running, completed, failed, or cancelled")
return
}
scans, err := h.repo.ListAutoscanScans(r.Context(), filter)
if err != nil {
writeAutoscanError(w, err)
return
}
total, err := h.repo.CountAutoscanScans(r.Context(), filter)
if err != nil {
writeAutoscanError(w, err)
return
}
out := make([]autoscanScanResponse, 0, len(scans))
for _, scan := range scans {
out = append(out, autoscanScanRunResponse(scan))
}
writeJSON(w, http.StatusOK, autoscanScansResponse{
Scans: out,
Total: total,
Limit: filter.Limit,
Offset: filter.Offset,
})
}
// --- Status ---
type autoscanStatusSource struct {
ID string `json:"id"`
PluginID string `json:"plugin_id"`
CapabilityID string `json:"capability_id"`
ConnectionID *string `json:"connection_id"`
Enabled bool `json:"enabled"`
Label string `json:"label"`
PathRewrites []autoscan.PathRewrite `json:"path_rewrites"`
LastRunAt *time.Time `json:"last_run_at,omitempty"`
LastError *string `json:"last_error,omitempty"`
}
type autoscanRunningPollResponse struct {
ID int64 `json:"id"`
SourceID *string `json:"source_id"`
PluginID string `json:"plugin_id"`
CapabilityID string `json:"capability_id"`
StartedAt time.Time `json:"started_at"`
ElapsedMS int64 `json:"elapsed_ms"`
MarkerBefore *string `json:"marker_before,omitempty"`
}
type autoscanStatusResponse struct {
Enabled bool `json:"enabled"`
Sources []autoscanStatusSource `json:"sources"`
RunningPolls []autoscanRunningPollResponse `json:"running_polls"`
ActiveScans int `json:"active_scans"`
AcceptedScans int `json:"accepted_scans"`
RunningScans int `json:"running_scans"`
LatestEventAt *time.Time `json:"latest_event_at,omitempty"`
}
func (h *AutoscanHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
s, err := h.repo.GetSettings(ctx)
if err != nil {
writeAutoscanError(w, err)
return
}
sources, err := h.repo.ListSources(ctx)
if err != nil {
writeAutoscanError(w, err)
return
}
queue, err := h.repo.GetQueueSummary(ctx)
if err != nil {
writeAutoscanError(w, err)
return
}
runningEvents, err := h.repo.ListRunningEvents(ctx)
if err != nil {
writeAutoscanError(w, err)
return
}
latestEventAt, err := h.repo.LatestEventAt(ctx)
if err != nil {
writeAutoscanError(w, err)
return
}
trimmed := make([]autoscanStatusSource, 0, len(sources))
for _, src := range sources {
rewrites := src.PathRewrites
if rewrites == nil {
rewrites = []autoscan.PathRewrite{}
}
trimmed = append(trimmed, autoscanStatusSource{
ID: src.ID,
PluginID: src.PluginID,
CapabilityID: src.CapabilityID,
ConnectionID: src.ConnectionID,
Enabled: src.Enabled,
Label: src.Label,
PathRewrites: rewrites,
LastRunAt: src.LastRunAt,
LastError: src.LastError,
})
}
now := time.Now()
runningPolls := make([]autoscanRunningPollResponse, 0, len(runningEvents))
for _, event := range runningEvents {
elapsed := now.Sub(event.StartedAt)
if elapsed < 0 {
elapsed = 0
}
runningPolls = append(runningPolls, autoscanRunningPollResponse{
ID: event.ID,
SourceID: event.SourceID,
PluginID: event.PluginID,
CapabilityID: event.CapabilityID,
StartedAt: event.StartedAt,
ElapsedMS: elapsed.Milliseconds(),
MarkerBefore: event.MarkerBefore,
})
}
writeJSON(w, http.StatusOK, autoscanStatusResponse{
Enabled: s.Enabled,
Sources: trimmed,
RunningPolls: runningPolls,
ActiveScans: queue.Active,
AcceptedScans: queue.Accepted,
RunningScans: queue.Running,
LatestEventAt: latestEventAt,
})
}
// writeAutoscanError maps autoscan repository/service errors to HTTP status
// codes: a missing connection or source maps to 404, everything else to 500.
func writeAutoscanError(w http.ResponseWriter, err error) {
if err == nil {
return
}
if errors.Is(err, autoscan.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Autoscan resource not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Autoscan operation failed")
}