Files

205 lines
6.2 KiB
Go
Raw Permalink Normal View History

// Package arrwebhook parses Sonarr/Radarr webhook payloads into autoscan
// changes. It is deliberately host-side and handler-free: input is the raw
// request body, output is the provider/event classification plus the file or
// subtree paths to feed autoscan.Service.IngestChanges. Payload-derived
// content never appears in returned error messages, so they are safe to log
// and to echo in HTTP responses.
package arrwebhook
import (
"encoding/json"
"errors"
"strings"
"github.com/Silo-Server/silo-server/internal/autoscan"
)
// Provider names accepted by Parse and stored in source_config.webhook_provider.
const (
ProviderAuto = "auto"
ProviderSonarr = "sonarr"
ProviderRadarr = "radarr"
)
// ErrMalformedPayload reports a body that is not valid JSON or has no
// eventType. Handlers map it to 400.
var ErrMalformedPayload = errors.New("arrwebhook: malformed payload")
// ErrUnknownProvider reports a payload whose provider could not be inferred in
// auto mode. Handlers map it to 400.
var ErrUnknownProvider = errors.New("arrwebhook: cannot infer provider from payload")
// ParsedWebhook is one classified delivery. Unsupported event types parse
// successfully with no changes (callers no-op them): arr adds event types over
// time and an unknown one must not make the webhook look unhealthy.
type ParsedWebhook struct {
Provider string
EventType string
Test bool
Changes []autoscan.Change
}
// fileRef is the common shape of arr webhook file resources (episodeFile,
// movieFile, and the renamed* variants).
type fileRef struct {
Path string `json:"path"`
PreviousPath string `json:"previousPath"`
}
type payload struct {
EventType string `json:"eventType"`
Series *struct {
Path string `json:"path"`
} `json:"series"`
EpisodeFile *fileRef `json:"episodeFile"`
EpisodeFiles []fileRef `json:"episodeFiles"`
RenamedEpisodeFiles []fileRef `json:"renamedEpisodeFiles"`
Movie *struct {
Path string `json:"path"`
FolderPath string `json:"folderPath"`
} `json:"movie"`
MovieFile *fileRef `json:"movieFile"`
MovieFiles []fileRef `json:"movieFiles"`
RenamedMovieFiles []fileRef `json:"renamedMovieFiles"`
// Download/import payloads include the files replaced by an upgrade. They
// must be reconciled as vanished paths even when a separate "file delete for
// upgrade" notification is not enabled in arr.
DeletedFiles []fileRef `json:"deletedFiles"`
}
// importEventTypes are the events that carry newly imported files.
var importEventTypes = map[string]bool{
"Download": true,
"Import": true,
"Upgrade": true,
"DownloadComplete": true,
}
// deleteEventTypes are the events that carry a removed file path.
var deleteEventTypes = map[string]bool{
"EpisodeFileDelete": true,
"MovieFileDelete": true,
}
// Parse classifies a webhook body. provider is "sonarr", "radarr", or "auto"
// (empty means auto); in auto mode the provider is inferred from the payload's
// series/movie shape. Inference failure is an error only for non-test events
// that would otherwise produce work.
func Parse(provider string, body []byte) (ParsedWebhook, error) {
var p payload
if err := json.Unmarshal(body, &p); err != nil {
return ParsedWebhook{}, ErrMalformedPayload
}
eventType := strings.TrimSpace(p.EventType)
if eventType == "" {
return ParsedWebhook{}, ErrMalformedPayload
}
out := ParsedWebhook{EventType: eventType, Provider: resolveProvider(provider, p)}
if strings.EqualFold(eventType, "Test") {
out.Test = true
return out, nil
}
switch {
case importEventTypes[eventType]:
out.Changes = filePaths(p, false)
if len(out.Changes) == 0 {
out.Changes = subtreeFallback(p)
}
case eventType == "Rename":
out.Changes = filePaths(p, true)
case deleteEventTypes[eventType]:
out.Changes = filePaths(p, false)
default:
// Unsupported event type: valid delivery, nothing to do.
return out, nil
}
if out.Provider == "" {
return ParsedWebhook{}, ErrUnknownProvider
}
return out, nil
}
// resolveProvider returns the explicit provider when set, otherwise infers
// sonarr/radarr from the payload shape ("" when inference fails).
func resolveProvider(provider string, p payload) string {
switch strings.ToLower(strings.TrimSpace(provider)) {
case ProviderSonarr:
return ProviderSonarr
case ProviderRadarr:
return ProviderRadarr
}
switch {
case p.Series != nil || p.EpisodeFile != nil || len(p.EpisodeFiles) > 0 || len(p.RenamedEpisodeFiles) > 0:
return ProviderSonarr
case p.Movie != nil || p.MovieFile != nil || len(p.MovieFiles) > 0 || len(p.RenamedMovieFiles) > 0:
return ProviderRadarr
}
return ""
}
// filePaths collects every file path in the payload as file-scope changes,
// deduped in order. includePrevious additionally collects previousPath values
// (rename events): the previous path may no longer exist, which the host's
// resolve pipeline already handles via its vanished-path fallback.
func filePaths(p payload, includePrevious bool) []autoscan.Change {
var refs []fileRef
if p.EpisodeFile != nil {
refs = append(refs, *p.EpisodeFile)
}
refs = append(refs, p.EpisodeFiles...)
refs = append(refs, p.RenamedEpisodeFiles...)
if p.MovieFile != nil {
refs = append(refs, *p.MovieFile)
}
refs = append(refs, p.MovieFiles...)
refs = append(refs, p.RenamedMovieFiles...)
refs = append(refs, p.DeletedFiles...)
seen := make(map[string]struct{})
var changes []autoscan.Change
add := func(path string) {
path = strings.TrimSpace(path)
if path == "" {
return
}
if _, dup := seen[path]; dup {
return
}
seen[path] = struct{}{}
changes = append(changes, autoscan.Change{SourcePath: path, Scope: autoscan.ChangeScopeFile})
}
for _, ref := range refs {
add(ref.Path)
if includePrevious {
add(ref.PreviousPath)
}
}
return changes
}
// subtreeFallback maps an import event with no file details to the series or
// movie folder as one subtree-scope change.
func subtreeFallback(p payload) []autoscan.Change {
path := ""
switch {
case p.Series != nil:
path = p.Series.Path
case p.Movie != nil:
path = p.Movie.FolderPath
if strings.TrimSpace(path) == "" {
path = p.Movie.Path
}
}
path = strings.TrimSpace(path)
if path == "" {
return nil
}
return []autoscan.Change{{SourcePath: path, Scope: autoscan.ChangeScopeSubtree}}
}