- 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.
2732 lines
80 KiB
Markdown
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 |