Initial project skeleton (M0)
Add SPECS.md, JSON config loader with env overrides and validation, and an empty tview shell with tabs and status bar. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
// Package config loads and validates the arr-tui configuration file.
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Service holds the connection settings of a single backend.
|
||||
type Service struct {
|
||||
URL string `json:"url"`
|
||||
APIKey string `json:"apikey"`
|
||||
}
|
||||
|
||||
// Arr is a Radarr or Sonarr instance, with optional defaults for the add form.
|
||||
type Arr struct {
|
||||
Service
|
||||
DefaultQualityProfile string `json:"default_quality_profile,omitempty"`
|
||||
DefaultRootFolder string `json:"default_root_folder,omitempty"`
|
||||
}
|
||||
|
||||
// UI holds interface settings.
|
||||
type UI struct {
|
||||
RefreshSeconds int `json:"refresh_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// Config is the full application configuration.
|
||||
type Config struct {
|
||||
Radarr Arr `json:"radarr"`
|
||||
Sonarr Arr `json:"sonarr"`
|
||||
SABnzbd Service `json:"sabnzbd"`
|
||||
UI UI `json:"ui"`
|
||||
}
|
||||
|
||||
const defaultRefreshSeconds = 3
|
||||
|
||||
// DefaultPath returns $XDG_CONFIG_HOME/arr-tui/config.json, falling back to
|
||||
// ~/.config/arr-tui/config.json.
|
||||
func DefaultPath() (string, error) {
|
||||
dir := os.Getenv("XDG_CONFIG_HOME")
|
||||
if dir == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir = filepath.Join(home, ".config")
|
||||
}
|
||||
return filepath.Join(dir, "arr-tui", "config.json"), nil
|
||||
}
|
||||
|
||||
// Load reads the config at path, applies environment overrides and defaults,
|
||||
// and validates the result. The returned warnings are non-fatal.
|
||||
func Load(path string) (*Config, []string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("reading config: %w", err)
|
||||
}
|
||||
|
||||
var warnings []string
|
||||
if info, err := os.Stat(path); err == nil && info.Mode().Perm()&0o077 != 0 {
|
||||
warnings = append(warnings, fmt.Sprintf(
|
||||
"%s is accessible by other users (mode %04o); it contains API keys, consider chmod 600",
|
||||
path, info.Mode().Perm()))
|
||||
}
|
||||
|
||||
cfg, err := Parse(data)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return cfg, warnings, nil
|
||||
}
|
||||
|
||||
// Parse decodes config JSON, applies environment overrides and defaults, and
|
||||
// validates the result. Unknown fields are rejected to catch typos.
|
||||
func Parse(data []byte) (*Config, error) {
|
||||
var cfg Config
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("parsing config: %w", err)
|
||||
}
|
||||
|
||||
cfg.applyEnv(os.Getenv)
|
||||
cfg.applyDefaults()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) applyEnv(get func(string) string) {
|
||||
override := func(dst *string, key string) {
|
||||
if v := get(key); v != "" {
|
||||
*dst = v
|
||||
}
|
||||
}
|
||||
override(&c.Radarr.URL, "ARR_TUI_RADARR_URL")
|
||||
override(&c.Radarr.APIKey, "ARR_TUI_RADARR_APIKEY")
|
||||
override(&c.Sonarr.URL, "ARR_TUI_SONARR_URL")
|
||||
override(&c.Sonarr.APIKey, "ARR_TUI_SONARR_APIKEY")
|
||||
override(&c.SABnzbd.URL, "ARR_TUI_SABNZBD_URL")
|
||||
override(&c.SABnzbd.APIKey, "ARR_TUI_SABNZBD_APIKEY")
|
||||
}
|
||||
|
||||
func (c *Config) applyDefaults() {
|
||||
if c.UI.RefreshSeconds <= 0 {
|
||||
c.UI.RefreshSeconds = defaultRefreshSeconds
|
||||
}
|
||||
c.Radarr.URL = strings.TrimRight(c.Radarr.URL, "/")
|
||||
c.Sonarr.URL = strings.TrimRight(c.Sonarr.URL, "/")
|
||||
c.SABnzbd.URL = strings.TrimRight(c.SABnzbd.URL, "/")
|
||||
}
|
||||
|
||||
// Validate checks that every service has a usable URL and API key.
|
||||
func (c *Config) Validate() error {
|
||||
var errs []error
|
||||
for _, s := range []struct {
|
||||
name string
|
||||
svc Service
|
||||
}{
|
||||
{"radarr", c.Radarr.Service},
|
||||
{"sonarr", c.Sonarr.Service},
|
||||
{"sabnzbd", c.SABnzbd},
|
||||
} {
|
||||
if s.svc.APIKey == "" {
|
||||
errs = append(errs, fmt.Errorf("%s: apikey is required", s.name))
|
||||
}
|
||||
u, err := url.Parse(s.svc.URL)
|
||||
switch {
|
||||
case s.svc.URL == "":
|
||||
errs = append(errs, fmt.Errorf("%s: url is required", s.name))
|
||||
case err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "":
|
||||
errs = append(errs, fmt.Errorf("%s: url %q must be an http(s) URL", s.name, s.svc.URL))
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const valid = `{
|
||||
"radarr": {"url": "http://localhost:7878/", "apikey": "r"},
|
||||
"sonarr": {"url": "http://localhost:8989", "apikey": "s"},
|
||||
"sabnzbd": {"url": "https://sab.example.com", "apikey": "n"}
|
||||
}`
|
||||
|
||||
func TestParseValid(t *testing.T) {
|
||||
cfg, err := Parse([]byte(valid))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Radarr.URL != "http://localhost:7878" {
|
||||
t.Errorf("trailing slash not trimmed: %q", cfg.Radarr.URL)
|
||||
}
|
||||
if cfg.UI.RefreshSeconds != defaultRefreshSeconds {
|
||||
t.Errorf("refresh default = %d", cfg.UI.RefreshSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseErrors(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
json string
|
||||
want string
|
||||
}{
|
||||
"unknown field": {`{"radar": {}}`, "unknown field"},
|
||||
"missing key": {`{"radarr":{"url":"http://a"},"sonarr":{"url":"http://b","apikey":"x"},"sabnzbd":{"url":"http://c","apikey":"x"}}`, "radarr: apikey is required"},
|
||||
"bad url": {`{"radarr":{"url":"localhost","apikey":"x"},"sonarr":{"url":"http://b","apikey":"x"},"sabnzbd":{"url":"http://c","apikey":"x"}}`, "must be an http(s) URL"},
|
||||
"invalid json": {`{`, "parsing config"},
|
||||
}
|
||||
for name, tc := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := Parse([]byte(tc.json))
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want containing %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvOverride(t *testing.T) {
|
||||
t.Setenv("ARR_TUI_RADARR_APIKEY", "from-env")
|
||||
cfg, err := Parse([]byte(valid))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Radarr.APIKey != "from-env" {
|
||||
t.Errorf("apikey = %q", cfg.Radarr.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPermissionWarning(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(valid), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, warnings, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(warnings) != 1 {
|
||||
t.Fatalf("warnings = %v", warnings)
|
||||
}
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, warnings, _ = Load(path); len(warnings) != 0 {
|
||||
t.Fatalf("unexpected warnings: %v", warnings)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Package ui contains the tview user interface.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
type view struct {
|
||||
key rune
|
||||
name string
|
||||
body tview.Primitive
|
||||
}
|
||||
|
||||
// App is the top-level arr-tui application.
|
||||
type App struct {
|
||||
app *tview.Application
|
||||
tabs *tview.TextView
|
||||
pages *tview.Pages
|
||||
status *tview.TextView
|
||||
views []view
|
||||
active int
|
||||
}
|
||||
|
||||
// New builds the application shell: a tab bar, one page per view and a status bar.
|
||||
func New() *App {
|
||||
a := &App{
|
||||
app: tview.NewApplication(),
|
||||
tabs: tview.NewTextView().SetDynamicColors(true),
|
||||
pages: tview.NewPages(),
|
||||
status: tview.NewTextView().SetDynamicColors(true),
|
||||
}
|
||||
|
||||
a.views = []view{
|
||||
{'1', "Movies", placeholder("Movie search (M2)")},
|
||||
{'2', "Series", placeholder("Series search (M3)")},
|
||||
{'3', "Downloads", placeholder("Downloads (M4/M5)")},
|
||||
{'4', "Library", placeholder("Library (v0.2)")},
|
||||
}
|
||||
for i, v := range a.views {
|
||||
a.pages.AddPage(v.name, v.body, true, i == 0)
|
||||
}
|
||||
|
||||
layout := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||
AddItem(a.tabs, 1, 0, false).
|
||||
AddItem(a.pages, 0, 1, true).
|
||||
AddItem(a.status, 1, 0, false)
|
||||
|
||||
a.app.SetRoot(layout, true).EnableMouse(false)
|
||||
a.app.SetInputCapture(a.handleKey)
|
||||
a.SetStatus("[gray]q quit · Tab / 1-4 switch view")
|
||||
a.switchTo(0)
|
||||
return a
|
||||
}
|
||||
|
||||
// SetStatus replaces the status bar text. Safe to call only from the UI goroutine;
|
||||
// use QueueUpdateDraw from other goroutines.
|
||||
func (a *App) SetStatus(text string) {
|
||||
a.status.SetText(text)
|
||||
}
|
||||
|
||||
// Run starts the event loop and blocks until the application quits.
|
||||
func (a *App) Run() error {
|
||||
return a.app.Run()
|
||||
}
|
||||
|
||||
func (a *App) handleKey(ev *tcell.EventKey) *tcell.EventKey {
|
||||
switch ev.Key() {
|
||||
case tcell.KeyCtrlC:
|
||||
a.app.Stop()
|
||||
return nil
|
||||
case tcell.KeyTab:
|
||||
a.switchTo((a.active + 1) % len(a.views))
|
||||
return nil
|
||||
case tcell.KeyBacktab:
|
||||
a.switchTo((a.active + len(a.views) - 1) % len(a.views))
|
||||
return nil
|
||||
case tcell.KeyRune:
|
||||
if ev.Rune() == 'q' {
|
||||
a.app.Stop()
|
||||
return nil
|
||||
}
|
||||
for i, v := range a.views {
|
||||
if ev.Rune() == v.key {
|
||||
a.switchTo(i)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return ev
|
||||
}
|
||||
|
||||
func (a *App) switchTo(i int) {
|
||||
a.active = i
|
||||
a.pages.SwitchToPage(a.views[i].name)
|
||||
a.renderTabs()
|
||||
}
|
||||
|
||||
func (a *App) renderTabs() {
|
||||
var s string
|
||||
for i, v := range a.views {
|
||||
if i == a.active {
|
||||
s += fmt.Sprintf("[black:white] %c %s [-:-] ", v.key, v.name)
|
||||
} else {
|
||||
s += fmt.Sprintf("[gray] %c %s [-] ", v.key, v.name)
|
||||
}
|
||||
}
|
||||
a.tabs.SetText(s)
|
||||
}
|
||||
|
||||
func placeholder(text string) tview.Primitive {
|
||||
return tview.NewTextView().SetText(text).SetTextAlign(tview.AlignCenter)
|
||||
}
|
||||
Reference in New Issue
Block a user