# 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=` | Search by title or `tvdb:` | | `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=` | Search by title | | `LookupIMDB(imdbID string)` | `GET /api/v3/movie/lookup/imdb?imdbId=` | 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:")` for Sonarr returns expected series - `LookupIMDB("")` 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: ` [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 --profile --rootfolder `** — both flags required. Detect if term looks like a TVDB ID (all digits) or a title: - **By ID**: lookup via `tvdb:`. If no result, error: `"No series found with TVDB 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 ''. 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): ```go 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 /movies` → `LookupIMDB` returns result → add called with correct profile/rootfolder - `film add tt26443616` → missing required flags → error with usage - `film add tt0000000 --profile 1 --rootfolder /movies` → `LookupIMDB` 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