Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions docs/user/CLI_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
18 changes: 18 additions & 0 deletions docs/user/MIGRATION_UNIFIED_CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
4 changes: 2 additions & 2 deletions internal/calendar/calendar.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
63 changes: 63 additions & 0 deletions internal/calendar/calendar_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package calendar
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -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
Expand Down
24 changes: 21 additions & 3 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down
57 changes: 55 additions & 2 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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")
Expand All @@ -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) {
Expand Down