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:
2026-10-20 17:38:15 +02:00
co-authored by Claude Sonnet 5.5
commit 9f05523c02
10 changed files with 693 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
/arr-tui
/dist/
config.json
*.test
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Nils Stinnesbeck
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+206
View File
@@ -0,0 +1,206 @@
# arr-tui — Specification
A terminal UI to search for movies and series, add them to Radarr / Sonarr, and watch the downloads progress in SABnzbd.
- **Language:** Go
- **TUI library:** [tview](https://github.com/rivo/tview) (on top of tcell)
- **License:** MIT
- **Module:** `git.stinnesbeck.com/nils/arr-tui`
- **Status:** M0 done
## 1. Goals
1. Search for movies (Radarr) and series (Sonarr) from one place.
2. Add a result to Radarr/Sonarr with sensible defaults (quality profile, root folder, monitored) and trigger a search so it gets downloaded.
3. Show the SABnzbd queue and history with live progress.
4. Be fast and keyboard-driven; usable over SSH.
### Non-goals (for now)
- Managing indexers, download clients, or Radarr/Sonarr settings.
- Replacing the Radarr/Sonarr web UIs for library management.
- Torrent clients (SABnzbd / Usenet only).
- Multi-user / auth beyond API keys.
## 2. How the pieces fit together
```
┌────────────┐ lookup / add / release search ┌──────────┐
│ │ ────────────────────────────────▶ │ Radarr │──┐
│ arr-tui │ └──────────┘ │ sends NZB
│ │ ────────────────────────────────▶ ┌──────────┐ │
│ │ │ Sonarr │──┤
└─────┬──────┘ └──────────┘ │
│ queue / history / pause / resume ▼
└────────────────────────────────────────▶ ┌──────────────┐
│ SABnzbd │
└──────────────┘
```
Radarr/Sonarr are expected to already have SABnzbd configured as their download client. arr-tui talks to all three APIs directly; it does **not** send NZBs to SABnzbd itself, except possibly via the manual-release flow, which still goes through Radarr/Sonarr (see 4.3).
## 3. External APIs
| Service | API | Auth | Endpoints used |
|---|---|---|---|
| Radarr | v3 REST (JSON) | `X-Api-Key` header | `GET /api/v3/movie/lookup?term=`, `GET /api/v3/movie`, `POST /api/v3/movie`, `GET /api/v3/qualityprofile`, `GET /api/v3/rootfolder`, `GET /api/v3/queue`, `GET /api/v3/release?movieId=`, `POST /api/v3/release`, `POST /api/v3/command` (`MoviesSearch`), `GET /api/v3/system/status` |
| Sonarr | v3 REST (JSON) | `X-Api-Key` header | `GET /api/v3/series/lookup?term=`, `GET /api/v3/series`, `POST /api/v3/series`, `GET /api/v3/qualityprofile`, `GET /api/v3/rootfolder`, `GET /api/v3/queue`, `GET /api/v3/release?episodeId=` / `?seriesId=&seasonNumber=`, `POST /api/v3/release`, `POST /api/v3/command` (`SeriesSearch`, `SeasonSearch`, `EpisodeSearch`), `GET /api/v3/system/status` |
| SABnzbd | `/api?mode=...&output=json` | `apikey` query param | `mode=queue`, `mode=history`, `mode=pause` / `resume`, `mode=queue&name=delete`, `mode=version` |
Notes:
- Endpoint shapes should be verified against each instance's own Swagger / the official docs when implementing; Radarr and Sonarr occasionally change fields between versions.
- Lookup results include an `id` of `0` when the item is not yet in the library; a non-zero `id` means it is already added. We use this to show an "in library" badge.
## 4. Features
### 4.1 MVP (v0.1)
- **Config:** load URLs + API keys from a config file; fail with a clear message if missing or unreachable.
- **Search:** one search box, with a toggle (movies / series). Results list with title, year, rating, status, and an "in library" marker. Detail pane with overview, runtime, genres, network/studio.
- **Add:** from a result, open a small form: quality profile, root folder, monitored (yes/no), "search now" (yes/no); for series also monitor mode (all / future / first season / none). Submit calls `POST /movie` or `POST /series` with `addOptions.searchForMovie` / `searchForMissingEpisodes`.
- **Interactive release search:** for a movie (or a series' season/episode) in the library, list releases from indexers (`GET /release`) with title, indexer, size, quality, age, and rejection reasons; sort/filter; grab a chosen one (`POST /release`). Rejected releases can be grabbed only after an explicit confirmation.
- **Downloads view:** all downloads and queues in one place, auto-refreshing every few seconds:
- SABnzbd queue (name, size, progress, ETA, speed) and history (completed / failed, with retry).
- Radarr and Sonarr queues (status, warnings, import errors), so stuck imports are visible.
- Global speed / pause state in the status bar. Pause/resume and delete a SABnzbd job.
- **Status bar / help:** key hints, connection state of each service.
### 4.2 v0.2
- **Library view:** browse existing movies/series with filters (missing, downloaded, monitored).
- **Series drill-down:** seasons and episodes, toggle monitoring per season/episode, trigger season/episode search.
### 4.3 Later / ideas
- Calendar / upcoming view.
- Notifications on completed downloads.
- Multiple instances per service (e.g. 4K Radarr).
- Poster preview via sixel / kitty graphics where supported.
## 5. UI Design (tview)
Layout sketch:
```
┌ arr-tui ───────────────────────────────────────────────────────────┐
│ [Movies] Series Downloads Library │ ← tab bar
├────────────────────────────────┬───────────────────────────────────┤
│ Search: ______________________ │ Title (Year) │
│ │ ★ 7.8 · 142 min · Sci-Fi │
│ > Movie One (2021) ✓ │ │
│ Movie Two (2019) │ Overview text wraps here... │
│ Movie Three (2024) │ │
├────────────────────────────────┴───────────────────────────────────┤
│ ↓ 12.4 MB/s · 2 jobs · Radarr ● Sonarr ● SABnzbd ● ? help │ ← status bar
└────────────────────────────────────────────────────────────────────┘
```
tview primitives: `Pages` (views), `Flex` (layout), `InputField` (search), `List`/`Table` (results, queue), `TextView` (details), `Form` (add dialog), `Modal` (confirmations).
Keybindings (initial proposal):
| Key | Action |
|---|---|
| `Tab` / `1-4` | Switch view |
| `/` | Focus search |
| `Enter` | Search / open details / confirm |
| `a` | Add selected result |
| `m` / `s` | Switch to movies / series search |
| `p` | Pause/resume SABnzbd |
| `d` | Delete selected download (with confirm) |
| `i` | Interactive release search for the selected item |
| `g` | Grab the selected release |
| `r` | Refresh |
| `?` | Help |
| `q` / `Ctrl+C` | Quit |
## 6. Architecture
- All network I/O runs in goroutines; UI updates go through `app.QueueUpdateDraw`. Never block the UI thread.
- Debounced search (e.g. 300 ms) with request cancellation via `context.Context`.
- A single ticker drives SABnzbd/queue polling, only while the Downloads view (or the status bar) needs it.
- HTTP client per service with timeouts, and a typed error that the UI can show in the status bar rather than crashing.
Proposed layout:
```
arr-tui/
├── cmd/arr-tui/main.go # entry point, wires config + UI
├── internal/
│ ├── config/ # load/validate config
│ ├── radarr/ # client + types
│ ├── sonarr/ # client + types
│ ├── sabnzbd/ # client + types
│ ├── arr/ # shared bits (HTTP helper, quality profile, root folder types)
│ └── ui/ # tview views: search, add form, downloads, status bar
├── SPECS.md
├── go.mod
└── README.md
```
Dependencies: `github.com/rivo/tview`, `github.com/gdamore/tcell/v2`. Prefer the standard library `net/http` and `encoding/json` for the API clients — no generated SDKs.
## 7. Configuration
Format: JSON. Location: `$XDG_CONFIG_HOME/arr-tui/config.json` (fallback `~/.config/arr-tui/config.json`), overridable with `--config`. Environment variables override file values (e.g. `ARR_TUI_RADARR_APIKEY`) so secrets don't have to live in the file. Parsed with the standard library `encoding/json` (unknown fields rejected to catch typos).
```json
{
"radarr": {
"url": "http://localhost:7878",
"apikey": "...",
"default_quality_profile": "HD-1080p",
"default_root_folder": "/movies"
},
"sonarr": {
"url": "http://localhost:8989",
"apikey": "...",
"default_quality_profile": "HD-1080p",
"default_root_folder": "/tv"
},
"sabnzbd": {
"url": "http://localhost:8080",
"apikey": "..."
},
"ui": {
"refresh_seconds": 3
}
}
```
The default quality profile / root folder fields are optional.
The file should be created with mode `0600`; warn if it is more permissive. API keys are never logged.
## 8. Testing
- Unit tests for each client using `httptest.Server` with recorded JSON fixtures.
- Config parsing / validation tests.
- UI logic (formatting, state) kept out of tview widgets so it can be tested without a terminal.
- Manual testing against real Radarr/Sonarr/SABnzbd instances (ideally a docker-compose file for local dev).
## 9. Milestones
- [x] **M0** — Project skeleton: `go mod init`, config loading, empty tview app with tabs and status bar.
- [ ] **M1** — Radarr client: system status, lookup, quality profiles, root folders, add movie.
- [ ] **M2** — Movie search + add flow in the UI.
- [ ] **M3** — Sonarr client and series search + add flow.
- [ ] **M4** — SABnzbd client and Downloads view (queue + history) with live updates and pause/resume/delete.
- [ ] **M5** — Radarr/Sonarr queues merged into the Downloads view.
- [ ] **M6** — Interactive release search (movies, then series seasons/episodes) and grab.
- [ ] **M7** — Polish: help screen, error handling, README, release builds.
- [ ] **M8+** — v0.2 features (library view, series drill-down).
## 10. Open Questions
Open: none.
Decided:
- Go module path: `git.stinnesbeck.com/nils/arr-tui`.
- One instance each of Radarr, Sonarr and SABnzbd (multi-instance is a later idea).
- Minimum Go version: latest stable.
- License: MIT (Nils Stinnesbeck). The tool will be published, so keep secrets out of the repo, include a `config.example.json`, and write a proper README.
- Config format: JSON.
- Interactive release search: in the MVP.
- Downloads view: shows SABnzbd and the Radarr/Sonarr queues from the start.
+41
View File
@@ -0,0 +1,41 @@
// Command arr-tui is a terminal UI for Radarr, Sonarr and SABnzbd.
package main
import (
"flag"
"fmt"
"os"
"git.stinnesbeck.com/nils/arr-tui/internal/config"
"git.stinnesbeck.com/nils/arr-tui/internal/ui"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "arr-tui:", err)
os.Exit(1)
}
}
func run() error {
path := flag.String("config", "", "path to config.json (default: $XDG_CONFIG_HOME/arr-tui/config.json)")
flag.Parse()
if *path == "" {
p, err := config.DefaultPath()
if err != nil {
return err
}
*path = p
}
_, warnings, err := config.Load(*path)
if err != nil {
return err
}
for _, w := range warnings {
fmt.Fprintln(os.Stderr, "warning:", w)
}
return ui.New().Run()
}
+21
View File
@@ -0,0 +1,21 @@
{
"radarr": {
"url": "http://localhost:7878",
"apikey": "YOUR_RADARR_API_KEY",
"default_quality_profile": "HD-1080p",
"default_root_folder": "/movies"
},
"sonarr": {
"url": "http://localhost:8989",
"apikey": "YOUR_SONARR_API_KEY",
"default_quality_profile": "HD-1080p",
"default_root_folder": "/tv"
},
"sabnzbd": {
"url": "http://localhost:8080",
"apikey": "YOUR_SABNZBD_API_KEY"
},
"ui": {
"refresh_seconds": 3
}
}
+17
View File
@@ -0,0 +1,17 @@
module git.stinnesbeck.com/nils/arr-tui
go 1.26.8
require (
github.com/gdamore/tcell/v2 v2.13.10
github.com/rivo/tview v0.42.0
)
require (
github.com/gdamore/encoding v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect
)
+47
View File
@@ -0,0 +1,47 @@
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v2 v2.13.10 h1:Afs3JKt83HnhuUKdZ3MnxUgOqQRWftj5JyDqv1LLynA=
github.com/gdamore/tcell/v2 v2.13.10/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+143
View File
@@ -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...)
}
+78
View File
@@ -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)
}
}
+115
View File
@@ -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)
}