From dfa293bc11fb9e205ec904cb3eb7425fc32fa494 Mon Sep 17 00:00:00 2001 From: "Calmcacil B." <716671+calmcacil@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:25:49 +0200 Subject: [PATCH] feat(calendar): add external service URLs --- README.md | 3 ++ docs/user/CLI_SPEC.md | 17 ++++++++ docs/user/MIGRATION_UNIFIED_CLI.md | 18 +++++++++ internal/calendar/calendar.go | 4 +- internal/calendar/calendar_test.go | 63 ++++++++++++++++++++++++++++++ internal/config/config.go | 24 ++++++++++-- internal/config/config_test.go | 57 ++++++++++++++++++++++++++- 7 files changed, 179 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 62f4cd3..b018e14 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,9 @@ You need `curl`, `tar`, and `sha256sum`. For manual downloads, upgrades, non-root installation, and removal, see the [installation guide](docs/user/INSTALLATION.md). Configuration is stored at `~/.config/calmstoolkit/config.json` with mode `0600`; override it with `--config` or `CALMSTOOLKIT_CONFIG`. +For Sonarr and Radarr instances, `url` is the address CalmsToolkit uses for API +requests. Set the optional `external_url` when browser links must use a different +address, such as when the API URL contains a container-only hostname. ## Commands diff --git a/docs/user/CLI_SPEC.md b/docs/user/CLI_SPEC.md index bb9ca85..543d9a7 100644 --- a/docs/user/CLI_SPEC.md +++ b/docs/user/CLI_SPEC.md @@ -2,6 +2,23 @@ Configuration precedence is `defaults < configuration file < environment < explicitly supplied flags`. An omitted boolean or interval flag never overwrites its configured value. The config path is explicit `--config`, then `CALMSTOOLKIT_CONFIG`, then `~/.config/calmstoolkit/config.json`. +Sonarr and Radarr instances distinguish the API address from the optional +browser-facing address: + +```json +{ + "name": "Sonarr", + "url": "http://sonarr:8989", + "external_url": "https://sonarr.example.com", + "api_key": "..." +} +``` + +CalmsToolkit connects to `url`. User-facing links, such as calendar queue +warnings, use `external_url` when present and otherwise fall back to `url`. +Both fields may include a reverse-proxy path and are normalized without a +trailing slash. + Global output modes are `auto`, `terminal`, `plain`, `json`, and `ndjson`. Auto selects terminal only for a capable UTF-8 TTY. `NO_COLOR`, `--no-color`, or `TERM=dumb` disables color; limited terminals use ASCII/plain output. JSON is one snapshot. NDJSON is one envelope per line and is mandatory for machine watch output. Interactive requests rejects both machine modes. Every machine record has: diff --git a/docs/user/MIGRATION_UNIFIED_CLI.md b/docs/user/MIGRATION_UNIFIED_CLI.md index 661f6db..5d61a59 100644 --- a/docs/user/MIGRATION_UNIFIED_CLI.md +++ b/docs/user/MIGRATION_UNIFIED_CLI.md @@ -25,3 +25,21 @@ alias arr-feed='calmstoolkit feed' ``` Before removing old scripts, walk through `config validate`, `doctor`, one terminal invocation, one piped invocation, and every JSON consumer. JSON now uses the envelope documented in `CLI_SPEC.md`; read feature fields from `.data`. + +## Browser-facing Sonarr and Radarr URLs + +Existing instance configuration remains valid. If an instance's `url` uses a +hostname that only resolves from the CalmsToolkit host or container network, +add an optional `external_url` for links shown to the user: + +```json +{ + "name": "Sonarr", + "url": "http://sonarr:8989", + "external_url": "https://sonarr.example.com", + "api_key": "..." +} +``` + +API requests and `doctor` continue to use `url`; calendar queue warnings use +`external_url`. When `external_url` is omitted, links continue to use `url`. diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go index 6c84b9b..0532103 100644 --- a/internal/calendar/calendar.go +++ b/internal/calendar/calendar.go @@ -352,7 +352,7 @@ func fetchSonarrInstance(ctx context.Context, client *httputil.Client, inst conf qMu.Lock() *queueIssues = append(*queueIssues, QueueIssue{ ServiceName: inst.Name, - URL: inst.URL + "/activity/queue", + URL: inst.BrowserURL() + "/activity/queue", Count: errorCount, }) qMu.Unlock() @@ -425,7 +425,7 @@ func fetchRadarrInstance(ctx context.Context, client *httputil.Client, inst conf qMu.Lock() *queueIssues = append(*queueIssues, QueueIssue{ ServiceName: inst.Name, - URL: inst.URL + "/activity/queue", + URL: inst.BrowserURL() + "/activity/queue", Count: errorCount, }) qMu.Unlock() diff --git a/internal/calendar/calendar_test.go b/internal/calendar/calendar_test.go index 58ebc3f..31b5391 100644 --- a/internal/calendar/calendar_test.go +++ b/internal/calendar/calendar_test.go @@ -3,8 +3,10 @@ package calendar import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -171,6 +173,67 @@ func TestFetchQueue(t *testing.T) { } } +func TestQueueIssuesUseExternalInstanceURL(t *testing.T) { + tests := []struct { + name string + calendarAPI string + configure func(config.ArrInstance) ToolConfig + }{ + { + name: "Sonarr", + calendarAPI: "/api/v3/calendar", + configure: func(inst config.ArrInstance) ToolConfig { + return ToolConfig{SonarrInstances: []config.ArrInstance{inst}, Days: 1} + }, + }, + { + name: "Radarr", + calendarAPI: "/api/v3/calendar", + configure: func(inst config.ArrInstance) ToolConfig { + return ToolConfig{RadarrInstances: []config.ArrInstance{inst}, Days: 1} + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case tt.calendarAPI: + fmt.Fprint(w, "[]") + case "/api/v3/queue": + json.NewEncoder(w).Encode(QueueResponse{Records: []QueueItem{{TrackedState: "importFailed"}}}) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + inst := config.ArrInstance{ + Name: tt.name, + URL: server.URL, + ExternalURL: "https://media.example.com/" + strings.ToLower(tt.name), + APIKey: "test-token", + } + cfg := tt.configure(inst) + cfg.Timeout = time.Second + + _, issues, err := aggregateCalendar(context.Background(), cfg) + if err != nil { + t.Fatalf("aggregateCalendar() error = %v", err) + } + if len(issues) != 1 { + t.Fatalf("got %d queue issues, want 1", len(issues)) + } + want := inst.ExternalURL + "/activity/queue" + if issues[0].URL != want { + t.Errorf("queue issue URL = %q, want %q", issues[0].URL, want) + } + }) + } +} + func TestTruncateWithEllipsis(t *testing.T) { tests := []struct { name string diff --git a/internal/config/config.go b/internal/config/config.go index 56048ca..b1e0c64 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -61,9 +61,19 @@ func Migrate(cfg *ToolkitConfig) error { // ArrInstance represents a Sonarr or Radarr server instance. type ArrInstance struct { - Name string `json:"name"` - URL string `json:"url"` - APIKey string `json:"api_key"` + Name string `json:"name"` + URL string `json:"url"` + ExternalURL string `json:"external_url,omitempty"` + APIKey string `json:"api_key"` +} + +// BrowserURL returns the browser-facing base URL when configured, otherwise +// it falls back to the API URL used by CalmsToolkit. +func (i ArrInstance) BrowserURL() string { + if externalURL := strings.TrimSuffix(strings.TrimSpace(i.ExternalURL), "/"); externalURL != "" { + return externalURL + } + return strings.TrimSuffix(strings.TrimSpace(i.URL), "/") } // GeneralConfig holds general toolkit settings. @@ -234,9 +244,11 @@ func LoadToolkitConfigAt(explicitPath string) (*ToolkitConfig, error) { for i := range cfg.Sonarr { cfg.Sonarr[i].URL = strings.TrimSuffix(cfg.Sonarr[i].URL, "/") + cfg.Sonarr[i].ExternalURL = strings.TrimSuffix(cfg.Sonarr[i].ExternalURL, "/") } for i := range cfg.Radarr { cfg.Radarr[i].URL = strings.TrimSuffix(cfg.Radarr[i].URL, "/") + cfg.Radarr[i].ExternalURL = strings.TrimSuffix(cfg.Radarr[i].ExternalURL, "/") } ApplyEnvironment(cfg) @@ -294,6 +306,9 @@ func (c *ToolkitConfig) Validate() error { } else if !validHTTPURL(inst.URL) { add("sonarr_instances[%d]: invalid url %q", i, inst.URL) } + if inst.ExternalURL != "" && !validHTTPURL(inst.ExternalURL) { + add("sonarr_instances[%d]: invalid external_url %q", i, inst.ExternalURL) + } if inst.APIKey == "" { add("sonarr_instances[%d]: api_key is required", i) } @@ -314,6 +329,9 @@ func (c *ToolkitConfig) Validate() error { } else if !validHTTPURL(inst.URL) { add("radarr_instances[%d]: invalid url %q", i, inst.URL) } + if inst.ExternalURL != "" && !validHTTPURL(inst.ExternalURL) { + add("radarr_instances[%d]: invalid external_url %q", i, inst.ExternalURL) + } if inst.APIKey == "" { add("radarr_instances[%d]: api_key is required", i) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 484712b..5056704 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -68,7 +68,7 @@ func TestConfigSaveLoadRoundTrip(t *testing.T) { cfg := DefaultToolkitConfig() cfg.Sonarr = []ArrInstance{ - {Name: "Sonarr HD", URL: "http://sonarr:8989", APIKey: "abc123"}, + {Name: "Sonarr HD", URL: "http://sonarr:8989", ExternalURL: "https://sonarr.example.com", APIKey: "abc123"}, } cfg.Radarr = []ArrInstance{ {Name: "Radarr HD", URL: "http://radarr:7878", APIKey: "xyz789"}, @@ -97,6 +97,9 @@ func TestConfigSaveLoadRoundTrip(t *testing.T) { if loaded.Sonarr[0].URL != "http://sonarr:8989" { t.Errorf("Sonarr[0].URL = %q, want %q", loaded.Sonarr[0].URL, "http://sonarr:8989") } + if loaded.Sonarr[0].ExternalURL != "https://sonarr.example.com" { + t.Errorf("Sonarr[0].ExternalURL = %q, want %q", loaded.Sonarr[0].ExternalURL, "https://sonarr.example.com") + } if loaded.Sonarr[0].APIKey != "abc123" { t.Errorf("Sonarr[0].APIKey = %q, want %q", loaded.Sonarr[0].APIKey, "abc123") } @@ -156,6 +159,26 @@ func TestConfigValidate(t *testing.T) { }, wantErr: true, }, + { + name: "invalid sonarr external URL", + cfg: &ToolkitConfig{ + Version: 1, + Sonarr: []ArrInstance{{ + Name: "x", URL: "http://sonarr:8989", ExternalURL: "sonarr.example.com", APIKey: "y", + }}, + }, + wantErr: true, + }, + { + name: "invalid radarr external URL", + cfg: &ToolkitConfig{ + Version: 1, + Radarr: []ArrInstance{{ + Name: "x", URL: "http://radarr:7878", ExternalURL: "radarr.example.com", APIKey: "y", + }}, + }, + wantErr: true, + }, { name: "invalid timeout", cfg: &ToolkitConfig{ @@ -199,7 +222,7 @@ func TestConfigURLNormalization(t *testing.T) { dir := t.TempDir() cfg := DefaultToolkitConfig() cfg.Sonarr = []ArrInstance{ - {Name: "Test", URL: "http://example.com/", APIKey: "key"}, + {Name: "Test", URL: "http://example.com/", ExternalURL: "https://external.example.com/sonarr/", APIKey: "key"}, } origHome := os.Getenv("HOME") @@ -218,6 +241,36 @@ func TestConfigURLNormalization(t *testing.T) { if loaded.Sonarr[0].URL != "http://example.com" { t.Errorf("URL = %q, want %q", loaded.Sonarr[0].URL, "http://example.com") } + if loaded.Sonarr[0].ExternalURL != "https://external.example.com/sonarr" { + t.Errorf("ExternalURL = %q, want %q", loaded.Sonarr[0].ExternalURL, "https://external.example.com/sonarr") + } +} + +func TestArrInstanceBrowserURL(t *testing.T) { + tests := []struct { + name string + inst ArrInstance + want string + }{ + { + name: "external URL", + inst: ArrInstance{URL: "http://sonarr:8989", ExternalURL: "https://media.example.com/sonarr/"}, + want: "https://media.example.com/sonarr", + }, + { + name: "API URL fallback", + inst: ArrInstance{URL: "http://sonarr:8989/"}, + want: "http://sonarr:8989", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.inst.BrowserURL(); got != tt.want { + t.Errorf("BrowserURL() = %q, want %q", got, tt.want) + } + }) + } } func TestConfigPath(t *testing.T) {