Initial release

This commit is contained in:
2026-03-12 22:13:57 +00:00
commit 6c067287d7
21 changed files with 2458 additions and 0 deletions
+302
View File
@@ -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
}
+220
View File
@@ -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
View File
@@ -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
View File
@@ -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"`
}
+293
View File
@@ -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
View File
@@ -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)
}
}