Files
2026-03-12 22:13:57 +00:00

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 slice
  • Lookup("some title") sends correct query param, returns results
  • Lookup("tvdb:<known_id>") for Sonarr returns expected series
  • LookupIMDB("<known_imdb_id>") for Radarr returns expected movie
  • Add() sends correct JSON body, returns parsed result
  • Get(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 — call sonarr.List(), print each as one line: Title (Year) — Status — Episodes: N/M
  • tv list "title" — call sonarr.Lookup(title), same format
  • tv 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 showing Title (Year) — TVDB ID: <id>. Exit non-zero.
    • On successful add: set qualityProfileId from --profile, rootFolderPath from --rootfolder, monitored: true, searchForMissingEpisodes: true, then Add(). Print confirmation.
  • tv profiles — call sonarr.QualityProfiles(), print each as: ID: <id> — Name: <name>
  • tv rootfolders — call sonarr.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 — call radarr.List(), print: Title (Year) — Status — HasFile: yes/no
  • films list "title" / film list "title" — call radarr.Lookup(title), same format
  • film add <term> --profile <id> --rootfolder <path> — both flags required. Detect IMDB ID (starts with tt) 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 showing Title (Year) — IMDB ID: <id>. Exit non-zero.
    • On successful add: set qualityProfileId from --profile, rootFolderPath from --rootfolder, minimumAvailability: "released" (default), monitored: true, searchForMovie: true, then Add(). Print confirmation.
  • film profiles — call radarr.QualityProfiles(), print each as: ID: <id> — Name: <name>
  • film rootfolders — call radarr.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 list with empty library → prints "No series found"
  • tv list with items → correct output lines
  • tv add 448176 --profile 1 --rootfolder /tv → lookup called with tvdb:448176, exact match → add called with correct profile/rootfolder
  • tv add 448176 → missing required flags → error with usage
  • tv 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 called
  • tv add "Break" --profile 1 --rootfolder /tv → lookup returns multiple, none exact → error with up to 5 suggestions including TVDB IDs
  • film add tt26443616 --profile 1 --rootfolder /moviesLookupIMDB returns result → add called with correct profile/rootfolder
  • film add tt26443616 → missing required flags → error with usage
  • film add tt0000000 --profile 1 --rootfolder /moviesLookupIMDB returns nothing → error "No film found with IMDB ID tt0000000"
  • film add "Dune" --profile 1 --rootfolder /movies → exact match → add called
  • film 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
  • films as alias for film in all subcommands
  • Exit codes: 0 success, 1 error

Implementation Order

  1. go mod init + .env + .gitignore + types
  2. Sonarr client + integration tests
  3. Radarr client + integration tests
  4. CLI parsing in main.go (with .env loading)
  5. TV commands + unit tests
  6. Film commands + unit tests
  7. End-to-end test against local instances