Initial release
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
.env
|
||||
/arrman
|
||||
.claude
|
||||
@@ -0,0 +1,41 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
`arrman` is a CLI tool to do minimal management of Sonarr and Radarr
|
||||
Targeted to be used by AI agents via SKILL.md
|
||||
|
||||
## Build & Test
|
||||
|
||||
```bash
|
||||
go build -o arrman .
|
||||
go test ./...
|
||||
go test -run TestName ./pkg/... # single test
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- Single self-contained Go binary, no external dependencies
|
||||
- Talks HTTPS to Sonarr (TV) and Radarr (films) v3 APIs using API tokens
|
||||
- CLI subcommand structure: `arrman [--json] {tv|film|films} {list|add|summary|profiles|rootfolders} [args]`
|
||||
- `--json` flag outputs structured JSON (arrays for lists, objects for single items)
|
||||
- Sonarr uses TheTVDB IDs for series lookup; Radarr uses IMDB IDs for film lookup
|
||||
- Config loaded from `.env` file (SONARR_URL, SONARR_API_KEY, RADARR_URL, RADARR_API_KEY)
|
||||
|
||||
## Commands
|
||||
|
||||
- arrman tv list
|
||||
- arrman tv list "<title>"
|
||||
- arrman tv list "<title>" --include-external
|
||||
- arrman films list
|
||||
- arrman films list "<title>"
|
||||
- arrman films list "<title>" --include-external
|
||||
- arrman tv add <title>|<thetvdb series ID> --profile <id> --rootfolder <path>
|
||||
- arrman film add <title>|<IMDB ID> --profile <id> --rootfolder <path>
|
||||
- arrman tv summary <title>|<thetvdb series ID>
|
||||
- arrman film summary <title>|<IMDB ID>
|
||||
- arrman tv profiles
|
||||
- arrman film profiles
|
||||
- arrman tv rootfolders
|
||||
- arrman film rootfolders
|
||||
@@ -0,0 +1,88 @@
|
||||
# arrman
|
||||
|
||||
A CLI tool for managing Sonarr (TV) and Radarr (films) libraries.
|
||||
|
||||
Single self-contained Go binary with no external dependencies. Talks to the Sonarr and Radarr v3 APIs using API tokens.
|
||||
|
||||
## Setup
|
||||
|
||||
Create a `.env` file in the project root:
|
||||
|
||||
```
|
||||
SONARR_URL=https://your-sonarr-instance
|
||||
SONARR_API_KEY=your-sonarr-api-key
|
||||
RADARR_URL=https://your-radarr-instance
|
||||
RADARR_API_KEY=your-radarr-api-key
|
||||
```
|
||||
|
||||
Set `ARRMAN_TLS_SKIP_VERIFY=true` to skip TLS certificate verification (e.g. for self-signed certs).
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
go build -ldflags "-X main.Version=$(cat VERSION)" -o arrman .
|
||||
```
|
||||
|
||||
Or use the publish script, which handles version incrementing:
|
||||
|
||||
```bash
|
||||
./publish.sh # interactive — asks whether to increment
|
||||
./publish.sh --increment # non-interactive — auto-increments patch version
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
arrman <domain> <action> [args...]
|
||||
```
|
||||
|
||||
Domains: `tv`, `film` (or `films`)
|
||||
|
||||
### List library
|
||||
|
||||
```bash
|
||||
arrman tv list # all TV series
|
||||
arrman tv list "breaking" # filter by title
|
||||
arrman tv list "breaking" --include-external # include results from TheTVDB
|
||||
|
||||
arrman films list # all films
|
||||
arrman films list "matrix" # filter by title
|
||||
arrman films list "matrix" --include-external # include results from Radarr sources
|
||||
```
|
||||
|
||||
### Add to library
|
||||
|
||||
Search and add use fuzzy matching, so you don't need an exact title:
|
||||
|
||||
```bash
|
||||
arrman tv add "brking bad" --profile 1 --rootfolder /tv # fuzzy matches "Breaking Bad"
|
||||
arrman tv add 81189 --profile 1 --rootfolder /tv # by TheTVDB ID
|
||||
|
||||
arrman film add "the matrx" --profile 1 --rootfolder /movies # fuzzy matches "The Matrix"
|
||||
arrman film add tt0133093 --profile 1 --rootfolder /movies # by IMDB ID
|
||||
```
|
||||
|
||||
If the match isn't confident enough, arrman shows suggestions with IDs so you can be more specific.
|
||||
|
||||
Use `profiles` and `rootfolders` to find valid values:
|
||||
|
||||
```bash
|
||||
arrman tv profiles
|
||||
arrman tv rootfolders
|
||||
arrman film profiles
|
||||
arrman film rootfolders
|
||||
```
|
||||
|
||||
### Show details
|
||||
|
||||
```bash
|
||||
arrman tv summary "Breaking Bad"
|
||||
arrman tv summary 81189 # by TheTVDB ID
|
||||
|
||||
arrman film summary "The Matrix"
|
||||
arrman film summary tt0133093 # by IMDB ID
|
||||
```
|
||||
|
||||
## Agent Skill
|
||||
|
||||
A [SKILL.md](SKILL.md) file is included for use with [Claude Code](https://claude.ai/code) and other SKILL compatible AI agents. It provides structured instructions so the agent can operate arrman on your behalf — searching, listing, and adding media through natural conversation.
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: arrman
|
||||
description: Manage TV series (Sonarr) and films (Radarr) using the arrman CLI. Use this skill whenever the user mentions adding, listing, searching, or getting details about TV shows, series, films, or movies in their media library — including phrases like "add this show", "is X in my library", "what films do I have", "add the movie Y", "show me my TV series", "what quality profiles are available", or any reference to Sonarr, Radarr, TVDB, or IMDB IDs.
|
||||
---
|
||||
|
||||
# arrman — Media Library Manager
|
||||
|
||||
arrman is a CLI tool at `/home/steve/src/arrman/arrman` that manages Steve's TV series (via Sonarr) and films (via Radarr). It talks to the Sonarr and Radarr APIs.
|
||||
|
||||
## Commands
|
||||
|
||||
**Always pass `--json` for structured output.** This gives you clean JSON instead of human-formatted text.
|
||||
|
||||
```
|
||||
arrman --json tv list # List all TV series in library
|
||||
arrman --json tv list "<search>" # Search library by title
|
||||
arrman --json tv list "<search>" --include-external # Search library + external (TVDB)
|
||||
arrman --json films list # List all films in library
|
||||
arrman --json films list "<search>" # Search library by title
|
||||
arrman --json films list "<search>" --include-external # Search library + external (TMDB)
|
||||
arrman --json tv add <term> --profile <id> --rootfolder <path> # Add TV series
|
||||
arrman --json film add <term> --profile <id> --rootfolder <path> # Add film
|
||||
arrman --json tv summary <term> # Detailed info about a TV series
|
||||
arrman --json film summary <term> # Detailed info about a film
|
||||
arrman --json tv profiles # List available TV quality profiles
|
||||
arrman --json film profiles # List available film quality profiles
|
||||
arrman --json tv rootfolders # List available TV root folders
|
||||
arrman --json film rootfolders # List available film root folders
|
||||
```
|
||||
|
||||
## How to use
|
||||
|
||||
### Searching and listing
|
||||
|
||||
- `list` with no argument shows everything in the library
|
||||
- `list "<title>"` searches the library by title (case-insensitive substring match)
|
||||
- `list "<title>" --include-external` also searches external sources (TVDB for TV, TMDB for films)
|
||||
- `summary` shows detailed info — use a title or an ID (TVDB for TV, IMDB for films)
|
||||
- Fuzzy matching is built in — close-enough titles will match
|
||||
|
||||
### Adding media
|
||||
|
||||
The `add` command requires **both** `--profile` and `--rootfolder`. These tell Sonarr/Radarr which quality to download and where to store files.
|
||||
|
||||
**Before adding anything, you need to know the profile ID and root folder path.** If the user hasn't already specified these (either in conversation or via a memory/instruction), you must ask. To help them choose, run:
|
||||
|
||||
```
|
||||
arrman tv profiles # for TV
|
||||
arrman film profiles # for films
|
||||
arrman tv rootfolders # for TV
|
||||
arrman film rootfolders # for films
|
||||
```
|
||||
|
||||
Present the options to the user and let them pick. Do not guess or assume defaults.
|
||||
|
||||
### Identifying media for add
|
||||
|
||||
- **TV series by TVDB ID**: if the term is all digits, arrman looks it up as a TVDB ID (e.g. `arrman tv add 81189 --profile 4 --rootfolder /media/tv`)
|
||||
- **TV series by name**: fuzzy-matched against search results (e.g. `arrman tv add "Breaking Bad" --profile 4 --rootfolder /media/tv`)
|
||||
- **Film by IMDB ID**: if the term starts with `tt`, arrman looks it up as an IMDB ID (e.g. `arrman film add tt1160419 --profile 6 --rootfolder /media/movies`)
|
||||
- **Film by name**: fuzzy-matched against search results (e.g. `arrman film add "Dune" --profile 6 --rootfolder /media/movies`)
|
||||
|
||||
### Safety
|
||||
|
||||
- arrman checks if something is already in the library before adding and will refuse with a clear message
|
||||
- If fuzzy matching can't find a confident match, it shows suggestions with IDs so the user can be more specific
|
||||
|
||||
## Workflow for adding
|
||||
|
||||
1. If the user asks to add something, first check whether you already know the quality profile and root folder (from conversation context, user instructions, or memory)
|
||||
2. If you don't know either, run the `profiles` and `rootfolders` commands for the relevant domain (tv or film) and present the options
|
||||
3. Once you have profile and rootfolder, run the `add` command
|
||||
4. Report back the result — either success confirmation or the error message
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.dcglab.co.uk/steve/arrman/radarr"
|
||||
)
|
||||
|
||||
type RadarrClient interface {
|
||||
List() ([]radarr.Movie, error)
|
||||
Lookup(term string) ([]radarr.Movie, error)
|
||||
LookupIMDB(imdbID string) (*radarr.Movie, error)
|
||||
Get(id int) (*radarr.Movie, error)
|
||||
QualityProfiles() ([]radarr.QualityProfile, error)
|
||||
RootFolders() ([]radarr.RootFolder, error)
|
||||
Add(m radarr.Movie) (*radarr.Movie, error)
|
||||
}
|
||||
|
||||
func filterMovies(movies []radarr.Movie, term string) []radarr.Movie {
|
||||
lower := strings.ToLower(term)
|
||||
var filtered []radarr.Movie
|
||||
for _, m := range movies {
|
||||
if strings.Contains(strings.ToLower(m.Title), lower) {
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func FilmList(client RadarrClient, term string, includeExternal bool, jsonOut bool) error {
|
||||
var movies []radarr.Movie
|
||||
var err error
|
||||
|
||||
if term != "" && includeExternal {
|
||||
movies, err = client.Lookup(term)
|
||||
} else {
|
||||
movies, err = client.List()
|
||||
if err == nil && term != "" {
|
||||
movies = filterMovies(movies, term)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if jsonOut {
|
||||
out := make([]jsonMovie, len(movies))
|
||||
for i, m := range movies {
|
||||
out[i] = movieToJSON(m)
|
||||
}
|
||||
return writeJSON(out)
|
||||
}
|
||||
|
||||
if len(movies) == 0 {
|
||||
fmt.Println("No films found")
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, m := range movies {
|
||||
hasFile := "no"
|
||||
if m.HasFile {
|
||||
hasFile = "yes"
|
||||
}
|
||||
fmt.Printf("%s (%d) — %s — HasFile: %s\n", m.Title, m.Year, m.Status, hasFile)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func FilmAdd(client RadarrClient, args []string, jsonOut bool) error {
|
||||
term, profile, rootfolder, err := parseAddArgs(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
profileID, err := strconv.Atoi(profile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--profile must be a number")
|
||||
}
|
||||
|
||||
var movie *radarr.Movie
|
||||
|
||||
// Check if term looks like an IMDB ID (starts with "tt")
|
||||
if strings.HasPrefix(term, "tt") {
|
||||
m, err := client.LookupIMDB(term)
|
||||
if err != nil {
|
||||
return fmt.Errorf("No film found with IMDB ID %s", term)
|
||||
}
|
||||
if m.Title == "" {
|
||||
return fmt.Errorf("No film found with IMDB ID %s", term)
|
||||
}
|
||||
movie = m
|
||||
} else {
|
||||
results, err := client.Lookup(term)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return fmt.Errorf("No films found for '%s'", term)
|
||||
}
|
||||
|
||||
// Rank results by similarity
|
||||
titles := make([]string, len(results))
|
||||
for i, m := range results {
|
||||
titles[i] = m.Title
|
||||
}
|
||||
ranked := rankMatches(term, titles)
|
||||
|
||||
if ranked[0].Similarity >= fuzzyThreshold {
|
||||
movie = &results[ranked[0].Index]
|
||||
} else {
|
||||
if jsonOut {
|
||||
suggestions := jsonSuggestions{
|
||||
Error: fmt.Sprintf("no match for '%s'", term),
|
||||
Suggestions: make([]jsonSuggestion, 0),
|
||||
}
|
||||
limit := 5
|
||||
if len(ranked) < limit {
|
||||
limit = len(ranked)
|
||||
}
|
||||
for _, rm := range ranked[:limit] {
|
||||
m := results[rm.Index]
|
||||
suggestions.Suggestions = append(suggestions.Suggestions, jsonSuggestion{
|
||||
Title: m.Title,
|
||||
Year: m.Year,
|
||||
ImdbID: m.ImdbID,
|
||||
})
|
||||
}
|
||||
writeJSON(suggestions)
|
||||
return fmt.Errorf("no match found")
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "No match for '%s'. Did you mean:\n", term)
|
||||
limit := 5
|
||||
if len(ranked) < limit {
|
||||
limit = len(ranked)
|
||||
}
|
||||
for _, rm := range ranked[:limit] {
|
||||
m := results[rm.Index]
|
||||
fmt.Fprintf(os.Stderr, " %s (%d) — IMDB ID: %s\n", m.Title, m.Year, m.ImdbID)
|
||||
}
|
||||
return fmt.Errorf("no match found")
|
||||
}
|
||||
}
|
||||
|
||||
if movie.ID != 0 {
|
||||
return fmt.Errorf("'%s' (%d) is already in your library", movie.Title, movie.Year)
|
||||
}
|
||||
|
||||
movie.QualityProfileID = profileID
|
||||
movie.RootFolderPath = rootfolder
|
||||
movie.Monitored = true
|
||||
movie.MinimumAvailability = "released"
|
||||
movie.AddOptions = &radarr.AddOptions{SearchForMovie: true}
|
||||
|
||||
result, err := client.Add(*movie)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if jsonOut {
|
||||
return writeJSON(jsonAdded{
|
||||
Status: "added",
|
||||
Title: result.Title,
|
||||
Year: result.Year,
|
||||
ImdbID: result.ImdbID,
|
||||
})
|
||||
}
|
||||
|
||||
fmt.Printf("Added: %s (%d) — IMDB ID: %s\n", result.Title, result.Year, result.ImdbID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func FilmSummary(client RadarrClient, term string, jsonOut bool) error {
|
||||
if term == "" {
|
||||
return fmt.Errorf("usage: arrman film summary <title or IMDB ID>")
|
||||
}
|
||||
|
||||
var m *radarr.Movie
|
||||
|
||||
if strings.HasPrefix(term, "tt") {
|
||||
var err error
|
||||
m, err = client.LookupIMDB(term)
|
||||
if err != nil {
|
||||
return fmt.Errorf("No film found with IMDB ID %s", term)
|
||||
}
|
||||
if m.Title == "" {
|
||||
return fmt.Errorf("No film found with IMDB ID %s", term)
|
||||
}
|
||||
} else {
|
||||
results, err := client.Lookup(term)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return fmt.Errorf("No films found for '%s'", term)
|
||||
}
|
||||
titles := make([]string, len(results))
|
||||
for i, r := range results {
|
||||
titles[i] = r.Title
|
||||
}
|
||||
ranked := rankMatches(term, titles)
|
||||
m = &results[ranked[0].Index]
|
||||
}
|
||||
|
||||
if jsonOut {
|
||||
return writeJSON(movieToJSON(*m))
|
||||
}
|
||||
|
||||
fmt.Printf("Title: %s\n", m.Title)
|
||||
fmt.Printf("Year: %d\n", m.Year)
|
||||
fmt.Printf("Status: %s\n", m.Status)
|
||||
if m.Studio != "" {
|
||||
fmt.Printf("Studio: %s\n", m.Studio)
|
||||
}
|
||||
if m.Runtime > 0 {
|
||||
fmt.Printf("Runtime: %d min\n", m.Runtime)
|
||||
}
|
||||
if m.ImdbID != "" {
|
||||
fmt.Printf("IMDB ID: %s\n", m.ImdbID)
|
||||
}
|
||||
hasFile := m.HasFile || (m.MovieFile != nil && m.MovieFile.RelativePath != "")
|
||||
if hasFile {
|
||||
fmt.Println("HasFile: yes")
|
||||
} else {
|
||||
fmt.Println("HasFile: no")
|
||||
}
|
||||
if m.MovieFile != nil {
|
||||
fmt.Printf("File: %s\n", m.MovieFile.RelativePath)
|
||||
fmt.Printf("Size: %s\n", formatBytes(m.MovieFile.Size))
|
||||
if m.MovieFile.Quality != nil {
|
||||
fmt.Printf("Quality: %s\n", m.MovieFile.Quality.Quality.Name)
|
||||
}
|
||||
}
|
||||
if m.Added != "" {
|
||||
fmt.Printf("Added: %s\n", m.Added)
|
||||
}
|
||||
if m.Overview != "" {
|
||||
fmt.Printf("Overview: %s\n", m.Overview)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func FilmProfiles(client RadarrClient, jsonOut bool) error {
|
||||
profiles, err := client.QualityProfiles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOut {
|
||||
out := make([]jsonProfile, len(profiles))
|
||||
for i, p := range profiles {
|
||||
out[i] = jsonProfile{ID: p.ID, Name: p.Name}
|
||||
}
|
||||
return writeJSON(out)
|
||||
}
|
||||
for _, p := range profiles {
|
||||
fmt.Printf("ID: %d — Name: %s\n", p.ID, p.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func FilmRootFolders(client RadarrClient, jsonOut bool) error {
|
||||
folders, err := client.RootFolders()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOut {
|
||||
out := make([]jsonRootFolder, len(folders))
|
||||
for i, f := range folders {
|
||||
out[i] = jsonRootFolder{ID: f.ID, Path: f.Path, FreeSpace: f.FreeSpace, FreeHuman: formatBytes(f.FreeSpace)}
|
||||
}
|
||||
return writeJSON(out)
|
||||
}
|
||||
for _, f := range folders {
|
||||
fmt.Printf("ID: %d — Path: %s — Free: %s\n", f.ID, f.Path, formatBytes(f.FreeSpace))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func movieToJSON(m radarr.Movie) jsonMovie {
|
||||
jm := jsonMovie{
|
||||
Title: m.Title,
|
||||
Year: m.Year,
|
||||
Status: m.Status,
|
||||
ImdbID: m.ImdbID,
|
||||
Studio: m.Studio,
|
||||
Runtime: m.Runtime,
|
||||
HasFile: m.HasFile || (m.MovieFile != nil && m.MovieFile.RelativePath != ""),
|
||||
Overview: m.Overview,
|
||||
Added: m.Added,
|
||||
}
|
||||
if m.MovieFile != nil {
|
||||
jm.File = m.MovieFile.RelativePath
|
||||
jm.Size = m.MovieFile.Size
|
||||
if m.MovieFile.Quality != nil {
|
||||
jm.Quality = m.MovieFile.Quality.Quality.Name
|
||||
}
|
||||
}
|
||||
return jm
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dcglab.co.uk/steve/arrman/radarr"
|
||||
)
|
||||
|
||||
type mockRadarr struct {
|
||||
movies []radarr.Movie
|
||||
added *radarr.Movie
|
||||
profiles []radarr.QualityProfile
|
||||
folders []radarr.RootFolder
|
||||
lookupFn func(term string) ([]radarr.Movie, error)
|
||||
lookupIMDB func(id string) (*radarr.Movie, error)
|
||||
addErr error
|
||||
}
|
||||
|
||||
func (m *mockRadarr) List() ([]radarr.Movie, error) {
|
||||
return m.movies, nil
|
||||
}
|
||||
|
||||
func (m *mockRadarr) Lookup(term string) ([]radarr.Movie, error) {
|
||||
if m.lookupFn != nil {
|
||||
return m.lookupFn(term)
|
||||
}
|
||||
return m.movies, nil
|
||||
}
|
||||
|
||||
func (m *mockRadarr) LookupIMDB(imdbID string) (*radarr.Movie, error) {
|
||||
if m.lookupIMDB != nil {
|
||||
return m.lookupIMDB(imdbID)
|
||||
}
|
||||
return nil, fmt.Errorf("not found")
|
||||
}
|
||||
|
||||
func (m *mockRadarr) Get(id int) (*radarr.Movie, error) {
|
||||
for _, mv := range m.movies {
|
||||
if mv.ID == id {
|
||||
return &mv, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRadarr) QualityProfiles() ([]radarr.QualityProfile, error) {
|
||||
return m.profiles, nil
|
||||
}
|
||||
|
||||
func (m *mockRadarr) RootFolders() ([]radarr.RootFolder, error) {
|
||||
return m.folders, nil
|
||||
}
|
||||
|
||||
func (m *mockRadarr) Add(mv radarr.Movie) (*radarr.Movie, error) {
|
||||
if m.addErr != nil {
|
||||
return nil, m.addErr
|
||||
}
|
||||
m.added = &mv
|
||||
mv.ID = 1
|
||||
return &mv, nil
|
||||
}
|
||||
|
||||
func TestFilmListEmpty(t *testing.T) {
|
||||
mock := &mockRadarr{}
|
||||
out := captureOutput(func() {
|
||||
FilmList(mock, "", false, false)
|
||||
})
|
||||
if !strings.Contains(out, "No films found") {
|
||||
t.Errorf("expected 'No films found', got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilmListWithItems(t *testing.T) {
|
||||
mock := &mockRadarr{
|
||||
movies: []radarr.Movie{
|
||||
{Title: "Dune", Year: 2021, Status: "released", HasFile: true},
|
||||
},
|
||||
}
|
||||
out := captureOutput(func() {
|
||||
FilmList(mock, "", false, false)
|
||||
})
|
||||
if !strings.Contains(out, "Dune (2021)") {
|
||||
t.Errorf("expected movie in output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "HasFile: yes") {
|
||||
t.Errorf("expected HasFile: yes, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilmAddByIMDB(t *testing.T) {
|
||||
mock := &mockRadarr{
|
||||
lookupIMDB: func(id string) (*radarr.Movie, error) {
|
||||
if id == "tt26443616" {
|
||||
return &radarr.Movie{Title: "Test Film", Year: 2024, ImdbID: "tt26443616", TmdbID: 12345}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("not found")
|
||||
},
|
||||
}
|
||||
out := captureOutput(func() {
|
||||
err := FilmAdd(mock, []string{"tt26443616", "--profile", "1", "--rootfolder", "/movies"}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "Added: Test Film") {
|
||||
t.Errorf("expected confirmation, got: %s", out)
|
||||
}
|
||||
if mock.added == nil {
|
||||
t.Fatal("expected Add to be called")
|
||||
}
|
||||
if mock.added.MinimumAvailability != "released" {
|
||||
t.Errorf("expected minimumAvailability=released, got %s", mock.added.MinimumAvailability)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilmAddMissingFlags(t *testing.T) {
|
||||
mock := &mockRadarr{}
|
||||
err := FilmAdd(mock, []string{"tt26443616"}, false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing flags")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilmAddIMDBNotFound(t *testing.T) {
|
||||
mock := &mockRadarr{
|
||||
lookupIMDB: func(id string) (*radarr.Movie, error) {
|
||||
return nil, fmt.Errorf("not found")
|
||||
},
|
||||
}
|
||||
err := FilmAdd(mock, []string{"tt0000000", "--profile", "1", "--rootfolder", "/movies"}, false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "No film found with IMDB ID tt0000000") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilmAddByTitle(t *testing.T) {
|
||||
mock := &mockRadarr{
|
||||
lookupFn: func(term string) ([]radarr.Movie, error) {
|
||||
return []radarr.Movie{
|
||||
{Title: "Dune", Year: 2021, ImdbID: "tt1160419"},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
out := captureOutput(func() {
|
||||
err := FilmAdd(mock, []string{"Dune", "--profile", "1", "--rootfolder", "/movies"}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "Added: Dune") {
|
||||
t.Errorf("expected confirmation, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilmAddFuzzyMatch(t *testing.T) {
|
||||
mock := &mockRadarr{
|
||||
lookupFn: func(term string) ([]radarr.Movie, error) {
|
||||
return []radarr.Movie{
|
||||
{Title: "Dune", Year: 2021, ImdbID: "tt1160419"},
|
||||
{Title: "Dune: Part Two", Year: 2024, ImdbID: "tt15239678"},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
out := captureOutput(func() {
|
||||
err := FilmAdd(mock, []string{"Dun", "--profile", "1", "--rootfolder", "/movies"}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "Added: Dune") {
|
||||
t.Errorf("expected fuzzy match to Dune, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilmAddNoMatch(t *testing.T) {
|
||||
mock := &mockRadarr{
|
||||
lookupFn: func(term string) ([]radarr.Movie, error) {
|
||||
return []radarr.Movie{
|
||||
{Title: "Something Completely Different", Year: 2020, ImdbID: "tt0000001"},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
err := FilmAdd(mock, []string{"xyz", "--profile", "1", "--rootfolder", "/movies"}, false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for no match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilmProfiles(t *testing.T) {
|
||||
mock := &mockRadarr{
|
||||
profiles: []radarr.QualityProfile{
|
||||
{ID: 1, Name: "HD-1080p"},
|
||||
},
|
||||
}
|
||||
out := captureOutput(func() {
|
||||
FilmProfiles(mock, false)
|
||||
})
|
||||
if !strings.Contains(out, "ID: 1 — Name: HD-1080p") {
|
||||
t.Errorf("expected profile output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilmRootFolders(t *testing.T) {
|
||||
mock := &mockRadarr{
|
||||
folders: []radarr.RootFolder{
|
||||
{ID: 1, Path: "/movies", FreeSpace: 214748364800},
|
||||
},
|
||||
}
|
||||
out := captureOutput(func() {
|
||||
FilmRootFolders(mock, false)
|
||||
})
|
||||
if !strings.Contains(out, "Path: /movies") {
|
||||
t.Errorf("expected folder output, got: %s", out)
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func parseAddArgs(args []string) (term, profile, rootfolder string, err error) {
|
||||
var termParts []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--profile":
|
||||
if i+1 >= len(args) {
|
||||
return "", "", "", fmt.Errorf("--profile requires a value")
|
||||
}
|
||||
i++
|
||||
profile = args[i]
|
||||
case "--rootfolder":
|
||||
if i+1 >= len(args) {
|
||||
return "", "", "", fmt.Errorf("--rootfolder requires a value")
|
||||
}
|
||||
i++
|
||||
rootfolder = args[i]
|
||||
default:
|
||||
termParts = append(termParts, args[i])
|
||||
}
|
||||
}
|
||||
|
||||
term = strings.Join(termParts, " ")
|
||||
if term == "" {
|
||||
return "", "", "", fmt.Errorf("usage: arrman <tv|film> add <term> --profile <id> --rootfolder <path>")
|
||||
}
|
||||
if profile == "" || rootfolder == "" {
|
||||
return "", "", "", fmt.Errorf("both --profile and --rootfolder are required")
|
||||
}
|
||||
return term, profile, rootfolder, nil
|
||||
}
|
||||
|
||||
func isAllDigits(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if !unicode.IsDigit(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// levenshtein computes the edit distance between two strings.
|
||||
func levenshtein(a, b string) int {
|
||||
a = strings.ToLower(a)
|
||||
b = strings.ToLower(b)
|
||||
if a == b {
|
||||
return 0
|
||||
}
|
||||
la, lb := len(a), len(b)
|
||||
if la == 0 {
|
||||
return lb
|
||||
}
|
||||
if lb == 0 {
|
||||
return la
|
||||
}
|
||||
|
||||
prev := make([]int, lb+1)
|
||||
curr := make([]int, lb+1)
|
||||
for j := range prev {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= la; i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= lb; j++ {
|
||||
cost := 1
|
||||
if a[i-1] == b[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
curr[j] = min(curr[j-1]+1, min(prev[j]+1, prev[j-1]+cost))
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[lb]
|
||||
}
|
||||
|
||||
// similarity returns a score between 0.0 and 1.0 indicating how similar two strings are.
|
||||
func similarity(a, b string) float64 {
|
||||
maxLen := len(a)
|
||||
if len(b) > maxLen {
|
||||
maxLen = len(b)
|
||||
}
|
||||
if maxLen == 0 {
|
||||
return 1.0
|
||||
}
|
||||
return 1.0 - float64(levenshtein(a, b))/float64(maxLen)
|
||||
}
|
||||
|
||||
// fuzzyThreshold is the minimum similarity score to consider a match.
|
||||
const fuzzyThreshold = 0.6
|
||||
|
||||
// bestMatch holds a title and its similarity score.
|
||||
type bestMatch struct {
|
||||
Index int
|
||||
Title string
|
||||
Similarity float64
|
||||
}
|
||||
|
||||
// rankMatches sorts candidates by similarity to the query and returns them.
|
||||
func rankMatches(query string, titles []string) []bestMatch {
|
||||
query = strings.ToLower(query)
|
||||
matches := make([]bestMatch, len(titles))
|
||||
for i, t := range titles {
|
||||
// Boost score if the title contains the query as a substring
|
||||
sim := similarity(query, strings.ToLower(t))
|
||||
if strings.Contains(strings.ToLower(t), query) {
|
||||
// Substring match gets a boost
|
||||
boost := float64(len(query)) / float64(len(t))
|
||||
if boost > sim {
|
||||
sim = boost
|
||||
}
|
||||
if sim < 0.7 {
|
||||
sim = 0.7
|
||||
}
|
||||
}
|
||||
matches[i] = bestMatch{Index: i, Title: t, Similarity: sim}
|
||||
}
|
||||
sort.Slice(matches, func(i, j int) bool {
|
||||
return matches[i].Similarity > matches[j].Similarity
|
||||
})
|
||||
return matches
|
||||
}
|
||||
|
||||
func formatBytes(b int64) string {
|
||||
const (
|
||||
gb = 1024 * 1024 * 1024
|
||||
mb = 1024 * 1024
|
||||
)
|
||||
switch {
|
||||
case b >= gb:
|
||||
return fmt.Sprintf("%.1f GB", float64(b)/float64(gb))
|
||||
case b >= mb:
|
||||
return fmt.Sprintf("%.1f MB", float64(b)/float64(mb))
|
||||
default:
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
)
|
||||
|
||||
func writeJSON(v any) error {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(v)
|
||||
}
|
||||
|
||||
// JSON output types — lightweight structs for clean, agent-friendly output.
|
||||
|
||||
type jsonSeries struct {
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year"`
|
||||
Status string `json:"status"`
|
||||
TvdbID int `json:"tvdb_id,omitempty"`
|
||||
Network string `json:"network,omitempty"`
|
||||
SeasonCount int `json:"season_count,omitempty"`
|
||||
EpisodesHave int `json:"episodes_have,omitempty"`
|
||||
EpisodesTotal int `json:"episodes_total,omitempty"`
|
||||
NextAiring string `json:"next_airing,omitempty"`
|
||||
Overview string `json:"overview,omitempty"`
|
||||
Added string `json:"added,omitempty"`
|
||||
}
|
||||
|
||||
type jsonMovie struct {
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year"`
|
||||
Status string `json:"status"`
|
||||
ImdbID string `json:"imdb_id,omitempty"`
|
||||
Studio string `json:"studio,omitempty"`
|
||||
Runtime int `json:"runtime,omitempty"`
|
||||
HasFile bool `json:"has_file"`
|
||||
File string `json:"file,omitempty"`
|
||||
Size int64 `json:"size_bytes,omitempty"`
|
||||
Quality string `json:"quality,omitempty"`
|
||||
Overview string `json:"overview,omitempty"`
|
||||
Added string `json:"added,omitempty"`
|
||||
}
|
||||
|
||||
type jsonProfile struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type jsonRootFolder struct {
|
||||
ID int `json:"id"`
|
||||
Path string `json:"path"`
|
||||
FreeSpace int64 `json:"free_space_bytes"`
|
||||
FreeHuman string `json:"free_space"`
|
||||
}
|
||||
|
||||
type jsonAdded struct {
|
||||
Status string `json:"status"`
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year"`
|
||||
TvdbID int `json:"tvdb_id,omitempty"`
|
||||
ImdbID string `json:"imdb_id,omitempty"`
|
||||
}
|
||||
|
||||
type jsonSuggestions struct {
|
||||
Error string `json:"error"`
|
||||
Suggestions []jsonSuggestion `json:"suggestions"`
|
||||
}
|
||||
|
||||
type jsonSuggestion struct {
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year"`
|
||||
TvdbID int `json:"tvdb_id,omitempty"`
|
||||
ImdbID string `json:"imdb_id,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"gitea.dcglab.co.uk/steve/arrman/sonarr"
|
||||
)
|
||||
|
||||
func filterSeries(series []sonarr.Series, term string) []sonarr.Series {
|
||||
lower := strings.ToLower(term)
|
||||
var filtered []sonarr.Series
|
||||
for _, s := range series {
|
||||
if strings.Contains(strings.ToLower(s.Title), lower) {
|
||||
filtered = append(filtered, s)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
type SonarrClient interface {
|
||||
List() ([]sonarr.Series, error)
|
||||
Lookup(term string) ([]sonarr.Series, error)
|
||||
Get(id int) (*sonarr.Series, error)
|
||||
QualityProfiles() ([]sonarr.QualityProfile, error)
|
||||
RootFolders() ([]sonarr.RootFolder, error)
|
||||
Add(s sonarr.Series) (*sonarr.Series, error)
|
||||
}
|
||||
|
||||
func TVList(client SonarrClient, term string, includeExternal bool, jsonOut bool) error {
|
||||
var series []sonarr.Series
|
||||
var err error
|
||||
|
||||
if term != "" && includeExternal {
|
||||
series, err = client.Lookup(term)
|
||||
} else {
|
||||
series, err = client.List()
|
||||
if err == nil && term != "" {
|
||||
series = filterSeries(series, term)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if jsonOut {
|
||||
out := make([]jsonSeries, len(series))
|
||||
for i, s := range series {
|
||||
out[i] = seriesToJSON(s)
|
||||
}
|
||||
return writeJSON(out)
|
||||
}
|
||||
|
||||
if len(series) == 0 {
|
||||
fmt.Println("No series found")
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, s := range series {
|
||||
eps := ""
|
||||
if s.Statistics != nil {
|
||||
pct := 0.0
|
||||
if s.Statistics.EpisodeCount > 0 {
|
||||
pct = float64(s.Statistics.EpisodeFileCount) / float64(s.Statistics.EpisodeCount) * 100
|
||||
}
|
||||
eps = fmt.Sprintf(" — Episodes: %d/%d (%.0f%%)", s.Statistics.EpisodeFileCount, s.Statistics.EpisodeCount, pct)
|
||||
}
|
||||
fmt.Printf("%s (%d) — %s%s\n", s.Title, s.Year, s.Status, eps)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TVAdd(client SonarrClient, args []string, jsonOut bool) error {
|
||||
term, profile, rootfolder, err := parseAddArgs(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
profileID, err := strconv.Atoi(profile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--profile must be a number")
|
||||
}
|
||||
|
||||
var series *sonarr.Series
|
||||
|
||||
// Check if term is all digits (TVDB ID)
|
||||
if isAllDigits(term) {
|
||||
results, err := client.Lookup("tvdb:" + term)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return fmt.Errorf("No series found with TVDB ID %s", term)
|
||||
}
|
||||
series = &results[0]
|
||||
} else {
|
||||
results, err := client.Lookup(term)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return fmt.Errorf("No series found for '%s'", term)
|
||||
}
|
||||
|
||||
// Rank results by similarity
|
||||
titles := make([]string, len(results))
|
||||
for i, s := range results {
|
||||
titles[i] = s.Title
|
||||
}
|
||||
ranked := rankMatches(term, titles)
|
||||
|
||||
if ranked[0].Similarity >= fuzzyThreshold {
|
||||
series = &results[ranked[0].Index]
|
||||
} else {
|
||||
if jsonOut {
|
||||
suggestions := jsonSuggestions{
|
||||
Error: fmt.Sprintf("no match for '%s'", term),
|
||||
Suggestions: make([]jsonSuggestion, 0),
|
||||
}
|
||||
limit := 5
|
||||
if len(ranked) < limit {
|
||||
limit = len(ranked)
|
||||
}
|
||||
for _, m := range ranked[:limit] {
|
||||
s := results[m.Index]
|
||||
suggestions.Suggestions = append(suggestions.Suggestions, jsonSuggestion{
|
||||
Title: s.Title,
|
||||
Year: s.Year,
|
||||
TvdbID: s.TvdbID,
|
||||
})
|
||||
}
|
||||
writeJSON(suggestions)
|
||||
return fmt.Errorf("no match found")
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "No match for '%s'. Did you mean:\n", term)
|
||||
limit := 5
|
||||
if len(ranked) < limit {
|
||||
limit = len(ranked)
|
||||
}
|
||||
for _, m := range ranked[:limit] {
|
||||
s := results[m.Index]
|
||||
fmt.Fprintf(os.Stderr, " %s (%d) — TVDB ID: %d\n", s.Title, s.Year, s.TvdbID)
|
||||
}
|
||||
return fmt.Errorf("no match found")
|
||||
}
|
||||
}
|
||||
|
||||
if series.ID != 0 {
|
||||
return fmt.Errorf("'%s' (%d) is already in your library", series.Title, series.Year)
|
||||
}
|
||||
|
||||
series.QualityProfileID = profileID
|
||||
series.RootFolderPath = rootfolder
|
||||
series.Monitored = true
|
||||
series.AddOptions = &sonarr.AddOptions{SearchForMissingEpisodes: true}
|
||||
|
||||
result, err := client.Add(*series)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if jsonOut {
|
||||
return writeJSON(jsonAdded{
|
||||
Status: "added",
|
||||
Title: result.Title,
|
||||
Year: result.Year,
|
||||
TvdbID: result.TvdbID,
|
||||
})
|
||||
}
|
||||
|
||||
fmt.Printf("Added: %s (%d) — TVDB ID: %d\n", result.Title, result.Year, result.TvdbID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TVSummary(client SonarrClient, term string, jsonOut bool) error {
|
||||
if term == "" {
|
||||
return fmt.Errorf("usage: arrman tv summary <title or TVDB ID>")
|
||||
}
|
||||
|
||||
var s *sonarr.Series
|
||||
|
||||
if isAllDigits(term) {
|
||||
results, err := client.Lookup("tvdb:" + term)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return fmt.Errorf("No series found with TVDB ID %s", term)
|
||||
}
|
||||
s = &results[0]
|
||||
} else {
|
||||
results, err := client.Lookup(term)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return fmt.Errorf("No series found for '%s'", term)
|
||||
}
|
||||
// Use best fuzzy match
|
||||
titles := make([]string, len(results))
|
||||
for i, r := range results {
|
||||
titles[i] = r.Title
|
||||
}
|
||||
ranked := rankMatches(term, titles)
|
||||
s = &results[ranked[0].Index]
|
||||
}
|
||||
|
||||
if jsonOut {
|
||||
return writeJSON(seriesToJSON(*s))
|
||||
}
|
||||
|
||||
fmt.Printf("Title: %s\n", s.Title)
|
||||
fmt.Printf("Year: %d\n", s.Year)
|
||||
fmt.Printf("Status: %s\n", s.Status)
|
||||
if s.Network != "" {
|
||||
fmt.Printf("Network: %s\n", s.Network)
|
||||
}
|
||||
fmt.Printf("Seasons: %d\n", s.SeasonCount)
|
||||
if s.Statistics != nil {
|
||||
pct := 0.0
|
||||
if s.Statistics.EpisodeCount > 0 {
|
||||
pct = float64(s.Statistics.EpisodeFileCount) / float64(s.Statistics.EpisodeCount) * 100
|
||||
}
|
||||
fmt.Printf("Episodes: %d/%d (%.0f%%)\n", s.Statistics.EpisodeFileCount, s.Statistics.EpisodeCount, pct)
|
||||
}
|
||||
if s.NextAiring != "" {
|
||||
fmt.Printf("Next Airing: %s\n", s.NextAiring)
|
||||
}
|
||||
if s.Added != "" {
|
||||
fmt.Printf("Added: %s\n", s.Added)
|
||||
}
|
||||
if s.Overview != "" {
|
||||
fmt.Printf("Overview: %s\n", s.Overview)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func TVProfiles(client SonarrClient, jsonOut bool) error {
|
||||
profiles, err := client.QualityProfiles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOut {
|
||||
out := make([]jsonProfile, len(profiles))
|
||||
for i, p := range profiles {
|
||||
out[i] = jsonProfile{ID: p.ID, Name: p.Name}
|
||||
}
|
||||
return writeJSON(out)
|
||||
}
|
||||
for _, p := range profiles {
|
||||
fmt.Printf("ID: %d — Name: %s\n", p.ID, p.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TVRootFolders(client SonarrClient, jsonOut bool) error {
|
||||
folders, err := client.RootFolders()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOut {
|
||||
out := make([]jsonRootFolder, len(folders))
|
||||
for i, f := range folders {
|
||||
out[i] = jsonRootFolder{ID: f.ID, Path: f.Path, FreeSpace: f.FreeSpace, FreeHuman: formatBytes(f.FreeSpace)}
|
||||
}
|
||||
return writeJSON(out)
|
||||
}
|
||||
for _, f := range folders {
|
||||
fmt.Printf("ID: %d — Path: %s — Free: %s\n", f.ID, f.Path, formatBytes(f.FreeSpace))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func seriesToJSON(s sonarr.Series) jsonSeries {
|
||||
js := jsonSeries{
|
||||
Title: s.Title,
|
||||
Year: s.Year,
|
||||
Status: s.Status,
|
||||
TvdbID: s.TvdbID,
|
||||
Network: s.Network,
|
||||
SeasonCount: s.SeasonCount,
|
||||
NextAiring: s.NextAiring,
|
||||
Overview: s.Overview,
|
||||
Added: s.Added,
|
||||
}
|
||||
if s.Statistics != nil {
|
||||
js.EpisodesHave = s.Statistics.EpisodeFileCount
|
||||
js.EpisodesTotal = s.Statistics.EpisodeCount
|
||||
}
|
||||
return js
|
||||
}
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dcglab.co.uk/steve/arrman/sonarr"
|
||||
)
|
||||
|
||||
type mockSonarr struct {
|
||||
series []sonarr.Series
|
||||
added *sonarr.Series
|
||||
profiles []sonarr.QualityProfile
|
||||
folders []sonarr.RootFolder
|
||||
lookupFn func(term string) ([]sonarr.Series, error)
|
||||
addErr error
|
||||
}
|
||||
|
||||
func (m *mockSonarr) List() ([]sonarr.Series, error) {
|
||||
return m.series, nil
|
||||
}
|
||||
|
||||
func (m *mockSonarr) Lookup(term string) ([]sonarr.Series, error) {
|
||||
if m.lookupFn != nil {
|
||||
return m.lookupFn(term)
|
||||
}
|
||||
return m.series, nil
|
||||
}
|
||||
|
||||
func (m *mockSonarr) Get(id int) (*sonarr.Series, error) {
|
||||
for _, s := range m.series {
|
||||
if s.ID == id {
|
||||
return &s, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockSonarr) QualityProfiles() ([]sonarr.QualityProfile, error) {
|
||||
return m.profiles, nil
|
||||
}
|
||||
|
||||
func (m *mockSonarr) RootFolders() ([]sonarr.RootFolder, error) {
|
||||
return m.folders, nil
|
||||
}
|
||||
|
||||
func (m *mockSonarr) Add(s sonarr.Series) (*sonarr.Series, error) {
|
||||
if m.addErr != nil {
|
||||
return nil, m.addErr
|
||||
}
|
||||
m.added = &s
|
||||
s.ID = 1
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func captureOutput(fn func()) string {
|
||||
old := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
fn()
|
||||
w.Close()
|
||||
os.Stdout = old
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, r)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestTVListEmpty(t *testing.T) {
|
||||
mock := &mockSonarr{}
|
||||
out := captureOutput(func() {
|
||||
TVList(mock, "", false, false)
|
||||
})
|
||||
if !strings.Contains(out, "No series found") {
|
||||
t.Errorf("expected 'No series found', got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTVListWithItems(t *testing.T) {
|
||||
mock := &mockSonarr{
|
||||
series: []sonarr.Series{
|
||||
{
|
||||
Title: "Breaking Bad",
|
||||
Year: 2008,
|
||||
Status: "ended",
|
||||
Statistics: &sonarr.Statistics{
|
||||
EpisodeFileCount: 62,
|
||||
EpisodeCount: 62,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
out := captureOutput(func() {
|
||||
TVList(mock, "", false, false)
|
||||
})
|
||||
if !strings.Contains(out, "Breaking Bad (2008)") {
|
||||
t.Errorf("expected series in output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "62/62") {
|
||||
t.Errorf("expected episode count, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTVAddByID(t *testing.T) {
|
||||
mock := &mockSonarr{
|
||||
lookupFn: func(term string) ([]sonarr.Series, error) {
|
||||
if term == "tvdb:448176" {
|
||||
return []sonarr.Series{{Title: "Test Show", Year: 2024, TvdbID: 448176}}, nil
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
out := captureOutput(func() {
|
||||
err := TVAdd(mock, []string{"448176", "--profile", "1", "--rootfolder", "/tv"}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "Added: Test Show") {
|
||||
t.Errorf("expected confirmation, got: %s", out)
|
||||
}
|
||||
if mock.added == nil {
|
||||
t.Fatal("expected Add to be called")
|
||||
}
|
||||
if mock.added.QualityProfileID != 1 {
|
||||
t.Errorf("expected profile 1, got %d", mock.added.QualityProfileID)
|
||||
}
|
||||
if mock.added.RootFolderPath != "/tv" {
|
||||
t.Errorf("expected rootfolder /tv, got %s", mock.added.RootFolderPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTVAddMissingFlags(t *testing.T) {
|
||||
mock := &mockSonarr{}
|
||||
err := TVAdd(mock, []string{"448176"}, false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing flags")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTVAddNoResult(t *testing.T) {
|
||||
mock := &mockSonarr{
|
||||
lookupFn: func(term string) ([]sonarr.Series, error) {
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
err := TVAdd(mock, []string{"999999", "--profile", "1", "--rootfolder", "/tv"}, false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "No series found with TVDB ID 999999") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTVAddByTitle(t *testing.T) {
|
||||
mock := &mockSonarr{
|
||||
lookupFn: func(term string) ([]sonarr.Series, error) {
|
||||
return []sonarr.Series{
|
||||
{Title: "Breaking Bad", Year: 2008, TvdbID: 81189},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
out := captureOutput(func() {
|
||||
err := TVAdd(mock, []string{"Breaking Bad", "--profile", "1", "--rootfolder", "/tv"}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "Added: Breaking Bad") {
|
||||
t.Errorf("expected confirmation, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTVAddFuzzyMatch(t *testing.T) {
|
||||
mock := &mockSonarr{
|
||||
lookupFn: func(term string) ([]sonarr.Series, error) {
|
||||
return []sonarr.Series{
|
||||
{Title: "Breaking Bad", Year: 2008, TvdbID: 81189},
|
||||
{Title: "Better Call Saul", Year: 2015, TvdbID: 273181},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
out := captureOutput(func() {
|
||||
err := TVAdd(mock, []string{"Breaking", "Bad", "--profile", "1", "--rootfolder", "/tv"}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "Added: Breaking Bad") {
|
||||
t.Errorf("expected fuzzy match to Breaking Bad, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTVAddNoMatch(t *testing.T) {
|
||||
mock := &mockSonarr{
|
||||
lookupFn: func(term string) ([]sonarr.Series, error) {
|
||||
return []sonarr.Series{
|
||||
{Title: "Something Completely Different", Year: 2020, TvdbID: 99999},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
err := TVAdd(mock, []string{"xyz", "--profile", "1", "--rootfolder", "/tv"}, false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for no match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTVProfiles(t *testing.T) {
|
||||
mock := &mockSonarr{
|
||||
profiles: []sonarr.QualityProfile{
|
||||
{ID: 1, Name: "HD-1080p"},
|
||||
{ID: 4, Name: "Ultra-HD"},
|
||||
},
|
||||
}
|
||||
out := captureOutput(func() {
|
||||
TVProfiles(mock, false)
|
||||
})
|
||||
if !strings.Contains(out, "ID: 1 — Name: HD-1080p") {
|
||||
t.Errorf("expected profile output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTVRootFolders(t *testing.T) {
|
||||
mock := &mockSonarr{
|
||||
folders: []sonarr.RootFolder{
|
||||
{ID: 1, Path: "/tv", FreeSpace: 107374182400},
|
||||
},
|
||||
}
|
||||
out := captureOutput(func() {
|
||||
TVRootFolders(mock, false)
|
||||
})
|
||||
if !strings.Contains(out, "Path: /tv") {
|
||||
t.Errorf("expected folder output, got: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitea.dcglab.co.uk/steve/arrman/cmd"
|
||||
"gitea.dcglab.co.uk/steve/arrman/radarr"
|
||||
"gitea.dcglab.co.uk/steve/arrman/sonarr"
|
||||
)
|
||||
|
||||
// Version is set at build time via -ldflags.
|
||||
var Version = "dev"
|
||||
|
||||
func main() {
|
||||
loadEnv()
|
||||
|
||||
if len(os.Args) >= 2 && os.Args[1] == "version" {
|
||||
fmt.Println(Version)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract global --json flag from any position in args
|
||||
filteredArgs, jsonOutput := extractFlag(os.Args[1:], "--json")
|
||||
if len(filteredArgs) < 2 {
|
||||
usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
domain := filteredArgs[0]
|
||||
action := filteredArgs[1]
|
||||
rest := filteredArgs[2:]
|
||||
|
||||
tlsSkip := strings.EqualFold(os.Getenv("ARRMAN_TLS_SKIP_VERIFY"), "true")
|
||||
|
||||
var err error
|
||||
|
||||
switch domain {
|
||||
case "tv":
|
||||
sonarrURL := requireEnv("SONARR_URL")
|
||||
sonarrKey := requireEnv("SONARR_API_KEY")
|
||||
client := sonarr.NewClient(sonarrURL, sonarrKey, tlsSkip)
|
||||
err = runTV(client, action, rest, jsonOutput)
|
||||
|
||||
case "film", "films":
|
||||
radarrURL := requireEnv("RADARR_URL")
|
||||
radarrKey := requireEnv("RADARR_API_KEY")
|
||||
client := radarr.NewClient(radarrURL, radarrKey, tlsSkip)
|
||||
err = runFilm(client, action, rest, jsonOutput)
|
||||
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Unknown domain: %s\n", domain)
|
||||
usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func extractFlag(args []string, flag string) ([]string, bool) {
|
||||
var remaining []string
|
||||
found := false
|
||||
for _, a := range args {
|
||||
if a == flag {
|
||||
found = true
|
||||
} else {
|
||||
remaining = append(remaining, a)
|
||||
}
|
||||
}
|
||||
return remaining, found
|
||||
}
|
||||
|
||||
func runTV(client cmd.SonarrClient, action string, args []string, jsonOutput bool) error {
|
||||
switch action {
|
||||
case "list":
|
||||
rest, inclExt := extractFlag(args, "--include-external")
|
||||
term := strings.Join(rest, " ")
|
||||
return cmd.TVList(client, term, inclExt, jsonOutput)
|
||||
case "add":
|
||||
return cmd.TVAdd(client, args, jsonOutput)
|
||||
case "summary":
|
||||
term := strings.Join(args, " ")
|
||||
return cmd.TVSummary(client, term, jsonOutput)
|
||||
case "profiles":
|
||||
return cmd.TVProfiles(client, jsonOutput)
|
||||
case "rootfolders":
|
||||
return cmd.TVRootFolders(client, jsonOutput)
|
||||
default:
|
||||
return fmt.Errorf("unknown tv action: %s", action)
|
||||
}
|
||||
}
|
||||
|
||||
func runFilm(client cmd.RadarrClient, action string, args []string, jsonOutput bool) error {
|
||||
switch action {
|
||||
case "list":
|
||||
rest, inclExt := extractFlag(args, "--include-external")
|
||||
term := strings.Join(rest, " ")
|
||||
return cmd.FilmList(client, term, inclExt, jsonOutput)
|
||||
case "add":
|
||||
return cmd.FilmAdd(client, args, jsonOutput)
|
||||
case "summary":
|
||||
term := strings.Join(args, " ")
|
||||
return cmd.FilmSummary(client, term, jsonOutput)
|
||||
case "profiles":
|
||||
return cmd.FilmProfiles(client, jsonOutput)
|
||||
case "rootfolders":
|
||||
return cmd.FilmRootFolders(client, jsonOutput)
|
||||
default:
|
||||
return fmt.Errorf("unknown film action: %s", action)
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, `Usage: arrman [--json] <domain> <action> [args...]
|
||||
|
||||
Domains: tv, film, films
|
||||
|
||||
Actions:
|
||||
list [title] [--include-external] List/search library (add flag to include external sources)
|
||||
add <term> --profile <id> --rootfolder <path> Add new
|
||||
summary <term> Show details
|
||||
profiles List quality profiles
|
||||
rootfolders List root folders
|
||||
|
||||
Global flags:
|
||||
--json Output results as structured JSON`)
|
||||
}
|
||||
|
||||
func loadEnv() {
|
||||
f, err := os.Open(".env")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := strings.TrimSpace(parts[1])
|
||||
if os.Getenv(key) == "" {
|
||||
os.Setenv(key, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requireEnv(key string) string {
|
||||
val := os.Getenv(key)
|
||||
if val == "" {
|
||||
fmt.Fprintf(os.Stderr, "Error: %s is not set. Check your .env file.\n", key)
|
||||
os.Exit(1)
|
||||
}
|
||||
return val
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
# 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
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VERSION_FILE="VERSION"
|
||||
|
||||
if [ ! -f "$VERSION_FILE" ]; then
|
||||
echo "Error: $VERSION_FILE not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
current=$(cat "$VERSION_FILE")
|
||||
echo "Current version: $current"
|
||||
|
||||
IFS='.' read -r major minor patch <<< "$current"
|
||||
|
||||
if [ "${1:-}" = "--increment" ]; then
|
||||
patch=$((patch + 1))
|
||||
new_version="${major}.${minor}.${patch}"
|
||||
echo "Auto-incrementing to: $new_version"
|
||||
else
|
||||
echo "Increment version? [y/N]"
|
||||
read -r answer
|
||||
if [[ "$answer" =~ ^[Yy] ]]; then
|
||||
echo " 1) patch (${major}.${minor}.$((patch + 1)))"
|
||||
echo " 2) minor (${major}.$((minor + 1)).0)"
|
||||
echo " 3) major ($((major + 1)).0.0)"
|
||||
echo -n "Choice [1]: "
|
||||
read -r choice
|
||||
case "${choice:-1}" in
|
||||
1) patch=$((patch + 1)); new_version="${major}.${minor}.${patch}" ;;
|
||||
2) minor=$((minor + 1)); patch=0; new_version="${major}.${minor}.${patch}" ;;
|
||||
3) major=$((major + 1)); minor=0; patch=0; new_version="${major}.${minor}.${patch}" ;;
|
||||
*) echo "Invalid choice" >&2; exit 1 ;;
|
||||
esac
|
||||
echo "New version: $new_version"
|
||||
else
|
||||
new_version="$current"
|
||||
echo "Keeping version: $new_version"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$new_version" > "$VERSION_FILE"
|
||||
|
||||
echo "Building arrman v${new_version}..."
|
||||
go build -ldflags "-X main.Version=${new_version}" -o arrman .
|
||||
|
||||
echo "Done: ./arrman v${new_version}"
|
||||
@@ -0,0 +1,153 @@
|
||||
package radarr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewClient(baseURL, apiKey string, tlsSkipVerify bool) *Client {
|
||||
client := &http.Client{}
|
||||
if strings.HasPrefix(baseURL, "https") && tlsSkipVerify {
|
||||
client.Transport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
}
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
apiKey: apiKey,
|
||||
httpClient: client,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) do(method, path string, body interface{}) ([]byte, error) {
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
reqBody = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, c.baseURL+path, reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("X-Api-Key", c.apiKey)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
func (c *Client) List() ([]Movie, error) {
|
||||
data, err := c.do("GET", "/api/v3/movie", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var movies []Movie
|
||||
if err := json.Unmarshal(data, &movies); err != nil {
|
||||
return nil, fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
return movies, nil
|
||||
}
|
||||
|
||||
func (c *Client) Lookup(term string) ([]Movie, error) {
|
||||
data, err := c.do("GET", "/api/v3/movie/lookup?term="+url.QueryEscape(term), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var movies []Movie
|
||||
if err := json.Unmarshal(data, &movies); err != nil {
|
||||
return nil, fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
return movies, nil
|
||||
}
|
||||
|
||||
func (c *Client) LookupIMDB(imdbID string) (*Movie, error) {
|
||||
data, err := c.do("GET", "/api/v3/movie/lookup/imdb?imdbId="+url.QueryEscape(imdbID), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var movie Movie
|
||||
if err := json.Unmarshal(data, &movie); err != nil {
|
||||
return nil, fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
return &movie, nil
|
||||
}
|
||||
|
||||
func (c *Client) Get(id int) (*Movie, error) {
|
||||
data, err := c.do("GET", fmt.Sprintf("/api/v3/movie/%d", id), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m Movie
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return nil, fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (c *Client) QualityProfiles() ([]QualityProfile, error) {
|
||||
data, err := c.do("GET", "/api/v3/qualityprofile", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var profiles []QualityProfile
|
||||
if err := json.Unmarshal(data, &profiles); err != nil {
|
||||
return nil, fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
func (c *Client) RootFolders() ([]RootFolder, error) {
|
||||
data, err := c.do("GET", "/api/v3/rootfolder", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var folders []RootFolder
|
||||
if err := json.Unmarshal(data, &folders); err != nil {
|
||||
return nil, fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
return folders, nil
|
||||
}
|
||||
|
||||
func (c *Client) Add(m Movie) (*Movie, error) {
|
||||
data, err := c.do("POST", "/api/v3/movie", m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result Movie
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package radarr
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testClient(t *testing.T) *Client {
|
||||
t.Helper()
|
||||
url := os.Getenv("RADARR_URL")
|
||||
key := os.Getenv("RADARR_API_KEY")
|
||||
if url == "" || key == "" {
|
||||
t.Skip("RADARR_URL and RADARR_API_KEY not set")
|
||||
}
|
||||
return NewClient(url, key, true)
|
||||
}
|
||||
|
||||
func TestList(t *testing.T) {
|
||||
c := testClient(t)
|
||||
movies, err := c.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(movies) == 0 {
|
||||
t.Fatal("List returned no movies")
|
||||
}
|
||||
m := movies[0]
|
||||
if m.Title == "" {
|
||||
t.Error("first movie has no title")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookup(t *testing.T) {
|
||||
c := testClient(t)
|
||||
results, err := c.Lookup("Dune")
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup: %v", err)
|
||||
}
|
||||
if len(results) == 0 {
|
||||
t.Fatal("Lookup returned no results")
|
||||
}
|
||||
if results[0].Title == "" {
|
||||
t.Error("first result has no title")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQualityProfiles(t *testing.T) {
|
||||
c := testClient(t)
|
||||
profiles, err := c.QualityProfiles()
|
||||
if err != nil {
|
||||
t.Fatalf("QualityProfiles: %v", err)
|
||||
}
|
||||
if len(profiles) == 0 {
|
||||
t.Fatal("no quality profiles returned")
|
||||
}
|
||||
if profiles[0].Name == "" {
|
||||
t.Error("first profile has no name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootFolders(t *testing.T) {
|
||||
c := testClient(t)
|
||||
folders, err := c.RootFolders()
|
||||
if err != nil {
|
||||
t.Fatalf("RootFolders: %v", err)
|
||||
}
|
||||
if len(folders) == 0 {
|
||||
t.Fatal("no root folders returned")
|
||||
}
|
||||
if folders[0].Path == "" {
|
||||
t.Error("first folder has no path")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package radarr
|
||||
|
||||
type Movie struct {
|
||||
ID int `json:"id,omitempty"`
|
||||
Title string `json:"title"`
|
||||
SortTitle string `json:"sortTitle,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Overview string `json:"overview,omitempty"`
|
||||
Year int `json:"year,omitempty"`
|
||||
Studio string `json:"studio,omitempty"`
|
||||
Runtime int `json:"runtime,omitempty"`
|
||||
ImdbID string `json:"imdbId,omitempty"`
|
||||
TmdbID int `json:"tmdbId,omitempty"`
|
||||
TitleSlug string `json:"titleSlug,omitempty"`
|
||||
Images []Image `json:"images,omitempty"`
|
||||
HasFile bool `json:"hasFile,omitempty"`
|
||||
QualityProfileID int `json:"qualityProfileId,omitempty"`
|
||||
RootFolderPath string `json:"rootFolderPath,omitempty"`
|
||||
Monitored bool `json:"monitored"`
|
||||
MinimumAvailability string `json:"minimumAvailability,omitempty"`
|
||||
AddOptions *AddOptions `json:"addOptions,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
MovieFile *MovieFile `json:"movieFile,omitempty"`
|
||||
Added string `json:"added,omitempty"`
|
||||
}
|
||||
|
||||
type Image struct {
|
||||
CoverType string `json:"coverType"`
|
||||
URL string `json:"url"`
|
||||
RemoteURL string `json:"remoteUrl"`
|
||||
}
|
||||
|
||||
type AddOptions struct {
|
||||
SearchForMovie bool `json:"searchForMovie"`
|
||||
}
|
||||
|
||||
type MovieFile struct {
|
||||
RelativePath string `json:"relativePath"`
|
||||
Size int64 `json:"size"`
|
||||
Quality *struct {
|
||||
Quality struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"quality"`
|
||||
} `json:"quality,omitempty"`
|
||||
}
|
||||
|
||||
type QualityProfile struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type RootFolder struct {
|
||||
ID int `json:"id"`
|
||||
Path string `json:"path"`
|
||||
FreeSpace int64 `json:"freeSpace"`
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package sonarr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewClient(baseURL, apiKey string, tlsSkipVerify bool) *Client {
|
||||
client := &http.Client{}
|
||||
if strings.HasPrefix(baseURL, "https") && tlsSkipVerify {
|
||||
client.Transport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
}
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
apiKey: apiKey,
|
||||
httpClient: client,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) do(method, path string, body interface{}) ([]byte, error) {
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
reqBody = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, c.baseURL+path, reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("X-Api-Key", c.apiKey)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
func (c *Client) List() ([]Series, error) {
|
||||
data, err := c.do("GET", "/api/v3/series", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var series []Series
|
||||
if err := json.Unmarshal(data, &series); err != nil {
|
||||
return nil, fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
return series, nil
|
||||
}
|
||||
|
||||
func (c *Client) Lookup(term string) ([]Series, error) {
|
||||
data, err := c.do("GET", "/api/v3/series/lookup?term="+url.QueryEscape(term), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var series []Series
|
||||
if err := json.Unmarshal(data, &series); err != nil {
|
||||
return nil, fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
return series, nil
|
||||
}
|
||||
|
||||
func (c *Client) Get(id int) (*Series, error) {
|
||||
data, err := c.do("GET", fmt.Sprintf("/api/v3/series/%d", id), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var s Series
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return nil, fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (c *Client) QualityProfiles() ([]QualityProfile, error) {
|
||||
data, err := c.do("GET", "/api/v3/qualityprofile", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var profiles []QualityProfile
|
||||
if err := json.Unmarshal(data, &profiles); err != nil {
|
||||
return nil, fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
func (c *Client) RootFolders() ([]RootFolder, error) {
|
||||
data, err := c.do("GET", "/api/v3/rootfolder", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var folders []RootFolder
|
||||
if err := json.Unmarshal(data, &folders); err != nil {
|
||||
return nil, fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
return folders, nil
|
||||
}
|
||||
|
||||
func (c *Client) Add(s Series) (*Series, error) {
|
||||
data, err := c.do("POST", "/api/v3/series", s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result Series
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package sonarr
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testClient(t *testing.T) *Client {
|
||||
t.Helper()
|
||||
url := os.Getenv("SONARR_URL")
|
||||
key := os.Getenv("SONARR_API_KEY")
|
||||
if url == "" || key == "" {
|
||||
t.Skip("SONARR_URL and SONARR_API_KEY not set")
|
||||
}
|
||||
return NewClient(url, key, true)
|
||||
}
|
||||
|
||||
func TestList(t *testing.T) {
|
||||
c := testClient(t)
|
||||
series, err := c.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(series) == 0 {
|
||||
t.Fatal("List returned no series")
|
||||
}
|
||||
s := series[0]
|
||||
if s.Title == "" {
|
||||
t.Error("first series has no title")
|
||||
}
|
||||
if s.TvdbID == 0 {
|
||||
t.Error("first series has no TVDB ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookup(t *testing.T) {
|
||||
c := testClient(t)
|
||||
results, err := c.Lookup("Breaking Bad")
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup: %v", err)
|
||||
}
|
||||
if len(results) == 0 {
|
||||
t.Fatal("Lookup returned no results")
|
||||
}
|
||||
if results[0].Title == "" {
|
||||
t.Error("first result has no title")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQualityProfiles(t *testing.T) {
|
||||
c := testClient(t)
|
||||
profiles, err := c.QualityProfiles()
|
||||
if err != nil {
|
||||
t.Fatalf("QualityProfiles: %v", err)
|
||||
}
|
||||
if len(profiles) == 0 {
|
||||
t.Fatal("no quality profiles returned")
|
||||
}
|
||||
if profiles[0].Name == "" {
|
||||
t.Error("first profile has no name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootFolders(t *testing.T) {
|
||||
c := testClient(t)
|
||||
folders, err := c.RootFolders()
|
||||
if err != nil {
|
||||
t.Fatalf("RootFolders: %v", err)
|
||||
}
|
||||
if len(folders) == 0 {
|
||||
t.Fatal("no root folders returned")
|
||||
}
|
||||
if folders[0].Path == "" {
|
||||
t.Error("first folder has no path")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package sonarr
|
||||
|
||||
type Series struct {
|
||||
ID int `json:"id,omitempty"`
|
||||
Title string `json:"title"`
|
||||
SortTitle string `json:"sortTitle,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Overview string `json:"overview,omitempty"`
|
||||
Network string `json:"network,omitempty"`
|
||||
Year int `json:"year,omitempty"`
|
||||
SeasonCount int `json:"seasonCount,omitempty"`
|
||||
TvdbID int `json:"tvdbId,omitempty"`
|
||||
ImdbID string `json:"imdbId,omitempty"`
|
||||
TitleSlug string `json:"titleSlug,omitempty"`
|
||||
Seasons []Season `json:"seasons,omitempty"`
|
||||
Images []Image `json:"images,omitempty"`
|
||||
Statistics *Statistics `json:"statistics,omitempty"`
|
||||
QualityProfileID int `json:"qualityProfileId,omitempty"`
|
||||
RootFolderPath string `json:"rootFolderPath,omitempty"`
|
||||
Monitored bool `json:"monitored"`
|
||||
AddOptions *AddOptions `json:"addOptions,omitempty"`
|
||||
NextAiring string `json:"nextAiring,omitempty"`
|
||||
PreviousAiring string `json:"previousAiring,omitempty"`
|
||||
SeriesType string `json:"seriesType,omitempty"`
|
||||
LanguageProfileID int `json:"languageProfileId,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Added string `json:"added,omitempty"`
|
||||
}
|
||||
|
||||
type Season struct {
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
Monitored bool `json:"monitored"`
|
||||
Statistics *Statistics `json:"statistics,omitempty"`
|
||||
}
|
||||
|
||||
type Statistics struct {
|
||||
EpisodeFileCount int `json:"episodeFileCount"`
|
||||
EpisodeCount int `json:"episodeCount"`
|
||||
TotalEpisodeCount int `json:"totalEpisodeCount"`
|
||||
SizeOnDisk int64 `json:"sizeOnDisk"`
|
||||
PercentOfEpisodes float64 `json:"percentOfEpisodes"`
|
||||
}
|
||||
|
||||
type Image struct {
|
||||
CoverType string `json:"coverType"`
|
||||
URL string `json:"url"`
|
||||
RemoteURL string `json:"remoteUrl"`
|
||||
}
|
||||
|
||||
type AddOptions struct {
|
||||
SearchForMissingEpisodes bool `json:"searchForMissingEpisodes"`
|
||||
}
|
||||
|
||||
type QualityProfile struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type RootFolder struct {
|
||||
ID int `json:"id"`
|
||||
Path string `json:"path"`
|
||||
FreeSpace int64 `json:"freeSpace"`
|
||||
}
|
||||
Reference in New Issue
Block a user