9.6 KiB
arrman Development Plan
Overview
Single Go binary CLI that wraps Sonarr and Radarr v3 APIs for listing, adding, and summarising TV series and films. Designed to be agent-friendly (used via SKILL.md).
Configuration
Credentials stored in .env file in the project root:
SONARR_URL=https://sonarr.example.com
SONARR_API_KEY=abc123
RADARR_URL=https://radarr.example.com
RADARR_API_KEY=def456
ARRMAN_TLS_SKIP_VERIFY=true # optional, for self-signed certs
Loaded at startup. Authentication: X-Api-Key header on all requests.
.env must be in .gitignore.
Project Structure
arrman/
├── main.go # entrypoint, CLI parsing, .env loading
├── cmd/
│ ├── tv.go # tv subcommands (list, add, summary)
│ └── film.go # film/films subcommands (list, add, summary)
├── sonarr/
│ ├── client.go # Sonarr HTTP client + API methods
│ ├── client_test.go # integration tests against local Sonarr
│ └── types.go # Sonarr response structs
├── radarr/
│ ├── client.go # Radarr HTTP client + API methods
│ ├── client_test.go # integration tests against local Radarr
│ └── types.go # Radarr response structs
├── .env # local credentials (not committed)
├── .gitignore
└── go.mod
No external dependencies beyond the Go standard library (no cobra, no viper). Keep it simple: os.Args parsing or a lightweight approach.
Phase 1: Project skeleton + API clients
1a. Go module and types
go mod init arrman- Define Sonarr types in
sonarr/types.go:Series,Season,Statistics,AddOptions,QualityProfile,RootFolder - Define Radarr types in
radarr/types.go:Movie,AddOptions,QualityProfile,RootFolder - Only include fields we actually use for display and add operations
1b. Sonarr client (sonarr/client.go)
Client struct holds base URL + API key, creates an http.Client. The client must support HTTP, HTTPS with valid certs, and HTTPS with self-signed certs (skip TLS verification when URL scheme is HTTPS and ARRMAN_TLS_SKIP_VERIFY=true in .env).
Methods:
| Method | API call | Purpose |
|---|---|---|
List() |
GET /api/v3/series |
Return all series |
Lookup(term string) |
GET /api/v3/series/lookup?term=<term> |
Search by title or tvdb:<id> |
Get(id int) |
GET /api/v3/series/{id} |
Full series detail |
QualityProfiles() |
GET /api/v3/qualityprofile |
List available quality profiles |
RootFolders() |
GET /api/v3/rootfolder |
List configured root folders |
Add(series Series) |
POST /api/v3/series |
Add new series |
1c. Radarr client (radarr/client.go)
Same pattern.
Methods:
| Method | API call | Purpose |
|---|---|---|
List() |
GET /api/v3/movie |
Return all movies |
Lookup(term string) |
GET /api/v3/movie/lookup?term=<term> |
Search by title |
LookupIMDB(imdbID string) |
GET /api/v3/movie/lookup/imdb?imdbId=<id> |
Lookup by IMDB ID (returns single object) |
Get(id int) |
GET /api/v3/movie/{id} |
Full movie detail |
QualityProfiles() |
GET /api/v3/qualityprofile |
List available quality profiles |
RootFolders() |
GET /api/v3/rootfolder |
List configured root folders |
Add(movie Movie) |
POST /api/v3/movie |
Add new movie |
1d. Tests for API clients
Integration tests against local Sonarr/Radarr instances (already running with data). Tests load .env for connection details.
Tests skip if env vars are not set (using t.Skip), so CI without servers still passes.
Test cases:
List()returns non-empty parsed sliceLookup("some title")sends correct query param, returns resultsLookup("tvdb:<known_id>")for Sonarr returns expected seriesLookupIMDB("<known_imdb_id>")for Radarr returns expected movieAdd()sends correct JSON body, returns parsed resultGet(id)returns parsed single item with expected fields populated- Error cases: bad API key returns error, invalid ID returns error
Phase 2: CLI commands
2a. CLI entrypoint (main.go)
Parse os.Args into: <domain> <action> [args...]
- domain:
tv→ Sonarr,film/films→ Radarr - action:
list,add,summary,profiles,rootfolders - Remaining args joined as the query/identifier
Load .env file on startup. Exit with clear error if required vars missing.
2b. TV commands (cmd/tv.go)
Functions called by main after parsing:
tv list— callsonarr.List(), print each as one line:Title (Year) — Status — Episodes: N/Mtv list "title"— callsonarr.Lookup(title), same formattv add <term> --profile <id> --rootfolder <path>— both flags required. Detect if term looks like a TVDB ID (all digits) or a title:- By ID: lookup via
tvdb:<id>. If no result, error:"No series found with TVDB ID <id>". If found, add it. - By title: lookup by title. If exactly one result matches the title exactly (case-insensitive), add it. Otherwise error with
"No exact match for '<title>'. Did you mean:"followed by up to 5 closest results, each showingTitle (Year) — TVDB ID: <id>. Exit non-zero. - On successful add: set
qualityProfileIdfrom--profile,rootFolderPathfrom--rootfolder,monitored: true,searchForMissingEpisodes: true, thenAdd(). Print confirmation.
- By ID: lookup via
tv profiles— callsonarr.QualityProfiles(), print each as:ID: <id> — Name: <name>tv rootfolders— callsonarr.RootFolders(), print each as:ID: <id> — Path: <path> — Free: <free space>tv summary <term>— lookup, find in library (or use lookup result), print multi-line detail: title, year, status, network, overview, seasons, episode stats, next airing
2c. Film commands (cmd/film.go)
films list/film list— callradarr.List(), print:Title (Year) — Status — HasFile: yes/nofilms list "title"/film list "title"— callradarr.Lookup(title), same formatfilm add <term> --profile <id> --rootfolder <path>— both flags required. Detect IMDB ID (starts withtt) or a title:- By ID:
LookupIMDB(). If no result, error:"No film found with IMDB ID <id>". If found, add it. - By title:
Lookup(). If exactly one result matches the title exactly (case-insensitive), add it. Otherwise error with"No exact match for '<title>'. Did you mean:"followed by up to 5 closest results, each showingTitle (Year) — IMDB ID: <id>. Exit non-zero. - On successful add: set
qualityProfileIdfrom--profile,rootFolderPathfrom--rootfolder,minimumAvailability: "released"(default),monitored: true,searchForMovie: true, thenAdd(). Print confirmation.
- By ID:
film profiles— callradarr.QualityProfiles(), print each as:ID: <id> — Name: <name>film rootfolders— callradarr.RootFolders(), print each as:ID: <id> — Path: <path> — Free: <free space>film summary <term>— lookup/find, print multi-line detail: title, year, overview, studio, runtime, status, file info
2d. Output format
Plain text, one item per line for lists. Designed for agent consumption — no colour, no tables, no interactive prompts. Summary uses labelled fields:
Title: Breaking Bad
Year: 2008
Status: ended
Network: AMC
Seasons: 5
Episodes: 62/62 (100%)
Overview: A chemistry teacher diagnosed with...
Phase 3: Tests for CLI layer
3a. Command tests
Test the command functions by injecting a mock/fake client (interface):
type SonarrClient interface {
List() ([]Series, error)
Lookup(term string) ([]Series, error)
Get(id int) (*Series, error)
QualityProfiles() ([]QualityProfile, error)
RootFolders() ([]RootFolder, error)
Add(s Series) (*Series, error)
}
Same for Radarr. Commands accept the interface, tests provide a stub.
Test cases:
tv listwith empty library → prints "No series found"tv listwith items → correct output linestv add 448176 --profile 1 --rootfolder /tv→ lookup called withtvdb:448176, exact match → add called with correct profile/rootfoldertv add 448176→ missing required flags → error with usagetv add 999999 --profile 1 --rootfolder /tv→ lookup returns empty → error "No series found with TVDB ID 999999"tv add "Breaking Bad" --profile 1 --rootfolder /tv→ lookup returns exact match → add calledtv add "Break" --profile 1 --rootfolder /tv→ lookup returns multiple, none exact → error with up to 5 suggestions including TVDB IDsfilm add tt26443616 --profile 1 --rootfolder /movies→LookupIMDBreturns result → add called with correct profile/rootfolderfilm add tt26443616→ missing required flags → error with usagefilm add tt0000000 --profile 1 --rootfolder /movies→LookupIMDBreturns nothing → error "No film found with IMDB ID tt0000000"film add "Dune" --profile 1 --rootfolder /movies→ exact match → add calledfilm add "Dun" --profile 1 --rootfolder /movies→ no exact match → error with up to 5 suggestions including IMDB IDs- Error propagation from client → printed to stderr, non-zero exit
Phase 4: Polish
- Validate required env vars on startup, print usage if missing
- Handle "already exists" errors from add gracefully
filmsas alias forfilmin all subcommands- Exit codes: 0 success, 1 error
Implementation Order
go mod init+.env+.gitignore+ types- Sonarr client + integration tests
- Radarr client + integration tests
- CLI parsing in
main.go(with.envloading) - TV commands + unit tests
- Film commands + unit tests
- End-to-end test against local instances