200 lines
9.6 KiB
Markdown
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
|