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,4 @@
|
||||
/arr-tui
|
||||
/dist/
|
||||
config.json
|
||||
*.test
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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