Initial release
This commit is contained in:
+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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user