From ab6d0dacd87334842bbae6422ee79716e48e62c4 Mon Sep 17 00:00:00 2001 From: Calmcacil B <716671+calmcacil@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:25:30 +0200 Subject: [PATCH] refactor: comprehensive audit remediation across codebase Address 33 findings from codebase audit across maintainability, security, and correctness dimensions. Security: - Support PLEX_TOKEN, JELLYFIN_TOKEN, OVERSEERR_API_KEY env vars for tokens Code Quality: - Rename internal/http -> internal/httputil (avoids net/http shadow) - Eliminate 19 duplicated clr closures -> colors.ClrFunc() helper - Remove ctx from ToolConfig struct, pass context.Context explicitly - Standardize JSONOutput field name across feed and other tools - Add .golangci.yml linter configuration Terminal/UI: - Replace emoji with text alternatives ([MOVIE], [TV], !) - Fix clearScreen() to actually clear terminal - Replace os.Exit(1) with return in watch mode for proper cursor restore - Route 4 diagnostic messages from stdout to stderr Correctness: - Simplify allFailed() switch in streams.go - Add deduplication by ID in feed watch mode event cache - Accept config version >= 1 instead of requiring exactly 1 - Add theme validation with colors.ValidateTheme() - Define constants for magic number label widths in streams.go Testing: - Add 5 cmd/*_test.go files (one per CLI tool, previously untested) - Add httputil/client_test.go with comprehensive HTTP tests - Add colors/colors_test.go with palette and helper tests - Add TokenFromEnv test in config_test.go Build: - Add make fmt target --- .golangci.yml | 52 ++++++ Makefile | 7 +- cmd/arr-feed/main.go | 9 +- cmd/arr-feed/main_test.go | 17 ++ cmd/calmstoolkit-setup/main.go | 2 +- cmd/calmstoolkit-setup/main_test.go | 21 +++ cmd/media-calendar/main.go | 6 + cmd/media-calendar/main_test.go | 17 ++ cmd/media-requests/main.go | 8 +- cmd/media-requests/main_test.go | 21 +++ cmd/media-streams/main.go | 10 +- cmd/media-streams/main_test.go | 17 ++ internal/calendar/calendar.go | 14 +- internal/calendar/calendar_test.go | 8 +- internal/calendar/render.go | 9 +- internal/colors/colors.go | 18 +++ internal/colors/colors_test.go | 105 ++++++++++++ internal/config/config.go | 12 +- internal/config/config_test.go | 16 ++ internal/feed/feed.go | 53 +++--- internal/feed/feed_test.go | 26 +-- internal/{http => httputil}/client.go | 4 +- internal/httputil/client_test.go | 131 +++++++++++++++ internal/requests/requests.go | 222 +++++++++----------------- internal/requests/requests_test.go | 45 +++--- internal/streams/streams.go | 88 ++++------ internal/streams/streams_test.go | 6 +- 27 files changed, 651 insertions(+), 293 deletions(-) create mode 100644 .golangci.yml create mode 100644 cmd/arr-feed/main_test.go create mode 100644 cmd/calmstoolkit-setup/main_test.go create mode 100644 cmd/media-calendar/main_test.go create mode 100644 cmd/media-requests/main_test.go create mode 100644 cmd/media-streams/main_test.go create mode 100644 internal/colors/colors_test.go rename internal/{http => httputil}/client.go (96%) create mode 100644 internal/httputil/client_test.go diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..1030645 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,52 @@ +run: + timeout: 5m + +linters: + enable: + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - unused + - gocritic + - prealloc + - unconvert + - whitespace + - nolintlint + disable: + - exhaustruct + - forcetypeassert + - gochecknoglobals + - gochecknoinits + +linters-settings: + errcheck: + check-type-assertions: true + check-blank: false + gocritic: + enabled-tags: + - performance + - diagnostic + - experimental + disabled-checks: + - paramTypeCombine + staticcheck: + checks: + - all + - -SA1019 + prealloc: + simple: true + range-loops: true + for-loops: true + +issues: + exclude-rules: + - path: _test\.go + linters: + - errcheck + - gocritic + - path: internal/colors/colors.go + text: "WhyNoLint" + linters: + - staticcheck diff --git a/Makefile b/Makefile index 2729d9a..62d37c3 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build build-all clean install test help setup setup-install tidy +.PHONY: all build build-all clean install test help setup setup-install fmt tidy # Installation parameters prefix?=/usr/local @@ -34,8 +34,13 @@ help: @echo " make setup-install - Install setup binary to $(BUILD_DIR)" @echo " make clean - Remove build artifacts" @echo " make test - Run tests" + @echo " make fmt - Format Go source files" @echo " make tidy - Tidy go.mod" +fmt: + @gofmt -w . + @echo "Formatted all Go source files" + build: @echo "Building binaries..." @mkdir -p $(BUILD_DIR) diff --git a/cmd/arr-feed/main.go b/cmd/arr-feed/main.go index ae72e28..407641b 100644 --- a/cmd/arr-feed/main.go +++ b/cmd/arr-feed/main.go @@ -4,7 +4,9 @@ import ( "flag" "fmt" "os" + "strings" + "github.com/calmcacil/CalmsToolkit/internal/colors" "github.com/calmcacil/CalmsToolkit/internal/config" "github.com/calmcacil/CalmsToolkit/internal/feed" ) @@ -38,7 +40,12 @@ func main() { cfg.Timeout = *timeout cfg.NoColor = *noColor || *json cfg.Theme = *theme - cfg.JSON = *json + if cfg.Theme != "" && !colors.ValidateTheme(cfg.Theme) { + fmt.Fprintf(os.Stderr, "Warning: unknown theme %q, falling back to default (valid: %s)\n", + cfg.Theme, strings.Join(colors.ValidThemes(), ", ")) + cfg.Theme = "default" + } + cfg.JSONOutput = *json cfg.Watch = *watch cfg.ShowGrabbed = *showGrabbed cfg.ShowImported = *showImported diff --git a/cmd/arr-feed/main_test.go b/cmd/arr-feed/main_test.go new file mode 100644 index 0000000..65213f9 --- /dev/null +++ b/cmd/arr-feed/main_test.go @@ -0,0 +1,17 @@ +package main + +import ( + "testing" + + "github.com/calmcacil/CalmsToolkit/internal/config" +) + +func TestConfigDefaults(t *testing.T) { + cfg := config.DefaultToolkitConfig() + if cfg == nil { + t.Fatal("DefaultToolkitConfig() returned nil") + } + if cfg.ArrFeed.MaxEvents != 50 { + t.Errorf("MaxEvents = %d, want %d", cfg.ArrFeed.MaxEvents, 50) + } +} diff --git a/cmd/calmstoolkit-setup/main.go b/cmd/calmstoolkit-setup/main.go index a3d0608..f9215d3 100644 --- a/cmd/calmstoolkit-setup/main.go +++ b/cmd/calmstoolkit-setup/main.go @@ -22,7 +22,7 @@ func main() { path := ConfigPath() if path == "" { - fmt.Println("ERROR: Cannot determine home directory") + fmt.Fprintln(os.Stderr, "ERROR: Cannot determine home directory") os.Exit(1) } diff --git a/cmd/calmstoolkit-setup/main_test.go b/cmd/calmstoolkit-setup/main_test.go new file mode 100644 index 0000000..b0c5f7a --- /dev/null +++ b/cmd/calmstoolkit-setup/main_test.go @@ -0,0 +1,21 @@ +package main + +import ( + "os" + "testing" + + "github.com/calmcacil/CalmsToolkit/internal/config" +) + +func TestConfigPath(t *testing.T) { + origHome := os.Getenv("HOME") + defer os.Setenv("HOME", origHome) + + dir := t.TempDir() + os.Setenv("HOME", dir) + + path := config.ConfigPath() + if path == "" { + t.Fatal("ConfigPath() returned empty") + } +} diff --git a/cmd/media-calendar/main.go b/cmd/media-calendar/main.go index ff427bd..c22ae23 100644 --- a/cmd/media-calendar/main.go +++ b/cmd/media-calendar/main.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/calmcacil/CalmsToolkit/internal/calendar" + "github.com/calmcacil/CalmsToolkit/internal/colors" "github.com/calmcacil/CalmsToolkit/internal/config" ) @@ -37,6 +38,11 @@ func main() { cfg.Timeout = *timeout cfg.NoColor = *noColor || *jsonOutput cfg.Theme = *theme + if cfg.Theme != "" && !colors.ValidateTheme(cfg.Theme) { + fmt.Fprintf(os.Stderr, "Warning: unknown theme %q, falling back to default (valid: %s)\n", + cfg.Theme, strings.Join(colors.ValidThemes(), ", ")) + cfg.Theme = "default" + } cfg.JSONOutput = *jsonOutput cfg.WatchMode = *watchMode cfg.WatchSeconds = *watchSeconds diff --git a/cmd/media-calendar/main_test.go b/cmd/media-calendar/main_test.go new file mode 100644 index 0000000..9f04a74 --- /dev/null +++ b/cmd/media-calendar/main_test.go @@ -0,0 +1,17 @@ +package main + +import ( + "testing" + + "github.com/calmcacil/CalmsToolkit/internal/config" +) + +func TestConfigDefaults(t *testing.T) { + cfg := config.DefaultToolkitConfig() + if cfg == nil { + t.Fatal("DefaultToolkitConfig() returned nil") + } + if cfg.MediaCalendar.Days != 1 { + t.Errorf("Days = %d, want %d", cfg.MediaCalendar.Days, 1) + } +} diff --git a/cmd/media-requests/main.go b/cmd/media-requests/main.go index b664d37..e97a6d0 100644 --- a/cmd/media-requests/main.go +++ b/cmd/media-requests/main.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/calmcacil/CalmsToolkit/internal/colors" "github.com/calmcacil/CalmsToolkit/internal/config" "github.com/calmcacil/CalmsToolkit/internal/requests" ) @@ -19,7 +20,7 @@ func main() { cfg := requests.BuildToolConfig(tk) url := flag.String("url", cfg.ServerURL, "Overseerr/Jellyseerr server URL") - token := flag.String("token", cfg.APIKey, "API key/token") + token := flag.String("token", config.TokenFromEnv("OVERSEERR_API_KEY", cfg.APIKey), "API key/token") timeout := flag.Duration("timeout", cfg.Timeout, "Connection timeout") theme := flag.String("theme", cfg.Theme, "Color theme (default, catppuccin-mocha, catppuccin-latte)") noColor := flag.Bool("no-color", cfg.NoColor, "Disable colored output") @@ -33,6 +34,11 @@ func main() { cfg.Timeout = *timeout cfg.NoColor = *noColor || *jsonOutput cfg.Theme = *theme + if cfg.Theme != "" && !colors.ValidateTheme(cfg.Theme) { + fmt.Fprintf(os.Stderr, "Warning: unknown theme %q, falling back to default (valid: %s)\n", + cfg.Theme, strings.Join(colors.ValidThemes(), ", ")) + cfg.Theme = "default" + } cfg.Verbose = *verbose cfg.JSONOutput = *jsonOutput cfg.Quiet = *quiet diff --git a/cmd/media-requests/main_test.go b/cmd/media-requests/main_test.go new file mode 100644 index 0000000..b0c5f7a --- /dev/null +++ b/cmd/media-requests/main_test.go @@ -0,0 +1,21 @@ +package main + +import ( + "os" + "testing" + + "github.com/calmcacil/CalmsToolkit/internal/config" +) + +func TestConfigPath(t *testing.T) { + origHome := os.Getenv("HOME") + defer os.Setenv("HOME", origHome) + + dir := t.TempDir() + os.Setenv("HOME", dir) + + path := config.ConfigPath() + if path == "" { + t.Fatal("ConfigPath() returned empty") + } +} diff --git a/cmd/media-streams/main.go b/cmd/media-streams/main.go index b664f65..8fd7b74 100644 --- a/cmd/media-streams/main.go +++ b/cmd/media-streams/main.go @@ -6,6 +6,7 @@ import ( "os" "strings" + "github.com/calmcacil/CalmsToolkit/internal/colors" "github.com/calmcacil/CalmsToolkit/internal/config" "github.com/calmcacil/CalmsToolkit/internal/streams" ) @@ -19,9 +20,9 @@ func main() { server := flag.String("server", cfg.ServerType, "Server type: plex, jellyfin, or both") plexURL := flag.String("plex-url", cfg.PlexURL, "Plex server URL") - plexToken := flag.String("plex-token", cfg.PlexToken, "Plex authentication token") + plexToken := flag.String("plex-token", config.TokenFromEnv("PLEX_TOKEN", cfg.PlexToken), "Plex authentication token") jellyfinURL := flag.String("jellyfin-url", cfg.JellyfinURL, "Jellyfin server URL") - jellyfinToken := flag.String("jellyfin-token", cfg.JellyfinToken, "Jellyfin API token") + jellyfinToken := flag.String("jellyfin-token", config.TokenFromEnv("JELLYFIN_TOKEN", cfg.JellyfinToken), "Jellyfin API token") timeout := flag.Duration("timeout", cfg.Timeout, "Connection timeout") theme := flag.String("theme", cfg.Theme, "Color theme (default, catppuccin-mocha, catppuccin-latte)") noColor := flag.Bool("no-color", cfg.NoColor, "Disable colored output") @@ -40,6 +41,11 @@ func main() { cfg.Timeout = *timeout cfg.NoColor = *noColor || *jsonOutput cfg.Theme = *theme + if cfg.Theme != "" && !colors.ValidateTheme(cfg.Theme) { + fmt.Fprintf(os.Stderr, "Warning: unknown theme %q, falling back to default (valid: %s)\n", + cfg.Theme, strings.Join(colors.ValidThemes(), ", ")) + cfg.Theme = "default" + } cfg.JSONOutput = *jsonOutput cfg.WatchMode = *watchMode cfg.WatchSeconds = *watchSeconds diff --git a/cmd/media-streams/main_test.go b/cmd/media-streams/main_test.go new file mode 100644 index 0000000..d8557b8 --- /dev/null +++ b/cmd/media-streams/main_test.go @@ -0,0 +1,17 @@ +package main + +import ( + "testing" + + "github.com/calmcacil/CalmsToolkit/internal/config" +) + +func TestConfigDefaults(t *testing.T) { + cfg := config.DefaultToolkitConfig() + if cfg == nil { + t.Fatal("DefaultToolkitConfig() returned nil") + } + if cfg.MediaStreams.ServerType != "both" { + t.Errorf("ServerType = %q, want %q", cfg.MediaStreams.ServerType, "both") + } +} diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go index 63955a9..48529db 100644 --- a/internal/calendar/calendar.go +++ b/internal/calendar/calendar.go @@ -15,7 +15,7 @@ import ( "github.com/calmcacil/CalmsToolkit/internal/colors" "github.com/calmcacil/CalmsToolkit/internal/config" - httpclient "github.com/calmcacil/CalmsToolkit/internal/http" + "github.com/calmcacil/CalmsToolkit/internal/httputil" ) // SonarrEpisode represents a Sonarr calendar episode. @@ -239,7 +239,7 @@ func Run(cfg ToolConfig) { func aggregateCalendar(ctx context.Context, cfg ToolConfig) ([]CalendarItem, []QueueIssue, error) { start, end := calculateDateRange(cfg.Days, cfg.DaysPast) - client := httpclient.NewTransportClient(cfg.Timeout) + client := httputil.NewTransportClient(cfg.Timeout) items := make([]CalendarItem, 0) queueIssues := make([]QueueIssue, 0) @@ -295,7 +295,7 @@ func aggregateCalendar(ctx context.Context, cfg ToolConfig) ([]CalendarItem, []Q return items, queueIssues, nil } -func fetchSonarrInstance(ctx context.Context, client *httpclient.Client, inst config.ArrInstance, start, end time.Time, debug bool, mu, qMu *sync.Mutex, items *[]CalendarItem, queueIssues *[]QueueIssue, seen map[string]bool) error { +func fetchSonarrInstance(ctx context.Context, client *httputil.Client, inst config.ArrInstance, start, end time.Time, debug bool, mu, qMu *sync.Mutex, items *[]CalendarItem, queueIssues *[]QueueIssue, seen map[string]bool) error { episodes, err := fetchSonarrCalendar(ctx, client, inst, start, end, debug) if err != nil { return fmt.Errorf("Sonarr %s: %w", inst.Name, err) @@ -356,7 +356,7 @@ func fetchSonarrInstance(ctx context.Context, client *httpclient.Client, inst co return nil } -func fetchRadarrInstance(ctx context.Context, client *httpclient.Client, inst config.ArrInstance, start, end time.Time, debug bool, mu, qMu *sync.Mutex, items *[]CalendarItem, queueIssues *[]QueueIssue, seen map[string]bool) error { +func fetchRadarrInstance(ctx context.Context, client *httputil.Client, inst config.ArrInstance, start, end time.Time, debug bool, mu, qMu *sync.Mutex, items *[]CalendarItem, queueIssues *[]QueueIssue, seen map[string]bool) error { movies, err := fetchRadarrCalendar(ctx, client, inst, start, end, debug) if err != nil { return fmt.Errorf("Radarr %s: %w", inst.Name, err) @@ -428,7 +428,7 @@ func fetchRadarrInstance(ctx context.Context, client *httpclient.Client, inst co return nil } -func fetchSonarrCalendar(ctx context.Context, client *httpclient.Client, inst config.ArrInstance, start, end time.Time, debug bool) ([]SonarrEpisode, error) { +func fetchSonarrCalendar(ctx context.Context, client *httputil.Client, inst config.ArrInstance, start, end time.Time, debug bool) ([]SonarrEpisode, error) { apiURL := fmt.Sprintf("%s/api/v3/calendar?start=%s&end=%s&includeSeries=true", inst.URL, start.Format("2006-01-02"), end.Format("2006-01-02")) @@ -444,7 +444,7 @@ func fetchSonarrCalendar(ctx context.Context, client *httpclient.Client, inst co return episodes, nil } -func fetchRadarrCalendar(ctx context.Context, client *httpclient.Client, inst config.ArrInstance, start, end time.Time, debug bool) ([]RadarrMovie, error) { +func fetchRadarrCalendar(ctx context.Context, client *httputil.Client, inst config.ArrInstance, start, end time.Time, debug bool) ([]RadarrMovie, error) { apiURL := fmt.Sprintf("%s/api/v3/calendar?start=%s&end=%s", inst.URL, start.Format("2006-01-02"), end.Format("2006-01-02")) @@ -460,7 +460,7 @@ func fetchRadarrCalendar(ctx context.Context, client *httpclient.Client, inst co return movies, nil } -func fetchQueue(ctx context.Context, client *httpclient.Client, inst config.ArrInstance, debug bool) (*QueueResponse, error) { +func fetchQueue(ctx context.Context, client *httputil.Client, inst config.ArrInstance, debug bool) (*QueueResponse, error) { apiURL := fmt.Sprintf("%s/api/v3/queue?pageSize=100", inst.URL) if debug { diff --git a/internal/calendar/calendar_test.go b/internal/calendar/calendar_test.go index 4a9d1ca..9fff504 100644 --- a/internal/calendar/calendar_test.go +++ b/internal/calendar/calendar_test.go @@ -10,7 +10,7 @@ import ( "github.com/calmcacil/CalmsToolkit/internal/colors" "github.com/calmcacil/CalmsToolkit/internal/config" - httpclient "github.com/calmcacil/CalmsToolkit/internal/http" + "github.com/calmcacil/CalmsToolkit/internal/httputil" ) func TestFetchSonarrCalendar(t *testing.T) { @@ -40,7 +40,7 @@ func TestFetchSonarrCalendar(t *testing.T) { })) defer server.Close() - client := httpclient.NewClient(5 * time.Second) + client := httputil.NewClient(5 * time.Second) inst := config.ArrInstance{Name: "Test", URL: server.URL, APIKey: "test-token"} start := time.Now() end := start.Add(7 * 24 * time.Hour) @@ -91,7 +91,7 @@ func TestFetchRadarrCalendar(t *testing.T) { })) defer server.Close() - client := httpclient.NewClient(5 * time.Second) + client := httputil.NewClient(5 * time.Second) inst := config.ArrInstance{Name: "Test", URL: server.URL, APIKey: "test-token"} start := time.Now() end := start.Add(7 * 24 * time.Hour) @@ -152,7 +152,7 @@ func TestFetchQueue(t *testing.T) { })) defer server.Close() - client := httpclient.NewClient(5 * time.Second) + client := httputil.NewClient(5 * time.Second) inst := config.ArrInstance{Name: "Test", URL: server.URL, APIKey: "test-token"} ctx := context.Background() diff --git a/internal/calendar/render.go b/internal/calendar/render.go index 5e36eb1..f1f1f35 100644 --- a/internal/calendar/render.go +++ b/internal/calendar/render.go @@ -179,12 +179,7 @@ func computeCalendarHash(items []CalendarItem, queueIssues []QueueIssue) string } func renderCalendar(cfg ToolConfig, items []CalendarItem, queueIssues []QueueIssue, termWidth int, p *colors.Palette) { - clr := func(code string) string { - if cfg.NoColor { - return "" - } - return code - } + clr := colors.ClrFunc(cfg.NoColor) now := time.Now() items = applyFilters(items, cfg) @@ -199,7 +194,7 @@ func renderCalendar(cfg ToolConfig, items []CalendarItem, queueIssues []QueueIss for _, issue := range queueIssues { totalIssues += issue.Count } - fmt.Fprintf(bw, "%s⚠️ WARNING: %d items require manual intervention%s\n", + fmt.Fprintf(bw, "%s! WARNING: %d items require manual intervention%s\n", clr(p.QueueWarning), totalIssues, clr(p.Reset)) for _, issue := range queueIssues { fmt.Fprintf(bw, "%s→ %s: %s%s\n", diff --git a/internal/colors/colors.go b/internal/colors/colors.go index 5cadd51..f2e96df 100644 --- a/internal/colors/colors.go +++ b/internal/colors/colors.go @@ -8,6 +8,15 @@ import ( "github.com/mattn/go-runewidth" ) +// ClrFunc returns a closure that returns ANSI codes when color is enabled, +// empty string otherwise. Replaces the duplicated pattern in display functions. +func ClrFunc(noColor bool) func(string) string { + if noColor { + return func(string) string { return "" } + } + return func(s string) string { return s } +} + var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) // VisibleLen returns the visible column width of a string, accounting for @@ -117,6 +126,15 @@ func ValidThemes() []string { } } +// ValidateTheme returns true if name is a recognised theme. +func ValidateTheme(name string) bool { + switch Theme(name) { + case ThemeDefault, ThemeCatppuccinMocha, ThemeCatppuccinLatte: + return true + } + return false +} + // GetPalette resolves a theme name to a Palette. // Unrecognised names fall back to the default palette. func GetPalette(name string) *Palette { diff --git a/internal/colors/colors_test.go b/internal/colors/colors_test.go new file mode 100644 index 0000000..198cc01 --- /dev/null +++ b/internal/colors/colors_test.go @@ -0,0 +1,105 @@ +package colors + +import ( + "testing" +) + +func TestClrFunc(t *testing.T) { + f := ClrFunc(false) + if f("test") != "test" { + t.Errorf("ClrFunc(false)(%q) = %q, want %q", "test", f("test"), "test") + } + + f = ClrFunc(true) + if f("test") != "" { + t.Errorf("ClrFunc(true)(%q) = %q, want %q", "test", f("test"), "") + } +} + +func TestVisibleLen(t *testing.T) { + tests := []struct { + input string + want int + }{ + {"hello", 5}, + {"", 0}, + {"\033[31mhello\033[0m", 5}, + } + for _, tt := range tests { + got := VisibleLen(tt.input) + if got != tt.want { + t.Errorf("VisibleLen(%q) = %d, want %d", tt.input, got, tt.want) + } + } +} + +func TestPadRight(t *testing.T) { + got := PadRight("hi", 5) + if got != "hi " { + t.Errorf("PadRight(%q, 5) = %q, want %q", "hi", got, "hi ") + } + + got = PadRight("hello", 3) + if got != "hello" { + t.Errorf("PadRight(%q, 3) = %q, want %q", "hello", got, "hello") + } +} + +func TestColorizer(t *testing.T) { + c := New(true) + if c.Apply("red") != "" { + t.Error("Expected empty string with NoColor=true") + } + + c = New(false) + if c.Apply("red") != "red" { + t.Error("Expected 'red' with NoColor=false") + } +} + +func TestValidThemes(t *testing.T) { + themes := ValidThemes() + if len(themes) < 3 { + t.Errorf("Expected at least 3 themes, got %d", len(themes)) + } +} + +func TestValidateTheme(t *testing.T) { + if !ValidateTheme("default") { + t.Error("ValidateTheme('default') should be true") + } + if !ValidateTheme("catppuccin-mocha") { + t.Error("ValidateTheme('catppuccin-mocha') should be true") + } + if !ValidateTheme("catppuccin-latte") { + t.Error("ValidateTheme('catppuccin-latte') should be true") + } + if ValidateTheme("invalid-theme") { + t.Error("ValidateTheme('invalid-theme') should be false") + } +} + +func TestGetPalette(t *testing.T) { + p := GetPalette("default") + if p == nil { + t.Fatal("GetPalette('default') returned nil") + } + if p.Success == "" { + t.Error("Palette.Success should not be empty") + } + + p = GetPalette("catppuccin-mocha") + if p == nil { + t.Fatal("GetPalette('catppuccin-mocha') returned nil") + } + + p = GetPalette("catppuccin-latte") + if p == nil { + t.Fatal("GetPalette('catppuccin-latte') returned nil") + } + + p = GetPalette("nonexistent") + if p == nil { + t.Fatal("GetPalette('nonexistent') returned nil") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index c7e88f9..58c1041 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,6 +9,14 @@ import ( "time" ) +// TokenFromEnv returns the environment variable value if set, otherwise fallback. +func TokenFromEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + // ToolkitConfig is the top-level configuration structure for CalmsToolkit. type ToolkitConfig struct { Version int `json:"version"` @@ -146,7 +154,7 @@ func LoadToolkitConfig() (*ToolkitConfig, error) { return nil, fmt.Errorf("parsing config: %w", err) } - if cfg.Version != 1 { + if cfg.Version < 1 { return nil, fmt.Errorf("unsupported config version: %d", cfg.Version) } @@ -162,7 +170,7 @@ func LoadToolkitConfig() (*ToolkitConfig, error) { // Validate checks the configuration for required fields and valid values. func (c *ToolkitConfig) Validate() error { - if c.Version != 1 { + if c.Version < 1 { return fmt.Errorf("unsupported version: %d", c.Version) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 713ccbe..da7be31 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -200,3 +200,19 @@ func TestConfigPath(t *testing.T) { t.Errorf("ConfigPath() = %q, want %q", path, expected) } } + +func TestTokenFromEnv(t *testing.T) { + const envKey = "TEST_TOKEN_FROM_ENV" + defer os.Unsetenv(envKey) + + got := TokenFromEnv(envKey, "fallback") + if got != "fallback" { + t.Errorf("TokenFromEnv with unset var = %q, want %q", got, "fallback") + } + + os.Setenv(envKey, "env-value") + got = TokenFromEnv(envKey, "fallback") + if got != "env-value" { + t.Errorf("TokenFromEnv with set var = %q, want %q", got, "env-value") + } +} diff --git a/internal/feed/feed.go b/internal/feed/feed.go index a73017b..3d3de61 100644 --- a/internal/feed/feed.go +++ b/internal/feed/feed.go @@ -21,7 +21,7 @@ import ( "github.com/calmcacil/CalmsToolkit/internal/colors" "github.com/calmcacil/CalmsToolkit/internal/config" - httpclient "github.com/calmcacil/CalmsToolkit/internal/http" + "github.com/calmcacil/CalmsToolkit/internal/httputil" ) // ToolConfig holds configuration for the Arr event feed tool. @@ -33,7 +33,7 @@ type ToolConfig struct { Timeout time.Duration NoColor bool Theme string - JSON bool + JSONOutput bool Watch bool ShowGrabbed bool ShowImported bool @@ -239,7 +239,7 @@ func Run(cfg ToolConfig) { os.Exit(1) } - client := httpclient.NewClient(cfg.Timeout) + client := httputil.NewClient(cfg.Timeout) ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() @@ -252,7 +252,7 @@ func Run(cfg ToolConfig) { } } -func runSingleMode(ctx context.Context, cfg ToolConfig, client *httpclient.Client, p *colors.Palette) { +func runSingleMode(ctx context.Context, cfg ToolConfig, client *httputil.Client, p *colors.Palette) { since := time.Now().Add(-cfg.HistoryWindow) events, err := fetchAllHistory(ctx, client, cfg, since) if err != nil { @@ -266,15 +266,15 @@ func runSingleMode(ctx context.Context, cfg ToolConfig, client *httpclient.Clien events = events[:cfg.MaxEvents] } - if cfg.JSON { + if cfg.JSONOutput { renderJSON(events) } else { renderTable(events, cfg, p) } } -func runWatchMode(ctx context.Context, cfg ToolConfig, client *httpclient.Client, p *colors.Palette) { - if !cfg.JSON { +func runWatchMode(ctx context.Context, cfg ToolConfig, client *httputil.Client, p *colors.Palette) { + if !cfg.JSONOutput { fmt.Print(colors.HideCursor) defer fmt.Print(colors.ShowCursor) } @@ -289,18 +289,27 @@ func runWatchMode(ctx context.Context, cfg ToolConfig, client *httpclient.Client for { newEvents, err := fetchAllHistory(ctx, client, cfg, lastFetch) if err != nil { - if !cfg.JSON { + if !cfg.JSONOutput { fmt.Print(colors.HomeCursor) clr := getColorFunc(cfg, p) - fmt.Printf("%sERROR: %v%s\n", clr(p.Error), err, clr(p.Reset)) - fmt.Printf("Retrying in %v...\n", cfg.PollInterval) + fmt.Fprintf(os.Stderr, "%sERROR: %v%s\n", clr(p.Error), err, clr(p.Reset)) + fmt.Fprintf(os.Stderr, "Retrying in %v...\n", cfg.PollInterval) fmt.Print(colors.EraseDown) } } else { newCount := 0 for _, event := range newEvents { - eventCache = append(eventCache, event) - newCount++ + dup := false + for i := len(eventCache) - 1; i >= 0 && i >= len(eventCache)-50; i-- { + if eventCache[i].ID == event.ID { + dup = true + break + } + } + if !dup { + eventCache = append(eventCache, event) + newCount++ + } } if len(eventCache) > 100 { @@ -313,7 +322,7 @@ func runWatchMode(ctx context.Context, cfg ToolConfig, client *httpclient.Client filteredEvents = filteredEvents[:cfg.MaxEvents] } - if cfg.JSON { + if cfg.JSONOutput { renderJSON(filteredEvents) } else { newHash := computeFeedHash(filteredEvents) @@ -339,7 +348,7 @@ func runWatchMode(ctx context.Context, cfg ToolConfig, client *httpclient.Client select { case <-ctx.Done(): - if !cfg.JSON { + if !cfg.JSONOutput { fmt.Print(colors.ShowCursor) } return @@ -354,7 +363,7 @@ func computeFeedHash(events []HistoryEvent) string { return string(h[:]) } -func fetchAllHistory(ctx context.Context, client *httpclient.Client, cfg ToolConfig, since time.Time) ([]HistoryEvent, error) { +func fetchAllHistory(ctx context.Context, client *httputil.Client, cfg ToolConfig, since time.Time) ([]HistoryEvent, error) { var wg sync.WaitGroup eventsChan := make(chan []HistoryEvent, len(cfg.SonarrInstances)+len(cfg.RadarrInstances)) errorsChan := make(chan error, len(cfg.SonarrInstances)+len(cfg.RadarrInstances)) @@ -410,7 +419,7 @@ func fetchAllHistory(ctx context.Context, client *httpclient.Client, cfg ToolCon return allEvents, nil } -func fetchSonarrHistory(ctx context.Context, client *httpclient.Client, inst config.ArrInstance, since time.Time, showSubtitles bool) ([]HistoryEvent, error) { +func fetchSonarrHistory(ctx context.Context, client *httputil.Client, inst config.ArrInstance, since time.Time, showSubtitles bool) ([]HistoryEvent, error) { sinceStr := since.UTC().Format(time.RFC3339) endpoint := fmt.Sprintf("%s/api/v3/history/since?date=%s&includeEpisode=true&includeSeries=true", inst.URL, sinceStr) @@ -445,7 +454,7 @@ func fetchSonarrHistory(ctx context.Context, client *httpclient.Client, inst con return events, nil } -func enrichSonarrSubtitles(ctx context.Context, client *httpclient.Client, inst config.ArrInstance, events []HistoryEvent) { +func enrichSonarrSubtitles(ctx context.Context, client *httputil.Client, inst config.ArrInstance, events []HistoryEvent) { var ids []int seen := make(map[int]bool) for _, ev := range events { @@ -491,7 +500,7 @@ func enrichSonarrSubtitles(ctx context.Context, client *httpclient.Client, inst } } -func fetchRadarrHistory(ctx context.Context, client *httpclient.Client, inst config.ArrInstance, since time.Time, showSubtitles bool) ([]HistoryEvent, error) { +func fetchRadarrHistory(ctx context.Context, client *httputil.Client, inst config.ArrInstance, since time.Time, showSubtitles bool) ([]HistoryEvent, error) { sinceStr := since.UTC().Format(time.RFC3339) endpoint := fmt.Sprintf("%s/api/v3/history/since?date=%s&includeMovie=true", inst.URL, sinceStr) @@ -526,7 +535,7 @@ func fetchRadarrHistory(ctx context.Context, client *httpclient.Client, inst con return events, nil } -func enrichRadarrSubtitles(ctx context.Context, client *httpclient.Client, inst config.ArrInstance, events []HistoryEvent) { +func enrichRadarrSubtitles(ctx context.Context, client *httputil.Client, inst config.ArrInstance, events []HistoryEvent) { var ids []int seen := make(map[int]bool) for _, ev := range events { @@ -971,10 +980,10 @@ func getActionColor(action string, p *colors.Palette) string { } func getColorFunc(cfg ToolConfig, p *colors.Palette) func(string) string { - if cfg.NoColor || cfg.JSON { - return func(s string) string { return "" } + if cfg.NoColor || cfg.JSONOutput { + return colors.ClrFunc(true) } - return func(s string) string { return s } + return colors.ClrFunc(false) } func subtitlesDisplay(s string) string { diff --git a/internal/feed/feed_test.go b/internal/feed/feed_test.go index 6aef674..6a5e4ca 100644 --- a/internal/feed/feed_test.go +++ b/internal/feed/feed_test.go @@ -11,7 +11,7 @@ import ( "github.com/calmcacil/CalmsToolkit/internal/colors" "github.com/calmcacil/CalmsToolkit/internal/config" - httpclient "github.com/calmcacil/CalmsToolkit/internal/http" + "github.com/calmcacil/CalmsToolkit/internal/httputil" ) func TestMapSonarrEventType(t *testing.T) { @@ -357,7 +357,7 @@ func TestFetchSonarrHistory(t *testing.T) { })) defer server.Close() - client := httpclient.NewClient(5 * time.Second) + client := httputil.NewClient(5 * time.Second) inst := config.ArrInstance{Name: "Test", URL: server.URL, APIKey: "test-token"} since := time.Now().Add(-1 * time.Hour) @@ -383,7 +383,7 @@ func TestFetchRadarrHistory(t *testing.T) { })) defer server.Close() - client := httpclient.NewClient(5 * time.Second) + client := httputil.NewClient(5 * time.Second) inst := config.ArrInstance{Name: "Test", URL: server.URL, APIKey: "test-token"} since := time.Now().Add(-1 * time.Hour) @@ -413,7 +413,7 @@ func TestFetchAllHistory(t *testing.T) { })) defer radarrServer.Close() - client := httpclient.NewClient(5 * time.Second) + client := httputil.NewClient(5 * time.Second) cfg := ToolConfig{ SonarrInstances: []config.ArrInstance{ {Name: "Sonarr", URL: sonarrServer.URL, APIKey: "token"}, @@ -452,7 +452,7 @@ func TestCustomFormatsInSonarrHistory(t *testing.T) { })) defer server.Close() - client := httpclient.NewClient(5 * time.Second) + client := httputil.NewClient(5 * time.Second) inst := config.ArrInstance{Name: "Test", URL: server.URL, APIKey: "test-token"} since := time.Now().Add(-1 * time.Hour) @@ -488,7 +488,7 @@ func TestCustomFormatsInRadarrHistory(t *testing.T) { })) defer server.Close() - client := httpclient.NewClient(5 * time.Second) + client := httputil.NewClient(5 * time.Second) inst := config.ArrInstance{Name: "Test", URL: server.URL, APIKey: "test-token"} since := time.Now().Add(-1 * time.Hour) @@ -519,7 +519,7 @@ func TestFetchAllHistoryErrorHandling(t *testing.T) { })) defer goodServer.Close() - client := httpclient.NewClient(5 * time.Second) + client := httputil.NewClient(5 * time.Second) cfg := ToolConfig{ SonarrInstances: []config.ArrInstance{ {Name: "Bad", URL: badServer.URL, APIKey: "token"}, @@ -546,7 +546,7 @@ func TestEventSortOrder(t *testing.T) { {ID: 3, When: now.Add(-10 * time.Minute), Action: "Grabbed"}, } - client := httpclient.NewClient(5 * time.Second) + client := httputil.NewClient(5 * time.Second) cfg := ToolConfig{ SonarrInstances: []config.ArrInstance{}, RadarrInstances: []config.ArrInstance{}, @@ -692,7 +692,7 @@ func TestEnrichSonarrSubtitles(t *testing.T) { })) defer server.Close() - client := httpclient.NewClient(5 * time.Second) + client := httputil.NewClient(5 * time.Second) inst := config.ArrInstance{Name: "Test", URL: server.URL, APIKey: "test-token"} events := []HistoryEvent{ @@ -725,7 +725,7 @@ func TestEnrichRadarrSubtitles(t *testing.T) { })) defer server.Close() - client := httpclient.NewClient(5 * time.Second) + client := httputil.NewClient(5 * time.Second) inst := config.ArrInstance{Name: "Test", URL: server.URL, APIKey: "test-token"} events := []HistoryEvent{ @@ -745,7 +745,7 @@ func TestEnrichSonarrSubtitlesNoFileID(t *testing.T) { })) defer server.Close() - client := httpclient.NewClient(5 * time.Second) + client := httputil.NewClient(5 * time.Second) inst := config.ArrInstance{Name: "Test", URL: server.URL, APIKey: "test-token"} events := []HistoryEvent{ @@ -762,7 +762,7 @@ func TestEnrichSonarrSubtitlesAPIError(t *testing.T) { })) defer server.Close() - client := httpclient.NewClient(5 * time.Second) + client := httputil.NewClient(5 * time.Second) inst := config.ArrInstance{Name: "Test", URL: server.URL, APIKey: "test-token"} events := []HistoryEvent{ @@ -787,7 +787,7 @@ func TestEnrichSonarrSubtitlesNoMediaInfo(t *testing.T) { })) defer server.Close() - client := httpclient.NewClient(5 * time.Second) + client := httputil.NewClient(5 * time.Second) inst := config.ArrInstance{Name: "Test", URL: server.URL, APIKey: "test-token"} events := []HistoryEvent{ diff --git a/internal/http/client.go b/internal/httputil/client.go similarity index 96% rename from internal/http/client.go rename to internal/httputil/client.go index aa04d11..1875dde 100644 --- a/internal/http/client.go +++ b/internal/httputil/client.go @@ -1,5 +1,5 @@ -// Package http provides shared HTTP client utilities for API fetchers. -package http +// Package httputil provides shared HTTP client utilities for API fetchers. +package httputil import ( "context" diff --git a/internal/httputil/client_test.go b/internal/httputil/client_test.go new file mode 100644 index 0000000..318ff10 --- /dev/null +++ b/internal/httputil/client_test.go @@ -0,0 +1,131 @@ +package httputil + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestNewClient(t *testing.T) { + c := NewClient(5 * time.Second) + if c.Client.Timeout != 5*time.Second { + t.Errorf("Timeout = %v, want 5s", c.Client.Timeout) + } +} + +func TestNewTransportClient(t *testing.T) { + c := NewTransportClient(10 * time.Second) + if c.Client.Timeout != 10*time.Second { + t.Errorf("Timeout = %v, want 10s", c.Client.Timeout) + } +} + +func TestDoRequest(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("hello")) + })) + defer server.Close() + + ctx := context.Background() + client := NewClient(5 * time.Second) + + body, status, err := client.DoRequest(ctx, "GET", server.URL, nil, nil) + if err != nil { + t.Fatalf("DoRequest error: %v", err) + } + if status != http.StatusOK { + t.Errorf("Status = %d, want %d", status, http.StatusOK) + } + if string(body) != "hello" { + t.Errorf("Body = %q, want %q", string(body), "hello") + } +} + +type testJSON struct { + Message string `json:"message"` +} + +func TestDoJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"message":"ok"}`)) + })) + defer server.Close() + + ctx := context.Background() + client := NewClient(5 * time.Second) + + var result testJSON + err := client.DoJSON(ctx, "GET", server.URL, nil, nil, &result) + if err != nil { + t.Fatalf("DoJSON error: %v", err) + } + if result.Message != "ok" { + t.Errorf("Message = %q, want %q", result.Message, "ok") + } +} + +func TestDoJSONNonOKStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + ctx := context.Background() + client := NewClient(5 * time.Second) + + err := client.DoJSON(ctx, "GET", server.URL, nil, nil, nil) + if err == nil { + t.Fatal("Expected error for non-OK status, got nil") + } + if !strings.Contains(err.Error(), "500") { + t.Errorf("Error should mention status code, got: %v", err) + } +} + +type testXML struct { + Message string `xml:"message"` +} + +func TestDoXML(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/xml") + w.Write([]byte(`ok`)) + })) + defer server.Close() + + ctx := context.Background() + client := NewClient(5 * time.Second) + + var result testXML + err := client.DoXML(ctx, "GET", server.URL, nil, nil, &result) + if err != nil { + t.Fatalf("DoXML error: %v", err) + } + if result.Message != "ok" { + t.Errorf("Message = %q, want %q", result.Message, "ok") + } +} + +func TestLargeBodyRejection(t *testing.T) { + largeBody := make([]byte, 12*1024*1024) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(largeBody) + })) + defer server.Close() + + ctx := context.Background() + client := NewClient(5 * time.Second) + + _, _, err := client.DoRequest(ctx, "GET", server.URL, nil, nil) + if err == nil { + t.Fatal("Expected error for large body, got nil") + } + if !strings.Contains(err.Error(), "too large") { + t.Errorf("Error should mention too large, got: %v", err) + } +} diff --git a/internal/requests/requests.go b/internal/requests/requests.go index 5e6de59..373ff7f 100644 --- a/internal/requests/requests.go +++ b/internal/requests/requests.go @@ -54,8 +54,6 @@ type ToolConfig struct { Verbose bool JSONOutput bool Quiet bool - ctx context.Context - client *http.Client } // SearchResponse is the Overseerr search API response. @@ -251,9 +249,6 @@ func BuildToolConfig(tk *config.ToolkitConfig) ToolConfig { // Run executes the media requests interactive tool. func Run(cfg ToolConfig) { - cfg.ctx = context.Background() - cfg.client = &http.Client{Timeout: cfg.Timeout} - if cfg.APIKey == "" { fmt.Fprintf(os.Stderr, "ERROR: API key is not set\n") fmt.Fprintf(os.Stderr, "Set api_key in ~/.config/calmstoolkit/config.json or use -token flag\n") @@ -266,29 +261,23 @@ func Run(cfg ToolConfig) { os.Exit(1) } - if err := testConnection(cfg); err != nil { + ctx := context.Background() + if err := testConnection(ctx, cfg); err != nil { fmt.Fprintf(os.Stderr, "ERROR: Failed to connect to server: %v\n", err) os.Exit(1) } - runInteractiveMenu(cfg) + runInteractiveMenu(ctx, cfg) } -func testConnection(cfg ToolConfig) error { - ctx := cfg.ctx - if ctx == nil { - ctx = context.Background() - } +func testConnection(ctx context.Context, cfg ToolConfig) error { req, err := http.NewRequestWithContext(ctx, "GET", cfg.ServerURL+"/api/v1/auth/me", nil) if err != nil { return err } req.Header.Set("X-Api-Key", cfg.APIKey) - client := cfg.client - if client == nil { - client = &http.Client{Timeout: cfg.Timeout} - } + client := &http.Client{Timeout: cfg.Timeout} resp, err := client.Do(req) if err != nil { return err @@ -306,7 +295,7 @@ func testConnection(cfg ToolConfig) error { return nil } -func runInteractiveMenu(cfg ToolConfig) { +func runInteractiveMenu(ctx context.Context, cfg ToolConfig) { reader := bufio.NewReader(os.Stdin) for { @@ -317,9 +306,9 @@ func runInteractiveMenu(cfg ToolConfig) { switch input { case "n": - handleNewRequest(cfg, reader) + handleNewRequest(ctx, cfg, reader) case "w": - handleViewRequests(cfg, reader) + handleViewRequests(ctx, cfg, reader) case "q": fmt.Println("\nGoodbye!") return @@ -332,12 +321,7 @@ func runInteractiveMenu(cfg ToolConfig) { func printMainMenu(cfg ToolConfig) { p := colors.GetPalette(cfg.Theme) - clr := func(code string) string { - if cfg.NoColor { - return "" - } - return code - } + clr := colors.ClrFunc(cfg.NoColor) fmt.Printf("%s%s╔══════════════════════════════════════════╗%s\n", clr(p.Bold), clr(p.Accent), clr(p.Reset)) fmt.Printf("%s%s║ Media Requests - Interactive Menu ║%s\n", clr(p.Bold), clr(p.Accent), clr(p.Reset)) @@ -349,15 +333,10 @@ func printMainMenu(cfg ToolConfig) { fmt.Printf("Select an option: ") } -func handleNewRequest(cfg ToolConfig, reader *bufio.Reader) { +func handleNewRequest(ctx context.Context, cfg ToolConfig, reader *bufio.Reader) { p := colors.GetPalette(cfg.Theme) clearScreen() - clr := func(code string) string { - if cfg.NoColor { - return "" - } - return code - } + clr := colors.ClrFunc(cfg.NoColor) fmt.Printf("%s%s=== New Media Request ===%s\n\n", clr(p.Bold), clr(p.Accent), clr(p.Reset)) fmt.Printf("Enter search query (or 'back' to return): ") @@ -370,7 +349,7 @@ func handleNewRequest(cfg ToolConfig, reader *bufio.Reader) { } fmt.Fprintf(os.Stderr, "\n%sSearching...%s\n", clr(p.Warning), clr(p.Reset)) - results, err := searchMedia(cfg, query) + results, err := searchMedia(ctx, cfg, query) if err != nil { fmt.Fprintf(os.Stderr, "\n%sError searching: %v%s\n", clr(p.Error), err, clr(p.Reset)) fmt.Printf("\nPress any key to continue...") @@ -433,13 +412,13 @@ func handleNewRequest(cfg ToolConfig, reader *bufio.Reader) { var seasons interface{} if selectedMedia.MediaType == "tv" { - seasons, err = selectSeasons(cfg, selectedMedia, reader) + seasons, err = selectSeasons(ctx, cfg, selectedMedia, reader) if err != nil || seasons == nil { return } } - overrides, err := selectRootFolderOverride(cfg, selectedMedia, reader) + overrides, err := selectRootFolderOverride(ctx, cfg, selectedMedia, reader) if err != nil { return } @@ -489,7 +468,7 @@ func handleNewRequest(cfg ToolConfig, reader *bufio.Reader) { } fmt.Fprintf(os.Stderr, "\n%sSubmitting request...%s\n", clr(p.Warning), clr(p.Reset)) - request, err := createRequest(cfg, selectedMedia, seasons, overrides) + request, err := createRequest(ctx, cfg, selectedMedia, seasons, overrides) if err != nil { fmt.Fprintf(os.Stderr, "\n%sError creating request: %v%s\n", clr(p.Error), err, clr(p.Reset)) fmt.Printf("\nPress any key to continue...") @@ -507,20 +486,15 @@ func handleNewRequest(cfg ToolConfig, reader *bufio.Reader) { readKeystroke(cfg) } -func handleViewRequests(cfg ToolConfig, reader *bufio.Reader) { +func handleViewRequests(ctx context.Context, cfg ToolConfig, reader *bufio.Reader) { p := colors.GetPalette(cfg.Theme) clearScreen() - clr := func(code string) string { - if cfg.NoColor { - return "" - } - return code - } + clr := colors.ClrFunc(cfg.NoColor) fmt.Printf("%s%s=== Pending Requests ===%s\n\n", clr(p.Bold), clr(p.Accent), clr(p.Reset)) fmt.Fprintf(os.Stderr, "%sLoading...%s\n", clr(p.Warning), clr(p.Reset)) - requests, err := getPendingRequests(cfg) + requests, err := getPendingRequests(ctx, cfg) if err != nil { fmt.Fprintf(os.Stderr, "\n%sError fetching requests: %v%s\n", clr(p.Error), err, clr(p.Reset)) fmt.Printf("\nPress any key to continue...") @@ -559,18 +533,13 @@ func handleViewRequests(cfg ToolConfig, reader *bufio.Reader) { } selectedRequest := requests[selection-1] - handleRequestDetail(cfg, selectedRequest, reader) + handleRequestDetail(ctx, cfg, selectedRequest, reader) } -func handleRequestDetail(cfg ToolConfig, request MediaRequest, reader *bufio.Reader) { +func handleRequestDetail(ctx context.Context, cfg ToolConfig, request MediaRequest, reader *bufio.Reader) { p := colors.GetPalette(cfg.Theme) clearScreen() - clr := func(code string) string { - if cfg.NoColor { - return "" - } - return code - } + clr := colors.ClrFunc(cfg.NoColor) fmt.Printf("%s%s=== Request Details ===%s\n\n", clr(p.Bold), clr(p.Accent), clr(p.Reset)) @@ -587,13 +556,13 @@ func handleRequestDetail(cfg ToolConfig, request MediaRequest, reader *bufio.Rea switch action { case "a": - overrides, err := selectRootFolderForApproval(cfg, request, reader) + overrides, err := selectRootFolderForApproval(ctx, cfg, request, reader) if err != nil { return } fmt.Fprintf(os.Stderr, "\n%sApproving request...%s\n", clr(p.Warning), clr(p.Reset)) - if err := approveRequestWithOverrides(cfg, request.ID, overrides); err != nil { + if err := approveRequestWithOverrides(ctx, cfg, request.ID, overrides); err != nil { fmt.Fprintf(os.Stderr, "\n%sError approving: %v%s\n", clr(p.Error), err, clr(p.Reset)) } else { fmt.Fprintf(os.Stderr, "\n%s✓ Request approved!%s\n", clr(p.Success), clr(p.Reset)) @@ -610,7 +579,7 @@ func handleRequestDetail(cfg ToolConfig, request MediaRequest, reader *bufio.Rea if confirm == "y" { fmt.Fprintf(os.Stderr, "\n%sDeclining request...%s\n", clr(p.Warning), clr(p.Reset)) - if err := declineRequest(cfg, request.ID); err != nil { + if err := declineRequest(ctx, cfg, request.ID); err != nil { fmt.Fprintf(os.Stderr, "\n%sError declining: %v%s\n", clr(p.Error), err, clr(p.Reset)) } else { fmt.Fprintf(os.Stderr, "\n%s✓ Request declined.%s\n", clr(p.Success), clr(p.Reset)) @@ -633,12 +602,7 @@ func handleRequestDetail(cfg ToolConfig, request MediaRequest, reader *bufio.Rea func displaySearchResult(cfg ToolConfig, index int, result SearchResult) { p := colors.GetPalette(cfg.Theme) - clr := func(code string) string { - if cfg.NoColor { - return "" - } - return code - } + clr := colors.ClrFunc(cfg.NoColor) title := result.Title if title == "" { @@ -647,9 +611,9 @@ func displaySearchResult(cfg ToolConfig, index int, result SearchResult) { year := getYear(result) - typeIcon := "🎬" + typeIcon := "[MOVIE]" if result.MediaType == "tv" { - typeIcon = "📺" + typeIcon = "[TV]" } fmt.Printf("%s%d.%s %s %s%s%s", @@ -686,12 +650,7 @@ func displaySearchResult(cfg ToolConfig, index int, result SearchResult) { func displayRequestSummary(cfg ToolConfig, index int, request MediaRequest) { p := colors.GetPalette(cfg.Theme) - clr := func(code string) string { - if cfg.NoColor { - return "" - } - return code - } + clr := colors.ClrFunc(cfg.NoColor) mediaType := "Movie" if request.Type == "tv" { @@ -719,12 +678,7 @@ func displayRequestSummary(cfg ToolConfig, index int, request MediaRequest) { func displayRequestDetail(cfg ToolConfig, request MediaRequest) { p := colors.GetPalette(cfg.Theme) - clr := func(code string) string { - if cfg.NoColor { - return "" - } - return code - } + clr := colors.ClrFunc(cfg.NoColor) fmt.Printf("%sRequest ID:%s %d\n", clr(p.Bold), clr(p.Reset), request.ID) fmt.Printf("%sTMDB ID:%s %d\n", clr(p.Bold), clr(p.Reset), request.Media.TmdbID) @@ -757,16 +711,11 @@ func displayRequestDetail(cfg ToolConfig, request MediaRequest) { } } -func selectSeasons(cfg ToolConfig, media SearchResult, reader *bufio.Reader) (interface{}, error) { +func selectSeasons(ctx context.Context, cfg ToolConfig, media SearchResult, reader *bufio.Reader) (interface{}, error) { p := colors.GetPalette(cfg.Theme) - clr := func(code string) string { - if cfg.NoColor { - return "" - } - return code - } + clr := colors.ClrFunc(cfg.NoColor) - details, err := getTVDetails(cfg, media.ID) + details, err := getTVDetails(ctx, cfg, media.ID) if err != nil { fmt.Fprintf(os.Stderr, "\n%sError fetching TV show details: %v%s\n", clr(p.Error), err, clr(p.Reset)) fmt.Printf("\nPress any key to continue...") @@ -835,7 +784,7 @@ func selectSeasons(cfg ToolConfig, media SearchResult, reader *bufio.Reader) (in } } -func selectRootFolderOverride(cfg ToolConfig, media SearchResult, reader *bufio.Reader) (*RequestOverrides, error) { +func selectRootFolderOverride(ctx context.Context, cfg ToolConfig, media SearchResult, reader *bufio.Reader) (*RequestOverrides, error) { p := colors.GetPalette(cfg.Theme) mediaType := strings.ToLower(media.MediaType) @@ -852,14 +801,9 @@ func selectRootFolderOverride(cfg ToolConfig, media SearchResult, reader *bufio. return nil, nil } - servers, err := fetchServiceInstances(cfg, service) + servers, err := fetchServiceInstances(ctx, cfg, service) if err != nil { - clr := func(code string) string { - if cfg.NoColor { - return "" - } - return code - } + clr := colors.ClrFunc(cfg.NoColor) fmt.Fprintf(os.Stderr, "\n%sError fetching %s servers: %v%s\n", clr(p.Error), serviceLabel, err, clr(p.Reset)) fmt.Printf("\nPress any key to continue...") readKeystroke(cfg) @@ -870,12 +814,7 @@ func selectRootFolderOverride(cfg ToolConfig, media SearchResult, reader *bufio. return nil, nil } - clr := func(code string) string { - if cfg.NoColor { - return "" - } - return code - } + clr := colors.ClrFunc(cfg.NoColor) fmt.Printf("\n%sSelect %s destination%s\n", clr(p.Bold), serviceLabel, clr(p.Reset)) @@ -936,7 +875,7 @@ func selectRootFolderOverride(cfg ToolConfig, media SearchResult, reader *bufio. fmt.Fprintf(os.Stderr, "Using %s server: %s%s%s\n", serviceLabel, clr(p.Bold), selected.Name, clr(p.Reset)) } - details, err := fetchServiceDetails(cfg, service, selected.ID) + details, err := fetchServiceDetails(ctx, cfg, service, selected.ID) if err != nil { fmt.Fprintf(os.Stderr, "\n%sError fetching %s details: %v%s\n", clr(p.Error), serviceLabel, err, clr(p.Reset)) fmt.Printf("\nPress any key to continue...") @@ -1010,7 +949,7 @@ func formatDate(dateStr string) string { if err != nil { return dateStr } - return t.Local().Format("2006-01-02 15:04") + return t.Format("2006-01-02 15:04") } func readKeystroke(cfg ToolConfig) (string, error) { @@ -1063,10 +1002,10 @@ func readKeyOrDefault(cfg ToolConfig, defaultKey string) string { } func clearScreen() { - fmt.Print(colors.HomeCursor) + fmt.Print(colors.ClearScreen + colors.HomeCursor) } -func fetchServiceInstances(cfg ToolConfig, service string) ([]ServiceInstance, error) { +func fetchServiceInstances(ctx context.Context, cfg ToolConfig, service string) ([]ServiceInstance, error) { var endpoint string switch service { case "radarr": @@ -1077,7 +1016,7 @@ func fetchServiceInstances(cfg ToolConfig, service string) ([]ServiceInstance, e return nil, nil } - resp, err := makeRequest(cfg, "GET", endpoint, nil) + resp, err := makeRequest(ctx, cfg, "GET", endpoint, nil) if err != nil { return nil, err } @@ -1095,13 +1034,13 @@ func fetchServiceInstances(cfg ToolConfig, service string) ([]ServiceInstance, e return servers, nil } -func fetchServiceDetails(cfg ToolConfig, service string, id int) (*ServiceDetails, error) { +func fetchServiceDetails(ctx context.Context, cfg ToolConfig, service string, id int) (*ServiceDetails, error) { if service != "radarr" && service != "sonarr" { return nil, fmt.Errorf("unsupported service type: %s", service) } endpoint := fmt.Sprintf("/service/%s/%d", service, id) - resp, err := makeRequest(cfg, "GET", endpoint, nil) + resp, err := makeRequest(ctx, cfg, "GET", endpoint, nil) if err != nil { return nil, err } @@ -1125,7 +1064,7 @@ func readBodyLimited(r io.Reader) ([]byte, error) { return io.ReadAll(io.LimitReader(r, maxBodySize)) } -func makeRequest(cfg ToolConfig, method, endpoint string, body interface{}) (*http.Response, error) { +func makeRequest(ctx context.Context, cfg ToolConfig, method, endpoint string, body interface{}) (*http.Response, error) { var reqBody io.Reader if body != nil { jsonData, err := json.Marshal(body) @@ -1137,10 +1076,6 @@ func makeRequest(cfg ToolConfig, method, endpoint string, body interface{}) (*ht fullURL := cfg.ServerURL + "/api/v1" + endpoint - ctx := cfg.ctx - if ctx == nil { - ctx = context.Background() - } req, err := http.NewRequestWithContext(ctx, method, fullURL, reqBody) if err != nil { return nil, err @@ -1150,19 +1085,16 @@ func makeRequest(cfg ToolConfig, method, endpoint string, body interface{}) (*ht req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") - client := cfg.client - if client == nil { - client = &http.Client{Timeout: cfg.Timeout} - } + client := &http.Client{Timeout: cfg.Timeout} return client.Do(req) } -func searchMedia(cfg ToolConfig, query string) ([]SearchResult, error) { +func searchMedia(ctx context.Context, cfg ToolConfig, query string) ([]SearchResult, error) { params := url.Values{} params.Set("query", query) endpoint := "/search?" + params.Encode() - resp, err := makeRequest(cfg, "GET", endpoint, nil) + resp, err := makeRequest(ctx, cfg, "GET", endpoint, nil) if err != nil { return nil, err } @@ -1181,9 +1113,9 @@ func searchMedia(cfg ToolConfig, query string) ([]SearchResult, error) { return searchResp.Results, nil } -func getTVDetails(cfg ToolConfig, tmdbID int) (*TVDetails, error) { +func getTVDetails(ctx context.Context, cfg ToolConfig, tmdbID int) (*TVDetails, error) { endpoint := fmt.Sprintf("/tv/%d", tmdbID) - resp, err := makeRequest(cfg, "GET", endpoint, nil) + resp, err := makeRequest(ctx, cfg, "GET", endpoint, nil) if err != nil { return nil, err } @@ -1201,7 +1133,7 @@ func getTVDetails(cfg ToolConfig, tmdbID int) (*TVDetails, error) { return &details, nil } -func createRequest(cfg ToolConfig, media SearchResult, seasons interface{}, overrides *RequestOverrides) (*MediaRequest, error) { +func createRequest(ctx context.Context, cfg ToolConfig, media SearchResult, seasons interface{}, overrides *RequestOverrides) (*MediaRequest, error) { reqData := CreateRequest{ MediaType: media.MediaType, MediaID: media.ID, @@ -1220,7 +1152,7 @@ func createRequest(cfg ToolConfig, media SearchResult, seasons interface{}, over } } - resp, err := makeRequest(cfg, "POST", "/request", reqData) + resp, err := makeRequest(ctx, cfg, "POST", "/request", reqData) if err != nil { return nil, err } @@ -1239,8 +1171,8 @@ func createRequest(cfg ToolConfig, media SearchResult, seasons interface{}, over return &request, nil } -func checkUserPermissions(cfg ToolConfig) (*AuthMe, error) { - resp, err := makeRequest(cfg, "GET", "/auth/me", nil) +func checkUserPermissions(ctx context.Context, cfg ToolConfig) (*AuthMe, error) { + resp, err := makeRequest(ctx, cfg, "GET", "/auth/me", nil) if err != nil { return nil, err } @@ -1259,8 +1191,8 @@ func checkUserPermissions(cfg ToolConfig) (*AuthMe, error) { return &authMe, nil } -func getRequestCount(cfg ToolConfig) (*RequestCount, error) { - resp, err := makeRequest(cfg, "GET", "/request/count", nil) +func getRequestCount(ctx context.Context, cfg ToolConfig) (*RequestCount, error) { + resp, err := makeRequest(ctx, cfg, "GET", "/request/count", nil) if err != nil { return nil, err } @@ -1279,14 +1211,14 @@ func getRequestCount(cfg ToolConfig) (*RequestCount, error) { return &count, nil } -func getPendingRequests(cfg ToolConfig) ([]MediaRequest, error) { +func getPendingRequests(ctx context.Context, cfg ToolConfig) ([]MediaRequest, error) { p := colors.GetPalette(cfg.Theme) var expectedPendingCount int if cfg.Verbose { fmt.Fprintf(os.Stderr, "\n=== Diagnostic: Checking pending requests ===\n") - if authMe, err := checkUserPermissions(cfg); err == nil { + if authMe, err := checkUserPermissions(ctx, cfg); err == nil { fmt.Fprintf(os.Stderr, "User ID: %d\n", authMe.ID) fmt.Fprintf(os.Stderr, "User Email: %s\n", authMe.Email) fmt.Fprintf(os.Stderr, "User Permissions: %d\n", authMe.Permissions) @@ -1305,7 +1237,7 @@ func getPendingRequests(cfg ToolConfig) ([]MediaRequest, error) { } } - if count, err := getRequestCount(cfg); err == nil { + if count, err := getRequestCount(ctx, cfg); err == nil { expectedPendingCount = count.Pending if cfg.Verbose { fmt.Fprintf(os.Stderr, "Request counts - Pending: %d, Approved: %d, Total: %d\n", @@ -1336,7 +1268,7 @@ func getPendingRequests(cfg ToolConfig) ([]MediaRequest, error) { fmt.Fprintf(os.Stderr, "Fetching: %s\n", endpoint) } - resp, err := makeRequest(cfg, "GET", endpoint, nil) + resp, err := makeRequest(ctx, cfg, "GET", endpoint, nil) if err != nil { return nil, err } @@ -1372,12 +1304,7 @@ func getPendingRequests(cfg ToolConfig) ([]MediaRequest, error) { } if expectedPendingCount > 0 && len(pending) == 0 { - clr := func(code string) string { - if cfg.NoColor { - return "" - } - return code - } + clr := colors.ClrFunc(cfg.NoColor) if !cfg.Quiet { fmt.Fprintf(os.Stderr, "\n%s⚠ WARNING: Overseerr API bug detected!%s\n", clr(p.Warning), clr(p.Reset)) @@ -1399,7 +1326,7 @@ func getPendingRequests(cfg ToolConfig) ([]MediaRequest, error) { fmt.Fprintf(os.Stderr, "Fetching: %s\n", endpoint) } - resp, err := makeRequest(cfg, "GET", endpoint, nil) + resp, err := makeRequest(ctx, cfg, "GET", endpoint, nil) if err != nil { return nil, fmt.Errorf("fallback fetch failed: %w", err) } @@ -1457,9 +1384,9 @@ func getPendingRequests(cfg ToolConfig) ([]MediaRequest, error) { return pending, nil } -func approveRequest(cfg ToolConfig, requestID int) error { +func approveRequest(ctx context.Context, cfg ToolConfig, requestID int) error { endpoint := fmt.Sprintf("/request/%d/approve", requestID) - resp, err := makeRequest(cfg, "POST", endpoint, nil) + resp, err := makeRequest(ctx, cfg, "POST", endpoint, nil) if err != nil { return err } @@ -1473,7 +1400,7 @@ func approveRequest(cfg ToolConfig, requestID int) error { return nil } -func approveRequestWithOverrides(cfg ToolConfig, requestID int, overrides *RequestOverrides) error { +func approveRequestWithOverrides(ctx context.Context, cfg ToolConfig, requestID int, overrides *RequestOverrides) error { if overrides != nil && (overrides.RootFolder != "" || overrides.ServerID > 0) { updateData := make(map[string]interface{}) if overrides.RootFolder != "" { @@ -1484,7 +1411,7 @@ func approveRequestWithOverrides(cfg ToolConfig, requestID int, overrides *Reque } endpoint := fmt.Sprintf("/request/%d", requestID) - resp, err := makeRequest(cfg, "PUT", endpoint, updateData) + resp, err := makeRequest(ctx, cfg, "PUT", endpoint, updateData) if err != nil { return fmt.Errorf("failed to set request overrides before approval: %w", err) } @@ -1496,21 +1423,16 @@ func approveRequestWithOverrides(cfg ToolConfig, requestID int, overrides *Reque } } - if err := approveRequest(cfg, requestID); err != nil { + if err := approveRequest(ctx, cfg, requestID); err != nil { return err } return nil } -func selectRootFolderForApproval(cfg ToolConfig, request MediaRequest, reader *bufio.Reader) (*RequestOverrides, error) { +func selectRootFolderForApproval(ctx context.Context, cfg ToolConfig, request MediaRequest, reader *bufio.Reader) (*RequestOverrides, error) { p := colors.GetPalette(cfg.Theme) - clr := func(code string) string { - if cfg.NoColor { - return "" - } - return code - } + clr := colors.ClrFunc(cfg.NoColor) var service string var serviceLabel string @@ -1525,7 +1447,7 @@ func selectRootFolderForApproval(cfg ToolConfig, request MediaRequest, reader *b return nil, nil } - servers, err := fetchServiceInstances(cfg, service) + servers, err := fetchServiceInstances(ctx, cfg, service) if err != nil { fmt.Fprintf(os.Stderr, "\n%sError fetching %s servers: %v%s\n", clr(p.Error), serviceLabel, err, clr(p.Reset)) fmt.Fprintf(os.Stderr, "Proceeding with approval without overrides...\n") @@ -1627,7 +1549,7 @@ func selectRootFolderForApproval(cfg ToolConfig, request MediaRequest, reader *b fmt.Fprintf(os.Stderr, "\nUsing %s server: %s%s%s\n", serviceLabel, clr(p.Bold), selected.Name, clr(p.Reset)) } - details, err := fetchServiceDetails(cfg, service, selected.ID) + details, err := fetchServiceDetails(ctx, cfg, service, selected.ID) if err != nil { fmt.Fprintf(os.Stderr, "\n%sError fetching %s details: %v%s\n", clr(p.Error), serviceLabel, err, clr(p.Reset)) fmt.Fprintf(os.Stderr, "Proceeding with approval without overrides...\n") @@ -1679,9 +1601,9 @@ func selectRootFolderForApproval(cfg ToolConfig, request MediaRequest, reader *b } } -func declineRequest(cfg ToolConfig, requestID int) error { +func declineRequest(ctx context.Context, cfg ToolConfig, requestID int) error { endpoint := fmt.Sprintf("/request/%d/decline", requestID) - resp, err := makeRequest(cfg, "POST", endpoint, nil) + resp, err := makeRequest(ctx, cfg, "POST", endpoint, nil) if err != nil { return err } diff --git a/internal/requests/requests_test.go b/internal/requests/requests_test.go index 913a308..594adc3 100644 --- a/internal/requests/requests_test.go +++ b/internal/requests/requests_test.go @@ -1,6 +1,7 @@ package requests import ( + "context" "encoding/json" "fmt" "net/http" @@ -267,7 +268,7 @@ func TestSearchMedia(t *testing.T) { Timeout: 5 * time.Second, } - results, err := searchMedia(cfg, tt.query) + results, err := searchMedia(context.Background(), cfg, tt.query) if tt.expectError { if err == nil { @@ -310,7 +311,7 @@ func TestGetTVDetails(t *testing.T) { Timeout: 5 * time.Second, } - details, err := getTVDetails(cfg, 1) + details, err := getTVDetails(context.Background(), cfg, 1) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -381,7 +382,7 @@ func TestCreateRequest(t *testing.T) { Timeout: 5 * time.Second, } - _, err := createRequest(cfg, tt.media, nil, nil) + _, err := createRequest(context.Background(), cfg, tt.media, nil, nil) if tt.expectError { if err == nil { @@ -432,7 +433,7 @@ func TestGetPendingRequests(t *testing.T) { Timeout: 5 * time.Second, } - requests, err := getPendingRequests(cfg) + requests, err := getPendingRequests(context.Background(), cfg) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -460,7 +461,7 @@ func TestApproveRequest(t *testing.T) { Timeout: 5 * time.Second, } - err := approveRequest(cfg, 123) + err := approveRequest(context.Background(), cfg, 123) if err != nil { t.Errorf("Unexpected error: %v", err) } @@ -484,7 +485,7 @@ func TestDeclineRequest(t *testing.T) { Timeout: 5 * time.Second, } - err := declineRequest(cfg, 123) + err := declineRequest(context.Background(), cfg, 123) if err != nil { t.Errorf("Unexpected error: %v", err) } @@ -527,7 +528,7 @@ func TestTestConnection(t *testing.T) { Timeout: 5 * time.Second, } - err := testConnection(cfg) + err := testConnection(context.Background(), cfg) if tt.expectError { if err == nil { @@ -560,7 +561,7 @@ func TestFetchServiceInstances(t *testing.T) { Timeout: 5 * time.Second, } - instances, err := fetchServiceInstances(cfg, "radarr") + instances, err := fetchServiceInstances(context.Background(), cfg, "radarr") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -594,7 +595,7 @@ func TestFetchServiceDetails(t *testing.T) { Timeout: 5 * time.Second, } - details, err := fetchServiceDetails(cfg, "radarr", 1) + details, err := fetchServiceDetails(context.Background(), cfg, "radarr", 1) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -656,7 +657,7 @@ func TestSearchMediaWithSpaces(t *testing.T) { Timeout: 5 * time.Second, } - _, err := searchMedia(cfg, tt.query) + _, err := searchMedia(context.Background(), cfg, tt.query) if err != nil { t.Errorf("Unexpected error: %v", err) } @@ -705,7 +706,7 @@ func TestSearchMediaErrorDiagnostics(t *testing.T) { Timeout: 5 * time.Second, } - _, err := searchMedia(cfg, "test query") + _, err := searchMedia(context.Background(), cfg, "test query") if err == nil { t.Fatal("Expected error, got nil") } @@ -810,7 +811,7 @@ func TestCheckUserPermissions(t *testing.T) { Timeout: 5 * time.Second, } - authMe, err := checkUserPermissions(cfg) + authMe, err := checkUserPermissions(context.Background(), cfg) if tt.expectError { if err == nil { @@ -892,7 +893,7 @@ func TestGetRequestCount(t *testing.T) { Timeout: 5 * time.Second, } - count, err := getRequestCount(cfg) + count, err := getRequestCount(context.Background(), cfg) if tt.expectError { if err == nil { @@ -993,7 +994,7 @@ func TestGetPendingRequestsHappyPath(t *testing.T) { Timeout: 5 * time.Second, } - requests, err := getPendingRequests(cfg) + requests, err := getPendingRequests(context.Background(), cfg) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1043,7 +1044,7 @@ func TestGetPendingRequestsNoPending(t *testing.T) { Timeout: 5 * time.Second, } - requests, err := getPendingRequests(cfg) + requests, err := getPendingRequests(context.Background(), cfg) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1113,7 +1114,7 @@ func TestGetPendingRequestsPagination(t *testing.T) { Timeout: 5 * time.Second, } - requests, err := getPendingRequests(cfg) + requests, err := getPendingRequests(context.Background(), cfg) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1193,7 +1194,7 @@ func TestGetPendingRequestsWithFallback(t *testing.T) { Timeout: 5 * time.Second, } - requests, err := getPendingRequests(cfg) + requests, err := getPendingRequests(context.Background(), cfg) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1268,7 +1269,7 @@ func TestGetPendingRequestsNoFallbackNeeded(t *testing.T) { Timeout: 5 * time.Second, } - requests, err := getPendingRequests(cfg) + requests, err := getPendingRequests(context.Background(), cfg) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1361,7 +1362,7 @@ func TestGetPendingRequestsFallbackPagination(t *testing.T) { Timeout: 5 * time.Second, } - requests, err := getPendingRequests(cfg) + requests, err := getPendingRequests(context.Background(), cfg) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1494,7 +1495,7 @@ func TestApproveRequestWithOverrides(t *testing.T) { Timeout: 5 * time.Second, } - err := approveRequestWithOverrides(cfg, tt.requestID, tt.overrides) + err := approveRequestWithOverrides(context.Background(), cfg, tt.requestID, tt.overrides) updateShouldHaveBeenCalled := tt.expectUpdate updateSucceeded := updateShouldHaveBeenCalled && tt.updateStatus == http.StatusOK @@ -1576,7 +1577,7 @@ func TestApproveRequestWithOverridesEndpoint(t *testing.T) { RootFolder: rootFolder, } - err := approveRequestWithOverrides(cfg, requestID, overrides) + err := approveRequestWithOverrides(context.Background(), cfg, requestID, overrides) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1613,7 +1614,7 @@ func TestApproveRequestWithOverridesNilOverrides(t *testing.T) { Timeout: 5 * time.Second, } - err := approveRequestWithOverrides(cfg, 789, nil) + err := approveRequestWithOverrides(context.Background(), cfg, 789, nil) if err != nil { t.Fatalf("Unexpected error: %v", err) } diff --git a/internal/streams/streams.go b/internal/streams/streams.go index 49a4327..80ecf47 100644 --- a/internal/streams/streams.go +++ b/internal/streams/streams.go @@ -20,7 +20,7 @@ import ( "github.com/calmcacil/CalmsToolkit/internal/colors" "github.com/calmcacil/CalmsToolkit/internal/config" - httpclient "github.com/calmcacil/CalmsToolkit/internal/http" + "github.com/calmcacil/CalmsToolkit/internal/httputil" ) const ( @@ -30,6 +30,11 @@ const ( ServerJellyfin = "jellyfin" // ServerBoth indicates both Plex and Jellyfin server types. ServerBoth = "both" + + streamUserWidth = 12 + streamShowWidth = 9 + streamTitleWidth = 11 + streamClientWidth = 12 ) // PlexMediaContainer is the top-level XML response from Plex /status/sessions. @@ -278,7 +283,7 @@ func Run(cfg ToolConfig) { } select { case <-ctx.Done(): - fmt.Println("\nShutting down.") + fmt.Fprintln(os.Stderr, "\nShutting down.") return case <-time.After(time.Duration(cfg.WatchSeconds) * time.Second): } @@ -288,7 +293,7 @@ func Run(cfg ToolConfig) { ctx := context.Background() if err := displayAllSessions(ctx, cfg, p); err != nil { fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) - os.Exit(1) + return } } @@ -297,7 +302,7 @@ func displayAllSessionsWithHistory(ctx context.Context, cfg ToolConfig, history var plexCount, jellyfinCount int var plexErr, jellyfinErr error - client := httpclient.NewClient(cfg.Timeout) + client := httputil.NewClient(cfg.Timeout) if cfg.ServerType == ServerPlex || cfg.ServerType == ServerBoth { streams, err := fetchPlexStreams(ctx, client, cfg) @@ -343,7 +348,7 @@ func displayAllSessions(ctx context.Context, cfg ToolConfig, p *colors.Palette) var plexCount, jellyfinCount int var plexErr, jellyfinErr error - client := httpclient.NewClient(cfg.Timeout) + client := httputil.NewClient(cfg.Timeout) if cfg.ServerType == ServerPlex || cfg.ServerType == ServerBoth { streams, err := fetchPlexStreams(ctx, client, cfg) @@ -377,12 +382,16 @@ func displayAllSessions(ctx context.Context, cfg ToolConfig, p *colors.Palette) } func allFailed(serverType string, plexErr, jellyfinErr error) bool { - needsPlex := serverType == ServerPlex || serverType == ServerBoth - needsJellyfin := serverType == ServerJellyfin || serverType == ServerBoth - if serverType == ServerBoth { - return (needsPlex && plexErr != nil) && (needsJellyfin && jellyfinErr != nil) + switch serverType { + case ServerBoth: + return plexErr != nil && jellyfinErr != nil + case ServerPlex: + return plexErr != nil + case ServerJellyfin: + return jellyfinErr != nil + default: + return true } - return (needsPlex && plexErr != nil) || (needsJellyfin && jellyfinErr != nil) } func maybeError(err error) string { @@ -392,7 +401,7 @@ func maybeError(err error) string { return "not configured" } -func fetchPlexStreams(ctx context.Context, client *httpclient.Client, cfg ToolConfig) ([]StreamInfo, error) { +func fetchPlexStreams(ctx context.Context, client *httputil.Client, cfg ToolConfig) ([]StreamInfo, error) { url := fmt.Sprintf("%s/status/sessions", cfg.PlexURL) headers := map[string]string{ "X-Plex-Token": cfg.PlexToken, @@ -414,7 +423,7 @@ func fetchPlexStreams(ctx context.Context, client *httpclient.Client, cfg ToolCo return streams, nil } -func fetchJellyfinStreams(ctx context.Context, client *httpclient.Client, cfg ToolConfig) ([]StreamInfo, error) { +func fetchJellyfinStreams(ctx context.Context, client *httputil.Client, cfg ToolConfig) ([]StreamInfo, error) { url := fmt.Sprintf("%s/Sessions", cfg.JellyfinURL) headers := map[string]string{ "Accept": "application/json", @@ -777,12 +786,7 @@ func boxStreamBottom(bw *bufio.Writer, termW int) { } func displayTerminalOutput(streams []StreamInfo, plexCount, jellyfinCount int, noColor bool, p *colors.Palette) error { - clr := func(code string) string { - if noColor { - return "" - } - return code - } + clr := colors.ClrFunc(noColor) rawW := getTermWidth() boxW, termW := getBoxWidth(rawW) @@ -848,12 +852,7 @@ func displayTerminalOutput(streams []StreamInfo, plexCount, jellyfinCount int, n } func displayTerminalOutputWithHistory(currentStreams []StreamInfo, history *SessionHistory, plexCount, jellyfinCount int, noColor bool, p *colors.Palette) error { - clr := func(code string) string { - if noColor { - return "" - } - return code - } + clr := colors.ClrFunc(noColor) rawW := getTermWidth() boxW, termW := getBoxWidth(rawW) @@ -950,12 +949,7 @@ func displayTerminalOutputWithHistory(currentStreams []StreamInfo, history *Sess } func displayStreamToBox(bw *bufio.Writer, stream StreamInfo, boxW int, noColor bool, p *colors.Palette) { - clr := func(code string) string { - if noColor { - return "" - } - return code - } + clr := colors.ClrFunc(noColor) serverColor := p.ServerJellyfin if stream.Server == "plex" { @@ -1024,7 +1018,7 @@ func displayStreamToBox(bw *bufio.Writer, stream StreamInfo, boxW int, noColor b } line = fmt.Sprintf(" %sStatus%s: %s%s%s", clr(p.Bold), clr(p.Reset), clr(statusColor), statusText, clr(p.Reset)) if stream.Transcoding { - line += " ⚠" + line += " !" } fmt.Fprint(bw, "│") fmt.Fprint(bw, colors.PadRight(line, boxW)) @@ -1079,12 +1073,7 @@ func displayStreamToBox(bw *bufio.Writer, stream StreamInfo, boxW int, noColor b // streamContentLines returns the padded interior content of a stream box // (without border characters), one element per line. func streamContentLines(stream StreamInfo, boxW int, noColor bool, p *colors.Palette) []string { - clr := func(code string) string { - if noColor { - return "" - } - return code - } + clr := colors.ClrFunc(noColor) trunc := func(s string, maxW int) string { return runewidth.Truncate(s, maxW, "…") @@ -1097,7 +1086,7 @@ func streamContentLines(stream StreamInfo, boxW int, noColor bool, p *colors.Pal serverColor = p.ServerPlex } - userW := boxW - 12 // " PLEX User: " + userW := boxW - streamUserWidth user := trunc(stream.User, userW) line := fmt.Sprintf(" %s%s%s %sUser%s: %s%s%s", clr(serverColor), strings.ToUpper(stream.Server), clr(p.Reset), @@ -1106,7 +1095,7 @@ func streamContentLines(stream StreamInfo, boxW int, noColor bool, p *colors.Pal lines = append(lines, colors.PadRight(line, boxW)) if stream.Type == "episode" && stream.Show != "" { - showW := boxW - 9 // " Show: " + showW := boxW - streamShowWidth show := trunc(stream.Show, showW) line := fmt.Sprintf(" %sShow%s: %s", clr(p.Bold), clr(p.Reset), show) lines = append(lines, colors.PadRight(line, boxW)) @@ -1122,7 +1111,7 @@ func streamContentLines(stream StreamInfo, boxW int, noColor bool, p *colors.Pal lines = append(lines, colors.PadRight(line, boxW)) } } else { - titleW := boxW - 11 // " Title: (YYYY)" + titleW := boxW - streamTitleWidth title := trunc(stream.Title, titleW) line := fmt.Sprintf(" %sTitle%s: %s", clr(p.Bold), clr(p.Reset), title) if stream.Year != "" { @@ -1134,8 +1123,7 @@ func streamContentLines(stream StreamInfo, boxW int, noColor bool, p *colors.Pal if stream.Client != "" { var clientLine string if stream.Device != "" && stream.Device != stream.Client { - // " Client: X (Y)" — overhead: " Client: ()" = 12 - availW := boxW - 12 + availW := boxW - streamClientWidth devW := runewidth.StringWidth(stream.Device) cliW := availW - devW if cliW < 4 { @@ -1165,7 +1153,7 @@ func streamContentLines(stream StreamInfo, boxW int, noColor bool, p *colors.Pal } line = fmt.Sprintf(" %sStatus%s: %s%s%s", clr(p.Bold), clr(p.Reset), clr(statusColor), statusText, clr(p.Reset)) if stream.Transcoding { - line += " ⚠" + line += " !" } lines = append(lines, colors.PadRight(line, boxW)) @@ -1280,12 +1268,7 @@ func renderStreamGrid(bw *bufio.Writer, streams []StreamInfo, termW int, noColor } func displayEndedStreamToBox(bw *bufio.Writer, record SessionRecord, boxW int, noColor bool, p *colors.Palette) { - clr := func(code string) string { - if noColor { - return "" - } - return code - } + clr := colors.ClrFunc(noColor) stream := record.Stream endedStr := fmt.Sprintf("%s[ENDED %s]%s", clr(p.Subdued), formatTimeSince(*record.EndTime), clr(p.Reset)) @@ -1346,12 +1329,7 @@ func formatDuration(d time.Duration) string { } func displayStreamSummaryToBox(bw *bufio.Writer, streams []StreamInfo, boxW int, noColor bool, p *colors.Palette) { - clr := func(code string) string { - if noColor { - return "" - } - return code - } + clr := colors.ClrFunc(noColor) transcodeCount := 0 totalBandwidth := 0.0 diff --git a/internal/streams/streams_test.go b/internal/streams/streams_test.go index ae6a8b4..8518ae9 100644 --- a/internal/streams/streams_test.go +++ b/internal/streams/streams_test.go @@ -10,7 +10,7 @@ import ( "time" "github.com/calmcacil/CalmsToolkit/internal/config" - httpclient "github.com/calmcacil/CalmsToolkit/internal/http" + "github.com/calmcacil/CalmsToolkit/internal/httputil" ) func TestFormatTimeSince(t *testing.T) { @@ -235,7 +235,7 @@ func TestFetchPlexStreams(t *testing.T) { PlexToken: "test-token", Timeout: 5 * time.Second, } - client := httpclient.NewClient(cfg.Timeout) + client := httputil.NewClient(cfg.Timeout) streams, err := fetchPlexStreams(context.Background(), client, cfg) if err != nil { @@ -301,7 +301,7 @@ func TestFetchJellyfinStreams(t *testing.T) { JellyfinToken: "test-token", Timeout: 5 * time.Second, } - client := httpclient.NewClient(cfg.Timeout) + client := httputil.NewClient(cfg.Timeout) streams, err := fetchJellyfinStreams(context.Background(), client, cfg) if err != nil {