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.
Two constructors return two concrete types, so an operation can only be called against the service that supports it:
NewSonarr(...)returns a*SonarrwithGetSeriesandGetEpisodes.NewRadarr(...)returns a*RadarrwithGetMovies.
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.
go get github.com/cplieger/arrapi@latest
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))
}NewSonarr(baseURL, apiKey string, opts ...Option) (*Sonarr, error)NewRadarr(baseURL, apiKey string, opts ...Option) (*Radarr, error)
baseURL must be an absolute http(s) URL with a host and no query or fragment (a path is allowed, for reverse-proxy sub-paths); apiKey must be non-empty. Both are validated at construction.
GetSeries(ctx) ([]Series, error)— every series in the libraryGetSeriesByID(ctx, seriesID int) (Series, error)— a single series by ID (IsNotFoundreports a missing ID)GetEpisodes(ctx, seriesID int) ([]Episode, error)— episodes for a series, including episode-file detailsGetEpisodeByID(ctx, episodeID int) (Episode, error)— a single episode by ID (IsNotFoundreports a missing ID)RescanSeries(ctx, seriesID int) (Command, error)— rescan the series' folder for new or changed files; returns the queued commandRefreshSeries(ctx, seriesID int) (Command, error)— refresh series metadata and rescan; returns the queued command
GetMovies(ctx) ([]Movie, error)— every movie in the libraryGetMovieByID(ctx, movieID int) (Movie, error)— a single movie by ID (IsNotFoundreports a missing ID)RescanMovie(ctx, movieID int) (Command, error)— rescan the movie's folder for new or changed files; returns the queued commandRefreshMovie(ctx, movieID int) (Command, error)— refresh movie metadata and rescan; returns the queued command
GetTags(ctx) ([]Tag, error)— all tags defined on the instanceResolveTagIDs(ctx, labels ...string) (ids map[int]struct{}, unmatched []string, err error)— fetch tags and resolve labels to IDs in one call; returns the matched IDs and the labels that matched no tag (no labels = no request)GetQualityProfiles(ctx) ([]QualityProfile, error)— configured quality profilesGetRootFolders(ctx) ([]RootFolder, error)— configured root foldersGetSystemStatus(ctx) (SystemStatus, error)— version and app nameGetHistorySince(ctx, since time.Time, eventTypes ...EventType) ([]HistoryRecord, error)— history events on or aftersince, newest first; pass one or moreEventTypes to filter (client-side), or none for allGetHistory(ctx, opts HistoryOptions) (HistoryPage, error)— one page of history (newest first), bounded by page size for backfills and large scansGetCommandByID(ctx, id int) (Command, error)— the state of a queued command, to poll a rescan or refresh to completionPing(ctx) error— connectivity + credential check with a short timeout (no retry)Close()— release idle connections; safe to call more than once
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, EventFolderImported, EventDownloadImported, EventDownloadFailed, EventFileDeleted, EventFileRenamed, and EventDownloadIgnored. It implements fmt.Stringer for logs, and an unrecognized upstream event decodes to 0 with its raw name preserved in HistoryRecord.RawEventType. Event filtering is client-side: the arr eventType query parameter is numbered per service (Sonarr and Radarr disagree on the integers), so a server-side filter is not portable.
GetHistory returns a HistoryPage (Records, Page, PageSize, TotalRecords) for bounded paging; HistoryOptions sets Page and PageSize.
TagIDs(tags []Tag, labels ...string) map[int]struct{}— resolve label names to their IDs (case-insensitive, whitespace-trimmed)UnmatchedLabels(tags []Tag, labels ...string) []string— the labels (verbatim) that match no tag, for flagging a misconfigured nameHasAnyTag(itemTags []int, ids map[int]struct{}) bool— does an item carry any of those tag IDs
DTO methods that build a link to the item's page in the arr web UI:
(*Series).WebURL(baseURL string) string→{baseURL}/series/{titleSlug}(Sonarr)(*Movie).WebURL(baseURL string) string→{baseURL}/movie/{tmdbID}(Radarr keys its web UI by the TMDB id)
Each returns "" when baseURL or the required field (the Sonarr title slug / Radarr TMDB id) is empty, so a caller reads "" as "no link". The Sonarr title slug is percent-escaped and confined to a single path segment — a ./.. or slash-bearing slug can't break out into the path, query, or fragment — so a community-editable slug is safe to interpolate.
| 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 |
Non-2xx responses surface as *StatusError (fields Code, Path, Body, and RetryAfter, the capped Retry-After hint on a 429). It implements httpx.Transient, so a 429 or any 5xx is classified as retryable and every 4xx as permanent, and httpx.RetryAfterHint, so httpx.RetryWithBackoff waits out that capped Retry-After before the next retry instead of its jittered backoff. IsNotFound(err) and IsRateLimited(err) report whether an error is (or wraps) a *StatusError with a 404 or 429. A response body that exceeds the size cap surfaces as *ResponseTooLargeError rather than being silently truncated.
- Retries
429, any5xx, and transient transport errors (timeouts, connection resets, DNS failures) with jittered exponential backoff (viahttpx.RetryWithBackoff), honoring the server'sRetry-Afterhint (capped) on a429.4xx(non-429) and non-transient transport errors fail immediately. - Retry diagnostics are emitted through
httpx's defaultsloglogger (aDebugline per retry, aWarnwhen retries are exhausted, taggedarrapi); the library owns no logger of its own and still returns typed errors regardless. - Every request carries the
X-Api-Keyheader and aUser-Agent, and is bounded byWithTimeout(spanning the body decode) when the caller's context has none. - Redirects are followed only within the same host (via
httpx.RedirectPolicyFuncwithWithSameHost), so theX-Api-Keyis never forwarded to another origin (Go strips onlyAuthorization/Cookieheaders on a cross-host redirect, not custom ones). A same-hosthttp->httpsupgrade is followed; a same-hosthttps->httpdowngrade is refused so the key never rides a cleartext hop, and a cross-host redirect is refused outright. The policy matches on host only, not port, so a same-host redirect to a different port is also followed. A caller-supplied client viaWithHTTPClientowns its own redirect policy. - Response bodies are size-capped before decoding (64 MB for list endpoints, 1 MB for single objects); an over-cap body is rejected as
*ResponseTooLargeErrorrather than truncated. - Clients own no long-lived goroutines and hold no locks a caller can observe; a single client is safe for concurrent use.
arrapi bounds every request by context and retries only transient failures:
- A caller-supplied context deadline is the authoritative total budget across all attempts and backoffs. It is honored as-is; arrapi imposes no separate client-level ceiling on top of it.
WithTimeout(default 120s) is a per-attempt budget, applied only when the caller's context carries no deadline of its own. The total is then bounded by the attempt count (WithMaxAttempts, default 3).- Retries cover transient failures: HTTP 429 and 5xx (honoring a capped
Retry-After), and transient transport errors (timeouts, connection resets, DNS). A 4xx other than 429 is permanent and fails fast. - A timeout or deadline expiry is terminal, not a retryable condition: once the budget is exhausted the call stops rather than retrying. Mutations (rescan/refresh commands) are single-attempt and never retried.
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. |
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.
GPL-3.0 — see LICENSE.