From 18140a995126b4fb292494b0d93132d51f78701e Mon Sep 17 00:00:00 2001 From: David Newhall II Date: Sun, 12 Apr 2026 19:05:36 -0700 Subject: [PATCH 1/2] Add more sonarr methods --- .golangci.yml | 3 + http.go | 21 ++- sonarr/autotagging.go | 143 ++++++++++++++++ sonarr/config_host_ui_importlist.go | 250 ++++++++++++++++++++++++++++ sonarr/customfilter.go | 114 +++++++++++++ sonarr/diskspace.go | 36 ++++ sonarr/filesystem.go | 102 ++++++++++++ sonarr/health.go | 36 ++++ sonarr/indexerflag.go | 34 ++++ sonarr/language.go | 52 ++++++ sonarr/localization.go | 73 ++++++++ sonarr/log.go | 126 ++++++++++++++ sonarr/mediacover.go | 38 +++++ sonarr/metadata.go | 238 ++++++++++++++++++++++++++ sonarr/queue.go | 110 ++++++++++++ sonarr/system.go | 214 ++++++++++++++++++++++++ sonarr/update.go | 50 ++++++ sonarr/wanted.go | 103 ++++++++++++ 18 files changed, 1736 insertions(+), 7 deletions(-) create mode 100644 sonarr/autotagging.go create mode 100644 sonarr/config_host_ui_importlist.go create mode 100644 sonarr/customfilter.go create mode 100644 sonarr/diskspace.go create mode 100644 sonarr/filesystem.go create mode 100644 sonarr/health.go create mode 100644 sonarr/indexerflag.go create mode 100644 sonarr/language.go create mode 100644 sonarr/localization.go create mode 100644 sonarr/log.go create mode 100644 sonarr/mediacover.go create mode 100644 sonarr/metadata.go create mode 100644 sonarr/update.go create mode 100644 sonarr/wanted.go diff --git a/.golangci.yml b/.golangci.yml index d9909f60..64e5d3a8 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -10,6 +10,9 @@ linters: - noinlineerr - wsl settings: + gosec: + excludes: + - G117 wsl_v5: allow-first-in-block: true allow-whole-block: false diff --git a/http.go b/http.go index 437531d1..72c08c2c 100644 --- a/http.go +++ b/http.go @@ -23,6 +23,8 @@ type Request struct { Body io.Reader // Used in PUT, POST, DELETE. Not for GET. Query url.Values // GET parameters work for any request type. URI string // Required: path portion of the URL. + // Headers are optional extra HTTP headers merged after defaults (overrides Content-Type, Accept, etc.). + Headers http.Header } // ReqError is returned when a Starr app returns an invalid status code. @@ -64,7 +66,7 @@ func (c *Config) req(ctx context.Context, method string, req Request) (*http.Res return nil, fmt.Errorf("http.NewRequestWithContext(%s): %w", req.URI, err) } - c.SetHeaders(httpReq) + c.SetHeaders(httpReq, req.Headers) if req.Query != nil { httpReq.URL.RawQuery = req.Query.Encode() @@ -131,8 +133,9 @@ func closeResp(resp *http.Response) { } } -// SetHeaders sets all our request headers based on method and other data. -func (c *Config) SetHeaders(req *http.Request) { +// SetHeaders sets default request headers, merges localReq.Headers (which override defaults), +// then forces application/x-www-form-urlencoded for native login POSTs. +func (c *Config) SetHeaders(req *http.Request, headers http.Header) { // This app allows http auth, in addition to api key (nginx proxy). if auth := c.HTTPUser + ":" + c.HTTPPass; auth != ":" { req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(auth))) @@ -142,14 +145,18 @@ func (c *Config) SetHeaders(req *http.Request) { req.Header.Set("Content-Type", "application/json") } + req.Header.Set("User-Agent", "go-starr: https://"+reflect.TypeFor[Config]().PkgPath()) + req.Header.Set("X-Api-Key", c.APIKey) + + for key, vals := range headers { + req.Header[http.CanonicalHeaderKey(key)] = vals + } + if req.Method == http.MethodPost && strings.HasSuffix(req.URL.RequestURI(), "/login") { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - } else { + } else if req.Header.Get("Accept") == "" { req.Header.Set("Accept", "application/json") } - - req.Header.Set("User-Agent", "go-starr: https://"+reflect.TypeFor[Config]().PkgPath()) - req.Header.Set("X-Api-Key", c.APIKey) } // SetAPIPath makes sure the path starts with /api. diff --git a/sonarr/autotagging.go b/sonarr/autotagging.go new file mode 100644 index 00000000..0c1283d8 --- /dev/null +++ b/sonarr/autotagging.go @@ -0,0 +1,143 @@ +package sonarr + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "path" + + "golift.io/starr" +) + +const bpAutoTagging = APIver + "/autotagging" + +// AutoTagging is the /api/v3/autotagging resource. +type AutoTagging struct { + ID int `json:"id,omitempty"` + Name string `json:"name,omitempty"` + RemoveTagsAutomatically bool `json:"removeTagsAutomatically"` + Tags []int `json:"tags,omitempty"` + Specifications []*AutoTaggingSpecification `json:"specifications,omitempty"` +} + +// AutoTaggingSpecification is one rule inside an AutoTagging definition. +type AutoTaggingSpecification struct { + ID int `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Implementation string `json:"implementation,omitempty"` + ImplementationName string `json:"implementationName,omitempty"` + Negate bool `json:"negate"` + Required bool `json:"required"` + Fields []*starr.FieldInput `json:"fields,omitempty"` +} + +// GetAutoTaggings returns all auto tagging configurations. +func (s *Sonarr) GetAutoTaggings() ([]*AutoTagging, error) { + return s.GetAutoTaggingsContext(context.Background()) +} + +// GetAutoTaggingsContext returns all auto tagging configurations. +func (s *Sonarr) GetAutoTaggingsContext(ctx context.Context) ([]*AutoTagging, error) { + var output []*AutoTagging + + req := starr.Request{URI: bpAutoTagging} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return output, nil +} + +// GetAutoTagging returns a single auto tagging configuration. +func (s *Sonarr) GetAutoTagging(id int) (*AutoTagging, error) { + return s.GetAutoTaggingContext(context.Background(), id) +} + +// GetAutoTaggingContext returns a single auto tagging configuration. +func (s *Sonarr) GetAutoTaggingContext(ctx context.Context, id int) (*AutoTagging, error) { + var output AutoTagging + + req := starr.Request{URI: path.Join(bpAutoTagging, starr.Str(id))} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return &output, nil +} + +// GetAutoTaggingSchema returns the specification schema templates for auto tagging. +func (s *Sonarr) GetAutoTaggingSchema() ([]*AutoTaggingSpecification, error) { + return s.GetAutoTaggingSchemaContext(context.Background()) +} + +// GetAutoTaggingSchemaContext returns the specification schema templates for auto tagging. +func (s *Sonarr) GetAutoTaggingSchemaContext(ctx context.Context) ([]*AutoTaggingSpecification, error) { + var output []*AutoTaggingSpecification + + req := starr.Request{URI: path.Join(bpAutoTagging, "schema")} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return output, nil +} + +// AddAutoTagging creates an auto tagging configuration. +func (s *Sonarr) AddAutoTagging(in *AutoTagging) (*AutoTagging, error) { + return s.AddAutoTaggingContext(context.Background(), in) +} + +// AddAutoTaggingContext creates an auto tagging configuration. +func (s *Sonarr) AddAutoTaggingContext(ctx context.Context, in *AutoTagging) (*AutoTagging, error) { + var output AutoTagging + + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(in); err != nil { + return nil, fmt.Errorf("json.Marshal(%s): %w", bpAutoTagging, err) + } + + req := starr.Request{URI: bpAutoTagging, Body: &body} + if err := s.PostInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Post(%s): %w", &req, err) + } + + return &output, nil +} + +// UpdateAutoTagging updates an auto tagging configuration. +func (s *Sonarr) UpdateAutoTagging(in *AutoTagging) (*AutoTagging, error) { + return s.UpdateAutoTaggingContext(context.Background(), in) +} + +// UpdateAutoTaggingContext updates an auto tagging configuration. +func (s *Sonarr) UpdateAutoTaggingContext(ctx context.Context, input *AutoTagging) (*AutoTagging, error) { + var output AutoTagging + + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(input); err != nil { + return nil, fmt.Errorf("json.Marshal(%s): %w", bpAutoTagging, err) + } + + req := starr.Request{URI: path.Join(bpAutoTagging, starr.Str(input.ID)), Body: &body} + if err := s.PutInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Put(%s): %w", &req, err) + } + + return &output, nil +} + +// DeleteAutoTagging deletes an auto tagging configuration. +func (s *Sonarr) DeleteAutoTagging(id int) error { + return s.DeleteAutoTaggingContext(context.Background(), id) +} + +// DeleteAutoTaggingContext deletes an auto tagging configuration. +func (s *Sonarr) DeleteAutoTaggingContext(ctx context.Context, id int) error { + req := starr.Request{URI: path.Join(bpAutoTagging, starr.Str(id))} + if err := s.DeleteAny(ctx, req); err != nil { + return fmt.Errorf("api.Delete(%s): %w", &req, err) + } + + return nil +} diff --git a/sonarr/config_host_ui_importlist.go b/sonarr/config_host_ui_importlist.go new file mode 100644 index 00000000..e438a26e --- /dev/null +++ b/sonarr/config_host_ui_importlist.go @@ -0,0 +1,250 @@ +package sonarr + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "path" + + "golift.io/starr" +) + +const ( + bpConfigHost = APIver + "/config/host" + bpConfigUI = APIver + "/config/ui" + bpConfigImportList = APIver + "/config/importlist" +) + +// HostConfig is the /api/v3/config/host resource. +type HostConfig struct { + ID int `json:"id,omitempty"` + BindAddress string `json:"bindAddress,omitempty"` + Port int `json:"port"` + SSLPort int `json:"sslPort"` + EnableSSL bool `json:"enableSsl"` + LaunchBrowser bool `json:"launchBrowser"` + AuthenticationMethod string `json:"authenticationMethod,omitempty"` + AuthenticationRequired string `json:"authenticationRequired,omitempty"` + AnalyticsEnabled bool `json:"analyticsEnabled"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + PasswordConfirmation string `json:"passwordConfirmation,omitempty"` + LogLevel string `json:"logLevel,omitempty"` + LogSizeLimit int `json:"logSizeLimit"` + ConsoleLogLevel string `json:"consoleLogLevel,omitempty"` + Branch string `json:"branch,omitempty"` + APIKey string `json:"apiKey,omitempty"` + SSLCertPath string `json:"sslCertPath,omitempty"` + SSLCertPassword string `json:"sslCertPassword,omitempty"` + URLBase string `json:"urlBase,omitempty"` + InstanceName string `json:"instanceName,omitempty"` + ApplicationURL string `json:"applicationUrl,omitempty"` + UpdateAutomatically bool `json:"updateAutomatically"` + UpdateMechanism string `json:"updateMechanism,omitempty"` + UpdateScriptPath string `json:"updateScriptPath,omitempty"` + ProxyEnabled bool `json:"proxyEnabled"` + ProxyType string `json:"proxyType,omitempty"` + ProxyHostname string `json:"proxyHostname,omitempty"` + ProxyPort int `json:"proxyPort"` + ProxyUsername string `json:"proxyUsername,omitempty"` + ProxyPassword string `json:"proxyPassword,omitempty"` + ProxyBypassFilter string `json:"proxyBypassFilter,omitempty"` + ProxyBypassLocalAddresses bool `json:"proxyBypassLocalAddresses"` + CertificateValidation string `json:"certificateValidation,omitempty"` + BackupFolder string `json:"backupFolder,omitempty"` + BackupInterval int `json:"backupInterval"` + BackupRetention int `json:"backupRetention"` + TrustCgnatIPAddresses bool `json:"trustCgnatIpAddresses"` +} + +// UIConfig is the /api/v3/config/ui resource. +type UIConfig struct { + ID int `json:"id,omitempty"` + FirstDayOfWeek int `json:"firstDayOfWeek"` + CalendarWeekColumnHeader string `json:"calendarWeekColumnHeader,omitempty"` + ShortDateFormat string `json:"shortDateFormat,omitempty"` + LongDateFormat string `json:"longDateFormat,omitempty"` + TimeFormat string `json:"timeFormat,omitempty"` + ShowRelativeDates bool `json:"showRelativeDates"` + EnableColorImpairedMode bool `json:"enableColorImpairedMode"` + Theme string `json:"theme,omitempty"` + UILanguage int `json:"uiLanguage"` +} + +// ImportListConfig is the /api/v3/config/importlist resource. +type ImportListConfig struct { + ID int `json:"id,omitempty"` + ListSyncLevel string `json:"listSyncLevel,omitempty"` + ListSyncTag int `json:"listSyncTag"` +} + +// GetHostConfig returns the host configuration. +func (s *Sonarr) GetHostConfig() (*HostConfig, error) { + return s.GetHostConfigContext(context.Background()) +} + +// GetHostConfigContext returns the host configuration. +func (s *Sonarr) GetHostConfigContext(ctx context.Context) (*HostConfig, error) { + var output HostConfig + + req := starr.Request{URI: bpConfigHost} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return &output, nil +} + +// GetHostConfigByID returns the host configuration for the given id. +func (s *Sonarr) GetHostConfigByID(id int) (*HostConfig, error) { + return s.GetHostConfigByIDContext(context.Background(), id) +} + +// GetHostConfigByIDContext returns the host configuration for the given id. +func (s *Sonarr) GetHostConfigByIDContext(ctx context.Context, id int) (*HostConfig, error) { + var output HostConfig + + req := starr.Request{URI: path.Join(bpConfigHost, starr.Str(id))} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return &output, nil +} + +// UpdateHostConfig updates the host configuration. +func (s *Sonarr) UpdateHostConfig(in *HostConfig) (*HostConfig, error) { + return s.UpdateHostConfigContext(context.Background(), in) +} + +// UpdateHostConfigContext updates the host configuration. +func (s *Sonarr) UpdateHostConfigContext(ctx context.Context, input *HostConfig) (*HostConfig, error) { + var output HostConfig + + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(input); err != nil { + return nil, fmt.Errorf("json.Marshal(%s): %w", bpConfigHost, err) + } + + req := starr.Request{URI: path.Join(bpConfigHost, starr.Str(input.ID)), Body: &body} + if err := s.PutInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Put(%s): %w", &req, err) + } + + return &output, nil +} + +// GetUIConfig returns the UI configuration. +func (s *Sonarr) GetUIConfig() (*UIConfig, error) { + return s.GetUIConfigContext(context.Background()) +} + +// GetUIConfigContext returns the UI configuration. +func (s *Sonarr) GetUIConfigContext(ctx context.Context) (*UIConfig, error) { + var output UIConfig + + req := starr.Request{URI: bpConfigUI} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return &output, nil +} + +// GetUIConfigByID returns the UI configuration for the given id. +func (s *Sonarr) GetUIConfigByID(id int) (*UIConfig, error) { + return s.GetUIConfigByIDContext(context.Background(), id) +} + +// GetUIConfigByIDContext returns the UI configuration for the given id. +func (s *Sonarr) GetUIConfigByIDContext(ctx context.Context, id int) (*UIConfig, error) { + var output UIConfig + + req := starr.Request{URI: path.Join(bpConfigUI, starr.Str(id))} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return &output, nil +} + +// UpdateUIConfig updates the UI configuration. +func (s *Sonarr) UpdateUIConfig(in *UIConfig) (*UIConfig, error) { + return s.UpdateUIConfigContext(context.Background(), in) +} + +// UpdateUIConfigContext updates the UI configuration. +func (s *Sonarr) UpdateUIConfigContext(ctx context.Context, input *UIConfig) (*UIConfig, error) { + var output UIConfig + + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(input); err != nil { + return nil, fmt.Errorf("json.Marshal(%s): %w", bpConfigUI, err) + } + + req := starr.Request{URI: path.Join(bpConfigUI, starr.Str(input.ID)), Body: &body} + if err := s.PutInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Put(%s): %w", &req, err) + } + + return &output, nil +} + +// GetImportListConfig returns the import list global configuration. +func (s *Sonarr) GetImportListConfig() (*ImportListConfig, error) { + return s.GetImportListConfigContext(context.Background()) +} + +// GetImportListConfigContext returns the import list global configuration. +func (s *Sonarr) GetImportListConfigContext(ctx context.Context) (*ImportListConfig, error) { + var output ImportListConfig + + req := starr.Request{URI: bpConfigImportList} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return &output, nil +} + +// GetImportListConfigByID returns the import list global configuration for the given id. +func (s *Sonarr) GetImportListConfigByID(id int) (*ImportListConfig, error) { + return s.GetImportListConfigByIDContext(context.Background(), id) +} + +// GetImportListConfigByIDContext returns the import list global configuration for the given id. +func (s *Sonarr) GetImportListConfigByIDContext(ctx context.Context, id int) (*ImportListConfig, error) { + var output ImportListConfig + + req := starr.Request{URI: path.Join(bpConfigImportList, starr.Str(id))} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return &output, nil +} + +// UpdateImportListConfig updates the import list global configuration. +func (s *Sonarr) UpdateImportListConfig(input *ImportListConfig) (*ImportListConfig, error) { + return s.UpdateImportListConfigContext(context.Background(), input) +} + +// UpdateImportListConfigContext updates the import list global configuration. +func (s *Sonarr) UpdateImportListConfigContext( + ctx context.Context, input *ImportListConfig, +) (*ImportListConfig, error) { + var output ImportListConfig + + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(input); err != nil { + return nil, fmt.Errorf("json.Marshal(%s): %w", bpConfigImportList, err) + } + + req := starr.Request{URI: path.Join(bpConfigImportList, starr.Str(input.ID)), Body: &body} + if err := s.PutInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Put(%s): %w", &req, err) + } + + return &output, nil +} diff --git a/sonarr/customfilter.go b/sonarr/customfilter.go new file mode 100644 index 00000000..7dc13569 --- /dev/null +++ b/sonarr/customfilter.go @@ -0,0 +1,114 @@ +package sonarr + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "path" + + "golift.io/starr" +) + +const bpCustomFilter = APIver + "/customfilter" + +// CustomFilter is the /api/v3/customfilter resource. +type CustomFilter struct { + ID int `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Label string `json:"label,omitempty"` + Filters []json.RawMessage `json:"filters,omitempty"` +} + +// GetCustomFilters returns all custom filters. +func (s *Sonarr) GetCustomFilters() ([]*CustomFilter, error) { + return s.GetCustomFiltersContext(context.Background()) +} + +// GetCustomFiltersContext returns all custom filters. +func (s *Sonarr) GetCustomFiltersContext(ctx context.Context) ([]*CustomFilter, error) { + var output []*CustomFilter + + req := starr.Request{URI: bpCustomFilter} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return output, nil +} + +// GetCustomFilter returns a single custom filter. +func (s *Sonarr) GetCustomFilter(id int) (*CustomFilter, error) { + return s.GetCustomFilterContext(context.Background(), id) +} + +// GetCustomFilterContext returns a single custom filter. +func (s *Sonarr) GetCustomFilterContext(ctx context.Context, id int) (*CustomFilter, error) { + var output CustomFilter + + req := starr.Request{URI: path.Join(bpCustomFilter, starr.Str(id))} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return &output, nil +} + +// AddCustomFilter creates a custom filter. +func (s *Sonarr) AddCustomFilter(in *CustomFilter) (*CustomFilter, error) { + return s.AddCustomFilterContext(context.Background(), in) +} + +// AddCustomFilterContext creates a custom filter. +func (s *Sonarr) AddCustomFilterContext(ctx context.Context, in *CustomFilter) (*CustomFilter, error) { + var output CustomFilter + + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(in); err != nil { + return nil, fmt.Errorf("json.Marshal(%s): %w", bpCustomFilter, err) + } + + req := starr.Request{URI: bpCustomFilter, Body: &body} + if err := s.PostInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Post(%s): %w", &req, err) + } + + return &output, nil +} + +// UpdateCustomFilter updates a custom filter. +func (s *Sonarr) UpdateCustomFilter(in *CustomFilter) (*CustomFilter, error) { + return s.UpdateCustomFilterContext(context.Background(), in) +} + +// UpdateCustomFilterContext updates a custom filter. +func (s *Sonarr) UpdateCustomFilterContext(ctx context.Context, input *CustomFilter) (*CustomFilter, error) { + var output CustomFilter + + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(input); err != nil { + return nil, fmt.Errorf("json.Marshal(%s): %w", bpCustomFilter, err) + } + + req := starr.Request{URI: path.Join(bpCustomFilter, starr.Str(input.ID)), Body: &body} + if err := s.PutInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Put(%s): %w", &req, err) + } + + return &output, nil +} + +// DeleteCustomFilter deletes a custom filter. +func (s *Sonarr) DeleteCustomFilter(id int) error { + return s.DeleteCustomFilterContext(context.Background(), id) +} + +// DeleteCustomFilterContext deletes a custom filter. +func (s *Sonarr) DeleteCustomFilterContext(ctx context.Context, id int) error { + req := starr.Request{URI: path.Join(bpCustomFilter, starr.Str(id))} + if err := s.DeleteAny(ctx, req); err != nil { + return fmt.Errorf("api.Delete(%s): %w", &req, err) + } + + return nil +} diff --git a/sonarr/diskspace.go b/sonarr/diskspace.go new file mode 100644 index 00000000..94ad6119 --- /dev/null +++ b/sonarr/diskspace.go @@ -0,0 +1,36 @@ +package sonarr + +import ( + "context" + "fmt" + + "golift.io/starr" +) + +const bpDiskSpace = APIver + "/diskspace" + +// DiskSpace is the /api/v3/diskspace resource. +type DiskSpace struct { + ID int `json:"id"` + Path string `json:"path,omitempty"` + Label string `json:"label,omitempty"` + FreeSpace int64 `json:"freeSpace"` + TotalSpace int64 `json:"totalSpace"` +} + +// GetDiskSpace returns disk space information for Sonarr paths. +func (s *Sonarr) GetDiskSpace() ([]*DiskSpace, error) { + return s.GetDiskSpaceContext(context.Background()) +} + +// GetDiskSpaceContext returns disk space information for Sonarr paths. +func (s *Sonarr) GetDiskSpaceContext(ctx context.Context) ([]*DiskSpace, error) { + var output []*DiskSpace + + req := starr.Request{URI: bpDiskSpace} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return output, nil +} diff --git a/sonarr/filesystem.go b/sonarr/filesystem.go new file mode 100644 index 00000000..201f1d3f --- /dev/null +++ b/sonarr/filesystem.go @@ -0,0 +1,102 @@ +package sonarr + +import ( + "context" + "fmt" + "io" + "net/url" + "path" + + "golift.io/starr" +) + +const bpFilesystem = APIver + "/filesystem" + +// FilesystemQuery is the query for /api/v3/filesystem. +type FilesystemQuery struct { + Path string + IncludeFiles bool + AllowFoldersWithoutTrailingSlashes bool +} + +// Values builds query parameters for the filesystem browser. +func (q *FilesystemQuery) Values() url.Values { + val := make(url.Values) + if q == nil { + return val + } + + if q.Path != "" { + val.Set("path", q.Path) + } + + val.Set("includeFiles", starr.Str(q.IncludeFiles)) + val.Set("allowFoldersWithoutTrailingSlashes", starr.Str(q.AllowFoldersWithoutTrailingSlashes)) + + return val +} + +// BrowseFilesystem lists files and folders for a path. +func (s *Sonarr) BrowseFilesystem(query *FilesystemQuery) ([]*starr.Path, error) { + return s.BrowseFilesystemContext(context.Background(), query) +} + +// BrowseFilesystemContext lists files and folders for a path. +func (s *Sonarr) BrowseFilesystemContext(ctx context.Context, query *FilesystemQuery) ([]*starr.Path, error) { + var output []*starr.Path + + req := starr.Request{URI: bpFilesystem, Query: query.Values()} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return output, nil +} + +// BrowseFilesystemMediaFiles lists media files under a path. +func (s *Sonarr) BrowseFilesystemMediaFiles(pathName string) ([]*starr.Path, error) { + return s.BrowseFilesystemMediaFilesContext(context.Background(), pathName) +} + +// BrowseFilesystemMediaFilesContext lists media files under a path. +func (s *Sonarr) BrowseFilesystemMediaFilesContext(ctx context.Context, pathName string) ([]*starr.Path, error) { + var output []*starr.Path + + params := make(url.Values) + if pathName != "" { + params.Set("path", pathName) + } + + req := starr.Request{URI: path.Join(bpFilesystem, "mediafiles"), Query: params} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return output, nil +} + +// GetFilesystemType returns the raw response body from /api/v3/filesystem/type. +func (s *Sonarr) GetFilesystemType(pathName string) ([]byte, error) { + return s.GetFilesystemTypeContext(context.Background(), pathName) +} + +// GetFilesystemTypeContext returns the raw response body from /api/v3/filesystem/type. +func (s *Sonarr) GetFilesystemTypeContext(ctx context.Context, pathName string) ([]byte, error) { + params := make(url.Values) + params.Set("path", pathName) + + req := starr.Request{URI: path.Join(bpFilesystem, "type"), Query: params} + + resp, err := s.Get(ctx, req) + if err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading HTTP response body: %w", err) + } + + return body, nil +} diff --git a/sonarr/health.go b/sonarr/health.go new file mode 100644 index 00000000..ab254c0c --- /dev/null +++ b/sonarr/health.go @@ -0,0 +1,36 @@ +package sonarr + +import ( + "context" + "fmt" + + "golift.io/starr" +) + +const bpHealth = APIver + "/health" + +// Health is the /api/v3/health resource. +type Health struct { + ID int `json:"id"` + Source string `json:"source,omitempty"` + Type string `json:"type,omitempty"` + Message string `json:"message,omitempty"` + WikiURL string `json:"wikiUrl,omitempty"` +} + +// GetHealth returns current health check messages. +func (s *Sonarr) GetHealth() ([]*Health, error) { + return s.GetHealthContext(context.Background()) +} + +// GetHealthContext returns current health check messages. +func (s *Sonarr) GetHealthContext(ctx context.Context) ([]*Health, error) { + var output []*Health + + req := starr.Request{URI: bpHealth} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return output, nil +} diff --git a/sonarr/indexerflag.go b/sonarr/indexerflag.go new file mode 100644 index 00000000..068b3158 --- /dev/null +++ b/sonarr/indexerflag.go @@ -0,0 +1,34 @@ +package sonarr + +import ( + "context" + "fmt" + + "golift.io/starr" +) + +const bpIndexerFlag = APIver + "/indexerflag" + +// IndexerFlag is the /api/v3/indexerflag resource. +type IndexerFlag struct { + ID int `json:"id"` + Name string `json:"name,omitempty"` + NameLower string `json:"nameLower,omitempty"` +} + +// GetIndexerFlags returns all indexer flags. +func (s *Sonarr) GetIndexerFlags() ([]*IndexerFlag, error) { + return s.GetIndexerFlagsContext(context.Background()) +} + +// GetIndexerFlagsContext returns all indexer flags. +func (s *Sonarr) GetIndexerFlagsContext(ctx context.Context) ([]*IndexerFlag, error) { + var output []*IndexerFlag + + req := starr.Request{URI: bpIndexerFlag} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return output, nil +} diff --git a/sonarr/language.go b/sonarr/language.go new file mode 100644 index 00000000..7e9142d2 --- /dev/null +++ b/sonarr/language.go @@ -0,0 +1,52 @@ +package sonarr + +import ( + "context" + "fmt" + "path" + + "golift.io/starr" +) + +const bpLanguage = APIver + "/language" + +// AudioLanguage is an item from /api/v3/language (episode audio languages). +type AudioLanguage struct { + ID int `json:"id"` + Name string `json:"name,omitempty"` + NameLower string `json:"nameLower,omitempty"` +} + +// GetAudioLanguages returns all languages from the /api/v3/language endpoint. +func (s *Sonarr) GetAudioLanguages() ([]*AudioLanguage, error) { + return s.GetAudioLanguagesContext(context.Background()) +} + +// GetAudioLanguagesContext returns all languages from the /api/v3/language endpoint. +func (s *Sonarr) GetAudioLanguagesContext(ctx context.Context) ([]*AudioLanguage, error) { + var output []*AudioLanguage + + req := starr.Request{URI: bpLanguage} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return output, nil +} + +// GetAudioLanguage returns a single language by id. +func (s *Sonarr) GetAudioLanguage(id int) (*AudioLanguage, error) { + return s.GetAudioLanguageContext(context.Background(), id) +} + +// GetAudioLanguageContext returns a single language by id. +func (s *Sonarr) GetAudioLanguageContext(ctx context.Context, id int) (*AudioLanguage, error) { + var output AudioLanguage + + req := starr.Request{URI: path.Join(bpLanguage, starr.Str(id))} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return &output, nil +} diff --git a/sonarr/localization.go b/sonarr/localization.go new file mode 100644 index 00000000..0bd3d234 --- /dev/null +++ b/sonarr/localization.go @@ -0,0 +1,73 @@ +package sonarr + +import ( + "context" + "fmt" + "path" + + "golift.io/starr" +) + +const bpLocalization = APIver + "/localization" + +// Localization is the /api/v3/localization resource. +type Localization struct { + ID int `json:"id"` + Strings map[string]string `json:"strings,omitempty"` +} + +// GetLocalization returns the default localization dictionary. +func (s *Sonarr) GetLocalization() (*Localization, error) { + return s.GetLocalizationContext(context.Background()) +} + +// GetLocalizationContext returns the default localization dictionary. +func (s *Sonarr) GetLocalizationContext(ctx context.Context) (*Localization, error) { + var output Localization + + req := starr.Request{URI: bpLocalization} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return &output, nil +} + +// GetLocalizationByID returns a localization dictionary by id. +func (s *Sonarr) GetLocalizationByID(id int) (*Localization, error) { + return s.GetLocalizationByIDContext(context.Background(), id) +} + +// GetLocalizationByIDContext returns a localization dictionary by id. +func (s *Sonarr) GetLocalizationByIDContext(ctx context.Context, id int) (*Localization, error) { + var output Localization + + req := starr.Request{URI: path.Join(bpLocalization, starr.Str(id))} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return &output, nil +} + +// UILanguage is an item from /api/v3/localization/language. +type UILanguage struct { + Identifier string `json:"identifier,omitempty"` +} + +// GetLocalizationLanguages returns available UI languages. +func (s *Sonarr) GetLocalizationLanguages() ([]*UILanguage, error) { + return s.GetLocalizationLanguagesContext(context.Background()) +} + +// GetLocalizationLanguagesContext returns available UI languages. +func (s *Sonarr) GetLocalizationLanguagesContext(ctx context.Context) ([]*UILanguage, error) { + var output []*UILanguage + + req := starr.Request{URI: path.Join(bpLocalization, "language")} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return output, nil +} diff --git a/sonarr/log.go b/sonarr/log.go new file mode 100644 index 00000000..7e6686ba --- /dev/null +++ b/sonarr/log.go @@ -0,0 +1,126 @@ +package sonarr + +import ( + "context" + "fmt" + "path" + "time" + + "golift.io/starr" +) + +const bpLog = APIver + "/log" + +// LogLine is one record from /api/v3/log. +type LogLine struct { + ID int `json:"id"` + Time time.Time `json:"time"` + Exception string `json:"exception,omitempty"` + ExceptionType string `json:"exceptionType,omitempty"` + Level string `json:"level,omitempty"` + Logger string `json:"logger,omitempty"` + Message string `json:"message,omitempty"` + Method string `json:"method,omitempty"` +} + +// LogPage is a page of log lines from /api/v3/log. +type LogPage struct { + Page int `json:"page"` + PageSize int `json:"pageSize"` + SortKey string `json:"sortKey"` + SortDirection string `json:"sortDirection"` + TotalRecords int `json:"totalRecords"` + Records []*LogLine `json:"records"` +} + +// LogFile describes a log file on disk. +type LogFile struct { + Filename string `json:"filename,omitempty"` + Contents string `json:"contents,omitempty"` + LastWrite time.Time `json:"lastWrite,omitzero"` +} + +// GetLogPage returns a page of application log lines. +func (s *Sonarr) GetLogPage(params *starr.PageReq) (*LogPage, error) { + return s.GetLogPageContext(context.Background(), params) +} + +// GetLogPageContext returns a page of application log lines. +func (s *Sonarr) GetLogPageContext(ctx context.Context, params *starr.PageReq) (*LogPage, error) { + var output LogPage + + req := starr.Request{URI: bpLog, Query: params.Params()} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return &output, nil +} + +// GetLogFiles returns the list of log files. +func (s *Sonarr) GetLogFiles() ([]*LogFile, error) { + return s.GetLogFilesContext(context.Background()) +} + +// GetLogFilesContext returns the list of log files. +func (s *Sonarr) GetLogFilesContext(ctx context.Context) ([]*LogFile, error) { + var output []*LogFile + + req := starr.Request{URI: path.Join(bpLog, "file")} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return output, nil +} + +// GetLogFile returns the contents of a named log file. +func (s *Sonarr) GetLogFile(filename string) (*LogFile, error) { + return s.GetLogFileContext(context.Background(), filename) +} + +// GetLogFileContext returns the contents of a named log file. +func (s *Sonarr) GetLogFileContext(ctx context.Context, filename string) (*LogFile, error) { + var output LogFile + + req := starr.Request{URI: path.Join(bpLog, "file", filename)} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return &output, nil +} + +// UpdateLogFiles triggers a log file update/roll. +func (s *Sonarr) UpdateLogFiles() ([]*LogFile, error) { + return s.UpdateLogFilesContext(context.Background()) +} + +// UpdateLogFilesContext triggers a log file update/roll. +func (s *Sonarr) UpdateLogFilesContext(ctx context.Context) ([]*LogFile, error) { + var output []*LogFile + + req := starr.Request{URI: path.Join(bpLog, "file", "update")} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return output, nil +} + +// UpdateLogFile triggers update for a specific log file. +func (s *Sonarr) UpdateLogFile(filename string) ([]*LogFile, error) { + return s.UpdateLogFileContext(context.Background(), filename) +} + +// UpdateLogFileContext triggers update for a specific log file. +func (s *Sonarr) UpdateLogFileContext(ctx context.Context, filename string) ([]*LogFile, error) { + var output []*LogFile + + req := starr.Request{URI: path.Join(bpLog, "file", "update", filename)} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return output, nil +} diff --git a/sonarr/mediacover.go b/sonarr/mediacover.go new file mode 100644 index 00000000..4abcb8b7 --- /dev/null +++ b/sonarr/mediacover.go @@ -0,0 +1,38 @@ +package sonarr + +import ( + "context" + "fmt" + "io" + "net/url" + "path" + + "golift.io/starr" +) + +const bpMediaCover = APIver + "/mediacover" + +// GetMediaCover downloads a series media cover image (jpg, png, or gif). +func (s *Sonarr) GetMediaCover(seriesID int64, filename string) ([]byte, error) { + return s.GetMediaCoverContext(context.Background(), seriesID, filename) +} + +// GetMediaCoverContext downloads a series media cover image (jpg, png, or gif). +func (s *Sonarr) GetMediaCoverContext(ctx context.Context, seriesID int64, filename string) ([]byte, error) { + uri := starr.SetAPIPath(path.Join(bpMediaCover, starr.Str(seriesID), url.PathEscape(filename))) + + req := starr.Request{URI: uri} + + resp, err := s.Get(ctx, req) + if err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response body from %s: %w", uri, err) + } + + return body, nil +} diff --git a/sonarr/metadata.go b/sonarr/metadata.go new file mode 100644 index 00000000..23266785 --- /dev/null +++ b/sonarr/metadata.go @@ -0,0 +1,238 @@ +package sonarr + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/url" + "path" + + "golift.io/starr" +) + +const bpMetadata = APIver + "/metadata" + +// MetadataProviderMessage is the provider message object on metadata consumers. +type MetadataProviderMessage struct { + Message string `json:"message,omitempty"` + Type string `json:"type,omitempty"` +} + +// MetadataOutput is the output from /api/v3/metadata (MetadataResource). +type MetadataOutput struct { + ID int64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Fields []*starr.FieldOutput `json:"fields,omitempty"` + ImplementationName string `json:"implementationName,omitempty"` + Implementation string `json:"implementation,omitempty"` + ConfigContract string `json:"configContract,omitempty"` + InfoLink string `json:"infoLink,omitempty"` + Message *MetadataProviderMessage `json:"message,omitempty"` + Tags []int `json:"tags,omitempty"` + Presets []*MetadataOutput `json:"presets,omitempty"` + Enable bool `json:"enable"` +} + +// MetadataInput is the input for creating or updating metadata consumers. +type MetadataInput struct { + ID int64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Fields []*starr.FieldInput `json:"fields,omitempty"` + Implementation string `json:"implementation,omitempty"` + ConfigContract string `json:"configContract,omitempty"` + Tags []int `json:"tags,omitempty"` + Enable bool `json:"enable"` +} + +// GetMetadata returns all configured metadata consumers. +func (s *Sonarr) GetMetadata() ([]*MetadataOutput, error) { + return s.GetMetadataContext(context.Background()) +} + +// GetMetadataContext returns all configured metadata consumers. +func (s *Sonarr) GetMetadataContext(ctx context.Context) ([]*MetadataOutput, error) { + var output []*MetadataOutput + + req := starr.Request{URI: bpMetadata} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return output, nil +} + +// GetMetadataByID returns a single metadata consumer. +func (s *Sonarr) GetMetadataByID(id int64) (*MetadataOutput, error) { + return s.GetMetadataByIDContext(context.Background(), id) +} + +// GetMetadataByIDContext returns a single metadata consumer. +func (s *Sonarr) GetMetadataByIDContext(ctx context.Context, id int64) (*MetadataOutput, error) { + var output MetadataOutput + + req := starr.Request{URI: path.Join(bpMetadata, starr.Str(id))} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return &output, nil +} + +// GetMetadataSchema returns metadata consumer templates. +func (s *Sonarr) GetMetadataSchema() ([]*MetadataOutput, error) { + return s.GetMetadataSchemaContext(context.Background()) +} + +// GetMetadataSchemaContext returns metadata consumer templates. +func (s *Sonarr) GetMetadataSchemaContext(ctx context.Context) ([]*MetadataOutput, error) { + var output []*MetadataOutput + + req := starr.Request{URI: path.Join(bpMetadata, "schema")} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return output, nil +} + +// AddMetadata creates a metadata consumer. +func (s *Sonarr) AddMetadata(input *MetadataInput, forceSave bool) (*MetadataOutput, error) { + return s.AddMetadataContext(context.Background(), input, forceSave) +} + +// AddMetadataContext creates a metadata consumer. +func (s *Sonarr) AddMetadataContext( + ctx context.Context, input *MetadataInput, forceSave bool, +) (*MetadataOutput, error) { + var output MetadataOutput + + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(input); err != nil { + return nil, fmt.Errorf("json.Marshal(%s): %w", bpMetadata, err) + } + + q := url.Values{} + if forceSave { + q.Set("forceSave", "true") + } + + req := starr.Request{URI: bpMetadata, Body: &body, Query: q} + if err := s.PostInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Post(%s): %w", &req, err) + } + + return &output, nil +} + +// UpdateMetadata updates a metadata consumer. +func (s *Sonarr) UpdateMetadata(input *MetadataInput, forceSave bool) (*MetadataOutput, error) { + return s.UpdateMetadataContext(context.Background(), input, forceSave) +} + +// UpdateMetadataContext updates a metadata consumer. +func (s *Sonarr) UpdateMetadataContext( + ctx context.Context, input *MetadataInput, forceSave bool, +) (*MetadataOutput, error) { + var output MetadataOutput + + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(input); err != nil { + return nil, fmt.Errorf("json.Marshal(%s): %w", bpMetadata, err) + } + + params := url.Values{} + if forceSave { + params.Set("forceSave", "true") + } + + uri := path.Join(bpMetadata, starr.Str(input.ID)) + + req := starr.Request{URI: uri, Body: &body, Query: params} + if err := s.PutInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Put(%s): %w", &req, err) + } + + return &output, nil +} + +// DeleteMetadata deletes a metadata consumer. +func (s *Sonarr) DeleteMetadata(id int64) error { + return s.DeleteMetadataContext(context.Background(), id) +} + +// DeleteMetadataContext deletes a metadata consumer. +func (s *Sonarr) DeleteMetadataContext(ctx context.Context, id int64) error { + req := starr.Request{URI: path.Join(bpMetadata, starr.Str(id))} + if err := s.DeleteAny(ctx, req); err != nil { + return fmt.Errorf("api.Delete(%s): %w", &req, err) + } + + return nil +} + +// MetadataAction runs a named action on a metadata consumer. +func (s *Sonarr) MetadataAction(name string, input *MetadataInput) error { + return s.MetadataActionContext(context.Background(), name, input) +} + +// MetadataActionContext runs a named action on a metadata consumer. +func (s *Sonarr) MetadataActionContext(ctx context.Context, name string, input *MetadataInput) error { + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(input); err != nil { + return fmt.Errorf("json.Marshal(%s): %w", bpMetadata, err) + } + + var output any + + req := starr.Request{URI: path.Join(bpMetadata, "action", path.Base(name)), Body: &body} + if err := s.PostInto(ctx, req, &output); err != nil { + return fmt.Errorf("api.Post(%s): %w", &req, err) + } + + return nil +} + +// TestMetadata tests a metadata consumer configuration. +func (s *Sonarr) TestMetadata(input *MetadataInput, forceTest bool) error { + return s.TestMetadataContext(context.Background(), input, forceTest) +} + +// TestMetadataContext tests a metadata consumer configuration. +func (s *Sonarr) TestMetadataContext(ctx context.Context, input *MetadataInput, forceTest bool) error { + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(input); err != nil { + return fmt.Errorf("json.Marshal(%s): %w", bpMetadata, err) + } + + query := url.Values{} + if forceTest { + query.Set("forceTest", "true") + } + + var output any + + req := starr.Request{URI: path.Join(bpMetadata, "test"), Body: &body, Query: query} + if err := s.PostInto(ctx, req, &output); err != nil { + return fmt.Errorf("api.Post(%s): %w", &req, err) + } + + return nil +} + +// TestAllMetadata tests all metadata consumers. +func (s *Sonarr) TestAllMetadata() error { + return s.TestAllMetadataContext(context.Background()) +} + +// TestAllMetadataContext tests all metadata consumers. +func (s *Sonarr) TestAllMetadataContext(ctx context.Context) error { + var output any + + req := starr.Request{URI: path.Join(bpMetadata, "testall")} + if err := s.PostInto(ctx, req, &output); err != nil { + return fmt.Errorf("api.Post(%s): %w", &req, err) + } + + return nil +} diff --git a/sonarr/queue.go b/sonarr/queue.go index 9087a473..4876477f 100644 --- a/sonarr/queue.go +++ b/sonarr/queue.go @@ -5,6 +5,8 @@ import ( "context" "encoding/json" "fmt" + "io" + "net/url" "path" "time" @@ -48,6 +50,18 @@ type QueueRecord struct { ErrorMessage string `json:"errorMessage"` } +// QueueStatus is the aggregate queue status from /api/v3/queue/status. +type QueueStatus struct { + ID int `json:"id,omitempty"` + TotalCount int `json:"totalCount,omitempty"` + Count int `json:"count,omitempty"` + UnknownCount int `json:"unknownCount,omitempty"` + Errors bool `json:"errors"` + Warnings bool `json:"warnings"` + UnknownErrors bool `json:"unknownErrors"` + UnknownWarnings bool `json:"unknownWarnings"` +} + // GetQueue returns a single page from the Sonarr Queue (processing, but not yet imported). // If you need control over the page, use sonarr.GetQueuePage(). // This function simply returns the number of queue records desired, @@ -153,3 +167,99 @@ func (s *Sonarr) QueueGrabContext(ctx context.Context, ids ...int64) error { return nil } + +// GetQueueDetails returns the raw JSON array from /api/v3/queue/details. +// Unmarshal into your own structs if you need typed fields beyond the main queue list. +func (s *Sonarr) GetQueueDetails(query url.Values) ([]byte, error) { + return s.GetQueueDetailsContext(context.Background(), query) +} + +// GetQueueDetailsContext returns the raw JSON array from /api/v3/queue/details. +func (s *Sonarr) GetQueueDetailsContext(ctx context.Context, query url.Values) ([]byte, error) { + uri := starr.SetAPIPath(path.Join(bpQueue, "details")) + + req := starr.Request{URI: uri, Query: query} + + resp, err := s.Get(ctx, req) + if err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response body from %s: %w", uri, err) + } + + return body, nil +} + +// GetQueueStatus returns aggregate queue status. +func (s *Sonarr) GetQueueStatus() (*QueueStatus, error) { + return s.GetQueueStatusContext(context.Background()) +} + +// GetQueueStatusContext returns aggregate queue status. +func (s *Sonarr) GetQueueStatusContext(ctx context.Context) (*QueueStatus, error) { + var output QueueStatus + + req := starr.Request{URI: path.Join(bpQueue, "status")} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return &output, nil +} + +// DeleteQueueBulk removes multiple queue items. +func (s *Sonarr) DeleteQueueBulk(ids []int64, opts *starr.QueueDeleteOpts) error { + return s.DeleteQueueBulkContext(context.Background(), ids, opts) +} + +// DeleteQueueBulkContext removes multiple queue items. +func (s *Sonarr) DeleteQueueBulkContext(ctx context.Context, ids []int64, opts *starr.QueueDeleteOpts) error { + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(struct { + IDs []int64 `json:"ids"` + }{IDs: ids}); err != nil { + return fmt.Errorf("json.Marshal(%s): %w", bpQueue, err) + } + + var deleteQuery url.Values + if opts != nil { + deleteQuery = opts.Values() + } else { + deleteQuery = (&starr.QueueDeleteOpts{}).Values() + } + + uri := starr.SetAPIPath(path.Join(bpQueue, "bulk")) + + req := starr.Request{URI: uri, Body: &body, Query: deleteQuery} + + resp, err := s.Delete(ctx, req) + if err != nil { + return fmt.Errorf("api.Delete(%s): %w", &req, err) + } + defer resp.Body.Close() + + _, _ = io.ReadAll(resp.Body) + + return nil +} + +// QueueGrabOne tells Sonarr to grab a single delayed queue item. +func (s *Sonarr) QueueGrabOne(queueID int64) error { + return s.QueueGrabOneContext(context.Background(), queueID) +} + +// QueueGrabOneContext tells Sonarr to grab a single delayed queue item. +func (s *Sonarr) QueueGrabOneContext(ctx context.Context, queueID int64) error { + var output any + + req := starr.Request{URI: path.Join(bpQueue, "grab", starr.Str(queueID))} + if err := s.PostInto(ctx, req, &output); err != nil { + return fmt.Errorf("api.Post(%s): %w", &req, err) + } + + return nil +} diff --git a/sonarr/system.go b/sonarr/system.go index c0c7a89c..aa826500 100644 --- a/sonarr/system.go +++ b/sonarr/system.go @@ -1,8 +1,12 @@ package sonarr import ( + "bytes" "context" "fmt" + "io" + "mime/multipart" + "net/http" "path" "time" @@ -78,3 +82,213 @@ func (s *Sonarr) GetBackupFilesContext(ctx context.Context) ([]*starr.BackupFile return output, nil } + +// SystemTask is a scheduled task from /api/v3/system/task. +type SystemTask struct { + ID int `json:"id,omitempty"` + Name string `json:"name,omitempty"` + TaskName string `json:"taskName,omitempty"` + Interval int `json:"interval,omitempty"` + LastExecution time.Time `json:"lastExecution,omitzero"` + LastStartTime time.Time `json:"lastStartTime,omitzero"` + NextExecution time.Time `json:"nextExecution,omitzero"` + LastDuration string `json:"lastDuration,omitempty"` +} + +// BackupRestoreResponse is returned when restoring a backup. +type BackupRestoreResponse struct { + RestartRequired bool `json:"restartRequired"` +} + +// DeleteBackup deletes a backup file by ID. +func (s *Sonarr) DeleteBackup(id int64) error { + return s.DeleteBackupContext(context.Background(), id) +} + +// DeleteBackupContext deletes a backup file by ID. +func (s *Sonarr) DeleteBackupContext(ctx context.Context, id int64) error { + req := starr.Request{URI: path.Join(bpSystem, "backup", starr.Str(id))} + if err := s.DeleteAny(ctx, req); err != nil { + return fmt.Errorf("api.Delete(%s): %w", &req, err) + } + + return nil +} + +// RestoreBackup restores an on-disk backup by ID. +func (s *Sonarr) RestoreBackup(id int64) (*BackupRestoreResponse, error) { + return s.RestoreBackupContext(context.Background(), id) +} + +// RestoreBackupContext restores an on-disk backup by ID. +func (s *Sonarr) RestoreBackupContext(ctx context.Context, id int64) (*BackupRestoreResponse, error) { + var output BackupRestoreResponse + + req := starr.Request{URI: path.Join(bpSystem, "backup", "restore", starr.Str(id))} + if err := s.PostInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Post(%s): %w", &req, err) + } + + return &output, nil +} + +// RestoreBackupUpload uploads a backup archive and restores it. +func (s *Sonarr) RestoreBackupUpload(filename string, file io.Reader) (*BackupRestoreResponse, error) { + return s.RestoreBackupUploadContext(context.Background(), filename, file) +} + +// RestoreBackupUploadContext uploads a backup archive and restores it. +func (s *Sonarr) RestoreBackupUploadContext( + ctx context.Context, filename string, file io.Reader, +) (*BackupRestoreResponse, error) { + var buf bytes.Buffer + + writer := multipart.NewWriter(&buf) + + part, err := writer.CreateFormFile("file", filename) + if err != nil { + return nil, fmt.Errorf("creating multipart form: %w", err) + } + + if _, err = io.Copy(part, file); err != nil { + return nil, fmt.Errorf("writing backup to multipart form: %w", err) + } + + if err = writer.Close(); err != nil { + return nil, fmt.Errorf("closing multipart writer: %w", err) + } + + var output BackupRestoreResponse + + hdr := make(http.Header) + hdr.Set("Content-Type", writer.FormDataContentType()) + + req := starr.Request{ + URI: path.Join(bpSystem, "backup", "restore", "upload"), + Body: &buf, + Headers: hdr, + } + if err := s.PostInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Post(%s): %w", &req, err) + } + + return &output, nil +} + +// Restart tells Sonarr to restart. +func (s *Sonarr) Restart() error { + return s.RestartContext(context.Background()) +} + +// RestartContext tells Sonarr to restart. +func (s *Sonarr) RestartContext(ctx context.Context) error { + var output any + + req := starr.Request{URI: path.Join(bpSystem, "restart")} + if err := s.PostInto(ctx, req, &output); err != nil { + return fmt.Errorf("api.Post(%s): %w", &req, err) + } + + return nil +} + +// Shutdown tells Sonarr to shut down. +func (s *Sonarr) Shutdown() error { + return s.ShutdownContext(context.Background()) +} + +// ShutdownContext tells Sonarr to shut down. +func (s *Sonarr) ShutdownContext(ctx context.Context) error { + var output any + + req := starr.Request{URI: path.Join(bpSystem, "shutdown")} + if err := s.PostInto(ctx, req, &output); err != nil { + return fmt.Errorf("api.Post(%s): %w", &req, err) + } + + return nil +} + +// GetSystemRoutes returns the raw JSON route table (schema-less in OpenAPI). +func (s *Sonarr) GetSystemRoutes() ([]byte, error) { + return s.GetSystemRoutesContext(context.Background()) +} + +// GetSystemRoutesContext returns the raw JSON route table (schema-less in OpenAPI). +func (s *Sonarr) GetSystemRoutesContext(ctx context.Context) ([]byte, error) { + uri := starr.SetAPIPath(path.Join(bpSystem, "routes")) + + req := starr.Request{URI: uri} + + resp, err := s.Get(ctx, req) + if err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response body from %s: %w", uri, err) + } + + return body, nil +} + +// GetSystemDuplicateRoutes returns duplicate route definitions as raw JSON. +func (s *Sonarr) GetSystemDuplicateRoutes() ([]byte, error) { + return s.GetSystemDuplicateRoutesContext(context.Background()) +} + +// GetSystemDuplicateRoutesContext returns duplicate route definitions as raw JSON. +func (s *Sonarr) GetSystemDuplicateRoutesContext(ctx context.Context) ([]byte, error) { + uri := starr.SetAPIPath(path.Join(bpSystem, "routes", "duplicate")) + + req := starr.Request{URI: uri} + + resp, err := s.Get(ctx, req) + if err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response body from %s: %w", uri, err) + } + + return body, nil +} + +// GetSystemTasks returns all scheduled tasks. +func (s *Sonarr) GetSystemTasks() ([]*SystemTask, error) { + return s.GetSystemTasksContext(context.Background()) +} + +// GetSystemTasksContext returns all scheduled tasks. +func (s *Sonarr) GetSystemTasksContext(ctx context.Context) ([]*SystemTask, error) { + var output []*SystemTask + + req := starr.Request{URI: path.Join(bpSystem, "task")} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return output, nil +} + +// GetSystemTask returns a single scheduled task. +func (s *Sonarr) GetSystemTask(id int64) (*SystemTask, error) { + return s.GetSystemTaskContext(context.Background(), id) +} + +// GetSystemTaskContext returns a single scheduled task. +func (s *Sonarr) GetSystemTaskContext(ctx context.Context, id int64) (*SystemTask, error) { + var output SystemTask + + req := starr.Request{URI: path.Join(bpSystem, "task", starr.Str(id))} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return &output, nil +} diff --git a/sonarr/update.go b/sonarr/update.go new file mode 100644 index 00000000..726c6c41 --- /dev/null +++ b/sonarr/update.go @@ -0,0 +1,50 @@ +package sonarr + +import ( + "context" + "fmt" + "time" + + "golift.io/starr" +) + +const bpUpdate = APIver + "/update" + +// UpdateChanges is the change log embedded in Update. +type UpdateChanges struct { + New []string `json:"new,omitempty"` + Fixed []string `json:"fixed,omitempty"` +} + +// Update is one available or installed update from /api/v3/update. +type Update struct { + ID int `json:"id,omitempty"` + Version string `json:"version,omitempty"` + Branch string `json:"branch,omitempty"` + ReleaseDate time.Time `json:"releaseDate,omitzero"` + FileName string `json:"fileName,omitempty"` + URL string `json:"url,omitempty"` + Installed bool `json:"installed"` + InstalledOn time.Time `json:"installedOn,omitzero"` + Installable bool `json:"installable"` + Latest bool `json:"latest"` + Changes *UpdateChanges `json:"changes,omitempty"` + Hash string `json:"hash,omitempty"` +} + +// GetUpdates returns available application updates. +func (s *Sonarr) GetUpdates() ([]*Update, error) { + return s.GetUpdatesContext(context.Background()) +} + +// GetUpdatesContext returns available application updates. +func (s *Sonarr) GetUpdatesContext(ctx context.Context) ([]*Update, error) { + var output []*Update + + req := starr.Request{URI: bpUpdate} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return output, nil +} diff --git a/sonarr/wanted.go b/sonarr/wanted.go new file mode 100644 index 00000000..660b16d9 --- /dev/null +++ b/sonarr/wanted.go @@ -0,0 +1,103 @@ +package sonarr + +import ( + "context" + "fmt" + "path" + + "golift.io/starr" +) + +const bpWanted = APIver + "/wanted" + +// WantedEpisodesPage is a paged list of episodes from /api/v3/wanted/missing or /wanted/cutoff. +type WantedEpisodesPage struct { + Page int `json:"page"` + PageSize int `json:"pageSize"` + SortKey string `json:"sortKey,omitempty"` + SortDirection string `json:"sortDirection,omitempty"` + TotalRecords int `json:"totalRecords"` + Records []*Episode `json:"records"` +} + +func wantedPageParams(params *starr.PageReq) *starr.PageReq { + if params == nil { + return &starr.PageReq{} + } + + return params +} + +// GetWantedMissingPage returns a page of missing episodes. +func (s *Sonarr) GetWantedMissingPage(params *starr.PageReq) (*WantedEpisodesPage, error) { + return s.GetWantedMissingPageContext(context.Background(), params) +} + +// GetWantedMissingPageContext returns a page of missing episodes. +func (s *Sonarr) GetWantedMissingPageContext(ctx context.Context, params *starr.PageReq) (*WantedEpisodesPage, error) { + var output WantedEpisodesPage + + p := wantedPageParams(params) + p.CheckSet("sortKey", "airDateUtc") + + req := starr.Request{URI: path.Join(bpWanted, "missing"), Query: p.Params()} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return &output, nil +} + +// GetWantedMissingEpisode returns a single missing episode by episode ID. +func (s *Sonarr) GetWantedMissingEpisode(episodeID int64) (*Episode, error) { + return s.GetWantedMissingEpisodeContext(context.Background(), episodeID) +} + +// GetWantedMissingEpisodeContext returns a single missing episode by episode ID. +func (s *Sonarr) GetWantedMissingEpisodeContext(ctx context.Context, episodeID int64) (*Episode, error) { + var output Episode + + req := starr.Request{URI: path.Join(bpWanted, "missing", starr.Str(episodeID))} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return &output, nil +} + +// GetWantedCutoffPage returns a page of episodes past quality cutoff. +func (s *Sonarr) GetWantedCutoffPage(params *starr.PageReq) (*WantedEpisodesPage, error) { + return s.GetWantedCutoffPageContext(context.Background(), params) +} + +// GetWantedCutoffPageContext returns a page of episodes past quality cutoff. +func (s *Sonarr) GetWantedCutoffPageContext(ctx context.Context, params *starr.PageReq) (*WantedEpisodesPage, error) { + var output WantedEpisodesPage + + p := wantedPageParams(params) + p.CheckSet("sortKey", "airDateUtc") + + req := starr.Request{URI: path.Join(bpWanted, "cutoff"), Query: p.Params()} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return &output, nil +} + +// GetWantedCutoffEpisode returns a single cutoff-unmet episode by episode ID. +func (s *Sonarr) GetWantedCutoffEpisode(episodeID int64) (*Episode, error) { + return s.GetWantedCutoffEpisodeContext(context.Background(), episodeID) +} + +// GetWantedCutoffEpisodeContext returns a single cutoff-unmet episode by episode ID. +func (s *Sonarr) GetWantedCutoffEpisodeContext(ctx context.Context, episodeID int64) (*Episode, error) { + var output Episode + + req := starr.Request{URI: path.Join(bpWanted, "cutoff", starr.Str(episodeID))} + if err := s.GetInto(ctx, req, &output); err != nil { + return nil, fmt.Errorf("api.Get(%s): %w", &req, err) + } + + return &output, nil +} From 844737bb066e19a482dc0d27233a62e93c207049 Mon Sep 17 00:00:00 2001 From: David Newhall II Date: Sun, 12 Apr 2026 19:15:00 -0700 Subject: [PATCH 2/2] keep these --- http.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/http.go b/http.go index 72c08c2c..2b2b3f48 100644 --- a/http.go +++ b/http.go @@ -145,9 +145,6 @@ func (c *Config) SetHeaders(req *http.Request, headers http.Header) { req.Header.Set("Content-Type", "application/json") } - req.Header.Set("User-Agent", "go-starr: https://"+reflect.TypeFor[Config]().PkgPath()) - req.Header.Set("X-Api-Key", c.APIKey) - for key, vals := range headers { req.Header[http.CanonicalHeaderKey(key)] = vals } @@ -157,6 +154,9 @@ func (c *Config) SetHeaders(req *http.Request, headers http.Header) { } else if req.Header.Get("Accept") == "" { req.Header.Set("Accept", "application/json") } + + req.Header.Set("User-Agent", "go-starr: https://"+reflect.TypeFor[Config]().PkgPath()) + req.Header.Set("X-Api-Key", c.APIKey) } // SetAPIPath makes sure the path starts with /api.