Files
arrman/plan.md
T
2026-03-12 22:13:57 +00:00

200 lines
9.6 KiB
Markdown

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