Files
arr-go-client/go_arr_tutorial.md
Michael Marquez 6240ed0b1f Add initial implementation of *arr service client
- Created `client.go` for the main client structure and methods to interact with *arr services.
- Added `types.go` to define data structures for system status, movies, and series.
- Implemented `radarr.go` for Radarr-specific client methods including health checks and movie retrieval.
- Introduced `interfaces.go` to define service interfaces for common operations across *arr services.
- Established a basic `main.go` for application entry point.
- Included a tutorial markdown file to guide users through building the client and understanding Go concepts.
- Initialized `go.mod` for module management.
- Organized code into appropriate packages for better structure and maintainability.
2025-08-21 18:52:15 -04:00

2732 lines
80 KiB
Markdown

# Learn Go by Building *arr Service Packages
This tutorial teaches Go fundamentals by building packages to interact with *arr services like Radarr, Sonarr, and Lidarr. You'll learn Go concepts incrementally while building real, useful code.
## Prerequisites
- Install Go from [golang.org](https://golang.org/dl/)
- Basic understanding of REST APIs
- A text editor (VS Code with Go extension recommended)
## Chapter 1: Go Basics and Project Setup
### 1.1 Understanding Go Modules
First, let's create our project structure:
```bash
mkdir arr-client
cd arr-client
go mod init github.com/yourusername/arr-client
```
**Deep Dive - Go Modules:**
Go modules are Go's dependency management system, similar to `package.json` in Node.js or `requirements.txt` in Python. When you run `go mod init`, Go creates a `go.mod` file that serves as the "root" of your module.
The module path (`github.com/yourusername/arr-client`) serves two purposes:
1. **Unique identifier**: It tells Go this is a distinct module
2. **Import path**: Other code will import your packages using this path
Think of it like a postal address for your code - it needs to be unique so Go can find and download it. Even if you never publish to GitHub, this convention helps organize your code.
### 1.2 Basic Go Syntax
Create `main.go`:
```go
package main
import "fmt"
func main() {
fmt.Println("Hello, *arr services!")
}
```
**Deep Dive - Program Structure:**
Let's break down each line:
1. **`package main`**: Every Go file must start with a package declaration. The `main` package is special - it tells Go this is an executable program (not a library). When Go builds your code, it looks for a `main` package and a `main` function as the entry point.
2. **`import "fmt"`**: This brings in the `fmt` (format) package from Go's standard library. The import statement is like `#include` in C++ or `import` in Python. Go's standard library is extensive and includes packages for HTTP clients, JSON parsing, file I/O, and much more.
3. **`func main()`**: This is the entry point of your program. When you run `go run main.go`, Go looks for this exact function. The `func` keyword declares a function, similar to `def` in Python or `function` in JavaScript.
4. **`fmt.Println("Hello, *arr services!")`**: This calls the `Println` function from the `fmt` package. The dot notation (`fmt.Println`) is how you access exported (public) functions from imported packages.
**Key Go Characteristics:**
- No semicolons needed (Go automatically inserts them)
- Curly braces `{}` define code blocks
- Capitalized names are exported (public), lowercase are unexported (private)
- Static typing (types are checked at compile time)
Run it: `go run main.go`
## Chapter 2: Structs and JSON - Building Data Models
### 2.1 Defining Data Structures
Create `types.go`:
```go
package main
import "time"
// SystemStatus represents the system status response
type SystemStatus struct {
Version string `json:"version"`
BuildTime string `json:"buildTime"`
IsDebug bool `json:"isDebug"`
IsProduction bool `json:"isProduction"`
IsAdmin bool `json:"isAdmin"`
IsUserInteractive bool `json:"isUserInteractive"`
StartupPath string `json:"startupPath"`
AppData string `json:"appData"`
OsName string `json:"osName"`
OsVersion string `json:"osVersion"`
}
// Movie represents a movie in Radarr
type Movie struct {
ID int `json:"id"`
Title string `json:"title"`
Year int `json:"year"`
Path string `json:"path"`
Monitored bool `json:"monitored"`
Added time.Time `json:"added"`
QualityProfileID int `json:"qualityProfileId"`
ImdbID string `json:"imdbId"`
TmdbID int `json:"tmdbId"`
}
// Series represents a TV series in Sonarr
type Series struct {
ID int `json:"id"`
Title string `json:"title"`
Year int `json:"year"`
Path string `json:"path"`
Monitored bool `json:"monitored"`
Added time.Time `json:"added"`
QualityProfileID int `json:"qualityProfileId"`
TvdbID int `json:"tvdbId"`
Seasons []Season `json:"seasons"`
}
// Season represents a season within a series
type Season struct {
SeasonNumber int `json:"seasonNumber"`
Monitored bool `json:"monitored"`
}
```
**Deep Dive - Structs:**
Structs are Go's way of creating custom types by grouping related data together. Think of them like classes in other languages, but without methods (we'll add those later).
**Anatomy of a struct:**
```go
type Movie struct {
ID int `json:"id"`
Title string `json:"title"`
}
```
1. **`type Movie struct`**: Declares a new type named `Movie` that's a struct
2. **Field declarations**: Each line inside declares a field with a name and type
3. **Struct tags**: The backtick strings (`json:"id"`) are metadata
**Why structs matter for our API client:**
When the *arr services return JSON like this:
```json
{
"id": 123,
"title": "The Matrix",
"year": 1999,
"monitored": true
}
```
Go needs to know where to put each piece of data. Structs provide this mapping.
**Struct Tags Deep Dive:**
```go
Title string `json:"title"`
```
The `json:"title"` tag tells Go's JSON package:
- When converting FROM JSON: Look for a JSON field named "title" and put it in the `Title` field
- When converting TO JSON: Take the `Title` field and create a JSON field named "title"
This is crucial because Go convention is PascalCase (`Title`) but JSON often uses camelCase (`title`) or snake_case (`title_name`).
**Field Visibility Rules:**
- `Title` (capitalized): Exported - other packages can access it
- `title` (lowercase): Unexported - only this package can access it
For JSON marshaling to work, fields must be exported (capitalized).
**Composite Types:**
```go
Seasons []Season `json:"seasons"`
```
This demonstrates Go's type system:
- `[]Season`: A slice (dynamic array) of `Season` structs
- Go supports slices, maps, pointers, and other composite types
- The JSON tag tells Go to expect an array in the JSON
**Connection to Previous Example:**
Notice we're still in `package main` - this means these types are in the same package as our `main.go`. Later, we'll organize these into separate packages for better structure.
## Chapter 3: HTTP Client and Error Handling - Making API Calls
### 3.1 Basic HTTP Client
Create `client.go`:
```go
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// Client represents an *arr service client
type Client struct {
BaseURL string
APIKey string
HTTPClient *http.Client
}
// NewClient creates a new *arr service client
func NewClient(baseURL, apiKey string) *Client {
return &Client{
BaseURL: baseURL,
APIKey: apiKey,
HTTPClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// GetSystemStatus fetches system status from the service
func (c *Client) GetSystemStatus() (*SystemStatus, error) {
url := fmt.Sprintf("%s/api/v3/system/status", c.BaseURL)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("X-Api-Key", c.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("making request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response body: %w", err)
}
var status SystemStatus
if err := json.Unmarshal(body, &status); err != nil {
return nil, fmt.Errorf("unmarshaling JSON: %w", err)
}
return &status, nil
}
```
**Deep Dive - Pointers and Memory Management:**
Let's understand the pointer syntax that might seem confusing:
**Pointer Basics:**
```go
// Without pointer - creates a copy
func badNewClient() Client {
return Client{BaseURL: "http://localhost"}
}
// With pointer - creates one instance, returns address
func NewClient() *Client {
return &Client{BaseURL: "http://localhost"}
}
```
**Why use pointers here?**
1. **Efficiency**: Structs can be large. Copying a `Client` struct every time would be expensive
2. **Shared state**: Multiple parts of your code can reference the same client instance
3. **Method receivers**: Methods can modify the struct they're called on
**The `&` and `*` operators:**
```go
client := &Client{} // & means "address of" - get pointer to new Client
var ptr *Client // * in declaration means "pointer to Client type"
value := *ptr // * in expression means "dereference" - get value at pointer
```
**Deep Dive - Constructor Pattern:**
```go
func NewClient(baseURL, apiKey string) *Client {
return &Client{
BaseURL: baseURL,
APIKey: apiKey,
HTTPClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
```
This is Go's constructor pattern:
1. **Function naming**: `NewType` or `New` for constructors
2. **Initialization**: Sets up sensible defaults (like timeout)
3. **Encapsulation**: Hides the complexity of setting up the HTTP client
4. **Returns pointer**: So multiple callers can share the same instance
**Connection to Previous Example:**
Notice how we're using the `SystemStatus` struct we defined earlier. The HTTP client fetches JSON data and uses our struct to organize it.
**Deep Dive - Method Receivers:**
```go
func (c *Client) GetSystemStatus() (*SystemStatus, error)
```
This is a method, not a regular function:
1. **`(c *Client)`**: The receiver - this method "belongs to" the Client type
2. **`c`**: The receiver name (like `self` in Python or `this` in JavaScript)
3. **`*Client`**: Pointer receiver - can modify the client if needed
**Why pointer receiver?**
```go
// Pointer receiver - can modify, avoids copying
func (c *Client) SetTimeout(d time.Duration) {
c.HTTPClient.Timeout = d // This works!
}
// Value receiver - gets a copy, can't modify original
func (c Client) SetTimeout(d time.Duration) {
c.HTTPClient.Timeout = d // This modifies the copy, not original!
}
```
**Deep Dive - Go's Error Handling Philosophy:**
Go's error handling is explicit and verbose by design:
```go
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("making request: %w", err)
}
```
**Why this pattern?**
1. **Explicit**: You can't ignore errors (compiler forces you to handle them)
2. **Predictable**: Every function that can fail returns `(result, error)`
3. **Composable**: You can wrap errors to add context
**Error Wrapping:**
```go
fmt.Errorf("making request: %w", err)
```
The `%w` verb wraps the original error, creating an error chain. This means:
- You get context about where the error happened
- You can still check the original error type if needed
- Error messages become more informative
**The `defer` Statement:**
```go
defer resp.Body.Close()
```
`defer` schedules a function call to happen when the current function returns:
1. **Cleanup guarantee**: Even if your function panics, deferred calls run
2. **LIFO order**: Multiple defers run in Last-In-First-Out order
3. **Common pattern**: Always defer cleanup operations after checking for errors
**Deep Dive - JSON Unmarshaling:**
```go
var status SystemStatus
if err := json.Unmarshal(body, &status); err != nil {
return nil, fmt.Errorf("unmarshaling JSON: %w", err)
}
```
This is where our struct definition pays off:
1. **`json.Unmarshal`**: Converts JSON bytes to Go structs
2. **`&status`**: We pass a pointer so `Unmarshal` can modify our struct
3. **Struct tags**: The `json:"version"` tags tell `Unmarshal` where to put each field
**Connection to Previous Examples:**
This builds directly on Chapter 2's struct definitions. The HTTP client fetches raw JSON, then uses our carefully designed structs to organize that data into usable Go objects.
## Chapter 4: Interfaces and Abstraction - Building Flexible APIs
### 4.1 Creating Service Interfaces
Create `interfaces.go`:
```go
package main
// ServiceClient defines the interface all *arr services must implement
type ServiceClient interface {
GetSystemStatus() (*SystemStatus, error)
GetHealth() ([]HealthCheck, error)
}
// MovieService defines movie-specific operations (Radarr)
type MovieService interface {
ServiceClient
GetMovies() ([]Movie, error)
GetMovie(id int) (*Movie, error)
AddMovie(movie *Movie) (*Movie, error)
}
// SeriesService defines TV series operations (Sonarr)
type SeriesService interface {
ServiceClient
GetSeries() ([]Series, error)
GetSeriesById(id int) (*Series, error)
AddSeries(series *Series) (*Series, error)
}
// HealthCheck represents a health check result
type HealthCheck struct {
Source string `json:"source"`
Type string `json:"type"`
Message string `json:"message"`
}
```
**Deep Dive - Interface Philosophy in Go:**
Interfaces in Go are fundamentally different from other languages:
**1. Implicit Satisfaction:**
```go
// You DON'T write this (like in Java/C#):
type RadarrClient struct implements MovieService {
// ...
}
// Instead, if RadarrClient has all the required methods,
// it automatically satisfies the interface
```
**2. Behavior Definition:**
Interfaces define what something can DO, not what it IS:
```go
// Bad: Defining what something is
type DatabaseConnection interface {
Connect()
GetConnectionString() string
}
// Good: Defining what something does
type DataStore interface {
Save(data []byte) error
Load(id string) ([]byte, error)
}
```
**Deep Dive - Interface Embedding:**
```go
type MovieService interface {
ServiceClient // Embedded interface
GetMovies() ([]Movie, error)
// ... more methods
}
```
Interface embedding means `MovieService` includes all methods from `ServiceClient` PLUS its own methods. Any type that satisfies `MovieService` must implement:
- `GetSystemStatus()` (from ServiceClient)
- `GetHealth()` (from ServiceClient)
- `GetMovies()` (from MovieService)
- `GetMovie()` (from MovieService)
- `AddMovie()` (from MovieService)
**Why Design This Way?**
This interface hierarchy models the real-world relationship:
1. **All *arr services** have common operations (status, health)
2. **Movie services** have movie-specific operations
3. **Series services** have series-specific operations
**Connection to Previous Examples:**
Notice how the interface methods match the method we implemented in `client.go`. Our `Client` struct from Chapter 3 partially satisfies `ServiceClient` (it has `GetSystemStatus` but we haven't implemented `GetHealth` yet).
### 4.2 Implementing Specific Services
Create `radarr.go`:
```go
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
)
// RadarrClient implements the MovieService interface
type RadarrClient struct {
*Client // Embedded struct
}
// NewRadarrClient creates a new Radarr client
func NewRadarrClient(baseURL, apiKey string) *RadarrClient {
return &RadarrClient{
Client: NewClient(baseURL, apiKey),
}
}
// GetHealth implements ServiceClient interface
func (r *RadarrClient) GetHealth() ([]HealthCheck, error) {
url := fmt.Sprintf("%s/api/v3/health", r.BaseURL)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("X-Api-Key", r.APIKey)
resp, err := r.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("making request: %w", err)
}
defer resp.Body.Close()
var health []HealthCheck
if err := json.NewDecoder(resp.Body).Decode(&health); err != nil {
return nil, fmt.Errorf("decoding response: %w", err)
}
return health, nil
}
// GetMovies retrieves all movies
func (r *RadarrClient) GetMovies() ([]Movie, error) {
url := fmt.Sprintf("%s/api/v3/movie", r.BaseURL)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("X-Api-Key", r.APIKey)
resp, err := r.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("making request: %w", err)
}
defer resp.Body.Close()
var movies []Movie
if err := json.NewDecoder(resp.Body).Decode(&movies); err != nil {
return nil, fmt.Errorf("decoding response: %w", err)
}
return movies, nil
}
// GetMovie retrieves a specific movie by ID
func (r *RadarrClient) GetMovie(id int) (*Movie, error) {
url := fmt.Sprintf("%s/api/v3/movie/%s", r.BaseURL, strconv.Itoa(id))
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("X-Api-Key", r.APIKey)
resp, err := r.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("making request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("movie with ID %d not found", id)
}
var movie Movie
if err := json.NewDecoder(resp.Body).Decode(&movie); err != nil {
return nil, fmt.Errorf("decoding response: %w", err)
}
return &movie, nil
}
// AddMovie adds a new movie (simplified - real implementation needs more fields)
func (r *RadarrClient) AddMovie(movie *Movie) (*Movie, error) {
// Implementation would POST to /api/v3/movie
// This is left as an exercise - involves JSON marshaling and POST requests
return nil, fmt.Errorf("AddMovie not implemented yet")
}
```
**Deep Dive - Struct Embedding:**
```go
type RadarrClient struct {
*Client // Embedded struct
}
```
This is composition, not inheritance. Here's what happens:
**Field Promotion:**
```go
radarr := NewRadarrClient("http://localhost:7878", "api-key")
// These all work because Client fields are "promoted":
fmt.Println(radarr.BaseURL) // Promoted from embedded Client
fmt.Println(radarr.APIKey) // Promoted from embedded Client
radarr.HTTPClient.Timeout = 60 // Direct access to embedded fields
```
**Method Promotion:**
```go
// RadarrClient automatically gets all Client methods:
status, err := radarr.GetSystemStatus() // This method is promoted from Client!
```
**Why Use Embedding Instead of Composition?**
Compare these approaches:
```go
// Composition (verbose)
type RadarrClient struct {
client *Client
}
func (r *RadarrClient) GetSystemStatus() (*SystemStatus, error) {
return r.client.GetSystemStatus() // Must delegate manually
}
// Embedding (automatic)
type RadarrClient struct {
*Client
}
// GetSystemStatus is automatically available!
```
**Connection to Previous Examples:**
The `RadarrClient` reuses our `Client` from Chapter 3, but adds movie-specific functionality. Notice how `NewRadarrClient` calls our `NewClient` constructor - we're building on previous work.
**Deep Dive - Alternative JSON Decoding:**
```go
// Chapter 3 approach - read all, then unmarshal
body, err := io.ReadAll(resp.Body)
var status SystemStatus
json.Unmarshal(body, &status)
// This chapter's approach - stream decode
var health []HealthCheck
json.NewDecoder(resp.Body).Decode(&health)
```
**When to use each:**
- `json.Unmarshal`: When you need to inspect/modify the raw JSON
- `json.NewDecoder`: When streaming directly from an io.Reader (more efficient for large responses)
**Deep Dive - String Conversion:**
```go
strconv.Itoa(id) // Integer to ASCII
```
Go is strict about types - you can't just concatenate an `int` with a `string`. `strconv.Itoa()` converts integers to strings. Other useful conversions:
```go
strconv.Atoi("123") // String to int
strconv.ParseBool("true") // String to bool
strconv.FormatFloat(3.14, 'f', 2, 64) // Float to string
```
**Interface Satisfaction Check:**
At this point, `RadarrClient` satisfies the `MovieService` interface because it has:
- `GetSystemStatus()` (promoted from embedded `Client`)
- `GetHealth()` (implemented in this chapter)
- `GetMovies()` (implemented in this chapter)
- `GetMovie()` (implemented in this chapter)
- `AddMovie()` (stub implementation)
## Chapter 5: Packages and Organization - Scaling Your Codebase
### 5.1 Understanding the Problem
Right now, all our code is in `package main`. This works for small programs, but creates problems as code grows:
1. **Single namespace**: All types/functions compete for names
2. **No encapsulation**: Everything is visible to everything else
3. **Hard to test**: Can't import `main` package in tests
4. **Monolithic**: Changes anywhere affect everything
### 5.2 Restructuring into Packages
Let's reorganize into proper packages:
```
arr-client/
├── go.mod
├── main.go
├── pkg/
│ ├── client/
│ │ ├── client.go
│ │ └── interfaces.go
│ ├── radarr/
│ │ └── radarr.go
│ ├── sonarr/
│ │ └── sonarr.go
│ └── types/
│ └── types.go
└── examples/
└── basic_usage.go
```
**Deep Dive - Package Organization Philosophy:**
**The `pkg/` directory:**
This is a Go convention meaning "library code that's safe for others to import." Not required, but signals intent.
**Package naming principles:**
1. **Short, clear names**: `types`, `client`, `radarr` (not `radarr_client_implementation`)
2. **No underscores**: Go prefers `httputil` over `http_util`
3. **Singular nouns**: `client` not `clients`
4. **Domain-focused**: Each package has a single, clear purpose
`pkg/types/types.go`:
```go
package types
import "time"
// SystemStatus represents system status for any *arr service
type SystemStatus struct {
Version string `json:"version"`
BuildTime string `json:"buildTime"`
IsDebug bool `json:"isDebug"`
IsProduction bool `json:"isProduction"`
IsAdmin bool `json:"isAdmin"`
IsUserInteractive bool `json:"isUserInteractive"`
StartupPath string `json:"startupPath"`
AppData string `json:"appData"`
OsName string `json:"osName"`
OsVersion string `json:"osVersion"`
}
// Movie represents a movie (Radarr)
type Movie struct {
ID int `json:"id"`
Title string `json:"title"`
Year int `json:"year"`
Path string `json:"path"`
Added time.Time `json:"added"`
Monitored bool `json:"monitored"`
QualityProfileID int `json:"qualityProfileId"`
ImdbID string `json:"imdbId"`
TmdbID int `json:"tmdbId"`
}
// Series represents a TV series (Sonarr)
type Series struct {
ID int `json:"id"`
Title string `json:"title"`
Year int `json:"year"`
Path string `json:"path"`
Monitored bool `json:"monitored"`
Added time.Time `json:"added"`
QualityProfileID int `json:"qualityProfileId"`
TvdbID int `json:"tvdbId"`
Seasons []Season `json:"seasons"`
}
// Season represents a season within a series
type Season struct {
SeasonNumber int `json:"seasonNumber"`
Monitored bool `json:"monitored"`
}
// HealthCheck represents a health check result
type HealthCheck struct {
Source string `json:"source"`
Type string `json:"type"`
Message string `json:"message"`
}
```
**Deep Dive - Why a Separate Types Package?**
This prevents circular imports:
```go
// Without types package - CIRCULAR IMPORT!
package radarr
import "myproject/sonarr" // Radarr imports Sonarr
package sonarr
import "myproject/radarr" // Sonarr imports Radarr - CIRCULAR!
// With types package - NO CIRCULAR IMPORT
package radarr
import "myproject/types" // Radarr imports types
package sonarr
import "myproject/types" // Sonarr imports types - OK!
```
`pkg/client/interfaces.go`:
```go
package client
import "github.com/yourusername/arr-client/pkg/types"
// ServiceClient defines common operations for all *arr services
type ServiceClient interface {
GetSystemStatus() (*types.SystemStatus, error)
GetHealth() ([]types.HealthCheck, error)
}
// MovieService defines movie-specific operations
type MovieService interface {
ServiceClient
GetMovies() ([]types.Movie, error)
GetMovie(id int) (*types.Movie, error)
AddMovie(movie *types.Movie) (*types.Movie, error)
}
// SeriesService defines series-specific operations
type SeriesService interface {
ServiceClient
GetSeries() ([]types.Series, error)
GetSeriesById(id int) (*types.Series, error)
AddSeries(series *types.Series) (*types.Series, error)
}
```
`pkg/client/client.go`:
```go
package client
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/yourusername/arr-client/pkg/types"
)
// BaseClient provides common HTTP client functionality
type BaseClient struct {
BaseURL string
APIKey string
HTTPClient *http.Client
}
// NewBaseClient creates a new base client
func NewBaseClient(baseURL, apiKey string) *BaseClient {
return &BaseClient{
BaseURL: baseURL,
APIKey: apiKey,
HTTPClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// GetSystemStatus fetches system status from the service
func (c *BaseClient) GetSystemStatus() (*types.SystemStatus, error) {
url := fmt.Sprintf("%s/api/v3/system/status", c.BaseURL)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("X-Api-Key", c.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("making request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response body: %w", err)
}
var status types.SystemStatus
if err := json.Unmarshal(body, &status); err != nil {
return nil, fmt.Errorf("unmarshaling JSON: %w", err)
}
return &status, nil
}
```
**Deep Dive - Import Paths:**
```go
import "github.com/yourusername/arr-client/pkg/types"
```
This import path is built from:
1. **Module path**: `github.com/yourusername/arr-client` (from go.mod)
2. **Package path**: `pkg/types` (directory structure)
Go uses this to find the package, whether it's:
- Local (in your project)
- Downloaded from GitHub
- In your GOPATH (older Go versions)
**Connection to Previous Examples:**
We've taken all the code from Chapters 1-4 and organized it properly. The functionality is the same, but now it's maintainable and reusable.
**Deep Dive - Exported vs Unexported Names:**
```go
// In pkg/types/types.go
type Movie struct { // Exported - other packages can use
ID int // Exported field
title string // Unexported - only types package can access
}
func NewMovie() *Movie { } // Exported function
func validateTitle() bool {} // Unexported function
```
**Visibility Rules:**
- **Same package**: Can access everything (exported and unexported)
- **Different package**: Can only access exported names (capitalized)
This is Go's encapsulation mechanism - much simpler than `public/private/protected` keywords.
`pkg/radarr/radarr.go`:
```go
package radarr
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/yourusername/arr-client/pkg/client"
"github.com/yourusername/arr-client/pkg/types"
)
// Client implements the MovieService interface for Radarr
type Client struct {
*client.BaseClient // Embedded from client package
}
// NewClient creates a new Radarr client
func NewClient(baseURL, apiKey string) *Client {
return &Client{
BaseClient: client.NewBaseClient(baseURL, apiKey),
}
}
// GetHealth implements ServiceClient interface
func (r *Client) GetHealth() ([]types.HealthCheck, error) {
url := fmt.Sprintf("%s/api/v3/health", r.BaseURL)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("X-Api-Key", r.APIKey)
resp, err := r.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("making request: %w", err)
}
defer resp.Body.Close()
var health []types.HealthCheck
if err := json.NewDecoder(resp.Body).Decode(&health); err != nil {
return nil, fmt.Errorf("decoding response: %w", err)
}
return health, nil
}
// GetMovies retrieves all movies
func (r *Client) GetMovies() ([]types.Movie, error) {
url := fmt.Sprintf("%s/api/v3/movie", r.BaseURL)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("X-Api-Key", r.APIKey)
resp, err := r.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("making request: %w", err)
}
defer resp.Body.Close()
var movies []types.Movie
if err := json.NewDecoder(resp.Body).Decode(&movies); err != nil {
return nil, fmt.Errorf("decoding response: %w", err)
}
return movies, nil
}
// GetMovie retrieves a specific movie by ID
func (r *Client) GetMovie(id int) (*types.Movie, error) {
url := fmt.Sprintf("%s/api/v3/movie/%s", r.BaseURL, strconv.Itoa(id))
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("X-Api-Key", r.APIKey)
resp, err := r.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("making request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("movie with ID %d not found", id)
}
var movie types.Movie
if err := json.NewDecoder(resp.Body).Decode(&movie); err != nil {
return nil, fmt.Errorf("decoding response: %w", err)
}
return &movie, nil
}
// AddMovie adds a new movie
func (r *Client) AddMovie(movie *types.Movie) (*types.Movie, error) {
// Implementation would POST to /api/v3/movie
// This is left as an exercise
return nil, fmt.Errorf("AddMovie not implemented yet")
}
```
**Connection to Previous Examples:**
Notice how the `radarr.Client` now embeds `client.BaseClient` instead of the old `Client` type. We've separated concerns: `client` package handles common HTTP operations, `radarr` package handles Radarr-specific logic.
**Deep Dive - Import Organization:**
Go has conventions for organizing imports:
```go
import (
// Standard library first
"encoding/json"
"fmt"
"net/http"
// Third-party packages next
"github.com/gin-gonic/gin"
// Your own packages last
"github.com/yourusername/arr-client/pkg/client"
"github.com/yourusername/arr-client/pkg/types"
)
```
Most editors automatically organize imports this way.
## Chapter 6: Testing - Ensuring Code Quality
### 6.1 Understanding Go Testing Philosophy
Go's testing philosophy is simple:
1. **Tests live alongside code**: `client.go` and `client_test.go` in same package
2. **Minimal framework**: Just functions that start with `Test`
3. **Table-driven tests**: Test multiple scenarios with data structures
4. **Fail fast**: Tests should be fast and give clear feedback
### 6.2 Writing Unit Tests
Create `pkg/radarr/radarr_test.go`:
```go
package radarr
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/yourusername/arr-client/pkg/types"
)
func TestClient_GetSystemStatus(t *testing.T) {
// Create a test server that mimics Radarr's API
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify the request is correct
if r.URL.Path != "/api/v3/system/status" {
t.Errorf("Expected path /api/v3/system/status, got %s", r.URL.Path)
}
if r.Header.Get("X-Api-Key") != "test-api-key" {
t.Errorf("Expected API key header to be 'test-api-key'")
}
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("Expected Content-Type header to be 'application/json'")
}
// Return mock response that matches what real Radarr would return
status := types.SystemStatus{
Version: "4.0.0.5831",
BuildTime: "2023-01-15T14:30:26Z",
IsDebug: false,
IsProduction: true,
IsAdmin: true,
OsName: "ubuntu",
OsVersion: "20.04",
}
// Send JSON response
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(status)
}))
defer server.Close() // Clean up when test finishes
// Create client with test server URL
client := NewClient(server.URL, "test-api-key")
// Test the method
status, err := client.GetSystemStatus()
if err != nil {
t.Fatalf("GetSystemStatus failed: %v", err)
}
// Verify results
if status.Version != "4.0.0.5831" {
t.Errorf("Expected version 4.0.0.5831, got %s", status.Version)
}
if status.IsProduction != true {
t.Errorf("Expected IsProduction to be true, got %v", status.IsProduction)
}
if status.OsName != "ubuntu" {
t.Errorf("Expected OsName 'ubuntu', got %s", status.OsName)
}
}
func TestClient_GetMovies(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify correct endpoint
if r.URL.Path != "/api/v3/movie" {
t.Errorf("Expected path /api/v3/movie, got %s", r.URL.Path)
}
// Mock response with multiple movies
movies := []types.Movie{
{
ID: 1,
Title: "The Matrix",
Year: 1999,
Path: "/movies/The Matrix (1999)",
Monitored: true,
Added: time.Date(2023, 1, 15, 14, 30, 0, 0, time.UTC),
ImdbID: "tt0133093",
TmdbID: 603,
},
{
ID: 2,
Title: "Inception",
Year: 2010,
Path: "/movies/Inception (2010)",
Monitored: true,
Added: time.Date(2023, 1, 16, 10, 0, 0, 0, time.UTC),
ImdbID: "tt1375666",
TmdbID: 27205,
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(movies)
}))
defer server.Close()
client := NewClient(server.URL, "test-api-key")
movies, err := client.GetMovies()
if err != nil {
t.Fatalf("GetMovies failed: %v", err)
}
// Test the slice length
if len(movies) != 2 {
t.Errorf("Expected 2 movies, got %d", len(movies))
}
// Test specific movie data
if movies[0].Title != "The Matrix" {
t.Errorf("Expected first movie 'The Matrix', got %s", movies[0].Title)
}
if movies[0].Year != 1999 {
t.Errorf("Expected first movie year 1999, got %d", movies[0].Year)
}
if movies[1].Title != "Inception" {
t.Errorf("Expected second movie 'Inception', got %s", movies[1].Title)
}
}
func TestClient_GetMovie_NotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Simulate 404 response
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
client := NewClient(server.URL, "test-api-key")
movie, err := client.GetMovie(999)
// Should return error for 404
if err == nil {
t.Fatal("Expected error for 404 response, got nil")
}
// Should not return a movie
if movie != nil {
t.Errorf("Expected nil movie for 404, got %+v", movie)
}
// Check error message
expectedMsg := "movie with ID 999 not found"
if err.Error() != expectedMsg {
t.Errorf("Expected error '%s', got '%s'", expectedMsg, err.Error())
}
}
```
**Deep Dive - httptest Package:**
```go
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Handle request
}))
defer server.Close()
```
`httptest` creates a real HTTP server for testing:
1. **Real HTTP**: Your code makes actual HTTP calls (not mocked)
2. **Controllable**: You control exactly what the server returns
3. **Isolated**: Each test gets its own server
4. **Fast**: Runs in-memory, no network calls
**Connection to Previous Examples:**
We're testing the `radarr.Client` methods we built in previous chapters. The test server mimics what a real Radarr API would return, letting us verify our HTTP client code works correctly.
**Deep Dive - Test Function Naming:**
```go
func TestClient_GetSystemStatus(t *testing.T)
func TestClient_GetMovies(t *testing.T)
func TestClient_GetMovie_NotFound(t *testing.T)
```
Go test naming convention:
- `Test` + what you're testing + specific scenario
- `TestClient_GetMovies` = testing the `GetMovies` method on `Client`
- `TestClient_GetMovie_NotFound` = testing `GetMovie` when movie isn't found
**Deep Dive - Error Testing Patterns:**
```go
// Testing for expected errors
movie, err := client.GetMovie(999)
if err == nil {
t.Fatal("Expected error for 404 response, got nil")
}
// Testing for no errors when success expected
status, err := client.GetSystemStatus()
if err != nil {
t.Fatalf("GetSystemStatus failed: %v", err)
}
```
**`t.Errorf` vs `t.Fatalf`:**
- `t.Errorf`: Log error but continue test (for multiple assertions)
- `t.Fatalf`: Log error and stop test immediately (when further testing is pointless)
### 6.3 Table-Driven Tests
Let's add a more sophisticated test using Go's table-driven testing pattern:
```go
func TestClient_GetMovie_Success(t *testing.T) {
// Test cases as a slice of structs
testCases := []struct {
name string
movieID int
expectedTitle string
expectedYear int
expectedImdbID string
}{
{
name: "The Matrix",
movieID: 1,
expectedTitle: "The Matrix",
expectedYear: 1999,
expectedImdbID: "tt0133093",
},
{
name: "Inception",
movieID: 2,
expectedTitle: "Inception",
expectedYear: 2010,
expectedImdbID: "tt1375666",
},
{
name: "Interstellar",
movieID: 3,
expectedTitle: "Interstellar",
expectedYear: 2014,
expectedImdbID: "tt0816692",
},
}
// Run each test case
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Create response based on requested ID
movie := types.Movie{
ID: tc.movieID,
Title: tc.expectedTitle,
Year: tc.expectedYear,
ImdbID: tc.expectedImdbID,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(movie)
}))
defer server.Close()
client := NewClient(server.URL, "test-api-key")
movie, err := client.GetMovie(tc.movieID)
if err != nil {
t.Fatalf("GetMovie failed: %v", err)
}
if movie.Title != tc.expectedTitle {
t.Errorf("Expected title %s, got %s", tc.expectedTitle, movie.Title)
}
if movie.Year != tc.expectedYear {
t.Errorf("Expected year %d, got %d", tc.expectedYear, movie.Year)
}
if movie.ImdbID != tc.expectedImdbID {
t.Errorf("Expected IMDB ID %s, got %s", tc.expectedImdbID, movie.ImdbID)
}
})
}
}
```
**Deep Dive - Table-Driven Tests:**
This pattern is idiomatic Go:
1. **Test data in structs**: Each test case is a struct with inputs and expected outputs
2. **Loop through cases**: `for _, tc := range testCases`
3. **Subtests**: `t.Run(tc.name, func(t *testing.T) {...})` creates individual subtests
4. **Clear failure reporting**: When a test fails, you know exactly which case failed
**Benefits:**
- Easy to add new test cases (just add to slice)
- Reduces code duplication
- Clear separation of test data and test logic
- Individual subtests can pass/fail independently
Run tests: `go test ./pkg/radarr/`
**Test output will look like:**
```
=== RUN TestClient_GetMovie_Success
=== RUN TestClient_GetMovie_Success/The_Matrix
=== RUN TestClient_GetMovie_Success/Inception
=== RUN TestClient_GetMovie_Success/Interstellar
--- PASS: TestClient_GetMovie_Success (0.00s)
--- PASS: TestClient_GetMovie_Success/The_Matrix (0.00s)
--- PASS: TestClient_GetMovie_Success/Inception (0.00s)
--- PASS: TestClient_GetMovie_Success/Interstellar (0.00s)
```
**Connection to Previous Examples:**
These tests validate all the HTTP client code we built in Chapters 3-5. We're testing not just that the code runs, but that it correctly parses responses, handles errors, and returns the right data structures.
## Chapter 7: Error Handling and Logging - Building Robust Applications
### 7.1 Understanding Go's Error Philosophy
Go treats errors as values, not exceptions. This has profound implications:
```go
// Not Go - exceptions (Java/Python style)
try {
result = dangerousOperation()
} catch (Exception e) {
handleError(e)
}
// Go - errors are values
result, err := dangerousOperation()
if err != nil {
return handleError(err)
}
```
**Why this approach?**
1. **Explicit**: You can't ignore errors
2. **Performance**: No stack unwinding overhead
3. **Predictable**: Error handling is in your control flow
4. **Composable**: Errors can be wrapped and annotated
### 7.2 Custom Error Types
Create `pkg/client/errors.go`:
```go
package client
import (
"fmt"
"net/http"
)
// APIError represents an API-specific error with rich context
type APIError struct {
StatusCode int
Message string
Endpoint string
Method string
RequestID string // Some APIs provide request IDs for debugging
}
// Error implements the error interface
func (e *APIError) Error() string {
if e.RequestID != "" {
return fmt.Sprintf("API error %d at %s %s (request: %s): %s",
e.StatusCode, e.Method, e.Endpoint, e.RequestID, e.Message)
}
return fmt.Sprintf("API error %d at %s %s: %s",
e.StatusCode, e.Method, e.Endpoint, e.Message)
}
// IsTemporary returns true if the error might succeed if retried
func (e *APIError) IsTemporary() bool {
// 5xx errors are typically temporary (server issues)
// 429 (rate limited) is temporary
return e.StatusCode >= 500 || e.StatusCode == http.StatusTooManyRequests
}
// IsClientError returns true if error is due to client mistake (4xx)
func (e *APIError) IsClientError() bool {
return e.StatusCode >= 400 && e.StatusCode < 500
}
// IsServerError returns true if error is server-side (5xx)
func (e *APIError) IsServerError() bool {
return e.StatusCode >= 500
}
// ValidationError represents errors with input validation
type ValidationError struct {
Field string
Message string
Value interface{}
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error for field '%s': %s (value: %v)",
e.Field, e.Message, e.Value)
}
// Helper functions for error checking
func IsNotFound(err error) bool {
if apiErr, ok := err.(*APIError); ok {
return apiErr.StatusCode == http.StatusNotFound
}
return false
}
func IsUnauthorized(err error) bool {
if apiErr, ok := err.(*APIError); ok {
return apiErr.StatusCode == http.StatusUnauthorized
}
return false
}
func IsForbidden(err error) bool {
if apiErr, ok := err.(*APIError); ok {
return apiErr.StatusCode == http.StatusForbidden
}
return false
}
// IsTemporary checks if any error is temporary (not just APIError)
func IsTemporary(err error) bool {
// Check if it's our APIError
if apiErr, ok := err.(*APIError); ok {
return apiErr.IsTemporary()
}
// Check if it implements temporary interface (net package does this)
if tempErr, ok := err.(interface{ Temporary() bool }); ok {
return tempErr.Temporary()
}
return false
}
```
**Deep Dive - Custom Error Types:**
```go
type APIError struct {
StatusCode int
Message string
Endpoint string
Method string
RequestID string
}
func (e *APIError) Error() string {
return fmt.Sprintf("API error %d at %s %s: %s",
e.StatusCode, e.Method, e.Endpoint, e.Message)
}
```
**Why create custom error types?**
1. **Rich context**: More information than just a string
2. **Type-safe checking**: `IsNotFound(err)` instead of string matching
3. **Behavior methods**: `IsTemporary()` tells you if you should retry
4. **Structured logging**: Error fields can be logged separately
**The error interface:**
```go
type error interface {
Error() string
}
```
Any type with an `Error() string` method satisfies this interface. This is why our custom types work as errors.
**Deep Dive - Type Assertions for Error Checking:**
```go
func IsNotFound(err error) bool {
if apiErr, ok := err.(*APIError); ok {
return apiErr.StatusCode == http.StatusNotFound
}
return false
}
```
This uses Go's type assertion:
1. **`err.(*APIError)`**: Try to convert `err` to `*APIError`
2. **`ok`**: Boolean indicating if conversion succeeded
3. **`apiErr`**: The converted value (if ok is true)
**Connection to Previous Examples:**
Instead of just returning `fmt.Errorf("unexpected status code: %d", resp.StatusCode)`, we can now return rich `APIError` instances with all the context needed for debugging.
### 7.3 Enhanced HTTP Client with Better Error Handling
Let's update `pkg/client/client.go` to use our custom errors:
```go
package client
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
"github.com/yourusername/arr-client/pkg/types"
)
// BaseClient provides common HTTP client functionality with enhanced error handling
type BaseClient struct {
BaseURL string
APIKey string
HTTPClient *http.Client
Logger Logger
}
// Logger interface allows pluggable logging
type Logger interface {
Printf(format string, v ...interface{})
Println(v ...interface{})
}
// NewBaseClient creates a new base client with sensible defaults
func NewBaseClient(baseURL, apiKey string) *BaseClient {
return &BaseClient{
BaseURL: baseURL,
APIKey: apiKey,
HTTPClient: &http.Client{
Timeout: 30 * time.Second,
},
Logger: log.New(os.Stdout, "[ARR-CLIENT] ", log.LstdFlags),
}
}
// SetLogger allows customizing the logger
func (c *BaseClient) SetLogger(logger Logger) {
c.Logger = logger
}
// SetTimeout allows customizing the HTTP timeout
func (c *BaseClient) SetTimeout(timeout time.Duration) {
c.HTTPClient.Timeout = timeout
}
// doRequest performs an HTTP request with comprehensive error handling
func (c *BaseClient) doRequest(method, endpoint string, body io.Reader) (*http.Response, error) {
url := fmt.Sprintf("%s%s", c.BaseURL, endpoint)
c.Logger.Printf("Making %s request to %s", method, url)
req, err := http.NewRequest(method, url, body)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
// Set required headers
req.Header.Set("X-Api-Key", c.APIKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "arr-client/1.0")
start := time.Now()
resp, err := c.HTTPClient.Do(req)
duration := time.Since(start)
if err != nil {
c.Logger.Printf("Request failed after %v: %v", duration, err)
return nil, fmt.Errorf("making request: %w", err)
}
c.Logger.Printf("Request completed in %v with status %d", duration, resp.StatusCode)
// Check for API errors
if resp.StatusCode >= 400 {
defer resp.Body.Close()
// Try to read error message from response
body, _ := io.ReadAll(resp.Body)
message := string(body)
if message == "" {
message = http.StatusText(resp.StatusCode)
}
return nil, &APIError{
StatusCode: resp.StatusCode,
Message: message,
Endpoint: endpoint,
Method: method,
RequestID: resp.Header.Get("X-Request-ID"), // Some APIs provide this
}
}
return resp, nil
}
// GetSystemStatus fetches system status with enhanced error handling
func (c *BaseClient) GetSystemStatus() (*types.SystemStatus, error) {
resp, err := c.doRequest("GET", "/api/v3/system/status", nil)
if err != nil {
return nil, fmt.Errorf("getting system status: %w", err)
}
defer resp.Body.Close()
var status types.SystemStatus
if err := json.NewDecoder(resp.Body).Decode(&status); err != nil {
return nil, fmt.Errorf("decoding system status response: %w", err)
}
c.Logger.Printf("System status retrieved: version %s", status.Version)
return &status, nil
}
// GetHealth fetches health status
func (c *BaseClient) GetHealth() ([]types.HealthCheck, error) {
resp, err := c.doRequest("GET", "/api/v3/health", nil)
if err != nil {
return nil, fmt.Errorf("getting health status: %w", err)
}
defer resp.Body.Close()
var health []types.HealthCheck
if err := json.NewDecoder(resp.Body).Decode(&health); err != nil {
return nil, fmt.Errorf("decoding health response: %w", err)
}
c.Logger.Printf("Health check retrieved: %d checks", len(health))
return health, nil
}
```
**Deep Dive - Refactoring for Reusability:**
The `doRequest` method eliminates duplication:
**Before (duplicated in every method):**
```go
req, err := http.NewRequest("GET", url, nil)
// error handling
req.Header.Set("X-Api-Key", c.APIKey)
// more headers
resp, err := c.HTTPClient.Do(req)
// more error handling
```
**After (centralized):**
```go
resp, err := c.doRequest("GET", "/api/v3/system/status", nil)
if err != nil {
return nil, fmt.Errorf("getting system status: %w", err)
}
```
**Benefits of this refactoring:**
1. **DRY principle**: Don't Repeat Yourself
2. **Consistent error handling**: All requests handled the same way
3. **Easier to modify**: Change headers/logging in one place
4. **Testability**: Can test the common request logic separately
**Deep Dive - Logging Interface:**
```go
type Logger interface {
Printf(format string, v ...interface{})
Println(v ...interface{})
}
```
**Why use an interface instead of `*log.Logger` directly?**
1. **Flexibility**: Can plug in any logger (stdlib, logrus, zap, etc.)
2. **Testing**: Can use a mock logger that captures logs for testing
3. **Configuration**: Different log levels, formats, destinations
**Example usage:**
```go
// Use standard library logger (default)
client := NewBaseClient("http://localhost", "api-key")
// Use custom logger
customLogger := log.New(file, "[CUSTOM] ", log.LstdFlags)
client.SetLogger(customLogger)
// Use no-op logger for silence
client.SetLogger(&NoOpLogger{})
```
**Connection to Previous Examples:**
We've taken the basic HTTP client from Chapter 3 and made it production-ready with proper error handling, logging, and reusable request logic.
### 7.4 Error Handling in Action
Let's see how to use these enhanced errors in `pkg/radarr/radarr.go`:
```go
package radarr
import (
"encoding/json"
"fmt"
"strconv"
"github.com/yourusername/arr-client/pkg/client"
"github.com/yourusername/arr-client/pkg/types"
)
// Client implements the MovieService interface for Radarr
type Client struct {
*client.BaseClient
}
// NewClient creates a new Radarr client
func NewClient(baseURL, apiKey string) *Client {
return &Client{
BaseClient: client.NewBaseClient(baseURL, apiKey),
}
}
// GetMovies retrieves all movies with proper error context
func (r *Client) GetMovies() ([]types.Movie, error) {
resp, err := r.doRequest("GET", "/api/v3/movie", nil)
if err != nil {
// Error is already wrapped by doRequest, just add context
return nil, fmt.Errorf("retrieving movies: %w", err)
}
defer resp.Body.Close()
var movies []types.Movie
if err := json.NewDecoder(resp.Body).Decode(&movies); err != nil {
return nil, fmt.Errorf("parsing movies response: %w", err)
}
r.Logger.Printf("Retrieved %d movies", len(movies))
return movies, nil
}
// GetMovie retrieves a specific movie with enhanced error handling
func (r *Client) GetMovie(id int) (*types.Movie, error) {
endpoint := fmt.Sprintf("/api/v3/movie/%s", strconv.Itoa(id))
resp, err := r.doRequest("GET", endpoint, nil)
if err != nil {
// Check if it's a 404 error and provide better context
if client.IsNotFound(err) {
return nil, fmt.Errorf("movie with ID %d not found: %w", id, err)
}
return nil, fmt.Errorf("retrieving movie %d: %w", id, err)
}
defer resp.Body.Close()
var movie types.Movie
if err := json.NewDecoder(resp.Body).Decode(&movie); err != nil {
return nil, fmt.Errorf("parsing movie %d response: %w", id, err)
}
r.Logger.Printf("Retrieved movie: %s (%d)", movie.Title, movie.Year)
return &movie, nil
}
// AddMovie adds a new movie with comprehensive error handling and validation
func (r *Client) AddMovie(movie *types.Movie) (*types.Movie, error) {
// Validate input
if err := r.validateMovie(movie); err != nil {
return nil, fmt.Errorf("invalid movie data: %w", err)
}
// Convert movie to JSON
movieData, err := json.Marshal(movie)
if err != nil {
return nil, fmt.Errorf("encoding movie data: %w", err)
}
resp, err := r.doRequest("POST", "/api/v3/movie", bytes.NewReader(movieData))
if err != nil {
// Provide context based on error type
if client.IsUnauthorized(err) {
return nil, fmt.Errorf("unauthorized to add movies - check API key: %w", err)
}
if client.IsForbidden(err) {
return nil, fmt.Errorf("forbidden to add movies - insufficient permissions: %w", err)
}
return nil, fmt.Errorf("adding movie '%s': %w", movie.Title, err)
}
defer resp.Body.Close()
var addedMovie types.Movie
if err := json.NewDecoder(resp.Body).Decode(&addedMovie); err != nil {
return nil, fmt.Errorf("parsing added movie response: %w", err)
}
r.Logger.Printf("Added movie: %s (%d) with ID %d",
addedMovie.Title, addedMovie.Year, addedMovie.ID)
return &addedMovie, nil
}
// validateMovie performs client-side validation
func (r *Client) validateMovie(movie *types.Movie) error {
if movie == nil {
return &client.ValidationError{
Field: "movie",
Message: "movie cannot be nil",
Value: nil,
}
}
if movie.Title == "" {
return &client.ValidationError{
Field: "title",
Message: "title is required",
Value: movie.Title,
}
}
if movie.Year < 1800 || movie.Year > 2100 {
return &client.ValidationError{
Field: "year",
Message: "year must be between 1800 and 2100",
Value: movie.Year,
}
}
if movie.TmdbID <= 0 {
return &client.ValidationError{
Field: "tmdbId",
Message: "TMDB ID is required and must be positive",
Value: movie.TmdbID,
}
}
return nil
}
```
**Deep Dive - Error Propagation and Context:**
Notice the error handling pattern:
```go
resp, err := r.doRequest("GET", endpoint, nil)
if err != nil {
if client.IsNotFound(err) {
return nil, fmt.Errorf("movie with ID %d not found: %w", id, err)
}
return nil, fmt.Errorf("retrieving movie %d: %w", id, err)
}
```
**This demonstrates:**
1. **Error checking**: Check for specific error types first
2. **Context addition**: Add domain-specific context ("movie with ID %d")
3. **Error wrapping**: Use `%w` to preserve the original error
**The error chain will look like:**
```
retrieving movie 123: API error 404 at GET /api/v3/movie/123: Not Found
```
**Deep Dive - Validation Errors:**
Client-side validation prevents unnecessary API calls:
```go
func (r *Client) validateMovie(movie *types.Movie) error {
if movie.Title == "" {
return &client.ValidationError{
Field: "title",
Message: "title is required",
Value: movie.Title,
}
}
// ...
}
```
**Benefits:**
1. **Fast failure**: Catch errors before expensive network calls
2. **Clear feedback**: User knows exactly what's wrong
3. **Structured errors**: Validation errors are different from API errors
**Connection to Previous Examples:**
We've enhanced the basic Radarr client from Chapter 4 with robust error handling, logging, and validation. The core functionality is the same, but now it's production-ready.
## Chapter 8: Concurrency - Handling Multiple Operations
### 8.1 Understanding Go's Concurrency Model
Go's concurrency is based on two main concepts:
1. **Goroutines**: Lightweight threads managed by Go runtime
2. **Channels**: Type-safe pipes for communication between goroutines
**Philosophy**: "Don't communicate by sharing memory; share memory by communicating."
### 8.2 Basic Goroutines and Channels
Create `pkg/client/concurrent.go`:
```go
package client
import (
"context"
"fmt"
"sync"
"time"
"github.com/yourusername/arr-client/pkg/types"
)
// ServiceResult represents the result of a service operation
type ServiceResult struct {
ServiceName string
Success bool
Error error
Duration time.Duration
}
// HealthResult represents health check result with service name
type HealthResult struct {
ServiceName string
Health []types.HealthCheck
Error error
Duration time.Duration
}
// CheckHealthConcurrently checks health of multiple services concurrently
func CheckHealthConcurrently(services map[string]ServiceClient) []HealthResult {
results := make(chan HealthResult, len(services))
var wg sync.WaitGroup
// Start a goroutine for each service
for name, service := range services {
wg.Add(1)
go func(serviceName string, svc ServiceClient) {
defer wg.Done()
start := time.Now()
health, err := svc.GetHealth()
duration := time.Since(start)
results <- HealthResult{
ServiceName: serviceName,
Health: health,
Error: err,
Duration: duration,
}
}(name, service)
}
// Close the results channel when all goroutines complete
go func() {
wg.Wait()
close(results)
}()
// Collect all results
var healthResults []HealthResult
for result := range results {
healthResults = append(healthResults, result)
}
return healthResults
}
// CheckSystemStatusConcurrently checks system status of multiple services
func CheckSystemStatusConcurrently(services map[string]ServiceClient) []ServiceResult {
results := make(chan ServiceResult, len(services))
var wg sync.WaitGroup
for name, service := range services {
wg.Add(1)
go func(serviceName string, svc ServiceClient) {
defer wg.Done()
start := time.Now()
_, err := svc.GetSystemStatus()
duration := time.Since(start)
results <- ServiceResult{
ServiceName: serviceName,
Success: err == nil,
Error: err,
Duration: duration,
}
}(name, service)
}
go func() {
wg.Wait()
close(results)
}()
var statusResults []ServiceResult
for result := range results {
statusResults = append(statusResults, result)
}
return statusResults
}
```
**Deep Dive - Goroutines:**
```go
go func(serviceName string, svc ServiceClient) {
defer wg.Done()
// ... do work
}(name, service)
```
**Breaking this down:**
1. **`go`**: Starts a new goroutine (like a lightweight thread)
2. **Anonymous function**: The function to run in the goroutine
3. **Parameters**: `(serviceName string, svc ServiceClient)` - captures loop variables safely
4. **Function call**: `(name, service)` - passes current loop values to the function
**Why pass parameters instead of capturing loop variables?**
```go
// WRONG - captures loop variable reference
for name, service := range services {
go func() {
// name and service will be the LAST values from the loop!
health, err := service.GetHealth()
}()
}
// CORRECT - passes values to function parameters
for name, service := range services {
go func(serviceName string, svc ServiceClient) {
// serviceName and svc are copies of current loop values
health, err := svc.GetHealth()
}(name, service)
}
```
**Deep Dive - Channels:**
```go
results := make(chan HealthResult, len(services))
```
**Channel basics:**
1. **Type-safe**: `chan HealthResult` only carries `HealthResult` values
2. **Buffered**: `len(services)` capacity means sends won't block
3. **Communication**: Goroutines send results, main goroutine receives
**Channel operations:**
```go
results <- HealthResult{...} // Send to channel
result := <-results // Receive from channel
close(results) // Close channel (no more sends)
```
**Deep Dive - WaitGroups:**
```go
var wg sync.WaitGroup
wg.Add(1) // Add 1 to the counter
defer wg.Done() // Subtract 1 from counter when function exits
wg.Wait() // Block until counter reaches 0
```
**WaitGroup pattern:**
1. **Add** before starting goroutines
2. **Done** when goroutine finishes (usually with `defer`)
3. **Wait** for all goroutines to finish
**Connection to Previous Examples:**
We're using the `ServiceClient` interface from Chapter 4. Any type that implements this interface (like our `radarr.Client`) can be used with these concurrent functions.
### 8.3 Context for Cancellation and Timeouts
Add timeout and cancellation support:
```go
// CheckHealthWithTimeout checks health with a timeout
func CheckHealthWithTimeout(ctx context.Context, service ServiceClient, timeout time.Duration) (*[]types.HealthCheck, error) {
// Create a context with timeout
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() // Always call cancel to free resources
type result struct {
health []types.HealthCheck
err error
}
resultCh := make(chan result, 1)
// Start the health check in a goroutine
go func() {
health, err := service.GetHealth()
select {
case resultCh <- result{health: health, err: err}:
// Result sent successfully
case <-timeoutCtx.Done():
// Context cancelled, don't send result
return
}
}()
// Wait for either result or timeout
select {
case res := <-resultCh:
return &res.health, res.err
case <-timeoutCtx.Done():
return nil, fmt.Errorf("health check timed out after %v: %w", timeout, timeoutCtx.Err())
}
}
// CheckMultipleServicesWithTimeout checks multiple services with individual timeouts
func CheckMultipleServicesWithTimeout(ctx context.Context, services map[string]ServiceClient, timeout time.Duration) []HealthResult {
results := make(chan HealthResult, len(services))
var wg sync.WaitGroup
for name, service := range services {
wg.Add(1)
go func(serviceName string, svc ServiceClient) {
defer wg.Done()
start := time.Now()
health, err := CheckHealthWithTimeout(ctx, svc, timeout)
duration := time.Since(start)
var healthSlice []types.HealthCheck
if health != nil {
healthSlice = *health
}
results <- HealthResult{
ServiceName: serviceName,
Health: healthSlice,
Error: err,
Duration: duration,
}
}(name, service)
}
go func() {
wg.Wait()
close(results)
}()
var healthResults []HealthResult
for result := range results {
healthResults = append(healthResults, result)
}
return healthResults
}
// RetryWithBackoff retries an operation with exponential backoff
func RetryWithBackoff(ctx context.Context, maxRetries int, baseDelay time.Duration, operation func() error) error {
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
// Check if context is cancelled
select {
case <-ctx.Done():
return fmt.Errorf("operation cancelled: %w", ctx.Err())
default:
}
// Try the operation
if err := operation(); err == nil {
return nil // Success!
} else {
lastErr = err
}
// Don't delay after the last attempt
if attempt == maxRetries-1 {
break
}
// Calculate delay with exponential backoff
delay := baseDelay * time.Duration(1<<uint(attempt)) // 2^attempt
select {
case <-ctx.Done():
return fmt.Errorf("operation cancelled during backoff: %w", ctx.Err())
case <-time.After(delay):
// Wait for delay or cancellation
}
}
return fmt.Errorf("operation failed after %d attempts: %w", maxRetries, lastErr)
}
```
**Deep Dive - Context Package:**
Context is Go's way of handling cancellation, timeouts, and request-scoped values:
```go
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
```
**Context types:**
- `context.Background()`: Root context, never cancelled
- `context.WithTimeout()`: Cancelled after a timeout
- `context.WithCancel()`: Can be cancelled manually
- `context.WithDeadline()`: Cancelled at a specific time
**Deep Dive - Select Statement:**
The `select` statement is like a switch for channel operations:
```go
select {
case res := <-resultCh:
// Received result from channel
return &res.health, res.err
case <-timeoutCtx.Done():
// Context was cancelled/timed out
return nil, timeoutCtx.Err()
}
```
**Select behavior:**
1. **Multiple cases ready**: Chooses one at random
2. **No cases ready**: Blocks until one becomes ready
3. **Default case**: Makes select non-blocking
**Deep Dive - Exponential Backoff:**
```go
delay := baseDelay * time.Duration(1<<uint(attempt)) // 2^attempt
```
**Why exponential backoff?**
- **Attempt 0**: Wait baseDelay (e.g., 1 second)
- **Attempt 1**: Wait 2 * baseDelay (2 seconds)
- **Attempt 2**: Wait 4 * baseDelay (4 seconds)
- **Attempt 3**: Wait 8 * baseDelay (8 seconds)
This prevents overwhelming a struggling server with rapid retries.
**Connection to Previous Examples:**
These concurrent functions use our enhanced error handling from Chapter 7. When operations fail, we get rich error information that helps with debugging.
### 8.4 Practical Usage Example
Let's create an example that ties everything together:
```go
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/yourusername/arr-client/pkg/client"
"github.com/yourusername/arr-client/pkg/radarr"
"github.com/yourusername/arr-client/pkg/sonarr"
)
func main() {
// Create clients for multiple services
services := map[string]client.ServiceClient{
"radarr": radarr.NewClient("http://localhost:7878", "radarr-api-key"),
"sonarr": sonarr.NewClient("http://localhost:8989", "sonarr-api-key"),
}
// Example 1: Basic concurrent health checks
fmt.Println("=== Basic Concurrent Health Checks ===")
healthResults := client.CheckHealthConcurrently(services)
for _, result := range healthResults {
if result.Error != nil {
fmt.Printf("%s: ERROR - %v (took %v)\n",
result.ServiceName, result.Error, result.Duration)
} else {
fmt.Printf("%s: OK - %d health checks (took %v)\n",
result.ServiceName, len(result.Health), result.Duration)
}
}
// Example 2: Health checks with timeout
fmt.Println("\n=== Health Checks with Timeout ===")
ctx := context.Background()
timeoutResults := client.CheckMultipleServicesWithTimeout(ctx, services, 5*time.Second)
for _, result := range timeoutResults {
if result.Error != nil {
fmt.Printf("%s: FAILED - %v (took %v)\n",
result.ServiceName, result.Error, result.Duration)
} else {
fmt.Printf("%s: SUCCESS - %d checks (took %v)\n",
result.ServiceName, len(result.Health), result.Duration)
}
}
// Example 3: Retry with backoff
fmt.Println("\n=== Retry with Backoff Example ===")
radarrClient := radarr.NewClient("http://localhost:7878", "api-key")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := client.RetryWithBackoff(ctx, 3, 1*time.Second, func() error {
_, err := radarrClient.GetSystemStatus()
if err != nil {
log.Printf("Attempt failed: %v", err)
return err
}
return nil
})
if err != nil {
fmt.Printf("Failed to get system status after retries: %v\n", err)
} else {
fmt.Println("Successfully got system status!")
}
}
```
**Real-world applications:**
1. **Health dashboards**: Check all services concurrently for fast updates
2. **Batch operations**: Add multiple movies in parallel
3. **Resilient clients**: Retry failed operations with backoff
4. **Timeout handling**: Don't let slow services block your application
**Connection to All Previous Examples:**
This brings together everything we've learned:
- Structs and interfaces (Chapters 2-4)
- Package organization (Chapter 5)
- Error handling and logging (Chapter 7)
- Concurrent operations with proper resource management
The concurrency layer adds reliability and performance without changing the core API design.
## Chapter 9: Configuration and CLI - Building User-Friendly Tools
### 9.1 Configuration Management
Real applications need flexible configuration. Let's create a robust config system.
Create `pkg/config/config.go`:
```go
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
// Config holds all application configuration
type Config struct {
// Service configurations
Services map[string]*ServiceConfig `json:"services"`
// Global settings
Timeout Duration `json:"timeout,omitempty"`
LogLevel string `json:"log_level,omitempty"`
LogFormat string `json:"log_format,omitempty"`
MaxRetries int `json:"max_retries,omitempty"`
}
// ServiceConfig represents configuration for a single *arr service
type ServiceConfig struct {
URL string `json:"url"`
APIKey string `json:"api_key"`
Timeout Duration `json:"timeout,omitempty"`
Enabled bool `json:"enabled"`
// Service-specific settings
QualityProfile string `json:"quality_profile,omitempty"`
RootFolder string `json:"root_folder,omitempty"`
Tags []string `json:"tags,omitempty"`
}
// Duration is a wrapper around time.Duration for JSON marshaling
type Duration struct {
time.Duration
}
// MarshalJSON implements JSON marshaling for Duration
func (d Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(d.String())
}
// UnmarshalJSON implements JSON unmarshaling for Duration
func (d *Duration) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
duration, err := time.ParseDuration(s)
if err != nil {
return fmt.Errorf("invalid duration format '%s': %w", s, err)
}
d.Duration = duration
return nil
}
// DefaultConfig returns a configuration with sensible defaults
func DefaultConfig() *Config {
return &Config{
Services: make(map[string]*ServiceConfig),
Timeout: Duration{30 * time.Second},
LogLevel: "info",
LogFormat: "text",
MaxRetries: 3,
}
}
// LoadConfig loads configuration from a file
func LoadConfig(filename string) (*Config, error) {
// Start with defaults
config := DefaultConfig()
// Check if file exists
if _, err := os.Stat(filename); os.IsNotExist(err) {
return nil, fmt.Errorf("config file does not exist: %s", filename)
}
data, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("reading config file: %w", err)
}
if err := json.Unmarshal(data, config); err != nil {
return nil, fmt.Errorf("parsing config file: %w", err)
}
// Validate configuration
if err := config.Validate(); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
}
return config, nil
}
// LoadConfigWithDefaults loads config, creating default file if it doesn't exist
func LoadConfigWithDefaults(filename string) (*Config, error) {
if _, err := os.Stat(filename); os.IsNotExist(err) {
// Create default config file
defaultConfig := DefaultConfig()
// Add example services
defaultConfig.Services["radarr"] = &ServiceConfig{
URL: "http://localhost:7878",
APIKey: "your-radarr-api-key-here",
Enabled: false, // Disabled by default
}
defaultConfig.Services["sonarr"] = &ServiceConfig{
URL: "http://localhost:8989",
APIKey: "your-sonarr-api-key-here",
Enabled: false,
}
if err := defaultConfig.SaveConfig(filename); err != nil {
return nil, fmt.Errorf("creating default config: %w", err)
}
return defaultConfig, nil
}
return LoadConfig(filename)
}
// SaveConfig saves configuration to file
func (c *Config) SaveConfig(filename string) error {
// Create directory if it doesn't exist
dir := filepath.Dir(filename)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("creating config directory: %w", err)
}
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
}
if err := os.WriteFile(filename, data, 0644); err != nil {
return fmt.Errorf("writing config file: %w", err)
}
return nil
}
// Validate checks if the configuration is valid
func (c *Config) Validate() error {
if c.Timeout.Duration <= 0 {
return fmt.Errorf("timeout must be positive, got %v", c.Timeout.Duration)
}
validLogLevels := map[string]bool{
"debug": true, "info": true, "warn": true, "error": true,
}
if !validLogLevels[c.LogLevel] {
return fmt.Errorf("invalid log level '%s', must be one of: debug, info, warn, error", c.LogLevel)
}
if c.MaxRetries < 0 {
return fmt.Errorf("max_retries must be non-negative, got %d", c.MaxRetries)
}
// Validate each service
for name, service := range c.Services {
if err := service.Validate(name); err != nil {
return fmt.Errorf("service '%s': %w", name, err)
}
}
return nil
}
// Validate checks if a service configuration is valid
func (sc *ServiceConfig) Validate(serviceName string) error {
if sc.URL == "" {
return fmt.Errorf("URL is required")
}
if sc.APIKey == "" && sc.Enabled {
return fmt.Errorf("API key is required for enabled services")
}
if sc.Timeout.Duration < 0 {
return fmt.Errorf("timeout cannot be negative")
}
return nil
}
// GetService returns configuration for a specific service
func (c *Config) GetService(name string) (*ServiceConfig, bool) {
service, exists := c.Services[name]
return service, exists
}
// GetEnabledServices returns a map of only enabled services
func (c *Config) GetEnabledServices() map[string]*ServiceConfig {
enabled := make(map[string]*ServiceConfig)
for name, service := range c.Services {
if service.Enabled {
enabled[name] = service
}
}
return enabled
}
```
**Deep Dive - Custom JSON Marshaling:**
```go
type Duration struct {
time.Duration
}
func (d Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(d.String())
}
```
**Why custom marshaling?**
`time.Duration` marshals as nanoseconds (not user-friendly):
```json
{"timeout": 30000000000} // 30 billion nanoseconds = 30 seconds
```
With custom marshaling:
```json
{"timeout": "30s"} // Much clearer!
```
**Deep Dive - Configuration Patterns:**
1. **Defaults + Override**: Start with sensible defaults, override from file
2. **Validation**: Check configuration is valid before using it
3. **Auto-creation**: Create default config file if none exists
4. **Nested structure**: Group related settings (services, logging, etc.)
**Connection to Previous Examples:**
The configuration maps directly to our client constructors. A `ServiceConfig` contains everything needed to create a `radarr.Client` or `sonarr.Client`.
### 9.2 Command Line Interface
Update `main.go` with a comprehensive CLI:
```go
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/yourusername/arr-client/pkg/client"
"github.com/yourusername/arr-client/pkg/config"
"github.com/yourusername/arr-client/pkg/radarr"
"github.com/yourusername/arr-client/pkg/sonarr"
)
var (
// Version info (set by build process)
version = "dev"
buildTime = "unknown"
gitCommit = "unknown"
)
func main() {
var (
configFile = flag.String("config", getDefaultConfigPath(), "Configuration file path")
command = flag.String("cmd", "status", "Command: status, health, movies, series, add-movie")
serviceName = flag.String("service", "", "Service to use (leave empty for all enabled services)")
timeout = flag.Duration("timeout", 0, "Request timeout (overrides config)")
verbose = flag.Bool("verbose", false, "Verbose output")
showVersion = flag.Bool("version", false, "Show version information")
// Command-specific flags
movieTitle = flag.String("title", "", "Movie title (for add-movie command)")
movieYear = flag.Int("year", 0, "Movie year (for add-movie command)")
tmdbID = flag.Int("tmdb-id", 0, "TMDB ID (for add-movie command)")
)
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s [options]\n\n", os.Args[0])
fmt.Fprintf(os.Stderr, "A command-line client for *arr services (Radarr, Sonarr, etc.)\n\n")
fmt.Fprintf(os.Stderr, "Options:\n")
flag.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nCommands:\n")
fmt.Fprintf(os.Stderr, " status - Get system status\n")
fmt.Fprintf(os.Stderr, " health - Check service health\n")
fmt.Fprintf(os.Stderr, " movies - List movies (Radarr only)\n")
fmt.Fprintf(os.Stderr, " series - List series (Sonarr only)\n")
fmt.Fprintf(os.Stderr, " add-movie - Add a movie (requires -title, -year, -tmdb-id)\n")
fmt.Fprintf(os.Stderr, "\nExamples:\n")
fmt.Fprintf(os.Stderr, " %s -cmd status\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s -cmd health -service radarr\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s -cmd movies -service radarr\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s -cmd add-movie -service radarr