205 lines
6.2 KiB
Go
205 lines
6.2 KiB
Go
// 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}}
|
||
|
|
}
|