arrapi

package module
v1.0.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 8, 2026 License: GPL-2.0, GPL-3.0 Imports: 13 Imported by: 0

README

arrapi

Go Reference Go version Test coverage Mutation OpenSSF Scorecard

Typed, resilient Go clients for the Sonarr and Radarr v3 APIs

A standalone Go library that wraps the Sonarr and Radarr v3 HTTP APIs behind two small, type-safe clients. Requests are authenticated, size-bounded, and retried on transient failures with jittered exponential backoff (via cplieger/httpx). The only runtime dependency is httpx.

Design

Two constructors return two concrete types, so an operation can only be called against the service that supports it:

  • NewSonarr(...) returns a *Sonarr with GetSeries and GetEpisodes.
  • NewRadarr(...) returns a *Radarr with GetMovies.

Both embed a shared core exposing the endpoints common to either service (GetTags, GetSystemStatus, Ping, Close). This replaces the single-client-does-both shape where a wrong call (GetMovies on a Sonarr instance) is only caught at runtime.

Install

go get github.com/cplieger/arrapi@latest

Usage

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/cplieger/arrapi"
)

func main() {
	ctx := context.Background()

	sonarr, err := arrapi.NewSonarr("http://sonarr:8989", "your-api-key")
	if err != nil {
		log.Fatal(err)
	}
	defer sonarr.Close()

	// Verify connectivity + credentials up front (fails fast on a bad key).
	if err := sonarr.Ping(ctx); err != nil {
		log.Fatalf("sonarr unreachable: %v", err)
	}

	// Fetch the whole series library in one batched, retried request.
	series, err := sonarr.GetSeries(ctx)
	if err != nil {
		log.Fatal(err)
	}

	// Tag filtering: keep series tagged "anime", drop those tagged "skip".
	tags, err := sonarr.GetTags(ctx)
	if err != nil {
		log.Fatal(err)
	}
	anime := arrapi.TagIDs(tags, "anime")
	skip := arrapi.TagIDs(tags, "skip")
	for _, s := range series {
		if arrapi.HasAnyTag(s.Tags, anime) && !arrapi.HasAnyTag(s.Tags, skip) {
			fmt.Printf("%s (tvdb %d, %d)\n", s.Title, s.TvdbID, s.Year)
		}
	}

	// Radarr is a separate typed client; options tune retry/timeout.
	radarr, err := arrapi.NewRadarr("http://radarr:7878", "your-api-key", arrapi.WithMaxAttempts(5))
	if err != nil {
		log.Fatal(err)
	}
	defer radarr.Close()

	movies, err := radarr.GetMovies(ctx)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%d movies\n", len(movies))
}

API

Constructors
  • NewSonarr(baseURL, apiKey string, opts ...Option) (*Sonarr, error)
  • NewRadarr(baseURL, apiKey string, opts ...Option) (*Radarr, error)

baseURL must be an absolute http(s) URL; apiKey must be non-empty. Both are validated at construction.

Sonarr
  • GetSeries(ctx) ([]Series, error) — every series in the library
  • GetEpisodes(ctx, seriesID int) ([]Episode, error) — episodes for a series, including episode-file details
  • RescanSeries(ctx, seriesID int) error — rescan the series' folder for new or changed files
  • RefreshSeries(ctx, seriesID int) error — refresh series metadata and rescan
Radarr
  • GetMovies(ctx) ([]Movie, error) — every movie in the library
  • RescanMovie(ctx, movieID int) error — rescan the movie's folder for new or changed files
  • RefreshMovie(ctx, movieID int) error — refresh movie metadata and rescan
Shared (both clients)
  • GetTags(ctx) ([]Tag, error) — all tags defined on the instance
  • GetQualityProfiles(ctx) ([]QualityProfile, error) — configured quality profiles
  • GetRootFolders(ctx) ([]RootFolder, error) — configured root folders
  • GetSystemStatus(ctx) (SystemStatus, error) — version and app name
  • GetHistorySince(ctx, since time.Time, eventType EventType) ([]HistoryRecord, error) — history events on or after since, optionally filtered to one EventType (0 = all)
  • Ping(ctx) error — connectivity + credential check with a short timeout (no retry)
  • Close() — release idle connections; safe to call more than once
History types

GetHistorySince returns []HistoryRecord (Date, EventType, SourceTitle, SeriesID/EpisodeID for Sonarr or MovieID for Radarr, plus a Data map). HistoryRecord.ImportedPath() pulls the imported file path from a download-import event. EventType decodes both Sonarr's integer and Radarr's string encodings; the exported constants are EventGrabbed, EventDownloadImported, EventDownloadFailed, EventFileDeleted, and EventFileRenamed.

Tag helpers (pure)
  • TagIDs(tags []Tag, labels ...string) map[int]struct{} — resolve label names to their IDs (case-insensitive, whitespace-trimmed)
  • HasAnyTag(itemTags []int, ids map[int]struct{}) bool — does an item carry any of those tag IDs
Options
Option Description
WithHTTPClient(c) Use a caller-owned *http.Client (share a pool, pin a CA, inject a test client)
WithMaxAttempts(n) Total attempts including the first, for a transient failure. Clamped to ≥1. Default 3
WithBaseDelay(d) Base delay for the exponential backoff between retries. Default 1s
WithTimeout(d) Per-request timeout applied when the caller's context has no deadline. Default 120s
Errors

Non-2xx responses surface as *StatusError (fields Code, Path, Body). It implements httpx.Transient, so a 429 or any 5xx is classified as retryable and every 4xx as permanent. IsNotFound(err) reports whether an error is (or wraps) a *StatusError with a 404.

Resilience

  • Retries 429, any 5xx, and transient transport errors (timeouts, connection resets, DNS failures) with jittered exponential backoff. 4xx (non-429) and non-transient transport errors fail immediately.
  • Every request carries the X-Api-Key header and is bounded by WithTimeout when the caller's context has none.
  • Response bodies are size-capped before decoding (64 MB for list endpoints, 1 MB for single objects) to guard against oversized or malicious payloads.
  • Concurrent identical reads (two goroutines both calling GetSeries, say) are coalesced into one upstream request via singleflight; a coalesced result may be shared, so treat a returned slice as read-only. History and commands are never coalesced.
  • Clients own no long-lived goroutines and hold no locks a caller can observe; a single client is safe for concurrent use.

Unsupported by Design

Deliberate non-goals, not TODOs:

Not included Rationale
Adding / editing / deleting media This is a read + connectivity + rescan client, not a full library-management client. Use golift/starr or the devopsarr *-go clients for CRUD.
Quality-profile item / cutoff detail QualityProfile models identity (name + ID); the nested quality-item and custom-format tree is out of scope.
Indexer / download-client / notification config Management-plane surface with no consumer need.

Disclaimer

This project is built with care and follows security best practices, but it is intended for personal / self-hosted use. No guarantees of fitness for production environments. Use at your own risk.

This project was built with AI-assisted tooling using Claude Opus and Kiro. The human maintainer defines architecture, supervises implementation, and makes all final decisions.

License

GPL-3.0 — see LICENSE.

Documentation

Overview

Package arrapi provides typed, resilient read clients for the Sonarr and Radarr v3 HTTP APIs.

Two constructors return two concrete client types, so an operation can only be called against the instance that supports it: NewSonarr returns a *Sonarr (GetSeries, GetEpisodes) and NewRadarr returns a *Radarr (GetMovies). Both embed a shared core that exposes the endpoints common to either service (GetTags, GetSystemStatus, Ping, Close).

Every request is authenticated with the instance's X-Api-Key, bounded by a per-request timeout, and retried on transient failures (HTTP 429, any 5xx, and transient transport errors) with jittered exponential backoff via github.com/cplieger/httpx. Non-2xx responses surface as a *StatusError, which reports whether it was transient and lets callers detect a 404 with IsNotFound.

Response bodies are size-bounded before decoding to guard against oversized or malicious payloads. The clients own no goroutines and hold no locks; a single client is safe for concurrent use.

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func HasAnyTag

func HasAnyTag(itemTags []int, ids map[int]struct{}) bool

HasAnyTag reports whether itemTags contains any of the tag IDs in ids. It is the companion to TagIDs for include/exclude filtering of series and movies.

func IsNotFound

func IsNotFound(err error) bool

IsNotFound reports whether err is (or wraps) a *StatusError with a 404 status.

func TagIDs

func TagIDs(tags []Tag, labels ...string) map[int]struct{}

TagIDs returns the set of tag IDs whose labels match any of the given labels. Matching is case-insensitive and trims surrounding whitespace (Sonarr and Radarr store tag labels lowercased). Labels with no matching tag are simply absent from the result. It returns nil when no labels are supplied.

Example

ExampleTagIDs shows resolving tag labels to IDs and using the result to filter items by tag.

package main

import (
	"fmt"

	"github.com/cplieger/arrapi"
)

func main() {
	tags := []arrapi.Tag{
		{ID: 1, Label: "anime"},
		{ID: 2, Label: "4k"},
		{ID: 3, Label: "kids"},
	}
	want := arrapi.TagIDs(tags, "anime", "kids")

	fmt.Println(arrapi.HasAnyTag([]int{3, 9}, want)) // carries "kids"
	fmt.Println(arrapi.HasAnyTag([]int{2}, want))    // only "4k"
}
Output:
true
false

Types

type AlternateTitle

type AlternateTitle struct {
	Title string `json:"title"`
}

AlternateTitle is an alternate title for a series or movie.

type Episode

type Episode struct {
	EpisodeFile           *EpisodeFile `json:"episodeFile,omitempty"`
	Title                 string       `json:"title,omitempty"`
	AirDate               string       `json:"airDate,omitempty"`
	ID                    int          `json:"id"`
	SeriesID              int          `json:"seriesId"`
	SeasonNumber          int          `json:"seasonNumber"`
	EpisodeNumber         int          `json:"episodeNumber"`
	AbsoluteEpisodeNumber int          `json:"absoluteEpisodeNumber,omitempty"`
	HasFile               bool         `json:"hasFile"`
	Monitored             bool         `json:"monitored"`
}

Episode is a Sonarr episode.

type EpisodeFile

type EpisodeFile struct {
	MediaInfo    *MediaInfo `json:"mediaInfo,omitempty"`
	RelativePath string     `json:"relativePath,omitempty"`
	Path         string     `json:"path,omitempty"`
	SceneName    string     `json:"sceneName,omitempty"`
	ReleaseGroup string     `json:"releaseGroup,omitempty"`
	ID           int        `json:"id"`
	Size         int64      `json:"size"`
}

EpisodeFile holds file details for a Sonarr episode.

type EventType

type EventType int

EventType identifies a Sonarr/Radarr history event. Sonarr encodes it as an integer and Radarr as a string; UnmarshalJSON accepts either form.

const (
	// EventGrabbed is a release grabbed from an indexer.
	EventGrabbed EventType = 1
	// EventDownloadImported is a downloaded file imported into the library
	// (the "downloadFolderImported" event).
	EventDownloadImported EventType = 3
	// EventDownloadFailed is a failed download.
	EventDownloadFailed EventType = 4
	// EventFileDeleted is an episode-file or movie-file deletion.
	EventFileDeleted EventType = 5
	// EventFileRenamed is an episode-file or movie-file rename.
	EventFileRenamed EventType = 6
)

func (*EventType) UnmarshalJSON

func (e *EventType) UnmarshalJSON(data []byte) error

UnmarshalJSON decodes the integer form (Sonarr) or the string form (Radarr). Unknown or negative values decode to 0 (unknown), which callers filter out; a value that is neither an int nor a string is an error.

type HistoryRecord

type HistoryRecord struct {
	Date        time.Time         `json:"date"`
	Data        map[string]string `json:"data,omitempty"`
	SourceTitle string            `json:"sourceTitle,omitempty"`
	EventType   EventType         `json:"eventType"`
	ID          int               `json:"id"`
	SeriesID    int               `json:"seriesId,omitempty"`
	EpisodeID   int               `json:"episodeId,omitempty"`
	MovieID     int               `json:"movieId,omitempty"`
}

HistoryRecord is a single Sonarr or Radarr history event. Sonarr populates SeriesID/EpisodeID; Radarr populates MovieID. Data carries event-specific key/value detail (e.g. "importedPath").

func (*HistoryRecord) ImportedPath

func (h *HistoryRecord) ImportedPath() string

ImportedPath returns the imported file path from a download-import event's data dictionary, or "" when absent.

type MediaInfo

type MediaInfo struct {
	AudioLanguages string `json:"audioLanguages,omitempty"`
	Subtitles      string `json:"subtitles,omitempty"`
	VideoCodec     string `json:"videoCodec,omitempty"`
	AudioCodec     string `json:"audioCodec,omitempty"`
}

MediaInfo holds media-analysis details for an episode or movie file.

type Movie

type Movie struct {
	MovieFile        *MovieFile       `json:"movieFile,omitempty"`
	Title            string           `json:"title"`
	SortTitle        string           `json:"sortTitle,omitempty"`
	ImdbID           string           `json:"imdbId,omitempty"`
	Path             string           `json:"path,omitempty"`
	RootFolderPath   string           `json:"rootFolderPath,omitempty"`
	Status           string           `json:"status,omitempty"`
	AlternateTitles  []AlternateTitle `json:"alternateTitles,omitempty"`
	Tags             []int            `json:"tags"`
	ID               int              `json:"id"`
	TmdbID           int              `json:"tmdbId"`
	Year             int              `json:"year"`
	QualityProfileID int              `json:"qualityProfileId,omitempty"`
	HasFile          bool             `json:"hasFile"`
	Monitored        bool             `json:"monitored"`
}

Movie is a Radarr movie.

type MovieFile

type MovieFile struct {
	MediaInfo    *MediaInfo `json:"mediaInfo,omitempty"`
	RelativePath string     `json:"relativePath,omitempty"`
	Path         string     `json:"path,omitempty"`
	SceneName    string     `json:"sceneName,omitempty"`
	ReleaseGroup string     `json:"releaseGroup,omitempty"`
	ID           int        `json:"id"`
	Size         int64      `json:"size"`
}

MovieFile holds file details for a Radarr movie.

type Option

type Option func(*config)

Option configures a Sonarr or Radarr client at construction time.

func WithBaseDelay

func WithBaseDelay(d time.Duration) Option

WithBaseDelay sets the base delay for the exponential backoff between retries. Default: 1s.

func WithHTTPClient

func WithHTTPClient(c *http.Client) Option

WithHTTPClient sets the underlying *http.Client. When set, the caller owns the client's transport, timeout, and redirect policy; arrapi's defaults are not applied. A nil client is ignored. Use this to share a connection pool, pin a custom CA (see github.com/cplieger/httpx.CATransport), or inject a test server client.

func WithMaxAttempts

func WithMaxAttempts(n int) Option

WithMaxAttempts sets the total number of HTTP attempts (including the first) for a transient failure. Values below 1 are clamped to 1 (try exactly once). Default: 3.

func WithTimeout

func WithTimeout(d time.Duration) Option

WithTimeout sets the per-request timeout applied when the caller's context carries no deadline of its own. Default: 120s.

type QualityProfile

type QualityProfile struct {
	Name string `json:"name"`
	ID   int    `json:"id"`
}

QualityProfile is a Sonarr or Radarr quality profile. Only the identity fields are modeled; the profile's item/cutoff detail is out of scope.

type Radarr

type Radarr struct {
	// contains filtered or unexported fields
}

Radarr is a client for a single Radarr v3 instance. The zero value is not usable; construct one with NewRadarr. A Radarr is safe for concurrent use.

func NewRadarr

func NewRadarr(baseURL, apiKey string, opts ...Option) (*Radarr, error)

NewRadarr returns a Radarr client for the given base URL (e.g. "http://radarr:7878") and API key. It returns an error if the URL is not an absolute http(s) URL or the key is empty.

func (Radarr) Close

func (c Radarr) Close()

Close releases idle connections held by the client's HTTP transport. It is safe to call more than once.

func (Radarr) GetHistorySince

func (c Radarr) GetHistorySince(ctx context.Context, since time.Time, eventType EventType) ([]HistoryRecord, error)

GetHistorySince returns history records on or after since (the arr endpoint orders newest first). Pass an eventType to filter to a single kind of event, or 0 for all types. Available on both Sonarr and Radarr. It is not coalesced: each poll carries a distinct timestamp and must see fresh results.

func (*Radarr) GetMovies

func (r *Radarr) GetMovies(ctx context.Context) ([]Movie, error)

GetMovies returns every movie in the Radarr library.

func (Radarr) GetQualityProfiles

func (c Radarr) GetQualityProfiles(ctx context.Context) ([]QualityProfile, error)

GetQualityProfiles returns every quality profile defined on the instance.

func (Radarr) GetRootFolders

func (c Radarr) GetRootFolders(ctx context.Context) ([]RootFolder, error)

GetRootFolders returns every root folder configured on the instance.

func (Radarr) GetSystemStatus

func (c Radarr) GetSystemStatus(ctx context.Context) (SystemStatus, error)

GetSystemStatus returns the instance's system status (version, app name).

func (Radarr) GetTags

func (c Radarr) GetTags(ctx context.Context) ([]Tag, error)

GetTags returns all tags defined in the Sonarr or Radarr instance.

func (Radarr) Ping

func (c Radarr) Ping(ctx context.Context) error

Ping verifies connectivity and credentials against the instance's system-status endpoint. It returns nil on success, a *StatusError (a 401 for an invalid API key), or a transport error. A short timeout bounds it so config validation fails fast; it does not retry.

func (*Radarr) RefreshMovie

func (r *Radarr) RefreshMovie(ctx context.Context, movieID int) error

RefreshMovie asks Radarr to refresh the movie's metadata and rescan its files.

func (*Radarr) RescanMovie

func (r *Radarr) RescanMovie(ctx context.Context, movieID int) error

RescanMovie asks Radarr to rescan the movie's folder for new or changed files.

type RootFolder

type RootFolder struct {
	Path       string `json:"path"`
	ID         int    `json:"id"`
	FreeSpace  int64  `json:"freeSpace,omitempty"`
	Accessible bool   `json:"accessible,omitempty"`
}

RootFolder is a configured root folder on a Sonarr or Radarr instance.

type Season

type Season struct {
	Statistics   *SeasonStatistics `json:"statistics,omitempty"`
	SeasonNumber int               `json:"seasonNumber"`
	Monitored    bool              `json:"monitored"`
}

Season is per-season metadata on a Sonarr series.

type SeasonStatistics

type SeasonStatistics struct {
	EpisodeFileCount  int `json:"episodeFileCount"`
	EpisodeCount      int `json:"episodeCount"`
	TotalEpisodeCount int `json:"totalEpisodeCount"`
}

SeasonStatistics holds per-season episode counts.

type Series

type Series struct {
	Statistics       *SeriesStatistics `json:"statistics,omitempty"`
	Title            string            `json:"title"`
	SortTitle        string            `json:"sortTitle,omitempty"`
	ImdbID           string            `json:"imdbId,omitempty"`
	Path             string            `json:"path,omitempty"`
	RootFolderPath   string            `json:"rootFolderPath,omitempty"`
	Status           string            `json:"status,omitempty"`
	Seasons          []Season          `json:"seasons,omitempty"`
	AlternateTitles  []AlternateTitle  `json:"alternateTitles,omitempty"`
	Tags             []int             `json:"tags"`
	ID               int               `json:"id"`
	TvdbID           int               `json:"tvdbId"`
	TmdbID           int               `json:"tmdbId,omitempty"`
	Year             int               `json:"year"`
	QualityProfileID int               `json:"qualityProfileId,omitempty"`
	Monitored        bool              `json:"monitored"`
}

Series is a Sonarr series.

type SeriesStatistics

type SeriesStatistics struct {
	SeasonCount       int `json:"seasonCount"`
	EpisodeFileCount  int `json:"episodeFileCount"`
	EpisodeCount      int `json:"episodeCount"`
	TotalEpisodeCount int `json:"totalEpisodeCount"`
}

SeriesStatistics holds series-level episode counts.

type Sonarr

type Sonarr struct {
	// contains filtered or unexported fields
}

Sonarr is a client for a single Sonarr v3 instance. The zero value is not usable; construct one with NewSonarr. A Sonarr is safe for concurrent use.

func NewSonarr

func NewSonarr(baseURL, apiKey string, opts ...Option) (*Sonarr, error)

NewSonarr returns a Sonarr client for the given base URL (e.g. "http://sonarr:8989") and API key. It returns an error if the URL is not an absolute http(s) URL or the key is empty.

Example

ExampleNewSonarr shows the construct → ping → fetch flow. It is compiled as documentation but not run (no Output comment), so it needs no live instance.

package main

import (
	"context"
	"fmt"

	"github.com/cplieger/arrapi"
)

func main() {
	ctx := context.Background()

	sonarr, err := arrapi.NewSonarr("http://sonarr:8989", "your-api-key")
	if err != nil {
		return
	}
	defer sonarr.Close()

	if err := sonarr.Ping(ctx); err != nil {
		return
	}
	series, err := sonarr.GetSeries(ctx)
	if err != nil {
		return
	}
	fmt.Printf("%d series\n", len(series))
}

func (Sonarr) Close

func (c Sonarr) Close()

Close releases idle connections held by the client's HTTP transport. It is safe to call more than once.

func (*Sonarr) GetEpisodes

func (s *Sonarr) GetEpisodes(ctx context.Context, seriesID int) ([]Episode, error)

GetEpisodes returns all episodes for the given series, including episode-file details (release group, size, media info) where present.

func (Sonarr) GetHistorySince

func (c Sonarr) GetHistorySince(ctx context.Context, since time.Time, eventType EventType) ([]HistoryRecord, error)

GetHistorySince returns history records on or after since (the arr endpoint orders newest first). Pass an eventType to filter to a single kind of event, or 0 for all types. Available on both Sonarr and Radarr. It is not coalesced: each poll carries a distinct timestamp and must see fresh results.

func (Sonarr) GetQualityProfiles

func (c Sonarr) GetQualityProfiles(ctx context.Context) ([]QualityProfile, error)

GetQualityProfiles returns every quality profile defined on the instance.

func (Sonarr) GetRootFolders

func (c Sonarr) GetRootFolders(ctx context.Context) ([]RootFolder, error)

GetRootFolders returns every root folder configured on the instance.

func (*Sonarr) GetSeries

func (s *Sonarr) GetSeries(ctx context.Context) ([]Series, error)

GetSeries returns every series in the Sonarr library.

func (Sonarr) GetSystemStatus

func (c Sonarr) GetSystemStatus(ctx context.Context) (SystemStatus, error)

GetSystemStatus returns the instance's system status (version, app name).

func (Sonarr) GetTags

func (c Sonarr) GetTags(ctx context.Context) ([]Tag, error)

GetTags returns all tags defined in the Sonarr or Radarr instance.

func (Sonarr) Ping

func (c Sonarr) Ping(ctx context.Context) error

Ping verifies connectivity and credentials against the instance's system-status endpoint. It returns nil on success, a *StatusError (a 401 for an invalid API key), or a transport error. A short timeout bounds it so config validation fails fast; it does not retry.

func (*Sonarr) RefreshSeries

func (s *Sonarr) RefreshSeries(ctx context.Context, seriesID int) error

RefreshSeries asks Sonarr to refresh the series' metadata and rescan its files.

func (*Sonarr) RescanSeries

func (s *Sonarr) RescanSeries(ctx context.Context, seriesID int) error

RescanSeries asks Sonarr to rescan the series' folder for new or changed files (for example after an external tool wrote a subtitle). It does not re-fetch metadata; use RefreshSeries for that.

type StatusError

type StatusError struct {
	Body string
	Path string
	Code int
}

StatusError is a non-2xx HTTP response from a Sonarr or Radarr API. It implements the httpx.Transient interface, so the httpx retry helpers treat a 429 or any 5xx as retryable and every 4xx as permanent.

func (*StatusError) Error

func (e *StatusError) Error() string

Error implements the error interface.

func (*StatusError) IsTransient

func (e *StatusError) IsTransient() bool

IsTransient reports whether the response is retryable: HTTP 429 (rate limited) or any 5xx (server error).

type SystemStatus

type SystemStatus struct {
	Version      string `json:"version"`
	AppName      string `json:"appName,omitempty"`
	InstanceName string `json:"instanceName,omitempty"`
}

SystemStatus is the subset of a Sonarr/Radarr system-status response used for connectivity checks and version reporting.

type Tag

type Tag struct {
	Label string `json:"label"`
	ID    int    `json:"id"`
}

Tag is a Sonarr or Radarr tag.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL