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

80 KiB

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
  • 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:

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:

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:

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:

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:

{
  "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:

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:

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:

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:

// 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:

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:

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:

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?

// 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:

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:

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:

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:

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:

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:

// 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:

// 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:

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:

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:

type RadarrClient struct {
    *Client // Embedded struct
}

This is composition, not inheritance. Here's what happens:

Field Promotion:

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:

// 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:

// 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:

// 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:

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:

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:

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:

// 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:

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:

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:

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:

// 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:

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:

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:

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:

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:

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:

// 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:

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:

// 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:

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:

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:

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:

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:

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):

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):

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:

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:

// 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:

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:

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:

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:

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 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?

// 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:

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:

results <- HealthResult{...}  // Send to channel
result := <-results           // Receive from channel
close(results)                // Close channel (no more sends)

Deep Dive - WaitGroups:

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:

// 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:

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:

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:

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:

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:

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:

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):

{"timeout": 30000000000}  // 30 billion nanoseconds = 30 seconds

With custom marshaling:

{"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:

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