commit 9f05523c029869b9cb73ca73c987d27f1237325a Author: Nils Stinnesbeck Date: Tue Oct 20 17:38:15 2026 +0200 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ceaba4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/arr-tui +/dist/ +config.json +*.test diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b425865 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/SPECS.md b/SPECS.md new file mode 100644 index 0000000..64c7c9f --- /dev/null +++ b/SPECS.md @@ -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. diff --git a/cmd/arr-tui/main.go b/cmd/arr-tui/main.go new file mode 100644 index 0000000..b5001e4 --- /dev/null +++ b/cmd/arr-tui/main.go @@ -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() +} diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..4bec46a --- /dev/null +++ b/config.example.json @@ -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 + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..529d6b7 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6dadf4a --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..aed7b87 --- /dev/null +++ b/internal/config/config.go @@ -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...) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..5ddff2a --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go new file mode 100644 index 0000000..9e4fbf6 --- /dev/null +++ b/internal/ui/app.go @@ -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) +}